Skip to main content

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}