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