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