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