Skip to main content

reddb_server/
streams.rs

1//! Durable stream primitive (issue #721, PRD #718).
2//!
3//! Tenant-scoped, append-only event log with monotonic per-consumer
4//! offsets. Streams are the third leg of ADR 0028's split — queues
5//! own per-message delivery state (ACK/NACK/DLQ), ephemeral
6//! notifications own no state at all, and streams own an immutable
7//! ordered log plus a small per-consumer offset bookkeeping table.
8//! Reading a stream never creates pending delivery state and never
9//! requires ACK or NACK; advancing the saved offset is the only
10//! "I'm done with this prefix" signal.
11//!
12//! ## Contract surface
13//!
14//! - [`StreamRegistry::create_stream`] — declare a new stream with a
15//!   retention contract. The stream becomes discoverable via
16//!   [`StreamRegistry::list_streams`].
17//! - [`StreamRegistry::append`] / [`StreamRegistry::append_authorized`]
18//!   — append an event payload with an optional stream-identity key.
19//!   Returns the assigned offset (`u64`, sequence within the stream).
20//! - [`StreamRegistry::read_since`] /
21//!   [`StreamRegistry::read_since_authorized`] — read up to `limit`
22//!   events with offset `>= from`. Read does NOT consume, lease, or
23//!   leave pending state behind, and does NOT advance the consumer's
24//!   saved offset — that is the caller's explicit responsibility via
25//!   `save_offset`.
26//! - [`StreamRegistry::save_offset`] /
27//!   [`StreamRegistry::get_offset`] — persist a consumer's progress.
28//!   Saving is **monotonic**: a smaller or equal offset is dropped
29//!   silently and the previously-saved value is returned. This makes
30//!   the operation safe to retry on duplicate or stale acks without
31//!   rewinding a consumer past events it already finished.
32//!
33//! ## Retention contract (first cut)
34//!
35//! Each stream carries a [`StreamRetention`] describing how the
36//! engine prunes old events. The first cut supports two independent
37//! caps that compose by AND (the stricter wins):
38//!
39//! * `max_events: Option<usize>` — drop the oldest events so the log
40//!   never exceeds N entries.
41//! * `max_age_ms: Option<u64>` — drop events older than `now -
42//!   max_age_ms`.
43//!
44//! Retention is applied at append time. A retention pass never
45//! rewrites the offset of surviving events — offsets remain sparse
46//! once the head moves forward. Consumers whose saved offset has
47//! fallen below the current head simply skip the truncated prefix
48//! the next time they call `read_since`; the engine does not raise
49//! an error for "consumer lagged past retention". Operators who care
50//! about that condition can compare `get_offset(consumer)` against
51//! the descriptor's `head_offset` themselves.
52//!
53//! ## Authorization model
54//!
55//! Mirrors the [`crate::notifications`] pattern: the registry never
56//! consults policies directly. Transports evaluate the `stream`
57//! action (and `stream:cross-tenant` for cross-tenant addressing)
58//! against the principal's effective policies and pass the resulting
59//! `has_cross_tenant_cap: bool` into the `_authorized` entry points.
60//! Same-scope operations succeed without the extra capability;
61//! everything else returns [`StreamError::CrossTenantDenied`] with
62//! the principal / target / stream triple preserved for audit.
63//!
64//! ## CDC compatibility
65//!
66//! [`StreamEvent`] carries `key` and `payload` as opaque UTF-8
67//! strings, plus the engine-assigned `offset` and `appended_at_ms`.
68//! That shape is intentionally the standard change-data-capture log
69//! shape: a later materialized-CDC slice can populate the same event
70//! type from a table's mutation tail, with `key` becoming the row
71//! primary key and `payload` the row JSON. Nothing in this module
72//! commits the engine to a specific CDC strategy — the contract is
73//! deliberately open on that axis.
74//!
75//! ## Durability
76//!
77//! The first slice of the primitive is an in-process append-only
78//! log. The registry is `Send + Sync` and intended to live behind
79//! an `Arc` on the runtime; persistence to disk-backed storage is a
80//! follow-up slice tracked under the same PRD (#718) — it does not
81//! change the public contract above, only where the bytes live.
82
83use std::collections::HashMap;
84use std::sync::Arc;
85
86use parking_lot::Mutex;
87
88/// Scope of a stream — tenant-isolated by default. Mirrors
89/// [`crate::notifications::NotificationScope`].
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91pub enum StreamScope {
92    /// Tenant-scoped stream — invisible to other tenants.
93    Tenant(String),
94    /// Cross-tenant / platform-global namespace.
95    Global,
96}
97
98impl StreamScope {
99    /// Construct a scope from a principal's tenant binding.
100    ///
101    /// `Some("acme")` → `Tenant("acme")`; `None` → `Global`
102    /// (platform tenant). Same mapping as
103    /// [`crate::notifications::NotificationScope::from_principal_tenant`]
104    /// so a future transport can reuse the resolver.
105    pub fn from_principal_tenant(tenant: Option<&str>) -> Self {
106        match tenant {
107            Some(t) => StreamScope::Tenant(t.to_string()),
108            None => StreamScope::Global,
109        }
110    }
111
112    /// Stable string identifier used in audit events.
113    pub fn label(&self) -> String {
114        match self {
115            StreamScope::Tenant(t) => format!("tenant:{t}"),
116            StreamScope::Global => "global".to_string(),
117        }
118    }
119}
120
121/// Retention contract for a single stream. Both fields are
122/// independently optional; pass `StreamRetention::default()` for an
123/// unbounded stream (no retention pruning).
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct StreamRetention {
126    /// Maximum number of events retained. Oldest events past the cap
127    /// are dropped on append. `None` means unbounded.
128    pub max_events: Option<usize>,
129    /// Maximum age in milliseconds. Events older than `now -
130    /// max_age_ms` are dropped on append. `None` means unbounded.
131    pub max_age_ms: Option<u64>,
132}
133
134/// A single event in the stream log. `offset` is engine-assigned
135/// and monotonically increasing within `(scope, stream)`; retention
136/// pruning may advance the head past low offsets but never reuses
137/// or rewrites them.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct StreamEvent {
140    pub scope: StreamScope,
141    pub stream: String,
142    /// Optional stream-identity key. Carries the caller's
143    /// partition / row-key hint; the engine does not interpret it
144    /// in this slice. CDC-materialization slices may use it as the
145    /// source row's primary key.
146    pub key: Option<String>,
147    /// Opaque UTF-8 payload — typically a JSON document. The engine
148    /// does not parse or validate it.
149    pub payload: String,
150    /// Engine-assigned monotonic sequence number within
151    /// `(scope, stream)`. The first event has offset `1`; offset
152    /// `0` is reserved as the "no progress yet" sentinel returned
153    /// by [`StreamRegistry::get_offset`] when a consumer has never
154    /// saved.
155    pub offset: u64,
156    pub appended_at_ms: u128,
157}
158
159/// Public-facing descriptor for stream discovery — what an
160/// introspection surface (e.g. a future `red.streams` virtual
161/// table) would emit per stream.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct StreamDescriptor {
164    pub scope: StreamScope,
165    pub name: String,
166    pub retention: StreamRetention,
167    /// Offset of the oldest event still retained, or `0` if the
168    /// stream is empty.
169    pub head_offset: u64,
170    /// Offset of the most recent event, or `0` if the stream is
171    /// empty. The next append will receive `tail_offset + 1`.
172    pub tail_offset: u64,
173    pub event_count: usize,
174}
175
176/// Errors surfaced by the stream registry.
177#[derive(Debug, PartialEq, Eq)]
178pub enum StreamError {
179    /// `create_stream` was called for a `(scope, name)` pair that
180    /// already has a stream.
181    AlreadyExists { scope: StreamScope, name: String },
182    /// An op targeted a stream that has not been created.
183    NotFound { scope: StreamScope, name: String },
184    /// The principal tried to address a stream outside their own
185    /// tenant without the `stream:cross-tenant` capability.
186    CrossTenantDenied {
187        principal_tenant: Option<String>,
188        target: StreamScope,
189        stream: String,
190    },
191}
192
193impl std::fmt::Display for StreamError {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            StreamError::AlreadyExists { scope, name } => {
197                write!(f, "stream: `{}/{}` already exists", scope.label(), name)
198            }
199            StreamError::NotFound { scope, name } => {
200                write!(f, "stream: `{}/{}` not found", scope.label(), name)
201            }
202            StreamError::CrossTenantDenied {
203                principal_tenant,
204                target,
205                stream,
206            } => {
207                let from = principal_tenant.as_deref().unwrap_or("<platform>");
208                write!(
209                    f,
210                    "stream: principal in tenant `{}` is not allowed to address `{}` stream `{}` without the `stream:cross-tenant` capability",
211                    from,
212                    target.label(),
213                    stream
214                )
215            }
216        }
217    }
218}
219
220impl std::error::Error for StreamError {}
221
222#[derive(Debug, Clone, PartialEq, Eq, Hash)]
223struct StreamKey {
224    scope: StreamScope,
225    name: String,
226}
227
228#[derive(Debug)]
229struct DurableStream {
230    retention: StreamRetention,
231    /// Append-only event log. Sorted by offset ascending.
232    /// Retention drops from the front so `events[0].offset` is the
233    /// current head.
234    events: Vec<StreamEvent>,
235    /// Next offset to assign. Starts at `1`; never decreases.
236    next_offset: u64,
237    /// Per-consumer saved offset. Always monotonic — see
238    /// [`StreamRegistry::save_offset`].
239    consumer_offsets: HashMap<String, u64>,
240}
241
242impl DurableStream {
243    fn new(retention: StreamRetention) -> Self {
244        Self {
245            retention,
246            events: Vec::new(),
247            next_offset: 1,
248            consumer_offsets: HashMap::new(),
249        }
250    }
251
252    fn descriptor(&self, scope: StreamScope, name: String) -> StreamDescriptor {
253        let head_offset = self.events.first().map(|e| e.offset).unwrap_or(0);
254        let tail_offset = self.events.last().map(|e| e.offset).unwrap_or(0);
255        StreamDescriptor {
256            scope,
257            name,
258            retention: self.retention.clone(),
259            head_offset,
260            tail_offset,
261            event_count: self.events.len(),
262        }
263    }
264
265    fn apply_retention(&mut self, now_ms: u128) {
266        // Drop expired events from the front in a single O(n) shift rather than
267        // repeated O(n) remove(0) calls. Chronological order is preserved.
268        if let Some(max_events) = self.retention.max_events {
269            let overflow = self.events.len().saturating_sub(max_events);
270            if overflow > 0 {
271                self.events.drain(0..overflow);
272            }
273        }
274        if let Some(max_age_ms) = self.retention.max_age_ms {
275            let cutoff = now_ms.saturating_sub(max_age_ms as u128);
276            // Count the contiguous leading run older than the cutoff — exactly
277            // the events the original break-on-first-fresh loop would remove.
278            let expired = self
279                .events
280                .iter()
281                .take_while(|e| e.appended_at_ms < cutoff)
282                .count();
283            if expired > 0 {
284                self.events.drain(0..expired);
285            }
286        }
287    }
288}
289
290/// In-memory registry of durable streams.
291#[derive(Default, Clone)]
292pub struct StreamRegistry {
293    inner: Arc<Mutex<HashMap<StreamKey, DurableStream>>>,
294}
295
296impl StreamRegistry {
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Declare a new stream. Returns
302    /// [`StreamError::AlreadyExists`] if `(scope, name)` is already
303    /// registered. The new stream is immediately discoverable via
304    /// [`Self::list_streams`].
305    pub fn create_stream(
306        &self,
307        scope: StreamScope,
308        name: impl Into<String>,
309        retention: StreamRetention,
310    ) -> Result<(), StreamError> {
311        let name = name.into();
312        let key = StreamKey {
313            scope: scope.clone(),
314            name: name.clone(),
315        };
316        let mut guard = self.inner.lock();
317        if guard.contains_key(&key) {
318            return Err(StreamError::AlreadyExists { scope, name });
319        }
320        guard.insert(key, DurableStream::new(retention));
321        Ok(())
322    }
323
324    /// Whether `(scope, name)` is registered.
325    pub fn exists(&self, scope: &StreamScope, name: &str) -> bool {
326        let key = StreamKey {
327            scope: scope.clone(),
328            name: name.to_string(),
329        };
330        self.inner.lock().contains_key(&key)
331    }
332
333    /// Snapshot every stream in `scope`. Used by introspection
334    /// surfaces (e.g. a future `red.streams` virtual table). Order
335    /// is unspecified; callers that need stable order should sort
336    /// on `name`.
337    pub fn list_streams(&self, scope: &StreamScope) -> Vec<StreamDescriptor> {
338        let guard = self.inner.lock();
339        guard
340            .iter()
341            .filter(|(k, _)| &k.scope == scope)
342            .map(|(k, s)| s.descriptor(k.scope.clone(), k.name.clone()))
343            .collect()
344    }
345
346    /// Describe a single stream, or `None` if not registered.
347    pub fn describe(&self, scope: &StreamScope, name: &str) -> Option<StreamDescriptor> {
348        let key = StreamKey {
349            scope: scope.clone(),
350            name: name.to_string(),
351        };
352        let guard = self.inner.lock();
353        guard.get(&key).map(|s| s.descriptor(key.scope, key.name))
354    }
355
356    /// Append an event. Returns the engine-assigned offset.
357    /// Retention pruning runs after the append, so the new event
358    /// is always retained even if it pushes the head past the cap
359    /// — only older events are dropped.
360    pub fn append(
361        &self,
362        scope: StreamScope,
363        name: impl Into<String>,
364        key: Option<String>,
365        payload: impl Into<String>,
366        now_ms: u128,
367    ) -> Result<u64, StreamError> {
368        let name = name.into();
369        let lookup_key = StreamKey {
370            scope: scope.clone(),
371            name: name.clone(),
372        };
373        let mut guard = self.inner.lock();
374        let stream = guard
375            .get_mut(&lookup_key)
376            .ok_or_else(|| StreamError::NotFound {
377                scope: scope.clone(),
378                name: name.clone(),
379            })?;
380        let offset = stream.next_offset;
381        stream.next_offset += 1;
382        stream.events.push(StreamEvent {
383            scope,
384            stream: name,
385            key,
386            payload: payload.into(),
387            offset,
388            appended_at_ms: now_ms,
389        });
390        stream.apply_retention(now_ms);
391        Ok(offset)
392    }
393
394    /// Authorization-gated [`Self::append`].
395    pub fn append_authorized(
396        &self,
397        principal_tenant: Option<&str>,
398        target: StreamScope,
399        name: impl Into<String>,
400        key: Option<String>,
401        payload: impl Into<String>,
402        has_cross_tenant_cap: bool,
403        now_ms: u128,
404    ) -> Result<u64, StreamError> {
405        let name = name.into();
406        Self::authorize(principal_tenant, &target, &name, has_cross_tenant_cap)?;
407        self.append(target, name, key, payload, now_ms)
408    }
409
410    /// Read up to `limit` events with offset `>= from`. Pure read —
411    /// does not create pending delivery state, does not advance
412    /// any consumer's saved offset, and does not require ACK/NACK.
413    /// If `from` is below the current head (because retention has
414    /// pruned older events), the returned slice simply starts at
415    /// the head with no error.
416    pub fn read_since(
417        &self,
418        scope: &StreamScope,
419        name: &str,
420        from: u64,
421        limit: usize,
422    ) -> Result<Vec<StreamEvent>, StreamError> {
423        let key = StreamKey {
424            scope: scope.clone(),
425            name: name.to_string(),
426        };
427        let guard = self.inner.lock();
428        let stream = guard.get(&key).ok_or_else(|| StreamError::NotFound {
429            scope: scope.clone(),
430            name: name.to_string(),
431        })?;
432        Ok(stream
433            .events
434            .iter()
435            .filter(|e| e.offset >= from)
436            .take(limit)
437            .cloned()
438            .collect())
439    }
440
441    /// Authorization-gated [`Self::read_since`].
442    pub fn read_since_authorized(
443        &self,
444        principal_tenant: Option<&str>,
445        target: StreamScope,
446        name: impl Into<String>,
447        from: u64,
448        limit: usize,
449        has_cross_tenant_cap: bool,
450    ) -> Result<Vec<StreamEvent>, StreamError> {
451        let name = name.into();
452        Self::authorize(principal_tenant, &target, &name, has_cross_tenant_cap)?;
453        self.read_since(&target, &name, from, limit)
454    }
455
456    /// Persist a consumer's offset on `(scope, name)`. Monotonic:
457    /// if `offset` is less than or equal to the currently saved
458    /// value, the save is a no-op and the existing value is
459    /// returned. Otherwise the new value is stored and returned.
460    /// This makes the operation safe to retry on duplicate or
461    /// stale "I'm done with offset N" notifications — a consumer
462    /// can never rewind past events it already finished.
463    pub fn save_offset(
464        &self,
465        scope: &StreamScope,
466        name: &str,
467        consumer: &str,
468        offset: u64,
469    ) -> Result<u64, StreamError> {
470        let key = StreamKey {
471            scope: scope.clone(),
472            name: name.to_string(),
473        };
474        let mut guard = self.inner.lock();
475        let stream = guard.get_mut(&key).ok_or_else(|| StreamError::NotFound {
476            scope: scope.clone(),
477            name: name.to_string(),
478        })?;
479        let entry = stream
480            .consumer_offsets
481            .entry(consumer.to_string())
482            .or_insert(0);
483        if offset > *entry {
484            *entry = offset;
485        }
486        Ok(*entry)
487    }
488
489    /// Retrieve a consumer's saved offset for `(scope, name)`.
490    /// Returns `0` for consumers that have never saved — `0` is
491    /// the reserved "no progress yet" sentinel since the first
492    /// real event is at offset `1`.
493    pub fn get_offset(
494        &self,
495        scope: &StreamScope,
496        name: &str,
497        consumer: &str,
498    ) -> Result<u64, StreamError> {
499        let key = StreamKey {
500            scope: scope.clone(),
501            name: name.to_string(),
502        };
503        let guard = self.inner.lock();
504        let stream = guard.get(&key).ok_or_else(|| StreamError::NotFound {
505            scope: scope.clone(),
506            name: name.to_string(),
507        })?;
508        Ok(stream.consumer_offsets.get(consumer).copied().unwrap_or(0))
509    }
510
511    fn authorize(
512        principal_tenant: Option<&str>,
513        target: &StreamScope,
514        stream: &str,
515        has_cross_tenant_cap: bool,
516    ) -> Result<(), StreamError> {
517        let same_scope = match (principal_tenant, target) {
518            (Some(pt), StreamScope::Tenant(tt)) => pt == tt,
519            // Platform principal (tenant=None) addressing Global is
520            // same-scope and needs no extra cap, matching the
521            // notifications precedent.
522            (None, StreamScope::Global) => true,
523            _ => false,
524        };
525        if same_scope || has_cross_tenant_cap {
526            return Ok(());
527        }
528        Err(StreamError::CrossTenantDenied {
529            principal_tenant: principal_tenant.map(str::to_string),
530            target: target.clone(),
531            stream: stream.to_string(),
532        })
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn t(name: &str) -> StreamScope {
541        StreamScope::Tenant(name.into())
542    }
543
544    #[test]
545    fn create_then_discover_via_list() {
546        let reg = StreamRegistry::new();
547        reg.create_stream(t("acme"), "orders", StreamRetention::default())
548            .unwrap();
549        let listed = reg.list_streams(&t("acme"));
550        assert_eq!(listed.len(), 1);
551        assert_eq!(listed[0].name, "orders");
552        assert_eq!(listed[0].event_count, 0);
553        assert_eq!(listed[0].head_offset, 0);
554        assert_eq!(listed[0].tail_offset, 0);
555        assert!(reg.exists(&t("acme"), "orders"));
556        assert!(reg.describe(&t("acme"), "orders").is_some());
557    }
558
559    #[test]
560    fn duplicate_create_rejected() {
561        let reg = StreamRegistry::new();
562        reg.create_stream(t("acme"), "orders", StreamRetention::default())
563            .unwrap();
564        let err = reg
565            .create_stream(t("acme"), "orders", StreamRetention::default())
566            .expect_err("dup create must fail");
567        assert!(matches!(err, StreamError::AlreadyExists { .. }));
568    }
569
570    #[test]
571    fn append_assigns_monotonic_offsets() {
572        let reg = StreamRegistry::new();
573        reg.create_stream(t("acme"), "orders", StreamRetention::default())
574            .unwrap();
575        let o1 = reg.append(t("acme"), "orders", None, "a", 100).unwrap();
576        let o2 = reg
577            .append(t("acme"), "orders", Some("k".into()), "b", 101)
578            .unwrap();
579        let o3 = reg.append(t("acme"), "orders", None, "c", 102).unwrap();
580        assert_eq!((o1, o2, o3), (1, 2, 3));
581        let desc = reg.describe(&t("acme"), "orders").unwrap();
582        assert_eq!(desc.head_offset, 1);
583        assert_eq!(desc.tail_offset, 3);
584        assert_eq!(desc.event_count, 3);
585    }
586
587    #[test]
588    fn append_on_unknown_stream_errors() {
589        let reg = StreamRegistry::new();
590        let err = reg
591            .append(t("acme"), "missing", None, "x", 0)
592            .expect_err("append on unknown stream must error");
593        assert!(matches!(err, StreamError::NotFound { .. }));
594    }
595
596    #[test]
597    fn read_since_returns_events_from_offset() {
598        let reg = StreamRegistry::new();
599        reg.create_stream(t("acme"), "orders", StreamRetention::default())
600            .unwrap();
601        for (i, payload) in ["a", "b", "c", "d"].iter().enumerate() {
602            reg.append(t("acme"), "orders", None, *payload, 100 + i as u128)
603                .unwrap();
604        }
605        let from_start = reg.read_since(&t("acme"), "orders", 0, 100).unwrap();
606        assert_eq!(from_start.len(), 4);
607        assert_eq!(from_start[0].offset, 1);
608        assert_eq!(from_start[3].payload, "d");
609
610        let from_middle = reg.read_since(&t("acme"), "orders", 3, 100).unwrap();
611        assert_eq!(from_middle.len(), 2);
612        assert_eq!(from_middle[0].offset, 3);
613        assert_eq!(from_middle[1].offset, 4);
614
615        let bounded = reg.read_since(&t("acme"), "orders", 0, 2).unwrap();
616        assert_eq!(bounded.len(), 2);
617        assert_eq!(bounded[1].offset, 2);
618    }
619
620    #[test]
621    fn read_does_not_advance_consumer_offset_no_pending_state() {
622        let reg = StreamRegistry::new();
623        reg.create_stream(t("acme"), "orders", StreamRetention::default())
624            .unwrap();
625        for i in 0..3 {
626            reg.append(t("acme"), "orders", None, "x", i).unwrap();
627        }
628        // Read everything multiple times. No ACK/NACK in the API,
629        // and get_offset stays at 0 — proves read leaves no pending
630        // delivery state behind.
631        for _ in 0..3 {
632            let events = reg.read_since(&t("acme"), "orders", 0, 100).unwrap();
633            assert_eq!(events.len(), 3);
634        }
635        assert_eq!(reg.get_offset(&t("acme"), "orders", "c1").unwrap(), 0);
636    }
637
638    #[test]
639    fn save_offset_is_monotonic() {
640        let reg = StreamRegistry::new();
641        reg.create_stream(t("acme"), "orders", StreamRetention::default())
642            .unwrap();
643        for i in 0..5 {
644            reg.append(t("acme"), "orders", None, "x", i).unwrap();
645        }
646        assert_eq!(reg.save_offset(&t("acme"), "orders", "c1", 3).unwrap(), 3);
647        // Stale save (smaller) is a no-op.
648        assert_eq!(
649            reg.save_offset(&t("acme"), "orders", "c1", 1).unwrap(),
650            3,
651            "stale save must not rewind",
652        );
653        // Equal save is a no-op (idempotent retry).
654        assert_eq!(reg.save_offset(&t("acme"), "orders", "c1", 3).unwrap(), 3,);
655        // Advance forward.
656        assert_eq!(reg.save_offset(&t("acme"), "orders", "c1", 5).unwrap(), 5,);
657        assert_eq!(reg.get_offset(&t("acme"), "orders", "c1").unwrap(), 5);
658    }
659
660    #[test]
661    fn get_offset_defaults_to_zero_for_new_consumer() {
662        let reg = StreamRegistry::new();
663        reg.create_stream(t("acme"), "orders", StreamRetention::default())
664            .unwrap();
665        assert_eq!(reg.get_offset(&t("acme"), "orders", "fresh").unwrap(), 0);
666    }
667
668    #[test]
669    fn consumer_offsets_are_isolated_per_consumer() {
670        let reg = StreamRegistry::new();
671        reg.create_stream(t("acme"), "orders", StreamRetention::default())
672            .unwrap();
673        reg.append(t("acme"), "orders", None, "x", 0).unwrap();
674        reg.save_offset(&t("acme"), "orders", "c1", 1).unwrap();
675        assert_eq!(reg.get_offset(&t("acme"), "orders", "c1").unwrap(), 1);
676        assert_eq!(reg.get_offset(&t("acme"), "orders", "c2").unwrap(), 0);
677    }
678
679    #[test]
680    fn streams_are_tenant_isolated() {
681        let reg = StreamRegistry::new();
682        reg.create_stream(t("acme"), "orders", StreamRetention::default())
683            .unwrap();
684        reg.create_stream(t("globex"), "orders", StreamRetention::default())
685            .unwrap();
686        reg.append(t("acme"), "orders", None, "acme-only", 0)
687            .unwrap();
688        let globex_events = reg.read_since(&t("globex"), "orders", 0, 100).unwrap();
689        assert!(
690            globex_events.is_empty(),
691            "globex must not see acme's events"
692        );
693        // Same name in different scopes resolves to different
694        // streams — list filters by scope.
695        assert_eq!(reg.list_streams(&t("acme")).len(), 1);
696        assert_eq!(reg.list_streams(&t("globex")).len(), 1);
697    }
698
699    #[test]
700    fn retention_max_events_drops_oldest() {
701        let reg = StreamRegistry::new();
702        reg.create_stream(
703            t("acme"),
704            "orders",
705            StreamRetention {
706                max_events: Some(3),
707                max_age_ms: None,
708            },
709        )
710        .unwrap();
711        for i in 0..5 {
712            reg.append(t("acme"), "orders", None, "x", 100 + i as u128)
713                .unwrap();
714        }
715        let desc = reg.describe(&t("acme"), "orders").unwrap();
716        // Newest 3 retained: offsets 3, 4, 5. Head moved to 3.
717        assert_eq!(desc.event_count, 3);
718        assert_eq!(desc.head_offset, 3);
719        assert_eq!(desc.tail_offset, 5);
720        let events = reg.read_since(&t("acme"), "orders", 0, 100).unwrap();
721        assert_eq!(
722            events.iter().map(|e| e.offset).collect::<Vec<_>>(),
723            vec![3, 4, 5],
724        );
725    }
726
727    #[test]
728    fn retention_max_age_drops_old_events() {
729        let reg = StreamRegistry::new();
730        reg.create_stream(
731            t("acme"),
732            "orders",
733            StreamRetention {
734                max_events: None,
735                max_age_ms: Some(1_000),
736            },
737        )
738        .unwrap();
739        reg.append(t("acme"), "orders", None, "old", 0).unwrap();
740        reg.append(t("acme"), "orders", None, "old2", 500).unwrap();
741        // This append's `now_ms` triggers a retention pass — events
742        // with appended_at_ms < (10_000 - 1_000) = 9_000 are dropped.
743        reg.append(t("acme"), "orders", None, "fresh", 10_000)
744            .unwrap();
745        let events = reg.read_since(&t("acme"), "orders", 0, 100).unwrap();
746        assert_eq!(events.len(), 1);
747        assert_eq!(events[0].payload, "fresh");
748        assert_eq!(events[0].offset, 3, "retention must not rewrite offsets");
749    }
750
751    #[test]
752    fn consumer_lagged_past_retention_does_not_error() {
753        let reg = StreamRegistry::new();
754        reg.create_stream(
755            t("acme"),
756            "orders",
757            StreamRetention {
758                max_events: Some(2),
759                max_age_ms: None,
760            },
761        )
762        .unwrap();
763        for i in 0..5 {
764            reg.append(t("acme"), "orders", None, "x", i).unwrap();
765        }
766        // Consumer had saved offset 1, but retention has advanced
767        // the head to 4. read_since must just return the current
768        // window rather than erroring — operators detect lag by
769        // comparing get_offset against descriptor.head_offset.
770        let events = reg.read_since(&t("acme"), "orders", 2, 100).unwrap();
771        assert_eq!(
772            events.iter().map(|e| e.offset).collect::<Vec<_>>(),
773            vec![4, 5],
774        );
775    }
776
777    #[test]
778    fn same_tenant_append_does_not_require_cross_tenant_cap() {
779        let reg = StreamRegistry::new();
780        reg.create_stream(t("acme"), "orders", StreamRetention::default())
781            .unwrap();
782        let offset = reg
783            .append_authorized(Some("acme"), t("acme"), "orders", None, "x", false, 0)
784            .expect("same-tenant append must succeed without cross-tenant cap");
785        assert_eq!(offset, 1);
786
787        reg.read_since_authorized(Some("acme"), t("acme"), "orders", 0, 100, false)
788            .expect("same-tenant read must succeed without cross-tenant cap");
789    }
790
791    #[test]
792    fn cross_tenant_append_denied_without_cap() {
793        let reg = StreamRegistry::new();
794        reg.create_stream(t("globex"), "orders", StreamRetention::default())
795            .unwrap();
796        let err = reg
797            .append_authorized(Some("acme"), t("globex"), "orders", None, "leak", false, 0)
798            .expect_err("cross-tenant append must be denied without cap");
799        match err {
800            StreamError::CrossTenantDenied {
801                principal_tenant,
802                target,
803                stream,
804            } => {
805                assert_eq!(principal_tenant.as_deref(), Some("acme"));
806                assert_eq!(target, t("globex"));
807                assert_eq!(stream, "orders");
808            }
809            other => panic!("unexpected error: {other:?}"),
810        }
811    }
812
813    #[test]
814    fn cross_tenant_read_denied_without_cap() {
815        let reg = StreamRegistry::new();
816        reg.create_stream(t("globex"), "orders", StreamRetention::default())
817            .unwrap();
818        let err = reg
819            .read_since_authorized(Some("acme"), t("globex"), "orders", 0, 100, false)
820            .expect_err("cross-tenant read must be denied without cap");
821        assert!(matches!(err, StreamError::CrossTenantDenied { .. }));
822    }
823
824    #[test]
825    fn cross_tenant_append_allowed_with_cap() {
826        let reg = StreamRegistry::new();
827        reg.create_stream(t("globex"), "orders", StreamRetention::default())
828            .unwrap();
829        let offset = reg
830            .append_authorized(
831                Some("acme"),
832                t("globex"),
833                "orders",
834                None,
835                "allowed",
836                true,
837                0,
838            )
839            .expect("append with cross-tenant cap must succeed");
840        assert_eq!(offset, 1);
841    }
842
843    #[test]
844    fn global_scope_requires_cross_tenant_cap_for_tenant_principal() {
845        let reg = StreamRegistry::new();
846        reg.create_stream(StreamScope::Global, "platform", StreamRetention::default())
847            .unwrap();
848        let err = reg
849            .append_authorized(
850                Some("acme"),
851                StreamScope::Global,
852                "platform",
853                None,
854                "leak",
855                false,
856                0,
857            )
858            .expect_err("tenant principal targeting Global must require cap");
859        assert!(matches!(err, StreamError::CrossTenantDenied { .. }));
860
861        // Platform principal (tenant=None) targeting Global is
862        // same-scope and needs no extra cap.
863        let offset = reg
864            .append_authorized(None, StreamScope::Global, "platform", None, "ok", false, 0)
865            .expect("platform principal targeting global is same-scope");
866        assert_eq!(offset, 1);
867    }
868
869    #[test]
870    fn from_principal_tenant_maps_correctly() {
871        assert_eq!(
872            StreamScope::from_principal_tenant(Some("acme")),
873            StreamScope::Tenant("acme".into())
874        );
875        assert_eq!(
876            StreamScope::from_principal_tenant(None),
877            StreamScope::Global
878        );
879    }
880
881    #[test]
882    fn event_carries_optional_key_for_future_cdc() {
883        let reg = StreamRegistry::new();
884        reg.create_stream(t("acme"), "rows", StreamRetention::default())
885            .unwrap();
886        reg.append(t("acme"), "rows", Some("user:42".into()), "{}", 0)
887            .unwrap();
888        let events = reg.read_since(&t("acme"), "rows", 0, 100).unwrap();
889        assert_eq!(events[0].key.as_deref(), Some("user:42"));
890    }
891}