Rindle docs and package mapSkip to main content

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, &params)?;
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}