Skip to main content

macrame/schema/
migrations.rs

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