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 = 10;
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, and why the obvious ways do not work
30    ///
31    /// A rung that rebuilds a table with inbound foreign keys cannot use the
32    /// `links`-style recipe. `links` has no inbound keys; `concepts` has two
33    /// (`links.source_id`, `links.target_id`). `examples/concepts_rebuild_probe.rs`
34    /// measured four approaches on libSQL 0.9.30 and **all four fail**:
35    ///
36    /// 1. `PRAGMA foreign_keys = OFF` *inside* the transaction — **silently
37    ///    ignored**. `execute` returns `Ok`, the value reads back `1`. The
38    ///    pragma is a no-op inside a transaction, and [`apply_step`] wraps every
39    ///    rung in `BEGIN IMMEDIATE`.
40    /// 2. `DROP TABLE concepts` with keys on — `FOREIGN KEY constraint failed`,
41    ///    with **or without** the delete guard. The guard is not the obstacle.
42    /// 3. `PRAGMA defer_foreign_keys = ON`, which is designed for exactly this —
43    ///    every statement succeeds and `foreign_key_check` reports **0
44    ///    violations**, and then **COMMIT fails**. SQLite counts deferred
45    ///    violation *events*; re-adding an equivalent parent row does not
46    ///    decrement the counter.
47    /// 4. Rename-around, with `legacy_alter_table` both on and off — the drop
48    ///    of the orphaned table fails either way.
49    ///
50    /// What works is toggling the pragma *outside* the transaction. So the
51    /// ladder has to know, and this flag is how a rung says so.
52    ///
53    /// # Why this does not weaken atomicity
54    ///
55    /// The rung is still **one transaction and one commit**, with the
56    /// `user_version` stamp inside it — [D-032](../../docs/architecture/s13-decision-register.md)'s
57    /// property is untouched. `PRAGMA foreign_keys` is per-*connection*, and the
58    /// migration connection is created in `open()` and discarded if the
59    /// migration fails, so a crash between the toggle and the reset cannot
60    /// leave a long-lived connection with enforcement off.
61    ///
62    /// And the suspension cannot hide a real violation: [`apply_step`] runs
63    /// `PRAGMA foreign_key_check` **inside** the transaction before committing,
64    /// and any row it reports fails the rung. Enforcement is suspended for the
65    /// duration; verification is not.
66    suspends_foreign_keys: bool,
67}
68
69/// The ladder, in no particular order — `run` walks it by matching `from`.
70///
71/// The rung out of 0 lays the whole schema; the rung out of 2 adds only what
72/// v3 introduced. There is deliberately still no rung out of 1: that is the
73/// pre-canonical schema D-032 refuses by name, and v2 is not the same case —
74/// it was written by this same 0.5.4 line with canonical timestamps and every
75/// CHECK in place, so it is missing a derivative table and nothing else.
76const STEPS: &[Step] = &[
77    Step {
78        from: 0,
79        to: SCHEMA_VERSION,
80        name: "baseline-0.5.4",
81        suspends_foreign_keys: false,
82        apply: |conn| Box::pin(baseline(conn)),
83    },
84    Step {
85        from: 2,
86        to: 3,
87        name: "analytics-annotations",
88        suspends_foreign_keys: false,
89        apply: |conn| Box::pin(add_analytics_annotations(conn)),
90    },
91    Step {
92        from: 3,
93        to: 4,
94        name: "traversal-covering-index",
95        suspends_foreign_keys: false,
96        apply: |conn| Box::pin(add_traversal_cover(conn)),
97    },
98    Step {
99        from: 4,
100        to: 5,
101        name: "concepts-fts",
102        suspends_foreign_keys: false,
103        apply: |conn| Box::pin(add_concepts_fts(conn)),
104    },
105    Step {
106        from: 5,
107        to: 6,
108        name: "single-open-interval-index",
109        suspends_foreign_keys: false,
110        apply: |conn| Box::pin(add_open_interval_index(conn)),
111    },
112    Step {
113        from: 6,
114        to: 7,
115        name: "links-weight-check",
116        suspends_foreign_keys: false,
117        apply: |conn| Box::pin(add_weight_check(conn)),
118    },
119    Step {
120        from: 7,
121        to: 8,
122        name: "concepts-rowid-pk-and-unread-indices",
123        // The only rung that needs it, and the reason the flag exists. See
124        // `Step::suspends_foreign_keys` for the four approaches the probe
125        // refuted.
126        suspends_foreign_keys: true,
127        apply: |conn| Box::pin(add_concepts_rowid_pk(conn)),
128    },
129    Step {
130        from: 9,
131        to: 10,
132        name: "concepts-log-insert-marker-gated",
133        // Same shape as the rung below and for the same reason: one trigger
134        // replaced, no table touched.
135        suspends_foreign_keys: false,
136        apply: |conn| Box::pin(gate_concepts_log_insert_on_marker(conn)),
137    },
138    Step {
139        from: 8,
140        to: 9,
141        name: "concepts-guard-marker-gated",
142        // One trigger replaced. No table is rebuilt and no row moves, so the
143        // inbound foreign keys that forced the flag on the rung above are not
144        // involved here at all.
145        suspends_foreign_keys: false,
146        apply: |conn| Box::pin(gate_concepts_guard_on_marker(conn)),
147    },
148];
149
150/// Bring `conn`'s database up to [`SCHEMA_VERSION`], or fail explaining why not.
151///
152/// Reading `user_version` before writing is the whole point. The previous
153/// implementation re-ran every `CREATE … IF NOT EXISTS` unconditionally and then
154/// stamped the version it had never read, which meant it could not distinguish a
155/// fresh file from a foreign one from a database written by a future build — it
156/// simply asserted the schema it wanted and hoped. `IF NOT EXISTS` hides exactly
157/// the case that matters: an object that exists with a *different* definition is
158/// silently kept, so a legacy table would survive with none of its constraints
159/// while the stamp claimed otherwise.
160/// What [`run`] did, so a caller can react to the schema having moved.
161///
162/// The one caller that must is `Database::open`: a `SCHEMA_VERSION` bump
163/// invalidates every snapshot on disk (D-043), and until Wave 4.4 nothing
164/// noticed — the first `reconstruct` after an upgrade skipped every snapshot as
165/// incompatible and folded from genesis, correctly and expensively, with the
166/// only trace a `warn!` per skipped file.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct MigrationOutcome {
169    /// The version the file carried on the way in.
170    pub from: u32,
171    /// [`SCHEMA_VERSION`], always — `run` either reaches it or fails.
172    pub to: u32,
173}
174
175impl MigrationOutcome {
176    /// Whether an **existing** database moved between versions.
177    ///
178    /// A fresh file (`from == 0`) is deliberately not an upgrade. It has no
179    /// snapshots to invalidate, so there is nothing to re-anchor — and treating
180    /// it as one made `Database::open` write a snapshot on every first open,
181    /// which broke two contracts the suite already pins: an idle database is
182    /// never anchored, and a handle opened with no cadence writes nothing until
183    /// `close()`. Both are worth keeping. `open()` touching the disk when it was
184    /// not asked to is surprising in its own right.
185    pub fn upgraded(&self) -> bool {
186        self.from != 0 && self.from != self.to
187    }
188}
189
190pub async fn run(conn: &libsql::Connection) -> Result<MigrationOutcome> {
191    let found = read_user_version(conn).await?;
192
193    if found > SCHEMA_VERSION {
194        return Err(DbError::Migration {
195            to: SCHEMA_VERSION,
196            reason: format!(
197                "database is at schema v{found}; this build understands v{SCHEMA_VERSION} \
198                 and will not operate on a schema it does not know. Upgrade macrame \
199                 rather than opening the file with an older build."
200            ),
201        });
202    }
203
204    if found == 0 {
205        refuse_if_occupied(conn).await?;
206    }
207
208    let mut current = found;
209    while current != SCHEMA_VERSION {
210        let step = STEPS
211            .iter()
212            .find(|s| s.from == current)
213            .ok_or_else(|| no_path_from(current))?;
214        apply_step(conn, step).await?;
215        current = step.to;
216    }
217
218    verify(conn).await?;
219    Ok(MigrationOutcome {
220        from: found,
221        to: SCHEMA_VERSION,
222    })
223}
224
225/// Version this build stamps on databases it creates.
226pub fn current_version() -> u32 {
227    SCHEMA_VERSION
228}
229
230/// Refuse to lay the baseline over a database that already holds something.
231///
232/// `user_version` defaults to 0, so an unrelated SQLite file is indistinguishable
233/// from a fresh one by version alone. Without this check, pointing macrame at the
234/// wrong path would quietly add four tables and nine triggers to somebody else's
235/// database — including delete guards that abort writes the owner never asked to
236/// have guarded.
237async fn refuse_if_occupied(conn: &libsql::Connection) -> Result<()> {
238    let mut rows = conn
239        .query(
240            "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'",
241            (),
242        )
243        .await?;
244    let objects: i64 = match rows.next().await? {
245        Some(row) => row.get(0)?,
246        None => 0,
247    };
248
249    if objects > 0 {
250        return Err(DbError::Migration {
251            to: SCHEMA_VERSION,
252            reason: format!(
253                "database carries no macrame schema version but already holds {objects} \
254                 object(s); refusing to lay the baseline over an unrelated database. \
255                 Point at a new file, or delete this one deliberately."
256            ),
257        });
258    }
259    Ok(())
260}
261
262/// Explain a version with no rung leading out of it.
263fn no_path_from(current: u32) -> DbError {
264    let reason = if current < SCHEMA_VERSION {
265        format!(
266            "database is at schema v{current}, written by a pre-0.5.4 build: its \
267             timestamps are second-precision and its tables carry none of the \
268             canonical-form CHECK constraints (D-029). This build provides no \
269             migration path — create a new database."
270        )
271    } else {
272        format!("no migration step leads out of schema v{current}")
273    };
274    DbError::Migration {
275        to: SCHEMA_VERSION,
276        reason,
277    }
278}
279
280/// Run one rung inside a single transaction, stamp included.
281///
282/// `user_version` is a database-header field and its write is journalled like
283/// any other, so stamping inside the transaction makes "the schema exists" and
284/// "the schema is declared to exist" the same commit. A crash mid-step therefore
285/// leaves a database that is still honestly at its old version, rather than one
286/// stamped for a schema it only partly has.
287async fn apply_step(conn: &libsql::Connection, step: &Step) -> Result<()> {
288    // Outside the transaction, because inside it the pragma is silently
289    // ignored — see `Step::suspends_foreign_keys` for the four approaches that
290    // do not work and the probe that measured them.
291    if step.suspends_foreign_keys {
292        conn.execute("PRAGMA foreign_keys = OFF", ()).await?;
293    }
294
295    let res = apply_step_inner(conn, step).await;
296
297    // Restored on **every** path, including the error one. A rung that fails
298    // must not leave the connection with enforcement off, even though that
299    // connection is about to be discarded: the guarantee should not depend on
300    // the caller's disposal habits.
301    if step.suspends_foreign_keys {
302        conn.execute("PRAGMA foreign_keys = ON", ()).await?;
303    }
304
305    res
306}
307
308async fn apply_step_inner(conn: &libsql::Connection, step: &Step) -> Result<()> {
309    let tx = conn
310        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
311        .await?;
312
313    let res: Result<()> = async {
314        (step.apply)(&tx).await?;
315
316        // Suspension is not permission. A rung that ran with enforcement off
317        // must still leave a database the engine would accept, so the check
318        // runs inside the transaction and its rows fail the rung — which means
319        // the rollback below, not a committed database nobody checked.
320        if step.suspends_foreign_keys {
321            let mut rows = tx.query("PRAGMA foreign_key_check", ()).await?;
322            if let Some(row) = rows.next().await? {
323                let table: String = row.get(0).unwrap_or_else(|_| "?".to_string());
324                return Err(DbError::Migration {
325                    to: step.to,
326                    reason: format!(
327                        "step {:?} suspended foreign keys and left a violation \
328                         in {table:?}; the rung is wrong, not the check",
329                        step.name
330                    ),
331                });
332            }
333        }
334
335        // PRAGMA takes no bind parameters; `to` is a u32 read from a const.
336        tx.execute(&format!("PRAGMA user_version = {}", step.to), ())
337            .await?;
338        Ok(())
339    }
340    .await;
341
342    match res {
343        Ok(()) => {
344            tx.commit().await?;
345            Ok(())
346        }
347        Err(e) => {
348            let _ = tx.rollback().await;
349            Err(DbError::Migration {
350                to: step.to,
351                reason: format!("step {:?}: {e}", step.name),
352            })
353        }
354    }
355}
356
357/// The 0.5.4 schema, applied to an empty database.
358async fn baseline(conn: &libsql::Connection) -> Result<()> {
359    // concepts first: links declares a foreign key into it.
360    conn.execute(CREATE_CONCEPTS_TABLE, ()).await?;
361    conn.execute(CREATE_LINKS_TABLE, ()).await?;
362    conn.execute(CREATE_LINKS_CURRENT_TABLE, ()).await?;
363    conn.execute(CREATE_TRANSACTION_LOG_TABLE, ()).await?;
364    // Derivative, and last: every index in CREATE_INDICES must have its table.
365    conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
366    // Before the triggers, not after: `trg_concepts_fts_*` name this table, and
367    // SQLite resolves a trigger body's tables at CREATE TRIGGER time.
368    conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
369
370    for index_ddl in CREATE_INDICES {
371        conn.execute(index_ddl, ()).await?;
372    }
373
374    for trigger_ddl in CREATE_TRIGGERS {
375        conn.execute(trigger_ddl, ()).await?;
376    }
377
378    Ok(())
379}
380
381/// v4 → v5: add the FTS5 index over concept text (§5.9, D-051).
382///
383/// Derivative and additive, so D-036 permits it — an FTS index over `concepts`
384/// is Doctrine VI's second category, disposable and reconstructible. The two
385/// triggers land on `concepts`, which *is* a frozen ledger table, but a trigger
386/// changes neither its columns nor its rows; the compat contract freezes the
387/// table's shape, and that is untouched.
388///
389/// Unlike the v2 → v3 rung this one **does** backfill, and can: the index is a
390/// pure function of text the ledger already holds, so `'rebuild'` reconstructs
391/// exactly what the triggers would have written had they always existed. That is
392/// the difference between this and D-041's annotations, where the old data was
393/// destroyed and no recovery existed.
394async fn add_concepts_fts(conn: &libsql::Connection) -> Result<()> {
395    conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
396    for trigger_ddl in CREATE_TRIGGERS {
397        conn.execute(trigger_ddl, ()).await?;
398    }
399    conn.execute(REBUILD_CONCEPTS_FTS, ()).await?;
400    Ok(())
401}
402
403/// v2 → v3: add the derivative analytics table (D-041).
404///
405/// Purely additive, and additive on the *periphery* — `analytics_annotations`
406/// is Doctrine VI's second category, so D-036's freeze on the ledger tables is
407/// not in play. Nothing is backfilled: annotations written before v3 went into
408/// `concepts.content`, which is the defect, and there is no way to tell a label
409/// that landed there from the document text it replaced. Recomputing is the
410/// recovery, and recomputing is what this table exists to make cheap.
411async fn add_analytics_annotations(conn: &libsql::Connection) -> Result<()> {
412    conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
413    for index_ddl in CREATE_INDICES {
414        conn.execute(index_ddl, ()).await?;
415    }
416    Ok(())
417}
418
419/// v5 → v6: index the single-open-interval probe (D-059).
420///
421/// Index-only and on a derivative table, so D-036 permits it on the same two
422/// grounds the v3 → v4 rung stood on. Nothing is dropped this time: the new
423/// index and `idx_lc_traversal_cover` serve different shapes — one needs three
424/// equality columns bound, the other leads on `source_id` alone — so neither
425/// subsumes the other and keeping both is the point rather than an oversight.
426///
427/// **This is the largest measured win in the tree and it sat proven and
428/// unshipped for a full cycle**, on the stated ground that an index is a schema
429/// change wanting its own rung. That was a description of the work rather than
430/// an objection to it. See [`CREATE_INDICES`] for the numbers.
431///
432/// Nothing is backfilled because an index has nothing to backfill; `CREATE
433/// INDEX` populates it from the table. That makes this the cheapest rung on the
434/// ladder and the only one whose cost is a function of existing row count alone.
435async fn add_open_interval_index(conn: &libsql::Connection) -> Result<()> {
436    for index_ddl in CREATE_INDICES {
437        conn.execute(index_ddl, ()).await?;
438    }
439    Ok(())
440}
441
442/// The v7 shape of `links`, pinned as text (T2.1, D-083).
443///
444/// **Deliberately not `ddl::CREATE_LINKS_TABLE`.** Every other rung on this
445/// ladder reuses the DDL constants, and for those it is right — they create an
446/// index or a derivative table, and getting today's definition is the point. A
447/// *table rebuild* is different: it produces whatever shape the constant names
448/// at the moment it runs, so the day `links` gains a v8 column, this rung would
449/// silently take a v6 database straight to the v8 shape and stamp it v7. The
450/// ladder would then have two databases both stamped v7 with different columns,
451/// and the v7 → v8 rung would run against a table that already had its change.
452///
453/// A migration rung is a statement about the past. Pinning the text is what
454/// makes it one.
455const LINKS_V7: &str = r#"
456CREATE TABLE links_v7 (
457    source_id   TEXT NOT NULL REFERENCES concepts(id),
458    target_id   TEXT NOT NULL REFERENCES concepts(id),
459    edge_type   TEXT NOT NULL,
460    valid_from  TEXT NOT NULL,
461    recorded_at TEXT NOT NULL,
462    valid_to    TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
463    weight      REAL NOT NULL DEFAULT 1.0,
464    properties  TEXT NOT NULL DEFAULT '{}',
465    PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at),
466    CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
467    -- (the timestamp CHECK, spelled out for the same pinning reason)
468    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)
469)
470"#;
471
472/// v6 → v7: constrain `links.weight` (§4.7, T2.1, D-083).
473///
474/// # The only rung that rewrites a ledger table, and what that costs
475///
476/// SQLite has no `ADD CONSTRAINT`, so this is a full rebuild of `links` — the
477/// largest table in the schema — inside [`apply_step`]'s single transaction:
478/// create, copy, drop, rename, recreate triggers. Cost is O(rows) in time and
479/// roughly 2× `links` in peak disk. Every other rung on this ladder is index
480/// work or an additive table; this one is not, and a caller upgrading a large
481/// database should expect it to take a while and to need the space.
482///
483/// **That 2× is an estimate and is still unmeasured**, flagged here in 0.8.0
484/// when the *concepts* rung below it was measured properly
485/// ([D-125](../../docs/architecture/s13-decision-register.md)). Do not read
486/// across from that measurement: the concepts rung peaks at 1.09× the whole
487/// file precisely because `concepts` is a small share of it, and this rung
488/// rebuilds the share that is large. If anyone needs the real number,
489/// `examples/v8_migration_scale_probe.rs` is the shape to copy — it needs a v6
490/// fixture instead of a v7 one.
491///
492/// It is taken **pre-1.0 on purpose**. D-032 makes this a baseline re-issue
493/// today, which is cheap; after 1.0 the compat contract (D-036) freezes the
494/// ledger tables and the same change becomes an unmigration.
495///
496/// # Doctrine III is not violated, and the case where it would be is refused
497///
498/// A rebuild that *altered* an assertion would be exactly what Doctrine III
499/// forbids. This one copies every row verbatim — no clamping, no rounding, no
500/// dropping. Which means a database already holding a weight the new constraint
501/// rejects cannot be migrated at all, and this refuses **before** touching
502/// anything, with a count and an example, rather than failing halfway through a
503/// copy with a bare `CHECK constraint failed`.
504///
505/// Such rows are reachable: until this rung, `assert_edge(weight = -1.0)` was
506/// accepted by the write API and refused only at load time (§4.7). That was the
507/// gap. An operator who has them must decide what those assertions meant, and
508/// that is not a decision a migration can take for them.
509///
510/// # Order, and the trap it avoids
511///
512/// `DROP TABLE links` first, then rename. Dropping the table takes its four
513/// triggers with it, so the rename does not reparse a schema containing trigger
514/// bodies that name a table which no longer exists — the failure T1.2 hit from
515/// the other direction. All triggers are `IF NOT EXISTS`, so re-running the
516/// whole array afterwards recreates the four on `links` and no-ops the rest.
517/// No index is defined on `links`, so there is none to rebuild.
518async fn add_weight_check(conn: &libsql::Connection) -> Result<()> {
519    let offending: i64 = conn
520        .query(
521            "SELECT COUNT(*) FROM links WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real')",
522            (),
523        )
524        .await?
525        .next()
526        .await?
527        .and_then(|r| r.get(0).ok())
528        .unwrap_or(0);
529
530    if offending > 0 {
531        let example: Option<String> = conn
532            .query(
533                "SELECT source_id || ' -> ' || target_id || ' (' || edge_type || \
534                 ') weight=' || CAST(weight AS TEXT) FROM links \
535                 WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real') LIMIT 1",
536                (),
537            )
538            .await?
539            .next()
540            .await?
541            .and_then(|r| r.get(0).ok());
542
543        return Err(DbError::Migration {
544            to: 7,
545            reason: format!(
546                "{offending} row(s) in `links` hold a weight the v7 constraint \
547                 rejects, e.g. {}. Copying them verbatim is impossible and \
548                 altering them would violate Doctrine III, so this migration \
549                 refuses rather than choosing on your behalf. These rows were \
550                 writable through `assert_edge` before v7 (§4.7) — decide what \
551                 they were meant to assert, archive them, and retry.",
552                example.as_deref().unwrap_or("<unreadable>")
553            ),
554        });
555    }
556
557    conn.execute(LINKS_V7, ()).await?;
558    conn.execute(
559        "INSERT INTO links_v7 (source_id, target_id, edge_type, valid_from, \
560         recorded_at, valid_to, weight, properties) \
561         SELECT source_id, target_id, edge_type, valid_from, recorded_at, \
562                valid_to, weight, properties FROM links",
563        (),
564    )
565    .await?;
566    conn.execute("DROP TABLE links", ()).await?;
567    conn.execute("ALTER TABLE links_v7 RENAME TO links", ())
568        .await?;
569
570    for trigger_ddl in CREATE_TRIGGERS {
571        conn.execute(trigger_ddl, ()).await?;
572    }
573
574    Ok(())
575}
576
577/// The v8 shape of `concepts`, pinned as text (B4, D-119).
578///
579/// Pinned for the reason [`LINKS_V7`] states: a rung that rebuilds a table must
580/// produce the shape that rung is *about*, not whatever
581/// [`CREATE_CONCEPTS_TABLE`] happens to say the day it runs. A migration rung is
582/// a statement about the past.
583const CONCEPTS_V8: &str = r#"
584CREATE TABLE concepts_v8 (
585    rowid_pk         INTEGER PRIMARY KEY,
586    id               TEXT NOT NULL UNIQUE,
587    title            TEXT NOT NULL,
588    content          TEXT NOT NULL DEFAULT '',
589    embedding_model  TEXT,
590    valid_from       TEXT NOT NULL,
591    valid_to         TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
592    recorded_at      TEXT NOT NULL,
593    retired          INTEGER NOT NULL DEFAULT 0,
594    -- (the timestamp CHECK, spelled out for the same pinning reason)
595    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)
596)
597"#;
598
599/// The six triggers a v7 `concepts` carries, dropped by name before the rebuild.
600///
601/// By name and not by discovery: a rung is a statement about the past, and the
602/// past is a fixed set. Enumerating what v7 had means a v9 trigger added later
603/// cannot be silently swept up by a `DROP` loop over `sqlite_master`.
604const CONCEPTS_TRIGGERS_V7: &[&str] = &[
605    "trg_concepts_monotonic_ra",
606    "trg_concepts_log_insert",
607    "trg_concepts_log_update",
608    "trg_concepts_guard_delete",
609    "trg_concepts_fts_insert",
610    "trg_concepts_fts_update",
611];
612
613/// The concepts delete guard **as v8 had it**: unconditional, aborting every
614/// physical delete (0.9.0, C2).
615///
616/// Pinned here for the same reason [`CONCEPTS_V8`] and [`CONCEPTS_TRIGGERS_V7`]
617/// are, and the reason is easy to miss. [`add_concepts_rowid_pk`] rebuilds
618/// `concepts` and puts the triggers back by looping over [`CREATE_TRIGGERS`] —
619/// which is *today's* DDL, and today's guard is marker-gated. Left alone, the
620/// `v7 → v8` rung would install a trigger body that did not exist at v8, so a
621/// database the ladder reports as v8 would not be a v8 database.
622///
623/// Harmless in the common path, because `run` never rests at an intermediate
624/// version — a v7 file climbs 7 → 8 → 9 in one call and the next rung replaces
625/// this body anyway. It is pinned regardless, because a rung is a statement
626/// about the past and a rung that quietly writes the present into it cannot be
627/// tested against a fixture: `a_v7_database_climbs_to_v8_and_gains_rowid_pk`
628/// would have been asserting against whatever the current release happened to
629/// think, which is the failure mode this whole file is built to avoid.
630const CONCEPTS_GUARD_DELETE_V8: &str = r#"
631    CREATE TRIGGER IF NOT EXISTS trg_concepts_guard_delete
632    BEFORE DELETE ON concepts
633    BEGIN
634        SELECT RAISE(ABORT, 'macrame: concepts are never physically archived (D-022)');
635    END;
636"#;
637
638/// v7 → v8: `concepts` gains `rowid_pk`, the FTS index gains its third trigger,
639/// and the two indices with no reader are dropped (B4, D-118, D-119).
640///
641/// # Why this rung must be taken pre-1.0 or never
642///
643/// `rowid_pk INTEGER PRIMARY KEY` means `id` stops being the primary key, and
644/// SQLite allows exactly one per table. That is a **primary-key change**, which
645/// [D-036](../../docs/architecture/s13-decision-register.md) forbids outright
646/// after 1.0 and classes as needing a major version with an explicit ETL path.
647/// Pre-1.0, D-032 makes it a baseline re-issue. There is no third option and no
648/// later cheap moment.
649///
650/// # What it buys
651///
652/// `concepts_fts` is external-content keyed on `concepts`'s rowid, which through
653/// v7 was **implicit** — and `VACUUM` renumbers implicit rowids, decoupling the
654/// index from its rows with no error and no integrity-check failure. D-071
655/// showed the hazard unreachable today only because the delete guard is
656/// unconditional, so rowids are dense and the renumbering is the identity map.
657/// 0.9.0's archival makes them sparse. This installs the fix while the fix is
658/// still free, and installs `trg_concepts_fts_delete` in the same rung.
659///
660/// **What it does not buy, corrected in place.** This paragraph read "*so 0.9.0
661/// needs no migration of its own*". That is wrong (D-126). The rung ships C2's
662/// steps 1 and 3; step 2 — `trg_concepts_guard_delete` becoming marker-gated —
663/// still needs a `v8 → v9` rung of its own, since re-issuing the baseline keeps
664/// the old trigger body and `verify` would not notice. It is cheap (a `DROP
665/// TRIGGER` and a `CREATE`, no table rebuild) but it is not nothing.
666///
667/// # Why it needs `suspends_foreign_keys`, and what still checks the result
668///
669/// `concepts` has inbound foreign keys from `links` (twice),
670/// `analytics_annotations` and every registered `embeddings_*` table, so the
671/// `links`-style rebuild is not available: the `DROP TABLE` fails with keys on,
672/// and the three obvious ways to turn them off inside the transaction all fail
673/// differently. See [`Step::suspends_foreign_keys`] for the four measured
674/// refutations. [`apply_step`] therefore toggles the pragma around the
675/// transaction and runs `PRAGMA foreign_key_check` inside it before committing.
676///
677/// **One consequence worth stating.** That check reports violations across the
678/// whole database, not only ones this rung could have caused. A v7 file that
679/// already held an orphaned `links` row — reachable only if it was written with
680/// enforcement off — will fail to migrate. That is the right outcome and it is
681/// not a silent one: the error names the table.
682///
683/// # Order, and the two traps in it
684///
685/// The triggers and `concepts_fts` come down **before** the table is touched,
686/// not after. Recreating the triggers while the old FTS table was still present
687/// would bind them to an index about to be dropped, and dropping `concepts_fts`
688/// while triggers still named it is the schema-reparse failure the `links` rung
689/// hit from the other direction. So: indices, triggers, FTS, then the rebuild,
690/// then the new FTS, then the triggers, then the rebuild of the index content.
691///
692/// `rowid` is copied into `rowid_pk` **by value** rather than left to
693/// auto-assign. On today's dense numbering the two agree, so this looks
694/// redundant; it is what makes the rung correct on a file whose rowids are not
695/// dense, and it means the migration preserves row identity rather than merely
696/// preserving row order.
697///
698/// # What it costs, measured (0.8.0, [D-125])
699///
700/// This rung rewrites a ledger table on somebody's data while holding the write
701/// lock, so the operator's two questions are how long they are down and how much
702/// free disk they need first. Both are measured rather than estimated —
703/// `cargo run --release --example v8_migration_scale_probe`, four scales up to
704/// 200k concepts / 600k links / 800k log rows (a 733 MiB file):
705///
706/// * **Time is linear at ~10–13 µs per concept**, 2.7 s at 200k. It scales with
707///   `concepts`, not with the file.
708/// * **Peak disk is 1.09× the starting file**, flat across every scale, and it
709///   **settles back to 1.00×** after a checkpoint. So the rung wants ~10%
710///   headroom transiently and keeps none of it. The intuition that a
711///   copy-and-swap needs 2× is right about the *table* and wrong about the
712///   *file*, because `concepts` is a small share of a database whose bulk is
713///   `links` and `transaction_log`.
714/// * **[`suspends_foreign_keys`]'s `PRAGMA foreign_key_check` is 13–17% of the
715///   rung**, a stable share. It is a whole-database scan, so unlike the rest of
716///   the rung it grows with `links` and the log rather than with `concepts` —
717///   on a database with an unusually large ledger relative to its concepts it
718///   will dominate.
719///
720/// The `links` rung above still carries an *estimated* 2×, which this
721/// measurement does not transfer to: that one rebuilds the big table, and the
722/// ratio that makes this rung cheap is exactly what makes that one expensive.
723async fn add_concepts_rowid_pk(conn: &libsql::Connection) -> Result<()> {
724    // (a) The two indices with no reader (D-089, completed by D-118).
725    conn.execute("DROP INDEX IF EXISTS idx_annotations_label", ())
726        .await?;
727    conn.execute("DROP INDEX IF EXISTS idx_lc_tgt_active", ())
728        .await?;
729
730    // (b) Clear the way: triggers, then the FTS index, then the table.
731    for name in CONCEPTS_TRIGGERS_V7 {
732        conn.execute(&format!("DROP TRIGGER IF EXISTS {name}"), ())
733            .await?;
734    }
735    conn.execute("DROP TABLE IF EXISTS concepts_fts", ()).await?;
736
737    conn.execute(CONCEPTS_V8, ()).await?;
738    conn.execute(
739        "INSERT INTO concepts_v8 (rowid_pk, id, title, content, embedding_model, \
740         valid_from, valid_to, recorded_at, retired) \
741         SELECT rowid, id, title, content, embedding_model, \
742                valid_from, valid_to, recorded_at, retired \
743         FROM concepts ORDER BY rowid",
744        (),
745    )
746    .await?;
747    conn.execute("DROP TABLE concepts", ()).await?;
748    conn.execute("ALTER TABLE concepts_v8 RENAME TO concepts", ())
749        .await?;
750
751    // (c) Put it back, in the order the trigger bodies require.
752    conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
753    for trigger_ddl in CREATE_TRIGGERS {
754        conn.execute(trigger_ddl, ()).await?;
755    }
756
757    // (d) …then correct the one trigger the loop above gets wrong. `CREATE_TRIGGERS`
758    // is today's DDL, and today's concepts guard is marker-gated (C2); v8's was
759    // unconditional. See `CONCEPTS_GUARD_DELETE_V8`. The `IF NOT EXISTS` in both
760    // bodies is why this needs the explicit DROP: without it the loop's version
761    // stays, because a re-issue of an existing name keeps the old body.
762    conn.execute("DROP TRIGGER IF EXISTS trg_concepts_guard_delete", ())
763        .await?;
764    conn.execute(CONCEPTS_GUARD_DELETE_V8, ()).await?;
765    conn.execute("DROP TRIGGER IF EXISTS trg_concepts_log_insert", ())
766        .await?;
767    conn.execute(CONCEPTS_LOG_INSERT_V9, ()).await?;
768
769    conn.execute(REBUILD_CONCEPTS_FTS, ()).await?;
770
771    Ok(())
772}
773
774/// `trg_concepts_log_insert` **as v9 had it**: unconditional (0.9.0, C3).
775///
776/// Pinned for the same reason as [`CONCEPTS_GUARD_DELETE_V8`], and the reason
777/// bites harder here: [`add_concepts_rowid_pk`] restores triggers from
778/// [`CREATE_TRIGGERS`], so without this the v7 → v8 rung would install the v10
779/// body — a database the ladder calls v8 whose concept inserts stop logging
780/// inside a session, three versions before that behaviour was decided.
781const CONCEPTS_LOG_INSERT_V9: &str = r#"
782    CREATE TRIGGER IF NOT EXISTS trg_concepts_log_insert
783    AFTER INSERT ON concepts
784    BEGIN
785        INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
786        VALUES ('concepts', NEW.id, 'I',
787                json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
788                            'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
789                            'retired', NEW.retired,
790                            'embedding_model', NEW.embedding_model),
791                NEW.recorded_at);
792    END;
793"#;
794
795/// v9 → v10: the concepts insert log trigger becomes marker-gated (C3).
796///
797/// Two statements, like the rung below, and necessary for a reason that is not
798/// tidiness. See [`CREATE_CONCEPTS_LOG_INSERT`]: an unlogged insert is what makes
799/// rehydration a *move* rather than a write, and without it a rehydrated concept
800/// outranks its own retirement in the fold and comes back alive.
801async fn gate_concepts_log_insert_on_marker(conn: &libsql::Connection) -> Result<()> {
802    conn.execute("DROP TRIGGER IF EXISTS trg_concepts_log_insert", ())
803        .await?;
804    conn.execute(CREATE_CONCEPTS_LOG_INSERT, ()).await?;
805    Ok(())
806}
807
808/// v8 → v9: the concepts delete guard becomes marker-gated (C2, D-126).
809///
810/// The whole rung is two statements, and the first is the one that matters.
811/// `CREATE TRIGGER IF NOT EXISTS` on an existing name **keeps the old body** —
812/// verified against libSQL 0.9.30, not assumed — so re-issuing the baseline
813/// against a v8 database leaves the unconditional guard exactly where it was.
814/// The `DROP` is therefore not tidiness; it is the only thing that makes the
815/// rung do anything at all. That, plus [`verify`] having compared trigger names
816/// and never bodies, is why D-126 could conclude this needs a rung rather than a
817/// baseline re-issue: without both, a v8 database opened by 0.9.0 code would
818/// carry the old guard, pass verification in silence, and then refuse concept
819/// archival at the trigger.
820///
821/// No table is rebuilt and no row moves, so this costs a schema write and
822/// nothing else — a `DROP TRIGGER` and a `CREATE TRIGGER`, independent of how
823/// large the database is. It is the cheapest rung this ladder has.
824async fn gate_concepts_guard_on_marker(conn: &libsql::Connection) -> Result<()> {
825    conn.execute("DROP TRIGGER IF EXISTS trg_concepts_guard_delete", ())
826        .await?;
827    conn.execute(CREATE_CONCEPTS_GUARD_DELETE, ()).await?;
828    Ok(())
829}
830
831/// v3 → v4: swap `idx_lc_src_active` for the traversal covering index (D-042).
832///
833/// Index-only, and on a derivative table, so D-036 permits it twice over. The
834/// drop is the point as much as the create: the new index has the same seek
835/// column and strictly more payload, so keeping the old one would cost a second
836/// index write on every assertion and buy nothing. Order matters only for peak
837/// disk — create first so the traversal is never left without an index at all,
838/// even though the whole rung is one transaction.
839async fn add_traversal_cover(conn: &libsql::Connection) -> Result<()> {
840    for index_ddl in CREATE_INDICES {
841        conn.execute(index_ddl, ()).await?;
842    }
843    conn.execute("DROP INDEX IF EXISTS idx_lc_src_active", ())
844        .await?;
845    Ok(())
846}
847
848/// The tables the baseline declares, by name, for [`verify`].
849pub(crate) const BASELINE_TABLES: &[&str] = &[
850    "concepts",
851    "links",
852    "links_current",
853    "transaction_log",
854    "analytics_annotations",
855    "concepts_fts",
856];
857
858/// Confirm the database actually holds what the DDL claims to create.
859///
860/// Cheap insurance against the failure mode `IF NOT EXISTS` is built to hide: a
861/// statement that no-ops instead of creating. It also catches the DDL arrays and
862/// reality drifting apart — add a trigger to [`CREATE_TRIGGERS`] that fails to
863/// compile as written and it is missing here rather than at the first write that
864/// needed it.
865///
866/// **Presence by name, not a count of everything present.** The original
867/// counted `sqlite_master` and required exactly four tables, which made
868/// verification fail on any database carrying an object the baseline did not
869/// create — and this schema now has three legitimate sources of those. A
870/// registered embedding model adds `embeddings_<model>` (§4.1); libSQL's vector
871/// index adds `libsql_vector_meta_shadow`, a shadow table and a shadow index of
872/// its own; and D-036 explicitly permits post-1.0 migrations to add indexes. A
873/// count treats all three as corruption and refuses to open a healthy file. What
874/// verification is actually for is the absence of something required, so that is
875/// what it now checks.
876async fn verify(conn: &libsql::Connection) -> Result<()> {
877    let mut rows = conn
878        .query(
879            "SELECT type, name, COALESCE(sql, '') FROM sqlite_master \
880             WHERE type IN ('table','trigger','index')",
881            (),
882        )
883        .await?;
884
885    let mut present: Vec<(String, String)> = Vec::new();
886    let mut bodies: Vec<(String, String)> = Vec::new();
887    while let Some(row) = rows.next().await? {
888        let (kind, name, sql): (String, String, String) =
889            (row.get(0)?, row.get(1)?, row.get(2)?);
890        if kind == "trigger" {
891            bodies.push((name.clone(), sql));
892        }
893        present.push((kind, name));
894    }
895    let has = |kind: &str, name: &str| {
896        present
897            .iter()
898            .any(|(k, n)| k == kind && n.eq_ignore_ascii_case(name))
899    };
900
901    let mut missing: Vec<String> = Vec::new();
902    for table in BASELINE_TABLES {
903        if !has("table", table) {
904            missing.push(format!("table {table}"));
905        }
906    }
907    for name in trigger_names() {
908        if !has("trigger", &name) {
909            missing.push(format!("trigger {name}"));
910        }
911    }
912    for name in index_names() {
913        if !has("index", &name) {
914            missing.push(format!("index {name}"));
915        }
916    }
917
918    if !missing.is_empty() {
919        return Err(DbError::Migration {
920            to: SCHEMA_VERSION,
921            reason: format!(
922                "schema verification failed: the database is stamped v{SCHEMA_VERSION} \
923                 but is missing {}: {}",
924                missing.len(),
925                missing.join(", ")
926            ),
927        });
928    }
929
930    // The three delete guards are checked by *body*, not only by name (0.9.0,
931    // C2, D-126). Presence was never the property that mattered for these: a
932    // guard with the right name and the wrong body is a guard that refuses a
933    // legal archive or permits an illegal delete, and the check above cannot
934    // see the difference. That is not hypothetical — it is exactly what
935    // `CREATE TRIGGER IF NOT EXISTS` produces when a baseline is re-issued
936    // against a database whose guard predates a change, and it is the reason
937    // the concepts guard needed a rung instead.
938    //
939    // The probe is `macrame_archive_session`, not the trigger's whole text.
940    // Comparing full bodies would fail on whitespace and would have to be
941    // updated by hand every time a guard is reworded, which makes it the kind
942    // of check people disable. What is asserted is the one property all three
943    // share and none may lose: **this guard is gated on the archive session.**
944    let ungated: Vec<&str> = DELETE_GUARDS
945        .iter()
946        .filter(|name| {
947            bodies
948                .iter()
949                .find(|(n, _)| n.eq_ignore_ascii_case(name))
950                .is_none_or(|(_, sql)| !sql.contains(ARCHIVE_SESSION_MARKER))
951        })
952        .copied()
953        .collect();
954
955    if !ungated.is_empty() {
956        return Err(DbError::Migration {
957            to: SCHEMA_VERSION,
958            reason: format!(
959                "schema verification failed: the database is stamped v{SCHEMA_VERSION} \
960                 but {} delete guard(s) do not probe the archive-session marker: {}. \
961                 A guard with the right name and a pre-v9 body refuses archival it \
962                 should permit; upgrading through the ladder replaces it.",
963                ungated.len(),
964                ungated.join(", ")
965            ),
966        });
967    }
968
969    // The guards above are checked for being *gated*. This checks that the gate
970    // is not currently open (0.10.0, W2).
971    //
972    // A committed `macrame_archive_session` disarms all three delete guards and
973    // silences the concepts log-insert trigger — Doctrine IV and Doctrine V
974    // suspended at once, with no error and no counter. Nothing checked for it,
975    // and the safety argument on record (§5.7) is about *crashes*: it is correct
976    // about those, because both archive paths bracket the marker inside the
977    // session transaction, so a rollback discards it. It says nothing about a
978    // writer that creates the table directly, and §4.7 concedes raw writers.
979    //
980    // Free to check here: `present` is already built, so this is one more scan
981    // of a vector, not another query. And it is safe *here specifically* —
982    // `verify` reads committed state at open, so it cannot observe an in-flight
983    // session and refuse a healthy database mid-archive. Moving it onto a path
984    // that runs during a session would break that.
985    if has("table", ARCHIVE_SESSION_MARKER) {
986        return Err(DbError::ArchiveSessionLeaked {
987            marker: ARCHIVE_SESSION_MARKER.to_string(),
988        });
989    }
990
991    Ok(())
992}
993
994/// The delete guards, whose bodies [`verify`] checks rather than only their
995/// names.
996///
997/// Listed rather than discovered, on the same reasoning as
998/// [`CONCEPTS_TRIGGERS_V7`]: the property being asserted is that *these three*
999/// tables cannot lose rows outside an archive session, and a loop over whatever
1000/// happens to be named `*_guard_delete` would assert whatever the schema
1001/// happens to contain.
1002const DELETE_GUARDS: &[&str] = &[
1003    "trg_concepts_guard_delete",
1004    "trg_links_guard_delete",
1005    "trg_txlog_guard_delete",
1006];
1007
1008/// The object names the DDL creates, recovered from the DDL itself.
1009///
1010/// Parsed rather than listed separately so that adding a trigger to
1011/// [`CREATE_TRIGGERS`] extends what `verify` requires, with no second list to
1012/// remember. A hand-kept list of names beside the statements that create them is
1013/// the drift D-035 is about.
1014fn names_after(ddl: &[&str], keyword: &str) -> Vec<String> {
1015    ddl.iter()
1016        .filter_map(|stmt| {
1017            let lower = stmt.to_ascii_lowercase();
1018            let at = lower.find(keyword)? + keyword.len();
1019            Some(
1020                stmt[at..]
1021                    .split_whitespace()
1022                    .next()?
1023                    .trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
1024                    .to_string(),
1025            )
1026        })
1027        .filter(|n| !n.is_empty())
1028        .collect()
1029}
1030
1031fn trigger_names() -> Vec<String> {
1032    names_after(CREATE_TRIGGERS, "create trigger if not exists ")
1033}
1034
1035fn index_names() -> Vec<String> {
1036    names_after(CREATE_INDICES, "create index if not exists ")
1037}
1038
1039async fn read_user_version(conn: &libsql::Connection) -> Result<u32> {
1040    // PRAGMA user_version yields a row, so it must go through query(), not
1041    // execute() -- libsql rejects a statement that returns rows from execute().
1042    let mut rows = conn.query("PRAGMA user_version", ()).await?;
1043    match rows.next().await? {
1044        Some(row) => Ok(row.get::<u32>(0)?),
1045        None => Ok(0),
1046    }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052
1053    /// A rung that does not advance the version would spin `run`'s loop forever.
1054    #[test]
1055    fn every_step_advances() {
1056        for step in STEPS {
1057            assert!(
1058                step.to > step.from,
1059                "step {:?} does not advance ({} -> {})",
1060                step.name,
1061                step.from,
1062                step.to
1063            );
1064        }
1065    }
1066
1067    /// Two rungs out of the same version make the ladder ambiguous: `run` takes
1068    /// whichever comes first in the array, which is not a decision anyone made.
1069    #[test]
1070    fn no_two_steps_share_an_origin() {
1071        for (i, a) in STEPS.iter().enumerate() {
1072            for b in &STEPS[i + 1..] {
1073                assert_ne!(a.from, b.from, "two steps start at v{}", a.from);
1074            }
1075        }
1076    }
1077
1078    /// The ladder has to actually reach the version this build stamps, or every
1079    /// fresh open fails with "no migration step leads out of v0".
1080    #[test]
1081    fn the_ladder_reaches_the_current_version() {
1082        let mut current = 0;
1083        for _ in 0..STEPS.len() {
1084            match STEPS.iter().find(|s| s.from == current) {
1085                Some(step) => current = step.to,
1086                None => break,
1087            }
1088        }
1089        assert_eq!(current, SCHEMA_VERSION);
1090    }
1091
1092    /// `verify` requires every name this returns, so a parse that silently
1093    /// yielded nothing would turn verification into a no-op that passes on an
1094    /// empty database.
1095    #[test]
1096    fn every_trigger_and_index_yields_a_name() {
1097        let triggers = super::trigger_names();
1098        assert_eq!(triggers.len(), CREATE_TRIGGERS.len());
1099        assert!(
1100            triggers.iter().all(|n| n.starts_with("trg_")),
1101            "{triggers:?}"
1102        );
1103
1104        let indices = super::index_names();
1105        assert_eq!(indices.len(), CREATE_INDICES.len());
1106        assert!(indices.iter().all(|n| n.starts_with("idx_")), "{indices:?}");
1107    }
1108
1109    /// v1 belongs to the pre-canonical schema and must stay unreachable, or a
1110    /// 0.5.3 database silently becomes a supported input again.
1111    #[test]
1112    fn legacy_v1_has_no_rung() {
1113        assert!(
1114            !STEPS.iter().any(|s| s.from == 1),
1115            "a step out of v1 reintroduces pre-0.5.4 databases as a supported input"
1116        );
1117    }
1118
1119    // -- the foreign-key suspension mechanism (0.8.0, B4, D-117) -------------
1120    //
1121    // No shipped rung sets `suspends_foreign_keys` yet — v7 → v8 is what will.
1122    // The mechanism lands first and is tested first, because the thing that
1123    // makes it safe is not that the rebuild works (the probe measured that) but
1124    // that a rung which suspends enforcement **still cannot commit a violation**.
1125    // A flag that turns checking off is only acceptable if something else turns
1126    // verification on, and that is what these two tests hold.
1127
1128    async fn scratch() -> libsql::Connection {
1129        let db = libsql::Builder::new_local(":memory:").build().await.unwrap();
1130        let conn = db.connect().unwrap();
1131        conn.execute("PRAGMA foreign_keys = ON", ()).await.unwrap();
1132        conn.execute("CREATE TABLE parent (id TEXT PRIMARY KEY)", ())
1133            .await
1134            .unwrap();
1135        conn.execute(
1136            "CREATE TABLE child (id TEXT PRIMARY KEY, p TEXT NOT NULL \
1137             REFERENCES parent(id))",
1138            (),
1139        )
1140        .await
1141        .unwrap();
1142        conn.execute("INSERT INTO parent VALUES ('a')", ())
1143            .await
1144            .unwrap();
1145        conn.execute("INSERT INTO child VALUES ('c', 'a')", ())
1146            .await
1147            .unwrap();
1148        conn
1149    }
1150
1151    /// The shape the probe found: rebuild a table that has inbound foreign keys
1152    /// by dropping and renaming, which is impossible with enforcement on.
1153    #[tokio::test]
1154    async fn a_suspending_rung_can_rebuild_a_table_with_inbound_keys() {
1155        let conn = scratch().await;
1156        let step = Step {
1157            from: 0,
1158            to: 99,
1159            name: "test-rebuild",
1160            suspends_foreign_keys: true,
1161            apply: |tx| {
1162                Box::pin(async move {
1163                    tx.execute("CREATE TABLE parent_new (rowid_pk INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE)", ()).await?;
1164                    tx.execute("INSERT INTO parent_new (id) SELECT id FROM parent ORDER BY rowid", ()).await?;
1165                    tx.execute("DROP TABLE parent", ()).await?;
1166                    tx.execute("ALTER TABLE parent_new RENAME TO parent", ()).await?;
1167                    Ok(())
1168                })
1169            },
1170        };
1171
1172        apply_step(&conn, &step).await.expect("the rung must apply");
1173
1174        // The rebuild happened, the child still resolves, and — the part that
1175        // matters — enforcement is back on afterwards.
1176        let mut rows = conn
1177            .query("SELECT rowid_pk FROM parent WHERE id = 'a'", ())
1178            .await
1179            .unwrap();
1180        assert!(rows.next().await.unwrap().is_some(), "parent lost its row");
1181
1182        let orphan = conn
1183            .execute("INSERT INTO child VALUES ('d', 'nonexistent')", ())
1184            .await;
1185        assert!(
1186            orphan.is_err(),
1187            "foreign keys were not restored after the rung"
1188        );
1189    }
1190
1191    /// **The reason the flag is safe.** A rung that suspends enforcement and
1192    /// leaves a genuine violation must not commit: `foreign_key_check` runs
1193    /// inside the transaction and its rows fail the rung.
1194    ///
1195    /// Without this, `suspends_foreign_keys` would be a way to write a corrupt
1196    /// database on purpose and have the ladder call it a success.
1197    #[tokio::test]
1198    async fn a_suspending_rung_that_leaves_a_violation_is_refused() {
1199        let conn = scratch().await;
1200        let step = Step {
1201            from: 0,
1202            to: 99,
1203            name: "test-orphan",
1204            suspends_foreign_keys: true,
1205            // Drops the parent and puts nothing back: `child.p` now points at
1206            // nothing. With enforcement on this could not even be attempted,
1207            // which is exactly why the check has to exist.
1208            apply: |tx| {
1209                Box::pin(async move {
1210                    tx.execute("DROP TABLE parent", ()).await?;
1211                    tx.execute("CREATE TABLE parent (id TEXT PRIMARY KEY)", ())
1212                        .await?;
1213                    Ok(())
1214                })
1215            },
1216        };
1217
1218        let err = apply_step(&conn, &step)
1219            .await
1220            .expect_err("a rung that orphans a row must not commit");
1221        // Pinned to *this* wording, not to "foreign key" generally. A DDL
1222        // statement failing for its own reasons would also produce an error
1223        // mentioning foreign keys, and the test would then pass while proving
1224        // nothing about the check that is the point of the flag.
1225        let text = err.to_string();
1226        assert!(
1227            text.contains("suspended foreign keys and left a violation"),
1228            "the rung must fail at `foreign_key_check`, not merely fail: {text}"
1229        );
1230        assert!(text.contains("test-orphan"), "the error should name the rung: {text}");
1231
1232        // Rolled back, and enforcement restored despite the failure.
1233        let mut rows = conn.query("PRAGMA user_version", ()).await.unwrap();
1234        let v: u32 = rows.next().await.unwrap().unwrap().get(0).unwrap();
1235        assert_eq!(v, 0, "a failed rung must not stamp its version");
1236
1237        let orphan = conn
1238            .execute("INSERT INTO child VALUES ('d', 'nonexistent')", ())
1239            .await;
1240        assert!(
1241            orphan.is_err(),
1242            "foreign keys must be restored even when the rung failed"
1243        );
1244    }
1245}