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(), ¶m_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, ¤t) {
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 ¶ms,
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}