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