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}