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}