Skip to main content

alkgit_substrate/
migrations.rs

1//! The registry schema migrations (ADR-024) and the co-tenant
2//! connection handle.
3//!
4//! # The machinery (the shared pattern)
5//!
6//! One alkgit-owned stamp table versions the co-tenant schema *as a
7//! unit* — the record rows, ref rows, the `registry_seq` singleton,
8//! the mutation-marker rows, and the LFS liveness rows ship and evolve
9//! together, so one integer stamp covers them all (ADR-024 §2). Steps
10//! are ordered, hand-written, and monomorphic *per engine twin* (the
11//! twins already carry engine-specific SQL; migrations ride them — no
12//! framework, no builder). The runner's contract, identical for both
13//! twins:
14//!
15//! - **One transaction per step** — the step's DDL and its stamp
16//!   advance commit together; a failed transaction rolls back whole
17//!   and nothing is left half-applied by the failed step.
18//! - **Construction-run, loud failure** — migrations run when the
19//!   adapter constructs its connection, and any unverified failure
20//!   rides [`OpenError::Migration`]: a
21//!   half-migrated store refuses service rather than degrading
22//!   silently (the fallible-constructor posture ADR-024 §1). Every
23//!   construction re-runs the runner, so a half-migrated store keeps
24//!   refusing until the state is real or the store is reset.
25//! - **Concurrent-open-safe** — the stamp is read inside each step's
26//!   own `BEGIN IMMEDIATE` transaction (the write lock serializes
27//!   racing openers; a loser sees the winner's committed stamp and
28//!   skips). A step whose SQL nevertheless fails verifies state
29//!   against committed data — the alkstore ADR-012 §5 re-key pattern:
30//!   the step's effect present ⇒ benign race swallowed (and the stamp
31//!   healed, converging the store); absent ⇒ propagate. Load-bearing
32//!   on sqlite, where multi-process co-tenancy of one file is native.
33//! - **Absent stamp ⇔ fresh store** — the stamp table bootstrap
34//!   creates the stamp row at value 0; a fresh store runs the whole
35//!   step set and stamps it.
36//! - **Additive-first, no refuse-gate in v1** — an unknown-forward
37//!   stamp is safe to open against, and the runner leaves it untouched
38//!   (the coexistence contract: explicit column lists, unknown
39//!   columns/tables unread — ADR-024 §4). A non-additive step is
40//!   major-version territory and owns its refusal semantics then.
41//!
42//! # The engine-neutral logical DDL (the shared schema, both twins)
43//!
44//! The initial co-tenant schema, stamped step 1 (ADR-024 §2/§5), in
45//! engine-neutral terms — both engines' SQL lands in this module over
46//! exactly this logical schema: the sqlite twin's step set is the
47//! `sqlite` module below; the postgres twin writes step 1's pg SQL
48//! (BIGINT sequence columns, CHECK'd visibility text) in its own
49//! module:
50//!
51//! - `alkgit_schema_stamp (id = 1, stamp)` — the alkgit-owned stamp
52//!   row (the machinery's meta table, beside its own tables).
53//! - `git_repos (repo_id PK, visibility in {'public','private'},
54//!   grants, seq)` — the record rows: the backend.md §`RepoRecord`
55//!   serde shape serialized (`grants` is the JSON object
56//!   identity-id → action array; `seq` is the row's mutation sequence
57//!   number, assigned inside the row-write transaction).
58//! - `git_refs (repo_id, name) → oid, symref_target` — the ref rows
59//!   (POC-O-validated shape; ADR-019 §2). HEAD is a symref row: `oid`
60//!   NULL with a set target — exactly the unborn
61//!   `RefLine { oid: None }` shape.
62//! - `registry_seq (id = 1, value)` — the watermark singleton; every
63//!   registry mutation bumps it inside the row-write transaction
64//!   (transactionally exact, ADR-019 §5).
65//! - `repo_mutation_markers (repo_id PK, seq)` — the per-push
66//!   mutation-marker rows (composition-internal, ADR-019 §7: pushes
67//!   do not touch `RepoRecord`; the receive flow's watermark leg; the
68//!   record schema is untouched).
69//! - `lfs_objects (repo_id, oid) → size, refcount` — the LFS liveness
70//!   rows with the **day-one refcount column** (ADR-024 §5 — nothing
71//!   published predates it, so no migration is owed and none should be
72//!   written; the column's consumption is wave 9). The table ships in
73//!   the step set unconditionally — the tables evolve as one
74//!   composition; the `lfs` feature gates the surface consuming it, so
75//!   an inert table is harmless and the stamp stays unit-shaped.
76//!
77//! The stamp table itself is bootstrapped idempotently before the
78//! first read (`CREATE TABLE IF NOT EXISTS` + `INSERT OR IGNORE` at
79//! 0), so "absent stamp ⇔ fresh store" holds under concurrent opens.
80//!
81//! # The co-tenant connection handles (one per engine twin)
82//!
83//! The handles' placement is symmetric across engines — one file-local
84//! module per engine twin in this, the ADR-024 machinery home:
85//!
86//! - [`CoTenantSqlite`] (the `sqlite` feature) — the thin wrapper over
87//!   the rusqlite connection that the registry adapter and the refs
88//!   layer both consume: it opens the co-tenant connection (WAL,
89//!   NORMAL synchronous, pinned 5000 ms busy timeout — substrate.md
90//!   §assembly tuning datums) beside the engine-owned schemas on the
91//!   same file (alkblobs ADR-013 §3's composition posture), runs the
92//!   migrations at construction, and hands out the connection for the
93//!   twins' blocking SQL (which the composition's concurrency model
94//!   bridges off the async executor).
95//! - [`CoTenantPg`] (the `postgres` feature) — the shape's other half:
96//!   the thin wrapper over the tokio-postgres client the postgres
97//!   twins consume, natively async (no blocking bridge — the shared
98//!   client lives under a `tokio::sync::Mutex` whose guard is held for
99//!   one statement batch or transaction, the twin's short-held-lock
100//!   model). Its engine-locally-realized runner (below) rides the
101//!   advisory migration lock instead of `BEGIN IMMEDIATE`.
102//!
103//! The postgres twin's pg machinery also carries the crate's advisory
104//! lock-key plumbing over the co-tenant schema: `advisory_lock_key`
105//! (the deterministic key over a scope's identity string) plus the
106//! named scope-key constructors beside it — the stamp-table/migration
107//! runner's key, the `git_refs` CAS scope's key (`git_refs_lock_key`,
108//! the refs CAS twins' and every registry transaction touching the
109//! refs table's serialization point), and the `lfs_objects` rows
110//! scope's key (`lfs_objects_lock_key`) — every transaction touching
111//! a co-tenant table holds that table's advisory lock (the wave-9
112//! lock-discipline invariant), and the composition spells the scope
113//! identity through these constructors only, never hand-inlined. The
114//! runner's step set is the pg step builder (`build_steps`), step
115//! order monotone with sqlite's (`STEP_VERSIONS`).
116
117#[cfg(feature = "sqlite")]
118use crate::error::OpenError;
119
120#[cfg(feature = "sqlite")]
121mod sqlite_twin {
122    use super::OpenError;
123    use std::path::Path;
124    use std::sync::Arc;
125
126    /// The alkgit-owned stamp table — one row versioning the
127    /// co-tenant schema as a unit (ADR-024 §2).
128    const STAMP_TABLE: &str = "alkgit_schema_stamp";
129
130    /// The pinned co-tenant busy timeout, in milliseconds
131    /// (substrate.md §assembly tuning datums — contracted at both
132    /// substrate crates; the co-tenant connection carries it too).
133    pub(crate) const BUSY_TIMEOUT_MS: i64 = 5_000;
134
135    /// The stamp table's idempotent bootstrap: create the table if
136    /// absent, seed the single stamp row at 0 (absent stamp ⇔ fresh
137    /// store). Safe under concurrent opens — sqlite serializes the
138    /// autocommit statements and both statements are
139    /// existence-guarded.
140    pub(crate) const STAMP_TABLE_BOOTSTRAP: &str = "
141        CREATE TABLE IF NOT EXISTS alkgit_schema_stamp (
142            id    INTEGER PRIMARY KEY CHECK (id = 1),
143            stamp INTEGER NOT NULL
144        );
145        INSERT OR IGNORE INTO alkgit_schema_stamp (id, stamp) VALUES (1, 0);";
146
147    /// One ordered migration step: its version (the stamp value it
148    /// marks — the step's position in the twin's global ordered set),
149    /// its monomorphic SQL applied inside one transaction, plus its
150    /// presence predicate — a query returning a single integer, truthy
151    /// exactly when the step's complete effect is present on the
152    /// engine. The predicate is the race-verification re-key (the
153    /// alkstore ADR-012 §5 pattern): on a step failure, its presence
154    /// decides swallow-vs-propagate.
155    pub(crate) struct Step {
156        pub(crate) version: i64,
157        pub(crate) name: &'static str,
158        pub(crate) sql: &'static str,
159        pub(crate) verify_sql: &'static str,
160    }
161
162    /// The sqlite step set. Step 1 is the initial co-tenant schema
163    /// (the engine-neutral DDL of the module doc, in sqlite SQL).
164    /// Post-publish additions append — additive-first (ADR-024 §4).
165    const SQLITE_STEPS: &[Step] = &[Step {
166        version: 1,
167        name: "initial co-tenant schema (records, refs, seq, mutation markers, LFS liveness rows)",
168        sql: "CREATE TABLE git_repos (
169                  repo_id        TEXT PRIMARY KEY,
170                  visibility     TEXT NOT NULL CHECK (visibility IN ('public', 'private')),
171                  grants         TEXT NOT NULL,
172                  seq            INTEGER NOT NULL
173              );
174              CREATE TABLE git_refs (
175                  repo_id        TEXT NOT NULL,
176                  name           TEXT NOT NULL,
177                  oid_hex        TEXT,
178                  symref_target  TEXT,
179                  PRIMARY KEY (repo_id, name)
180              );
181              CREATE TABLE registry_seq (
182                  id    INTEGER PRIMARY KEY CHECK (id = 1),
183                  value INTEGER NOT NULL
184              );
185              INSERT INTO registry_seq (id, value) VALUES (1, 0);
186              CREATE TABLE repo_mutation_markers (
187                  repo_id  TEXT PRIMARY KEY,
188                  seq      INTEGER NOT NULL
189              );
190              CREATE TABLE lfs_objects (
191                  repo_id   TEXT NOT NULL,
192                  oid       TEXT NOT NULL,
193                  size      INTEGER NOT NULL,
194                  refcount  INTEGER NOT NULL,
195                  PRIMARY KEY (repo_id, oid)
196              );",
197        verify_sql: "SELECT count(*) = 5 FROM sqlite_master WHERE type = 'table' \
198                     AND name IN ('git_repos', 'git_refs', 'registry_seq', \
199                                  'repo_mutation_markers', 'lfs_objects')",
200    }];
201
202    /// The co-tenant connection handle — the thin wrapper over the
203    /// rusqlite connection that the sqlite registry adapter and the
204    /// refs layer both consume (alkgit owns its tables and its
205    /// connection, co-tenant beside the engine-owned schemas on the
206    /// same file — alkblobs ADR-013 §3's composition posture).
207    ///
208    /// Construction is the assembly-time fallible surface: the open,
209    /// the WAL/NORMAL/busy-timeout pinning, and the full
210    /// construction-time migration run all fail loudly into
211    /// [`OpenError`](crate::OpenError) — a half-migrated store refuses
212    /// service (ADR-024 §1). The connection is shared under a
213    /// short-held lock; consumers run their blocking SQL under
214    /// [`CoTenantSqlite::lock`], bridged off the async executor per
215    /// the composition's concurrency model (the trait impls'
216    /// `spawn_blocking` home).
217    #[derive(Clone)]
218    pub struct CoTenantSqlite {
219        conn: Arc<parking_lot::Mutex<rusqlite::Connection>>,
220    }
221
222    impl CoTenantSqlite {
223        /// Open the co-tenant connection at `path` — the same file the
224        /// alkstore engine was opened at — run the migrations, and
225        /// return the handle. In-memory paths are rejected: `:memory:`
226        /// confers no co-tenancy (per-connection databases; WAL cannot
227        /// engage) — ephemeral assemblies compose the mem engines
228        /// (substrate.md §assembly shapes, DQ-7).
229        pub fn open(path: impl AsRef<Path>) -> Result<Self, OpenError> {
230            let path = path.as_ref();
231            if path == Path::new(":memory:") {
232                return Err(OpenError::Invalid(
233                    "sqlite memory mode confers no co-tenancy — compose the \
234                     mem engines for ephemeral assemblies"
235                        .into(),
236                ));
237            }
238            let conn = rusqlite::Connection::open(path).map_err(|e| {
239                OpenError::Engine(format!("co-tenant sqlite open ({}): {e}", path.display()))
240            })?;
241            conn.pragma_update(None, "busy_timeout", BUSY_TIMEOUT_MS)
242                .map_err(|e| OpenError::Engine(format!("co-tenant sqlite busy_timeout: {e}")))?;
243            if let Err(e) = conn.pragma_update(None, "journal_mode", "WAL") {
244                // Concurrent opens race on the journal-mode switch (it
245                // takes the file lock briefly and SQLITE_BUSY is not
246                // retried by the busy handler). Bounded verify-or-retry:
247                // WAL ⇒ a concurrent opener set it — benign; a still-not-
248                // WAL file gets a few bounded re-attempts of its own
249                // (the mode is never un-set once WAL, so persistence past
250                // the window is a real failure) — then propagate loud.
251                const VERIFY_RETRIES: usize = 25;
252                let mut became_wal = false;
253                for _ in 0..VERIFY_RETRIES {
254                    let mode: Result<String, _> =
255                        conn.query_row("PRAGMA journal_mode", [], |r| r.get(0));
256                    if mode.is_ok_and(|m| m.eq_ignore_ascii_case("wal")) {
257                        became_wal = true;
258                        break;
259                    }
260                    if conn.pragma_update(None, "journal_mode", "WAL").is_ok() {
261                        became_wal = true;
262                        break;
263                    }
264                    std::thread::sleep(std::time::Duration::from_millis(10));
265                }
266                if !became_wal {
267                    return Err(OpenError::Engine(format!(
268                        "co-tenant sqlite journal_mode: {e}"
269                    )));
270                }
271            }
272            conn.pragma_update(None, "synchronous", "NORMAL")
273                .map_err(|e| OpenError::Engine(format!("co-tenant sqlite synchronous: {e}")))?;
274            let mut conn = conn;
275            migrate(&mut conn)?;
276            Ok(CoTenantSqlite {
277                conn: Arc::new(parking_lot::Mutex::new(conn)),
278            })
279        }
280
281        /// Hold the connection for blocking SQL work. The guard is
282        /// short-held — one statement batch or transaction per hold,
283        /// bridged off the async executor by the caller.
284        pub fn lock(&self) -> parking_lot::MutexGuard<'_, rusqlite::Connection> {
285            self.conn.lock()
286        }
287
288        /// The store's current co-tenant schema stamp — the version
289        /// support and diagnostics report (an ADR-024 consequence: a
290        /// real version to report). 0 means no step applied yet; a
291        /// value beyond the shipped step set is the safe
292        /// unknown-forward coexistence shape (ADR-024 §4).
293        pub fn stamp(&self) -> Result<i64, OpenError> {
294            read_stamp(&self.lock())
295        }
296
297        /// The shipped step count — the highest stamp this binary's
298        /// step set owns (an honest coexistence datum beside
299        /// [`CoTenantSqlite::stamp`]).
300        pub fn step_count() -> usize {
301            SQLITE_STEPS.len()
302        }
303    }
304
305    /// The construction-time migration run: the stamp-table bootstrap
306    /// plus every pending step, loud on unverified failure. Returns
307    /// the post-run stamp.
308    pub(crate) fn migrate(conn: &mut rusqlite::Connection) -> Result<i64, OpenError> {
309        run_steps(conn, SQLITE_STEPS)
310    }
311
312    /// The step runner — the shared pattern of the module doc, over
313    /// any ordered step set (the tests drive it with probe steps; the
314    /// postgres twin mirrors the pattern with its own SQL).
315    pub(crate) fn run_steps(
316        conn: &mut rusqlite::Connection,
317        steps: &[Step],
318    ) -> Result<i64, OpenError> {
319        conn.execute_batch(STAMP_TABLE_BOOTSTRAP)
320            .map_err(|e| OpenError::Migration {
321                step: 0,
322                reason: format!("stamp-table bootstrap ({STAMP_TABLE}): {e}"),
323            })?;
324        for step in steps {
325            apply_step(conn, step)?;
326        }
327        read_stamp(conn)
328    }
329
330    /// Apply one step if pending. The stamp is read inside the step's
331    /// own BEGIN IMMEDIATE transaction — the write lock serializes
332    /// concurrent openers, so a loser observes the winner's committed
333    /// stamp and skips without ever executing duplicate DDL.
334    fn apply_step(conn: &mut rusqlite::Connection, step: &Step) -> Result<(), OpenError> {
335        let step_no = step.version as usize;
336        let tx = conn
337            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
338            .map_err(|e| OpenError::Migration {
339                step: step_no,
340                reason: format!("step {step_no} ({}): BEGIN IMMEDIATE: {e}", step.name),
341            })?;
342        let stamp_now: i64 = tx
343            .query_row(read_stamp_sql().as_str(), [], |r| r.get(0))
344            .map_err(|e| OpenError::Migration {
345                step: step_no,
346                reason: format!("step {step_no} ({}): stamp read: {e}", step.name),
347            })?;
348        if stamp_now >= step.version {
349            return Ok(());
350        }
351        let outcome = tx
352            .execute_batch(step.sql)
353            .and_then(|_| tx.execute(write_stamp_sql().as_str(), rusqlite::params![step.version]))
354            .and_then(|_| tx.commit());
355        match outcome {
356            Ok(()) => Ok(()),
357            Err(e) => verify_race(conn, step, &e),
358        }
359    }
360
361    /// The failure re-key (the ADR-024 §3 race-verification swallow —
362    /// the alkstore ADR-012 §5 pattern): the failed transaction rolled
363    /// back whole, so only a *concurrently committed* application (or
364    /// an externally created equivalent state) can explain a present
365    /// effect. Present ⇒ benign race swallowed (healing the stamp when
366    /// the concurrent applier's own stamp write has not landed);
367    /// absent ⇒ propagate loud.
368    fn verify_race(
369        conn: &rusqlite::Connection,
370        step: &Step,
371        failure: &rusqlite::Error,
372    ) -> Result<(), OpenError> {
373        if step_effect_present(conn, step)? {
374            let step_no = step.version as usize;
375            conn.execute(mark_stamp_sql().as_str(), rusqlite::params![step.version])
376                .map_err(|e| OpenError::Migration {
377                    step: step_no,
378                    reason: format!(
379                        "step {step_no} ({}): race-swallow stamp heal: {e}",
380                        step.name
381                    ),
382                })?;
383            Ok(())
384        } else {
385            let step_no = step.version as usize;
386            Err(OpenError::Migration {
387                step: step_no,
388                reason: format!("step {step_no} ({}): {failure}", step.name),
389            })
390        }
391    }
392
393    /// The step's presence predicate: truthy single-integer row iff
394    /// the step's complete effect is present. A query failure is loud
395    /// (unverifiable state must not be swallowed).
396    fn step_effect_present(conn: &rusqlite::Connection, step: &Step) -> Result<bool, OpenError> {
397        let step_no = step.version as usize;
398        match conn.query_row(step.verify_sql, [], |r| r.get::<_, i64>(0)) {
399            Ok(truthy) => Ok(truthy > 0),
400            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
401            Err(e) => Err(OpenError::Migration {
402                step: step_no,
403                reason: format!("step {step_no} ({}): verification query: {e}", step.name),
404            }),
405        }
406    }
407
408    fn read_stamp_sql() -> String {
409        format!("SELECT stamp FROM {STAMP_TABLE} WHERE id = 1")
410    }
411
412    fn write_stamp_sql() -> String {
413        format!("UPDATE {STAMP_TABLE} SET stamp = ?1 WHERE id = 1")
414    }
415
416    fn mark_stamp_sql() -> String {
417        format!("UPDATE {STAMP_TABLE} SET stamp = ?1 WHERE id = 1 AND stamp < ?1")
418    }
419
420    pub(crate) fn read_stamp(conn: &rusqlite::Connection) -> Result<i64, OpenError> {
421        conn.query_row(read_stamp_sql().as_str(), [], |r| r.get(0))
422            .map_err(|e| OpenError::Migration {
423                step: 0,
424                reason: format!("stamp read ({STAMP_TABLE}): {e}"),
425            })
426    }
427
428    #[cfg(test)]
429    pub(crate) fn presence_sql(table: &str) -> &'static str {
430        Box::leak(
431            format!(
432                "SELECT count(*) = 1 FROM sqlite_master \
433                 WHERE type = 'table' AND name = '{table}'"
434            )
435            .into_boxed_str(),
436        )
437    }
438
439    #[cfg(test)]
440    pub(crate) fn step_probe(table: &str, name: &'static str, version: i64) -> Step {
441        Step {
442            version,
443            name,
444            sql: Box::leak(format!("CREATE TABLE {table} (x TEXT, y TEXT)").into_boxed_str()),
445            verify_sql: presence_sql(table),
446        }
447    }
448
449    #[cfg(test)]
450    pub(crate) fn failing_step_probe(table: &str, name: &'static str, version: i64) -> Step {
451        Step {
452            version,
453            name,
454            sql: Box::leak(
455                format!(
456                    "CREATE TABLE {table} (x TEXT, y TEXT); CREATE TABLE {table} (x TEXT, y TEXT)"
457                )
458                .into_boxed_str(),
459            ),
460            verify_sql: presence_sql(table),
461        }
462    }
463}
464
465#[cfg(feature = "sqlite")]
466pub use sqlite_twin::CoTenantSqlite;
467
468#[cfg(feature = "postgres")]
469pub(crate) mod pg_twin {
470    use std::sync::Arc;
471
472    use alkstore_postgres::quote_identifier;
473    use tokio_postgres::NoTls;
474
475    use crate::error::OpenError;
476
477    /// The co-tenant schema the pg machinery's tables live in by
478    /// default — alkgit's own PostgreSQL schema, beside the engine's
479    /// (`alkstore-postgres`' default `alkstore`) and the alkblobs kv
480    /// shapes' on the same database (the co-tenancy composition posture,
481    /// alkblobs ADR-013 §3).
482    pub const DEFAULT_SCHEMA: &str = "alkgit";
483
484    /// The alkgit-owned stamp table — one row versioning the co-tenant
485    /// schema as a unit (ADR-024 §2), beside the co-tenant tables in the
486    /// alkgit schema.
487    pub(crate) const STAMP_TABLE: &str = "alkgit_schema_stamp";
488
489    /// The shipped pg step set's versions, in order (step order
490    /// monotone across the twins — sqlite's set carries the same
491    /// integers, ADR-024's release discipline; the parity test pins it).
492    pub(crate) const STEP_VERSIONS: &[i64] = &[1];
493
494    /// The module's schema-name boundary: non-empty names validated
495    /// conservatively (no controls, no quotes, ≤ 63 bytes, not
496    /// digit-leading) so every later SQL build composes from
497    /// [`quote_identifier`]'s escaping alone; an empty option means
498    /// [`DEFAULT_SCHEMA`].
499    fn validated_schema(schema: &str) -> Result<Arc<str>, OpenError> {
500        let schema = if schema.is_empty() {
501            DEFAULT_SCHEMA
502        } else {
503            schema
504        };
505        if schema.len() > 63
506            || schema.starts_with(|c: char| c.is_ascii_digit())
507            || schema
508                .chars()
509                .any(|c| c.is_control() || c == '"' || c == '\0')
510        {
511            return Err(OpenError::Invalid(format!(
512                "postgres co-tenant schema name is not a usable identifier: {schema:?}"
513            )));
514        }
515        Ok(Arc::from(schema))
516    }
517
518    /// The qualified `schema.table` identifier (crate-internal SQL
519    /// composition — the refs twin's legs compose through it too).
520    pub(crate) fn qualify(schema: &str, table: &str) -> String {
521        format!("{}.{}", quote_identifier(schema), quote_identifier(table))
522    }
523
524    /// The advisory-lock key over a scope's identity string (schema +
525    /// what it protects): deterministic, so concurrent openers/writers of
526    /// one schema serialize on the same key and differently-scoped
527    /// schemas never serialize on each other.
528    fn advisory_lock_key(scope: &str) -> i64 {
529        let mut hash: u64 = 0xcbf29ce484222325;
530        for byte in scope.as_bytes() {
531            hash ^= u64::from(*byte);
532            hash = hash.wrapping_mul(0x100000001b3);
533        }
534        hash as i64
535    }
536
537    /// The schema-stamp scope's key — the migration runner's
538    /// serialization (deterministically the same for every opener of
539    /// one schema, so concurrent opens serialize exactly the way the
540    /// sqlite machinery's `BEGIN IMMEDIATE` write lock serializes its
541    /// openers; differently-named co-tenant schemas — assembly tests,
542    /// sibling deployments — never serialize on each other).
543    fn migration_lock_key(schema: &str) -> i64 {
544        advisory_lock_key(&format!("{schema}\0{STAMP_TABLE}"))
545    }
546
547    /// The refs-table scope's key — the refs CAS twins hold it for the
548    /// call's whole transaction, and every registry transaction
549    /// touching `git_refs` (the HEAD seed, the removal's refs leg)
550    /// takes it first statement.
551    pub(crate) fn git_refs_lock_key(schema: &str) -> i64 {
552        advisory_lock_key(&format!("{schema}\0git_refs"))
553    }
554
555    /// The LFS rows-table scope's key — the LFS row twins take it for
556    /// each write transaction, and every transaction touching
557    /// `lfs_objects` (the removal's LFS leg with its recount) takes it
558    /// first statement.
559    pub(crate) fn lfs_objects_lock_key(schema: &str) -> i64 {
560        advisory_lock_key(&format!("{schema}\0lfs_objects"))
561    }
562
563    /// The pg co-tenant connection handle — the thin wrapper over the
564    /// tokio-postgres client that the postgres registry twin (and any
565    /// later pg co-tenant consumers) hold: it connects the alkgit-owned
566    /// connection beside the engine-owned schemas on the same database
567    /// (alkblobs ADR-013 §3's composition posture), drives its migration
568    /// run at construction, and hands out the client for the twin's SQL.
569    ///
570    /// Natively async: the driver's client is `Send + Sync` and every
571    /// surface is a straight `.await` (the engine's ADR-004/007 posture,
572    /// inherited — no `spawn_blocking` bridge here). The client is
573    /// shared under a tokio `tokio::sync::Mutex`; the guard is
574    /// short-held — one statement batch or transaction per hold — and
575    /// writers serialize further inside each mutation transaction on the
576    /// `registry_seq` row lock.
577    #[derive(Clone)]
578    pub struct CoTenantPg {
579        client: Arc<tokio::sync::Mutex<tokio_postgres::Client>>,
580        schema: Arc<str>,
581    }
582
583    impl CoTenantPg {
584        /// Open the co-tenant connection to the engine's database at
585        /// `config` (a tokio-postgres connection string) and run the
586        /// migrations — loud ([`OpenError`]): a half-migrated store
587        /// refuses service. An empty `schema` means [`DEFAULT_SCHEMA`].
588        /// The connection driver task is spawned by the handle; its
589        /// lifetime is the handle's.
590        pub async fn open(config: &str, schema: &str) -> Result<Self, OpenError> {
591            let schema = validated_schema(schema)?;
592            let cfg = config
593                .parse::<tokio_postgres::Config>()
594                .map_err(|e| OpenError::Engine(format!("co-tenant postgres config: {e}")))?;
595            let (client, conn) = cfg
596                .connect(NoTls)
597                .await
598                .map_err(|e| OpenError::Engine(format!("co-tenant postgres connect: {e}")))?;
599            tokio::spawn(async move {
600                let _ = conn.await;
601            });
602            let mut client = client;
603            migrate(&mut client, &schema).await?;
604            Ok(CoTenantPg {
605                client: Arc::new(tokio::sync::Mutex::new(client)),
606                schema,
607            })
608        }
609
610        /// The co-tenant schema name ([`DEFAULT_SCHEMA`] when opened
611        /// empty; qualified SQL composes through [`quote_identifier`]).
612        pub fn schema(&self) -> &str {
613            &self.schema
614        }
615
616        /// Hold the co-tenant client for SQL work. The guard is
617        /// short-held — one statement batch or transaction per hold —
618        /// awaited (the async model, never held across an unrelated
619        /// mutation).
620        pub async fn lock(&self) -> tokio::sync::MutexGuard<'_, tokio_postgres::Client> {
621            self.client.lock().await
622        }
623
624        /// The store's current co-tenant schema stamp — the version
625        /// support and diagnostics report (an ADR-024 consequence: a real
626        /// version to report). 0 means no step applied yet; a value
627        /// beyond the shipped step set is the safe unknown-forward
628        /// coexistence shape (ADR-024 §4).
629        pub async fn stamp(&self) -> Result<i64, OpenError> {
630            let read = format!(
631                "SELECT stamp FROM {} WHERE id = 1",
632                qualify(&self.schema, STAMP_TABLE)
633            );
634            let guard = self.client.lock().await;
635            stamp_of(&guard, &read).await
636        }
637
638        /// The shipped step count — the highest stamp this binary's step
639        /// set owns (the honest coexistence datum beside
640        /// [`CoTenantPg::stamp`]).
641        pub fn step_count() -> usize {
642            STEP_VERSIONS.len()
643        }
644    }
645
646    /// The row's integer read for the stamp (and every other single
647    /// integer the runner reads).
648    async fn stamp_of<R: tokio_postgres::ToStatement>(
649        client: &tokio_postgres::Client,
650        read: &R,
651    ) -> Result<i64, OpenError> {
652        client
653            .query_one(read, &[])
654            .await
655            .map_err(|e| OpenError::Migration {
656                step: 0,
657                reason: format!("stamp read ({STAMP_TABLE}): {e}"),
658            })
659            .and_then(|row| {
660                row.try_get::<_, i64>(0).map_err(|e| OpenError::Migration {
661                    step: 0,
662                    reason: format!("stamp read ({STAMP_TABLE}): {e}"),
663                })
664            })
665    }
666
667    /// One ordered pg migration step: its version (the stamp value it
668    /// marks), its monomorphic SQL applied inside one transaction, plus
669    /// its presence-predicate table set — the race-verification re-key
670    /// (the alkstore ADR-012 §5 pattern): on a step failure, the set's
671    /// presence decides swallow-vs-propagate.
672    pub(crate) struct PgStep {
673        pub(crate) version: i64,
674        name: &'static str,
675        sql: String,
676        tables: &'static [&'static str],
677    }
678
679    /// The pg step set for the schema. Step 1 is the initial co-tenant
680    /// schema — the engine-neutral logical DDL of the module doc, in
681    /// pg SQL (BIGINT sequence columns, CHECK'd visibility text).
682    /// Step order monotone with the sqlite set ([`STEP_VERSIONS`]);
683    /// post-publish additions append — additive-first (ADR-024 §4).
684    pub(crate) fn build_steps(schema: &str) -> Vec<PgStep> {
685        let q = |table: &str| qualify(schema, table);
686        vec![PgStep {
687            version: 1,
688            name:
689                "initial co-tenant schema (records, refs, seq, mutation markers, LFS liveness rows)",
690            sql: format!(
691                "CREATE TABLE {} (
692                     repo_id        TEXT PRIMARY KEY,
693                     visibility     TEXT NOT NULL CHECK (visibility IN ('public', 'private')),
694                     grants         TEXT NOT NULL,
695                     seq            BIGINT NOT NULL
696                 );
697                 CREATE TABLE {} (
698                     repo_id        TEXT NOT NULL,
699                     name           TEXT NOT NULL,
700                     oid_hex        TEXT,
701                     symref_target  TEXT,
702                     PRIMARY KEY (repo_id, name)
703                 );
704                 CREATE TABLE {} (
705                     id    INTEGER PRIMARY KEY CHECK (id = 1),
706                     value BIGINT NOT NULL
707                 );
708                 INSERT INTO {} (id, value) VALUES (1, 0);
709                 CREATE TABLE {} (
710                     repo_id  TEXT PRIMARY KEY,
711                     seq      BIGINT NOT NULL
712                 );
713                 CREATE TABLE {} (
714                     repo_id   TEXT NOT NULL,
715                     oid       TEXT NOT NULL,
716                     size      BIGINT NOT NULL,
717                     refcount  BIGINT NOT NULL,
718                     PRIMARY KEY (repo_id, oid)
719                 );",
720                q("git_repos"),
721                q("git_refs"),
722                q("registry_seq"),
723                q("registry_seq"),
724                q("repo_mutation_markers"),
725                q("lfs_objects"),
726            ),
727            tables: &[
728                "git_repos",
729                "git_refs",
730                "registry_seq",
731                "repo_mutation_markers",
732                "lfs_objects",
733            ],
734        }]
735    }
736
737    /// The stamp table's idempotent bootstrap: create the schema and the
738    /// stamp table if absent, seed the single stamp row at 0 (absent
739    /// stamp ⇔ fresh store). One transaction under the schema's advisory
740    /// migration lock ([`migration_lock_key`]). On failure the re-key
741    /// verifies state against committed data: the effect present ⇒ a
742    /// benign race swallowed; absent ⇒ propagate loud. The advisory
743    /// lock releases at the transaction's end.
744    async fn bootstrap_stamp_table(
745        client: &mut tokio_postgres::Client,
746        schema: &str,
747    ) -> Result<(), OpenError> {
748        let stamp_table = qualify(schema, STAMP_TABLE);
749        let bootstrap = format!(
750            "CREATE SCHEMA IF NOT EXISTS {};
751             CREATE TABLE IF NOT EXISTS {} (
752                 id    INTEGER PRIMARY KEY CHECK (id = 1),
753                 stamp BIGINT NOT NULL
754             );
755             INSERT INTO {} (id, stamp) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;",
756            quote_identifier(schema),
757            stamp_table,
758            stamp_table,
759        );
760        let ctx = || format!("stamp-table bootstrap ({STAMP_TABLE})");
761        let tx = client
762            .transaction()
763            .await
764            .map_err(|e| OpenError::Migration {
765                step: 0,
766                reason: format!("{}: BEGIN: {e}", ctx()),
767            })?;
768        tx.execute(
769            "SELECT pg_advisory_xact_lock($1)",
770            &[&migration_lock_key(schema)],
771        )
772        .await
773        .map_err(|e| OpenError::Migration {
774            step: 0,
775            reason: format!("{}: {e}", ctx()),
776        })?;
777        match tx.batch_execute(&bootstrap).await {
778            Ok(()) => tx.commit().await.map_err(|e| OpenError::Migration {
779                step: 0,
780                reason: format!("{}: commit: {e}", ctx()),
781            }),
782            Err(e) => {
783                drop(tx);
784                if stamp_table_present(client, schema).await? {
785                    return Ok(());
786                }
787                Err(OpenError::Migration {
788                    step: 0,
789                    reason: format!("{}: {e}", ctx()),
790                })
791            }
792        }
793    }
794
795    /// The stamp table's presence — the bootstrap's re-key.
796    async fn stamp_table_present(
797        client: &tokio_postgres::Client,
798        schema: &str,
799    ) -> Result<bool, OpenError> {
800        client
801            .query_one(
802                "SELECT to_regclass($1) IS NOT NULL",
803                &[&format!("{schema}.{STAMP_TABLE}")],
804            )
805            .await
806            .map(|row| row.get::<_, bool>(0))
807            .map_err(|e| OpenError::Migration {
808                step: 0,
809                reason: format!("stamp-table presence query ({STAMP_TABLE}): {e}"),
810            })
811    }
812
813    /// The step's presence predicate: truthy exactly when the step's
814    /// complete table set is present in the schema's catalog. A query
815    /// failure is loud (unverifiable state must not be swallowed).
816    async fn step_effect_present(
817        client: &tokio_postgres::Client,
818        schema: &str,
819        step: &PgStep,
820    ) -> Result<bool, OpenError> {
821        client
822            .query_one(
823                "SELECT (SELECT count(*) FROM pg_class c \
824                 JOIN pg_namespace n ON n.oid = c.relnamespace \
825                 WHERE n.nspname = $1 AND c.relname = ANY($2)) = $3",
826                &[&schema, &step.tables, &(step.tables.len() as i64)],
827            )
828            .await
829            .map(|row| row.get::<_, bool>(0))
830            .map_err(|e| OpenError::Migration {
831                step: step.version as usize,
832                reason: format!(
833                    "step {} ({}): verification query: {e}",
834                    step.version, step.name
835                ),
836            })
837    }
838
839    /// The construction-time migration run: the stamp-table bootstrap
840    /// plus every pending step, loud on unverified failure. Returns the
841    /// post-run stamp.
842    async fn migrate(client: &mut tokio_postgres::Client, schema: &str) -> Result<i64, OpenError> {
843        bootstrap_stamp_table(client, schema).await?;
844        for step in build_steps(schema) {
845            apply_step(client, schema, &step).await?;
846        }
847        let read = format!(
848            "SELECT stamp FROM {} WHERE id = 1",
849            qualify(schema, STAMP_TABLE)
850        );
851        client
852            .query_one(&read, &[])
853            .await
854            .map(|row| row.get::<_, i64>(0))
855            .map_err(|e| OpenError::Migration {
856                step: 0,
857                reason: format!("stamp read ({STAMP_TABLE}) in {schema}: {e}"),
858            })
859    }
860
861    /// Apply one step if pending. The stamp is read inside the step's
862    /// own transaction, under the schema's advisory migration lock —
863    /// concurrent openers serialize; a loser sees the winner's committed
864    /// stamp and skips without ever executing duplicate DDL. A step whose
865    /// SQL nevertheless fails verifies state against committed data (the
866    /// re-key): the effect present ⇒ benign race swallowed (the stamp
867    /// healed when the concurrent applier's own advance has not landed);
868    /// absent ⇒ propagate loud.
869    async fn apply_step(
870        client: &mut tokio_postgres::Client,
871        schema: &str,
872        step: &PgStep,
873    ) -> Result<(), OpenError> {
874        let step_no = step.version as usize;
875        let stamp_read = format!(
876            "SELECT stamp FROM {} WHERE id = 1",
877            qualify(schema, STAMP_TABLE)
878        );
879        let stamp_advance = format!(
880            "UPDATE {} SET stamp = $1 WHERE id = 1",
881            qualify(schema, STAMP_TABLE)
882        );
883        let stamp_heal = format!(
884            "UPDATE {} SET stamp = $1 WHERE id = 1 AND stamp < $1",
885            qualify(schema, STAMP_TABLE)
886        );
887        let step_ctx = || format!("step {step_no} ({})", step.name);
888        let tx = client
889            .transaction()
890            .await
891            .map_err(|e| OpenError::Migration {
892                step: step_no,
893                reason: format!("{}: BEGIN: {e}", step_ctx()),
894            })?;
895        tx.execute(
896            "SELECT pg_advisory_xact_lock($1)",
897            &[&migration_lock_key(schema)],
898        )
899        .await
900        .map_err(|e| OpenError::Migration {
901            step: step_no,
902            reason: format!("{}: {e}", step_ctx()),
903        })?;
904        let stamp_now: i64 = tx
905            .query_one(&stamp_read, &[])
906            .await
907            .map_err(|e| OpenError::Migration {
908                step: step_no,
909                reason: format!("{}: stamp read: {e}", step_ctx()),
910            })?
911            .try_get(0)
912            .map_err(|e| OpenError::Migration {
913                step: step_no,
914                reason: format!("{}: stamp read: {e}", step_ctx()),
915            })?;
916        if stamp_now >= step.version {
917            tx.commit().await.ok();
918            return Ok(());
919        }
920        let outcome = tx.batch_execute(&step.sql).await;
921        let outcome = match outcome {
922            Ok(()) => tx.execute(&stamp_advance, &[&step.version]).await,
923            Err(e) => Err(e),
924        };
925        match outcome {
926            Ok(_) => tx.commit().await.map_err(|e| OpenError::Migration {
927                step: step_no,
928                reason: format!("{}: commit: {e}", step_ctx()),
929            }),
930            Err(e) => {
931                drop(tx);
932                if step_effect_present(client, schema, step).await? {
933                    client
934                        .execute(&stamp_heal, &[&step.version])
935                        .await
936                        .map_err(|e| OpenError::Migration {
937                            step: step_no,
938                            reason: format!("{}: race-swallow stamp heal: {e}", step_ctx()),
939                        })?;
940                    return Ok(());
941                }
942                Err(OpenError::Migration {
943                    step: step_no,
944                    reason: format!("{}: {e}", step_ctx()),
945                })
946            }
947        }
948    }
949}
950
951#[cfg(feature = "postgres")]
952pub use pg_twin::{CoTenantPg, DEFAULT_SCHEMA};
953
954#[cfg(feature = "postgres")]
955pub(crate) use pg_twin::{git_refs_lock_key, lfs_objects_lock_key, qualify};
956
957#[cfg(all(test, feature = "sqlite"))]
958mod tests {
959    use super::sqlite_twin::{
960        failing_step_probe, read_stamp, run_steps, step_probe, BUSY_TIMEOUT_MS,
961        STAMP_TABLE_BOOTSTRAP,
962    };
963    use super::*;
964    use crate::registry::{InMemoryRegistry, RegistryRead, RegistryWrite};
965    use crate::testutil::{mem_registry_pair, unique_db_path};
966
967    fn columns_of(conn: &rusqlite::Connection, table: &str) -> Vec<String> {
968        let mut stmt = conn
969            .prepare(&format!("PRAGMA table_info({table})"))
970            .unwrap();
971        stmt.query_map([], |r| r.get::<_, String>(1))
972            .unwrap()
973            .map(|c| c.unwrap())
974            .collect()
975    }
976
977    fn table_exists(conn: &rusqlite::Connection, table: &str) -> bool {
978        conn.query_row(
979            "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
980            [table],
981            |r| r.get::<_, i64>(0),
982        )
983        .unwrap()
984            > 0
985    }
986
987    #[test]
988    fn fresh_create_stamps_one_and_builds_the_full_co_tenant_schema() {
989        let path = unique_db_path("migrations", "fresh-create");
990        let handle = CoTenantSqlite::open(&path).unwrap();
991        assert_eq!(handle.stamp().unwrap(), 1);
992        assert_eq!(CoTenantSqlite::step_count(), 1);
993
994        let db = handle.lock();
995        assert_eq!(
996            db.query_row("PRAGMA busy_timeout", [], |r| r.get::<_, i64>(0))
997                .unwrap(),
998            BUSY_TIMEOUT_MS,
999            "the pinned busy timeout rides the co-tenant connection"
1000        );
1001        assert_eq!(
1002            db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
1003                .unwrap(),
1004            "wal"
1005        );
1006        assert_eq!(
1007            db.query_row("SELECT value FROM registry_seq WHERE id = 1", [], |r| r
1008                .get::<_, i64>(0))
1009                .unwrap(),
1010            0,
1011            "the watermark singleton seeds at 0 on a fresh store"
1012        );
1013        for table in [
1014            "git_repos",
1015            "git_refs",
1016            "registry_seq",
1017            "repo_mutation_markers",
1018            "lfs_objects",
1019        ] {
1020            assert!(table_exists(&db, table));
1021        }
1022        assert_eq!(
1023            columns_of(&db, "git_repos"),
1024            vec![
1025                "repo_id".to_string(),
1026                "visibility".to_string(),
1027                "grants".to_string(),
1028                "seq".to_string()
1029            ],
1030            "the record rows carry the RepoRecord serde shape's members plus the mutation seq"
1031        );
1032        assert_eq!(
1033            columns_of(&db, "git_refs"),
1034            vec![
1035                "repo_id".to_string(),
1036                "name".to_string(),
1037                "oid_hex".to_string(),
1038                "symref_target".to_string()
1039            ],
1040            "HEAD lands as the symref row shape: oid_hex NULL + symref_target set"
1041        );
1042        assert_eq!(
1043            columns_of(&db, "lfs_objects"),
1044            vec![
1045                "repo_id".to_string(),
1046                "oid".to_string(),
1047                "size".to_string(),
1048                "refcount".to_string()
1049            ],
1050            "the LFS liveness rows carry the day-one refcount column (ADR-024 §5)"
1051        );
1052        assert_eq!(
1053            columns_of(&db, "repo_mutation_markers"),
1054            vec!["repo_id".to_string(), "seq".to_string()],
1055        );
1056        drop(db);
1057
1058        let reopened = rusqlite::Connection::open(&path).expect("second opener on one file");
1059        assert_eq!(read_stamp(&reopened).unwrap(), 1, "the stamp is durable");
1060        assert!(
1061            table_exists(&reopened, "git_repos"),
1062            "the schema is durable"
1063        );
1064        drop(reopened);
1065        let _ = std::fs::remove_file(&path);
1066    }
1067
1068    #[test]
1069    fn fresh_create_pins_the_visibility_domain() {
1070        let path = unique_db_path("migrations", "visibility-check");
1071        let handle = CoTenantSqlite::open(&path).unwrap();
1072        let db = handle.lock();
1073        assert!(
1074            db.execute(
1075                "INSERT INTO git_repos (repo_id, visibility, grants, seq) \
1076                 VALUES ('r', 'weird', '{}', 1)",
1077                []
1078            )
1079            .is_err(),
1080            "the visibility CHECK pins the record domain at the table"
1081        );
1082        drop(db);
1083        let _ = std::fs::remove_file(&path);
1084    }
1085
1086    #[test]
1087    fn additive_append_applies_step_two_then_is_idempotent() {
1088        let mut db = rusqlite::Connection::open_in_memory().unwrap();
1089        let steps = vec![
1090            step_probe("m_probe_one", "probe one", 1),
1091            step_probe("m_probe_two", "probe two — additive append", 2),
1092        ];
1093        let stamp = run_steps(&mut db, &steps).unwrap();
1094        assert_eq!(stamp, 2, "both steps applied and stamped");
1095        assert!(table_exists(&db, "m_probe_one"));
1096        assert!(table_exists(&db, "m_probe_two"));
1097
1098        let again = run_steps(&mut db, &steps).unwrap();
1099        assert_eq!(again, 2, "a re-run is a no-op — steps skip on the stamp");
1100        assert_eq!(
1101            db.query_row(
1102                "SELECT count(*) FROM alkgit_schema_stamp WHERE id = 1",
1103                [],
1104                |r| r.get::<_, i64>(0)
1105            )
1106            .unwrap(),
1107            1,
1108            "one stamp row, always"
1109        );
1110
1111        let appended = vec![step_probe(
1112            "m_probe_three",
1113            "probe three — appended later",
1114            3,
1115        )];
1116        let stamp = run_steps(&mut db, &appended).unwrap();
1117        assert_eq!(stamp, 3, "an additive append advances the stamp one step");
1118        assert!(table_exists(&db, "m_probe_three"));
1119    }
1120
1121    #[test]
1122    fn concurrent_open_race_on_one_file_swallows_the_benign_race() {
1123        let path = unique_db_path("migrations", "concurrent-open");
1124        let stamps = std::thread::scope(|scope| {
1125            let mut joins = Vec::new();
1126            for _ in 0..4 {
1127                joins.push(scope.spawn(|| {
1128                    CoTenantSqlite::open(&path)
1129                        .map(|handle| handle.stamp().expect("stamped handle reads"))
1130                }));
1131            }
1132            joins
1133                .into_iter()
1134                .map(|join| join.join().expect("no opener thread panics"))
1135                .collect::<Vec<_>>()
1136        });
1137        for stamp in &stamps {
1138            assert_eq!(
1139                stamp.as_ref().expect("every opener succeeds"),
1140                &1,
1141                "the benign race is swallowed — all openers converge on the stamped schema"
1142            );
1143        }
1144        let _ = std::fs::remove_file(&path);
1145    }
1146
1147    #[test]
1148    fn a_pre_applied_effect_swallows_and_heals_the_stamp() {
1149        let mut db = rusqlite::Connection::open_in_memory().unwrap();
1150        db.execute_batch(STAMP_TABLE_BOOTSTRAP).unwrap();
1151        db.execute("CREATE TABLE m_probe_one (x TEXT, y TEXT)", [])
1152            .unwrap();
1153        assert_eq!(
1154            read_stamp(&db).unwrap(),
1155            0,
1156            "the concurrent applier's effect is present, its stamp write not landed"
1157        );
1158
1159        let steps = vec![step_probe("m_probe_one", "probe one", 1)];
1160        run_steps(&mut db, &steps).unwrap();
1161        assert_eq!(
1162            read_stamp(&db).unwrap(),
1163            1,
1164            "the race swallowed on the presence re-key and healed the stamp"
1165        );
1166        assert!(table_exists(&db, "m_probe_one"));
1167
1168        let again = run_steps(&mut db, &steps).unwrap();
1169        assert_eq!(again, 1, "the healed store is idempotent on reopen");
1170    }
1171
1172    #[test]
1173    fn a_failing_step_fails_loudly_and_propagates() {
1174        let path = unique_db_path("migrations", "loud-failure");
1175        let mut db = rusqlite::Connection::open(&path).unwrap();
1176        let steps = vec![
1177            step_probe("m_probe_good", "probe good", 1),
1178            failing_step_probe("m_probe_bad", "probe bad — fails inside its transaction", 2),
1179            step_probe("m_probe_after", "probe after — never reached", 3),
1180        ];
1181        let err = run_steps(&mut db, &steps).unwrap_err();
1182        assert!(matches!(err, OpenError::Migration { step: 2, .. }));
1183        match &err {
1184            OpenError::Migration { reason, .. } => {
1185                assert!(reason.contains("already exists"), "{reason}");
1186            }
1187            other => panic!("unexpected variant: {other:?}"),
1188        }
1189        assert_eq!(
1190            read_stamp(&db).unwrap(),
1191            1,
1192            "step 1 committed, step 2 did not"
1193        );
1194        assert!(table_exists(&db, "m_probe_good"));
1195        assert!(
1196            !table_exists(&db, "m_probe_bad"),
1197            "the failed tx rolled back whole"
1198        );
1199        assert!(!table_exists(&db, "m_probe_after"), "later steps never ran");
1200        drop(db);
1201
1202        let mut retry = rusqlite::Connection::open(&path).unwrap();
1203        let retry_err = run_steps(&mut retry, &steps).expect_err("the run fails again");
1204        assert!(
1205            matches!(retry_err, OpenError::Migration { step: 2, .. }),
1206            "the half-migrated store refuses service again — loud on every construction"
1207        );
1208        drop(retry);
1209        let _ = std::fs::remove_file(&path);
1210    }
1211
1212    #[test]
1213    fn an_unknown_forward_stamp_is_safe_to_open_against() {
1214        let path = unique_db_path("migrations", "forward-stamp");
1215        let handle = CoTenantSqlite::open(&path).unwrap();
1216        {
1217            let db = handle.lock();
1218            db.execute("UPDATE alkgit_schema_stamp SET stamp = 99 WHERE id = 1", [])
1219                .unwrap();
1220        }
1221        let reopened = CoTenantSqlite::open(&path).unwrap();
1222        assert_eq!(
1223            reopened.stamp().unwrap(),
1224            99,
1225            "no refuse-gate ships in v1 — the forward stamp is left untouched"
1226        );
1227        assert!(
1228            table_exists(&reopened.lock(), "git_repos"),
1229            "the schema is intact"
1230        );
1231
1232        let mut db = rusqlite::Connection::open_in_memory().unwrap();
1233        let steps = vec![step_probe("m_probe_one", "probe one", 1)];
1234        run_steps(&mut db, &steps).unwrap();
1235        db.execute("UPDATE alkgit_schema_stamp SET stamp = 42 WHERE id = 1", [])
1236            .unwrap();
1237        let forward_steps = vec![
1238            step_probe("m_probe_two", "probe two", 2),
1239            step_probe("m_probe_three", "probe three", 3),
1240        ];
1241        let stamp = run_steps(&mut db, &forward_steps).unwrap();
1242        assert_eq!(stamp, 42, "pending steps skip on the forward stamp");
1243        assert!(
1244            !table_exists(&db, "m_probe_two"),
1245            "nothing applied ahead of the stamp"
1246        );
1247        drop(handle);
1248        let _ = std::fs::remove_file(&path);
1249    }
1250
1251    #[test]
1252    fn memory_mode_is_rejected_at_the_handle() {
1253        assert!(
1254            matches!(CoTenantSqlite::open(":memory:"), Err(OpenError::Invalid(_))),
1255            ":memory: confers no co-tenancy — the handle rejects the shape (DQ-7)"
1256        );
1257    }
1258
1259    #[tokio::test]
1260    async fn in_memory_adapter_is_stamp_free_fresh_on_every_construction() {
1261        let record = alkgit::RepoRecord {
1262            repo_id: "a".to_string(),
1263            visibility: alkgit::Visibility::Public,
1264            grants: Default::default(),
1265        };
1266
1267        let first = InMemoryRegistry::new();
1268        assert_eq!(first.put(&record).await.unwrap(), 1);
1269
1270        let second = InMemoryRegistry::new();
1271        assert_eq!(
1272            second.resolve("a"),
1273            None,
1274            "a fresh construction builds the current state empty — nothing durable is versioned"
1275        );
1276        assert_eq!(second.watermark().await.unwrap(), 0);
1277
1278        let (_, store_one) = mem_registry_pair();
1279        let (_, store_two) = mem_registry_pair();
1280        store_one.put_repo(&record).await.unwrap();
1281        assert_eq!(
1282            store_two.resolve("a").await.unwrap_err(),
1283            alkgit::RegistryError::NotFound,
1284            "two in-memory constructions are independent stores, not two views of one"
1285        );
1286    }
1287}