Rindle docs and package mapSkip to main content

rindle_cdc_apply/
store.rs

1//! [`ApplyStore`] — the connection pair + capture context behind the CDC apply plane:
2//! the store half of what used to be `rindle-replica`'s `ClusterInner` (design 309 §3).
3//!
4//! One observed read-write **writer** (every durable mutation flows through it; the
5//! `rindle-cdc` preupdate hook watches it), one physically read-only **reader** for
6//! trusted ad-hoc reads, the committed-tx watermark, and the schema-DDL machinery
7//! (`exec_ddl`, the marker-transaction primitives, checked public DDL). No engine, no
8//! worker pool, no drain — the derivation host (`rindle_replica::Cluster`) composes
9//! those ON TOP of this store; a headless applier composes nothing.
10
11use std::cell::{Cell, RefCell};
12use std::path::Path;
13use std::rc::Rc;
14
15use rusqlite::{Connection, OptionalExtension};
16
17use rindle_cdc::CaptureCtx;
18use rindle_writeplane::schema_envelope::{self, DdlActionRecorder, DdlApplyReport, DdlStep};
19use rindle_writeplane::table_shape::ReplicatedTableSchema;
20use rindle_writeplane::{
21    sql, writeplane, ForeignKeys, JournalMode, ReplicaError, SqlStatementRequest, StatementResult,
22    TxId, CLIENT_MUTATIONS_TABLE, ROOM_CLIENT_MUTATIONS_TABLE,
23};
24
25use super::connection::{open_journal, open_journal_read_only, open_scratch};
26
27/// A standalone authority's checked DDL apply can fail either in the cluster/schema machinery or
28/// in the shared write-plane policy checks. Keeping the latter typed preserves stable migration
29/// error codes all the way to the host's HTTP renderer.
30#[derive(Debug)]
31pub enum DdlMigrationError {
32    Replica(ReplicaError),
33    Bookkeeping(writeplane::BookkeepingError),
34}
35
36impl std::fmt::Display for DdlMigrationError {
37    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            DdlMigrationError::Replica(error) => error.fmt(formatter),
40            DdlMigrationError::Bookkeeping(error) => error.fmt(formatter),
41        }
42    }
43}
44
45impl std::error::Error for DdlMigrationError {}
46
47impl From<ReplicaError> for DdlMigrationError {
48    fn from(error: ReplicaError) -> Self {
49        DdlMigrationError::Replica(error)
50    }
51}
52
53impl From<writeplane::BookkeepingError> for DdlMigrationError {
54    fn from(error: writeplane::BookkeepingError) -> Self {
55        DdlMigrationError::Bookkeeping(error)
56    }
57}
58
59/// A DDL/migration failure raised here has to surface through `writeplane`'s public SQL
60/// error body. The impl lives beside [`DdlMigrationError`] because it is the local type —
61/// `impl From<Local> for Foreign` is what the orphan rule permits.
62impl From<DdlMigrationError> for writeplane::WritePlaneError {
63    fn from(error: DdlMigrationError) -> Self {
64        match error {
65            DdlMigrationError::Replica(error) => error.into(),
66            DdlMigrationError::Bookkeeping(error) => error.into(),
67        }
68    }
69}
70
71/// Enough entries for the bounded set of mutation shapes across a practical table registry.
72/// Mutation SQL is constant per `(table, operation, column set)`, so steady-state follower apply
73/// should compile each shape once rather than once per row.
74const WRITER_STATEMENT_CACHE_CAPACITY: usize = 256;
75
76/// The `BEGIN` flavor a headless apply store's writer transaction opens with — the same
77/// flavor the live `rindle_replica::Cluster` passes (306 S5 left `BEGIN IMMEDIATE` as
78/// the one writer flavor), so the headless applier and the live follower run
79/// byte-identical transactions.
80pub const DEFAULT_WRITER_BEGIN_SQL: &str = "BEGIN IMMEDIATE";
81
82/// One `PRAGMA wal_checkpoint(TRUNCATE)` outcome ([`ApplyStore::checkpoint_truncate`]).
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct WalCheckpoint {
85    /// True when the checkpoint could not run to completion (readers/writers held it off).
86    pub busy: bool,
87    /// Frames in the WAL at checkpoint time (-1 when unavailable).
88    pub log_frames: i64,
89    /// Frames successfully moved into the database (-1 when unavailable).
90    pub checkpointed_frames: i64,
91}
92
93/// The apply plane's connection pair + capture context. `!Send` (the preupdate hook and
94/// the transaction cells are single-thread state) — one per thread, like the replica
95/// handles built over it.
96pub struct ApplyStore {
97    /// A physically **read-only** connection exposed via [`read`](Self::read) for trusted ad-hoc
98    /// reads. Opening it with `SQLITE_OPEN_READ_ONLY` ensures even a callback that disables
99    /// `PRAGMA query_only` cannot mutate behind CDC.
100    ///
101    /// **Declared before [`writer`](Self::writer) on purpose — this ordering is load-bearing.**
102    /// Fields drop in declaration order, and SQLite runs the cleanup checkpoint that unlinks
103    /// `-wal`/`-shm` only when the LAST connection to the database closes — which a read-only
104    /// connection cannot do. So the read-only reader must never be the last one standing, or a
105    /// closed database is left beside live sidecars. On a headless store these two are the ONLY
106    /// connections, so the ordering carries the whole invariant (pinned by the `wal_sidecars`
107    /// tests here and in `rindle-replica`).
108    ///
109    /// `None` on a **scratch** store ([`open_scratch`](Self::open_scratch)), which is
110    /// single-connection: reads run on the writer. A scratch runs a rollback journal, where a
111    /// second connection is not the free concurrency WAL makes it — a reader holding SHARED
112    /// blocks the writer's commit escalation to EXCLUSIVE — and there is no one to serve it
113    /// anyway, since the whole store is one thread's private derivation.
114    reader: Option<Connection>,
115    /// The single read-write connection. Every durable mutation goes through here;
116    /// the preupdate hook observes them.
117    pub(crate) writer: Connection,
118    /// The preupdate-hook capture context (installed on `writer`).
119    pub(crate) cdc: Rc<CaptureCtx>,
120    /// The `BEGIN` flavor [`ApplyTxn::begin`](crate::ApplyTxn) opens the writer
121    /// transaction with (see [`DEFAULT_WRITER_BEGIN_SQL`]).
122    pub(crate) writer_begin_sql: &'static str,
123    /// Last durably-committed global tx id (0 = nothing committed yet).
124    pub(crate) committed_tx: Cell<u64>,
125    /// Single-writer guard: true while an `ApplyTxn` is open.
126    pub(crate) in_write: Cell<bool>,
127}
128
129impl Drop for ApplyStore {
130    fn drop(&mut self) {
131        // Remove the hook before the writer connection (and capture context) tear
132        // down, so a stray late callback can't deref freed memory.
133        rindle_cdc::uninstall(&self.writer);
134    }
135}
136
137impl ApplyStore {
138    /// Open the store over a **file-backed** SQLite database: the observed writer (with
139    /// the capture hook installed), the read-only reader, the `__replica_meta`
140    /// committed-tx watermark, and the journal negotiation (design 306 D3/D5 — a fresh
141    /// file gets `journal`'s mode; an existing `wal`/`wal2` file keeps its mode unless
142    /// `Wal2` is explicitly required). Asserts `sqlite3_threadsafe() != 0`, failing
143    /// loud rather than silently degrading. `writer_begin_sql` is the `BEGIN` flavor
144    /// every write transaction opens with ([`DEFAULT_WRITER_BEGIN_SQL`] unless a
145    /// derivation host maps its own mode onto it). `foreign_keys` is the writer's
146    /// enforcement posture: a headless replay and a live follower pass
147    /// [`ForeignKeys::Unenforced`] (the stream's rows were validated upstream and its
148    /// cascades already ran there), an origin write plane passes
149    /// [`ForeignKeys::Enforced`] — see [`rindle_writeplane::foreign_keys`].
150    pub fn open(
151        path: &Path,
152        journal: JournalMode,
153        writer_begin_sql: &'static str,
154        foreign_keys: ForeignKeys,
155    ) -> Result<ApplyStore, ReplicaError> {
156        if unsafe { rusqlite::ffi::sqlite3_threadsafe() } == 0 {
157            return Err(ReplicaError::NotThreadsafe);
158        }
159        let writer = open_journal(path, journal, foreign_keys)?;
160        writer.set_prepared_statement_cache_capacity(WRITER_STATEMENT_CACHE_CAPACITY);
161        let reader = open_journal_read_only(path)?;
162        Self::assemble(writer, Some(reader), writer_begin_sql)
163    }
164
165    /// Open a **scratch** store: one connection, `journal_mode=memory`, `synchronous=OFF`
166    /// (see `connection::open_scratch` for exactly what that trades and what the caller
167    /// owes in return). Everything else — the pragmas, the capture hook,
168    /// the watermark, the transaction machinery — is identical to [`open`](Self::open), so a
169    /// scratch materializes the same store as a durable one; only its crash posture differs.
170    ///
171    /// For a database DERIVED from an authority that can rebuild it (`rindle-backup`'s
172    /// producer scratch, rebuilt from the archive). Never for one anybody restores from.
173    pub fn open_scratch(
174        path: &Path,
175        writer_begin_sql: &'static str,
176        foreign_keys: ForeignKeys,
177    ) -> Result<ApplyStore, ReplicaError> {
178        if unsafe { rusqlite::ffi::sqlite3_threadsafe() } == 0 {
179            return Err(ReplicaError::NotThreadsafe);
180        }
181        let writer = open_scratch(path, foreign_keys)?;
182        writer.set_prepared_statement_cache_capacity(WRITER_STATEMENT_CACHE_CAPACITY);
183        Self::assemble(writer, None, writer_begin_sql)
184    }
185
186    /// The half of opening that is the same however the connections were made: the
187    /// bookkeeping watermark, the capture hook, and the handle itself.
188    fn assemble(
189        writer: Connection,
190        reader: Option<Connection>,
191        writer_begin_sql: &'static str,
192    ) -> Result<ApplyStore, ReplicaError> {
193        // Bookkeeping watermark (its "__" name is skipped by CDC), then read it.
194        writer
195            .execute_batch(
196                "CREATE TABLE IF NOT EXISTS __replica_meta (id INTEGER PRIMARY KEY, tx_id INTEGER NOT NULL)",
197            )
198            .map_err(|e| ReplicaError::sqlite("create __replica_meta", e))?;
199        let committed: u64 = writer
200            .query_row("SELECT tx_id FROM __replica_meta WHERE id = 0", [], |r| {
201                r.get::<_, i64>(0)
202            })
203            .optional()
204            .map_err(|e| ReplicaError::sqlite("read watermark", e))?
205            .map(|v| v as u64)
206            .unwrap_or(0);
207
208        // Observe every row mutation on the writer.
209        let cdc = CaptureCtx::new();
210        rindle_cdc::install(&writer, &cdc);
211
212        Ok(ApplyStore {
213            reader,
214            writer,
215            cdc,
216            writer_begin_sql,
217            committed_tx: Cell::new(committed),
218            in_write: Cell::new(false),
219        })
220    }
221
222    /// The observed writer connection, for narrow host bookkeeping while no write
223    /// transaction is open. Anything written here still passes through the capture hook
224    /// — an application-table row landed outside the typed transaction machinery will
225    /// trip the next commit's capture accounting, not silently diverge — but hosts
226    /// should stay on the typed methods and keep this for unregistered bookkeeping.
227    pub fn writer_connection(&self) -> &Connection {
228        &self.writer
229    }
230
231    /// The capture context installed on the writer. Observation (`has_table`,
232    /// `buffer_len`) is always safe; the mutating methods (`reset`/`drain`/`rewind`)
233    /// belong to the transaction machinery — calling them under an open [`ApplyTxn`](crate::ApplyTxn)
234    /// corrupts its capture accounting.
235    pub fn capture(&self) -> &CaptureCtx {
236        &self.cdc
237    }
238
239    /// The last durably-committed global tx id (0 if none yet).
240    pub fn committed_tx_id(&self) -> TxId {
241        TxId(self.committed_tx.get())
242    }
243
244    /// True while a write transaction ([`ApplyTxn`](crate::ApplyTxn)) is open.
245    pub fn in_write(&self) -> bool {
246        self.in_write.get()
247    }
248
249    /// Run an arbitrary read against a physically read-only connection (a wal2 reader sees
250    /// the latest committed snapshot). Schema changes belong in [`Self::exec_ddl`], and row
251    /// mutations in a write transaction.
252    ///
253    /// On a single-connection scratch store the read runs on the writer instead, and so sees
254    /// the open write transaction's own uncommitted rows. Every caller here reads schema or
255    /// bookkeeping it either just wrote or is about to depend on, so seeing more is never
256    /// wrong; a future caller that needs the committed snapshot specifically must not assume
257    /// this connection provides it.
258    pub fn read<T>(
259        &self,
260        f: impl FnOnce(&Connection) -> rusqlite::Result<T>,
261    ) -> Result<T, ReplicaError> {
262        f(self.reader.as_ref().unwrap_or(&self.writer)).map_err(|e| ReplicaError::sqlite("read", e))
263    }
264
265    /// Walk every declared foreign key and report violating rows — the **opt-in audit**
266    /// that stands in for enforcement on an apply-plane store (see
267    /// [`rindle_writeplane::foreign_keys`]).
268    ///
269    /// `PRAGMA foreign_key_check` reports what is in the file, not what SQLite would have
270    /// refused, so this answers the same question on a store opened
271    /// [`ForeignKeys::Unenforced`] as on one opened [`ForeignKeys::Enforced`] — which is
272    /// the whole reason the apply plane can turn enforcement off without giving up the
273    /// ability to prove referential integrity. It is a full scan of the referencing
274    /// tables: run it deliberately (an operator command, a post-restore gate, a soak
275    /// assertion), never per commit. Runs on the read connection, so it neither takes the
276    /// write lock nor disturbs an applier.
277    ///
278    /// Stops after `max_rows` violations, reporting
279    /// [`rindle_writeplane::ForeignKeyAudit::truncated`]; pass
280    /// [`rindle_writeplane::FOREIGN_KEY_AUDIT_ROW_CAP`] unless
281    /// you have a reason not to, or `0` for no cap.
282    pub fn foreign_key_audit(
283        &self,
284        max_rows: usize,
285    ) -> Result<rindle_writeplane::ForeignKeyAudit, ReplicaError> {
286        let conn = self.reader.as_ref().unwrap_or(&self.writer);
287        rindle_writeplane::foreign_key_check(conn, max_rows)
288    }
289
290    /// The database's schema cookie (`PRAGMA schema_version`), read on the writer connection so
291    /// an open mutation transaction's uncommitted DDL is visible. Hosts whose trusted SQL facade
292    /// runs no statement classification compare it across a transaction to detect DDL that must
293    /// invalidate reader pools and capture registration.
294    pub fn schema_cookie(&self) -> Result<i64, ReplicaError> {
295        self.writer
296            .query_row("PRAGMA schema_version", [], |row| row.get(0))
297            .map_err(|e| ReplicaError::sqlite("schema cookie", e))
298    }
299
300    /// Checkpoint and truncate the WAL through the writer. Snapshot/install code uses this
301    /// after quiescing writes so the portable main file carries every committed frame. The
302    /// public [`Self::read`] connection is physically read-only and deliberately cannot perform a
303    /// checkpoint.
304    pub fn checkpoint_truncate(&self) -> Result<WalCheckpoint, ReplicaError> {
305        if self.in_write.get() {
306            return Err(ReplicaError::Open(
307                "cannot checkpoint while a write transaction is open".into(),
308            ));
309        }
310        let (busy, log_frames, checkpointed_frames): (i64, i64, i64) = self
311            .writer
312            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
313                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
314            })
315            .map_err(|error| ReplicaError::sqlite("PRAGMA wal_checkpoint(TRUNCATE)", error))?;
316        Ok(WalCheckpoint {
317            busy: busy != 0,
318            log_frames,
319            checkpointed_frames,
320        })
321    }
322
323    /// The engine-free half of base-table registration: introspect + validate the
324    /// table's replicated shape, ensure the PK UNIQUE index row-identity point lookups
325    /// require, and teach the capture hook the column types + primary key. Returns the
326    /// introspected schema so a derivation host can build its sources from the SAME
327    /// introspection (`rindle_replica::Cluster::register_table` lifts it to the
328    /// engine's `ColumnDef` shape). Rejects BLOB columns / PK-less tables; the table
329    /// must be **plain** (no triggers, no generated columns).
330    pub fn register_table_capture(
331        &self,
332        table: &str,
333    ) -> Result<ReplicatedTableSchema, ReplicaError> {
334        // Registering a NEW table mid-write-txn would create its PK index inside the
335        // uncommitted writer transaction — invisible to a derivation host's autocommit source
336        // builds (they'd fail the PK-UNIQUE assert). Reject the nonsensical sequence.
337        if self.in_write.get() {
338            return Err(ReplicaError::Open(
339                "cannot register a new table while a write transaction is open".into(),
340            ));
341        }
342        let ts = schema_envelope::introspect_replicated_table(&self.writer, table)?;
343        ensure_unique_pk_index_for(
344            &self.writer,
345            table,
346            &ts.columns
347                .iter()
348                .map(|column| column.name.as_str())
349                .collect::<Vec<_>>(),
350            &ts.primary_key,
351            ts.pk_is_rowid_alias,
352            ts.without_rowid,
353        )?;
354        let col_types: Vec<_> = ts.columns.iter().map(|c| c.value_type).collect();
355        self.cdc
356            .set_table_with_primary_key(table, &col_types, &ts.primary_key);
357        Ok(ts)
358    }
359
360    /// Create the client-mutations ledger tables (idempotent): the daemon-plane
361    /// [`CLIENT_MUTATIONS_TABLE`] and the room-scoped [`ROOM_CLIENT_MUTATIONS_TABLE`].
362    /// Both are **replicated data** (lmid-as-data rides the change stream), so a
363    /// headless applier consuming a master's stream registers the former for capture
364    /// exactly like the live follower does. The registration itself is the caller's
365    /// next step (`register_table_capture`, or the derivation host's engine-inclusive
366    /// registration). Rejected mid-write-txn.
367    pub fn create_client_mutations_tables(&self) -> Result<(), ReplicaError> {
368        if self.in_write.get() {
369            return Err(ReplicaError::Open(
370                "cannot enable client mutations while a write transaction is open".into(),
371            ));
372        }
373        self.writer
374            .execute_batch(&format!(
375                "CREATE TABLE IF NOT EXISTS {CLIENT_MUTATIONS_TABLE} \
376                 (client_id TEXT NOT NULL PRIMARY KEY, last_mutation_id INTEGER NOT NULL);\n\
377                 CREATE TABLE IF NOT EXISTS {ROOM_CLIENT_MUTATIONS_TABLE} \
378                 (doc TEXT NOT NULL, client_id TEXT NOT NULL, last_mutation_id INTEGER NOT NULL, \
379                  PRIMARY KEY (doc, client_id))"
380            ))
381            .map_err(|e| ReplicaError::sqlite("create client mutations table", e))
382    }
383
384    /// Run schema DDL (`CREATE`/`ALTER`/`DROP`/`REINDEX`) against the writer connection — the
385    /// supported way to define the plain base tables you then register and write through. The
386    /// historical bounded `ANALYZE` call is also accepted; row-changing statements and
387    /// row-producing DDL are rejected. The complete batch and schema-envelope validation commit
388    /// atomically. Rejected while a write transaction is open (DDL there would be invisible to a
389    /// derivation host's workers until commit).
390    pub fn exec_ddl(&self, sql: &str) -> Result<(), ReplicaError> {
391        if self.in_write.get() {
392            return Err(ReplicaError::Open(
393                "cannot run DDL while a write transaction is open".into(),
394            ));
395        }
396        schema_envelope::ensure_embedded_ddl_batch(sql)?;
397        schema_envelope::transactionally_apply_schema(
398            &self.writer,
399            &self.cdc,
400            "exec_ddl",
401            true,
402            false,
403            || {
404                self.writer
405                    .execute_batch(sql)
406                    .map_err(|error| ReplicaError::sqlite("exec_ddl", error))
407            },
408        )
409    }
410
411    /// Apply schema `statements` AND stamp a durable idempotency marker (`key` → `marker_table`) in
412    /// ONE ordinary transaction on the writer connection. This is the atomic unit a `BEGIN CONCURRENT`
413    /// cursor advance can't give a follower's DDL (schema changes are illegal in a concurrent txn):
414    /// because the marker lands in the SAME plain transaction as the DDL, `marker present` ⇔ `DDL
415    /// applied`, exactly. A crash-window replay then dedups by `key` (a migration id / entry offset)
416    /// instead of re-running the DDL and inferring "already applied?" from the error string. The
417    /// `marker_table` MUST be UNREGISTERED bookkeeping (like `_rindle_source_offsets`) so the capture
418    /// hook ignores its rows. The resulting schema is validated atomically before commit, and the
419    /// consumer reboots its table sources afterward. Rejected while a write transaction is open
420    /// (mirrors [`exec_ddl`]).
421    ///
422    /// # This seam trusts its statements
423    ///
424    /// Unlike [`exec_ddl`], this path does NOT run `ensure_embedded_ddl_batch` and passes
425    /// `allow_row_changes = true`, so a historical create-copy-swap migration can land its data copy
426    /// atomically with the marker. Copied rows come from an already-managed table and so satisfy the
427    /// replicated value envelope by induction — but a table created inside the same batch is not yet
428    /// CDC-registered, and `rindle-cdc` only counts uncaptured events for unregistered tables rather
429    /// than validating their cells. **A literal invalid value in a statement given to this function
430    /// is therefore not validated.**
431    ///
432    /// That is accepted deliberately: the only production caller is the follower's replicated-DDL
433    /// apply path (`ApplyConsumer::apply_ddl_with_marker` and its `ClusterConsumer` delegate).
434    /// Its statements originate from the write master's migrations, and the master's migrate
435    /// surface is DDL-only. The guarantee is a reachability argument rather than an enforced check,
436    /// so **if migrations ever admit DML, this seam needs a real boundary check** — validate cells
437    /// for unregistered main tables in the capture hook, or register batch-created tables before
438    /// the copy runs. See
439    /// `follow-ups/222-224-sql-client-second-review.md` §2.6.
440    ///
441    /// [`exec_ddl`]: Self::exec_ddl
442    pub fn exec_ddl_with_marker(
443        &self,
444        marker_table: &str,
445        key: &str,
446        statements: &[String],
447    ) -> Result<DdlApplyReport, ReplicaError> {
448        self.exec_ddl_with_marker_impl(
449            marker_table,
450            key,
451            statements,
452            false,
453            |_conn, _step| Ok(()),
454            |_conn, _changed_rows, _schemas, _report| Ok(()),
455        )
456    }
457
458    /// [`exec_ddl_with_marker`](Self::exec_ddl_with_marker), with one caller-owned bookkeeping
459    /// hook invoked after every real statement and its authorizer actions. The hook runs inside
460    /// the SAME transaction as the DDL and marker. It may mutate unregistered bookkeeping tables,
461    /// but must not issue schema DDL (the recorder and schema validator are still active).
462    ///
463    /// This is the follower's atomic desired-state seam: schema-derived metadata must never lag a
464    /// committed marker and be reconstructed during crash replay, because startup repair can act
465    /// on that stale metadata before replay gets a chance to clean it up.
466    pub fn exec_ddl_with_marker_and_step_effects<F>(
467        &self,
468        marker_table: &str,
469        key: &str,
470        statements: &[String],
471        mut apply_step_effects: F,
472    ) -> Result<DdlApplyReport, ReplicaError>
473    where
474        F: FnMut(&Connection, &DdlStep) -> rusqlite::Result<()>,
475    {
476        self.exec_ddl_with_marker_impl(
477            marker_table,
478            key,
479            statements,
480            true,
481            |conn, step| {
482                apply_step_effects(conn, step)
483                    .map_err(|error| ReplicaError::sqlite("ddl-marker atomic step effects", error))
484            },
485            |_conn, _changed_rows, _schemas, _report| Ok(()),
486        )
487    }
488
489    /// Commit one public DDL unit on the standalone WAL2 authority. Every statement is first
490    /// prepared under the shared public authorizer, then executed under the DDL action recorder;
491    /// schema/capture/foreign-key validation, caller bookkeeping, and the next `TxId` watermark
492    /// all land before the one SQLite COMMIT. The daemon reopens its engine after this boundary,
493    /// so workers never continue with a pre-DDL schema at the newly persisted watermark.
494    pub(crate) fn exec_public_ddl_unit<F, G>(
495        &self,
496        statements: &[SqlStatementRequest],
497        declared_tables: &[String],
498        ddl_only_message: &'static str,
499        mut apply_step_effects: F,
500        finish: G,
501    ) -> Result<(Vec<StatementResult>, DdlApplyReport, TxId), DdlMigrationError>
502    where
503        F: FnMut(&Connection, &DdlStep) -> rusqlite::Result<()>,
504        G: FnOnce(
505            &Connection,
506            TxId,
507            &[StatementResult],
508        ) -> Result<(), writeplane::BookkeepingError>,
509    {
510        if self.in_write.get() {
511            return Err(ReplicaError::Open(
512                "cannot run public DDL while a write transaction is open".into(),
513            )
514            .into());
515        }
516        let writer = &self.writer;
517        let pre_tables = writeplane::pre_ddl_table_names(writer)?;
518        let next_tx_id = TxId(self.committed_tx.get() + 1);
519        let ran_drop_table = statements
520            .iter()
521            .any(|statement| writeplane::statement_is_drop_table(&statement.sql));
522
523        let (results, report) = self.exec_ddl_unit_impl(
524            "public ddl",
525            statements,
526            true,
527            |conn, statement| {
528                // SQLite allows one authorizer. Preflight under the denying public policy, then
529                // replace it with the observing DDL recorder for the real execution.
530                sql::preflight_public_ddl_statement(conn, statement)
531                    .map_err(writeplane::BookkeepingError::from)?;
532                conn.flush_prepared_statement_cache();
533                let recorder = DdlActionRecorder::install(conn);
534                let mark = recorder.mark();
535                let result = writeplane::with_writer_statement_budget(conn, true, || {
536                    sql::run_preflighted_ddl_statement(conn, statement)
537                })
538                .map_err(writeplane::BookkeepingError::from)?
539                .map_err(writeplane::BookkeepingError::from)?;
540                let (dropped_tables, altered_tables, created_indexes, dropped_indexes) =
541                    recorder.delta_since(mark);
542                drop(recorder);
543                Ok((
544                    result,
545                    DdlStep {
546                        sql: statement.sql.clone(),
547                        dropped_tables,
548                        altered_tables,
549                        created_indexes,
550                        dropped_indexes,
551                    },
552                ))
553            },
554            |conn, step| {
555                apply_step_effects(conn, step).map_err(writeplane::BookkeepingError::Sqlite)?;
556                Ok::<(), DdlMigrationError>(())
557            },
558            |_conn, _report, _results| Ok::<(), DdlMigrationError>(()),
559            |conn, changed_rows, schemas, _report, results| {
560                writeplane::validate_public_ddl_poststate(
561                    conn,
562                    changed_rows,
563                    &pre_tables,
564                    schemas,
565                    declared_tables,
566                    ddl_only_message,
567                    ran_drop_table,
568                )?;
569                finish(conn, next_tx_id, results)?;
570                self.persist_watermark_in_open_transaction(next_tx_id.0)?;
571                Ok::<(), DdlMigrationError>(())
572            },
573        )?;
574        self.committed_tx.set(next_tx_id.0);
575        Ok((results, report, next_tx_id))
576    }
577
578    fn exec_ddl_with_marker_impl<E, F, V>(
579        &self,
580        marker_table: &str,
581        key: &str,
582        statements: &[String],
583        step_effects_atomic: bool,
584        mut apply_step_effects: F,
585        validate: V,
586    ) -> Result<DdlApplyReport, E>
587    where
588        E: From<ReplicaError>,
589        F: FnMut(&Connection, &DdlStep) -> Result<(), E>,
590        V: FnOnce(&Connection, usize, &[ReplicatedTableSchema], &DdlApplyReport) -> Result<(), E>,
591    {
592        if self.in_write.get() {
593            return Err(ReplicaError::Open(
594                "cannot run DDL while a write transaction is open".into(),
595            )
596            .into());
597        }
598        let w = &self.writer;
599        // Execute at REAL statement boundaries so every observed action is attributed to its own
600        // statement. Fall back to the whole slot only if the splitter refuses (over-limit/NUL —
601        // such a slot fails execution anyway).
602        let statements = statements
603            .iter()
604            .flat_map(|slot| sql::split_sql_script(slot).unwrap_or_else(|_| vec![slot.clone()]))
605            .collect::<Vec<_>>();
606        // Observe (never deny) the destructive actions the statements actually perform — the
607        // authorizer is SQLite's own parser reporting each DROP/ALTER as it is prepared, so
608        // comments, script-valued slots, and qualified names cannot hide one (design 227 third
609        // review pass). Safe to install here: this path runs `protect_registered_tables=false`,
610        // so no deny-guard competes for the connection's single authorizer slot.
611        let recorder = DdlActionRecorder::install(w);
612        let applied = self.exec_ddl_unit_impl(
613            "ddl-marker",
614            &statements,
615            step_effects_atomic,
616            |conn, sql| {
617                let mark = recorder.mark();
618                conn.execute_batch(sql)
619                    .map_err(|error| E::from(ReplicaError::sqlite("ddl-marker apply", error)))?;
620                let (dropped_tables, altered_tables, created_indexes, dropped_indexes) =
621                    recorder.delta_since(mark);
622                Ok((
623                    (),
624                    DdlStep {
625                        sql: sql.clone(),
626                        dropped_tables,
627                        altered_tables,
628                        created_indexes,
629                        dropped_indexes,
630                    },
631                ))
632            },
633            &mut apply_step_effects,
634            |conn, report, _results| {
635                // Persist the ordered report IN the marker transaction: marker present iff DDL
636                // and caller-owned step effects applied and the report is durable. Replay uses
637                // it for bounce decisions and pre-atomic compatibility repair.
638                let report_json = serde_json::to_string(report).map_err(|e| {
639                    E::from(ReplicaError::Mutation(format!("ddl report encode: {e}")))
640                })?;
641                conn.execute(
642                    &format!("INSERT OR IGNORE INTO {marker_table} (key, actions) VALUES (?1, ?2)"),
643                    rusqlite::params![key, report_json],
644                )
645                .map_err(|e| E::from(ReplicaError::sqlite("ddl-marker apply", e)))?;
646                Ok(())
647            },
648            |conn, changed_rows, schemas, report, _results| {
649                validate(conn, changed_rows, schemas, report)
650            },
651        );
652        drop(recorder);
653        applied.map(|(_results, report)| report)
654    }
655
656    /// The one transaction/step-report spine shared by trusted marker DDL and guarded public DDL.
657    /// Callers provide the statement runner (their authorizer/budget policy), the ordered
658    /// bookkeeping effects, their in-transaction tail, and final post-state validation.
659    #[allow(clippy::too_many_arguments)]
660    fn exec_ddl_unit_impl<E, I, O, R, F, A, V>(
661        &self,
662        op: &'static str,
663        statements: &[I],
664        step_effects_atomic: bool,
665        mut run_statement: R,
666        mut apply_step_effects: F,
667        after_statements: A,
668        validate: V,
669    ) -> Result<(Vec<O>, DdlApplyReport), E>
670    where
671        E: From<ReplicaError>,
672        R: FnMut(&Connection, &I) -> Result<(O, DdlStep), E>,
673        F: FnMut(&Connection, &DdlStep) -> Result<(), E>,
674        A: FnOnce(&Connection, &DdlApplyReport, &[O]) -> Result<(), E>,
675        V: FnOnce(
676            &Connection,
677            usize,
678            &[ReplicatedTableSchema],
679            &DdlApplyReport,
680            &[O],
681        ) -> Result<(), E>,
682    {
683        let writer = &self.writer;
684        let results = RefCell::new(Vec::<O>::with_capacity(statements.len()));
685        let steps = RefCell::new(Vec::<DdlStep>::with_capacity(statements.len()));
686        schema_envelope::transactionally_apply_schema_checked(
687            writer,
688            &self.cdc,
689            op,
690            false,
691            true,
692            || {
693                for statement in statements {
694                    let (result, step) = run_statement(writer, statement)?;
695                    apply_step_effects(writer, &step)?;
696                    results.borrow_mut().push(result);
697                    steps.borrow_mut().push(step);
698                }
699                let report = DdlApplyReport {
700                    step_effects_atomic,
701                    steps: steps.borrow().clone(),
702                };
703                after_statements(writer, &report, &results.borrow())
704            },
705            |conn, changed_rows, schemas| {
706                let report = DdlApplyReport {
707                    step_effects_atomic,
708                    steps: steps.borrow().clone(),
709                };
710                validate(conn, changed_rows, schemas, &report, &results.borrow())
711            },
712        )?;
713        Ok((
714            results.into_inner(),
715            DdlApplyReport {
716                step_effects_atomic,
717                steps: steps.into_inner(),
718            },
719        ))
720    }
721
722    /// Upsert the committed-tx watermark into `__replica_meta` (inside the writer txn,
723    /// so it is durable atomically with the data), then COMMIT.
724    pub(crate) fn persist_and_commit(&self, tx_id: u64) -> Result<(), ReplicaError> {
725        self.persist_watermark_in_open_transaction(tx_id)?;
726        self.writer
727            .execute_batch("COMMIT")
728            .map_err(|e| ReplicaError::sqlite("COMMIT", e))?;
729        Ok(())
730    }
731
732    /// Persist only the watermark inside an already-open transaction. Ordinary DML commits call
733    /// this immediately before their own COMMIT; checked public DDL calls it from the schema
734    /// transaction's final validation hook so schema, outcome/journal metadata, and `TxId` are
735    /// one durable atom.
736    pub(crate) fn persist_watermark_in_open_transaction(
737        &self,
738        tx_id: u64,
739    ) -> Result<(), ReplicaError> {
740        self.writer
741            .execute(
742                "INSERT INTO __replica_meta(id, tx_id) VALUES(0, ?1) \
743                 ON CONFLICT(id) DO UPDATE SET tx_id = excluded.tx_id",
744                [tx_id as i64],
745            )
746            .map_err(|e| ReplicaError::sqlite("persist watermark", e))?;
747        Ok(())
748    }
749}
750
751/// Ensure `table` has a UNIQUE index covering exactly the PK columns, which row-identity
752/// point lookups (the engine's `TableSource`, the follower's apply keys) require.
753/// Idempotent. `column_names` is the table's columns in `cid` order; `primary_key` holds
754/// indexes into it.
755///
756/// A rowid-alias `INTEGER PRIMARY KEY` is **skipped**: the rowid already enforces uniqueness and
757/// SQLite point-looks-it-up natively (`SEARCH … USING INTEGER PRIMARY KEY (rowid=?)`), so a
758/// synthetic index would be pure write amplification with no plan benefit. (Databases created by
759/// older versions may still carry a now-unused `rindle_replica_pk_*` index; it is harmless and
760/// left in place.)
761pub fn ensure_unique_pk_index_for(
762    conn: &Connection,
763    table: &str,
764    column_names: &[&str],
765    primary_key: &[usize],
766    pk_is_rowid_alias: bool,
767    without_rowid: bool,
768) -> Result<(), ReplicaError> {
769    if (pk_is_rowid_alias && !without_rowid)
770        || has_pk_unique_index(conn, table, column_names, primary_key)?
771    {
772        return Ok(());
773    }
774    let cols = primary_key
775        .iter()
776        .map(|&c| rindle_writeplane::quote_ident(column_names[c]))
777        .collect::<Vec<_>>()
778        .join(", ");
779    let sql = format!(
780        "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {} ({cols})",
781        rindle_writeplane::quote_ident(&format!("rindle_replica_pk_{table}")),
782        rindle_writeplane::quote_ident(table),
783    );
784    conn.execute_batch(&sql)
785        .map_err(|e| ReplicaError::sqlite("create pk unique index", e))?;
786    Ok(())
787}
788
789/// Does an existing UNIQUE index cover *exactly* the PK column set?
790fn has_pk_unique_index(
791    conn: &Connection,
792    table: &str,
793    column_names: &[&str],
794    primary_key: &[usize],
795) -> Result<bool, ReplicaError> {
796    let want: std::collections::BTreeSet<usize> = primary_key.iter().copied().collect();
797    let name_to_id = |n: &str| -> Option<usize> { column_names.iter().position(|c| *c == n) };
798
799    let mut list = conn
800        .prepare("SELECT name, \"unique\" FROM pragma_index_list(?1)")
801        .map_err(|e| ReplicaError::sqlite("prepare pragma_index_list", e))?;
802    let indexes: Vec<(String, i64)> = list
803        .query_map([table], |r| {
804            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
805        })
806        .map_err(|e| ReplicaError::sqlite("run pragma_index_list", e))?
807        .filter_map(Result::ok)
808        .collect();
809
810    for (idx, unique) in indexes {
811        if unique == 0 {
812            continue;
813        }
814        let mut info = conn
815            .prepare("SELECT name FROM pragma_index_info(?1)")
816            .map_err(|e| ReplicaError::sqlite("prepare pragma_index_info", e))?;
817        let cols: std::collections::BTreeSet<usize> = info
818            .query_map([&idx], |r| r.get::<_, String>(0))
819            .map_err(|e| ReplicaError::sqlite("run pragma_index_info", e))?
820            .filter_map(Result::ok)
821            .filter_map(|n| name_to_id(&n))
822            .collect();
823        if cols == want {
824            return Ok(true);
825        }
826    }
827    Ok(false)
828}