macrame/schema/migrations.rs
1use std::future::Future;
2use std::pin::Pin;
3
4use crate::error::{DbError, Result};
5use crate::schema::ddl::*;
6
7/// Schema version this build understands, stored in SQLite's `user_version`.
8///
9/// The baseline is **2**, not 1, on purpose. Builds before 0.5.4 stamped
10/// `user_version = 1` over the pre-canonical schema — no `CHECK` constraints,
11/// second-precision timestamps, the narrow sentinel. Had the canonical baseline
12/// kept the number 1, one of those files would open silently and every
13/// guarantee D-029 buys would be void on it while `user_version` insisted all
14/// was well. Reserving 1 as a value this build refuses by name is what makes
15/// "no legacy support" an enforced property instead of a README sentence.
16pub const SCHEMA_VERSION: u32 = 19;
17
18type StepFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
19
20/// One rung of the ladder: takes a database at `from` and leaves it at `to`.
21struct Step {
22 from: u32,
23 to: u32,
24 name: &'static str,
25 apply: for<'a> fn(&'a libsql::Connection) -> StepFuture<'a>,
26 /// Suspend foreign-key enforcement **around** this rung's transaction
27 /// (0.8.0, B4, D-117).
28 ///
29 /// # Why a rung would need this
30 ///
31 /// Two rungs need it, for reasons that share only the remedy.
32 ///
33 /// **v7 → v8 rebuilds a table with inbound foreign keys** and so cannot use
34 /// the `links`-style recipe; the four approaches below are what
35 /// `examples/concepts_rebuild_probe.rs` measured and ruled out.
36 ///
37 /// **v11 → v12 adds a column carrying a `REFERENCES` clause** to tables
38 /// that already hold rows. libSQL applies SQLite's
39 /// "a `REFERENCES` column added by `ALTER` must default to NULL" rule
40 /// *dynamically*: it refuses only when the table is non-empty **and** keys
41 /// are on (probe §15). Being inside a transaction is not the axis — the
42 /// pragma is, which is exactly what this flag toggles, and the resulting
43 /// key is fully real (see [`BRANCH_COLUMN`]). Note the asymmetry the flag
44 /// makes visible: a **fresh** v12 database never needs the suspension,
45 /// because there the clause sits in a `CREATE TABLE` with no rows to
46 /// validate. The two paths reach the same schema by different routes,
47 /// which is the shape D-035 says to say out loud rather than discover.
48 ///
49 /// # Why the obvious ways do not work
50 ///
51 /// A rung that rebuilds a table with inbound foreign keys cannot use the
52 /// `links`-style recipe. `links` has no inbound keys; `concepts` has two
53 /// (`links.source_id`, `links.target_id`). `examples/concepts_rebuild_probe.rs`
54 /// measured four approaches on libSQL 0.9.30 and **all four fail**:
55 ///
56 /// 1. `PRAGMA foreign_keys = OFF` *inside* the transaction — **silently
57 /// ignored**. `execute` returns `Ok`, the value reads back `1`. The
58 /// pragma is a no-op inside a transaction, and [`apply_step`] wraps every
59 /// rung in `BEGIN IMMEDIATE`.
60 /// 2. `DROP TABLE concepts` with keys on — `FOREIGN KEY constraint failed`,
61 /// with **or without** the delete guard. The guard is not the obstacle.
62 /// 3. `PRAGMA defer_foreign_keys = ON`, which is designed for exactly this —
63 /// every statement succeeds and `foreign_key_check` reports **0
64 /// violations**, and then **COMMIT fails**. SQLite counts deferred
65 /// violation *events*; re-adding an equivalent parent row does not
66 /// decrement the counter.
67 /// 4. Rename-around, with `legacy_alter_table` both on and off — the drop
68 /// of the orphaned table fails either way.
69 ///
70 /// What works is toggling the pragma *outside* the transaction. So the
71 /// ladder has to know, and this flag is how a rung says so.
72 ///
73 /// # Why this does not weaken atomicity
74 ///
75 /// The rung is still **one transaction and one commit**, with the
76 /// `user_version` stamp inside it — [D-032](../../docs/architecture/s13-decision-register.md)'s
77 /// property is untouched. `PRAGMA foreign_keys` is per-*connection*, and the
78 /// migration connection is created in `open()` and discarded if the
79 /// migration fails, so a crash between the toggle and the reset cannot
80 /// leave a long-lived connection with enforcement off.
81 ///
82 /// And the suspension cannot hide a real violation: [`apply_step`] runs
83 /// `PRAGMA foreign_key_check` **inside** the transaction before committing,
84 /// and any row it reports fails the rung. Enforcement is suspended for the
85 /// duration; verification is not.
86 suspends_foreign_keys: bool,
87}
88
89/// The ladder, in no particular order — `run` walks it by matching `from`.
90///
91/// The rung out of 0 lays the whole schema; the rung out of 2 adds only what
92/// v3 introduced. There is deliberately still no rung out of 1: that is the
93/// pre-canonical schema D-032 refuses by name, and v2 is not the same case —
94/// it was written by this same 0.5.4 line with canonical timestamps and every
95/// CHECK in place, so it is missing a derivative table and nothing else.
96const STEPS: &[Step] = &[
97 Step {
98 from: 0,
99 to: SCHEMA_VERSION,
100 name: "baseline-0.5.4",
101 suspends_foreign_keys: false,
102 apply: |conn| Box::pin(baseline(conn)),
103 },
104 Step {
105 from: 2,
106 to: 3,
107 name: "analytics-annotations",
108 suspends_foreign_keys: false,
109 apply: |conn| Box::pin(add_analytics_annotations(conn)),
110 },
111 Step {
112 from: 3,
113 to: 4,
114 name: "traversal-covering-index",
115 suspends_foreign_keys: false,
116 apply: |conn| Box::pin(add_traversal_cover(conn)),
117 },
118 Step {
119 from: 4,
120 to: 5,
121 name: "concepts-fts",
122 suspends_foreign_keys: false,
123 apply: |conn| Box::pin(add_concepts_fts(conn)),
124 },
125 Step {
126 from: 5,
127 to: 6,
128 name: "single-open-interval-index",
129 suspends_foreign_keys: false,
130 apply: |conn| Box::pin(add_open_interval_index(conn)),
131 },
132 Step {
133 from: 6,
134 to: 7,
135 name: "links-weight-check",
136 suspends_foreign_keys: false,
137 apply: |conn| Box::pin(add_weight_check(conn)),
138 },
139 Step {
140 from: 7,
141 to: 8,
142 name: "concepts-rowid-pk-and-unread-indices",
143 // The only rung that needs it, and the reason the flag exists. See
144 // `Step::suspends_foreign_keys` for the four approaches the probe
145 // refuted.
146 suspends_foreign_keys: true,
147 apply: |conn| Box::pin(add_concepts_rowid_pk(conn)),
148 },
149 Step {
150 from: 9,
151 to: 10,
152 name: "concepts-log-insert-marker-gated",
153 // Same shape as the rung below and for the same reason: one trigger
154 // replaced, no table touched.
155 suspends_foreign_keys: false,
156 apply: |conn| Box::pin(gate_concepts_log_insert_on_marker(conn)),
157 },
158 Step {
159 from: 8,
160 to: 9,
161 name: "concepts-guard-marker-gated",
162 // One trigger replaced. No table is rebuilt and no row moves, so the
163 // inbound foreign keys that forced the flag on the rung above are not
164 // involved here at all.
165 suspends_foreign_keys: false,
166 apply: |conn| Box::pin(gate_concepts_guard_on_marker(conn)),
167 },
168 Step {
169 from: 12,
170 to: 13,
171 name: "branches-archive-gate",
172 // One `DROP TRIGGER` and one `CREATE TRIGGER`. No row moves, no table is
173 // rebuilt, and the trigger names no table but the one it is on — so the
174 // foreign keys that forced the flag on the v7 -> v8 and v11 -> v12 rungs
175 // are not involved.
176 suspends_foreign_keys: false,
177 apply: |conn| Box::pin(gate_branches_delete_guard(conn)),
178 },
179 Step {
180 from: 13,
181 to: 14,
182 name: "lineage-cut-index",
183 // One `CREATE INDEX` on an existing derivative table. Nothing is
184 // rebuilt and no row moves, which is the same ground the v3 -> v4,
185 // v5 -> v6 and v10 -> v11 rungs stood on.
186 suspends_foreign_keys: false,
187 apply: |conn| Box::pin(add_lineage_cut_index(conn)),
188 },
189 Step {
190 from: 17,
191 to: 18,
192 name: "branch-partition-indices",
193 // Two `CREATE INDEX` statements, each on one table and referencing no
194 // other. Nothing to defer, exactly as on the rung above.
195 suspends_foreign_keys: false,
196 apply: |conn| Box::pin(add_branch_partition_indices(conn)),
197 },
198 Step {
199 from: 18,
200 to: 19,
201 name: "single-open-plan-pin",
202 // One `DROP TRIGGER` and one `CREATE TRIGGER`: the v18 body of
203 // `trg_links_single_open` leaves its `branch_id` predicate seekable,
204 // and on a statistics-free database the planner serves the probe from
205 // `idx_lc_lineage_cut` with only `branch_id` bound — one whole-lineage
206 // scan per trigger firing, O(rows) per row over a bulk (D-274). No
207 // table is rebuilt, no row moves, and the trigger is on `links`, whose
208 // inbound foreign keys are the parents' — the two rungs that needed
209 // the flag touch the table itself, this one does not.
210 suspends_foreign_keys: false,
211 apply: |conn| Box::pin(pin_single_open_plan(conn)),
212 },
213 Step {
214 from: 16,
215 to: 17,
216 name: "txlog-fold-partition-index",
217 // An index on one table, referencing no other. Nothing to defer.
218 suspends_foreign_keys: false,
219 apply: |conn| Box::pin(add_fold_partition_index(conn)),
220 },
221 Step {
222 from: 15,
223 to: 16,
224 name: "log-integrity-bit",
225 // Nothing declares a foreign key into or out of the new table, and the
226 // seed reads `transaction_log` without writing it.
227 suspends_foreign_keys: false,
228 apply: |conn| Box::pin(add_log_integrity(conn)),
229 },
230 Step {
231 from: 14,
232 to: 15,
233 name: "links-lineage-key",
234 // The second rung on this ladder to rebuild `links`, and it takes the
235 // v6 -> v7 rung's answer to the same question: nothing declares a
236 // foreign key *into* `links`, so the drop and rename need no
237 // suspension. Its own `REFERENCES concepts(id)` columns are satisfied
238 // by every row being copied, because they were satisfied before.
239 suspends_foreign_keys: false,
240 apply: |conn| Box::pin(add_links_lineage_key(conn)),
241 },
242 Step {
243 from: 11,
244 to: 12,
245 name: "branch-storage",
246 // Not for the rebuild — `links_current` carries no inbound foreign key,
247 // so it takes the `links`-style recipe the v7 -> v8 rung could not use.
248 // For the three `ADD COLUMN`s: the new column carries a `REFERENCES`
249 // clause, and libSQL refuses that on a table that already holds rows
250 // while keys are on. Second reason, same flag — see
251 // `Step::suspends_foreign_keys`.
252 suspends_foreign_keys: true,
253 apply: |conn| Box::pin(add_branch_storage(conn)),
254 },
255 Step {
256 from: 10,
257 to: 11,
258 name: "links-archive-indices",
259 // Two `CREATE INDEX`es on an existing table. No row moves and no table
260 // is rebuilt, so the inbound foreign keys that forced the flag on the
261 // v7 -> v8 rung are not involved.
262 suspends_foreign_keys: false,
263 apply: |conn| Box::pin(add_links_archive_indices(conn)),
264 },
265];
266
267/// Bring `conn`'s database up to [`SCHEMA_VERSION`], or fail explaining why not.
268///
269/// Reading `user_version` before writing is the whole point. The previous
270/// implementation re-ran every `CREATE … IF NOT EXISTS` unconditionally and then
271/// stamped the version it had never read, which meant it could not distinguish a
272/// fresh file from a foreign one from a database written by a future build — it
273/// simply asserted the schema it wanted and hoped. `IF NOT EXISTS` hides exactly
274/// the case that matters: an object that exists with a *different* definition is
275/// silently kept, so a legacy table would survive with none of its constraints
276/// while the stamp claimed otherwise.
277/// What [`run`] did, so a caller can react to the schema having moved.
278///
279/// The one caller that must is `Database::open`: a `SCHEMA_VERSION` bump
280/// invalidates every snapshot on disk (D-043), and until Wave 4.4 nothing
281/// noticed — the first `reconstruct` after an upgrade skipped every snapshot as
282/// incompatible and folded from genesis, correctly and expensively, with the
283/// only trace a `warn!` per skipped file.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285#[non_exhaustive]
286pub struct MigrationOutcome {
287 /// The version the file carried on the way in.
288 pub from: u32,
289 /// [`SCHEMA_VERSION`], always — `run` either reaches it or fails.
290 pub to: u32,
291}
292
293impl MigrationOutcome {
294 /// Whether an **existing** database moved between versions.
295 ///
296 /// A fresh file (`from == 0`) is deliberately not an upgrade. It has no
297 /// snapshots to invalidate, so there is nothing to re-anchor — and treating
298 /// it as one made `Database::open` write a snapshot on every first open,
299 /// which broke two contracts the suite already pins: an idle database is
300 /// never anchored, and a handle opened with no cadence writes nothing until
301 /// `close()`. Both are worth keeping. `open()` touching the disk when it was
302 /// not asked to is surprising in its own right.
303 pub fn upgraded(&self) -> bool {
304 self.from != 0 && self.from != self.to
305 }
306}
307
308pub async fn run(conn: &libsql::Connection) -> Result<MigrationOutcome> {
309 let found = read_user_version(conn).await?;
310
311 if found > SCHEMA_VERSION {
312 return Err(DbError::Migration {
313 to: SCHEMA_VERSION,
314 reason: format!(
315 "database is at schema v{found}; this build understands v{SCHEMA_VERSION} \
316 and will not operate on a schema it does not know. Upgrade macrame \
317 rather than opening the file with an older build."
318 ),
319 });
320 }
321
322 if found == 0 {
323 refuse_if_occupied(conn).await?;
324 }
325
326 let mut current = found;
327 while current != SCHEMA_VERSION {
328 let step = STEPS
329 .iter()
330 .find(|s| s.from == current)
331 .ok_or_else(|| no_path_from(current))?;
332 apply_step(conn, step).await?;
333 current = step.to;
334 }
335
336 verify(conn).await?;
337 Ok(MigrationOutcome {
338 from: found,
339 to: SCHEMA_VERSION,
340 })
341}
342
343/// Version this build stamps on databases it creates.
344pub fn current_version() -> u32 {
345 SCHEMA_VERSION
346}
347
348/// Refuse to lay the baseline over a database that already holds something.
349///
350/// `user_version` defaults to 0, so an unrelated SQLite file is indistinguishable
351/// from a fresh one by version alone. Without this check, pointing macrame at the
352/// wrong path would quietly add four tables and nine triggers to somebody else's
353/// database — including delete guards that abort writes the owner never asked to
354/// have guarded.
355async fn refuse_if_occupied(conn: &libsql::Connection) -> Result<()> {
356 let mut rows = conn
357 .query(
358 "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'",
359 (),
360 )
361 .await?;
362 let objects: i64 = match rows.next().await? {
363 Some(row) => row.get(0)?,
364 None => 0,
365 };
366
367 if objects > 0 {
368 return Err(DbError::Migration {
369 to: SCHEMA_VERSION,
370 reason: format!(
371 "database carries no macrame schema version but already holds {objects} \
372 object(s); refusing to lay the baseline over an unrelated database. \
373 Point at a new file, or delete this one deliberately."
374 ),
375 });
376 }
377 Ok(())
378}
379
380/// Explain a version with no rung leading out of it.
381fn no_path_from(current: u32) -> DbError {
382 let reason = if current < SCHEMA_VERSION {
383 format!(
384 "database is at schema v{current}, written by a pre-0.5.4 build: its \
385 timestamps are second-precision and its tables carry none of the \
386 canonical-form CHECK constraints (D-029). This build provides no \
387 migration path — create a new database."
388 )
389 } else {
390 format!("no migration step leads out of schema v{current}")
391 };
392 DbError::Migration {
393 to: SCHEMA_VERSION,
394 reason,
395 }
396}
397
398/// Run one rung inside a single transaction, stamp included.
399///
400/// `user_version` is a database-header field and its write is journalled like
401/// any other, so stamping inside the transaction makes "the schema exists" and
402/// "the schema is declared to exist" the same commit. A crash mid-step therefore
403/// leaves a database that is still honestly at its old version, rather than one
404/// stamped for a schema it only partly has.
405async fn apply_step(conn: &libsql::Connection, step: &Step) -> Result<()> {
406 // Outside the transaction, because inside it the pragma is silently
407 // ignored — see `Step::suspends_foreign_keys` for the four approaches that
408 // do not work and the probe that measured them.
409 if step.suspends_foreign_keys {
410 conn.execute("PRAGMA foreign_keys = OFF", ()).await?;
411 }
412
413 let res = apply_step_inner(conn, step).await;
414
415 // Restored on **every** path, including the error one. A rung that fails
416 // must not leave the connection with enforcement off, even though that
417 // connection is about to be discarded: the guarantee should not depend on
418 // the caller's disposal habits.
419 if step.suspends_foreign_keys {
420 conn.execute("PRAGMA foreign_keys = ON", ()).await?;
421 }
422
423 res
424}
425
426async fn apply_step_inner(conn: &libsql::Connection, step: &Step) -> Result<()> {
427 let tx = conn
428 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
429 .await?;
430
431 let res: Result<()> = async {
432 (step.apply)(&tx).await?;
433
434 // Suspension is not permission. A rung that ran with enforcement off
435 // must still leave a database the engine would accept, so the check
436 // runs inside the transaction and its rows fail the rung — which means
437 // the rollback below, not a committed database nobody checked.
438 if step.suspends_foreign_keys {
439 let mut rows = tx.query("PRAGMA foreign_key_check", ()).await?;
440 if let Some(row) = rows.next().await? {
441 let table: String = row.get(0).unwrap_or_else(|_| "?".to_string());
442 return Err(DbError::Migration {
443 to: step.to,
444 reason: format!(
445 "step {:?} suspended foreign keys and left a violation \
446 in {table:?}; the rung is wrong, not the check",
447 step.name
448 ),
449 });
450 }
451 }
452
453 // PRAGMA takes no bind parameters; `to` is a u32 read from a const.
454 tx.execute(&format!("PRAGMA user_version = {}", step.to), ())
455 .await?;
456 Ok(())
457 }
458 .await;
459
460 match res {
461 Ok(()) => {
462 tx.commit().await?;
463 Ok(())
464 }
465 Err(e) => {
466 let _ = tx.rollback().await;
467 Err(DbError::Migration {
468 to: step.to,
469 reason: format!("step {:?}: {e}", step.name),
470 })
471 }
472 }
473}
474
475/// The 0.5.4 schema, applied to an empty database.
476async fn baseline(conn: &libsql::Connection) -> Result<()> {
477 // branches first, and seeded immediately: every ledger table's `branch_id`
478 // defaults to `'main'` and declares a foreign key onto this row, so a
479 // database without it cannot accept a single write (§15.2, D-214).
480 conn.execute(CREATE_BRANCHES_TABLE, ()).await?;
481 seed_root_branch(conn).await?;
482 // concepts next: links declares a foreign key into it.
483 conn.execute(CREATE_CONCEPTS_TABLE, ()).await?;
484 conn.execute(CREATE_LINKS_TABLE, ()).await?;
485 conn.execute(CREATE_LINKS_CURRENT_TABLE, ()).await?;
486 conn.execute(CREATE_TRANSACTION_LOG_TABLE, ()).await?;
487 // Derivative, and last: every index in CREATE_INDICES must have its table.
488 conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
489 // v16 (W14.5, D-249). The same seed statement the rung runs, not a
490 // literal 0: a baseline log is empty and the answer is 0, but writing the
491 // answer here rather than deriving it is how the two paths start to
492 // disagree (D-035).
493 conn.execute(CREATE_LOG_INTEGRITY_TABLE, ()).await?;
494 conn.execute(SEED_LOG_INTEGRITY, ()).await?;
495 // Before the triggers, not after: `trg_concepts_fts_*` name this table, and
496 // SQLite resolves a trigger body's tables at CREATE TRIGGER time.
497 conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
498
499 for index_ddl in CREATE_INDICES {
500 conn.execute(index_ddl, ()).await?;
501 }
502
503 for trigger_ddl in CREATE_TRIGGERS {
504 conn.execute(trigger_ddl, ()).await?;
505 }
506
507 Ok(())
508}
509
510/// v15 → v16: the log records whether anything has left it (0.15.7, W14.5, [D-249]).
511///
512/// Review C-5: the reach guard's intactness test was `COUNT(*)` over the whole
513/// hot log, on every recorded-time read below the newest surviving stamp —
514/// 32.6 ms at 500,000 rows, in front of an id-bounded hydration that is flat at
515/// 0.14 ms.
516/// The fact is one bit and the storage knows it the moment it becomes true.
517///
518/// # The seed is derived, and it is stricter than the query it retires
519///
520/// A database arriving here may already have been archived, so the initial
521/// value cannot be assumed. [`SEED_LOG_INTEGRITY`] derives it from the log's
522/// `sqlite_sequence` high-water mark, which counts every id ever allocated and
523/// does not fall when rows are deleted ([D-049] for why a rollback is not a
524/// deletion). So a database migrated at v16 starts from the truth about its own
525/// history, whatever that history was — one statement, once, on the rung
526/// instead of a scan on every read.
527///
528/// It is deliberately *not* the comparison the guard used. `MIN(seq_id) = 1 AND
529/// COUNT(*) = MAX(seq_id)` is exact on a log with rows in it and blind on an
530/// empty one, where it answers *intact* — right for a database that has never
531/// been written, wrong for one archived down to nothing, and those are the two
532/// states a bit about archiving most needs to tell apart. Seeding from the
533/// high-water mark tells them apart: absent for the first, positive for the
534/// second. `the_bit_agrees_with_the_count_it_replaced` pins both halves of that
535/// — the agreement everywhere else, and the disagreement here.
536///
537/// [D-049]: ../../docs/architecture/s13-decision-register.md#d-049
538/// [D-249]: ../../docs/architecture/s13-decision-register.md#d-249
539async fn add_log_integrity(conn: &libsql::Connection) -> Result<()> {
540 conn.execute(CREATE_LOG_INTEGRITY_TABLE, ()).await?;
541 conn.execute(SEED_LOG_INTEGRITY, ()).await?;
542 // After the seed: the trigger must not fire during it, and it cannot —
543 // the seed does not delete — but the ordering is what a reader checks.
544 conn.execute(CREATE_TXLOG_MARK_GAP, ()).await?;
545 Ok(())
546}
547
548/// v11 → v12: the branch storage model (§15.2, W12.2, [D-214]).
549///
550/// Storage only, in the sense that matters: no write-path changes, and every
551/// existing `INSERT` in the crate still omits `branch_id` and takes the
552/// default.
553///
554/// **The public API gate does move, by +18 items and -0**, and the plan's
555/// prediction that it would not was wrong rather than nearly right. Every
556/// added item is schema text in [`crate::schema::ddl`], whose whole purpose is
557/// to publish the schema — thirteen new consts for the `branches` table, its
558/// seed, the column, the four guards and the three abort messages; four
559/// promotions of trigger bodies that were anonymous entries in
560/// [`CREATE_TRIGGERS`] until a rung needed to name them; and three variants on
561/// the `#[non_exhaustive]` `AbortKind`, which is what that attribute is for.
562/// Nothing removed, nothing narrowed. The gate is still the check that would
563/// catch a leak — it simply had something true to report.
564///
565/// # What the shape is, and what it refuses to be
566///
567/// Links and the transaction log **branch**; concepts do not. `concepts.id`
568/// stays `NOT NULL UNIQUE` and stays the parent of `links.source_id` and
569/// `links.target_id`, so `branch_id` on `concepts` is **provenance** — where a
570/// concept was minted — and never identity. `examples/branch_identity_probe.rs`
571/// measured the two alternatives and both break something the design depends
572/// on: widening uniqueness to `(id, branch_id)` leaves today's single-column
573/// foreign keys accepting `CREATE` and failing **every insert** with `foreign
574/// key mismatch` (§3), and a composite key `(source_id, branch_id)` forbids
575/// copy-on-write outright (§4), which is the whole economy of a fork.
576///
577/// # Why `links_current` is rebuilt and the other three are altered
578///
579/// `branch_id` has to be **in the primary key** of `links_current`, not merely
580/// on it: the table is one row per open belief about an edge, and two lineages
581/// believing different things about one edge is two rows. Probe §5 confirmed
582/// the split — `links` accepts both rows because `recorded_at` is already in
583/// its key, and `links_current` refuses the second. SQLite cannot add a column
584/// to a primary key, so the table is re-derived rather than described: it is
585/// derivative under Doctrine VI, `rebuild_within` already knows how to
586/// reconstruct it from `links`, and re-deriving cannot disagree with the ledger
587/// the way a hand-written `INSERT … SELECT` can.
588///
589/// `branch_id` goes **last** in the key on purpose. The autoindex keeps its
590/// leading columns, so D-059's primary-key-versus-covering-index contest is
591/// unperturbed by this rung; whether a branch-leading composition reads better
592/// is §15.3's measurement to make, not a shape to guess at now (F-33).
593///
594/// # The triggers, and the one that is easy to miss
595///
596/// Three log triggers are redefined so the log row carries the lineage the
597/// write actually happened on. Without that every entry reads `'main'`, and the
598/// fold's new `PARTITION BY … branch_id` would partition on a constant — the
599/// widened folds and these triggers are one repair in two files, not two
600/// changes. `DROP` then `CREATE`, never a re-issue: `CREATE TRIGGER IF NOT
601/// EXISTS` against an existing name keeps the **old body**, which is the lesson
602/// [`CONCEPTS_GUARD_DELETE_V8`] already records.
603///
604/// # Why this rung suspends foreign keys
605///
606/// Not for the `links_current` rebuild — nothing declares a key into it. For
607/// the three `ADD COLUMN`s: libSQL refuses a `REFERENCES` column added to a
608/// table that already holds rows while enforcement is on, and every database
609/// climbing this rung holds rows by definition. [`Step::suspends_foreign_keys`]
610/// toggles the pragma outside the transaction, which is the only placement that
611/// works, and `apply_step` re-checks with `PRAGMA foreign_key_check` inside it
612/// before committing. The rung is still one transaction and one commit.
613///
614/// [D-214]: ../../docs/architecture/s13-decision-register.md
615async fn add_branch_storage(conn: &libsql::Connection) -> Result<()> {
616 // The register and its root, before any column defaults to a row that has
617 // to exist for the foreign key to be satisfiable.
618 conn.execute(CREATE_BRANCHES_TABLE, ()).await?;
619 seed_root_branch(conn).await?;
620
621 // Metadata-only: SQLite records a constant default in the schema header and
622 // rewrites no row. Measured at 83-139 microseconds over 20,000 rows (probe
623 // §1). The `REFERENCES` clause is why the step suspends foreign keys — on a
624 // populated table with keys on, libSQL refuses it. See [`BRANCH_COLUMN`].
625 for table in ["concepts", "links", "transaction_log"] {
626 conn.execute(
627 &format!("ALTER TABLE {table} ADD COLUMN {BRANCH_COLUMN}"),
628 (),
629 )
630 .await?;
631 }
632
633 // `links_current` instead gets the `links` recipe: drop, re-create with the
634 // widened key, re-derive. Safe here and not on `concepts` because nothing
635 // declares a foreign key into it.
636 conn.execute("DROP TABLE links_current", ()).await?;
637 conn.execute(CREATE_LINKS_CURRENT_TABLE, ()).await?;
638
639 // `DROP TABLE` took the table's indices with it, and neither the `CREATE`
640 // above nor the rebuild below restores them — one declares a table and the
641 // other fills one. Without these two the database is stamped v12 and fails
642 // its own open-time verification, which is how this was found.
643 conn.execute(LC_TRAVERSAL_COVER, ()).await?;
644 conn.execute(LC_OPEN_INTERVAL, ()).await?;
645
646 // Triggers whose bodies name columns that just changed. Dropped by name
647 // first, because `IF NOT EXISTS` would silently keep the pre-v12 body and
648 // leave a database the ladder calls v12 that logs without lineage.
649 for (name, ddl) in [
650 ("trg_concepts_log_insert", CREATE_CONCEPTS_LOG_INSERT),
651 ("trg_concepts_log_update", CREATE_CONCEPTS_LOG_UPDATE),
652 ("trg_links_log_insert", CREATE_LINKS_LOG_INSERT),
653 ("trg_links_current_sync", CREATE_LINKS_CURRENT_SYNC),
654 ("trg_links_single_open", CREATE_LINKS_SINGLE_OPEN),
655 ] {
656 conn.execute(&format!("DROP TRIGGER IF EXISTS {name}"), ())
657 .await?;
658 conn.execute(ddl, ()).await?;
659 }
660
661 // New guards. `IF NOT EXISTS` is correct for these: no earlier body exists
662 // to be kept.
663 for ddl in [
664 CREATE_CONCEPTS_GUARD_LINEAGE,
665 CREATE_CONCEPTS_GUARD_BRANCH,
666 CREATE_BRANCHES_GUARD_UPDATE,
667 CREATE_BRANCHES_GUARD_DELETE,
668 ] {
669 conn.execute(ddl, ()).await?;
670 }
671
672 // Last, and inside the same transaction: the materialization is re-derived
673 // only once every trigger that maintains it speaks v12.
674 crate::integrity::rebuild::rebuild_within(conn, crate::integrity::rebuild::Verify::Yes).await?;
675
676 Ok(())
677}
678
679/// Insert the root lineage, shared by the baseline and the rung.
680///
681/// One helper rather than two call sites composing the same statement, because
682/// `'main'` spliced twice is `'main'` spelled two ways eventually.
683async fn seed_root_branch(conn: &libsql::Connection) -> Result<()> {
684 let now = crate::util::timestamp::format(std::time::SystemTime::now());
685 conn.execute(SEED_MAIN_BRANCH, libsql::params![now]).await?;
686 Ok(())
687}
688
689/// Every trigger v12 introduced or redefined, by name (§15.2, D-214).
690///
691/// Consulted by [`triggers_before_v12`] and by nothing else. Kept as names
692/// rather than folded into that function so the two halves of the rule — what
693/// v12 touched, and what a pre-v12 rung installs instead — are separately
694/// readable.
695const V12_TRIGGERS: &[&str] = &[
696 // New at v12: three of these sit on `branches`, which is why a pre-v12 rung
697 // installing them fails outright rather than merely installing the wrong
698 // body.
699 "trg_concepts_cross_lineage",
700 "trg_concepts_branch_immutable",
701 "trg_branches_frozen_update",
702 "trg_branches_frozen_delete",
703 // Redefined at v12 to name `branch_id`. Their v11 bodies are in
704 // [`TRIGGERS_V11`].
705 "trg_links_current_sync",
706 "trg_links_single_open",
707 "trg_concepts_log_insert",
708 "trg_concepts_log_update",
709 "trg_links_log_insert",
710];
711
712/// Triggers introduced *after* v12, which a pre-v12 rung must also leave out
713/// (v16, W14.5, [D-249](../../docs/architecture/s13-decision-register.md#d-249)).
714///
715/// A second list rather than more entries in [`V12_TRIGGERS`], because the two
716/// exclusions are excluded for opposite reasons and a reader has to be able to
717/// tell them apart: v12's are left out because the rung must install their
718/// *older* bodies, listed in [`TRIGGERS_V11`]; these are left out because at
719/// that point on the ladder they have no older body — they do not exist yet,
720/// and `trg_txlog_mark_gap` names a table three rungs away from being created.
721/// It failed loudly rather than quietly, which is the one mercy: the v6 → v7
722/// rung deletes log rows, so the trigger fired against a missing table and the
723/// climb stopped.
724const LATER_TRIGGERS: &[&str] = &["trg_txlog_mark_gap"];
725
726/// The five redefined triggers **as v11 had them** (§15.2, D-214).
727///
728/// Pinned for the reason [`CONCEPTS_LOG_INSERT_V9`] states, and the reason has
729/// now bitten twice: a rung that restores triggers from today's
730/// [`CREATE_TRIGGERS`] installs *today's* bodies on a database several versions
731/// short of them. There it produced a v8 database that stopped logging concept
732/// inserts; here it would produce a v5 database whose sync trigger writes a
733/// column `links_current` does not have.
734///
735/// `trg_concepts_log_insert` is the marker-gated v10 body, not the v9 one —
736/// [`add_concepts_rowid_pk`] still corrects it back to [`CONCEPTS_LOG_INSERT_V9`]
737/// afterwards, because that rung is about v8 and this list is about v11.
738const TRIGGERS_V11: &[&str] = &[
739 r#"
740 CREATE TRIGGER IF NOT EXISTS trg_links_current_sync
741 AFTER INSERT ON links
742 BEGIN
743 INSERT INTO links_current
744 (source_id, target_id, edge_type, valid_from, valid_to,
745 weight, properties, recorded_at)
746 VALUES
747 (NEW.source_id, NEW.target_id, NEW.edge_type, NEW.valid_from,
748 NEW.valid_to, NEW.weight, NEW.properties, NEW.recorded_at)
749 ON CONFLICT(source_id, target_id, edge_type, valid_from) DO UPDATE SET
750 valid_to = excluded.valid_to,
751 weight = excluded.weight,
752 properties = excluded.properties,
753 recorded_at = excluded.recorded_at
754 WHERE excluded.recorded_at > links_current.recorded_at;
755 END;
756 "#,
757 // The abort text is written out rather than spliced from
758 // `abort_single_open!()`, which is private to `ddl`. Held to the const by
759 // `the_pinned_v11_triggers_carry_the_messages_the_crate_declares` below,
760 // so a divergence is a red test rather than a message that reads wrong.
761 r#"
762 CREATE TRIGGER IF NOT EXISTS trg_links_single_open
763 BEFORE INSERT ON links
764 WHEN NEW.valid_to = '9999-12-31T23:59:59.999999Z'
765 AND EXISTS (
766 SELECT 1 FROM links_current
767 WHERE source_id = NEW.source_id
768 AND target_id = NEW.target_id
769 AND edge_type = NEW.edge_type
770 AND valid_from <> NEW.valid_from
771 AND valid_to = '9999-12-31T23:59:59.999999Z'
772 )
773 BEGIN
774 SELECT RAISE(ABORT, 'macrame: edge already has an open interval; retire it first');
775 END;
776 "#,
777 concat!(
778 r#"
779 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_insert
780 AFTER INSERT ON concepts
781 WHEN NOT EXISTS (
782 SELECT 1 FROM sqlite_master
783 WHERE type = 'table' AND name = '"#,
784 "macrame_archive_session",
785 r#"'
786 )
787 BEGIN
788 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
789 VALUES ('concepts', NEW.id, 'I',
790 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
791 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
792 'retired', NEW.retired,
793 'embedding_model', NEW.embedding_model),
794 NEW.recorded_at);
795 END;
796 "#
797 ),
798 r#"
799 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_update
800 AFTER UPDATE ON concepts
801 BEGIN
802 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
803 VALUES ('concepts', NEW.id, 'U',
804 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
805 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
806 'retired', NEW.retired,
807 'embedding_model', NEW.embedding_model),
808 NEW.recorded_at);
809 END;
810 "#,
811 r#"
812 CREATE TRIGGER IF NOT EXISTS trg_links_log_insert
813 AFTER INSERT ON links
814 BEGIN
815 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
816 VALUES ('links',
817 NEW.source_id || '|' || NEW.target_id || '|' || NEW.edge_type || '|' || NEW.valid_from,
818 'I',
819 json_object('v', 1, 'source_id', NEW.source_id, 'target_id', NEW.target_id,
820 'edge_type', NEW.edge_type, 'valid_from', NEW.valid_from,
821 'valid_to', NEW.valid_to, 'weight', NEW.weight,
822 'properties', json(NEW.properties)),
823 NEW.recorded_at);
824 END;
825 "#,
826];
827
828/// The trigger set a rung *below* v12 installs: today's, minus what v12
829/// introduced, plus the v11 bodies of what v12 redefined.
830///
831/// Three rungs rebuild a table and put the triggers back, and each has to put
832/// back the triggers **of its own era**. The alternative — a pinned list per
833/// rung — was rejected because those three eras are identical in every trigger
834/// that matters here, and three copies of one list is the shape [D-124] names.
835///
836/// [D-124]: ../../docs/architecture/s13-decision-register.md
837fn triggers_before_v12() -> impl Iterator<Item = &'static str> {
838 CREATE_TRIGGERS
839 .iter()
840 .copied()
841 .filter(|t| {
842 !V12_TRIGGERS
843 .iter()
844 .chain(LATER_TRIGGERS.iter())
845 .any(|name| t.contains(name))
846 })
847 .chain(TRIGGERS_V11.iter().copied())
848}
849
850/// v4 → v5: add the FTS5 index over concept text (§5.9, D-051).
851///
852/// Derivative and additive, so D-036 permits it — an FTS index over `concepts`
853/// is Doctrine VI's second category, disposable and reconstructible. The two
854/// triggers land on `concepts`, which *is* a frozen ledger table, but a trigger
855/// changes neither its columns nor its rows; the compat contract freezes the
856/// table's shape, and that is untouched.
857///
858/// Unlike the v2 → v3 rung this one **does** backfill, and can: the index is a
859/// pure function of text the ledger already holds, so `'rebuild'` reconstructs
860/// exactly what the triggers would have written had they always existed. That is
861/// the difference between this and D-041's annotations, where the old data was
862/// destroyed and no recovery existed.
863async fn add_concepts_fts(conn: &libsql::Connection) -> Result<()> {
864 conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
865 for trigger_ddl in triggers_before_v12() {
866 conn.execute(trigger_ddl, ()).await?;
867 }
868 conn.execute(REBUILD_CONCEPTS_FTS, ()).await?;
869 Ok(())
870}
871
872/// v2 → v3: add the derivative analytics table (D-041).
873///
874/// Purely additive, and additive on the *periphery* — `analytics_annotations`
875/// is Doctrine VI's second category, so D-036's freeze on the ledger tables is
876/// not in play. Nothing is backfilled: annotations written before v3 went into
877/// `concepts.content`, which is the defect, and there is no way to tell a label
878/// that landed there from the document text it replaced. Recomputing is the
879/// recovery, and recomputing is what this table exists to make cheap.
880/// **No index.** This rung used to re-issue the whole of [`CREATE_INDICES`],
881/// which it needed for exactly one entry — `idx_annotations_label`, the index
882/// on the table it creates. [D-089](../../docs/architecture/s13-decision-register.md#d-089)
883/// found nothing seeks on that index and the v7 → v8 rung dropped it, which
884/// left this loop re-issuing six indices belonging to other rungs and owning
885/// none of them. See [`create_indices`] for why that shape had to go.
886async fn add_analytics_annotations(conn: &libsql::Connection) -> Result<()> {
887 conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
888 Ok(())
889}
890
891/// v10 → v11: index the archive cutoff and the reverse-reachability arm
892/// (0.12.6, W3.1/W3.2, D-151).
893///
894/// # The first rung to index a frozen table, which is the case D-036 named
895///
896/// Every index rung before this one landed on `links_current`, a derivative
897/// table [D-036](../../docs/architecture/s13-decision-register.md#d-036) gives
898/// no stability guarantee at all. These two land on **`links`**, which is a
899/// normative ledger table and frozen. That is not an exception being taken:
900/// D-036's freeze restricts post-1.0 change on the core to *additive*
901/// operations and names `ADD COLUMN` and **new indexes** as the two that
902/// qualify. An index adds no column, moves no row, and changes no bitemporal
903/// semantics — `CREATE INDEX` reads the table and writes a b-tree beside it. A
904/// v10 database and a v11 database hold identical `links` rows.
905///
906/// So this rung is doing the thing the freeze was drafted to permit, and it is
907/// worth saying once, here, because the *next* one to touch `links` may not be.
908///
909/// # Cost
910///
911/// Two b-trees built from an existing table, so proportional to the row count
912/// and nothing else, with nothing to backfill. The standing cost is two extra
913/// index writes per ledger insert, forever, which is what
914/// [`ddl::CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES) records the
915/// measured before/after plans for and what
916/// `tests/index_plan_tests.rs` holds registry entries against.
917/// v12 → v13: the `branches` delete guard becomes marker-gated (0.14.13,
918/// §15.4, [D-230](../../docs/architecture/s13-decision-register.md#d-230)).
919///
920/// The cheapest kind of rung and the most necessary: `CREATE TRIGGER IF NOT
921/// EXISTS` on an existing name keeps the **old body**, so nothing short of an
922/// explicit drop replaces the unconditional v12 guard. Without this rung every
923/// database not created by this build would refuse
924/// [`crate::Database::archive_branch`] with a trigger abort, and `verify` —
925/// which now carries `trg_branches_frozen_delete` in [`DELETE_GUARDS`] — is
926/// what turns that into a sentence at open time instead.
927///
928/// See [`CREATE_BRANCHES_GUARD_DELETE`] for why the guard changed at all. Only
929/// the delete half moves; `trg_branches_frozen_update` is left exactly as it
930/// was, because archival is a move and not an edit.
931async fn gate_branches_delete_guard(conn: &libsql::Connection) -> Result<()> {
932 conn.execute("DROP TRIGGER IF EXISTS trg_branches_frozen_delete", ())
933 .await?;
934 conn.execute(CREATE_BRANCHES_GUARD_DELETE, ()).await?;
935 Ok(())
936}
937
938async fn add_links_archive_indices(conn: &libsql::Connection) -> Result<()> {
939 create_indices(conn, &["idx_links_recorded_at", "idx_links_target"]).await
940}
941
942/// v13 → v14: the lineage read gets an index to seek on (0.14.14, §15.4,
943/// [D-231](../../docs/architecture/s13-decision-register.md#d-231)).
944///
945/// Index-only and on a derivative table, so [D-036] permits it on the same two
946/// grounds every index rung before it stood on. Nothing is dropped:
947/// [`LC_LINEAGE_CUT`] leads on `branch_id` and the two indices already here
948/// lead on `source_id`, so no pair subsumes another.
949///
950/// **This is not the rung §15.4 owes, and the difference is the release.** The
951/// plan asked for `idx_lc_traversal_cover` to *gain* `branch_id`, and
952/// [D-219](../../docs/architecture/s13-decision-register.md#d-219) measured
953/// three placements of it. Both were reasoning about a reader that resolved
954/// with `branch_id IN (ancestry)` — the form the same probe run then showed is
955/// not a resolution at all, and which 0.14.4 consequently did not ship. Under
956/// the reader that did ship, that index is not on the branched path and the
957/// folded shape buys nothing measurable; and any shape leading on `branch_id`
958/// evicts the *trunk* walk from its covering index. See [`CREATE_INDICES`] for
959/// the numbers and the plans.
960///
961/// Nothing is backfilled, because an index has nothing to backfill — `CREATE
962/// INDEX` populates it from the table — so the cost is a function of existing
963/// row count alone.
964///
965/// [D-036]: ../../docs/architecture/s13-decision-register.md#d-036
966async fn add_lineage_cut_index(conn: &libsql::Connection) -> Result<()> {
967 create_indices(conn, &["idx_lc_lineage_cut"]).await
968}
969
970/// v16 → v17: the fold gets its partition in an index (0.15.12, W15.2, [D-254]).
971///
972/// Index-only, on a derivative table in the sense [D-036] means it, and the
973/// cheapest kind of rung there is: one `CREATE INDEX` inside the ladder's own
974/// transaction, no data movement and no shape change.
975///
976/// **It is a rung rather than a line in `CREATE_INDICES` alone** because an
977/// existing database would otherwise never get it. `CREATE_INDICES` runs on the
978/// baseline; a file created by an earlier build is brought forward only by the
979/// ladder, and a fold that is 1.39x faster on new databases and unchanged on
980/// every database anyone already has is not the improvement the measurement
981/// describes.
982///
983/// The cost is what any index rung costs on a large log: SQLite sorts the table
984/// once to build it. That is bounded by the log's size and paid once, against a
985/// sort of the same size paid by **every reconstruction** until now.
986///
987/// [D-036]: ../../docs/architecture/s13-decision-register.md#d-036
988/// [D-254]: ../../docs/architecture/s13-decision-register.md#d-254
989async fn add_fold_partition_index(conn: &libsql::Connection) -> Result<()> {
990 create_indices(conn, &["idx_txlog_fold_partition"]).await
991}
992
993/// v17 → v18: the archive gets the lineage it is archiving (0.15.30, W16.6,
994/// [D-273]).
995///
996/// Index-only and the cheapest kind of rung there is, on exactly the ground the
997/// rung above stands on: two `CREATE INDEX` statements inside the ladder's own
998/// transaction, no data movement and no shape change.
999///
1000/// **The build is cheap in a way an ordinary index rung is not.** Both are
1001/// partial — `WHERE branch_id <> 'main'` — so SQLite still reads each table once
1002/// to decide what qualifies, but what it *writes* is only the rows lineages
1003/// other than the trunk wrote. On the overwhelmingly common ledger, that is a
1004/// handful of pages against a table of millions.
1005///
1006/// A rung rather than a line in `CREATE_INDICES` alone, for the reason
1007/// [`add_fold_partition_index`] gives: `CREATE_INDICES` runs on the baseline, so
1008/// a file created by an earlier build would never see these, and an archive that
1009/// stops growing with the trunk on new databases only is not the improvement
1010/// [D-273] describes.
1011///
1012/// [D-273]: ../../docs/architecture/s13-decision-register.md#d-273
1013async fn add_branch_partition_indices(conn: &libsql::Connection) -> Result<()> {
1014 create_indices(conn, &["idx_links_branch", "idx_txlog_branch"]).await
1015}
1016
1017/// v18 → v19: the single-open probe's plan is pinned against statistics (0.16.1,
1018/// [D-274]).
1019///
1020/// One trigger body replaced, by the same two statements every trigger rung
1021/// runs. The v18 body left `branch_id = NEW.branch_id` seekable, which is
1022/// correct on a database that has statistics and a defect on one that does
1023/// not: with no `sqlite_stat1` the planner enters `idx_lc_lineage_cut` with
1024/// only `branch_id` bound and scans the lineage per firing. D-274 spells the
1025/// `+` into the body; this rung puts that body on every database the ladder
1026/// brings forward, because [`CREATE_TRIGGERS`] runs on the baseline and a file
1027/// created by an earlier build would otherwise keep the v18 body — a plan
1028/// repair that fixes new databases only is not the repair the measurement
1029/// describes, which is [`add_fold_partition_index`]'s reason restated.
1030///
1031/// # Why this is a rung and not a silent rebuild
1032///
1033/// `CREATE TRIGGER IF NOT EXISTS` on an existing name keeps the old body
1034/// (§15.2, [D-214]), so anything short of the explicit drop would stamp a v19
1035/// database whose trigger still plans the scan — the exact failure the v12 →
1036/// v13 rung documented for `trg_branches_frozen_delete`, one layer down.
1037///
1038/// [D-274]: ../../docs/architecture/s13-decision-register.md#d-274
1039async fn pin_single_open_plan(conn: &libsql::Connection) -> Result<()> {
1040 conn.execute("DROP TRIGGER IF EXISTS trg_links_single_open", ())
1041 .await?;
1042 conn.execute(CREATE_LINKS_SINGLE_OPEN, ()).await?;
1043 Ok(())
1044}
1045
1046/// The v15 shape of `links`, pinned as text (0.14.15, [D-232]).
1047///
1048/// Pinned for the reason [`LINKS_V7`] states in full, and this is the second
1049/// rung to need it. Note what the pinning buys *here specifically*: the two
1050/// rungs that rebuild this table now sit on the same ladder, and they must
1051/// produce different shapes — `LINKS_V7` has no `branch_id` at all, because at
1052/// v7 there was none. A rung reading `ddl::CREATE_LINKS_TABLE` would make both
1053/// of them produce today's, and a v6 database would arrive at v7 already
1054/// carrying a v15 key.
1055///
1056/// The `REFERENCES concepts(id)` clauses are spelled out rather than dropped
1057/// and re-added: `links` is being rebuilt, not altered, so the new table
1058/// declares them from the start and the copy satisfies them row for row.
1059const LINKS_V15: &str = r#"
1060CREATE TABLE links_v15 (
1061 source_id TEXT NOT NULL REFERENCES concepts(id),
1062 target_id TEXT NOT NULL REFERENCES concepts(id),
1063 edge_type TEXT NOT NULL,
1064 valid_from TEXT NOT NULL,
1065 recorded_at TEXT NOT NULL,
1066 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
1067 weight REAL NOT NULL DEFAULT 1.0,
1068 properties TEXT NOT NULL DEFAULT '{}',
1069 branch_id TEXT NOT NULL DEFAULT 'main' REFERENCES branches(branch_id),
1070 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at, branch_id),
1071 CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
1072 -- (the timestamp CHECK, spelled out for the same pinning reason)
1073 CHECK (valid_from GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND valid_to GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND recorded_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND 1)
1074)
1075"#;
1076
1077/// The four triggers a v14 `links` carries, by name, dropped with the table.
1078///
1079/// Enumerated for [`CONCEPTS_TRIGGERS_V7`]'s reason: a rung is a statement
1080/// about a fixed past, so a v16 trigger added to this table later cannot be
1081/// swept into a rung that predates it. They are not `DROP`ped explicitly —
1082/// `DROP TABLE` takes them — but the rung has to put exactly these back, and
1083/// the list is what says which.
1084const LINKS_TRIGGERS_V15: &[&str] = &[
1085 "trg_links_current_sync",
1086 "trg_links_single_open",
1087 "trg_links_log_insert",
1088 "trg_links_guard_delete",
1089];
1090
1091/// v14 → v15: `links` is keyed by lineage (0.14.15, §15.4, [D-232]).
1092///
1093/// # What was actually broken
1094///
1095/// Two lineages asserting one edge key at one `recorded_at` collided on
1096/// `PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at)`.
1097/// §15.4 called this "unreachable through the crate until branch-scoped writes
1098/// exist, which is 0.14.5", and left it to a later rung "to widen or to decline
1099/// in writing". **It became reachable at 0.14.8 and nothing noticed**, because
1100/// the reasoning that made it look unreachable is about the clock — successive
1101/// calls return strictly increasing values, so two sequential assertions cannot
1102/// share a stamp — and the batch paths do not make successive calls. They take
1103/// **one stamp for the whole batch** ([D-014]), deliberately, because the rows
1104/// were asserted by one act.
1105///
1106/// `reject_overlaps_within` then groups candidates by `(source, target,
1107/// edge_type, branch_id)`, so a trunk row and a branch row about one edge are
1108/// in different groups, are not an overlap, and are handed to the insert as a
1109/// legal pair. `examples/links_key_reach_probe.rs` reproduces it on both batch
1110/// surfaces and shows the caller receiving raw engine text.
1111///
1112/// Widening rather than refusing, and the choice is not close: the two
1113/// assertions are *legitimate*. Two lineages are allowed to believe different
1114/// things about one edge — that is what a lineage is — and rejecting the pair
1115/// would let a storage key decide what a caller may assert in one transaction.
1116///
1117/// # Why this is its own release
1118///
1119/// §15.4 assigned it to the same rung as an index. It is not the same size: an
1120/// index is one `CREATE INDEX` on a derivative table, and this is a rebuild of
1121/// the ledger's largest table — the operation [`LINKS_V7`] exists because of
1122/// and [D-119] had to suspend foreign keys for. Bundling the two would have
1123/// made one revert undo both.
1124///
1125/// **Measured**, since the v6 → v7 rung's cost estimate is on record as
1126/// unmeasured: create, copy, drop, rename runs in **2.7 ms at 1,000 rows,
1127/// 14.4 ms at 10,000 and 122.9 ms at 50,000** — linear, and cheap because no
1128/// trigger fires. The insert targets `links_v15`, and every trigger on this
1129/// table names `links`.
1130///
1131/// # Order, and the two things that get taken with the table
1132///
1133/// `DROP TABLE links` before the rename, for [`add_weight_check`]'s reason: the
1134/// drop takes the four triggers with it, so the rename does not reparse a
1135/// schema whose trigger bodies name a table that no longer exists.
1136///
1137/// It takes **the two indices** as well, which the v6 → v7 rung did not have to
1138/// think about — its docstring says in as many words that "no index is defined
1139/// on `links`", and that stopped being true at v11. They are put back by name
1140/// through [`create_indices`], which is also what makes this rung's failure
1141/// mode a panic naming the index rather than a database stamped v15 that fails
1142/// its own open-time verification.
1143///
1144/// [D-014]: ../../docs/architecture/s13-decision-register.md#d-014
1145/// [D-119]: ../../docs/architecture/s13-decision-register.md#d-119
1146/// [D-232]: ../../docs/architecture/s13-decision-register.md#d-232
1147async fn add_links_lineage_key(conn: &libsql::Connection) -> Result<()> {
1148 conn.execute(LINKS_V15, ()).await?;
1149 conn.execute(
1150 "INSERT INTO links_v15 (source_id, target_id, edge_type, valid_from, \
1151 recorded_at, valid_to, weight, properties, branch_id) \
1152 SELECT source_id, target_id, edge_type, valid_from, recorded_at, \
1153 valid_to, weight, properties, branch_id FROM links",
1154 (),
1155 )
1156 .await?;
1157 conn.execute("DROP TABLE links", ()).await?;
1158 conn.execute("ALTER TABLE links_v15 RENAME TO links", ())
1159 .await?;
1160
1161 // The triggers the drop took. By const and not by copy — the bodies do not
1162 // change at v15, and `CREATE_LINKS_CURRENT_SYNC` states the rule: a rung
1163 // with its own copy of a trigger is a copy that drifts. `LINKS_TRIGGERS_V15`
1164 // is what pins *which four*, which is the half a later rung will need.
1165 for ddl in [
1166 CREATE_LINKS_CURRENT_SYNC,
1167 CREATE_LINKS_SINGLE_OPEN,
1168 CREATE_LINKS_LOG_INSERT,
1169 CREATE_LINKS_GUARD_DELETE,
1170 ] {
1171 conn.execute(ddl, ()).await?;
1172 }
1173 debug_assert_eq!(LINKS_TRIGGERS_V15.len(), 4);
1174
1175 // And the two indices, which `DROP TABLE` took with equally little noise.
1176 create_indices(conn, &["idx_links_recorded_at", "idx_links_target"]).await
1177}
1178
1179/// v5 → v6: index the single-open-interval probe (D-059).
1180///
1181/// Index-only and on a derivative table, so D-036 permits it on the same two
1182/// grounds the v3 → v4 rung stood on. Nothing is dropped this time: the new
1183/// index and `idx_lc_traversal_cover` serve different shapes — one needs three
1184/// equality columns bound, the other leads on `source_id` alone — so neither
1185/// subsumes the other and keeping both is the point rather than an oversight.
1186///
1187/// **This is the largest measured win in the tree and it sat proven and
1188/// unshipped for a full cycle**, on the stated ground that an index is a schema
1189/// change wanting its own rung. That was a description of the work rather than
1190/// an objection to it. See [`CREATE_INDICES`] for the numbers.
1191///
1192/// Nothing is backfilled because an index has nothing to backfill; `CREATE
1193/// INDEX` populates it from the table. That makes this the cheapest rung on the
1194/// ladder and the only one whose cost is a function of existing row count alone.
1195async fn add_open_interval_index(conn: &libsql::Connection) -> Result<()> {
1196 create_indices(conn, &["idx_lc_open_interval"]).await
1197}
1198
1199/// The v7 shape of `links`, pinned as text (T2.1, D-083).
1200///
1201/// **Deliberately not `ddl::CREATE_LINKS_TABLE`.** Every other rung on this
1202/// ladder reuses the DDL constants, and for those it is right — they create an
1203/// index or a derivative table, and getting today's definition is the point. A
1204/// *table rebuild* is different: it produces whatever shape the constant names
1205/// at the moment it runs, so the day `links` gains a v8 column, this rung would
1206/// silently take a v6 database straight to the v8 shape and stamp it v7. The
1207/// ladder would then have two databases both stamped v7 with different columns,
1208/// and the v7 → v8 rung would run against a table that already had its change.
1209///
1210/// A migration rung is a statement about the past. Pinning the text is what
1211/// makes it one.
1212const LINKS_V7: &str = r#"
1213CREATE TABLE links_v7 (
1214 source_id TEXT NOT NULL REFERENCES concepts(id),
1215 target_id TEXT NOT NULL REFERENCES concepts(id),
1216 edge_type TEXT NOT NULL,
1217 valid_from TEXT NOT NULL,
1218 recorded_at TEXT NOT NULL,
1219 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
1220 weight REAL NOT NULL DEFAULT 1.0,
1221 properties TEXT NOT NULL DEFAULT '{}',
1222 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at),
1223 CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
1224 -- (the timestamp CHECK, spelled out for the same pinning reason)
1225 CHECK (valid_from GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND valid_to GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND recorded_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND 1)
1226)
1227"#;
1228
1229/// v6 → v7: constrain `links.weight` (§4.7, T2.1, D-083).
1230///
1231/// # The only rung that rewrites a ledger table, and what that costs
1232///
1233/// SQLite has no `ADD CONSTRAINT`, so this is a full rebuild of `links` — the
1234/// largest table in the schema — inside [`apply_step`]'s single transaction:
1235/// create, copy, drop, rename, recreate triggers. Cost is O(rows) in time and
1236/// roughly 2× `links` in peak disk. Every other rung on this ladder is index
1237/// work or an additive table; this one is not, and a caller upgrading a large
1238/// database should expect it to take a while and to need the space.
1239///
1240/// **That 2× is an estimate and is still unmeasured**, flagged here in 0.8.0
1241/// when the *concepts* rung below it was measured properly
1242/// ([D-125](../../docs/architecture/s13-decision-register.md)). Do not read
1243/// across from that measurement: the concepts rung peaks at 1.09× the whole
1244/// file precisely because `concepts` is a small share of it, and this rung
1245/// rebuilds the share that is large. If anyone needs the real number,
1246/// `examples/v8_migration_scale_probe.rs` is the shape to copy — it needs a v6
1247/// fixture instead of a v7 one.
1248///
1249/// It is taken **pre-1.0 on purpose**. D-032 makes this a baseline re-issue
1250/// today, which is cheap; after 1.0 the compat contract (D-036) freezes the
1251/// ledger tables and the same change becomes an unmigration.
1252///
1253/// # Doctrine III is not violated, and the case where it would be is refused
1254///
1255/// A rebuild that *altered* an assertion would be exactly what Doctrine III
1256/// forbids. This one copies every row verbatim — no clamping, no rounding, no
1257/// dropping. Which means a database already holding a weight the new constraint
1258/// rejects cannot be migrated at all, and this refuses **before** touching
1259/// anything, with a count and an example, rather than failing halfway through a
1260/// copy with a bare `CHECK constraint failed`.
1261///
1262/// Such rows are reachable: until this rung, `assert_edge(weight = -1.0)` was
1263/// accepted by the write API and refused only at load time (§4.7). That was the
1264/// gap. An operator who has them must decide what those assertions meant, and
1265/// that is not a decision a migration can take for them.
1266///
1267/// # Order, and the trap it avoids
1268///
1269/// `DROP TABLE links` first, then rename. Dropping the table takes its four
1270/// triggers with it, so the rename does not reparse a schema containing trigger
1271/// bodies that name a table which no longer exists — the failure T1.2 hit from
1272/// the other direction. All triggers are `IF NOT EXISTS`, so re-running the
1273/// whole array afterwards recreates the four on `links` and no-ops the rest.
1274/// No index is defined on `links`, so there is none to rebuild.
1275async fn add_weight_check(conn: &libsql::Connection) -> Result<()> {
1276 let offending: i64 = conn
1277 .query(
1278 "SELECT COUNT(*) FROM links WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real')",
1279 (),
1280 )
1281 .await?
1282 .next()
1283 .await?
1284 .and_then(|r| r.get(0).ok())
1285 .unwrap_or(0);
1286
1287 if offending > 0 {
1288 let example: Option<String> = conn
1289 .query(
1290 "SELECT source_id || ' -> ' || target_id || ' (' || edge_type || \
1291 ') weight=' || CAST(weight AS TEXT) FROM links \
1292 WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real') LIMIT 1",
1293 (),
1294 )
1295 .await?
1296 .next()
1297 .await?
1298 .and_then(|r| r.get(0).ok());
1299
1300 return Err(DbError::Migration {
1301 to: 7,
1302 reason: format!(
1303 "{offending} row(s) in `links` hold a weight the v7 constraint \
1304 rejects, e.g. {}. Copying them verbatim is impossible and \
1305 altering them would violate Doctrine III, so this migration \
1306 refuses rather than choosing on your behalf. These rows were \
1307 writable through `assert_edge` before v7 (§4.7) — decide what \
1308 they were meant to assert, archive them, and retry.",
1309 example.as_deref().unwrap_or("<unreadable>")
1310 ),
1311 });
1312 }
1313
1314 conn.execute(LINKS_V7, ()).await?;
1315 conn.execute(
1316 "INSERT INTO links_v7 (source_id, target_id, edge_type, valid_from, \
1317 recorded_at, valid_to, weight, properties) \
1318 SELECT source_id, target_id, edge_type, valid_from, recorded_at, \
1319 valid_to, weight, properties FROM links",
1320 (),
1321 )
1322 .await?;
1323 conn.execute("DROP TABLE links", ()).await?;
1324 conn.execute("ALTER TABLE links_v7 RENAME TO links", ())
1325 .await?;
1326
1327 for trigger_ddl in triggers_before_v12() {
1328 conn.execute(trigger_ddl, ()).await?;
1329 }
1330
1331 Ok(())
1332}
1333
1334/// The v8 shape of `concepts`, pinned as text (B4, D-119).
1335///
1336/// Pinned for the reason [`LINKS_V7`] states: a rung that rebuilds a table must
1337/// produce the shape that rung is *about*, not whatever
1338/// [`CREATE_CONCEPTS_TABLE`] happens to say the day it runs. A migration rung is
1339/// a statement about the past.
1340const CONCEPTS_V8: &str = r#"
1341CREATE TABLE concepts_v8 (
1342 rowid_pk INTEGER PRIMARY KEY,
1343 id TEXT NOT NULL UNIQUE,
1344 title TEXT NOT NULL,
1345 content TEXT NOT NULL DEFAULT '',
1346 embedding_model TEXT,
1347 valid_from TEXT NOT NULL,
1348 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
1349 recorded_at TEXT NOT NULL,
1350 retired INTEGER NOT NULL DEFAULT 0,
1351 -- (the timestamp CHECK, spelled out for the same pinning reason)
1352 CHECK (valid_from GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND valid_to GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND recorded_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z' AND 1)
1353)
1354"#;
1355
1356/// The six triggers a v7 `concepts` carries, dropped by name before the rebuild.
1357///
1358/// By name and not by discovery: a rung is a statement about the past, and the
1359/// past is a fixed set. Enumerating what v7 had means a v9 trigger added later
1360/// cannot be silently swept up by a `DROP` loop over `sqlite_master`.
1361const CONCEPTS_TRIGGERS_V7: &[&str] = &[
1362 "trg_concepts_monotonic_ra",
1363 "trg_concepts_log_insert",
1364 "trg_concepts_log_update",
1365 "trg_concepts_guard_delete",
1366 "trg_concepts_fts_insert",
1367 "trg_concepts_fts_update",
1368];
1369
1370/// The concepts delete guard **as v8 had it**: unconditional, aborting every
1371/// physical delete (0.9.0, C2).
1372///
1373/// Pinned here for the same reason [`CONCEPTS_V8`] and [`CONCEPTS_TRIGGERS_V7`]
1374/// are, and the reason is easy to miss. [`add_concepts_rowid_pk`] rebuilds
1375/// `concepts` and puts the triggers back by looping over [`CREATE_TRIGGERS`] —
1376/// which is *today's* DDL, and today's guard is marker-gated. Left alone, the
1377/// `v7 → v8` rung would install a trigger body that did not exist at v8, so a
1378/// database the ladder reports as v8 would not be a v8 database.
1379///
1380/// Harmless in the common path, because `run` never rests at an intermediate
1381/// version — a v7 file climbs 7 → 8 → 9 in one call and the next rung replaces
1382/// this body anyway. It is pinned regardless, because a rung is a statement
1383/// about the past and a rung that quietly writes the present into it cannot be
1384/// tested against a fixture: `a_v7_database_climbs_to_v8_and_gains_rowid_pk`
1385/// would have been asserting against whatever the current release happened to
1386/// think, which is the failure mode this whole file is built to avoid.
1387const CONCEPTS_GUARD_DELETE_V8: &str = r#"
1388 CREATE TRIGGER IF NOT EXISTS trg_concepts_guard_delete
1389 BEFORE DELETE ON concepts
1390 BEGIN
1391 SELECT RAISE(ABORT, 'macrame: concepts are never physically archived (D-022)');
1392 END;
1393"#;
1394
1395/// v7 → v8: `concepts` gains `rowid_pk`, the FTS index gains its third trigger,
1396/// and the two indices with no reader are dropped (B4, D-118, D-119).
1397///
1398/// # Why this rung must be taken pre-1.0 or never
1399///
1400/// `rowid_pk INTEGER PRIMARY KEY` means `id` stops being the primary key, and
1401/// SQLite allows exactly one per table. That is a **primary-key change**, which
1402/// [D-036](../../docs/architecture/s13-decision-register.md) forbids outright
1403/// after 1.0 and classes as needing a major version with an explicit ETL path.
1404/// Pre-1.0, D-032 makes it a baseline re-issue. There is no third option and no
1405/// later cheap moment.
1406///
1407/// # What it buys
1408///
1409/// `concepts_fts` is external-content keyed on `concepts`'s rowid, which through
1410/// v7 was **implicit** — and `VACUUM` renumbers implicit rowids, decoupling the
1411/// index from its rows with no error and no integrity-check failure. D-071
1412/// showed the hazard unreachable today only because the delete guard is
1413/// unconditional, so rowids are dense and the renumbering is the identity map.
1414/// 0.9.0's archival makes them sparse. This installs the fix while the fix is
1415/// still free, and installs `trg_concepts_fts_delete` in the same rung.
1416///
1417/// **What it does not buy, corrected in place.** This paragraph read "*so 0.9.0
1418/// needs no migration of its own*". That is wrong (D-126). The rung ships C2's
1419/// steps 1 and 3; step 2 — `trg_concepts_guard_delete` becoming marker-gated —
1420/// still needs a `v8 → v9` rung of its own, since re-issuing the baseline keeps
1421/// the old trigger body and `verify` would not notice. It is cheap (a `DROP
1422/// TRIGGER` and a `CREATE`, no table rebuild) but it is not nothing.
1423///
1424/// # Why it needs `suspends_foreign_keys`, and what still checks the result
1425///
1426/// `concepts` has inbound foreign keys from `links` (twice),
1427/// `analytics_annotations` and every registered `embeddings_*` table, so the
1428/// `links`-style rebuild is not available: the `DROP TABLE` fails with keys on,
1429/// and the three obvious ways to turn them off inside the transaction all fail
1430/// differently. See [`Step::suspends_foreign_keys`] for the four measured
1431/// refutations. [`apply_step`] therefore toggles the pragma around the
1432/// transaction and runs `PRAGMA foreign_key_check` inside it before committing.
1433///
1434/// **One consequence worth stating.** That check reports violations across the
1435/// whole database, not only ones this rung could have caused. A v7 file that
1436/// already held an orphaned `links` row — reachable only if it was written with
1437/// enforcement off — will fail to migrate. That is the right outcome and it is
1438/// not a silent one: the error names the table.
1439///
1440/// # Order, and the two traps in it
1441///
1442/// The triggers and `concepts_fts` come down **before** the table is touched,
1443/// not after. Recreating the triggers while the old FTS table was still present
1444/// would bind them to an index about to be dropped, and dropping `concepts_fts`
1445/// while triggers still named it is the schema-reparse failure the `links` rung
1446/// hit from the other direction. So: indices, triggers, FTS, then the rebuild,
1447/// then the new FTS, then the triggers, then the rebuild of the index content.
1448///
1449/// `rowid` is copied into `rowid_pk` **by value** rather than left to
1450/// auto-assign. On today's dense numbering the two agree, so this looks
1451/// redundant; it is what makes the rung correct on a file whose rowids are not
1452/// dense, and it means the migration preserves row identity rather than merely
1453/// preserving row order.
1454///
1455/// # What it costs, measured (0.8.0, [D-125])
1456///
1457/// This rung rewrites a ledger table on somebody's data while holding the write
1458/// lock, so the operator's two questions are how long they are down and how much
1459/// free disk they need first. Both are measured rather than estimated —
1460/// `cargo run --release --example v8_migration_scale_probe`, four scales up to
1461/// 200k concepts / 600k links / 800k log rows (a 733 MiB file):
1462///
1463/// * **Time is linear at ~10–13 µs per concept**, 2.7 s at 200k. It scales with
1464/// `concepts`, not with the file.
1465/// * **Peak disk is 1.09× the starting file**, flat across every scale, and it
1466/// **settles back to 1.00×** after a checkpoint. So the rung wants ~10%
1467/// headroom transiently and keeps none of it. The intuition that a
1468/// copy-and-swap needs 2× is right about the *table* and wrong about the
1469/// *file*, because `concepts` is a small share of a database whose bulk is
1470/// `links` and `transaction_log`.
1471/// * **[`suspends_foreign_keys`]'s `PRAGMA foreign_key_check` is 13–17% of the
1472/// rung**, a stable share. It is a whole-database scan, so unlike the rest of
1473/// the rung it grows with `links` and the log rather than with `concepts` —
1474/// on a database with an unusually large ledger relative to its concepts it
1475/// will dominate.
1476///
1477/// The `links` rung above still carries an *estimated* 2×, which this
1478/// measurement does not transfer to: that one rebuilds the big table, and the
1479/// ratio that makes this rung cheap is exactly what makes that one expensive.
1480async fn add_concepts_rowid_pk(conn: &libsql::Connection) -> Result<()> {
1481 // (a) The two indices with no reader (D-089, completed by D-118).
1482 conn.execute("DROP INDEX IF EXISTS idx_annotations_label", ())
1483 .await?;
1484 conn.execute("DROP INDEX IF EXISTS idx_lc_tgt_active", ())
1485 .await?;
1486
1487 // (b) Clear the way: triggers, then the FTS index, then the table.
1488 for name in CONCEPTS_TRIGGERS_V7 {
1489 conn.execute(&format!("DROP TRIGGER IF EXISTS {name}"), ())
1490 .await?;
1491 }
1492 conn.execute("DROP TABLE IF EXISTS concepts_fts", ())
1493 .await?;
1494
1495 conn.execute(CONCEPTS_V8, ()).await?;
1496 conn.execute(
1497 "INSERT INTO concepts_v8 (rowid_pk, id, title, content, embedding_model, \
1498 valid_from, valid_to, recorded_at, retired) \
1499 SELECT rowid, id, title, content, embedding_model, \
1500 valid_from, valid_to, recorded_at, retired \
1501 FROM concepts ORDER BY rowid",
1502 (),
1503 )
1504 .await?;
1505 conn.execute("DROP TABLE concepts", ()).await?;
1506 conn.execute("ALTER TABLE concepts_v8 RENAME TO concepts", ())
1507 .await?;
1508
1509 // (c) Put it back, in the order the trigger bodies require.
1510 conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
1511 for trigger_ddl in triggers_before_v12() {
1512 conn.execute(trigger_ddl, ()).await?;
1513 }
1514
1515 // (d) …then correct the one trigger the loop above gets wrong. `CREATE_TRIGGERS`
1516 // is today's DDL, and today's concepts guard is marker-gated (C2); v8's was
1517 // unconditional. See `CONCEPTS_GUARD_DELETE_V8`. The `IF NOT EXISTS` in both
1518 // bodies is why this needs the explicit DROP: without it the loop's version
1519 // stays, because a re-issue of an existing name keeps the old body.
1520 conn.execute("DROP TRIGGER IF EXISTS trg_concepts_guard_delete", ())
1521 .await?;
1522 conn.execute(CONCEPTS_GUARD_DELETE_V8, ()).await?;
1523 conn.execute("DROP TRIGGER IF EXISTS trg_concepts_log_insert", ())
1524 .await?;
1525 conn.execute(CONCEPTS_LOG_INSERT_V9, ()).await?;
1526
1527 conn.execute(REBUILD_CONCEPTS_FTS, ()).await?;
1528
1529 Ok(())
1530}
1531
1532/// `trg_concepts_log_insert` **as v9 had it**: unconditional (0.9.0, C3).
1533///
1534/// Pinned for the same reason as [`CONCEPTS_GUARD_DELETE_V8`], and the reason
1535/// bites harder here: [`add_concepts_rowid_pk`] restores triggers from
1536/// [`CREATE_TRIGGERS`], so without this the v7 → v8 rung would install the v10
1537/// body — a database the ladder calls v8 whose concept inserts stop logging
1538/// inside a session, three versions before that behaviour was decided.
1539const CONCEPTS_LOG_INSERT_V9: &str = r#"
1540 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_insert
1541 AFTER INSERT ON concepts
1542 BEGIN
1543 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
1544 VALUES ('concepts', NEW.id, 'I',
1545 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
1546 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
1547 'retired', NEW.retired,
1548 'embedding_model', NEW.embedding_model),
1549 NEW.recorded_at);
1550 END;
1551"#;
1552
1553/// v9 → v10: the concepts insert log trigger becomes marker-gated (C3).
1554///
1555/// Two statements, like the rung below, and necessary for a reason that is not
1556/// tidiness. See [`CREATE_CONCEPTS_LOG_INSERT`]: an unlogged insert is what makes
1557/// rehydration a *move* rather than a write, and without it a rehydrated concept
1558/// outranks its own retirement in the fold and comes back alive.
1559async fn gate_concepts_log_insert_on_marker(conn: &libsql::Connection) -> Result<()> {
1560 conn.execute("DROP TRIGGER IF EXISTS trg_concepts_log_insert", ())
1561 .await?;
1562 conn.execute(CREATE_CONCEPTS_LOG_INSERT, ()).await?;
1563 Ok(())
1564}
1565
1566/// v8 → v9: the concepts delete guard becomes marker-gated (C2, D-126).
1567///
1568/// The whole rung is two statements, and the first is the one that matters.
1569/// `CREATE TRIGGER IF NOT EXISTS` on an existing name **keeps the old body** —
1570/// verified against libSQL 0.9.30, not assumed — so re-issuing the baseline
1571/// against a v8 database leaves the unconditional guard exactly where it was.
1572/// The `DROP` is therefore not tidiness; it is the only thing that makes the
1573/// rung do anything at all. That, plus [`verify`] having compared trigger names
1574/// and never bodies, is why D-126 could conclude this needs a rung rather than a
1575/// baseline re-issue: without both, a v8 database opened by 0.9.0 code would
1576/// carry the old guard, pass verification in silence, and then refuse concept
1577/// archival at the trigger.
1578///
1579/// No table is rebuilt and no row moves, so this costs a schema write and
1580/// nothing else — a `DROP TRIGGER` and a `CREATE TRIGGER`, independent of how
1581/// large the database is. It is the cheapest rung this ladder has.
1582async fn gate_concepts_guard_on_marker(conn: &libsql::Connection) -> Result<()> {
1583 conn.execute("DROP TRIGGER IF EXISTS trg_concepts_guard_delete", ())
1584 .await?;
1585 conn.execute(CREATE_CONCEPTS_GUARD_DELETE, ()).await?;
1586 Ok(())
1587}
1588
1589/// The entries of [`CREATE_INDICES`] a rung names, created in declaration
1590/// order (0.14.14, D-231).
1591///
1592/// # Why a rung names its indices instead of running the list
1593///
1594/// Every index rung before v14 ran the whole of [`CREATE_INDICES`], which was
1595/// correct for eleven versions and stopped being correct silently. A rung is a
1596/// statement about the schema at *its* version, and that loop makes it a
1597/// statement about the schema at **today's** — so the moment v14 declared an
1598/// index over `links_current.branch_id`, the v3 → v4, v5 → v6 and v10 → v11
1599/// rungs all began failing with `no such column: branch_id` on databases that
1600/// legitimately had no such column yet. Ten migration tests, one cause.
1601///
1602/// The blanket loop was never load-bearing: each of those rungs owes exactly
1603/// the indices its own decision record names, and the extras it re-issued were
1604/// already there under `IF NOT EXISTS`. So this takes the DDL from the one
1605/// place that declares it and lets the rung say which of it applies, which is
1606/// what [`ddl::LC_TRAVERSAL_COVER`](crate::schema::ddl::CREATE_INDICES)'s own
1607/// note already argued for: *"a rung should state which indices it owes rather
1608/// than derive the list from a definition that will keep changing after it."*
1609///
1610/// A name that matches no declaration is a panic rather than a silent no-op,
1611/// because the failure it prevents — a rung that creates nothing and stamps a
1612/// version anyway — is exactly the one `verify` had to be written to catch.
1613async fn create_indices(conn: &libsql::Connection, names: &[&str]) -> Result<()> {
1614 for name in names {
1615 let ddl = CREATE_INDICES
1616 .iter()
1617 .find(|sql| sql.contains(name))
1618 .unwrap_or_else(|| panic!("{name} is not declared in ddl::CREATE_INDICES"));
1619 conn.execute(ddl, ()).await?;
1620 }
1621 Ok(())
1622}
1623
1624/// v3 → v4: swap `idx_lc_src_active` for the traversal covering index (D-042).
1625///
1626/// Index-only, and on a derivative table, so D-036 permits it twice over. The
1627/// drop is the point as much as the create: the new index has the same seek
1628/// column and strictly more payload, so keeping the old one would cost a second
1629/// index write on every assertion and buy nothing. Order matters only for peak
1630/// disk — create first so the traversal is never left without an index at all,
1631/// even though the whole rung is one transaction.
1632async fn add_traversal_cover(conn: &libsql::Connection) -> Result<()> {
1633 create_indices(conn, &["idx_lc_traversal_cover"]).await?;
1634 conn.execute("DROP INDEX IF EXISTS idx_lc_src_active", ())
1635 .await?;
1636 Ok(())
1637}
1638
1639/// The tables the baseline declares, by name, for [`verify`].
1640pub(crate) const BASELINE_TABLES: &[&str] = &[
1641 "branches",
1642 "concepts",
1643 "links",
1644 "links_current",
1645 "transaction_log",
1646 "analytics_annotations",
1647 "concepts_fts",
1648 // v16 (W14.5, D-249).
1649 "log_integrity",
1650];
1651
1652/// Confirm the database actually holds what the DDL claims to create.
1653///
1654/// Cheap insurance against the failure mode `IF NOT EXISTS` is built to hide: a
1655/// statement that no-ops instead of creating. It also catches the DDL arrays and
1656/// reality drifting apart — add a trigger to [`CREATE_TRIGGERS`] that fails to
1657/// compile as written and it is missing here rather than at the first write that
1658/// needed it.
1659///
1660/// **Presence by name, not a count of everything present.** The original
1661/// counted `sqlite_master` and required exactly four tables, which made
1662/// verification fail on any database carrying an object the baseline did not
1663/// create — and this schema now has three legitimate sources of those. A
1664/// registered embedding model adds `embeddings_<model>` (§4.1); libSQL's vector
1665/// index adds `libsql_vector_meta_shadow`, a shadow table and a shadow index of
1666/// its own; and D-036 explicitly permits post-1.0 migrations to add indexes. A
1667/// count treats all three as corruption and refuses to open a healthy file. What
1668/// verification is actually for is the absence of something required, so that is
1669/// what it now checks.
1670async fn verify(conn: &libsql::Connection) -> Result<()> {
1671 let mut rows = conn
1672 .query(
1673 "SELECT type, name, COALESCE(sql, '') FROM sqlite_master \
1674 WHERE type IN ('table','trigger','index')",
1675 (),
1676 )
1677 .await?;
1678
1679 let mut present: Vec<(String, String)> = Vec::new();
1680 let mut bodies: Vec<(String, String)> = Vec::new();
1681 let mut links_sql = String::new();
1682 while let Some(row) = rows.next().await? {
1683 let (kind, name, sql): (String, String, String) = (row.get(0)?, row.get(1)?, row.get(2)?);
1684 if kind == "table" && name.eq_ignore_ascii_case("links") {
1685 links_sql = sql.clone();
1686 }
1687 if kind == "trigger" {
1688 bodies.push((name.clone(), sql));
1689 }
1690 present.push((kind, name));
1691 }
1692 let has = |kind: &str, name: &str| {
1693 present
1694 .iter()
1695 .any(|(k, n)| k == kind && n.eq_ignore_ascii_case(name))
1696 };
1697
1698 let mut missing: Vec<String> = Vec::new();
1699 for table in BASELINE_TABLES {
1700 if !has("table", table) {
1701 missing.push(format!("table {table}"));
1702 }
1703 }
1704 for name in trigger_names() {
1705 if !has("trigger", &name) {
1706 missing.push(format!("trigger {name}"));
1707 }
1708 }
1709 for name in index_names() {
1710 if !has("index", &name) {
1711 missing.push(format!("index {name}"));
1712 }
1713 }
1714
1715 if !missing.is_empty() {
1716 return Err(DbError::Migration {
1717 to: SCHEMA_VERSION,
1718 reason: format!(
1719 "schema verification failed: the database is stamped v{SCHEMA_VERSION} \
1720 but is missing {}: {}",
1721 missing.len(),
1722 missing.join(", ")
1723 ),
1724 });
1725 }
1726
1727 // `links` is checked by **key**, not only by name (0.14.15, D-232), and the
1728 // reason is the one D-126 gives below for the delete guards: presence was
1729 // never the property that mattered.
1730 //
1731 // A table's primary key has no name for the loop above to look for. So a
1732 // database stamped v15 whose `links` still carries the v14 key — a stamp
1733 // written by hand, a restore from a file that never climbed, a rung that
1734 // silently no-opped — opens cleanly, reads correctly, and then refuses one
1735 // legal batch write in a hundred with raw engine text. That is precisely
1736 // the shape v15 exists to remove, and every other v15 object is present, so
1737 // nothing else here would notice.
1738 //
1739 // The probe is the column name inside the `PRIMARY KEY` clause and not the
1740 // table's whole text, for D-126's reason: a full-text comparison fails on
1741 // whitespace and has to be re-pinned every time a comment moves, which
1742 // makes it the kind of check people disable.
1743 let keyed_by_lineage = links_sql
1744 .split_once("PRIMARY KEY")
1745 .and_then(|(_, rest)| rest.split_once(')'))
1746 .is_some_and(|(key, _)| key.contains("branch_id"));
1747 if !links_sql.is_empty() && !keyed_by_lineage {
1748 return Err(DbError::Migration {
1749 to: SCHEMA_VERSION,
1750 reason: format!(
1751 "schema verification failed: the database is stamped \
1752 v{SCHEMA_VERSION} but `links` is not keyed by lineage. Its \
1753 primary key must end in `branch_id` (v15); without it a batch \
1754 asserting one edge key on two lineages collides, because the \
1755 batch paths share one `recorded_at` by contract. Upgrading \
1756 through the ladder rebuilds the table."
1757 ),
1758 });
1759 }
1760
1761 // The three delete guards are checked by *body*, not only by name (0.9.0,
1762 // C2, D-126). Presence was never the property that mattered for these: a
1763 // guard with the right name and the wrong body is a guard that refuses a
1764 // legal archive or permits an illegal delete, and the check above cannot
1765 // see the difference. That is not hypothetical — it is exactly what
1766 // `CREATE TRIGGER IF NOT EXISTS` produces when a baseline is re-issued
1767 // against a database whose guard predates a change, and it is the reason
1768 // the concepts guard needed a rung instead.
1769 //
1770 // The probe is `macrame_archive_session`, not the trigger's whole text.
1771 // Comparing full bodies would fail on whitespace and would have to be
1772 // updated by hand every time a guard is reworded, which makes it the kind
1773 // of check people disable. What is asserted is the one property all three
1774 // share and none may lose: **this guard is gated on the archive session.**
1775 let ungated: Vec<&str> = DELETE_GUARDS
1776 .iter()
1777 .filter(|name| {
1778 bodies
1779 .iter()
1780 .find(|(n, _)| n.eq_ignore_ascii_case(name))
1781 .is_none_or(|(_, sql)| !sql.contains(ARCHIVE_SESSION_MARKER))
1782 })
1783 .copied()
1784 .collect();
1785
1786 if !ungated.is_empty() {
1787 return Err(DbError::Migration {
1788 to: SCHEMA_VERSION,
1789 reason: format!(
1790 "schema verification failed: the database is stamped v{SCHEMA_VERSION} \
1791 but {} delete guard(s) do not probe the archive-session marker: {}. \
1792 A guard with the right name and a pre-v9 body refuses archival it \
1793 should permit; upgrading through the ladder replaces it.",
1794 ungated.len(),
1795 ungated.join(", ")
1796 ),
1797 });
1798 }
1799
1800 // The guards above are checked for being *gated*. This checks that the gate
1801 // is not currently open (0.10.0, W2).
1802 //
1803 // A committed `macrame_archive_session` disarms all three delete guards and
1804 // silences the concepts log-insert trigger — Doctrine IV and Doctrine V
1805 // suspended at once, with no error and no counter. Nothing checked for it,
1806 // and the safety argument on record (§5.7) is about *crashes*: it is correct
1807 // about those, because both archive paths bracket the marker inside the
1808 // session transaction, so a rollback discards it. It says nothing about a
1809 // writer that creates the table directly, and §4.7 concedes raw writers.
1810 //
1811 // Free to check here: `present` is already built, so this is one more scan
1812 // of a vector, not another query. And it is safe *here specifically* —
1813 // `verify` reads committed state at open, so it cannot observe an in-flight
1814 // session and refuse a healthy database mid-archive. Moving it onto a path
1815 // that runs during a session would break that.
1816 if has("table", ARCHIVE_SESSION_MARKER) {
1817 return Err(DbError::ArchiveSessionLeaked {
1818 marker: ARCHIVE_SESSION_MARKER.to_string(),
1819 });
1820 }
1821
1822 Ok(())
1823}
1824
1825/// The delete guards, whose bodies [`verify`] checks rather than only their
1826/// names.
1827///
1828/// Listed rather than discovered, on the same reasoning as
1829/// [`CONCEPTS_TRIGGERS_V7`]: the property being asserted is that *these three*
1830/// tables cannot lose rows outside an archive session, and a loop over whatever
1831/// happens to be named `*_guard_delete` would assert whatever the schema
1832/// happens to contain.
1833const DELETE_GUARDS: &[&str] = &[
1834 "trg_concepts_guard_delete",
1835 "trg_links_guard_delete",
1836 "trg_txlog_guard_delete",
1837 // v13 (0.14.13, §15.4, D-230). The list was three names and one sentence —
1838 // *branches are never archived* — for eight releases; `archive_branch` is
1839 // what made the fourth name belong here, and carrying it is what makes a
1840 // v12 database's stale unconditional guard a refusal at open rather than a
1841 // trigger abort in the middle of the first abandonment.
1842 "trg_branches_frozen_delete",
1843];
1844
1845/// The object names the DDL creates, recovered from the DDL itself.
1846///
1847/// Parsed rather than listed separately so that adding a trigger to
1848/// [`CREATE_TRIGGERS`] extends what `verify` requires, with no second list to
1849/// remember. A hand-kept list of names beside the statements that create them is
1850/// the drift D-035 is about.
1851fn names_after(ddl: &[&str], keyword: &str) -> Vec<String> {
1852 ddl.iter()
1853 .filter_map(|stmt| {
1854 let lower = stmt.to_ascii_lowercase();
1855 let at = lower.find(keyword)? + keyword.len();
1856 Some(
1857 stmt[at..]
1858 .split_whitespace()
1859 .next()?
1860 .trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
1861 .to_string(),
1862 )
1863 })
1864 .filter(|n| !n.is_empty())
1865 .collect()
1866}
1867
1868fn trigger_names() -> Vec<String> {
1869 names_after(CREATE_TRIGGERS, "create trigger if not exists ")
1870}
1871
1872fn index_names() -> Vec<String> {
1873 names_after(CREATE_INDICES, "create index if not exists ")
1874}
1875
1876async fn read_user_version(conn: &libsql::Connection) -> Result<u32> {
1877 // PRAGMA user_version yields a row, so it must go through query(), not
1878 // execute() -- libsql rejects a statement that returns rows from execute().
1879 let mut rows = conn.query("PRAGMA user_version", ()).await?;
1880 match rows.next().await? {
1881 Some(row) => Ok(row.get::<u32>(0)?),
1882 None => Ok(0),
1883 }
1884}
1885
1886#[cfg(test)]
1887mod tests {
1888 use super::*;
1889
1890 /// A rung that does not advance the version would spin `run`'s loop forever.
1891 #[test]
1892 fn every_step_advances() {
1893 for step in STEPS {
1894 assert!(
1895 step.to > step.from,
1896 "step {:?} does not advance ({} -> {})",
1897 step.name,
1898 step.from,
1899 step.to
1900 );
1901 }
1902 }
1903
1904 /// Two rungs out of the same version make the ladder ambiguous: `run` takes
1905 /// whichever comes first in the array, which is not a decision anyone made.
1906 #[test]
1907 fn no_two_steps_share_an_origin() {
1908 for (i, a) in STEPS.iter().enumerate() {
1909 for b in &STEPS[i + 1..] {
1910 assert_ne!(a.from, b.from, "two steps start at v{}", a.from);
1911 }
1912 }
1913 }
1914
1915 /// The ladder has to actually reach the version this build stamps, or every
1916 /// fresh open fails with "no migration step leads out of v0".
1917 ///
1918 /// **Starting at v2 and not at v0, which is the whole test** (0.14.14,
1919 /// [D-231](../../docs/architecture/s13-decision-register.md#d-231)). The
1920 /// baseline step is `from: 0, to: SCHEMA_VERSION`, so a walk beginning at
1921 /// v0 takes it, lands on the top, and reports success — *on any `STEPS`
1922 /// array whatsoever*, including one with every incremental rung deleted.
1923 /// The previous version of this test did exactly that: removing the v13 →
1924 /// v14 rung left it green while ten integration tests went red. It was
1925 /// checking that the baseline is the baseline.
1926 ///
1927 /// The walk that means something starts at the lowest version a *stored*
1928 /// database can hold. That is v2: v1 is pre-canonical and refused
1929 /// deliberately, which [`legacy_v1_has_no_rung`]
1930 /// pins from the other side.
1931 #[test]
1932 fn the_ladder_reaches_the_current_version() {
1933 let mut current = 2;
1934 for _ in 0..STEPS.len() {
1935 match STEPS.iter().find(|s| s.from == current) {
1936 Some(step) => current = step.to,
1937 None => break,
1938 }
1939 }
1940 assert_eq!(
1941 current, SCHEMA_VERSION,
1942 "the incremental ladder stops at v{current} and this build stamps \
1943 v{SCHEMA_VERSION}: a database stored at v{current} has no rung out \
1944 of it and cannot be opened"
1945 );
1946 }
1947
1948 /// Every version a stored database can hold has a rung out of it.
1949 ///
1950 /// The chain walk above finds the *first* break and stops. This says the
1951 /// same thing per version, so the failure names which rung is missing
1952 /// rather than which version the walk happened to stall on — and it also
1953 /// refuses a gap the walk would jump over, because a step is free to skip
1954 /// versions and none of them does.
1955 #[test]
1956 fn every_stored_version_has_a_rung_out_of_it() {
1957 for v in 2..SCHEMA_VERSION {
1958 assert!(
1959 STEPS.iter().any(|s| s.from == v),
1960 "no rung leads out of v{v}, so a database stored at v{v} cannot \
1961 be opened by this build"
1962 );
1963 }
1964 }
1965
1966 /// `verify` requires every name this returns, so a parse that silently
1967 /// yielded nothing would turn verification into a no-op that passes on an
1968 /// empty database.
1969 #[test]
1970 fn every_trigger_and_index_yields_a_name() {
1971 let triggers = super::trigger_names();
1972 assert_eq!(triggers.len(), CREATE_TRIGGERS.len());
1973 assert!(
1974 triggers.iter().all(|n| n.starts_with("trg_")),
1975 "{triggers:?}"
1976 );
1977
1978 let indices = super::index_names();
1979 assert_eq!(indices.len(), CREATE_INDICES.len());
1980 assert!(indices.iter().all(|n| n.starts_with("idx_")), "{indices:?}");
1981 }
1982
1983 /// v1 belongs to the pre-canonical schema and must stay unreachable, or a
1984 /// 0.5.3 database silently becomes a supported input again.
1985 #[test]
1986 fn legacy_v1_has_no_rung() {
1987 assert!(
1988 !STEPS.iter().any(|s| s.from == 1),
1989 "a step out of v1 reintroduces pre-0.5.4 databases as a supported input"
1990 );
1991 }
1992
1993 // -- the foreign-key suspension mechanism (0.8.0, B4, D-117) -------------
1994 //
1995 // No shipped rung sets `suspends_foreign_keys` yet — v7 → v8 is what will.
1996 // The mechanism lands first and is tested first, because the thing that
1997 // makes it safe is not that the rebuild works (the probe measured that) but
1998 // that a rung which suspends enforcement **still cannot commit a violation**.
1999 // A flag that turns checking off is only acceptable if something else turns
2000 // verification on, and that is what these two tests hold.
2001
2002 async fn scratch() -> libsql::Connection {
2003 let db = libsql::Builder::new_local(":memory:")
2004 .build()
2005 .await
2006 .unwrap();
2007 let conn = db.connect().unwrap();
2008 conn.execute("PRAGMA foreign_keys = ON", ()).await.unwrap();
2009 conn.execute("CREATE TABLE parent (id TEXT PRIMARY KEY)", ())
2010 .await
2011 .unwrap();
2012 conn.execute(
2013 "CREATE TABLE child (id TEXT PRIMARY KEY, p TEXT NOT NULL \
2014 REFERENCES parent(id))",
2015 (),
2016 )
2017 .await
2018 .unwrap();
2019 conn.execute("INSERT INTO parent VALUES ('a')", ())
2020 .await
2021 .unwrap();
2022 conn.execute("INSERT INTO child VALUES ('c', 'a')", ())
2023 .await
2024 .unwrap();
2025 conn
2026 }
2027
2028 /// The shape the probe found: rebuild a table that has inbound foreign keys
2029 /// by dropping and renaming, which is impossible with enforcement on.
2030 #[tokio::test]
2031 async fn a_suspending_rung_can_rebuild_a_table_with_inbound_keys() {
2032 let conn = scratch().await;
2033 let step = Step {
2034 from: 0,
2035 to: 99,
2036 name: "test-rebuild",
2037 suspends_foreign_keys: true,
2038 apply: |tx| {
2039 Box::pin(async move {
2040 tx.execute("CREATE TABLE parent_new (rowid_pk INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE)", ()).await?;
2041 tx.execute(
2042 "INSERT INTO parent_new (id) SELECT id FROM parent ORDER BY rowid",
2043 (),
2044 )
2045 .await?;
2046 tx.execute("DROP TABLE parent", ()).await?;
2047 tx.execute("ALTER TABLE parent_new RENAME TO parent", ())
2048 .await?;
2049 Ok(())
2050 })
2051 },
2052 };
2053
2054 apply_step(&conn, &step).await.expect("the rung must apply");
2055
2056 // The rebuild happened, the child still resolves, and — the part that
2057 // matters — enforcement is back on afterwards.
2058 let mut rows = conn
2059 .query("SELECT rowid_pk FROM parent WHERE id = 'a'", ())
2060 .await
2061 .unwrap();
2062 assert!(rows.next().await.unwrap().is_some(), "parent lost its row");
2063
2064 let orphan = conn
2065 .execute("INSERT INTO child VALUES ('d', 'nonexistent')", ())
2066 .await;
2067 assert!(
2068 orphan.is_err(),
2069 "foreign keys were not restored after the rung"
2070 );
2071 }
2072
2073 /// **The reason the flag is safe.** A rung that suspends enforcement and
2074 /// leaves a genuine violation must not commit: `foreign_key_check` runs
2075 /// inside the transaction and its rows fail the rung.
2076 ///
2077 /// Without this, `suspends_foreign_keys` would be a way to write a corrupt
2078 /// database on purpose and have the ladder call it a success.
2079 #[tokio::test]
2080 async fn a_suspending_rung_that_leaves_a_violation_is_refused() {
2081 let conn = scratch().await;
2082 let step = Step {
2083 from: 0,
2084 to: 99,
2085 name: "test-orphan",
2086 suspends_foreign_keys: true,
2087 // Drops the parent and puts nothing back: `child.p` now points at
2088 // nothing. With enforcement on this could not even be attempted,
2089 // which is exactly why the check has to exist.
2090 apply: |tx| {
2091 Box::pin(async move {
2092 tx.execute("DROP TABLE parent", ()).await?;
2093 tx.execute("CREATE TABLE parent (id TEXT PRIMARY KEY)", ())
2094 .await?;
2095 Ok(())
2096 })
2097 },
2098 };
2099
2100 let err = apply_step(&conn, &step)
2101 .await
2102 .expect_err("a rung that orphans a row must not commit");
2103 // Pinned to *this* wording, not to "foreign key" generally. A DDL
2104 // statement failing for its own reasons would also produce an error
2105 // mentioning foreign keys, and the test would then pass while proving
2106 // nothing about the check that is the point of the flag.
2107 let text = err.to_string();
2108 assert!(
2109 text.contains("suspended foreign keys and left a violation"),
2110 "the rung must fail at `foreign_key_check`, not merely fail: {text}"
2111 );
2112 assert!(
2113 text.contains("test-orphan"),
2114 "the error should name the rung: {text}"
2115 );
2116
2117 // Rolled back, and enforcement restored despite the failure.
2118 let mut rows = conn.query("PRAGMA user_version", ()).await.unwrap();
2119 let v: u32 = rows.next().await.unwrap().unwrap().get(0).unwrap();
2120 assert_eq!(v, 0, "a failed rung must not stamp its version");
2121
2122 let orphan = conn
2123 .execute("INSERT INTO child VALUES ('d', 'nonexistent')", ())
2124 .await;
2125 assert!(
2126 orphan.is_err(),
2127 "foreign keys must be restored even when the rung failed"
2128 );
2129 }
2130}