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 = 7;
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}
27
28/// The ladder, in no particular order — `run` walks it by matching `from`.
29///
30/// The rung out of 0 lays the whole schema; the rung out of 2 adds only what
31/// v3 introduced. There is deliberately still no rung out of 1: that is the
32/// pre-canonical schema D-032 refuses by name, and v2 is not the same case —
33/// it was written by this same 0.5.4 line with canonical timestamps and every
34/// CHECK in place, so it is missing a derivative table and nothing else.
35const STEPS: &[Step] = &[
36 Step {
37 from: 0,
38 to: SCHEMA_VERSION,
39 name: "baseline-0.5.4",
40 apply: |conn| Box::pin(baseline(conn)),
41 },
42 Step {
43 from: 2,
44 to: 3,
45 name: "analytics-annotations",
46 apply: |conn| Box::pin(add_analytics_annotations(conn)),
47 },
48 Step {
49 from: 3,
50 to: 4,
51 name: "traversal-covering-index",
52 apply: |conn| Box::pin(add_traversal_cover(conn)),
53 },
54 Step {
55 from: 4,
56 to: 5,
57 name: "concepts-fts",
58 apply: |conn| Box::pin(add_concepts_fts(conn)),
59 },
60 Step {
61 from: 5,
62 to: 6,
63 name: "single-open-interval-index",
64 apply: |conn| Box::pin(add_open_interval_index(conn)),
65 },
66 Step {
67 from: 6,
68 to: 7,
69 name: "links-weight-check",
70 apply: |conn| Box::pin(add_weight_check(conn)),
71 },
72];
73
74/// Bring `conn`'s database up to [`SCHEMA_VERSION`], or fail explaining why not.
75///
76/// Reading `user_version` before writing is the whole point. The previous
77/// implementation re-ran every `CREATE … IF NOT EXISTS` unconditionally and then
78/// stamped the version it had never read, which meant it could not distinguish a
79/// fresh file from a foreign one from a database written by a future build — it
80/// simply asserted the schema it wanted and hoped. `IF NOT EXISTS` hides exactly
81/// the case that matters: an object that exists with a *different* definition is
82/// silently kept, so a legacy table would survive with none of its constraints
83/// while the stamp claimed otherwise.
84/// What [`run`] did, so a caller can react to the schema having moved.
85///
86/// The one caller that must is `Database::open`: a `SCHEMA_VERSION` bump
87/// invalidates every snapshot on disk (D-043), and until Wave 4.4 nothing
88/// noticed — the first `reconstruct` after an upgrade skipped every snapshot as
89/// incompatible and folded from genesis, correctly and expensively, with the
90/// only trace a `warn!` per skipped file.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct MigrationOutcome {
93 /// The version the file carried on the way in.
94 pub from: u32,
95 /// [`SCHEMA_VERSION`], always — `run` either reaches it or fails.
96 pub to: u32,
97}
98
99impl MigrationOutcome {
100 /// Whether an **existing** database moved between versions.
101 ///
102 /// A fresh file (`from == 0`) is deliberately not an upgrade. It has no
103 /// snapshots to invalidate, so there is nothing to re-anchor — and treating
104 /// it as one made `Database::open` write a snapshot on every first open,
105 /// which broke two contracts the suite already pins: an idle database is
106 /// never anchored, and a handle opened with no cadence writes nothing until
107 /// `close()`. Both are worth keeping. `open()` touching the disk when it was
108 /// not asked to is surprising in its own right.
109 pub fn upgraded(&self) -> bool {
110 self.from != 0 && self.from != self.to
111 }
112}
113
114pub async fn run(conn: &libsql::Connection) -> Result<MigrationOutcome> {
115 let found = read_user_version(conn).await?;
116
117 if found > SCHEMA_VERSION {
118 return Err(DbError::Migration {
119 to: SCHEMA_VERSION,
120 reason: format!(
121 "database is at schema v{found}; this build understands v{SCHEMA_VERSION} \
122 and will not operate on a schema it does not know. Upgrade macrame \
123 rather than opening the file with an older build."
124 ),
125 });
126 }
127
128 if found == 0 {
129 refuse_if_occupied(conn).await?;
130 }
131
132 let mut current = found;
133 while current != SCHEMA_VERSION {
134 let step = STEPS
135 .iter()
136 .find(|s| s.from == current)
137 .ok_or_else(|| no_path_from(current))?;
138 apply_step(conn, step).await?;
139 current = step.to;
140 }
141
142 verify(conn).await?;
143 Ok(MigrationOutcome {
144 from: found,
145 to: SCHEMA_VERSION,
146 })
147}
148
149/// Version this build stamps on databases it creates.
150pub fn current_version() -> u32 {
151 SCHEMA_VERSION
152}
153
154/// Refuse to lay the baseline over a database that already holds something.
155///
156/// `user_version` defaults to 0, so an unrelated SQLite file is indistinguishable
157/// from a fresh one by version alone. Without this check, pointing macrame at the
158/// wrong path would quietly add four tables and nine triggers to somebody else's
159/// database — including delete guards that abort writes the owner never asked to
160/// have guarded.
161async fn refuse_if_occupied(conn: &libsql::Connection) -> Result<()> {
162 let mut rows = conn
163 .query(
164 "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'",
165 (),
166 )
167 .await?;
168 let objects: i64 = match rows.next().await? {
169 Some(row) => row.get(0)?,
170 None => 0,
171 };
172
173 if objects > 0 {
174 return Err(DbError::Migration {
175 to: SCHEMA_VERSION,
176 reason: format!(
177 "database carries no macrame schema version but already holds {objects} \
178 object(s); refusing to lay the baseline over an unrelated database. \
179 Point at a new file, or delete this one deliberately."
180 ),
181 });
182 }
183 Ok(())
184}
185
186/// Explain a version with no rung leading out of it.
187fn no_path_from(current: u32) -> DbError {
188 let reason = if current < SCHEMA_VERSION {
189 format!(
190 "database is at schema v{current}, written by a pre-0.5.4 build: its \
191 timestamps are second-precision and its tables carry none of the \
192 canonical-form CHECK constraints (D-029). This build provides no \
193 migration path — create a new database."
194 )
195 } else {
196 format!("no migration step leads out of schema v{current}")
197 };
198 DbError::Migration {
199 to: SCHEMA_VERSION,
200 reason,
201 }
202}
203
204/// Run one rung inside a single transaction, stamp included.
205///
206/// `user_version` is a database-header field and its write is journalled like
207/// any other, so stamping inside the transaction makes "the schema exists" and
208/// "the schema is declared to exist" the same commit. A crash mid-step therefore
209/// leaves a database that is still honestly at its old version, rather than one
210/// stamped for a schema it only partly has.
211async fn apply_step(conn: &libsql::Connection, step: &Step) -> Result<()> {
212 let tx = conn
213 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
214 .await?;
215
216 let res: Result<()> = async {
217 (step.apply)(&tx).await?;
218 // PRAGMA takes no bind parameters; `to` is a u32 read from a const.
219 tx.execute(&format!("PRAGMA user_version = {}", step.to), ())
220 .await?;
221 Ok(())
222 }
223 .await;
224
225 match res {
226 Ok(()) => {
227 tx.commit().await?;
228 Ok(())
229 }
230 Err(e) => {
231 let _ = tx.rollback().await;
232 Err(DbError::Migration {
233 to: step.to,
234 reason: format!("step {:?}: {e}", step.name),
235 })
236 }
237 }
238}
239
240/// The 0.5.4 schema, applied to an empty database.
241async fn baseline(conn: &libsql::Connection) -> Result<()> {
242 // concepts first: links declares a foreign key into it.
243 conn.execute(CREATE_CONCEPTS_TABLE, ()).await?;
244 conn.execute(CREATE_LINKS_TABLE, ()).await?;
245 conn.execute(CREATE_LINKS_CURRENT_TABLE, ()).await?;
246 conn.execute(CREATE_TRANSACTION_LOG_TABLE, ()).await?;
247 // Derivative, and last: every index in CREATE_INDICES must have its table.
248 conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
249 // Before the triggers, not after: `trg_concepts_fts_*` name this table, and
250 // SQLite resolves a trigger body's tables at CREATE TRIGGER time.
251 conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
252
253 for index_ddl in CREATE_INDICES {
254 conn.execute(index_ddl, ()).await?;
255 }
256
257 for trigger_ddl in CREATE_TRIGGERS {
258 conn.execute(trigger_ddl, ()).await?;
259 }
260
261 Ok(())
262}
263
264/// v4 → v5: add the FTS5 index over concept text (§5.9, D-051).
265///
266/// Derivative and additive, so D-036 permits it — an FTS index over `concepts`
267/// is Doctrine VI's second category, disposable and reconstructible. The two
268/// triggers land on `concepts`, which *is* a frozen ledger table, but a trigger
269/// changes neither its columns nor its rows; the compat contract freezes the
270/// table's shape, and that is untouched.
271///
272/// Unlike the v2 → v3 rung this one **does** backfill, and can: the index is a
273/// pure function of text the ledger already holds, so `'rebuild'` reconstructs
274/// exactly what the triggers would have written had they always existed. That is
275/// the difference between this and D-041's annotations, where the old data was
276/// destroyed and no recovery existed.
277async fn add_concepts_fts(conn: &libsql::Connection) -> Result<()> {
278 conn.execute(CREATE_CONCEPTS_FTS, ()).await?;
279 for trigger_ddl in CREATE_TRIGGERS {
280 conn.execute(trigger_ddl, ()).await?;
281 }
282 conn.execute(REBUILD_CONCEPTS_FTS, ()).await?;
283 Ok(())
284}
285
286/// v2 → v3: add the derivative analytics table (D-041).
287///
288/// Purely additive, and additive on the *periphery* — `analytics_annotations`
289/// is Doctrine VI's second category, so D-036's freeze on the ledger tables is
290/// not in play. Nothing is backfilled: annotations written before v3 went into
291/// `concepts.content`, which is the defect, and there is no way to tell a label
292/// that landed there from the document text it replaced. Recomputing is the
293/// recovery, and recomputing is what this table exists to make cheap.
294async fn add_analytics_annotations(conn: &libsql::Connection) -> Result<()> {
295 conn.execute(CREATE_ANALYTICS_ANNOTATIONS_TABLE, ()).await?;
296 for index_ddl in CREATE_INDICES {
297 conn.execute(index_ddl, ()).await?;
298 }
299 Ok(())
300}
301
302/// v5 → v6: index the single-open-interval probe (D-059).
303///
304/// Index-only and on a derivative table, so D-036 permits it on the same two
305/// grounds the v3 → v4 rung stood on. Nothing is dropped this time: the new
306/// index and `idx_lc_traversal_cover` serve different shapes — one needs three
307/// equality columns bound, the other leads on `source_id` alone — so neither
308/// subsumes the other and keeping both is the point rather than an oversight.
309///
310/// **This is the largest measured win in the tree and it sat proven and
311/// unshipped for a full cycle**, on the stated ground that an index is a schema
312/// change wanting its own rung. That was a description of the work rather than
313/// an objection to it. See [`CREATE_INDICES`] for the numbers.
314///
315/// Nothing is backfilled because an index has nothing to backfill; `CREATE
316/// INDEX` populates it from the table. That makes this the cheapest rung on the
317/// ladder and the only one whose cost is a function of existing row count alone.
318async fn add_open_interval_index(conn: &libsql::Connection) -> Result<()> {
319 for index_ddl in CREATE_INDICES {
320 conn.execute(index_ddl, ()).await?;
321 }
322 Ok(())
323}
324
325/// The v7 shape of `links`, pinned as text (T2.1, D-083).
326///
327/// **Deliberately not `ddl::CREATE_LINKS_TABLE`.** Every other rung on this
328/// ladder reuses the DDL constants, and for those it is right — they create an
329/// index or a derivative table, and getting today's definition is the point. A
330/// *table rebuild* is different: it produces whatever shape the constant names
331/// at the moment it runs, so the day `links` gains a v8 column, this rung would
332/// silently take a v6 database straight to the v8 shape and stamp it v7. The
333/// ladder would then have two databases both stamped v7 with different columns,
334/// and the v7 → v8 rung would run against a table that already had its change.
335///
336/// A migration rung is a statement about the past. Pinning the text is what
337/// makes it one.
338const LINKS_V7: &str = r#"
339CREATE TABLE links_v7 (
340 source_id TEXT NOT NULL REFERENCES concepts(id),
341 target_id TEXT NOT NULL REFERENCES concepts(id),
342 edge_type TEXT NOT NULL,
343 valid_from TEXT NOT NULL,
344 recorded_at TEXT NOT NULL,
345 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
346 weight REAL NOT NULL DEFAULT 1.0,
347 properties TEXT NOT NULL DEFAULT '{}',
348 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at),
349 CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
350 -- (the timestamp CHECK, spelled out for the same pinning reason)
351 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)
352)
353"#;
354
355/// v6 → v7: constrain `links.weight` (§4.7, T2.1, D-083).
356///
357/// # The only rung that rewrites a ledger table, and what that costs
358///
359/// SQLite has no `ADD CONSTRAINT`, so this is a full rebuild of `links` — the
360/// largest table in the schema — inside [`apply_step`]'s single transaction:
361/// create, copy, drop, rename, recreate triggers. Cost is O(rows) in time and
362/// roughly 2× `links` in peak disk. Every other rung on this ladder is index
363/// work or an additive table; this one is not, and a caller upgrading a large
364/// database should expect it to take a while and to need the space.
365///
366/// It is taken **pre-1.0 on purpose**. D-032 makes this a baseline re-issue
367/// today, which is cheap; after 1.0 the compat contract (D-036) freezes the
368/// ledger tables and the same change becomes an unmigration.
369///
370/// # Doctrine III is not violated, and the case where it would be is refused
371///
372/// A rebuild that *altered* an assertion would be exactly what Doctrine III
373/// forbids. This one copies every row verbatim — no clamping, no rounding, no
374/// dropping. Which means a database already holding a weight the new constraint
375/// rejects cannot be migrated at all, and this refuses **before** touching
376/// anything, with a count and an example, rather than failing halfway through a
377/// copy with a bare `CHECK constraint failed`.
378///
379/// Such rows are reachable: until this rung, `assert_edge(weight = -1.0)` was
380/// accepted by the write API and refused only at load time (§4.7). That was the
381/// gap. An operator who has them must decide what those assertions meant, and
382/// that is not a decision a migration can take for them.
383///
384/// # Order, and the trap it avoids
385///
386/// `DROP TABLE links` first, then rename. Dropping the table takes its four
387/// triggers with it, so the rename does not reparse a schema containing trigger
388/// bodies that name a table which no longer exists — the failure T1.2 hit from
389/// the other direction. All triggers are `IF NOT EXISTS`, so re-running the
390/// whole array afterwards recreates the four on `links` and no-ops the rest.
391/// No index is defined on `links`, so there is none to rebuild.
392async fn add_weight_check(conn: &libsql::Connection) -> Result<()> {
393 let offending: i64 = conn
394 .query(
395 "SELECT COUNT(*) FROM links WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real')",
396 (),
397 )
398 .await?
399 .next()
400 .await?
401 .and_then(|r| r.get(0).ok())
402 .unwrap_or(0);
403
404 if offending > 0 {
405 let example: Option<String> = conn
406 .query(
407 "SELECT source_id || ' -> ' || target_id || ' (' || edge_type || \
408 ') weight=' || CAST(weight AS TEXT) FROM links \
409 WHERE NOT (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real') LIMIT 1",
410 (),
411 )
412 .await?
413 .next()
414 .await?
415 .and_then(|r| r.get(0).ok());
416
417 return Err(DbError::Migration {
418 to: 7,
419 reason: format!(
420 "{offending} row(s) in `links` hold a weight the v7 constraint \
421 rejects, e.g. {}. Copying them verbatim is impossible and \
422 altering them would violate Doctrine III, so this migration \
423 refuses rather than choosing on your behalf. These rows were \
424 writable through `assert_edge` before v7 (§4.7) — decide what \
425 they were meant to assert, archive them, and retry.",
426 example.as_deref().unwrap_or("<unreadable>")
427 ),
428 });
429 }
430
431 conn.execute(LINKS_V7, ()).await?;
432 conn.execute(
433 "INSERT INTO links_v7 (source_id, target_id, edge_type, valid_from, \
434 recorded_at, valid_to, weight, properties) \
435 SELECT source_id, target_id, edge_type, valid_from, recorded_at, \
436 valid_to, weight, properties FROM links",
437 (),
438 )
439 .await?;
440 conn.execute("DROP TABLE links", ()).await?;
441 conn.execute("ALTER TABLE links_v7 RENAME TO links", ())
442 .await?;
443
444 for trigger_ddl in CREATE_TRIGGERS {
445 conn.execute(trigger_ddl, ()).await?;
446 }
447
448 Ok(())
449}
450
451/// v3 → v4: swap `idx_lc_src_active` for the traversal covering index (D-042).
452///
453/// Index-only, and on a derivative table, so D-036 permits it twice over. The
454/// drop is the point as much as the create: the new index has the same seek
455/// column and strictly more payload, so keeping the old one would cost a second
456/// index write on every assertion and buy nothing. Order matters only for peak
457/// disk — create first so the traversal is never left without an index at all,
458/// even though the whole rung is one transaction.
459async fn add_traversal_cover(conn: &libsql::Connection) -> Result<()> {
460 for index_ddl in CREATE_INDICES {
461 conn.execute(index_ddl, ()).await?;
462 }
463 conn.execute("DROP INDEX IF EXISTS idx_lc_src_active", ())
464 .await?;
465 Ok(())
466}
467
468/// The tables the baseline declares, by name, for [`verify`].
469pub(crate) const BASELINE_TABLES: &[&str] = &[
470 "concepts",
471 "links",
472 "links_current",
473 "transaction_log",
474 "analytics_annotations",
475 "concepts_fts",
476];
477
478/// Confirm the database actually holds what the DDL claims to create.
479///
480/// Cheap insurance against the failure mode `IF NOT EXISTS` is built to hide: a
481/// statement that no-ops instead of creating. It also catches the DDL arrays and
482/// reality drifting apart — add a trigger to [`CREATE_TRIGGERS`] that fails to
483/// compile as written and it is missing here rather than at the first write that
484/// needed it.
485///
486/// **Presence by name, not a count of everything present.** The original
487/// counted `sqlite_master` and required exactly four tables, which made
488/// verification fail on any database carrying an object the baseline did not
489/// create — and this schema now has three legitimate sources of those. A
490/// registered embedding model adds `embeddings_<model>` (§4.1); libSQL's vector
491/// index adds `libsql_vector_meta_shadow`, a shadow table and a shadow index of
492/// its own; and D-036 explicitly permits post-1.0 migrations to add indexes. A
493/// count treats all three as corruption and refuses to open a healthy file. What
494/// verification is actually for is the absence of something required, so that is
495/// what it now checks.
496async fn verify(conn: &libsql::Connection) -> Result<()> {
497 let mut rows = conn
498 .query(
499 "SELECT type, name FROM sqlite_master WHERE type IN ('table','trigger','index')",
500 (),
501 )
502 .await?;
503
504 let mut present: Vec<(String, String)> = Vec::new();
505 while let Some(row) = rows.next().await? {
506 present.push((row.get(0)?, row.get(1)?));
507 }
508 let has = |kind: &str, name: &str| {
509 present
510 .iter()
511 .any(|(k, n)| k == kind && n.eq_ignore_ascii_case(name))
512 };
513
514 let mut missing: Vec<String> = Vec::new();
515 for table in BASELINE_TABLES {
516 if !has("table", table) {
517 missing.push(format!("table {table}"));
518 }
519 }
520 for name in trigger_names() {
521 if !has("trigger", &name) {
522 missing.push(format!("trigger {name}"));
523 }
524 }
525 for name in index_names() {
526 if !has("index", &name) {
527 missing.push(format!("index {name}"));
528 }
529 }
530
531 if !missing.is_empty() {
532 return Err(DbError::Migration {
533 to: SCHEMA_VERSION,
534 reason: format!(
535 "schema verification failed: the database is stamped v{SCHEMA_VERSION} \
536 but is missing {}: {}",
537 missing.len(),
538 missing.join(", ")
539 ),
540 });
541 }
542 Ok(())
543}
544
545/// The object names the DDL creates, recovered from the DDL itself.
546///
547/// Parsed rather than listed separately so that adding a trigger to
548/// [`CREATE_TRIGGERS`] extends what `verify` requires, with no second list to
549/// remember. A hand-kept list of names beside the statements that create them is
550/// the drift D-035 is about.
551fn names_after(ddl: &[&str], keyword: &str) -> Vec<String> {
552 ddl.iter()
553 .filter_map(|stmt| {
554 let lower = stmt.to_ascii_lowercase();
555 let at = lower.find(keyword)? + keyword.len();
556 Some(
557 stmt[at..]
558 .split_whitespace()
559 .next()?
560 .trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
561 .to_string(),
562 )
563 })
564 .filter(|n| !n.is_empty())
565 .collect()
566}
567
568fn trigger_names() -> Vec<String> {
569 names_after(CREATE_TRIGGERS, "create trigger if not exists ")
570}
571
572fn index_names() -> Vec<String> {
573 names_after(CREATE_INDICES, "create index if not exists ")
574}
575
576async fn read_user_version(conn: &libsql::Connection) -> Result<u32> {
577 // PRAGMA user_version yields a row, so it must go through query(), not
578 // execute() -- libsql rejects a statement that returns rows from execute().
579 let mut rows = conn.query("PRAGMA user_version", ()).await?;
580 match rows.next().await? {
581 Some(row) => Ok(row.get::<u32>(0)?),
582 None => Ok(0),
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 /// A rung that does not advance the version would spin `run`'s loop forever.
591 #[test]
592 fn every_step_advances() {
593 for step in STEPS {
594 assert!(
595 step.to > step.from,
596 "step {:?} does not advance ({} -> {})",
597 step.name,
598 step.from,
599 step.to
600 );
601 }
602 }
603
604 /// Two rungs out of the same version make the ladder ambiguous: `run` takes
605 /// whichever comes first in the array, which is not a decision anyone made.
606 #[test]
607 fn no_two_steps_share_an_origin() {
608 for (i, a) in STEPS.iter().enumerate() {
609 for b in &STEPS[i + 1..] {
610 assert_ne!(a.from, b.from, "two steps start at v{}", a.from);
611 }
612 }
613 }
614
615 /// The ladder has to actually reach the version this build stamps, or every
616 /// fresh open fails with "no migration step leads out of v0".
617 #[test]
618 fn the_ladder_reaches_the_current_version() {
619 let mut current = 0;
620 for _ in 0..STEPS.len() {
621 match STEPS.iter().find(|s| s.from == current) {
622 Some(step) => current = step.to,
623 None => break,
624 }
625 }
626 assert_eq!(current, SCHEMA_VERSION);
627 }
628
629 /// `verify` requires every name this returns, so a parse that silently
630 /// yielded nothing would turn verification into a no-op that passes on an
631 /// empty database.
632 #[test]
633 fn every_trigger_and_index_yields_a_name() {
634 let triggers = super::trigger_names();
635 assert_eq!(triggers.len(), CREATE_TRIGGERS.len());
636 assert!(
637 triggers.iter().all(|n| n.starts_with("trg_")),
638 "{triggers:?}"
639 );
640
641 let indices = super::index_names();
642 assert_eq!(indices.len(), CREATE_INDICES.len());
643 assert!(indices.iter().all(|n| n.starts_with("idx_")), "{indices:?}");
644 }
645
646 /// v1 belongs to the pre-canonical schema and must stay unreachable, or a
647 /// 0.5.3 database silently becomes a supported input again.
648 #[test]
649 fn legacy_v1_has_no_rung() {
650 assert!(
651 !STEPS.iter().any(|s| s.from == 1),
652 "a step out of v1 reintroduces pre-0.5.4 databases as a supported input"
653 );
654 }
655}