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