Rindle docs and package mapSkip to main content

rindle_replica/
consumer.rs

1//! The **cluster consumer**: the engine-side composition a normalized-protocol server
2//! front builds on (CLUSTER-FOLD-IN-DESIGN.md WS6). Lifted from the `@rindle/replica`
3//! napi addon's `cluster_core` so the Node daemon and the Rust daemon (`rindle-server`'s
4//! network front) drive the SAME stack:
5//!
6//! - the coordinator ([`Cluster`]) lives on ONE thread (`!Send`): `register_table` /
7//!   `query_normalized` / `commit*` / `destroy_query` all land here and return promptly;
8//! - per-query normalized batches, per-connection progress frames, and faults are produced
9//!   **asynchronously** by the drain thread and pushed into the caller's [`DrainSink`].
10//!
11//! The apply half — registration, `apply_muts`, the cursor discipline, replicated DDL —
12//! is the engine-free [`ApplyConsumer`] (design 309), shared over the SAME store as the
13//! cluster: every apply method here **delegates**, so the live follower and a headless
14//! replay (`rindle-backup-sqlite`) run one implementation. This type adds what only a
15//! derivation host has: the drain/query surface, the lmid mutation plane, and the room
16//! ledger.
17//!
18//! The table helpers ([`ColType`], [`create_table_ddl`], [`build_mutation_sql`]) are shared
19//! with the single-thread napi `Replica` so both schemas/SQL are identical.
20//!
21//! [`ColType`]: crate::ColType
22//! [`create_table_ddl`]: crate::create_table_ddl
23//! [`build_mutation_sql`]: crate::build_mutation_sql
24
25use std::path::Path;
26
27use rindle::value::OwnedValue;
28use rindle::Ast;
29use rindle_wire::family_key::{Binding, FamilyTemplate};
30use rusqlite::OptionalExtension;
31
32use crate::apply::{upsert_source_offset_hashed, ApplyConsumer};
33use crate::cluster::{Cluster, ClusterWriteTxn, DdlMigrationError};
34use crate::drain::{ConnId, Drain, DrainHandle, DrainSink};
35use crate::normalize::{reject_unsupported_sync_aggregate, table_tree};
36use crate::normalize_protocol::{
37    build_query_parts, hello_from_parts, NormalizedHello, TableWireSchema,
38};
39use crate::table_shape::{BaseTableSchema, ColType, Mutation, TableMeta};
40use crate::writeplane::{
41    self, BookkeepingError, MigrationRecord, PublicOperationCommit, SqlOutcomeNamespace,
42    StoredPublicOutcome,
43};
44use crate::ReplicaError;
45use crate::{
46    cas_cell_matches, RoomRowConflict, CLIENT_MUTATIONS_TABLE, OUTCOME_RETENTION_LMIDS,
47    ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_PLACEMENT_TABLE,
48    ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE, SOURCE_OFFSET_WHOLE_RUN,
49};
50use crate::{QueryId, SqlStatementRequest, StatementResult, StatementRunError, TableNode};
51
52pub use crate::apply::{SourceHead, APPLIED_DDL_TABLE};
53
54/// The query-wide pieces a server needs to frame each subscriber's `hello` for a shared
55/// query (see [`ClusterConsumer::register_shared_query`]): the deterministic table set and
56/// its `normalized_fp`. The footprint fold itself lives on the drain thread.
57#[derive(Clone, Debug)]
58pub struct SharedQueryInfo {
59    pub tables: Vec<TableWireSchema>,
60    pub normalized_fp: u64,
61}
62
63/// The outcome of a CAS-guarded room flush ([`ClusterConsumer::commit_room_flush`]).
64pub enum RoomFlushOutcome {
65    /// Effects + cursor + identity committed in one transaction.
66    Applied { cv: u64 },
67    /// At least one precondition missed: NOTHING applied (rolled back); each miss
68    /// carries the authoritative current image for the room's §5.4 convergence.
69    Conflicts(Vec<RoomRowConflict>),
70}
71
72/// The cluster-backed consumer (coordinator side). `!Send` — lives on one thread; the
73/// drain pushes batches/progress/faults into the sink from its own thread.
74pub struct ClusterConsumer {
75    cluster: Cluster,
76    /// Kept alive for the process; its `Drop` stops the drain loop.
77    _drain: Drain,
78    handle: DrainHandle,
79    /// The engine-free apply surface (design 309), sharing the cluster's store + pool
80    /// fan-out — owner of the [`TableMeta`] registry and every `apply_*`/`commit_*`
81    /// implementation this type delegates to.
82    apply: ApplyConsumer,
83}
84
85impl ClusterConsumer {
86    /// Open a cluster-backed consumer over `path` with `n_workers` IVM worker threads,
87    /// wiring drain output into `sink`. The caller owns the file's lifecycle. A fresh
88    /// file gets plain `wal` (design 306 D5); the daemon's roles pass their wal2 opt-in
89    /// via [`open_with_journal`](Self::open_with_journal).
90    pub fn open(
91        path: &Path,
92        n_workers: usize,
93        sink: impl DrainSink + 'static,
94    ) -> Result<ClusterConsumer, ReplicaError> {
95        Self::open_with_journal(path, n_workers, crate::JournalMode::default(), sink)
96    }
97
98    /// [`open`](Self::open) with an explicit fresh-file [`crate::JournalMode`] — the
99    /// daemon passes [`crate::JournalMode::Wal2`] (checkpoint headroom + the
100    /// follower/backup fleet contract; an existing wal/wal2 file keeps its mode).
101    pub fn open_with_journal(
102        path: &Path,
103        n_workers: usize,
104        journal: crate::JournalMode,
105        sink: impl DrainSink + 'static,
106    ) -> Result<ClusterConsumer, ReplicaError> {
107        Self::open_with(
108            path,
109            n_workers,
110            crate::OpenOptions {
111                journal,
112                ..crate::OpenOptions::default()
113            },
114            sink,
115        )
116    }
117
118    /// The full-combination opener: every [`crate::OpenOptions`] field is honored —
119    /// the daemon's escape hatch for combining wal2 with a non-default derivation
120    /// mode or operator storage (e.g. the design-306 soak fallback) without a new
121    /// rung per combination.
122    pub fn open_with(
123        path: &Path,
124        n_workers: usize,
125        opts: crate::OpenOptions,
126        sink: impl DrainSink + 'static,
127    ) -> Result<ClusterConsumer, ReplicaError> {
128        let (cluster, events) = Cluster::open_with(path, n_workers, opts)?;
129        let apply = ApplyConsumer::from_parts(cluster.store_rc(), cluster.fanout_rc());
130        let drain = Drain::spawn(events, cluster.committed_tx_id().0, sink);
131        let handle = drain.handle();
132        Ok(ClusterConsumer {
133            cluster,
134            _drain: drain,
135            handle,
136            apply,
137        })
138    }
139
140    /// The underlying coordinator (controlled writes, lmid reads, raw event taps).
141    pub fn cluster(&self) -> &Cluster {
142        &self.cluster
143    }
144
145    /// Walk every declared foreign key and report violating rows — the opt-in audit a
146    /// follower runs instead of enforcing per row. Delegates to
147    /// [`Cluster::foreign_key_audit`](crate::Cluster::foreign_key_audit); see there for
148    /// the cost, the cap, and why enforcement and verification are separable.
149    pub fn foreign_key_audit(
150        &self,
151        max_rows: usize,
152    ) -> Result<crate::ForeignKeyAudit, ReplicaError> {
153        self.cluster.foreign_key_audit(max_rows)
154    }
155
156    /// Define + register a base table (`CREATE TABLE` + cluster source). Idempotent.
157    pub fn register_table(
158        &self,
159        table: &str,
160        columns: &[String],
161        pk: &[usize],
162        col_types: &[ColType],
163    ) -> Result<(), ReplicaError> {
164        self.apply
165            .register_table_via(table, columns, pk, col_types, || {
166                self.cluster.register_table(table)
167            })
168    }
169
170    /// Register an EXISTING base table (already created by the host's own DDL): reads its
171    /// column order + pk from SQLite and registers it for capture + derivation.
172    pub fn register_existing_table(&self, table: &str) -> Result<(), ReplicaError> {
173        self.apply
174            .register_existing_table_via(table, || self.cluster.register_table(table))
175    }
176
177    /// One-time setup for client mutations: the `_rindle_client_mutations` table is created,
178    /// captured, AND engine-hosted (lmid-as-data) — its meta joins the table map so the
179    /// per-client system query's `hello` resolves like any table's.
180    pub fn enable_client_mutations(&self) -> Result<(), ReplicaError> {
181        self.apply
182            .enable_client_mutations_via(|| self.cluster.enable_client_mutations())
183    }
184
185    /// One-time setup for the §4 realtime lifecycle ([`Cluster::enable_realtime_lifecycle`] —
186    /// see [`Db::enable_realtime_lifecycle`](crate::Db::enable_realtime_lifecycle) for the
187    /// table roster and why each is registered): the four lifecycle tables join the table
188    /// map so doorbell / fence / outcome / ledger subscriptions resolve their `hello` like
189    /// any table's — [`ROOM_CLIENT_MUTATIONS_TABLE`] had NO meta before this (Slice C kept
190    /// it direct-SQL-only). All four are `_rindle_*`-prefixed and so excluded from
191    /// [`base_table_schemas`](Self::base_table_schemas). Requires
192    /// [`enable_client_mutations`](Self::enable_client_mutations) first. Enabling also
193    /// switches on [`commit_room_flush`](Self::commit_room_flush)'s §4.2 watermark
194    /// co-commit.
195    pub fn enable_realtime_lifecycle(&self) -> Result<(), ReplicaError> {
196        self.cluster.enable_realtime_lifecycle()?;
197        let mut tables = self.apply.tables();
198        tables.insert(
199            SCOPE_SESSIONS_TABLE.to_string(),
200            TableMeta {
201                columns: vec![
202                    "scope".to_string(),
203                    "client_id".to_string(),
204                    "expires_at".to_string(),
205                ],
206                pk: vec![0, 1],
207                col_types: vec![ColType::String, ColType::String, ColType::Number],
208                nullable: vec![false, false, false],
209            },
210        );
211        tables.insert(
212            ROOM_WATERMARK_TABLE.to_string(),
213            TableMeta {
214                columns: vec!["doc".to_string(), "flush_seq".to_string()],
215                pk: vec![0],
216                col_types: vec![ColType::String, ColType::Number],
217                nullable: vec![false, false],
218            },
219        );
220        tables.insert(
221            ROOM_MUTATION_OUTCOMES_TABLE.to_string(),
222            TableMeta {
223                columns: vec![
224                    "doc".to_string(),
225                    "client_id".to_string(),
226                    "mid".to_string(),
227                    "kind".to_string(),
228                    "reason".to_string(),
229                    "name".to_string(),
230                    "args".to_string(),
231                ],
232                pk: vec![0, 1, 2],
233                col_types: vec![
234                    ColType::String,
235                    ColType::String,
236                    ColType::Number,
237                    ColType::String,
238                    ColType::String,
239                    ColType::String,
240                    ColType::String,
241                ],
242                // `reason`/`name`/`args` mirror the frame's optional fields
243                // (`mutationOutcome {mid, kind, reason?, name?, args?}`); the rest are row
244                // identity + the verdict.
245                nullable: vec![false, false, false, false, true, true, true],
246            },
247        );
248        tables.insert(
249            ROOM_CLIENT_MUTATIONS_TABLE.to_string(),
250            TableMeta {
251                columns: vec![
252                    "doc".to_string(),
253                    "client_id".to_string(),
254                    "last_mutation_id".to_string(),
255                ],
256                pk: vec![0, 1],
257                col_types: vec![ColType::String, ColType::String, ColType::Number],
258                nullable: vec![false, false, false],
259            },
260        );
261        Ok(())
262    }
263
264    /// The registered **base** tables' schemas (name + ordered columns/types + PK names), sorted by
265    /// name, for client-schema codegen via `/schema` (DRIZZLE-MIGRATIONS-DESIGN.md §6.2). Excludes
266    /// the daemon's own bookkeeping/internal tables — `_rindle_*` (e.g. `_rindle_client_mutations`),
267    /// `__*` (e.g. the `__replica_meta` commit watermark), and `sqlite_*` — which must stay invisible
268    /// to the client schema just as they are to CDC + planning (rindle-cdc skips `sqlite_*` + `__*`).
269    /// Reads straight from the in-memory table map — the live introspected schema, no DB round-trip.
270    pub fn base_table_schemas(&self) -> Vec<BaseTableSchema> {
271        self.apply.base_table_schemas()
272    }
273
274    /// Re-read the visibility sidecars after a `ddl` entry (design 406 §10); `true` when the
275    /// advertised schema changed and a bounce is due.
276    pub fn refresh_visibility(&self) -> Result<bool, ReplicaError> {
277        self.apply.refresh_visibility()
278    }
279
280    /// The durably stored high-water mutation id for `client_id` (0 if new).
281    pub fn client_lmid(&self, client_id: &str) -> Result<u64, ReplicaError> {
282        self.cluster.client_lmid(client_id)
283    }
284
285    /// The durable last sequence a foreign `producer` wrote (0 if new). This deliberately
286    /// runs the shared connection-only helper on the observed writer while no mutation is open,
287    /// keeping the read serialized with every write-plane decision.
288    pub fn producer_seq(&self, producer: &str) -> Result<u64, BookkeepingError> {
289        writeplane::read_producer_seq(self.cluster.writer_connection(), producer)
290    }
291
292    /// Read the distinct-producer count into `census` off the observed writer — the one scan the
293    /// incrementally-maintained gauge takes, at open (design 306 §4). Same narrow-access rule as
294    /// [`producer_seq`](Self::producer_seq): the shared helper runs here rather than exposing the
295    /// writer connection across crates.
296    pub fn seed_producer_census(
297        &self,
298        census: &writeplane::ProducerCensus,
299    ) -> Result<(), BookkeepingError> {
300        census.seed(self.cluster.writer_connection())
301    }
302
303    /// Read one durable deploy/public migration identity while the standalone engine owns the
304    /// idle writer. Kept narrow instead of exposing the raw writer connection across crates.
305    pub fn migration_record(&self, id: &str) -> Result<Option<MigrationRecord>, BookkeepingError> {
306        writeplane::migration_record(self.cluster.writer_connection(), id)
307    }
308
309    /// Read the captured data-migration marker for `id` on the serialized writer.
310    pub fn data_migration_checksum(&self, id: &str) -> Result<Option<String>, BookkeepingError> {
311        writeplane::data_migration_checksum(self.cluster.writer_connection(), id)
312    }
313
314    /// Persist the standalone producer's DDL journal row after the marker transaction. A crash
315    /// between the two is healed by replay: the durable marker skips DDL and this insert retries.
316    pub fn journal_local_migration(
317        &self,
318        id: &str,
319        checksum: Option<&str>,
320        content_checksum: &str,
321        run_id: &str,
322        statements: &str,
323        applied_at: i64,
324    ) -> Result<(), BookkeepingError> {
325        self.local_bookkeeping_transaction(|conn| {
326            writeplane::journal_migration(
327                conn,
328                id,
329                checksum,
330                content_checksum,
331                run_id,
332                statements,
333                applied_at,
334            )
335        })
336    }
337
338    /// Fill legacy migration identity columns without minting a captured data commit.
339    pub fn adopt_migration_checksums(
340        &self,
341        id: &str,
342        checksum: Option<&str>,
343        content_checksum: &str,
344    ) -> Result<(), BookkeepingError> {
345        self.local_bookkeeping_transaction(|conn| {
346            writeplane::adopt_migration_checksums(conn, id, checksum, content_checksum)
347        })
348    }
349
350    /// Read one exact public-SQL outcome while the serialized standalone writer is idle.
351    pub fn stored_public_outcome(
352        &self,
353        outcome_key: &str,
354    ) -> Result<Option<StoredPublicOutcome>, BookkeepingError> {
355        writeplane::read_sql_outcome(self.cluster.writer_connection(), outcome_key)
356    }
357
358    /// The durable one-shot outcome retention floor at `now_ms`.
359    pub fn public_operation_floor_ms(&self, now_ms: i64) -> Result<i64, BookkeepingError> {
360        writeplane::sql_outcome_floor_ms(
361            self.cluster.writer_connection(),
362            SqlOutcomeNamespace::Operation,
363            now_ms,
364        )
365    }
366
367    /// Resolve a retried public transaction commit from standalone's co-transactional outcome.
368    pub fn public_commit_outcome(
369        &self,
370        transaction_id: &str,
371        now_ms: i64,
372    ) -> Result<Option<Option<String>>, crate::session::SessionError> {
373        writeplane::public_commit_outcome_on_conn(
374            self.cluster.writer_connection(),
375            transaction_id,
376            now_ms,
377            |_, _, _| Ok(None),
378        )
379    }
380
381    /// Sweep the bounded outcome cache in one host-local metadata transaction.
382    pub fn sweep_sql_outcomes(&self, now_ms: i64) -> Result<(), BookkeepingError> {
383        self.local_bookkeeping_transaction(|conn| writeplane::sweep_sql_outcomes(conn, now_ms))
384    }
385
386    /// Fresh standalone public DDL: schema + desired-index effects + exact replay outcome + TxId
387    /// watermark commit as one SQLite atom.
388    #[allow(clippy::too_many_arguments)]
389    pub fn apply_public_ddl_operation<F>(
390        &self,
391        statement: &SqlStatementRequest,
392        declared_tables: &[String],
393        outcome_key: &str,
394        request_identity: &str,
395        result_byte_limit: usize,
396        now_ms: i64,
397        apply_step_effects: F,
398    ) -> Result<(PublicOperationCommit, crate::DdlApplyReport), DdlMigrationError>
399    where
400        F: FnMut(&rusqlite::Connection, &crate::DdlStep) -> rusqlite::Result<()>,
401    {
402        self.apply.apply_public_ddl_operation(
403            statement,
404            declared_tables,
405            outcome_key,
406            request_identity,
407            result_byte_limit,
408            now_ms,
409            apply_step_effects,
410        )
411    }
412
413    /// Fresh standalone DDL migration, with its permanent identity row and exact TxId cursor
414    /// committed in the checked DDL transaction rather than backfilled afterward. Both front
415    /// doors — the public `/v1/sql/migrate` route and the private deploy route — apply through
416    /// this one primitive so they mint identical journal rows and either can absorb the other's
417    /// replay; only the opaque checksum is optional (the deploy surface accepts checksum-less
418    /// DDL files, whose identity is the statement vector alone).
419    #[allow(clippy::too_many_arguments)]
420    pub fn apply_public_ddl_migration<F>(
421        &self,
422        id: &str,
423        supplied_checksum: Option<&str>,
424        content_checksum: &str,
425        normalized: &[String],
426        identity_json: &str,
427        declared_tables: &[String],
428        now_ms: i64,
429        apply_step_effects: F,
430    ) -> Result<(String, crate::DdlApplyReport), DdlMigrationError>
431    where
432        F: FnMut(&rusqlite::Connection, &crate::DdlStep) -> rusqlite::Result<()>,
433    {
434        self.apply.apply_public_ddl_migration(
435            id,
436            supplied_checksum,
437            content_checksum,
438            normalized,
439            identity_json,
440            declared_tables,
441            now_ms,
442            apply_step_effects,
443        )
444    }
445
446    fn local_bookkeeping_transaction(
447        &self,
448        apply: impl FnOnce(&rusqlite::Connection) -> Result<(), BookkeepingError>,
449    ) -> Result<(), BookkeepingError> {
450        let conn = self.cluster.writer_connection();
451        conn.execute_batch("BEGIN")?;
452        let outcome = apply(conn).and_then(|()| {
453            conn.execute_batch("COMMIT")?;
454            Ok(())
455        });
456        if outcome.is_err() {
457            let _ = conn.execute_batch("ROLLBACK");
458        }
459        outcome
460    }
461
462    /// Register a connection (drain-side progress bookkeeping).
463    pub fn connect(&self, conn: ConnId) {
464        self.handle.connect(conn);
465    }
466
467    /// Drop a connection's drain-side progress bookkeeping. The caller destroys the
468    /// connection's queries separately (via [`destroy_query`](Self::destroy_query)).
469    pub fn disconnect(&self, conn: ConnId) {
470        self.handle.disconnect(conn);
471    }
472
473    /// Register a NORMALIZED live query for `conn` under `server_qid`. Returns the slim
474    /// `hello` **synchronously** (schema-derived); the seq-0 snapshot and every later batch
475    /// arrive **asynchronously** through the sink.
476    ///
477    /// The 1:1 convenience over the shared-query primitives: it registers an engine query
478    /// **and** attaches a single subscriber whose sub id is the same `server_qid`. A server
479    /// that dedups identical ASTs across subscribers uses [`register_shared_query`] +
480    /// [`attach_subscriber`] directly instead.
481    ///
482    /// [`register_shared_query`]: Self::register_shared_query
483    /// [`attach_subscriber`]: Self::attach_subscriber
484    pub fn query_normalized(
485        &self,
486        conn: ConnId,
487        server_qid: u64,
488        ast: Ast,
489        epoch: u64,
490    ) -> Result<NormalizedHello, ReplicaError> {
491        let info = self.register_shared_query(server_qid, ast)?;
492        let hello = hello_from_parts(epoch, info.tables, info.normalized_fp);
493        self.attach_subscriber(server_qid, server_qid, conn, epoch);
494        Ok(hello)
495    }
496
497    /// Register a **shared** engine query under `eqid` (one IVM pipeline + one footprint
498    /// fold), with NO subscriber yet. Identical ASTs registered under one `eqid` are
499    /// computed once; subscribers attach via [`attach_subscriber`](Self::attach_subscriber),
500    /// each with its own epoch / seq cursor / connection route (`RINDLE-SERVER-DESIGN.md`
501    /// §6/§11). Returns the deterministic table set + fingerprint the caller turns into each
502    /// subscriber's `hello`.
503    pub fn register_shared_query(
504        &self,
505        eqid: u64,
506        ast: Ast,
507    ) -> Result<SharedQueryInfo, ReplicaError> {
508        // The precomputed-sync path is count-only; a relationship sum/avg has no synthetic
509        // table encoding, so reject it here before any of the normalize machinery runs.
510        reject_unsupported_sync_aggregate(&ast).map_err(ReplicaError::Build)?;
511        let schemas = self.normalized_table_schemas(&ast)?;
512        let (fold, tables, normalized_fp, proj) = build_query_parts(&ast, schemas);
513        let hydrated_cv = self.cluster.committed_tx_id().0;
514        let worker = self.cluster.query(QueryId(eqid), ast)?;
515        self.handle.register_query(
516            QueryId(eqid),
517            worker,
518            fold,
519            normalized_fp,
520            proj,
521            hydrated_cv,
522        );
523        Ok(SharedQueryInfo {
524            tables,
525            normalized_fp,
526        })
527    }
528
529    /// Register a **parameterized query family** under `eqid` (design 310 §5.1/§5.2): one
530    /// IVM pipeline over the template, plus one footprint fold **per binding**, each built
531    /// from the concrete member AST (`FamilyTemplate::instantiate`, impl plan D8) so every
532    /// frame a partition emits equals the standalone query's byte for byte. Returns each
533    /// binding's [`SharedQueryInfo`] (its tables + fingerprint — the same for every member,
534    /// modulo the literal, but computed per member so nothing is assumed).
535    pub fn register_shared_family(
536        &self,
537        eqid: u64,
538        template: &FamilyTemplate,
539        bindings: &[Binding],
540    ) -> Result<Vec<(Binding, SharedQueryInfo)>, ReplicaError> {
541        let param_cols = self.family_param_cols(template)?;
542        let mut parts = Vec::with_capacity(bindings.len());
543        for b in bindings {
544            parts.push((b.clone(), self.partition_parts(template, b)?));
545        }
546        let hydrated_cv = self.cluster.committed_tx_id().0;
547        let worker = self.cluster.family(
548            QueryId(eqid),
549            template.stripped.clone(),
550            template.params.clone(),
551            bindings.to_vec(),
552        )?;
553        self.handle
554            .register_family(QueryId(eqid), worker, param_cols, hydrated_cv);
555        let mut infos = Vec::with_capacity(parts.len());
556        for (b, (fold, tables, normalized_fp, proj)) in parts {
557            self.handle.bind_partition(
558                QueryId(eqid),
559                b.clone(),
560                fold,
561                normalized_fp,
562                proj,
563                hydrated_cv,
564            );
565            infos.push((
566                b,
567                SharedQueryInfo {
568                    tables,
569                    normalized_fp,
570                },
571            ));
572        }
573        Ok(infos)
574    }
575
576    /// Bind one more partition of the family registered under `eqid` (design 310 §4.4):
577    /// the engine hydrates only that partition; the drain gets its own footprint.
578    pub fn bind_shared(
579        &self,
580        eqid: u64,
581        template: &FamilyTemplate,
582        binding: &Binding,
583    ) -> Result<SharedQueryInfo, ReplicaError> {
584        let (fold, tables, normalized_fp, proj) = self.partition_parts(template, binding)?;
585        let hydrated_cv = self.cluster.committed_tx_id().0;
586        self.cluster.bind(QueryId(eqid), binding.clone())?;
587        self.handle.bind_partition(
588            QueryId(eqid),
589            binding.clone(),
590            fold,
591            normalized_fp,
592            proj,
593            hydrated_cv,
594        );
595        Ok(SharedQueryInfo {
596            tables,
597            normalized_fp,
598        })
599    }
600
601    /// Unbind one partition of the family under `eqid`: the engine drains it silently, the
602    /// drain drops its footprint (faulting any subscriber still on it).
603    pub fn unbind_shared(&self, eqid: u64, binding: &Binding) -> Result<bool, ReplicaError> {
604        let bound = self.cluster.unbind(QueryId(eqid), binding.clone())?;
605        self.handle.unbind_partition(QueryId(eqid), binding.clone());
606        Ok(bound)
607    }
608
609    /// Attach subscriber `sub` to one partition of a shared family (the family form of
610    /// [`attach_subscriber`](Self::attach_subscriber)).
611    pub fn attach_partition_subscriber(
612        &self,
613        eqid: u64,
614        binding: &Binding,
615        sub: u64,
616        conn: ConnId,
617        epoch: u64,
618    ) {
619        self.handle
620            .attach_partition_subscriber(QueryId(eqid), binding.clone(), sub, conn, epoch);
621    }
622
623    /// The one-shot snapshot of ONE partition of a family (the family form of
624    /// [`query_snapshot`](Self::query_snapshot)): the family's assembled view filtered to
625    /// the rows whose partition key is `binding`, rendered under the concrete member's
626    /// view schema.
627    pub fn partition_snapshot(
628        &self,
629        eqid: u64,
630        template: &FamilyTemplate,
631        binding: &Binding,
632    ) -> Result<serde_json::Value, ReplicaError> {
633        let concrete = template.instantiate(binding);
634        let schema = self.cluster.view_schema(&concrete)?;
635        let param_cols = self.family_param_cols(template)?;
636        let changes: Vec<crate::ChangeEvent> = self
637            .cluster
638            .read_snapshot(QueryId(eqid))?
639            .into_iter()
640            .filter(|c| rindle::canon::canonical_key(c.root_row(), &param_cols) == *binding)
641            .collect();
642        Ok(crate::wire_json::assembled_snapshot_to_json(
643            &changes, &schema,
644        ))
645    }
646
647    /// The drain-side parts of one family partition: the concrete member's fold, tables,
648    /// fingerprint and projection.
649    fn partition_parts(
650        &self,
651        template: &FamilyTemplate,
652        binding: &Binding,
653    ) -> Result<
654        (
655            crate::normalize::NormalizeFold,
656            Vec<TableWireSchema>,
657            u64,
658            crate::normalize_protocol::ProjMap,
659        ),
660        ReplicaError,
661    > {
662        let concrete = template.instantiate(binding);
663        reject_unsupported_sync_aggregate(&concrete).map_err(ReplicaError::Build)?;
664        let schemas = self.normalized_table_schemas(&concrete)?;
665        Ok(build_query_parts(&concrete, schemas))
666    }
667
668    /// The family's partition key as root-row column indices: the template's parameter
669    /// names resolved against the root table's wire schema (whose column order is the
670    /// engine's row order).
671    fn family_param_cols(&self, template: &FamilyTemplate) -> Result<Vec<usize>, ReplicaError> {
672        let schemas = self.normalized_table_schemas(&template.stripped)?;
673        let root = schemas
674            .iter()
675            .find(|t| t.name == template.stripped.table)
676            .ok_or_else(|| {
677                ReplicaError::Schema(format!(
678                    "unknown root table for query family: {}",
679                    template.stripped.table
680                ))
681            })?;
682        template
683            .params
684            .iter()
685            .map(|p| {
686                root.columns.iter().position(|c| c == p).ok_or_else(|| {
687                    ReplicaError::Schema(format!(
688                        "unknown parameter column {p} on {}",
689                        template.stripped.table
690                    ))
691                })
692            })
693            .collect()
694    }
695
696    /// Read the **assembled** view of a registered shared query as a one-shot snapshot — the
697    /// SSR REST path (`SSR-DESIGN.md` §3). No subscriber, no streaming, no lease: it re-reads
698    /// the live view of `eqid` and renders it to the flat/nested wire JSON a stateless API
699    /// server can hydrate directly (cells keyed by name, relationships nested inline). The
700    /// query must already be registered (via
701    /// [`register_shared_query`](Self::register_shared_query)); an absent query (or degraded
702    /// shard) yields an empty array. `ast` is the same AST the query was registered under —
703    /// it supplies the view schema (relationship names / projection / `.one()` shape).
704    pub fn query_snapshot(&self, eqid: u64, ast: &Ast) -> Result<serde_json::Value, ReplicaError> {
705        let schema = self.cluster.view_schema(ast)?;
706        let changes = self.cluster.read_snapshot(QueryId(eqid))?;
707        Ok(crate::wire_json::assembled_snapshot_to_json(
708            &changes, &schema,
709        ))
710    }
711
712    /// Attach subscriber `sub` (its own `conn` route + `epoch`) to a shared engine query.
713    /// Its seq-0 snapshot (from the cached footprint if the query is already hydrated, else
714    /// when it hydrates) and every later batch arrive asynchronously through the sink. Build
715    /// the subscriber's `hello` from the [`SharedQueryInfo`] [`register_shared_query`]
716    /// returned (see [`hello_for`](crate::normalize_protocol::hello_from_parts)).
717    ///
718    /// [`register_shared_query`]: Self::register_shared_query
719    pub fn attach_subscriber(&self, eqid: u64, sub: u64, conn: ConnId, epoch: u64) {
720        self.handle
721            .attach_subscriber(QueryId(eqid), sub, conn, epoch);
722    }
723
724    /// Detach one subscriber; the shared engine query and its peers keep running.
725    pub fn detach_subscriber(&self, sub: u64) {
726        self.handle.detach_subscriber(sub);
727    }
728
729    /// Fault every subscriber on a shared engine query (each gets a terminal `faulted`) and
730    /// detach them, leaving the query for a following [`destroy_query`](Self::destroy_query)
731    /// — gives active subscribers a re-subscribe signal before an explicit dematerialize.
732    pub fn fault_subscribers(&self, eqid: u64, reason: &str) {
733        self.handle
734            .fault_subscribers(QueryId(eqid), reason.to_string());
735    }
736
737    /// Tear down a registered query (cluster pipeline + drain bookkeeping, dropping every
738    /// subscriber still on it). For the 1:1 [`query_normalized`](Self::query_normalized)
739    /// path this removes the lone subscriber too.
740    pub fn destroy_query(&self, server_qid: u64) {
741        self.cluster.destroy_query(QueryId(server_qid));
742        self.handle.remove_query(QueryId(server_qid));
743    }
744
745    /// Apply a batch of positional mutations as one **raw foreign write** (no `lmid`,
746    /// confirms nothing), returning the commit version synchronously.
747    pub fn commit_normalized(&self, muts: &[Mutation]) -> Result<u64, ReplicaError> {
748        self.apply.commit_normalized(muts)
749    }
750
751    /// Apply a change-source batch AND advance the source's durable cursor in ONE write txn
752    /// (CHANGE-SOURCE-DESIGN.md §4). The `_rindle_source_offsets` upsert rides the same
753    /// transaction as the effects — exactly the `upsert_lmid` discipline — so a crash can
754    /// never commit the data without the cursor (or vice-versa). The caller owns the
755    /// monotonic-absorb dedup (`offset <= stored` ⇒ skip) BEFORE calling this; there is no
756    /// gap rejection (the source owns contiguity, §4).
757    pub fn commit_normalized_with_offset(
758        &self,
759        muts: &[Mutation],
760        source: &str,
761        offset: &str,
762        chunk_seq: i64,
763        run_id: Option<&str>,
764    ) -> Result<u64, ReplicaError> {
765        self.apply
766            .commit_normalized_with_offset(muts, source, offset, chunk_seq, run_id)
767    }
768
769    /// Terminal step of the **streaming-follower apply** (`REPLICATOR-PRECOMMIT-STREAMING-DESIGN.md`
770    /// §7): the caller has opened ONE [`ClusterWriteTxn`] via [`cluster`](Self::cluster)`.write()`
771    /// and driven [`apply_muts`](Self::apply_muts) into it once per `chunk` frame; this upserts the
772    /// source cursor in that SAME open txn (co-transactional with the chunk applies — a crash can
773    /// never commit the data without the cursor) and commits, returning the commit version. It is
774    /// exactly [`commit_normalized_with_offset`](Self::commit_normalized_with_offset)'s cursor
775    /// discipline, but with the row-changes already applied incrementally as chunks arrived rather
776    /// than handed over as one batch.
777    pub fn commit_follower_txn(
778        &self,
779        txn: ClusterWriteTxn,
780        source: &str,
781        offset: &str,
782        chunk_seq: i64,
783        run_id: Option<&str>,
784    ) -> Result<u64, ReplicaError> {
785        self.apply
786            .commit_follower_txn(txn.into_core(), source, offset, chunk_seq, run_id)
787    }
788
789    /// [`commit_follower_txn`](Self::commit_follower_txn) plus the frame's durable row-count/commit stamps.
790    /// Effects, cursor, run fence, and head accounting land in one transaction.
791    pub fn commit_follower_txn_with_head(
792        &self,
793        txn: ClusterWriteTxn,
794        source: &str,
795        offset: &str,
796        chunk_seq: i64,
797        run_id: Option<&str>,
798        head: SourceHead,
799    ) -> Result<u64, ReplicaError> {
800        self.apply.commit_follower_txn_with_head(
801            txn.into_core(),
802            source,
803            offset,
804            chunk_seq,
805            run_id,
806            head,
807        )
808    }
809
810    /// Advance only a CDC transport locator at an unchanged semantic cursor.
811    ///
812    /// The source-offset table is deliberately unregistered, so this empty
813    /// application commit emits no IVM row delta. The compare-and-swap keeps a
814    /// stale connection from replacing a newer locator, and the update remains
815    /// a real SQLite transaction so a portable image observes either locator
816    /// in full, never torn metadata.
817    pub fn refresh_follower_run_id(
818        &self,
819        source: &str,
820        offset: &str,
821        previous_run_id: &str,
822        next_run_id: &str,
823    ) -> Result<u64, ReplicaError> {
824        self.apply
825            .refresh_follower_run_id(source, offset, previous_run_id, next_run_id)
826    }
827
828    /// Create the `_rindle_source_offsets` bookkeeping table (idempotent). Not registered for
829    /// capture — daemon metadata, like `_rindle_sql_outcomes`.
830    pub fn ensure_source_offsets_table(&self) -> Result<(), ReplicaError> {
831        self.apply.ensure_source_offsets_table()
832    }
833
834    /// The durably-stored `(offset, chunk_seq, run_id)` checkpoint for `source` (`None` ⇒ never
835    /// applied; the caller treats that as the genesis `""` and subscribes from the start).
836    /// `chunk_seq` is [`SOURCE_OFFSET_WHOLE_RUN`] for a whole-run checkpoint (the common case) or a
837    /// real within-run ordinal for a mid-run segment left by the commit-at-DDL-boundary follower
838    /// (§6.6). The resume/dedup keyset is `(offset, chunk_seq)`; `run_id` is the checkpointed run's
839    /// identity token, echoed on the subscribe as the fencing proof
840    /// (RELAY-CURSOR-EPOCH-FENCING-DESIGN.md §2) — `None` for a pre-fence checkpoint.
841    #[allow(clippy::type_complexity)]
842    pub fn source_checkpoint(
843        &self,
844        source: &str,
845    ) -> Result<Option<(String, i64, Option<String>)>, ReplicaError> {
846        self.apply.source_checkpoint(source)
847    }
848
849    /// Persisted accounting carried beside the source checkpoint.
850    pub fn source_head(&self, source: &str) -> Result<Option<SourceHead>, ReplicaError> {
851        self.apply.source_head(source)
852    }
853
854    /// The stored §8.3 batch identity for `source`'s checkpoint (`None` = no row, or a
855    /// hash-less source). Compared — never recomputed — against a resubmission's
856    /// declared hash at the exact stored offset.
857    pub fn source_checkpoint_hash(&self, source: &str) -> Result<Option<String>, ReplicaError> {
858        self.apply.source_checkpoint_hash(source)
859    }
860
861    /// Create the `_rindle_producer_offsets` foreign-write watermark table (idempotent). The DDL
862    /// is the shared one, so this cannot drift from the write-master's or the restore's copy.
863    /// Callers register it for capture afterwards — it is replicated data, not host bookkeeping
864    /// (design 306 §3.3).
865    pub fn ensure_producer_offsets_table(&self) -> Result<(), ReplicaError> {
866        self.apply.ensure_producer_offsets_table()
867    }
868
869    // ------------------- the room write-behind (RINDLE-REALTIME §5.3) -------------------
870
871    /// Create the `_rindle_room_placement` fencing table (idempotent). Unregistered
872    /// bookkeeping like the offsets table.
873    pub fn ensure_room_placement_table(&self) -> Result<(), ReplicaError> {
874        self.cluster.exec_ddl(&format!(
875            "CREATE TABLE IF NOT EXISTS {ROOM_PLACEMENT_TABLE} \
876                 (doc TEXT PRIMARY KEY, epoch INTEGER NOT NULL)"
877        ))
878    }
879
880    /// Claim the next placement epoch for `doc` (§2.5): one write transaction, one
881    /// monotone bump. The claim is what fences every prior epoch's flushes.
882    pub fn claim_room_epoch(&self, doc: &str) -> Result<i64, ReplicaError> {
883        let mut txn = self.cluster.write()?;
884        txn.exec(
885            &format!(
886                "INSERT INTO {ROOM_PLACEMENT_TABLE} (doc, epoch) VALUES (?1, 1) \
887                 ON CONFLICT(doc) DO UPDATE SET epoch = epoch + 1"
888            ),
889            &[OwnedValue::str(doc)],
890        )?;
891        let rows = txn.query(
892            &format!("SELECT epoch FROM {ROOM_PLACEMENT_TABLE} WHERE doc = ?1"),
893            &[OwnedValue::str(doc)],
894        )?;
895        let epoch = match rows.first().and_then(|r| r.first()) {
896            Some(OwnedValue::Int(e)) => *e,
897            other => {
898                return Err(ReplicaError::Schema(format!(
899                    "room placement epoch read back {other:?}"
900                )))
901            }
902        };
903        txn.commit_with_info()?;
904        Ok(epoch)
905    }
906
907    /// The current placement epoch for `doc` (`None` = never claimed — a fence-bearing
908    /// request against it is stale by definition).
909    pub fn room_epoch(&self, doc: &str) -> Result<Option<i64>, ReplicaError> {
910        self.cluster.read(|conn| {
911            conn.query_row(
912                &format!("SELECT epoch FROM {ROOM_PLACEMENT_TABLE} WHERE doc = ?1"),
913                [doc],
914                |r| r.get::<_, i64>(0),
915            )
916            .optional()
917        })
918    }
919
920    /// The **domain-scoped** ledger's `last_mutation_id` per client under `doc` (absent =
921    /// 0 to the caller) — the room's boot probe (§3.3): what lets a rebooted room absorb
922    /// already-durable mutations as replay dedup instead of double-applying them. Reads
923    /// [`ROOM_CLIENT_MUTATIONS_TABLE`] keyed by `(doc, client_id)` (§7.1) — NOT the
924    /// slow-path [`CLIENT_MUTATIONS_TABLE`], so a client's room stream and its daemon
925    /// stream never alias.
926    pub fn room_lmids(
927        &self,
928        doc: &str,
929        clients: &[String],
930    ) -> Result<Vec<(String, i64)>, ReplicaError> {
931        self.cluster.read(|conn| {
932            let mut out = Vec::with_capacity(clients.len());
933            for c in clients {
934                let lmid = conn
935                    .query_row(
936                        &format!(
937                            "SELECT last_mutation_id FROM {ROOM_CLIENT_MUTATIONS_TABLE} \
938                             WHERE doc = ?1 AND client_id = ?2"
939                        ),
940                        [doc, c.as_str()],
941                        |r| r.get::<_, i64>(0),
942                    )
943                    .optional()?;
944                if let Some(lmid) = lmid {
945                    out.push((c.clone(), lmid));
946                }
947            }
948            Ok(out)
949        })
950    }
951
952    /// Apply a room flush batch (§5.3 step 5): optional **CAS preconditions** — every
953    /// change's old image (`Edit.old` / `Remove`'s old / `Add`'s asserted absence)
954    /// must match the current row under the identity comparator (`null == null`, one
955    /// number domain; NOT join semantics) — then effects + cursor + batch identity in
956    /// ONE transaction. Any CAS miss rolls the whole batch back and returns the
957    /// authoritative current images. The caller owns the fence and the dedup (both
958    /// race-free on the single-threaded engine).
959    ///
960    /// When `doc` is `Some` this is a **room** flush: its ledger co-edits (§5.3 step 3 —
961    /// the changes targeting [`CLIENT_MUTATIONS_TABLE`]) are domain-scoped and retarget to
962    /// [`ROOM_CLIENT_MUTATIONS_TABLE`] keyed by `(doc, client_id)` (§7.1). They are pulled
963    /// out of the CAS + effects pass and upserted monotonically in the same transaction,
964    /// so the daemon's slow-path [`CLIENT_MUTATIONS_TABLE`] row is never perturbed by a
965    /// room flush (§8.5's ledger-isolation invariant — the Rev 1 data-loss bug). When
966    /// `doc` is `None` (a plain change source / replicator follower) the batch applies
967    /// verbatim, so the slow-path lmid stream still rides its own `CLIENT_MUTATIONS_TABLE`
968    /// rows unchanged.
969    ///
970    /// When the §4 lifecycle is enabled
971    /// ([`enable_realtime_lifecycle`](Self::enable_realtime_lifecycle)) a room flush also
972    /// co-commits the §4.2 downgrade-fence watermark —
973    /// `ROOM_WATERMARK_TABLE(doc, flush_seq = offset)`, monotone — in the SAME
974    /// transaction, so the fence rides the flush's echo through every authority shape
975    /// (see the inline comment for the dedup/regression reasoning).
976    ///
977    /// A room flush's **outcome co-edits** (Slice I-ii — changes targeting
978    /// [`ROOM_MUTATION_OUTCOMES_TABLE`], doc-less 6-wide rows) split off exactly like the
979    /// ledger's: they NEVER enter the CAS pass or `apply_muts` — the split is
980    /// unconditional for a `doc` flush (a lifecycle-disabled daemon would otherwise fail
981    /// the whole batch on the unregistered table), while the upsert itself is gated on
982    /// `realtime_lifecycle_enabled`, so **a lifecycle-disabled daemon accepts a batch
983    /// carrying outcome rows and silently discards them** (it has no downgrade path to
984    /// resolve through — graceful, not an error). With the lifecycle on, each row upserts
985    /// keyed `(doc, client_id, mid)` (idempotent on replay by PK) in the SAME transaction
986    /// as the lmid rows covering those mids — the I-ii soundness contract: an absent
987    /// outcome row under a covering daemon-carried lmid means `applied`. Retention prunes
988    /// in the same transaction by lmid DISTANCE ([`OUTCOME_RETENTION_LMIDS`]), for exactly
989    /// the `(doc, client)` rows this flush advanced.
990    pub fn commit_room_flush(
991        &self,
992        muts: &[Mutation],
993        source: &str,
994        offset: &str,
995        doc: Option<&str>,
996        batch_hash: Option<&str>,
997        cas: bool,
998    ) -> Result<RoomFlushOutcome, ReplicaError> {
999        let mut txn = self.cluster.write()?;
1000        // A room flush (doc present) domain-scopes its ledger co-edits: split them off so
1001        // the CAS check + `apply_muts` below see only the data rows, and the ledger rows
1002        // land in `ROOM_CLIENT_MUTATIONS_TABLE(doc, …)` instead of the slow-path table.
1003        // The outcome co-edits (I-ii) split with them — unconditionally, lifecycle or not:
1004        // left in the data pass they would error in `apply_muts` (the table is
1005        // unregistered without the lifecycle) or CAS-trip (the wire row is doc-less,
1006        // width-mismatched against the registered 7-column table).
1007        let data: Vec<Mutation> = if doc.is_some() {
1008            muts.iter()
1009                .filter(|m| {
1010                    m.table() != CLIENT_MUTATIONS_TABLE && m.table() != ROOM_MUTATION_OUTCOMES_TABLE
1011                })
1012                .cloned()
1013                .collect()
1014        } else {
1015            Vec::new()
1016        };
1017        let data_muts: &[Mutation] = if doc.is_some() { &data } else { muts };
1018        if cas {
1019            let tables = self.apply.tables();
1020            let mut conflicts: Vec<RoomRowConflict> = Vec::new();
1021            for m in data_muts {
1022                let table = m.table();
1023                let meta = tables
1024                    .get(table)
1025                    .ok_or_else(|| ReplicaError::Schema(format!("unknown table: {table}")))?;
1026                let (asserted, keyed): (Option<&[OwnedValue]>, &[OwnedValue]) = match m {
1027                    Mutation::Add { row, .. } => (None, row),
1028                    Mutation::Remove { row, .. } => (Some(row), row),
1029                    Mutation::Edit { old, .. } => (Some(old), old),
1030                    Mutation::Truncate { .. } => (None, &[][..]),
1031                    Mutation::Upsert { row, .. } => (None, row),
1032                };
1033                // A width drift (a migration landed under the batch, §8.4/T9) can't
1034                // key the row — surface it as a conflict on a best-effort pk.
1035                let widthy = keyed.len() == meta.columns.len();
1036                let pk_cells: Vec<OwnedValue> = if widthy {
1037                    meta.pk.iter().map(|&c| keyed[c].clone()).collect()
1038                } else {
1039                    keyed.first().cloned().into_iter().collect()
1040                };
1041                let current = if widthy {
1042                    let cols = meta.columns.join(", ");
1043                    let pred = meta
1044                        .pk
1045                        .iter()
1046                        .enumerate()
1047                        .map(|(i, &c)| format!("{} = ?{}", meta.columns[c], i + 1))
1048                        .collect::<Vec<_>>()
1049                        .join(" AND ");
1050                    txn.query(
1051                        &format!("SELECT {cols} FROM {table} WHERE {pred}"),
1052                        &pk_cells,
1053                    )?
1054                    .into_iter()
1055                    .next()
1056                } else {
1057                    None
1058                };
1059                let holds = match (asserted, &current) {
1060                    (None, None) => widthy, // an add asserts absence
1061                    (Some(a), Some(c)) => {
1062                        a.len() == c.len()
1063                            && a.iter().zip(c.iter()).all(|(x, y)| cas_cell_matches(x, y))
1064                    }
1065                    _ => false,
1066                };
1067                if !holds {
1068                    conflicts.push(RoomRowConflict {
1069                        table: table.to_string(),
1070                        pk: pk_cells,
1071                        current: current.clone(),
1072                    });
1073                }
1074            }
1075            if !conflicts.is_empty() {
1076                // Dropping the txn rolls everything back: nothing applied (§5.3). The
1077                // domain-scoped ledger upsert below is never reached, so a data CAS miss
1078                // also rolls back the lmid advance — the batch stays atomic.
1079                return Ok(RoomFlushOutcome::Conflicts(conflicts));
1080            }
1081        }
1082        self.apply_muts(&mut txn, data_muts)?;
1083        // §5.3 step 3, domain-scoped (§7.1): the ledger co-edits retarget to
1084        // `ROOM_CLIENT_MUTATIONS_TABLE` keyed by `(doc, client_id)`. Monotonic upsert (the
1085        // room owns this stream and flushes are epoch-fenced, so last-writer-wins is safe);
1086        // a `Remove` would retire a row, though the room never emits one for lmids.
1087        if let Some(doc) = doc {
1088            // The `(client, lmid)` rows this flush advances — the outcome prune below keys
1089            // on exactly these (prune only when the covering watermark moves).
1090            let mut advanced: Vec<(OwnedValue, i64)> = Vec::new();
1091            for m in muts.iter().filter(|m| m.table() == CLIENT_MUTATIONS_TABLE) {
1092                match m {
1093                    Mutation::Truncate { .. } => {}
1094                    Mutation::Add { row, .. }
1095                    | Mutation::Edit { new: row, .. }
1096                    | Mutation::Upsert { row, .. } => {
1097                        txn.exec(
1098                            &format!(
1099                                "INSERT INTO {ROOM_CLIENT_MUTATIONS_TABLE} \
1100                                 (doc, client_id, last_mutation_id) VALUES(?1, ?2, ?3) \
1101                                 ON CONFLICT(doc, client_id) DO UPDATE \
1102                                 SET last_mutation_id = excluded.last_mutation_id"
1103                            ),
1104                            &[OwnedValue::str(doc), row[0].clone(), row[1].clone()],
1105                        )?;
1106                        if let Some(OwnedValue::Int(lmid)) = row.get(1) {
1107                            advanced.push((row[0].clone(), *lmid));
1108                        }
1109                    }
1110                    Mutation::Remove { row, .. } => {
1111                        txn.exec(
1112                            &format!(
1113                                "DELETE FROM {ROOM_CLIENT_MUTATIONS_TABLE} \
1114                                 WHERE doc = ?1 AND client_id = ?2"
1115                            ),
1116                            &[OwnedValue::str(doc), row[0].clone()],
1117                        )?;
1118                    }
1119                }
1120            }
1121            // Slice I-ii: the outcome co-edits land keyed `(doc, client_id, mid)` in this
1122            // SAME transaction as the lmid rows covering them — the soundness contract a
1123            // post-downgrade client resolves by (absent row under a covering lmid =
1124            // `applied`) only holds if the pair is atomic. Gated on the lifecycle: a
1125            // disabled daemon has no outcomes table and DISCARDS the rows (documented
1126            // above). Upserts are idempotent by PK, so a keyset-replayed batch re-lands
1127            // identical rows; a `Remove` retires one (the room never emits it — symmetry
1128            // with the ledger split).
1129            if self.cluster.realtime_lifecycle_enabled() {
1130                for m in muts
1131                    .iter()
1132                    .filter(|m| m.table() == ROOM_MUTATION_OUTCOMES_TABLE)
1133                {
1134                    match m {
1135                        Mutation::Truncate { .. } => {}
1136                        Mutation::Add { row, .. }
1137                        | Mutation::Edit { new: row, .. }
1138                        | Mutation::Upsert { row, .. } => {
1139                            // The wire row is the doc-less `(client_id, mid, kind, reason,
1140                            // name, args)` — a wrong width is a room-side protocol bug,
1141                            // refused loudly (whole-txn rollback), never partially stored.
1142                            if row.len() != 6 {
1143                                return Err(ReplicaError::Mutation(format!(
1144                                    "room outcome row must be 6-wide \
1145                                     (client_id, mid, kind, reason, name, args); got {}",
1146                                    row.len()
1147                                )));
1148                            }
1149                            let mut params = vec![OwnedValue::str(doc)];
1150                            params.extend(row.iter().cloned());
1151                            txn.exec(
1152                                &format!(
1153                                    "INSERT INTO {ROOM_MUTATION_OUTCOMES_TABLE} \
1154                                     (doc, client_id, mid, kind, reason, name, args) \
1155                                     VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7) \
1156                                     ON CONFLICT(doc, client_id, mid) DO UPDATE \
1157                                     SET kind = excluded.kind, reason = excluded.reason, \
1158                                         name = excluded.name, args = excluded.args"
1159                                ),
1160                                &params,
1161                            )?;
1162                        }
1163                        Mutation::Remove { row, .. } => {
1164                            txn.exec(
1165                                &format!(
1166                                    "DELETE FROM {ROOM_MUTATION_OUTCOMES_TABLE} \
1167                                     WHERE doc = ?1 AND client_id = ?2 AND mid = ?3"
1168                                ),
1169                                &[OwnedValue::str(doc), row[0].clone(), row[1].clone()],
1170                            )?;
1171                        }
1172                    }
1173                }
1174                // Retention, AFTER the upserts so an over-K backlog in one batch degrades
1175                // exactly like the shell's FIFO (oldest first): prune by lmid DISTANCE —
1176                // never time — for each `(doc, client)` this flush advanced. `mid ≤
1177                // lmid − K` keeps the K-deep window `(lmid − K, lmid]`; a pruned mid reads
1178                // as `applied` (the H-v-mirrored loss class, see OUTCOME_RETENTION_LMIDS).
1179                for (client, lmid) in advanced {
1180                    txn.exec(
1181                        &format!(
1182                            "DELETE FROM {ROOM_MUTATION_OUTCOMES_TABLE} \
1183                             WHERE doc = ?1 AND client_id = ?2 AND mid <= ?3"
1184                        ),
1185                        &[
1186                            OwnedValue::str(doc),
1187                            client,
1188                            OwnedValue::Int(lmid - OUTCOME_RETENTION_LMIDS),
1189                        ],
1190                    )?;
1191                }
1192            }
1193            // §4.2: the downgrade-fence watermark co-commits in the SAME transaction as
1194            // the flush it covers — one more row in a batch that already co-commits lmid
1195            // rows. Gated on the lifecycle being enabled (`realtime_lifecycle_enabled`),
1196            // so a daemon that never called `enable_realtime_lifecycle` applies room
1197            // flushes exactly as before. A CAS miss returned above (whole-txn rollback),
1198            // so the watermark can never land on a conflicted batch; the server's keyset
1199            // dedup (`run_already_applied`, rindle-server net.rs) short-circuits replays
1200            // before this path runs, and the monotone guard makes even a raw reordered
1201            // replica-level caller unable to regress the fence.
1202            if self.cluster.realtime_lifecycle_enabled() {
1203                // A room-plane offset IS the flush seq, zero-padded decimal (`padOffset`,
1204                // packages/room/src/shell.ts). A `doc`-flush offset that doesn't parse
1205                // back is a protocol violation — loud, never silently skipped.
1206                let flush_seq: i64 = offset.parse().map_err(|_| {
1207                    ReplicaError::Mutation(format!(
1208                        "room flush offset is not a flush seq (doc {doc:?}, offset {offset:?})"
1209                    ))
1210                })?;
1211                txn.exec(
1212                    &format!(
1213                        "INSERT INTO {ROOM_WATERMARK_TABLE} (doc, flush_seq) VALUES (?1, ?2) \
1214                         ON CONFLICT(doc) DO UPDATE SET flush_seq = excluded.flush_seq \
1215                         WHERE excluded.flush_seq > {ROOM_WATERMARK_TABLE}.flush_seq"
1216                    ),
1217                    &[OwnedValue::str(doc), OwnedValue::Int(flush_seq)],
1218                )?;
1219            }
1220        }
1221        upsert_source_offset_hashed(
1222            txn.core_mut(),
1223            source,
1224            offset,
1225            SOURCE_OFFSET_WHOLE_RUN,
1226            None,
1227            batch_hash,
1228        )?;
1229        let info = txn.commit_with_info()?;
1230        Ok(RoomFlushOutcome::Applied { cv: info.tx_id.0 })
1231    }
1232
1233    /// The durably-stored cursor string for `source`, discarding the `chunk_seq` sub-position — for
1234    /// the string-only callers (snapshot-restore resume points, which are always run boundaries). The
1235    /// resume/dedup paths use [`source_checkpoint`](Self::source_checkpoint) for the full keyset.
1236    pub fn source_offset(&self, source: &str) -> Result<Option<String>, ReplicaError> {
1237        self.apply.source_offset(source)
1238    }
1239
1240    /// Create the `_rindle_applied_ddl` idempotency journal (idempotent). Not registered for capture —
1241    /// daemon metadata, like `_rindle_source_offsets`. `actions` holds the entry's ordered apply
1242    /// report (design 227 fourth review pass), written in the same transaction as the marker.
1243    pub fn ensure_applied_ddl_table(&self) -> Result<(), ReplicaError> {
1244        self.apply.ensure_applied_ddl_table()
1245    }
1246
1247    /// Whether a `ddl` entry keyed by `key` (migration id / offset) is already journaled — the
1248    /// crash-window-replay dedup, checked BEFORE re-applying.
1249    pub fn ddl_already_applied(&self, key: &str) -> Result<bool, ReplicaError> {
1250        self.apply.ddl_already_applied(key)
1251    }
1252
1253    /// Apply a `ddl` entry's `statements` and journal its `key` atomically — one ordinary
1254    /// transaction, so retries can detect applied DDL from the durable marker. Delegates to
1255    /// [`Cluster::exec_ddl_with_marker`] against the [`APPLIED_DDL_TABLE`] journal.
1256    pub fn apply_ddl_with_marker(
1257        &self,
1258        key: &str,
1259        statements: &[String],
1260    ) -> Result<crate::DdlApplyReport, ReplicaError> {
1261        self.apply.apply_ddl_with_marker(key, statements)
1262    }
1263
1264    /// Apply DDL, caller-owned per-statement bookkeeping effects, and the durable marker in one
1265    /// transaction. See [`Cluster::exec_ddl_with_marker_and_step_effects`].
1266    pub fn apply_ddl_with_marker_and_step_effects<F>(
1267        &self,
1268        key: &str,
1269        statements: &[String],
1270        apply_step_effects: F,
1271    ) -> Result<crate::DdlApplyReport, ReplicaError>
1272    where
1273        F: FnMut(&rusqlite::Connection, &crate::DdlStep) -> rusqlite::Result<()>,
1274    {
1275        self.apply
1276            .apply_ddl_with_marker_and_step_effects(key, statements, apply_step_effects)
1277    }
1278
1279    /// The ordered apply report persisted with `key`'s marker (same transaction as the DDL), or
1280    /// `None` for entries marked before the report column existed (they degrade to the caller's
1281    /// end-state fallback). A replay consumes this instead of re-observing — the DDL does not
1282    /// re-run, so there is nothing to observe (design 227 fourth review pass).
1283    pub fn stored_ddl_report(
1284        &self,
1285        key: &str,
1286    ) -> Result<Option<crate::DdlApplyReport>, ReplicaError> {
1287        self.apply.stored_ddl_report(key)
1288    }
1289
1290    /// Open one mutation's write transaction; run SQL against the handle, then
1291    /// [`ClusterMutationWrite::commit_with_lmid`] lands effects + lmid atomically (or
1292    /// [`commit`](ClusterMutationWrite::commit) for a foreign write).
1293    pub fn begin_mutation(&self) -> Result<ClusterMutationWrite, ReplicaError> {
1294        Ok(ClusterMutationWrite {
1295            txn: Some(self.cluster.write()?),
1296        })
1297    }
1298
1299    /// Apply positional mutations to an open cluster write transaction (build_sql per row).
1300    pub fn apply_muts(
1301        &self,
1302        txn: &mut ClusterWriteTxn,
1303        muts: &[Mutation],
1304    ) -> Result<(), ReplicaError> {
1305        self.apply.apply_muts(txn.core_mut(), muts)
1306    }
1307
1308    /// The flat schema of every base table the query's tree can surface (root + each
1309    /// `related`/EXISTS child table), for the publisher's `hello` + PK map. A relationship
1310    /// **aggregate** surfaces a SYNTHETIC table (`AGGREGATE-SYNC-DESIGN.md` §3.2) whose
1311    /// schema is derived from the AST (`agg_table_schemas`), not the DB registry — the
1312    /// child rows it replaces are never synced.
1313    pub fn normalized_table_schemas(
1314        &self,
1315        ast: &Ast,
1316    ) -> Result<Vec<TableWireSchema>, ReplicaError> {
1317        use crate::normalize::{agg_table_schemas, AggTable};
1318        use std::collections::HashMap;
1319        let synth: HashMap<Box<str>, AggTable> = agg_table_schemas(ast)
1320            .into_iter()
1321            .map(|t| (t.name.clone(), t))
1322            .collect();
1323        let mut names = std::collections::BTreeSet::new();
1324        collect_table_names(&table_tree(ast), &mut names);
1325        let tables = self.apply.tables();
1326        let mut out = Vec::with_capacity(names.len());
1327        for name in &names {
1328            if let Some(at) = synth.get(name.as_str()) {
1329                // Synthetic aggregate table: schema from the AST, PK = the leading group cols.
1330                out.push(TableWireSchema {
1331                    name: at.name.clone(),
1332                    columns: at.columns.clone(),
1333                    primary_key: (0..at.key_len as u32).collect(),
1334                });
1335            } else {
1336                let meta = tables.get(name).ok_or_else(|| {
1337                    ReplicaError::Schema(format!("unknown table in query tree: {name}"))
1338                })?;
1339                out.push(TableWireSchema {
1340                    name: name.as_str().into(),
1341                    columns: meta.columns.iter().map(|c| c.as_str().into()).collect(),
1342                    primary_key: meta.pk.iter().map(|&i| i as u32).collect(),
1343                });
1344            }
1345        }
1346        Ok(out)
1347    }
1348}
1349
1350fn collect_table_names(node: &TableNode, out: &mut std::collections::BTreeSet<String>) {
1351    out.insert(node.table.to_string());
1352    // Pruned (`exists_noSync`) slots are `None`: their permission tables are never synced,
1353    // so they correctly drop out of the needed-tables set.
1354    for child in node.children.iter().flatten() {
1355        collect_table_names(child, out);
1356    }
1357}
1358
1359/// One server-side mutation's open cluster transaction (see [`ClusterConsumer::begin_mutation`]).
1360/// Dropping without committing rolls back.
1361pub struct ClusterMutationWrite {
1362    txn: Option<ClusterWriteTxn>,
1363}
1364
1365impl ClusterMutationWrite {
1366    fn txn(&mut self) -> Result<&mut ClusterWriteTxn, ReplicaError> {
1367        self.txn
1368            .as_mut()
1369            .ok_or_else(|| ReplicaError::Mutation("transaction already finished".into()))
1370    }
1371
1372    /// Run one statement with positional parameters (the mutator's write path).
1373    pub fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
1374        self.txn()?.exec(sql, params)
1375    }
1376
1377    /// Run one mutation statement under the shared writer time/VM budget. `guarded` additionally
1378    /// installs the public reserved-object authorizer for the statement.
1379    pub fn exec_bounded(
1380        &mut self,
1381        sql: &str,
1382        params: &[OwnedValue],
1383        guarded: bool,
1384    ) -> Result<usize, StatementRunError> {
1385        let txn = self.txn.as_mut().ok_or_else(|| {
1386            StatementRunError::Malformed("mutation transaction already finished".into())
1387        })?;
1388        txn.exec_bounded(sql, params, guarded)
1389    }
1390
1391    /// Run one statement on the public v1 surface. Interactive transactions preserve the outer
1392    /// transaction on ordinary statement errors; one-shot batches set `preserve_on_error=false`
1393    /// and let the shared coordinator roll the complete unit back.
1394    pub fn public_statement(
1395        &mut self,
1396        statement: &SqlStatementRequest,
1397        preserve_on_error: bool,
1398        result_byte_limit: usize,
1399    ) -> Result<StatementResult, StatementRunError> {
1400        let txn = self.txn.as_mut().ok_or_else(|| {
1401            StatementRunError::Malformed("mutation transaction already finished".into())
1402        })?;
1403        let result = txn.public_statement(statement, preserve_on_error, result_byte_limit);
1404        if result.is_err() && !txn.is_open() {
1405            // A savepoint setup/release failure or SQLite auto-abort closed the outer transaction.
1406            // Drop the finished handle now so the next admitted request can open the writer.
1407            self.txn.take();
1408        }
1409        result
1410    }
1411
1412    pub fn public_transaction_open(&self) -> bool {
1413        self.txn.as_ref().is_some_and(ClusterWriteTxn::is_open)
1414    }
1415
1416    /// Run a read through the open transaction (sees its own uncommitted writes, §4.1).
1417    pub fn query(
1418        &mut self,
1419        sql: &str,
1420        params: &[OwnedValue],
1421    ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError> {
1422        self.txn()?.query(sql, params)
1423    }
1424
1425    /// [`query`](Self::query) plus ordered column names, enforcing SQLite's read-only verdict for
1426    /// the shared mutation-session contract.
1427    pub fn query_with_cols(
1428        &mut self,
1429        sql: &str,
1430        params: &[OwnedValue],
1431    ) -> Result<(Vec<String>, Vec<Vec<OwnedValue>>), ReplicaError> {
1432        self.txn()?.query_with_cols(sql, params)
1433    }
1434
1435    /// Advance a foreign writer's producer watermark inside this open transaction, so the receipt
1436    /// commits with the effects it deduplicates or not at all.
1437    ///
1438    /// Written through the capture-aware [`exec`](ClusterWriteTxn::exec) — NOT the connection-only
1439    /// helper — because the watermark is replicated data (design 306 §3.3): an uncaptured row is
1440    /// absent from the journal, so a restored store would come back holding every effect and no
1441    /// dedup state, and the first retry after the restore would re-apply. The SQL itself is the
1442    /// shared one, so this cannot drift from [`writeplane::upsert_producer_seq`].
1443    pub fn upsert_producer_seq(
1444        &mut self,
1445        producer: &str,
1446        seq: u64,
1447    ) -> Result<(), BookkeepingError> {
1448        let txn = self.txn.as_mut().ok_or_else(|| {
1449            BookkeepingError::StoreFormat("mutation transaction already finished".into())
1450        })?;
1451        txn.exec(
1452            &writeplane::producer_offsets_upsert_sql(),
1453            &[OwnedValue::str(producer), OwnedValue::Int(seq as i64)],
1454        )
1455        .map(|_| ())
1456        .map_err(|error| BookkeepingError::StoreFormat(error.to_string()))
1457    }
1458
1459    /// Append the captured apply-once marker at the tail of a standalone data migration.
1460    pub fn journal_data_migration(
1461        &mut self,
1462        id: &str,
1463        content_checksum: &str,
1464        applied_at: i64,
1465    ) -> Result<(), BookkeepingError> {
1466        let txn = self.txn.as_ref().ok_or_else(|| {
1467            BookkeepingError::StoreFormat("mutation transaction already finished".into())
1468        })?;
1469        writeplane::journal_data_migration(txn.connection(), id, content_checksum, applied_at)
1470    }
1471
1472    /// Upsert `last_mutation_id = mid` for `client_id` **in this transaction**, commit, and
1473    /// return the commit version `cv` synchronously. The lmid row is ordinary captured
1474    /// data: it derives through the client's own system query and is released by the same
1475    /// `cv_min` as the commit's effects.
1476    pub fn commit_with_lmid(&mut self, client_id: &str, mid: u64) -> Result<u64, ReplicaError> {
1477        let mut txn = self
1478            .txn
1479            .take()
1480            .ok_or_else(|| ReplicaError::Mutation("transaction already finished".into()))?;
1481        txn.exec(
1482            &format!(
1483                "INSERT INTO {CLIENT_MUTATIONS_TABLE}(client_id, last_mutation_id) VALUES(?1, ?2) \
1484                 ON CONFLICT(client_id) DO UPDATE \
1485                 SET last_mutation_id = excluded.last_mutation_id"
1486            ),
1487            &[OwnedValue::str(client_id), OwnedValue::Int(mid as i64)],
1488        )?;
1489        let info = txn.commit_with_info()?;
1490        Ok(info.tx_id.0)
1491    }
1492
1493    /// Commit WITHOUT an lmid advance (a foreign/system write: confirms no client mutation),
1494    /// returning the commit version `cv`.
1495    pub fn commit(&mut self) -> Result<u64, ReplicaError> {
1496        let txn = self
1497            .txn
1498            .take()
1499            .ok_or_else(|| ReplicaError::Mutation("transaction already finished".into()))?;
1500        let info = txn.commit_with_info()?;
1501        Ok(info.tx_id.0)
1502    }
1503
1504    /// Store one public batch/transaction terminal outcome in the same SQLite transaction as its
1505    /// application effects. A captured unit renders the predicted next TxId into `cursor` before
1506    /// COMMIT; a zero-effect unit commits only the outcome metadata and does not advance TxId.
1507    pub fn commit_public_outcome(
1508        &mut self,
1509        outcome_key: &str,
1510        request_identity: Option<&str>,
1511        result_json: &str,
1512        now_ms: i64,
1513    ) -> Result<Option<String>, DdlMigrationError> {
1514        let txn = self
1515            .txn
1516            .take()
1517            .ok_or_else(|| ReplicaError::Mutation("transaction already finished".into()))?;
1518        let captured = txn.captured_user_event_count() != 0;
1519        let cursor = captured.then(|| format!("w:{:016x}", txn.next_tx_id().0));
1520        if let Err(error) = writeplane::insert_sql_outcome_with_cursor(
1521            txn.connection(),
1522            outcome_key,
1523            result_json,
1524            None,
1525            request_identity,
1526            cursor.as_deref(),
1527            now_ms,
1528        ) {
1529            txn.rollback();
1530            return Err(error.into());
1531        }
1532        if captured {
1533            let expected = txn.next_tx_id();
1534            let committed = txn.commit_with_info()?;
1535            debug_assert_eq!(committed.tx_id, expected);
1536        } else {
1537            txn.commit_metadata_only()?;
1538        }
1539        Ok(cursor)
1540    }
1541
1542    /// Roll back: effects discarded, nothing delivered.
1543    pub fn rollback(&mut self) {
1544        if let Some(txn) = self.txn.take() {
1545            txn.rollback();
1546        }
1547    }
1548}