rindle_cdc_apply/consumer.rs
1//! [`ApplyConsumer`] — the headless twin of `rindle-replica`'s `ClusterConsumer` apply
2//! half (design 309 §3): everything needed to apply a rindle CDC stream to a SQLite
3//! database — table registration, row apply, the co-transactional cursor discipline,
4//! replicated DDL with idempotency markers, resume — with **no derivation**. The live
5//! `ClusterConsumer` delegates every one of these methods here (one implementation,
6//! moved, never forked — the byte-identity argument of design 309 §5); a headless host
7//! (`rindle-backup-sqlite`'s portable replay, an external stream consumer) composes
8//! this with [`NoFanout`](super::NoFanout) and nothing else.
9//!
10//! The type system replaces the old refusal rule: this consumer simply has no
11//! derivation methods (`query_normalized`, `read_snapshot`, `view_schema` do not
12//! exist here), so a host that cannot derive cannot express the call.
13
14use std::collections::HashMap;
15use std::path::Path;
16use std::rc::Rc;
17use std::sync::{Mutex, MutexGuard};
18
19use rindle_value::value::OwnedValue;
20use rusqlite::OptionalExtension;
21
22use rindle_writeplane::schema_envelope::validate_default_expression;
23use rindle_writeplane::table_shape::{
24 build_mutation_sql, create_table_ddl, quote_ident, ColType, Mutation, TableMeta,
25};
26use rindle_writeplane::{
27 writeplane, ForeignKeys, JournalMode, ReplicaError, SqlArgs, SqlStatementRequest,
28 StatementResult, CLIENT_MUTATIONS_TABLE, SOURCE_OFFSETS_TABLE, SOURCE_OFFSET_WHOLE_RUN,
29};
30
31use super::fanout::{CommitFanout, NoFanout};
32use super::store::{ApplyStore, DdlMigrationError, DEFAULT_WRITER_BEGIN_SQL};
33use super::txn::ApplyTxn;
34
35/// The pg-source type-metadata sidecar (`_rindle_columns`; pg-source `schema_map`, Amendment 2026-06-30).
36/// Present only on a **PG-mirror follower**, where the mirror `CREATE TABLE` is affinity-only
37/// (`INTEGER`/`TEXT`/`REAL`) and never marks a non-PK column `NOT NULL` — so the semantic type
38/// (`boolean`/`json`) and the nullability both live here, not in the declared type a `PRAGMA` can read.
39/// [`ApplyConsumer::register_existing_table`] consults it so `/schema` codegen is faithful (design 206).
40/// Named by value, not imported: the apply plane does not depend on `rindle-pg-source`, and the tokens are
41/// a persisted-file contract (they live in the follower's own SQLite), matched by string like the rows.
42const RINDLE_COLUMNS_TABLE: &str = "_rindle_columns";
43const RINDLE_TABLES_TABLE: &str = "_rindle_tables";
44
45/// Recover the client [`ColType`] from a `_rindle_columns` row's `(rindle_type, pg_type_tag)` tokens.
46///
47/// `rindle_type` (the source profile's `RindleType::label()`) splits `boolean`/`integer`/`float`/
48/// `string`/`json` (design 406 §11.1 writes `json` directly; a pre-406 sidecar reports a `json`/
49/// `jsonb` column as `string`, which the stable `pg_type_tag` still distinguishes). `integer` is
50/// the exact-i64 plane (design 226). Every other text-stored family (uuid, numeric, temporal,
51/// bytea-hex, text) surfaces as `String`, matching the engine's own value type. Unknown tokens
52/// fall through to `String`, the permissive default.
53fn coltype_from_sidecar(rindle_type: &str, pg_type_tag: &str) -> ColType {
54 match rindle_type {
55 "boolean" => ColType::Boolean,
56 "integer" => ColType::Int,
57 "float" | "number" => ColType::Number,
58 "json" => ColType::Json,
59 // "string" (and any unrecognized label): text-stored. json/jsonb are the one text-stored kind the
60 // client surfaces distinctly, recovered here from the type tag.
61 _ => match pg_type_tag {
62 "RINDLE_PG_JSON" | "RINDLE_PG_JSONB" => ColType::Json,
63 _ => ColType::String,
64 },
65 }
66}
67
68/// The `_rindle_source_offsets` DDL — one string, shared with the snapshot/restore
69/// stores that mint the table on a bare connection so it cannot drift from the
70/// consumer's own [`ensure_source_offsets_table`](ApplyConsumer::ensure_source_offsets_table).
71pub fn source_offsets_table_ddl() -> String {
72 format!(
73 "CREATE TABLE IF NOT EXISTS {SOURCE_OFFSETS_TABLE} \
74 (source TEXT PRIMARY KEY, offset TEXT NOT NULL, \
75 chunk_seq INTEGER NOT NULL DEFAULT {SOURCE_OFFSET_WHOLE_RUN}, run_id TEXT, \
76 batch_hash TEXT, run_rows INTEGER, rows_cum INTEGER, committed_at INTEGER)"
77 )
78}
79
80/// [`source_offsets_table_ddl`] applied to a caller-owned connection (the
81/// initial-snapshot store writes it before any consumer exists).
82pub fn ensure_source_offsets_table_on_conn(
83 conn: &rusqlite::Connection,
84) -> Result<(), ReplicaError> {
85 conn.execute_batch(&source_offsets_table_ddl())
86 .map_err(|error| ReplicaError::sqlite("create source offsets table", error))
87}
88
89/// Upsert the durable `(offset, chunk_seq)` checkpoint for `source` into `txn` (co-transactional
90/// with the effects that txn carries — a crash can never land data without its cursor, §4). Shared
91/// by the whole-batch ([`ApplyConsumer::commit_normalized_with_offset`]) and streaming-follower
92/// ([`ApplyConsumer::commit_follower_txn`]) commit paths. A whole-run commit passes
93/// [`SOURCE_OFFSET_WHOLE_RUN`]; a mid-run segment passes the boundary chunk's `chunk_seq`.
94/// `run_id` is the run's identity token from its `begin` frame (the fencing proof echoed on the
95/// next subscribe — RELAY-CURSOR-EPOCH-FENCING-DESIGN.md §2); `None` (a pre-fence upstream or a
96/// non-streaming source) stores NULL, which subscribes without a fence as before.
97pub fn upsert_source_offset(
98 txn: &mut ApplyTxn,
99 source: &str,
100 offset: &str,
101 chunk_seq: i64,
102 run_id: Option<&str>,
103) -> Result<(), ReplicaError> {
104 upsert_source_offset_hashed(txn, source, offset, chunk_seq, run_id, None)
105}
106
107/// [`upsert_source_offset`] carrying the §8.3 **batch identity**: the emitter-computed
108/// `batch_hash` of the run's exact bytes, stored beside the cursor so a same-offset
109/// resubmission with a different body is a loud error, never a silent dedup (T6). The
110/// hash-less paths (follower stream, plain change sources) write `NULL` — once the
111/// cursor advances, the previous offset's identity is dead anyway.
112pub fn upsert_source_offset_hashed(
113 txn: &mut ApplyTxn,
114 source: &str,
115 offset: &str,
116 chunk_seq: i64,
117 run_id: Option<&str>,
118 batch_hash: Option<&str>,
119) -> Result<(), ReplicaError> {
120 upsert_source_offset_full(txn, source, offset, chunk_seq, run_id, batch_hash, None)
121}
122
123fn upsert_source_offset_full(
124 txn: &mut ApplyTxn,
125 source: &str,
126 offset: &str,
127 chunk_seq: i64,
128 run_id: Option<&str>,
129 batch_hash: Option<&str>,
130 head: Option<SourceHead>,
131) -> Result<(), ReplicaError> {
132 let head = head.unwrap_or_default();
133 let counter = |name: &str, value: Option<u64>| -> Result<OwnedValue, ReplicaError> {
134 value
135 .map(|value| {
136 i64::try_from(value).map(OwnedValue::Int).map_err(|_| {
137 ReplicaError::Mutation(format!("source {name} exceeds SQLite INTEGER: {value}"))
138 })
139 })
140 .transpose()
141 .map(|value| value.unwrap_or(OwnedValue::Null))
142 };
143 let run_rows = counter("run_rows", head.run_rows)?;
144 let rows_cum = counter("rows_cum", head.rows_cum)?;
145 // `run_id` is COALESCE'd, not overwritten: an engine-written empty frame carries
146 // run_id=None but does not change the timeline, so it must not erase the run
147 // fence (RELAY-CURSOR-EPOCH-FENCING §2). A restore landing on an empty-cid head
148 // would otherwise clear the fence to NULL and resume unfenced. The accounting
149 // columns below are COALESCE'd for the same reason (preserve across empty advances).
150 txn.exec(
151 &format!(
152 "INSERT INTO {SOURCE_OFFSETS_TABLE} \
153 (source, offset, chunk_seq, run_id, batch_hash, run_rows, rows_cum, committed_at) \
154 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
155 ON CONFLICT(source) DO UPDATE SET offset = excluded.offset, \
156 chunk_seq = excluded.chunk_seq, \
157 run_id = COALESCE(excluded.run_id, {SOURCE_OFFSETS_TABLE}.run_id), \
158 batch_hash = excluded.batch_hash, \
159 run_rows = COALESCE(excluded.run_rows, {SOURCE_OFFSETS_TABLE}.run_rows), \
160 rows_cum = COALESCE(excluded.rows_cum, {SOURCE_OFFSETS_TABLE}.rows_cum), \
161 committed_at = COALESCE(excluded.committed_at, {SOURCE_OFFSETS_TABLE}.committed_at)"
162 ),
163 &[
164 OwnedValue::str(source),
165 OwnedValue::str(offset),
166 OwnedValue::Int(chunk_seq),
167 match run_id {
168 Some(run_id) => OwnedValue::str(run_id),
169 None => OwnedValue::Null,
170 },
171 batch_hash.map_or(OwnedValue::Null, OwnedValue::str),
172 run_rows,
173 rows_cum,
174 head.committed_at.map_or(OwnedValue::Null, OwnedValue::Int),
175 ],
176 )?;
177 Ok(())
178}
179
180/// The follower's DDL idempotency journal: one row per applied `ddl` entry, keyed by the migration
181/// id (or the entry offset when a source ships none). Unregistered bookkeeping like the offsets
182/// table — never captured/fanned. A crash-window replay (the `ddl` re-delivered before its cursor
183/// advanced) dedups against this BEFORE re-applying, so an already-applied reshape is skipped exactly
184/// rather than re-run-and-inferred-from-the-error (see [`ApplyStore::exec_ddl_with_marker`]).
185pub const APPLIED_DDL_TABLE: &str = "_rindle_applied_ddl";
186
187/// Persisted source accounting at the applied cursor. Portable bases carry this
188/// so lag/change-count stamps resume from restored history instead of zero.
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
190pub struct SourceHead {
191 pub run_rows: Option<u64>,
192 pub rows_cum: Option<u64>,
193 pub committed_at: Option<i64>,
194}
195
196/// The registered-table metadata for [`CLIENT_MUTATIONS_TABLE`] — one literal, shared by
197/// the headless enable and the live `ClusterConsumer`'s so the two can never drift.
198pub fn client_mutations_table_meta() -> TableMeta {
199 TableMeta {
200 columns: vec!["client_id".to_string(), "last_mutation_id".to_string()],
201 pk: vec![0],
202 col_types: vec![ColType::String, ColType::Number],
203 // Bookkeeping table (both columns NOT NULL); excluded from `base_table_schemas`.
204 nullable: vec![false, false],
205 }
206}
207
208/// The engine-free CDC apply consumer. `!Send` — lives on one thread, like the store
209/// under it. See the module docs; construction is [`open`](Self::open) (headless) or
210/// [`from_parts`](Self::from_parts) (a derivation host composing over a shared store).
211pub struct ApplyConsumer {
212 store: Rc<ApplyStore>,
213 fanout: Rc<dyn CommitFanout>,
214 tables: Mutex<HashMap<String, TableMeta>>,
215 /// What a running backfill hides (design 406 §10): tables and `(table, column)`s whose
216 /// sidecar row says `visible = 0`. `TableMeta` keeps every physical column so positional
217 /// mutations are unaffected; only the advertised schema is filtered.
218 hidden: Mutex<Hidden>,
219}
220
221/// The visibility sidecars' hidden set (406 §10).
222#[derive(Clone, Debug, Default, PartialEq, Eq)]
223pub struct Hidden {
224 pub tables: std::collections::BTreeSet<String>,
225 pub columns: std::collections::BTreeSet<(String, String)>,
226}
227
228/// Read the visibility sidecars. A store without them (the direct-SQLite topology, or a mirror
229/// minted before the bit existed) hides nothing.
230fn read_hidden(conn: &rusqlite::Connection) -> Result<Hidden, rusqlite::Error> {
231 let mut hidden = Hidden::default();
232 let has_tables_sidecar = conn
233 .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1")?
234 .exists([RINDLE_TABLES_TABLE])?;
235 if has_tables_sidecar {
236 let mut stmt = conn.prepare(&format!(
237 "SELECT \"table_name\" FROM {} WHERE \"visible\" = 0",
238 quote_ident(RINDLE_TABLES_TABLE)
239 ))?;
240 let mut rows = stmt.query([])?;
241 while let Some(row) = rows.next()? {
242 hidden.tables.insert(row.get(0)?);
243 }
244 }
245 let has_visible_bit = conn
246 .prepare(&format!(
247 "SELECT 1 FROM pragma_table_info({}) WHERE name = 'visible'",
248 quote_str(RINDLE_COLUMNS_TABLE)
249 ))?
250 .exists([])?;
251 if has_visible_bit {
252 let mut stmt = conn.prepare(&format!(
253 "SELECT \"table_name\", \"column_name\" FROM {} WHERE \"visible\" = 0",
254 quote_ident(RINDLE_COLUMNS_TABLE)
255 ))?;
256 let mut rows = stmt.query([])?;
257 while let Some(row) = rows.next()? {
258 hidden.columns.insert((row.get(0)?, row.get(1)?));
259 }
260 }
261 Ok(hidden)
262}
263
264fn quote_str(s: &str) -> String {
265 format!("'{}'", s.replace('\'', "''"))
266}
267
268impl ApplyConsumer {
269 /// Open a headless apply consumer over `path`. The caller owns the file's lifecycle.
270 /// A fresh file gets plain `wal` (design 306 D5) and writes open `BEGIN IMMEDIATE` —
271 /// exactly what `ClusterConsumer::open`'s defaults resolve to, so the headless
272 /// applier and the default live follower produce byte-identical stores.
273 ///
274 /// Foreign keys are **not enforced** ([`ForeignKeys::Unenforced`]), which is the apply
275 /// plane's posture and not a relaxation: the rows this consumer writes were validated
276 /// by the authority that produced the stream, they arrive in the stream's order rather
277 /// than a topological one, and the authority's `ON DELETE`/`ON UPDATE` actions are
278 /// already in the stream as ordinary row changes — re-running them here would apply
279 /// each cascade twice. [`rindle_writeplane::foreign_keys`] has the full argument, and
280 /// [`foreign_key_audit`](Self::foreign_key_audit) is how a host proves referential
281 /// integrity anyway. A host that really is the origin of its rows says so with
282 /// [`open_with`](Self::open_with).
283 pub fn open(path: &Path) -> Result<ApplyConsumer, ReplicaError> {
284 Self::open_with_journal(path, JournalMode::default())
285 }
286
287 /// [`open`](Self::open) with an explicit fresh-file [`JournalMode`] — a wal2-fleet
288 /// host passes [`JournalMode::Wal2`] (an existing wal/wal2 file keeps its mode).
289 pub fn open_with_journal(
290 path: &Path,
291 journal: JournalMode,
292 ) -> Result<ApplyConsumer, ReplicaError> {
293 Self::open_with(path, journal, ForeignKeys::Unenforced)
294 }
295
296 /// [`open_with_journal`](Self::open_with_journal) with an explicit [`ForeignKeys`]
297 /// posture — the escape hatch for a host that is the ORIGIN of the rows it writes
298 /// through this consumer rather than a replayer of somebody else's. Everything else
299 /// resolves exactly as [`open`](Self::open).
300 pub fn open_with(
301 path: &Path,
302 journal: JournalMode,
303 foreign_keys: ForeignKeys,
304 ) -> Result<ApplyConsumer, ReplicaError> {
305 let store = ApplyStore::open(path, journal, DEFAULT_WRITER_BEGIN_SQL, foreign_keys)?;
306 Ok(Self::from_parts(Rc::new(store), Rc::new(NoFanout)))
307 }
308
309 /// Open a headless consumer over a **scratch** database — single-connection,
310 /// `journal_mode=memory`, `synchronous=OFF`. See
311 /// [`ApplyStore::open_scratch`](crate::ApplyStore::open_scratch) for what that trades
312 /// and what the caller owes in return; the applied result is identical, only the crash
313 /// posture differs.
314 ///
315 /// A scratch is by definition DERIVED from an authority that can rebuild it, so it is
316 /// an apply-plane store and opens [`ForeignKeys::Unenforced`] like [`open`](Self::open).
317 pub fn open_scratch(path: &Path) -> Result<ApplyConsumer, ReplicaError> {
318 let store =
319 ApplyStore::open_scratch(path, DEFAULT_WRITER_BEGIN_SQL, ForeignKeys::Unenforced)?;
320 Ok(Self::from_parts(Rc::new(store), Rc::new(NoFanout)))
321 }
322
323 /// Walk every declared foreign key and report violating rows — the opt-in audit that
324 /// stands in for enforcement on this store. Delegates to
325 /// [`ApplyStore::foreign_key_audit`](crate::ApplyStore::foreign_key_audit); see there
326 /// for the cost and the cap.
327 pub fn foreign_key_audit(
328 &self,
329 max_rows: usize,
330 ) -> Result<rindle_writeplane::ForeignKeyAudit, ReplicaError> {
331 self.store.foreign_key_audit(max_rows)
332 }
333
334 /// Compose a consumer over an existing store + fan-out — the seam a derivation host
335 /// (`rindle_replica::ClusterConsumer`) uses to share ONE store between its cluster
336 /// and its apply surface.
337 pub fn from_parts(store: Rc<ApplyStore>, fanout: Rc<dyn CommitFanout>) -> ApplyConsumer {
338 let hidden = store.read(read_hidden).unwrap_or_default();
339 ApplyConsumer {
340 store,
341 fanout,
342 tables: Mutex::new(HashMap::new()),
343 hidden: Mutex::new(hidden),
344 }
345 }
346
347 /// Re-read the visibility sidecars (after a `ddl` entry, design 406 §9 step 6). `true` when
348 /// the hidden set changed — the caller bounces so the advertised schema moves with it.
349 pub fn refresh_visibility(&self) -> Result<bool, ReplicaError> {
350 let fresh = self.store.read(read_hidden)?;
351 let mut hidden = self.hidden.lock().unwrap();
352 let changed = *hidden != fresh;
353 *hidden = fresh;
354 Ok(changed)
355 }
356
357 /// The current hidden set (406 §10).
358 pub fn hidden(&self) -> Hidden {
359 self.hidden.lock().unwrap().clone()
360 }
361
362 /// The underlying store (controlled writes, ad-hoc reads, DDL, checkpoints).
363 pub fn store(&self) -> &ApplyStore {
364 &self.store
365 }
366
367 /// The registered-table metadata registry — one entry per table this consumer can
368 /// apply into, exactly what [`apply_muts`](Self::apply_muts) builds its SQL from. A
369 /// derivation host reads and extends it (its lifecycle tables carry meta too); most
370 /// callers never touch it directly.
371 pub fn tables(&self) -> MutexGuard<'_, HashMap<String, TableMeta>> {
372 self.tables.lock().unwrap()
373 }
374
375 /// Open one write transaction on the store (the follower's streaming apply drives
376 /// [`apply_muts`](Self::apply_muts) into it per chunk frame, then
377 /// [`commit_follower_txn_with_head`](Self::commit_follower_txn_with_head)).
378 pub fn begin(&self) -> Result<ApplyTxn, ReplicaError> {
379 ApplyTxn::begin(self.store.clone(), self.fanout.clone())
380 }
381
382 /// Define + register a base table (`CREATE TABLE` + capture registration). Idempotent.
383 pub fn register_table(
384 &self,
385 table: &str,
386 columns: &[String],
387 pk: &[usize],
388 col_types: &[ColType],
389 ) -> Result<(), ReplicaError> {
390 self.register_table_via(table, columns, pk, col_types, || {
391 self.store.register_table_capture(table).map(|_| ())
392 })
393 }
394
395 /// [`register_table`](Self::register_table) with the registration step swapped out —
396 /// the derivation-host seam: `ClusterConsumer` passes its engine-inclusive
397 /// `Cluster::register_table` so the capture half AND the worker sources build in the
398 /// one place they always did, while the DDL and the meta bookkeeping stay here.
399 pub fn register_table_via(
400 &self,
401 table: &str,
402 columns: &[String],
403 pk: &[usize],
404 col_types: &[ColType],
405 register: impl FnOnce() -> Result<(), ReplicaError>,
406 ) -> Result<(), ReplicaError> {
407 if self.tables.lock().unwrap().contains_key(table) {
408 return Ok(());
409 }
410 let ddl = create_table_ddl(table, columns, pk, col_types);
411 self.store.exec_ddl(&ddl)?;
412 register()?;
413 self.tables.lock().unwrap().insert(
414 table.to_string(),
415 TableMeta {
416 columns: columns.to_vec(),
417 pk: pk.to_vec(),
418 col_types: col_types.to_vec(),
419 // Typed registration leaves ordinary fields nullable, while PK fields are emitted
420 // `NOT NULL` because every replicated row must have stable identity.
421 nullable: (0..columns.len()).map(|i| !pk.contains(&i)).collect(),
422 },
423 );
424 Ok(())
425 }
426
427 /// Register an EXISTING base table (already created by the host's own DDL): reads its
428 /// column order + pk from SQLite and registers it for capture (a derivation host adds
429 /// its sources through the `_via` seam).
430 pub fn register_existing_table(&self, table: &str) -> Result<(), ReplicaError> {
431 self.register_existing_table_via(table, || {
432 self.store.register_table_capture(table).map(|_| ())
433 })
434 }
435
436 /// [`register_existing_table`](Self::register_existing_table) with the registration
437 /// step swapped out (see [`register_table_via`](Self::register_table_via)).
438 pub fn register_existing_table_via(
439 &self,
440 table: &str,
441 register: impl FnOnce() -> Result<(), ReplicaError>,
442 ) -> Result<(), ReplicaError> {
443 if self.tables.lock().unwrap().contains_key(table) {
444 return Ok(());
445 }
446 let meta = self.store.read(|conn| {
447 let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", quote_ident(table)))?;
448 let mut columns = Vec::new();
449 let mut col_types = Vec::new();
450 let mut nullable = Vec::new();
451 let mut pk_pos: Vec<(i64, usize)> = Vec::new();
452 let mut rows = stmt.query([])?;
453 let mut defaults: Vec<Option<String>> = Vec::new();
454 while let Some(row) = rows.next()? {
455 let name: String = row.get(1)?;
456 // PRAGMA table_info column 2 is the declared type; ColType::from_sqlite_decl
457 // recovers BOOLEAN/JSON from the declared name, else falls back to affinity.
458 let decl: String = row.get(2)?;
459 // Column 3 is `notnull` (0 ⇒ nullable) — carried for `/schema` codegen (design 206);
460 // the same bit table introspection reads into the engine's `ColumnDef.optional`.
461 let notnull: i64 = row.get(3)?;
462 // Column 4 is `dflt_value` — read so exact-i64 columns get default
463 // admission (below); other columns keep this surface's permissiveness.
464 defaults.push(row.get(4)?);
465 let pk_rank: i64 = row.get(5)?;
466 if pk_rank > 0 {
467 pk_pos.push((pk_rank, columns.len()));
468 }
469 columns.push(name);
470 col_types.push(ColType::from_sqlite_decl(&decl));
471 nullable.push(notnull == 0);
472 }
473 pk_pos.sort();
474
475 // PG-mirror topology: if the source minted a `_rindle_columns` sidecar, it carries the semantic
476 // type + nullability the affinity-only mirror DDL drops (pg-source Amendment 2026-06-30).
477 // Override the PRAGMA-derived facts per column so `/schema` codegen emits `boolean()`/`json()`
478 // and `.nullable()` faithfully instead of collapsing to `number`/`string` and the mirror's
479 // permissive all-nullable. Absent (the direct-SQLite topology) ⇒ the PRAGMA facts stand — the
480 // app's own migration DDL already carries the real types + `NOT NULL` (design 206).
481 let has_sidecar = conn
482 .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1")?
483 .exists([RINDLE_COLUMNS_TABLE])?;
484 if has_sidecar {
485 let mut by_name: HashMap<String, (ColType, bool)> = HashMap::new();
486 let mut sc_stmt = conn.prepare(&format!(
487 "SELECT \"column_name\", \"rindle_type\", \"pg_type_tag\", \"not_null\" \
488 FROM {} WHERE \"table_name\" = ?1",
489 quote_ident(RINDLE_COLUMNS_TABLE)
490 ))?;
491 let mut sc_rows = sc_stmt.query([table])?;
492 while let Some(row) = sc_rows.next()? {
493 let cname: String = row.get(0)?;
494 let rtype: String = row.get(1)?;
495 let tag: String = row.get(2)?;
496 let not_null: i64 = row.get(3)?;
497 by_name.insert(cname, (coltype_from_sidecar(&rtype, &tag), not_null == 0));
498 }
499 for (i, name) in columns.iter().enumerate() {
500 if let Some(&(ty, is_nullable)) = by_name.get(name) {
501 col_types[i] = ty;
502 nullable[i] = is_nullable;
503 }
504 }
505 }
506
507 // Exact-i64 columns whose DDL carries a default (post-sidecar types): these
508 // get the same default admission the master's envelope runs, below.
509 let int_defaults: Vec<(String, String)> = columns
510 .iter()
511 .zip(&col_types)
512 .zip(&defaults)
513 .filter(|((_, ty), _)| **ty == ColType::Int)
514 .filter_map(|((name, _), d)| d.clone().map(|d| (name.clone(), d)))
515 .collect();
516
517 Ok((
518 TableMeta {
519 columns,
520 pk: pk_pos.into_iter().map(|(_, i)| i).collect(),
521 col_types,
522 nullable,
523 },
524 int_defaults,
525 ))
526 })?;
527 let (meta, int_defaults) = meta;
528 if meta.columns.is_empty() {
529 return Err(ReplicaError::Schema(format!("unknown table: {table}")));
530 }
531 // Design 226 §4.1 on the embedded/napi surface too: an exact-i64 column's
532 // default must evaluate INTEGER-or-NULL and be time-invariant. Without this,
533 // an off-plane default (`BIGINT DEFAULT 2.5`) registers fine and every later
534 // omitted-column insert fails at capture with a storage-class message that
535 // never names the default — refuse at admission, like the master's envelope.
536 if !int_defaults.is_empty() {
537 self.store.read(|conn| {
538 Ok(int_defaults.iter().try_for_each(|(column, default_sql)| {
539 validate_default_expression(
540 conn,
541 table,
542 column,
543 default_sql,
544 rindle_value::value::ValueType::Int,
545 )
546 }))
547 })??;
548 }
549 register()?;
550 self.tables.lock().unwrap().insert(table.to_string(), meta);
551 Ok(())
552 }
553
554 /// One-time setup for client mutations on a headless applier: the
555 /// `_rindle_client_mutations` (and room-scoped) ledger tables are created and the
556 /// former registered for capture — its rows are **replicated data** on the master's
557 /// stream, so an applier that skips this stalls on the first lmid row with
558 /// `unknown table`. The meta joins the table map like any table's.
559 pub fn enable_client_mutations(&self) -> Result<(), ReplicaError> {
560 self.enable_client_mutations_via(|| {
561 self.store.create_client_mutations_tables()?;
562 self.store
563 .register_table_capture(CLIENT_MUTATIONS_TABLE)
564 .map(|_| ())
565 })
566 }
567
568 /// [`enable_client_mutations`](Self::enable_client_mutations) with the DDL +
569 /// registration step swapped out — `ClusterConsumer` passes
570 /// `Cluster::enable_client_mutations` (the same DDL plus engine hosting) so the meta
571 /// bookkeeping stays here either way.
572 pub fn enable_client_mutations_via(
573 &self,
574 enable: impl FnOnce() -> Result<(), ReplicaError>,
575 ) -> Result<(), ReplicaError> {
576 enable()?;
577 self.tables.lock().unwrap().insert(
578 CLIENT_MUTATIONS_TABLE.to_string(),
579 client_mutations_table_meta(),
580 );
581 Ok(())
582 }
583
584 /// The registered **base** tables' schemas (name + ordered columns/types + PK names), sorted by
585 /// name, for client-schema codegen via `/schema` (DRIZZLE-MIGRATIONS-DESIGN.md §6.2). Excludes
586 /// the daemon's own bookkeeping/internal tables — `_rindle_*` (e.g. `_rindle_client_mutations`),
587 /// `__*` (e.g. the `__replica_meta` commit watermark), and `sqlite_*` — which must stay invisible
588 /// to the client schema just as they are to CDC + planning (rindle-cdc skips `sqlite_*` + `__*`).
589 /// Reads straight from the in-memory table map — the live introspected schema, no DB round-trip.
590 pub fn base_table_schemas(&self) -> Vec<rindle_writeplane::BaseTableSchema> {
591 let tables = self.tables.lock().unwrap();
592 let hidden = self.hidden.lock().unwrap();
593 let mut out: Vec<rindle_writeplane::BaseTableSchema> = tables
594 .iter()
595 .filter(|(name, _)| {
596 !name.starts_with("_rindle_")
597 && !name.starts_with("__")
598 && !name.starts_with("sqlite_")
599 && !hidden.tables.contains(*name)
600 })
601 .map(|(name, meta)| {
602 let pk_set: std::collections::BTreeSet<usize> = meta.pk.iter().copied().collect();
603 let columns = meta
604 .columns
605 .iter()
606 .zip(&meta.col_types)
607 .zip(&meta.nullable)
608 .enumerate()
609 // A column mid-backfill is not advertised (406 §10); the mirror still
610 // holds it positionally.
611 .filter(|(_, ((column, _), _))| {
612 !hidden
613 .columns
614 .contains(&((*name).clone(), (*column).clone()))
615 })
616 .map(
617 |(i, ((name, &ty), &nullable))| rindle_writeplane::BaseColumn {
618 name: name.clone(),
619 ty,
620 // A PK column is row identity — never null, whatever the DDL says (SQLite
621 // doesn't enforce NOT NULL on rowid-table PKs), so its type stays `T`.
622 nullable: nullable && !pk_set.contains(&i),
623 },
624 )
625 .collect();
626 let primary_key = meta.pk.iter().map(|&i| meta.columns[i].clone()).collect();
627 rindle_writeplane::BaseTableSchema {
628 name: (*name).clone(),
629 columns,
630 primary_key,
631 }
632 })
633 .collect();
634 out.sort_by(|a, b| a.name.cmp(&b.name));
635 out
636 }
637
638 /// Apply positional mutations to an open write transaction (build_sql per row).
639 pub fn apply_muts(&self, txn: &mut ApplyTxn, muts: &[Mutation]) -> Result<(), ReplicaError> {
640 let tables = self.tables.lock().unwrap();
641 for m in muts {
642 let table = m.table();
643 let meta = tables
644 .get(table)
645 .ok_or_else(|| ReplicaError::Schema(format!("unknown table: {table}")))?;
646 let (sql, params) = build_mutation_sql(&meta.columns, &meta.pk, m)?;
647 txn.exec(&sql, ¶ms)?;
648 }
649 Ok(())
650 }
651
652 /// Apply a batch of positional mutations as one **raw foreign write** (no `lmid`,
653 /// confirms nothing), returning the commit version synchronously.
654 pub fn commit_normalized(&self, muts: &[Mutation]) -> Result<u64, ReplicaError> {
655 let mut txn = self.begin()?;
656 self.apply_muts(&mut txn, muts)?;
657 let info = txn.commit_with_info()?;
658 Ok(info.tx_id.0)
659 }
660
661 /// Apply a change-source batch AND advance the source's durable cursor in ONE write txn
662 /// (CHANGE-SOURCE-DESIGN.md §4). The `_rindle_source_offsets` upsert rides the same
663 /// transaction as the effects — exactly the `upsert_lmid` discipline — so a crash can
664 /// never commit the data without the cursor (or vice-versa). The caller owns the
665 /// monotonic-absorb dedup (`offset <= stored` ⇒ skip) BEFORE calling this; there is no
666 /// gap rejection (the source owns contiguity, §4).
667 pub fn commit_normalized_with_offset(
668 &self,
669 muts: &[Mutation],
670 source: &str,
671 offset: &str,
672 chunk_seq: i64,
673 run_id: Option<&str>,
674 ) -> Result<u64, ReplicaError> {
675 let mut txn = self.begin()?;
676 self.apply_muts(&mut txn, muts)?;
677 upsert_source_offset(&mut txn, source, offset, chunk_seq, run_id)?;
678 let info = txn.commit_with_info()?;
679 Ok(info.tx_id.0)
680 }
681
682 /// Terminal step of the **streaming-follower apply** (`REPLICATOR-PRECOMMIT-STREAMING-DESIGN.md`
683 /// §7): the caller has opened ONE [`ApplyTxn`](super::ApplyTxn) via [`begin`](Self::begin) and driven
684 /// [`apply_muts`](Self::apply_muts) into it once per `chunk` frame; this upserts the
685 /// source cursor in that SAME open txn (co-transactional with the chunk applies — a crash can
686 /// never commit the data without the cursor) and commits, returning the commit version. It is
687 /// exactly [`commit_normalized_with_offset`](Self::commit_normalized_with_offset)'s cursor
688 /// discipline, but with the row-changes already applied incrementally as chunks arrived rather
689 /// than handed over as one batch.
690 pub fn commit_follower_txn(
691 &self,
692 mut txn: ApplyTxn,
693 source: &str,
694 offset: &str,
695 chunk_seq: i64,
696 run_id: Option<&str>,
697 ) -> Result<u64, ReplicaError> {
698 upsert_source_offset(&mut txn, source, offset, chunk_seq, run_id)?;
699 let info = txn.commit_with_info()?;
700 Ok(info.tx_id.0)
701 }
702
703 /// [`commit_follower_txn`](Self::commit_follower_txn) plus the frame's durable row-count/commit stamps.
704 /// Effects, cursor, run fence, and head accounting land in one transaction.
705 pub fn commit_follower_txn_with_head(
706 &self,
707 mut txn: ApplyTxn,
708 source: &str,
709 offset: &str,
710 chunk_seq: i64,
711 run_id: Option<&str>,
712 head: SourceHead,
713 ) -> Result<u64, ReplicaError> {
714 upsert_source_offset_full(
715 &mut txn,
716 source,
717 offset,
718 chunk_seq,
719 run_id,
720 None,
721 Some(head),
722 )?;
723 let info = txn.commit_with_info()?;
724 Ok(info.tx_id.0)
725 }
726
727 /// Advance only a CDC transport locator at an unchanged semantic cursor.
728 ///
729 /// The source-offset table is deliberately unregistered, so this empty
730 /// application commit emits no IVM row delta. The compare-and-swap keeps a
731 /// stale connection from replacing a newer locator, and the update remains
732 /// a real SQLite transaction so a portable image observes either locator
733 /// in full, never torn metadata.
734 pub fn refresh_follower_run_id(
735 &self,
736 source: &str,
737 offset: &str,
738 previous_run_id: &str,
739 next_run_id: &str,
740 ) -> Result<u64, ReplicaError> {
741 let mut txn = self.begin()?;
742 let changed = txn.exec(
743 &format!(
744 "UPDATE {SOURCE_OFFSETS_TABLE} SET run_id = ?1 \
745 WHERE source = ?2 AND offset = ?3 AND chunk_seq = ?4 AND run_id = ?5"
746 ),
747 &[
748 OwnedValue::str(next_run_id),
749 OwnedValue::str(source),
750 OwnedValue::str(offset),
751 OwnedValue::Int(SOURCE_OFFSET_WHOLE_RUN),
752 OwnedValue::str(previous_run_id),
753 ],
754 )?;
755 if changed != 1 {
756 return Err(ReplicaError::Mutation(format!(
757 "CDC locator refresh lost its checkpoint compare-and-swap for source {source:?}"
758 )));
759 }
760 let info = txn.commit_with_info()?;
761 Ok(info.tx_id.0)
762 }
763
764 /// Create the `_rindle_source_offsets` bookkeeping table (idempotent). Not registered for
765 /// capture — daemon metadata, like `_rindle_sql_outcomes`.
766 pub fn ensure_source_offsets_table(&self) -> Result<(), ReplicaError> {
767 self.store.exec_ddl(&source_offsets_table_ddl())?;
768 // Idempotent forward-migration for a pre-`chunk_seq` table (RELAY-DDL §6.6): add the
769 // column with the whole-run default so every cursor written before this slice reads back
770 // as a run-boundary checkpoint (which it was). `ADD COLUMN` is a metadata-only change (the
771 // PK is unchanged), so no table rebuild is needed — unlike the relay's `_rindle_change_log`.
772 let cols = self.store.read(|conn| {
773 conn.prepare(&format!("PRAGMA table_info({SOURCE_OFFSETS_TABLE})"))?
774 .query_map([], |r| r.get::<_, String>(1))?
775 .collect::<rusqlite::Result<Vec<String>>>()
776 })?;
777 if !cols.iter().any(|c| c == "chunk_seq") {
778 self.store.exec_ddl(&format!(
779 "ALTER TABLE {SOURCE_OFFSETS_TABLE} \
780 ADD COLUMN chunk_seq INTEGER NOT NULL DEFAULT {SOURCE_OFFSET_WHOLE_RUN}"
781 ))?;
782 }
783 // Same idempotent forward-migration for the pre-fence table: the run identity token
784 // (RELAY-CURSOR-EPOCH-FENCING-DESIGN.md §2), nullable — a NULL cursor row subscribes
785 // without a fence, exactly the pre-upgrade behavior.
786 if !cols.iter().any(|c| c == "run_id") {
787 self.store.exec_ddl(&format!(
788 "ALTER TABLE {SOURCE_OFFSETS_TABLE} ADD COLUMN run_id TEXT"
789 ))?;
790 }
791 // The §8.3 batch-identity column (nullable — only hash-bearing sources,
792 // i.e. room flushes, populate it). Same metadata-only forward-migration.
793 if !cols.iter().any(|c| c == "batch_hash") {
794 self.store.exec_ddl(&format!(
795 "ALTER TABLE {SOURCE_OFFSETS_TABLE} ADD COLUMN batch_hash TEXT"
796 ))?;
797 }
798 for column in ["run_rows", "rows_cum", "committed_at"] {
799 if !cols.iter().any(|existing| existing == column) {
800 self.store.exec_ddl(&format!(
801 "ALTER TABLE {SOURCE_OFFSETS_TABLE} ADD COLUMN {column} INTEGER"
802 ))?;
803 }
804 }
805 Ok(())
806 }
807
808 /// The durably-stored `(offset, chunk_seq, run_id)` checkpoint for `source` (`None` ⇒ never
809 /// applied; the caller treats that as the genesis `""` and subscribes from the start).
810 /// `chunk_seq` is [`SOURCE_OFFSET_WHOLE_RUN`] for a whole-run checkpoint (the common case) or a
811 /// real within-run ordinal for a mid-run segment left by the commit-at-DDL-boundary follower
812 /// (§6.6). The resume/dedup keyset is `(offset, chunk_seq)`; `run_id` is the checkpointed run's
813 /// identity token, echoed on the subscribe as the fencing proof
814 /// (RELAY-CURSOR-EPOCH-FENCING-DESIGN.md §2) — `None` for a pre-fence checkpoint.
815 #[allow(clippy::type_complexity)]
816 pub fn source_checkpoint(
817 &self,
818 source: &str,
819 ) -> Result<Option<(String, i64, Option<String>)>, ReplicaError> {
820 self.store.read(|conn| {
821 conn.query_row(
822 &format!(
823 "SELECT offset, chunk_seq, run_id FROM {SOURCE_OFFSETS_TABLE} \
824 WHERE source = ?1"
825 ),
826 [source],
827 |r| {
828 Ok((
829 r.get::<_, String>(0)?,
830 r.get::<_, i64>(1)?,
831 r.get::<_, Option<String>>(2)?,
832 ))
833 },
834 )
835 .optional()
836 })
837 }
838
839 /// Persisted accounting carried beside the source checkpoint.
840 pub fn source_head(&self, source: &str) -> Result<Option<SourceHead>, ReplicaError> {
841 let stored = self.store.read(|conn| {
842 conn.query_row(
843 &format!(
844 "SELECT run_rows, rows_cum, committed_at \
845 FROM {SOURCE_OFFSETS_TABLE} WHERE source = ?1"
846 ),
847 [source],
848 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
849 )
850 .optional()
851 })?;
852 let Some((run_rows, rows_cum, committed_at)) = stored else {
853 return Ok(None);
854 };
855 let counter = |name: &str, value: Option<i64>| -> Result<Option<u64>, ReplicaError> {
856 value
857 .map(|value| {
858 u64::try_from(value).map_err(|_| {
859 ReplicaError::Mutation(format!("stored source {name} is negative: {value}"))
860 })
861 })
862 .transpose()
863 };
864 Ok(Some(SourceHead {
865 run_rows: counter("run_rows", run_rows)?,
866 rows_cum: counter("rows_cum", rows_cum)?,
867 committed_at,
868 }))
869 }
870
871 /// The stored §8.3 batch identity for `source`'s checkpoint (`None` = no row, or a
872 /// hash-less source). Compared — never recomputed — against a resubmission's
873 /// declared hash at the exact stored offset.
874 pub fn source_checkpoint_hash(&self, source: &str) -> Result<Option<String>, ReplicaError> {
875 self.store.read(|conn| {
876 conn.query_row(
877 &format!("SELECT batch_hash FROM {SOURCE_OFFSETS_TABLE} WHERE source = ?1"),
878 [source],
879 |r| r.get::<_, Option<String>>(0),
880 )
881 .optional()
882 .map(Option::flatten)
883 })
884 }
885
886 /// The durably-stored cursor string for `source`, discarding the `chunk_seq` sub-position — for
887 /// the string-only callers (snapshot-restore resume points, which are always run boundaries). The
888 /// resume/dedup paths use [`source_checkpoint`](Self::source_checkpoint) for the full keyset.
889 pub fn source_offset(&self, source: &str) -> Result<Option<String>, ReplicaError> {
890 Ok(self.source_checkpoint(source)?.map(|(offset, _, _)| offset))
891 }
892
893 /// Create the `_rindle_producer_offsets` foreign-write watermark table (idempotent). The DDL
894 /// is the shared one, so this cannot drift from the write-master's or the restore's copy.
895 /// Callers register it for capture afterwards — it is replicated data, not host bookkeeping
896 /// (design 306 §3.3).
897 pub fn ensure_producer_offsets_table(&self) -> Result<(), ReplicaError> {
898 self.store.exec_ddl(&writeplane::producer_offsets_ddl())
899 }
900
901 /// Create the `_rindle_applied_ddl` idempotency journal (idempotent). Not registered for capture —
902 /// daemon metadata, like `_rindle_source_offsets`. `actions` holds the entry's ordered apply
903 /// report (design 227 fourth review pass), written in the same transaction as the marker.
904 pub fn ensure_applied_ddl_table(&self) -> Result<(), ReplicaError> {
905 self.store.exec_ddl(&format!(
906 "CREATE TABLE IF NOT EXISTS {APPLIED_DDL_TABLE} \
907 (key TEXT PRIMARY KEY, applied_at INTEGER NOT NULL DEFAULT (unixepoch()), \
908 actions TEXT)"
909 ))?;
910 // Additive upgrade for pre-report files: markers written before the column existed keep
911 // NULL actions and degrade to the caller's end-state fallback on replay.
912 let has_actions = self.store.read(|conn| {
913 let mut stmt = conn.prepare(&format!(
914 "SELECT name FROM pragma_table_info('{APPLIED_DDL_TABLE}')"
915 ))?;
916 let names = stmt
917 .query_map([], |row| row.get::<_, String>(0))?
918 .collect::<rusqlite::Result<Vec<String>>>()?;
919 Ok(names.iter().any(|name| name == "actions"))
920 })?;
921 if !has_actions {
922 self.store.exec_ddl(&format!(
923 "ALTER TABLE {APPLIED_DDL_TABLE} ADD COLUMN actions TEXT"
924 ))?;
925 }
926 Ok(())
927 }
928
929 /// Whether a `ddl` entry keyed by `key` (migration id / offset) is already journaled — the
930 /// crash-window-replay dedup, checked BEFORE re-applying.
931 pub fn ddl_already_applied(&self, key: &str) -> Result<bool, ReplicaError> {
932 self.store.read(|conn| {
933 conn.query_row(
934 &format!("SELECT 1 FROM {APPLIED_DDL_TABLE} WHERE key = ?1"),
935 [key],
936 |_| Ok(()),
937 )
938 .optional()
939 .map(|row| row.is_some())
940 })
941 }
942
943 /// Apply a `ddl` entry's `statements` and journal its `key` atomically — one ordinary
944 /// transaction, since DDL can't ride the `BEGIN CONCURRENT` cursor advance. Delegates to
945 /// [`ApplyStore::exec_ddl_with_marker`] against the [`APPLIED_DDL_TABLE`] journal.
946 pub fn apply_ddl_with_marker(
947 &self,
948 key: &str,
949 statements: &[String],
950 ) -> Result<rindle_writeplane::DdlApplyReport, ReplicaError> {
951 self.store
952 .exec_ddl_with_marker(APPLIED_DDL_TABLE, key, statements)
953 }
954
955 /// Apply DDL, caller-owned per-statement bookkeeping effects, and the durable marker in one
956 /// transaction. See [`ApplyStore::exec_ddl_with_marker_and_step_effects`].
957 pub fn apply_ddl_with_marker_and_step_effects<F>(
958 &self,
959 key: &str,
960 statements: &[String],
961 apply_step_effects: F,
962 ) -> Result<rindle_writeplane::DdlApplyReport, ReplicaError>
963 where
964 F: FnMut(&rusqlite::Connection, &rindle_writeplane::DdlStep) -> rusqlite::Result<()>,
965 {
966 self.store.exec_ddl_with_marker_and_step_effects(
967 APPLIED_DDL_TABLE,
968 key,
969 statements,
970 apply_step_effects,
971 )
972 }
973
974 /// The ordered apply report persisted with `key`'s marker (same transaction as the DDL), or
975 /// `None` for entries marked before the report column existed (they degrade to the caller's
976 /// end-state fallback). A replay consumes this instead of re-observing — the DDL does not
977 /// re-run, so there is nothing to observe (design 227 fourth review pass).
978 pub fn stored_ddl_report(
979 &self,
980 key: &str,
981 ) -> Result<Option<rindle_writeplane::DdlApplyReport>, ReplicaError> {
982 let json: Option<Option<String>> = self.store.read(|conn| {
983 conn.query_row(
984 &format!("SELECT actions FROM {APPLIED_DDL_TABLE} WHERE key = ?1"),
985 [key],
986 |row| row.get::<_, Option<String>>(0),
987 )
988 .optional()
989 })?;
990 Ok(json
991 .flatten()
992 .and_then(|json| serde_json::from_str(&json).ok()))
993 }
994
995 /// Fresh standalone public DDL: schema + desired-index effects + exact replay outcome + TxId
996 /// watermark commit as one SQLite atom.
997 #[allow(clippy::too_many_arguments)]
998 pub fn apply_public_ddl_operation<F>(
999 &self,
1000 statement: &SqlStatementRequest,
1001 declared_tables: &[String],
1002 outcome_key: &str,
1003 request_identity: &str,
1004 result_byte_limit: usize,
1005 now_ms: i64,
1006 apply_step_effects: F,
1007 ) -> Result<
1008 (
1009 writeplane::PublicOperationCommit,
1010 rindle_writeplane::DdlApplyReport,
1011 ),
1012 DdlMigrationError,
1013 >
1014 where
1015 F: FnMut(&rusqlite::Connection, &rindle_writeplane::DdlStep) -> rusqlite::Result<()>,
1016 {
1017 let (results, report, tx_id) = self.store.exec_public_ddl_unit(
1018 std::slice::from_ref(statement),
1019 declared_tables,
1020 "DDL operations may not create or mutate table rows",
1021 apply_step_effects,
1022 |conn, tx_id, results| {
1023 let wire = results
1024 .iter()
1025 .map(StatementResult::to_wire_json)
1026 .collect::<Result<Vec<_>, _>>()?;
1027 let stored = serde_json::to_string(&wire)
1028 .map_err(|error| writeplane::BookkeepingError::Wire(error.to_string()))?;
1029 if stored.len() > result_byte_limit {
1030 return Err(writeplane::sql_result_limit_error());
1031 }
1032 let cursor = format!("w:{:016x}", tx_id.0);
1033 writeplane::insert_sql_outcome_with_cursor(
1034 conn,
1035 outcome_key,
1036 &stored,
1037 None,
1038 Some(request_identity),
1039 Some(&cursor),
1040 now_ms,
1041 )
1042 },
1043 )?;
1044 let wire = results
1045 .iter()
1046 .map(StatementResult::to_wire_json)
1047 .collect::<Result<Vec<_>, _>>()
1048 .map_err(writeplane::BookkeepingError::from)?;
1049 Ok((
1050 writeplane::PublicOperationCommit {
1051 results: wire,
1052 cursor: Some(format!("w:{:016x}", tx_id.0)),
1053 },
1054 report,
1055 ))
1056 }
1057
1058 /// Fresh standalone DDL migration, with its permanent identity row and exact TxId cursor
1059 /// committed in the checked DDL transaction rather than backfilled afterward. Both front
1060 /// doors — the public `/v1/sql/migrate` route and the private deploy route — apply through
1061 /// this one primitive so they mint identical journal rows and either can absorb the other's
1062 /// replay; only the opaque checksum is optional (the deploy surface accepts checksum-less
1063 /// DDL files, whose identity is the statement vector alone).
1064 #[allow(clippy::too_many_arguments)]
1065 pub fn apply_public_ddl_migration<F>(
1066 &self,
1067 id: &str,
1068 supplied_checksum: Option<&str>,
1069 content_checksum: &str,
1070 normalized: &[String],
1071 identity_json: &str,
1072 declared_tables: &[String],
1073 now_ms: i64,
1074 apply_step_effects: F,
1075 ) -> Result<(String, rindle_writeplane::DdlApplyReport), DdlMigrationError>
1076 where
1077 F: FnMut(&rusqlite::Connection, &rindle_writeplane::DdlStep) -> rusqlite::Result<()>,
1078 {
1079 let requests = normalized
1080 .iter()
1081 .map(|sql| SqlStatementRequest {
1082 sql: sql.clone(),
1083 args: SqlArgs::default(),
1084 want_rows: false,
1085 })
1086 .collect::<Vec<_>>();
1087 let (_results, report, tx_id) = self.store.exec_public_ddl_unit(
1088 &requests,
1089 declared_tables,
1090 "migrations may not create or mutate table rows",
1091 apply_step_effects,
1092 |conn, tx_id, _| {
1093 let cursor = format!("w:{:016x}", tx_id.0);
1094 writeplane::journal_migration_with_cursor(
1095 conn,
1096 id,
1097 supplied_checksum,
1098 content_checksum,
1099 None,
1100 identity_json,
1101 Some(&cursor),
1102 now_ms,
1103 )
1104 },
1105 )?;
1106 Ok((format!("w:{:016x}", tx_id.0), report))
1107 }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::{coltype_from_sidecar, ColType};
1113
1114 #[test]
1115 fn coltype_from_sidecar_maps_rindle_type_then_json_by_tag() {
1116 // boolean/integer/float come straight off `rindle_type`; the tag is only consulted to split JSON
1117 // out of the text-stored `rindle_type = "string"` (the one kind `rindle_type` collapses).
1118 assert_eq!(
1119 coltype_from_sidecar("boolean", "RINDLE_PG_BOOL"),
1120 ColType::Boolean
1121 );
1122 // `integer` is the exact-i64 plane (design 226 / 406 §11.1); `float`/legacy `number` are f64.
1123 assert_eq!(
1124 coltype_from_sidecar("integer", "RINDLE_PG_INT4"),
1125 ColType::Int
1126 );
1127 assert_eq!(
1128 coltype_from_sidecar("number", "RINDLE_PG_INT4"),
1129 ColType::Number
1130 );
1131 assert_eq!(
1132 coltype_from_sidecar("json", "RINDLE_PG_JSONB"),
1133 ColType::Json
1134 );
1135 assert_eq!(
1136 coltype_from_sidecar("float", "RINDLE_PG_FLOAT8"),
1137 ColType::Number
1138 );
1139 assert_eq!(
1140 coltype_from_sidecar("string", "RINDLE_PG_JSON"),
1141 ColType::Json
1142 );
1143 assert_eq!(
1144 coltype_from_sidecar("string", "RINDLE_PG_JSONB"),
1145 ColType::Json
1146 );
1147 // Every other text-stored family surfaces as a string (uuid, numeric, temporal, bytea-hex, text).
1148 assert_eq!(
1149 coltype_from_sidecar("string", "RINDLE_PG_TEXT"),
1150 ColType::String
1151 );
1152 assert_eq!(
1153 coltype_from_sidecar("string", "RINDLE_PG_UUID"),
1154 ColType::String
1155 );
1156 assert_eq!(
1157 coltype_from_sidecar("string", "RINDLE_PG_NUMERIC"),
1158 ColType::String
1159 );
1160 // Unknown tokens fall through to the permissive string default.
1161 assert_eq!(
1162 coltype_from_sidecar("mystery", "RINDLE_PG_FUTURE"),
1163 ColType::String
1164 );
1165 }
1166}