Skip to main content

salamander/
db.rs

1//! DESIGN.md §7 — the public API surface: open, append, commit, projection
2//! access, and `view_at`.
3//!
4//! `Salamander<B>` is the payload-generic engine (P1). It frames and
5//! persists bodies of any [`Body`] type without interpreting them; the
6//! agent-specific `session_view` / `fork` operations live in
7//! [`crate::agent`] as an `impl Salamander<agent::EventBody>` block.
8
9use std::collections::HashMap;
10use std::io::Write;
11use std::ops::{Bound, Range};
12use std::path::{Path, PathBuf};
13use std::time::{Instant, SystemTime, UNIX_EPOCH};
14
15use crate::branch::{BranchCatalog, BranchInfo, BranchName, BranchStatus};
16use crate::commit::CommitPolicy;
17use crate::event::{Body, Event};
18use crate::format::{
19    derive_stream_id, generate_id_bytes, BatchId, BranchId, CodecId, EventId, EventType, Metadata,
20    OwnedStoredRecord, RecordEnvelopeV2, StreamId, StreamRevision,
21};
22use crate::log::reader::{FrameFilter, ResolvedFilter};
23use crate::log::{Log, LogReader, RecordReader, ReplayEnd, ReplayPlan, StreamSelector};
24use crate::projection::{decode_stored_event, replay_into, NamespaceScoped, Projection};
25use crate::stream::{event_fingerprint, StreamCatalog};
26use crate::view::{catch_up, View};
27use crate::{
28    AppendReceipt, AppendRequest, Durability, ExpectedRevision, ReceiptDurability, Result,
29    SalamanderError, StreamName,
30};
31
32/// The embedded event-sourcing engine, generic over its payload type `B`.
33///
34/// Frames, orders, and persists events of any [`Body`] type without
35/// interpreting them; derived state is produced by [`Projection`]s and
36/// live [`View`](crate::View)s folded from the log. See
37/// [`AgentDb`](crate::AgentDb) and [`JsonDb`](crate::JsonDb) for ready-made
38/// payload vocabularies.
39pub struct Salamander<B> {
40    pub(crate) log: Log,
41    /// Live views owned by the DB, driven type-erased and kept at head by
42    /// the fan-out in `append` (query-layer design §4). Registered by name;
43    /// `B` is used here, so no `PhantomData` marker is needed.
44    views: HashMap<String, Box<dyn View<B>>>,
45    /// When `append` should auto-commit on the caller's behalf (WP-4). The
46    /// counters below track what has accumulated since the last commit.
47    policy: CommitPolicy,
48    pending_bytes: u64,
49    pending_count: u64,
50    last_commit: Instant,
51    catalog: StreamCatalog,
52    branches: BranchCatalog,
53    durable_head: u64,
54    root: PathBuf,
55}
56
57fn load_core_catalog(root: &Path, database_id: [u8; 16], head: u64) -> Option<StreamCatalog> {
58    let bytes = std::fs::read(root.join("core-catalog.bin")).ok()?;
59    if bytes.len() > 256 * 1024 * 1024 {
60        return None;
61    }
62    let checkpoint: CoreCatalogCheckpoint = bincode::deserialize(&bytes).ok()?;
63    if checkpoint.database_id != database_id || checkpoint.head != head {
64        return None;
65    }
66    let payload =
67        bincode::serialize(&(checkpoint.database_id, checkpoint.head, &checkpoint.catalog)).ok()?;
68    (crc32c::crc32c(&payload) == checkpoint.checksum).then_some(checkpoint.catalog)
69}
70
71fn persist_core_catalog(
72    root: &Path,
73    database_id: [u8; 16],
74    head: u64,
75    catalog: &StreamCatalog,
76) -> Result<()> {
77    let payload = bincode::serialize(&(database_id, head, catalog))
78        .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
79    let checkpoint = CoreCatalogCheckpoint {
80        database_id,
81        head,
82        catalog: catalog.clone(),
83        checksum: crc32c::crc32c(&payload),
84    };
85    let bytes = bincode::serialize(&checkpoint)
86        .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
87    let temporary = root.join("core-catalog.tmp");
88    let final_path = root.join("core-catalog.bin");
89    {
90        let mut file = std::fs::File::create(&temporary)?;
91        file.write_all(&bytes)?;
92        file.sync_all()?;
93    }
94    std::fs::rename(temporary, final_path)?;
95    Ok(())
96}
97
98#[derive(serde::Serialize, serde::Deserialize)]
99struct CoreCatalogCheckpoint {
100    database_id: [u8; 16],
101    head: u64,
102    catalog: StreamCatalog,
103    checksum: u32,
104}
105
106impl<B: Body> Salamander<B> {
107    /// Opens instantly in Phase 2; Phase 1 replays fully and measures it
108    /// (DESIGN.md §7). Phase 1 doesn't cache any projection state on
109    /// `Salamander` itself — every accessor below rebuilds from the log on
110    /// demand ("no snapshot cleverness," IMPLEMENTATION.md Step 5), so
111    /// there's nothing to warm up here beyond recovering the log itself.
112    /// Registered views (query layer) start empty and are caught up on
113    /// `register`. Opens with the default `Manual` commit policy — the
114    /// caller drives durability; see [`open_with_policy`](Self::open_with_policy).
115    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
116        Self::open_with_policy(dir, CommitPolicy::default())
117    }
118
119    /// Like [`open`](Self::open), but with a group-commit policy active from
120    /// the start (WP-4). The policy can also be changed later with
121    /// [`set_commit_policy`](Self::set_commit_policy).
122    pub fn open_with_policy(dir: impl AsRef<Path>, policy: CommitPolicy) -> Result<Self> {
123        let root = dir.as_ref().to_path_buf();
124        let log = Log::open(&root)?;
125        let catalog = match load_core_catalog(&root, log.database_id().into_bytes(), log.head()) {
126            Some(catalog) => catalog,
127            None => {
128                let catalog = StreamCatalog::rebuild(log.records_from(0))?;
129                // Best effort: cache failure cannot make an otherwise valid
130                // log unavailable, and the next commit retries publication.
131                let _ = persist_core_catalog(
132                    &root,
133                    log.database_id().into_bytes(),
134                    log.head(),
135                    &catalog,
136                );
137                catalog
138            }
139        };
140        let branches = BranchCatalog::rebuild(log.system_records())?;
141        let durable_head = log.head();
142        Ok(Salamander {
143            log,
144            views: HashMap::new(),
145            policy,
146            pending_bytes: 0,
147            pending_count: 0,
148            last_commit: Instant::now(),
149            catalog,
150            branches,
151            durable_head,
152            root,
153        })
154    }
155
156    /// Replace the group-commit policy. Takes effect on the next append; the
157    /// uncommitted counters carry over unchanged (a smaller threshold may
158    /// therefore fire on the very next append).
159    pub fn set_commit_policy(&mut self, policy: CommitPolicy) {
160        self.policy = policy;
161    }
162
163    /// The active group-commit policy.
164    pub fn commit_policy(&self) -> CommitPolicy {
165        self.policy
166    }
167
168    /// Appends a single `body` to `namespace` on the default branch and
169    /// returns its position. A convenience wrapper over
170    /// [`append_batch`](Self::append_batch) with buffered durability.
171    pub fn append(&mut self, namespace: &str, body: B) -> Result<u64> {
172        let request = AppendRequest {
173            branch: BranchId::ZERO,
174            stream: crate::StreamName::new(namespace)?,
175            expected: ExpectedRevision::Any,
176            idempotency_key: None,
177            events: vec![crate::NewEvent::new(
178                EventType::new(std::any::type_name::<B>())?,
179                body,
180            )],
181            durability: Durability::Buffered,
182        };
183        Ok(self.append_batch(request)?.first_position)
184    }
185
186    /// Like [`append`](Self::append), but targets a specific branch.
187    pub fn append_on_branch(&mut self, branch: BranchId, namespace: &str, body: B) -> Result<u64> {
188        let request = AppendRequest {
189            branch,
190            stream: crate::StreamName::new(namespace)?,
191            expected: ExpectedRevision::Any,
192            idempotency_key: None,
193            events: vec![crate::NewEvent::new(
194                EventType::new(std::any::type_name::<B>())?,
195                body,
196            )],
197            durability: Durability::Buffered,
198        };
199        Ok(self.append_batch(request)?.first_position)
200    }
201
202    #[allow(dead_code)]
203    fn append_wp01_compat(&mut self, namespace: &str, body: B) -> Result<u64> {
204        let timestamp_ms = current_timestamp_ms();
205        let mut event = Event {
206            offset: 0,
207            timestamp_ms,
208            namespace: namespace.to_string(),
209            body,
210        };
211        let bytes = bincode::serialize(&event.body)
212            .map_err(|e| SalamanderError::Serialization(e.to_string()))?;
213        let database_id = self.log.database_id();
214        let branch_id = BranchId::ZERO;
215        let id_bytes = generate_id_bytes();
216        let mut metadata = Metadata::new();
217        metadata.insert(
218            "salamander.stream_name".to_string(),
219            namespace.as_bytes().to_vec(),
220        );
221        let envelope = RecordEnvelopeV2 {
222            event_id: EventId::from_bytes(id_bytes),
223            database_id,
224            branch_id,
225            stream_id: derive_stream_id(database_id, branch_id, namespace),
226            // WP-02 replaces this placeholder with catalog-owned per-stream
227            // revisions. It remains deterministic and monotonic for v2 data
228            // written during WP-01.
229            stream_revision: StreamRevision(self.log.head()),
230            timestamp_unix_nanos: (timestamp_ms as i64).saturating_mul(1_000_000),
231            event_type: EventType::new(std::any::type_name::<B>())?,
232            schema_version: 1,
233            codec: CodecId::RUST_BINCODE_V1,
234            batch_id: BatchId::from_bytes(id_bytes),
235            batch_index: 0,
236            metadata,
237        };
238        let (offset, last) = self.log.append_batch(&[(envelope, bytes.clone())])?;
239        debug_assert_eq!(offset, last);
240
241        // Fan out to every registered view synchronously, with the real
242        // log-assigned offset stamped in — so a view is always at head
243        // before `append` returns (INV-2). Runs before `commit`, so views
244        // track *visible* state, mirroring the log's visible/durable split
245        // (query-layer design §4.4). Empty when no view is registered, so
246        // the common path pays nothing.
247        event.offset = offset;
248        for view in self.views.values_mut() {
249            view.apply(&event);
250        }
251
252        // Group commit (WP-4): tally what's now uncommitted and let the
253        // policy decide whether to fsync. `commit` resets the counters.
254        self.pending_bytes += bytes.len() as u64;
255        self.pending_count += 1;
256        if self.policy.should_commit(
257            self.pending_bytes,
258            self.pending_count,
259            self.last_commit.elapsed(),
260        ) {
261            self.commit()?;
262        }
263
264        self.catalog = StreamCatalog::rebuild(self.log.records_from(0))?;
265        if self.durable_head == self.log.head() {
266            persist_core_catalog(
267                &self.root,
268                self.log.database_id().into_bytes(),
269                self.durable_head,
270                &self.catalog,
271            )?;
272        }
273        Ok(offset)
274    }
275
276    /// Appends a batch of events atomically, validating the
277    /// optimistic-concurrency expectation and idempotency key in the
278    /// writer-critical section, and returns the [`AppendReceipt`].
279    pub fn append_batch(&mut self, request: AppendRequest<B>) -> Result<AppendReceipt> {
280        self.append_batch_with_id(request, None)
281    }
282
283    pub(crate) fn append_batch_with_id(
284        &mut self,
285        request: AppendRequest<B>,
286        supplied_batch_id: Option<BatchId>,
287    ) -> Result<AppendReceipt> {
288        request.validate()?;
289        let branch = self
290            .branches
291            .get(request.branch)
292            .ok_or_else(|| SalamanderError::BranchNotFound(format!("{:?}", request.branch)))?;
293        if branch.status == BranchStatus::Archived {
294            return Err(SalamanderError::BranchArchived(
295                branch.name.as_str().to_string(),
296            ));
297        }
298        let previous = self.catalog.revision(request.branch, &request.stream);
299        let serialized: Vec<Vec<u8>> = request
300            .events
301            .iter()
302            .map(|event| {
303                bincode::serialize(&event.body)
304                    .map_err(|error| SalamanderError::Serialization(error.to_string()))
305            })
306            .collect::<Result<_>>()?;
307        let request_digest = request_fingerprint(&request, &serialized);
308        if let Some(key) = &request.idempotency_key {
309            if let Some((digest, mut receipt)) = self.catalog.idempotent(request.branch, key) {
310                if digest != request_digest {
311                    return Err(SalamanderError::IdempotencyConflict);
312                }
313                if request.durability == Durability::Sync
314                    && receipt.durability != ReceiptDurability::Synced
315                {
316                    self.commit()?;
317                    receipt.durability = ReceiptDurability::Synced;
318                }
319                return Ok(receipt);
320            }
321        }
322        validate_expected(request.expected, previous)?;
323
324        let database_id = self.log.database_id();
325        let stream_id = self
326            .catalog
327            .stream_id(request.branch, &request.stream)
328            .unwrap_or_else(|| {
329                derive_stream_id(database_id, request.branch, request.stream.as_str())
330            });
331        let batch_id =
332            supplied_batch_id.unwrap_or_else(|| BatchId::from_bytes(generate_id_bytes()));
333        let first_revision = previous.map_or(0, |revision| revision.0 + 1);
334        let timestamp_ms = current_timestamp_ms();
335        let mut stored = Vec::with_capacity(request.events.len());
336        let mut digests = Vec::with_capacity(request.events.len());
337
338        for (index, (event, body)) in request.events.iter().zip(&serialized).enumerate() {
339            let event_id = event
340                .event_id
341                .unwrap_or_else(|| EventId::from_bytes(generate_id_bytes()));
342            let mut metadata = event.metadata.clone();
343            metadata.insert(
344                "salamander.stream_name".into(),
345                request.stream.as_str().as_bytes().to_vec(),
346            );
347            if let Some(key) = &request.idempotency_key {
348                metadata.insert("salamander.idempotency_key".into(), key.as_bytes().to_vec());
349                metadata.insert(
350                    "salamander.request_digest".into(),
351                    request_digest.to_le_bytes().to_vec(),
352                );
353            }
354            let envelope = RecordEnvelopeV2 {
355                event_id,
356                database_id,
357                branch_id: request.branch,
358                stream_id,
359                stream_revision: StreamRevision(first_revision + index as u64),
360                timestamp_unix_nanos: (timestamp_ms as i64).saturating_mul(1_000_000),
361                event_type: event.event_type.clone(),
362                schema_version: event.schema_version,
363                codec: CodecId::RUST_BINCODE_V1,
364                batch_id,
365                batch_index: index as u32,
366                metadata,
367            };
368            let record = crate::format::OwnedStoredRecord {
369                kind: crate::format::FrameKind::Event,
370                flags: 0,
371                position: self.log.head() + index as u64,
372                envelope: envelope.clone(),
373                payload: body.clone(),
374            };
375            let digest = event_fingerprint(&record);
376            if self
377                .catalog
378                .event_digest(event_id)
379                .is_some_and(|old| old != digest)
380                || digests
381                    .iter()
382                    .any(|(id, old)| *id == event_id && *old != digest)
383            {
384                return Err(SalamanderError::EventIdConflict);
385            }
386            digests.push((event_id, digest));
387            stored.push((envelope, body.clone()));
388        }
389
390        let supplied_ids: Vec<_> = request.events.iter().map(|event| event.event_id).collect();
391        if supplied_ids
392            .iter()
393            .flatten()
394            .any(|id| self.catalog.event_receipt(*id).is_some())
395        {
396            let mut original: Option<AppendReceipt> = None;
397            for (supplied, (_, digest)) in supplied_ids.iter().zip(&digests) {
398                let Some(id) = supplied else {
399                    return Err(SalamanderError::EventIdConflict);
400                };
401                let Some((stored_digest, receipt)) = self.catalog.event_receipt(*id) else {
402                    return Err(SalamanderError::EventIdConflict);
403                };
404                if stored_digest != *digest
405                    || original
406                        .as_ref()
407                        .is_some_and(|existing| existing.batch_id != receipt.batch_id)
408                {
409                    return Err(SalamanderError::EventIdConflict);
410                }
411                original = Some(receipt);
412            }
413            let mut original = original.ok_or(SalamanderError::EventIdConflict)?;
414            if supplied_batch_id.is_some_and(|id| id != original.batch_id) {
415                return Err(SalamanderError::BatchIdConflict);
416            }
417            if request.durability == Durability::Sync
418                && original.durability != ReceiptDurability::Synced
419            {
420                self.commit()?;
421                original.durability = ReceiptDurability::Synced;
422            }
423            return Ok(original);
424        }
425
426        if self.catalog.batch_receipt(batch_id).is_some() {
427            return Err(SalamanderError::BatchIdConflict);
428        }
429
430        let (first_position, last_position) = self.log.append_batch(&stored)?;
431        for (index, event) in request.events.iter().enumerate() {
432            let runtime = Event {
433                offset: first_position + index as u64,
434                timestamp_ms,
435                namespace: request.stream.as_str().to_string(),
436                body: event.body.clone(),
437            };
438            for view in self.views.values_mut() {
439                view.apply(&runtime);
440            }
441        }
442
443        self.pending_bytes += serialized.iter().map(Vec::len).sum::<usize>() as u64;
444        self.pending_count += request.events.len() as u64;
445        let sync = request.durability == Durability::Sync
446            || self.policy.should_commit(
447                self.pending_bytes,
448                self.pending_count,
449                self.last_commit.elapsed(),
450            );
451        if sync {
452            self.commit()?;
453        }
454        let receipt = AppendReceipt {
455            batch_id,
456            first_position,
457            last_position,
458            stream_id,
459            previous_revision: previous,
460            current_revision: StreamRevision(first_revision + request.events.len() as u64 - 1),
461            durability: if sync {
462                ReceiptDurability::Synced
463            } else if request.durability == Durability::Flush {
464                ReceiptDurability::Flushed
465            } else {
466                ReceiptDurability::Buffered
467            },
468        };
469        self.catalog.record_batch(
470            request.branch,
471            &request.stream,
472            stream_id,
473            digests,
474            request
475                .idempotency_key
476                .as_ref()
477                .map(|key| (key, request_digest)),
478            receipt.clone(),
479        );
480        if sync {
481            persist_core_catalog(
482                &self.root,
483                self.log.database_id().into_bytes(),
484                self.durable_head,
485                &self.catalog,
486            )?;
487        }
488        Ok(receipt)
489    }
490
491    /// Metadata for the branch with `id`, or `None` if it does not exist.
492    pub fn branch(&self, id: BranchId) -> Option<&BranchInfo> {
493        self.branches.get(id)
494    }
495
496    /// Metadata for the branch with the given name, or `None`.
497    pub fn branch_named(&self, name: &str) -> Option<&BranchInfo> {
498        self.branches.named(name)
499    }
500
501    /// The branch's ancestry, root first, ending with `id`.
502    pub fn branch_ancestry(&self, id: BranchId) -> Result<Vec<BranchInfo>> {
503        self.branches.ancestry(id)
504    }
505
506    /// The direct child branches of `id`.
507    pub fn branch_children(&self, id: BranchId) -> Vec<BranchInfo> {
508        self.branches.children(id)
509    }
510
511    /// The nearest common ancestor of two branches.
512    pub fn branch_common_ancestor(&self, left: BranchId, right: BranchId) -> Result<BranchInfo> {
513        self.branches.common_ancestor(left, right)
514    }
515
516    /// The divergence of two timelines as an engine operation — a
517    /// position plus three replay plans, computed from the branch catalog
518    /// alone (`docs/specs/first-class-diff.md`). No record is read or
519    /// compared: two timelines are identical below the divergence position
520    /// by construction, because inherited replay is positional (DIFF-1).
521    /// Feed the returned plans to [`read`](Self::read) to enumerate the
522    /// shared prefix or either divergent suffix; computing the diff itself
523    /// performs no log I/O and writes nothing (DIFF-5).
524    pub fn diff(&self, request: DiffRequest) -> Result<TimelineDiff> {
525        request.streams.validate()?;
526        let head = self.log.head();
527        let resolve = |end: ReplayEnd| match end {
528            ReplayEnd::Head => Ok(head),
529            ReplayEnd::At(position) if position > head => {
530                Err(SalamanderError::OffsetBeyondHead(position))
531            }
532            ReplayEnd::At(position) => Ok(position),
533        };
534        let left_until = resolve(request.left_until)?;
535        let right_until = resolve(request.right_until)?;
536        let branch = |id: BranchId| {
537            self.branches
538                .get(id)
539                .cloned()
540                .ok_or_else(|| SalamanderError::BranchNotFound(format!("{id:?}")))
541        };
542        let left = branch(request.left)?;
543        let right = branch(request.right)?;
544        let (common_ancestor, divergence) =
545            self.branches
546                .divergence(request.left, left_until, request.right, right_until)?;
547        let plan = |branch: BranchId, from: u64, until: u64| ReplayPlan {
548            branch,
549            streams: request.streams.clone(),
550            from: Bound::Included(from),
551            until: ReplayEnd::At(until),
552            ..ReplayPlan::default()
553        };
554        Ok(TimelineDiff {
555            shared: plan(common_ancestor.id, 0, divergence),
556            common_ancestor,
557            divergence,
558            left: DiffSide {
559                suffix: plan(left.id, divergence, left_until),
560                branch: left,
561                until: left_until,
562            },
563            right: DiffSide {
564                suffix: plan(right.id, divergence, right_until),
565                branch: right,
566                until: right_until,
567            },
568        })
569    }
570
571    /// Build a bounded-memory streaming reader for `plan` (WP-04). The
572    /// plan's branch is resolved to its flattened ancestry scopes, so
573    /// inherited parent history is visible through the fork point; every
574    /// other selection (streams, position window, time, max events) is
575    /// applied by the reader from envelope data alone.
576    pub fn read(&self, plan: ReplayPlan) -> Result<LogReader<'_>> {
577        plan.streams.validate()?;
578        let head = self.log.head();
579        let until = match plan.until {
580            ReplayEnd::Head => head,
581            ReplayEnd::At(position) => {
582                if position > head {
583                    return Err(SalamanderError::OffsetBeyondHead(position));
584                }
585                position
586            }
587        };
588        let from = match plan.from {
589            Bound::Unbounded => 0,
590            Bound::Included(position) => position,
591            Bound::Excluded(position) => position.saturating_add(1),
592        };
593        let scopes = self.branches.replay_scopes(plan.branch, until)?;
594        Ok(self.log.plan_reader(ResolvedFilter {
595            from,
596            until,
597            selector: plan.streams,
598            scopes: Some(scopes),
599            time: plan.time,
600            kinds: FrameFilter::UserEvents,
601            max_events: plan.max_events,
602            verification: plan.verification,
603        }))
604    }
605
606    /// Replays the events of `namespace` visible on `branch` within
607    /// `range`, in order, invoking `f` on each — inherited parent history
608    /// is included through the fork point.
609    pub fn replay_branch(
610        &self,
611        branch: BranchId,
612        namespace: &str,
613        range: Range<u64>,
614        mut f: impl FnMut(&Event<B>),
615    ) -> Result<()> {
616        let mut reader = self.read(ReplayPlan {
617            branch,
618            from: Bound::Included(range.start),
619            until: ReplayEnd::At(range.end),
620            ..ReplayPlan::default()
621        })?;
622        while let Some(record) = reader.next()? {
623            let record = OwnedStoredRecord::from(record);
624            let event = decode_stored_event::<B>(&record)?;
625            if event.namespace == namespace {
626                f(&event);
627            }
628        }
629        Ok(())
630    }
631
632    /// Creates a branch forked from `parent` at position `at`, which must
633    /// be a committed batch boundary visible in the parent. The child
634    /// inherits parent history up to `at` and then diverges; the parent is
635    /// unaffected.
636    pub fn fork_branch(
637        &mut self,
638        parent: BranchId,
639        at: u64,
640        name: BranchName,
641        metadata: Metadata,
642    ) -> Result<BranchInfo> {
643        if self.branches.get(parent).is_none() {
644            return Err(SalamanderError::BranchNotFound(format!("{parent:?}")));
645        }
646        if at > self.head() {
647            return Err(SalamanderError::OffsetBeyondHead(at));
648        }
649        if !self.is_batch_boundary(at)? {
650            return Err(SalamanderError::NotBatchBoundary(at));
651        }
652        let id = BranchId::from_bytes(generate_id_bytes());
653        let info = BranchInfo {
654            id,
655            name,
656            parent: Some(parent),
657            fork_position: Some(at),
658            created_at_unix_nanos: (current_timestamp_ms() as i64).saturating_mul(1_000_000),
659            metadata,
660            status: BranchStatus::Active,
661        };
662        // Validate on a candidate catalog before writing; publish the new
663        // in-memory graph only after the system frame is accepted.
664        let mut updated_branches = self.branches.clone();
665        updated_branches.insert(info.clone())?;
666        let payload = serde_json::to_vec(&info)
667            .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
668        let event_bytes = generate_id_bytes();
669        let envelope = RecordEnvelopeV2 {
670            event_id: EventId::from_bytes(event_bytes),
671            database_id: self.log.database_id(),
672            branch_id: id,
673            stream_id: crate::StreamId::ZERO,
674            stream_revision: StreamRevision(0),
675            timestamp_unix_nanos: info.created_at_unix_nanos,
676            event_type: EventType::new("salamander.branch.created")?,
677            schema_version: 1,
678            codec: CodecId::JSON_UTF8,
679            batch_id: BatchId::from_bytes(event_bytes),
680            batch_index: 0,
681            metadata: Metadata::new(),
682        };
683        self.log.append_system(&envelope, &payload)?;
684        if let Err(error) = self.commit() {
685            self.branches = BranchCatalog::rebuild(self.log.system_records())?;
686            return Err(error);
687        }
688        self.branches = updated_branches;
689        Ok(info)
690    }
691
692    /// Archives a branch: it keeps its readable history but rejects new
693    /// writes. The default branch cannot be archived.
694    pub fn archive_branch(&mut self, id: BranchId) -> Result<BranchInfo> {
695        let mut info = self
696            .branches
697            .get(id)
698            .cloned()
699            .ok_or_else(|| SalamanderError::BranchNotFound(format!("{id:?}")))?;
700        if id == BranchId::ZERO {
701            return Err(SalamanderError::InvalidArgument(
702                "the default branch cannot be archived".into(),
703            ));
704        }
705        if info.status == BranchStatus::Archived {
706            return Ok(info);
707        }
708        info.status = BranchStatus::Archived;
709        let mut updated_branches = self.branches.clone();
710        updated_branches.archive(info.clone())?;
711        let payload = serde_json::to_vec(&info)
712            .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
713        let event_bytes = generate_id_bytes();
714        let envelope = RecordEnvelopeV2 {
715            event_id: EventId::from_bytes(event_bytes),
716            database_id: self.log.database_id(),
717            branch_id: id,
718            stream_id: crate::StreamId::ZERO,
719            stream_revision: StreamRevision(0),
720            timestamp_unix_nanos: (current_timestamp_ms() as i64).saturating_mul(1_000_000),
721            event_type: EventType::new("salamander.branch.archived")?,
722            schema_version: 1,
723            codec: CodecId::JSON_UTF8,
724            batch_id: BatchId::from_bytes(event_bytes),
725            batch_index: 0,
726            metadata: Metadata::new(),
727        };
728        self.log.append_system(&envelope, &payload)?;
729        if let Err(error) = self.commit() {
730            self.branches = BranchCatalog::rebuild(self.log.system_records())?;
731            return Err(error);
732        }
733        self.branches = updated_branches;
734        Ok(info)
735    }
736
737    fn is_batch_boundary(&self, at: u64) -> Result<bool> {
738        if at == 0 || at == self.head() {
739            return Ok(true);
740        }
741        // Stream just the two records either side of the boundary; the
742        // reader stops after them instead of materializing the log tail.
743        let mut before = None;
744        let mut after = None;
745        for item in self.log.records_from(at - 1) {
746            let record = item?;
747            if record.position == at - 1 {
748                before = Some(record.envelope.batch_id);
749            } else if record.position >= at {
750                after = (record.position == at).then_some(record.envelope.batch_id);
751                break;
752            }
753        }
754        Ok(matches!((before, after), (Some(left), Some(right)) if left != right))
755    }
756
757    /// fsync the log and return the durable head (DESIGN.md §3.3). Always
758    /// available regardless of the commit policy; resets the group-commit
759    /// counters so the next auto-commit measures from here.
760    pub fn commit(&mut self) -> Result<u64> {
761        let head = self.log.commit()?;
762        self.durable_head = head;
763        self.pending_bytes = 0;
764        self.pending_count = 0;
765        self.last_commit = Instant::now();
766        persist_core_catalog(
767            &self.root,
768            self.log.database_id().into_bytes(),
769            head,
770            &self.catalog,
771        )?;
772        Ok(head)
773    }
774
775    /// Payload bytes appended but not yet committed (fsynced). Reset to 0 by
776    /// `commit()` and by any auto-commit the policy triggers.
777    pub fn uncommitted_bytes(&self) -> u64 {
778        self.pending_bytes
779    }
780
781    /// Events appended but not yet committed (fsynced).
782    pub fn uncommitted_count(&self) -> u64 {
783        self.pending_count
784    }
785
786    /// Full rebuild: a fresh `P`, replayed to `head()`. The projection's
787    /// `Body` must match this engine's payload type `B` — you can't fold a
788    /// log of one payload type with a projection written for another.
789    pub fn projection<P: Projection<Body = B> + Default>(&self) -> Result<P> {
790        let mut p = P::default();
791        replay_into(&mut p, &self.log, self.log.head())?;
792        Ok(p)
793    }
794
795    /// Full rebuild of a namespace-scoped projection, replayed to
796    /// `head()`. This is the plain, non-stitched view: for the agent
797    /// `SessionProjection` specifically, prefer [`crate::agent`]'s
798    /// `session_view` if `namespace` might be a fork (see its doc comment
799    /// for why).
800    pub fn projection_for<P: NamespaceScoped<Body = B>>(&self, namespace: &str) -> Result<P> {
801        let mut p = P::new_for(namespace);
802        replay_into(&mut p, &self.log, self.log.head())?;
803        Ok(p)
804    }
805
806    /// Read-only projection as of offset `n` (DESIGN.md §5, time-travel).
807    /// For views that can't be `Default`-constructed (e.g. `IndexedView`,
808    /// which owns closures), use [`replay_to`](Self::replay_to) instead.
809    pub fn view_at<P: Projection<Body = B> + Default>(&self, n: u64) -> Result<P> {
810        if n > self.log.head() {
811            return Err(SalamanderError::OffsetBeyondHead(n));
812        }
813        let mut p = P::default();
814        replay_into(&mut p, &self.log, n)?;
815        Ok(p)
816    }
817
818    // ── query layer: live registered views (query-layer design §4) ───────
819
820    /// Register a live view under `name`, catching it up from its cursor to
821    /// head before it starts receiving fan-out (so it's immediately at
822    /// head, INV-2). Re-registering a name replaces the previous view.
823    pub fn register(&mut self, name: &str, mut view: Box<dyn View<B>>) -> Result<()> {
824        catch_up(view.as_mut(), &self.log, self.log.head())?;
825        self.views.insert(name.to_string(), view);
826        Ok(())
827    }
828
829    /// Remove and return a registered view (query-layer design OQ-Q2 — a
830    /// long-lived host must be able to reclaim view memory). `None` if no
831    /// view is registered under `name`.
832    pub fn deregister(&mut self, name: &str) -> Option<Box<dyn View<B>>> {
833        self.views.remove(name)
834    }
835
836    /// Typed, read-only access to a registered view: downcast the erased
837    /// `dyn View<B>` back to the concrete `T` the query methods live on.
838    /// `None` if `name` isn't registered or the type doesn't match.
839    ///
840    /// The borrow checker enforces the one correctness rule for free: this
841    /// takes `&self`, `append` takes `&mut self`, so a query reference can
842    /// never be held across an append — you can't query a half-updated view.
843    pub fn view<T: View<B>>(&self, name: &str) -> Option<&T> {
844        self.views.get(name)?.as_any().downcast_ref::<T>()
845    }
846
847    /// Time-travel for a caller-constructed view: hand in a fresh, empty
848    /// view/projection and get it back replayed to offset `n`. This is the
849    /// historical counterpart to `register` (which replays to head) and the
850    /// path for non-`Default` views like `IndexedView` (query-layer design
851    /// §4.3 — "one view type, two modes").
852    pub fn replay_to<P: Projection<Body = B>>(&self, mut view: P, n: u64) -> Result<P> {
853        if n > self.log.head() {
854            return Err(SalamanderError::OffsetBeyondHead(n));
855        }
856        replay_into(&mut view, &self.log, n)?;
857        Ok(view)
858    }
859
860    /// Next offset to be assigned.
861    pub fn head(&self) -> u64 {
862        self.log.head()
863    }
864
865    /// Exclusive upper position proven durable by the latest successful sync.
866    pub fn durable_head(&self) -> u64 {
867        self.durable_head
868    }
869
870    pub(crate) fn stream_id(&self, branch: BranchId, stream: &StreamName) -> Option<StreamId> {
871        self.catalog.stream_id(branch, stream)
872    }
873
874    /// Raw event iteration over `namespace` within `range` (DESIGN.md §7).
875    pub fn replay(
876        &self,
877        namespace: &str,
878        range: Range<u64>,
879        f: impl FnMut(&Event<B>),
880    ) -> Result<()> {
881        crate::introspect::replay(&self.log, namespace, range, f)
882    }
883}
884
885/// What to diff: two timelines, each a branch bounded by an exclusive
886/// until, plus a stream selector scoped onto the emitted plans. See
887/// [`Salamander::diff`].
888#[derive(Debug, Clone, PartialEq, Eq)]
889pub struct DiffRequest {
890    /// The left timeline's branch.
891    pub left: BranchId,
892    /// The right timeline's branch.
893    pub right: BranchId,
894    /// Exclusive upper bound of the left timeline (default: head).
895    pub left_until: ReplayEnd,
896    /// Exclusive upper bound of the right timeline (default: head).
897    pub right_until: ReplayEnd,
898    /// Which streams the emitted replay plans select. The divergence
899    /// position itself is positional and stream-independent.
900    pub streams: StreamSelector,
901}
902
903impl DiffRequest {
904    /// A whole-timeline diff of two branches at head, all streams.
905    pub fn new(left: BranchId, right: BranchId) -> Self {
906        Self {
907            left,
908            right,
909            left_until: ReplayEnd::Head,
910            right_until: ReplayEnd::Head,
911            streams: StreamSelector::All,
912        }
913    }
914}
915
916/// One side of a [`TimelineDiff`]: the branch, its resolved until, and the
917/// replay plan for its divergent suffix `[divergence, until)`.
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct DiffSide {
920    /// The branch this side describes.
921    pub branch: BranchInfo,
922    /// The resolved exclusive upper bound of this timeline.
923    pub until: u64,
924    /// Replay plan for this timeline's records past the divergence.
925    pub suffix: ReplayPlan,
926}
927
928/// The result of [`Salamander::diff`]: where two timelines share history
929/// and what each says after that — a position plus three replay plans.
930/// Both timelines replay identically below [`divergence`](Self::divergence)
931/// by construction; no record comparison is involved (DIFF-1, DIFF-6).
932#[derive(Debug, Clone, PartialEq, Eq)]
933pub struct TimelineDiff {
934    /// The deepest branch node the two ancestries share.
935    pub common_ancestor: BranchInfo,
936    /// Exclusive upper bound of the shared history.
937    pub divergence: u64,
938    /// Replay plan for the shared prefix `[0, divergence)`, on the common
939    /// ancestor's timeline — resolving it against either side yields the
940    /// same records.
941    pub shared: ReplayPlan,
942    /// The left timeline's branch, until, and suffix plan.
943    pub left: DiffSide,
944    /// The right timeline's branch, until, and suffix plan.
945    pub right: DiffSide,
946}
947
948fn current_timestamp_ms() -> u64 {
949    SystemTime::now()
950        .duration_since(UNIX_EPOCH)
951        .map(|d| d.as_millis() as u64)
952        .unwrap_or(0)
953}
954
955fn validate_expected(expected: ExpectedRevision, actual: Option<StreamRevision>) -> Result<()> {
956    let matches = match expected {
957        ExpectedRevision::Any => true,
958        ExpectedRevision::NoStream => actual.is_none(),
959        ExpectedRevision::Exact(expected) => actual == Some(expected),
960    };
961    if matches {
962        return Ok(());
963    }
964    Err(SalamanderError::RevisionConflict {
965        expected: format!("{expected:?}"),
966        actual: format!("{actual:?}"),
967    })
968}
969
970fn request_fingerprint<B>(request: &AppendRequest<B>, bodies: &[Vec<u8>]) -> u32 {
971    let mut bytes = Vec::new();
972    bytes.extend_from_slice(request.branch.as_bytes());
973    bytes.extend_from_slice(request.stream.as_str().as_bytes());
974    bytes.extend_from_slice(format!("{:?}", request.expected).as_bytes());
975    for (event, body) in request.events.iter().zip(bodies) {
976        bytes.extend_from_slice(event.event_id.unwrap_or(EventId::ZERO).as_bytes());
977        bytes.extend_from_slice(event.event_type.as_str().as_bytes());
978        bytes.extend_from_slice(&event.schema_version.to_le_bytes());
979        for (key, value) in &event.metadata {
980            bytes.extend_from_slice(key.as_bytes());
981            bytes.extend_from_slice(value);
982        }
983        bytes.extend_from_slice(body);
984    }
985    crc32c::crc32c(&bytes)
986}