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