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