Skip to main content

alkgit_substrate/
sqlite.rs

1//! The sqlite registry twin — the family adapter pattern's default
2//! engine assembly (ADR-019 §3, substrate.md §registry: "one trait
3//! pair, two implements — the sqlite adapter (`sqlite` feature,
4//! `alkstore-sqlite` + the co-tenant rusqlite connection)").
5//!
6//! - [`SqliteRegistry`] — the durable write side ([`RegistryWrite`])
7//!   over the co-tenant `git_repos` rows. Every mutation bumps the
8//!   durable `registry_seq` singleton **inside the row-write
9//!   transaction** (transactionally exact — the watermark's anchor,
10//!   ADR-019 §5; failed mutations consume none, the tx rolls back
11//!   whole), and the twins' row commits also carry the refs-table legs
12//!   the composition owes: **put_repo seeds `HEAD → refs/heads/main`**
13//!   (substrate.md §refs — git parity: `git init --bare` creates the
14//!   dangling HEAD at init, not at first push; git's push grammar
15//!   carries no HEAD. The dangling symref is exactly the unborn-HEAD
16//!   shape `ls-refs=unborn` serves — the OQ-19 rider (a) fixture — and
17//!   is CAS-unreachable from a push per git parity; the row is
18//!   CAS-rewriteable, so the v1-pinned target is a two-way door, the
19//!   constructor knob additive later) and **remove_repo removes the
20//!   registry row, the repo's ref rows, and the repo's mutation-marker
21//!   rows together** (backend.md — deletion is a GC consequence; objects
22//!   stay for the sweep, no synchronous object deletion exists — the
23//!   removal only touches co-tenant rows). Plus
24//!   `SqliteRegistry::record_mutation` — the per-push mutation-marker
25//!   write (ADR-019 §7, composition-internal: bumps the
26//!   seq/watermark legs without touching the record; the receive-pack
27//!   composition is the consumer).
28//! - [`SqliteRegistryAssembly`] — the adapter constructor: one
29//!   fallible assembly call (loud failure — ADR-024 §1) that opens the
30//!   co-tenant connection ([`CoTenantSqlite::open`] — WAL, the pinned
31//!   `busy_timeout` 5000 ms, the migrations run at construction) beside
32//!   the engine on one file, opens the `alkstore-sqlite` engine, loads
33//!   the cached read provider's initial snapshot, wires the reactive
34//!   halves ([`Reactor`]) plus the announced write side
35//!   ([`AnnouncingRegistry`]) and the refs twin, and hands back the
36//!   pieces. The wave-8 single composition entry unifies construction;
37//!   this is the per-piece constructor (the recorded interpretation).
38//!   Post-commit announce (publish + notify + enqueue — each
39//!   independent, fail-soft) is wired by default: mutations ride
40//!   [`AnnouncingRegistry`], never a non-reactive shipped path
41//!   (ADR-019 §5).
42//!
43//! # The sqlite asymmetry carried (substrate.md §the reactive halves)
44//!
45//! The co-tenant file is itself the invalidation bus: the watcher's
46//! file-wide `data_version` bump fans a wake to listeners on *any*
47//! commit to the file — including alkgit's own registry writes on
48//! their own connection, including cross-process (~3.2 ms measured,
49//! POC-R; the probe is test-pinned here). A lost hint cannot exist
50//! while any write lands; the reconcile sweep is the correctness
51//! backstop, not a latency bound. The post-commit notify leg stays
52//! redundant but kept — for semantics and for postgres.
53//!
54//! Concurrency model: rusqlite is blocking — the trait methods bridge
55//! their SQL off the async executor (`spawn_blocking`) under the
56//! co-tenant handle's short-held lock, exactly the refs twin's shape;
57//! the pinned busy timeout absorbs the cross-process write-lock waits.
58
59use std::path::Path;
60use std::sync::atomic::AtomicU64;
61use std::sync::Arc;
62
63use alkgit::{RegistryError, RepoRecord};
64use async_trait::async_trait;
65
66use crate::error::OpenError;
67use crate::migrations::CoTenantSqlite;
68use crate::reactor::{AnnouncingRegistry, Reactor, RegistryEvent};
69use crate::refs::{SqliteRefTable, VersionedRefTable};
70use crate::registry::{
71    row_to_record, validate_record, visibility_str, CachedRegistryProvider, RegistryBackend,
72    RegistryRow, RegistryWrite,
73};
74
75/// The HEAD seed target moved engine-neutral ([`crate::registry`]) —
76/// the sqlite twin re-exports it under its own name for the twin's
77/// consumers.
78pub use crate::registry::HEAD_SEED_TARGET;
79
80/// The durable write side of the family pair over the co-tenant
81/// `git_repos` rows (the pair's sqlite implement). Reads ride the
82/// cached provider in the composed assemblies — the hot path never
83/// touches this side except the refresh reads.
84///
85/// Mount over an already-migrated co-tenant handle (the refs twin's
86/// shape — the same handle the composition opens once for both twins;
87/// the migrations run at the handle's construction, loud, ADR-024 §1).
88pub struct SqliteRegistry {
89    handle: CoTenantSqlite,
90}
91
92impl SqliteRegistry {
93    /// Mount the twin over a co-tenant handle.
94    pub fn new(handle: CoTenantSqlite) -> Self {
95        SqliteRegistry { handle }
96    }
97
98    /// The marker write's transaction rides its own `BEGIN IMMEDIATE`
99    /// (`durable_record_mutation`): the marker row upserted with a
100    /// `registry_seq` bump inside one transaction — the watermark's
101    /// anchor moves on every push while the record row and its schema
102    /// stay untouched (`rows_since` never carries markers; the seq is
103    /// the reconcile watermark's currency). The record must exist (the
104    /// receive flow resolved it) — a removed repo's marker is refused
105    /// ([`RegistryError::NotFound`]). Consuming composition: the
106    /// assembly's fused mutation legs ([`crate::pins::RegistryMutations`]
107    /// — the receive flow's post-CAS legs), which announce the write
108    /// post-commit.
109    pub(crate) async fn record_mutation(&self, repo_id: &str) -> Result<i64, RegistryError> {
110        let handle = self.handle.clone();
111        let repo_id = repo_id.to_owned();
112        blocking(move || durable_record_mutation(&handle, &repo_id)).await
113    }
114}
115
116#[async_trait]
117impl RegistryWrite for SqliteRegistry {
118    async fn put(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
119        validate_record(record)?;
120        let handle = self.handle.clone();
121        let record = record.clone();
122        blocking(move || durable_put(&handle, &record)).await
123    }
124
125    async fn update(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
126        validate_record(record)?;
127        let handle = self.handle.clone();
128        let record = record.clone();
129        blocking(move || durable_update(&handle, &record)).await
130    }
131
132    async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError> {
133        let handle = self.handle.clone();
134        let repo_id = repo_id.to_owned();
135        blocking(move || durable_remove(&handle, &repo_id)).await
136    }
137
138    async fn watermark(&self) -> Result<i64, RegistryError> {
139        let handle = self.handle.clone();
140        blocking(move || durable_watermark(&handle)).await
141    }
142
143    async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError> {
144        let handle = self.handle.clone();
145        blocking(move || durable_rows_since(&handle, seq)).await
146    }
147}
148
149/// The per-mutation blocking-task bridge — the trait methods move their
150/// owned inputs into the spawn_blocking task; join failure is a
151/// substrate failure (message-only, the family posture).
152async fn blocking<R, T>(work: T) -> Result<R, RegistryError>
153where
154    T: FnOnce() -> Result<R, RegistryError> + Send + 'static,
155    R: Send + 'static,
156{
157    tokio::task::spawn_blocking(work)
158        .await
159        .map_err(|e| RegistryError::Io(format!("registry blocking task: {e}")))?
160}
161
162fn io_err(context: &str, error: rusqlite::Error) -> RegistryError {
163    RegistryError::Io(format!("{context}: {error}"))
164}
165
166/// The `put`'s transaction: the existence check first (a taken id is
167/// [`RegistryError::AlreadyExists`], no seq consumed), then the seq
168/// bump, the record row, and the HEAD seed row — commit or roll back
169/// whole.
170fn durable_put(handle: &CoTenantSqlite, record: &RepoRecord) -> Result<i64, RegistryError> {
171    let mut conn = handle.lock();
172    let tx = conn
173        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
174        .map_err(|e| io_err("registry put BEGIN IMMEDIATE", e))?;
175    let taken: i64 = tx
176        .query_row(
177            "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
178            rusqlite::params![record.repo_id],
179            |r| r.get(0),
180        )
181        .map_err(|e| io_err("registry put existence read", e))?;
182    if taken > 0 {
183        return Err(RegistryError::AlreadyExists);
184    }
185    let grants = serde_json::to_string(&record.grants).map_err(|_| RegistryError::Invalid)?;
186    let seq: i64 = tx
187        .query_row(
188            "UPDATE registry_seq SET value = value + 1 WHERE id = 1 RETURNING value",
189            [],
190            |r| r.get(0),
191        )
192        .map_err(|e| io_err("registry put seq bump", e))?;
193    tx.execute(
194        "INSERT INTO git_repos (repo_id, visibility, grants, seq) VALUES (?1, ?2, ?3, ?4)",
195        rusqlite::params![
196            record.repo_id,
197            visibility_str(record.visibility),
198            grants,
199            seq
200        ],
201    )
202    .map_err(|e| io_err("registry put record row", e))?;
203    tx.execute(
204        "INSERT INTO git_refs (repo_id, name, oid_hex, symref_target) \
205         VALUES (?1, 'HEAD', NULL, ?2)",
206        rusqlite::params![record.repo_id, HEAD_SEED_TARGET],
207    )
208    .map_err(|e| io_err("registry put HEAD seed", e))?;
209    tx.commit().map_err(|e| io_err("registry put commit", e))?;
210    Ok(seq)
211}
212
213/// The `update`'s transaction: existence first
214/// ([`RegistryError::NotFound`]), the seq bump, the record row's
215/// replace. The refs-table rows are untouched (HEAD belongs to the
216/// record's creation).
217fn durable_update(handle: &CoTenantSqlite, record: &RepoRecord) -> Result<i64, RegistryError> {
218    let mut conn = handle.lock();
219    let tx = conn
220        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
221        .map_err(|e| io_err("registry update BEGIN IMMEDIATE", e))?;
222    let exists: i64 = tx
223        .query_row(
224            "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
225            rusqlite::params![record.repo_id],
226            |r| r.get(0),
227        )
228        .map_err(|e| io_err("registry update existence read", e))?;
229    if exists == 0 {
230        return Err(RegistryError::NotFound);
231    }
232    let grants = serde_json::to_string(&record.grants).map_err(|_| RegistryError::Invalid)?;
233    let seq: i64 = tx
234        .query_row(
235            "UPDATE registry_seq SET value = value + 1 WHERE id = 1 RETURNING value",
236            [],
237            |r| r.get(0),
238        )
239        .map_err(|e| io_err("registry update seq bump", e))?;
240    tx.execute(
241        "UPDATE git_repos SET visibility = ?2, grants = ?3, seq = ?4 WHERE repo_id = ?1",
242        rusqlite::params![
243            record.repo_id,
244            visibility_str(record.visibility),
245            grants,
246            seq
247        ],
248    )
249    .map_err(|e| io_err("registry update record row", e))?;
250    tx.commit()
251        .map_err(|e| io_err("registry update commit", e))?;
252    Ok(seq)
253}
254
255/// The `remove`'s transaction: existence first
256/// ([`RegistryError::NotFound`]), the seq bump, then the record row,
257/// the repo's ref rows (the HEAD seed included), the repo's
258/// mutation-marker rows, and the repo's LFS liveness rows — the whole
259/// repo's co-tenant state in one removal. The `lfs_objects` leg is
260/// unconditional (the table ships unconditionally in the step set;
261/// the rows only exist when the `lfs` surface wrote them, and a mixed
262/// deployment that turned the feature off must not leak), followed by
263/// the shared-oids' refcount recompute (`substrate/src/lfs.rs`'s
264/// semantics — every surviving row of an oid reads the same count at
265/// commit). Objects and packs are not touched (deletion is a GC
266/// consequence).
267fn durable_remove(handle: &CoTenantSqlite, repo_id: &str) -> Result<i64, RegistryError> {
268    let mut conn = handle.lock();
269    let tx = conn
270        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
271        .map_err(|e| io_err("registry remove BEGIN IMMEDIATE", e))?;
272    let exists: i64 = tx
273        .query_row(
274            "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
275            rusqlite::params![repo_id],
276            |r| r.get(0),
277        )
278        .map_err(|e| io_err("registry remove existence read", e))?;
279    if exists == 0 {
280        return Err(RegistryError::NotFound);
281    }
282    let seq: i64 = tx
283        .query_row(
284            "UPDATE registry_seq SET value = value + 1 WHERE id = 1 RETURNING value",
285            [],
286            |r| r.get(0),
287        )
288        .map_err(|e| io_err("registry remove seq bump", e))?;
289    tx.execute(
290        "DELETE FROM git_repos WHERE repo_id = ?1",
291        rusqlite::params![repo_id],
292    )
293    .map_err(|e| io_err("registry remove record row", e))?;
294    tx.execute(
295        "DELETE FROM git_refs WHERE repo_id = ?1",
296        rusqlite::params![repo_id],
297    )
298    .map_err(|e| io_err("registry remove ref rows", e))?;
299    tx.execute(
300        "DELETE FROM repo_mutation_markers WHERE repo_id = ?1",
301        rusqlite::params![repo_id],
302    )
303    .map_err(|e| io_err("registry remove marker rows", e))?;
304    tx.execute(
305        "DELETE FROM lfs_objects WHERE repo_id = ?1",
306        rusqlite::params![repo_id],
307    )
308    .map_err(|e| io_err("registry remove lfs rows", e))?;
309    tx.execute(
310        "UPDATE lfs_objects \
311         SET refcount = (SELECT count(*) FROM lfs_objects AS c WHERE c.oid = lfs_objects.oid)",
312        [],
313    )
314    .map_err(|e| io_err("registry remove lfs recount", e))?;
315    tx.commit()
316        .map_err(|e| io_err("registry remove commit", e))?;
317    Ok(seq)
318}
319
320fn durable_watermark(handle: &CoTenantSqlite) -> Result<i64, RegistryError> {
321    let conn = handle.lock();
322    conn.query_row("SELECT value FROM registry_seq WHERE id = 1", [], |r| {
323        r.get(0)
324    })
325    .map_err(|e| io_err("registry watermark read", e))
326}
327
328fn durable_rows_since(
329    handle: &CoTenantSqlite,
330    seq: i64,
331) -> Result<Vec<RegistryRow>, RegistryError> {
332    let conn = handle.lock();
333    let mut stmt = conn
334        .prepare(
335            "SELECT repo_id, visibility, grants, seq FROM git_repos \
336             WHERE seq > ?1 ORDER BY seq",
337        )
338        .map_err(|e| io_err("registry rows_since prepare", e))?;
339    let rows = stmt
340        .query_map(rusqlite::params![seq], |row| {
341            Ok((
342                row.get::<_, String>(0)?,
343                row.get::<_, String>(1)?,
344                row.get::<_, String>(2)?,
345                row.get::<_, i64>(3)?,
346            ))
347        })
348        .map_err(|e| io_err("registry rows_since query", e))?;
349    let mut since = Vec::new();
350    for row in rows {
351        let (repo_id, visibility, grants, row_seq) =
352            row.map_err(|e| io_err("registry rows_since row", e))?;
353        since.push(row_to_record(&repo_id, &visibility, &grants, row_seq)?);
354    }
355    Ok(since)
356}
357
358/// The marker row upsert inside its one transaction: existence first
359/// (the receive flow resolved the record), the seq bump, the marker
360/// row.
361fn durable_record_mutation(handle: &CoTenantSqlite, repo_id: &str) -> Result<i64, RegistryError> {
362    let mut conn = handle.lock();
363    let tx = conn
364        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
365        .map_err(|e| io_err("mutation marker BEGIN IMMEDIATE", e))?;
366    let exists: i64 = tx
367        .query_row(
368            "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
369            rusqlite::params![repo_id],
370            |r| r.get(0),
371        )
372        .map_err(|e| io_err("mutation marker existence read", e))?;
373    if exists == 0 {
374        return Err(RegistryError::NotFound);
375    }
376    let seq: i64 = tx
377        .query_row(
378            "UPDATE registry_seq SET value = value + 1 WHERE id = 1 RETURNING value",
379            [],
380            |r| r.get(0),
381        )
382        .map_err(|e| io_err("mutation marker seq bump", e))?;
383    tx.execute(
384        "INSERT INTO repo_mutation_markers (repo_id, seq) VALUES (?1, ?2) \
385         ON CONFLICT(repo_id) DO UPDATE SET seq = excluded.seq",
386        rusqlite::params![repo_id, seq],
387    )
388    .map_err(|e| io_err("mutation marker row", e))?;
389    tx.commit()
390        .map_err(|e| io_err("mutation marker commit", e))?;
391    Ok(seq)
392}
393
394/// The sqlite twin assembly — the pieces one adapter constructor wires
395/// over the default engine assembly (single-host shape, substrate.md
396/// §assembly: `alkstore-sqlite` + the co-tenant twin on one file):
397///
398/// - [`SqliteRegistryAssembly::store`] — the opened alkstore engine
399///   handle (the reactive halves' substrate).
400/// - [`SqliteRegistryAssembly::write`] — the **announced write side**
401///   ([`AnnouncingRegistry`]): the pair with the post-commit announce
402///   wired by default (ADR-019 §5 — no non-reactive shipped path).
403/// - [`SqliteRegistryAssembly::durable`] — the bare twin (announce
404///   free — the probe legs run here).
405/// - [`SqliteRegistryAssembly::provider`] — the cached read provider,
406///   loaded once from the durable side at construction (loud — a
407///   registry serving an empty snapshot over a populated table would
408///   contradict the definite-miss read contract).
409/// - [`SqliteRegistryAssembly::reactor`] — the wake runner/stream
410///   consumer/reconcile machinery, holding the durable side.
411/// - [`SqliteRegistryAssembly::refs`] — the refs row CAS over the same
412///   co-tenant handle, write-counter-wrapped ([`VersionedRefTable`];
413///   the seeded HEAD row is its unborn shape) — the counter
414///   [`SqliteRegistryAssembly::refs_version`] exposes is the liveness
415///   source's arbitration-window signal.
416/// - [`SqliteRegistryAssembly::backend`] — the core-traits binding
417///   ([`RegistryBackend::with_cached_provider`]) over the announced
418///   write side: the store methods announce post-commit and write
419///   through.
420/// - [`SqliteRegistryAssembly::lfs_rows`] — the LFS pointer-lifecycle
421///   row CAS over the same co-tenant handle, write-counter-wrapped
422///   ([`VersionedLfsRows`](crate::lfs::VersionedLfsRows) — the
423///   refs-twins' wrap posture; the composition entry composes it
424///   unchanged) — the counter [`SqliteRegistryAssembly::lfs_version`]
425///   exposes is the LFS liveness source's arbitration-window signal.
426///
427/// The wave-8 single composition entry unifies construction; this is
428/// the per-piece constructor (the recorded interpretation, flagged for
429/// the gate).
430pub struct SqliteRegistryAssembly {
431    store: Arc<dyn alkstore::Store>,
432    durable: Arc<SqliteRegistry>,
433    registry: Arc<dyn RegistryWrite>,
434    provider: Arc<CachedRegistryProvider>,
435    reactor: Arc<Reactor>,
436    refs: Arc<VersionedRefTable<SqliteRefTable>>,
437    refs_version: Arc<AtomicU64>,
438    backend: Arc<RegistryBackend>,
439    #[cfg(feature = "lfs")]
440    lfs: Arc<crate::lfs::VersionedLfsRows<crate::lfs::SqliteLfsRows>>,
441    #[cfg(feature = "lfs")]
442    lfs_version: Arc<AtomicU64>,
443}
444
445impl SqliteRegistryAssembly {
446    /// The adapter constructor: open the co-tenant connection at
447    /// `path` (migrations at construction, loud — a half-migrated
448    /// store refuses service; `:memory:` confers no co-tenancy and is
449    /// rejected before the engine opens), open the alkstore-sqlite
450    /// engine at the same path, load the provider's initial snapshot,
451    /// and wire the reactive composition. Every failure is
452    /// [`OpenError`].
453    pub async fn open(path: impl AsRef<Path>) -> Result<Self, OpenError> {
454        let path = path.as_ref();
455        let handle = CoTenantSqlite::open(path)?;
456        let path_str = path.to_str().ok_or_else(|| {
457            OpenError::Invalid(format!(
458                "sqlite registry path is not valid utf-8: {}",
459                path.display()
460            ))
461        })?;
462        let store: Arc<dyn alkstore::Store> = Arc::from(
463            alkstore_sqlite::open(path_str, Default::default()).map_err(|e| {
464                OpenError::Engine(format!("alkstore-sqlite open ({}): {e}", path.display()))
465            })?,
466        );
467        let durable: Arc<SqliteRegistry> = Arc::new(SqliteRegistry::new(handle));
468
469        let provider = Arc::new(CachedRegistryProvider::new());
470        let rows = durable
471            .rows_since(0)
472            .await
473            .map_err(|e| OpenError::Engine(format!("cached provider initial load: {e}")))?;
474        provider.install(rows);
475
476        let durable_side: Arc<dyn RegistryWrite> = durable.clone();
477        let reactor = Arc::new(Reactor::new(
478            store.clone(),
479            durable_side.clone(),
480            provider.clone(),
481        ));
482        let registry: Arc<dyn RegistryWrite> = Arc::new(AnnouncingRegistry::new(
483            durable_side.clone(),
484            reactor.clone(),
485        ));
486        let backend = Arc::new(RegistryBackend::with_cached_provider(
487            registry.clone(),
488            provider.clone(),
489        ));
490        let refs_version = Arc::new(AtomicU64::new(0));
491        let refs = Arc::new(VersionedRefTable::new(
492            Arc::new(SqliteRefTable::new(durable.handle.clone())),
493            refs_version.clone(),
494        ));
495        #[cfg(feature = "lfs")]
496        let lfs_version = Arc::new(AtomicU64::new(0));
497        #[cfg(feature = "lfs")]
498        let lfs = Arc::new(crate::lfs::VersionedLfsRows::new(
499            Arc::new(crate::lfs::SqliteLfsRows::new(durable.handle.clone())),
500            lfs_version.clone(),
501        ));
502        Ok(SqliteRegistryAssembly {
503            store,
504            durable,
505            registry,
506            provider,
507            reactor,
508            refs,
509            refs_version,
510            backend,
511            #[cfg(feature = "lfs")]
512            lfs,
513            #[cfg(feature = "lfs")]
514            lfs_version,
515        })
516    }
517
518    /// The opened alkstore engine handle (the reactive halves'
519    /// substrate; the wave-7 maintenance chain rides the same).
520    pub fn store(&self) -> &Arc<dyn alkstore::Store> {
521        &self.store
522    }
523
524    /// The announced write side — the pair with the post-commit
525    /// announce wired (the write surface the composition's callers
526    /// use).
527    pub fn write(&self) -> &Arc<dyn RegistryWrite> {
528        &self.registry
529    }
530
531    /// The bare twin (announce-free — the transactional legs' probe
532    /// surface).
533    pub fn durable(&self) -> &Arc<SqliteRegistry> {
534        &self.durable
535    }
536
537    /// The cached read provider (the hot read).
538    pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
539        &self.provider
540    }
541
542    /// The reactor (the wake runner / stream consumer / reconcile
543    /// sweep run through it).
544    pub fn reactor(&self) -> &Arc<Reactor> {
545        &self.reactor
546    }
547
548    /// The refs row CAS over the same co-tenant handle — the
549    /// write-counter-wrapped composition refs ([`VersionedRefTable`]):
550    /// the CAS semantics are unchanged (verbatim delegation), every
551    /// CAS call bumps the shared counter the liveness source's
552    /// arbitration consult reads (crate::liveness — the composition's
553    /// invalidation half). Pushes route through this impl.
554    pub fn refs(&self) -> &Arc<VersionedRefTable<SqliteRefTable>> {
555        &self.refs
556    }
557
558    /// The refs writes' shared counter — the same handle
559    /// [`Self::refs`] bumps and the [`crate::liveness::RefClosure`]
560    /// source consults; hand it to the closure's constructor.
561    pub fn refs_version(&self) -> &Arc<AtomicU64> {
562        &self.refs_version
563    }
564
565    /// The core-traits binding (the engine-twin binding announced and
566    /// write-through wired).
567    pub fn backend(&self) -> &Arc<RegistryBackend> {
568        &self.backend
569    }
570
571    /// The LFS pointer-lifecycle row CAS over the same co-tenant
572    /// handle — the write-counter-wrapped rows
573    /// ([`crate::lfs::VersionedLfsRows`]; the refs twins' wrap posture,
574    /// the per-piece parity posture the per-piece-path embedder
575    /// composes): the row semantics are unchanged (verbatim
576    /// delegation), every `assert` bumps the shared counter the LFS
577    /// liveness source's arbitration consult reads (`crate::liveness`
578    /// — the composition's invalidation half). The record-removal leg
579    /// takes a removed repo's rows with the record transactionally
580    /// (the twins' unconditional in-SQL copy).
581    #[cfg(feature = "lfs")]
582    pub fn lfs_rows(&self) -> &Arc<crate::lfs::VersionedLfsRows<crate::lfs::SqliteLfsRows>> {
583        &self.lfs
584    }
585
586    /// The LFS rows' asserts' shared counter — the same handle
587    /// [`Self::lfs_rows`] bumps and the
588    /// [`crate::liveness::LfsMedia`] source consults; hand it to the
589    /// media source's constructor.
590    #[cfg(feature = "lfs")]
591    pub fn lfs_version(&self) -> &Arc<AtomicU64> {
592        &self.lfs_version
593    }
594}
595
596/// The per-push mutation-marker write, announced
597/// ([`RegistryEvent::Mutation`] post-commit, fail-soft) — the receive
598/// flow's watermark leg (ADR-019 §7; the composition-internal
599/// [`RegistryMutations`](crate::pins::RegistryMutations) legs the
600/// wrapped refs implement rides — [`crate::pins::CommittedRefs`]; the
601/// record schema untouched). The durable leg carries no marker-driven
602/// record change: `rows_since` never carries markers.
603#[async_trait]
604impl crate::pins::RegistryMutations for SqliteRegistryAssembly {
605    async fn record_mutation(&self, repo_id: &str) -> Result<i64, RegistryError> {
606        let seq = self.durable.record_mutation(repo_id).await?;
607        self.reactor
608            .announce(&RegistryEvent::Mutation {
609                seq,
610                repo_id: repo_id.to_owned(),
611            })
612            .await;
613        Ok(seq)
614    }
615}
616
617#[cfg(all(test, feature = "sqlite"))]
618mod tests {
619    use std::time::Duration;
620
621    use crate::registry::RegistryRead;
622    use crate::testutil::{
623        private_record, record, spawn_wake_runner, stop_and_join, unique_db_path, wait_until,
624    };
625
626    use alkgit::{AccessAction, GitRefs, GitRegistry, GitRegistryStore, Visibility};
627    use alkstore::{QueueOpts, StopToken, Store, StreamEvent, WakeReceiver};
628    use serde_json::Value;
629
630    use super::*;
631
632    /// One co-tenant file with the twin mounted — the durable-side
633    /// probe without the reactive wiring (the assembly is over it).
634    async fn durable_twin(tag: &str) -> (CoTenantSqlite, SqliteRegistry) {
635        let path = unique_db_path("sqlite", tag);
636        let handle = CoTenantSqlite::open(&path).unwrap();
637        let twin = SqliteRegistry::new(handle.clone());
638        (handle, twin)
639    }
640
641    fn count(handle: &CoTenantSqlite, sql: &str, repo_id: &str) -> i64 {
642        handle
643            .lock()
644            .query_row(sql, rusqlite::params![repo_id], |r| r.get(0))
645            .unwrap()
646    }
647
648    #[tokio::test]
649    async fn durable_twin_carries_the_family_write_semantics() {
650        let (_, twin) = durable_twin("family").await;
651
652        let seq_put = twin.put(&record("owner/r")).await.unwrap();
653        assert_eq!(seq_put, 1, "first mutation carries seq 1");
654
655        let mut granted = record("owner/r");
656        granted.visibility = Visibility::Private;
657        granted
658            .grants
659            .insert("u-7731".to_owned(), vec![AccessAction::Write]);
660        let seq_update = twin.update(&granted).await.unwrap();
661        assert_eq!(seq_update, 2);
662        let rows = twin.rows_since(0).await.unwrap();
663        assert_eq!(rows.len(), 1);
664        assert_eq!(rows[0].seq, seq_update);
665        assert_eq!(
666            rows[0].record, granted,
667            "the record round-trips the columns"
668        );
669
670        assert_eq!(twin.watermark().await.unwrap(), 2);
671        let seq_remove = twin.remove("owner/r").await.unwrap();
672        assert_eq!(seq_remove, 3);
673        assert_eq!(twin.watermark().await.unwrap(), 3);
674        assert!(
675            twin.rows_since(0).await.unwrap().is_empty(),
676            "the committed removal leaves no row"
677        );
678    }
679
680    #[tokio::test]
681    async fn durable_twin_error_map_matches_the_family() {
682        let (_, twin) = durable_twin("errors").await;
683
684        assert_eq!(
685            twin.update(&record("ghost")).await.unwrap_err(),
686            RegistryError::NotFound
687        );
688        assert_eq!(
689            twin.remove("ghost").await.unwrap_err(),
690            RegistryError::NotFound
691        );
692        assert_eq!(
693            twin.record_mutation("ghost").await.unwrap_err(),
694            RegistryError::NotFound,
695            "a marker write is refused for a removed repo"
696        );
697
698        assert_eq!(twin.put(&record("owner/r")).await.unwrap(), 1);
699        assert_eq!(
700            twin.put(&record("owner/r")).await.unwrap_err(),
701            RegistryError::AlreadyExists
702        );
703        assert_eq!(
704            twin.watermark().await.unwrap(),
705            1,
706            "the failed duplicate put consumed no seq"
707        );
708
709        let malformed = record("Owner/name");
710        assert_eq!(
711            twin.put(&malformed).await.unwrap_err(),
712            RegistryError::Invalid
713        );
714        let mut empty_key = record("empty");
715        empty_key
716            .grants
717            .insert(String::new(), vec![AccessAction::Read]);
718        assert_eq!(
719            twin.put(&empty_key).await.unwrap_err(),
720            RegistryError::Invalid
721        );
722        assert_eq!(
723            twin.watermark().await.unwrap(),
724            1,
725            "the rejections consumed no seq"
726        );
727    }
728
729    /// The seq is transactionally exact (the watermark's anchor,
730    /// ADR-019 §5): the bump lives in the row-write transaction — a
731    /// read on a second connection right after the commit sees the
732    /// bumped seq and the row, before any announce could fire.
733    #[tokio::test]
734    async fn the_seq_bump_is_transactionally_exact() {
735        let path = unique_db_path("sqlite", "tx-seq");
736        let handle = CoTenantSqlite::open(&path).unwrap();
737        let twin = SqliteRegistry::new(handle.clone());
738        let second = CoTenantSqlite::open(&path).unwrap();
739
740        let seq = twin.put(&record("owner/tx")).await.unwrap();
741
742        {
743            let db = second.lock();
744            assert_eq!(
745                db.query_row("SELECT value FROM registry_seq WHERE id = 1", [], |r| r
746                    .get::<_, i64>(0))
747                    .unwrap(),
748                seq,
749                "the second connection sees the bumped seq — it committed with the row"
750            );
751            assert_eq!(
752                db.query_row(
753                    "SELECT seq FROM git_repos WHERE repo_id = 'owner/tx'",
754                    [],
755                    |r| r.get::<_, i64>(0)
756                )
757                .unwrap(),
758                seq,
759            );
760            assert_eq!(
761                db.query_row(
762                    "SELECT symref_target FROM git_refs WHERE repo_id = 'owner/tx' AND name = 'HEAD'",
763                    [],
764                    |r| r.get::<_, String>(0)
765                )
766                .unwrap(),
767                HEAD_SEED_TARGET,
768                "the refs-table leg rode the same transaction"
769            );
770        }
771
772        twin.update(&private_record("owner/tx")).await.unwrap();
773        twin.remove("owner/tx").await.unwrap();
774
775        {
776            let db = second.lock();
777            assert_eq!(
778                db.query_row("SELECT value FROM registry_seq WHERE id = 1", [], |r| r
779                    .get::<_, i64>(0))
780                    .unwrap(),
781                3,
782            );
783        }
784
785        let reopened = CoTenantSqlite::open(&path).unwrap();
786        let fresh = SqliteRegistry::new(reopened);
787        assert_eq!(
788            fresh.watermark().await.unwrap(),
789            3,
790            "the seq singleton is durable"
791        );
792        assert!(
793            fresh.put(&record("owner/tx")).await.unwrap() > 3,
794            "the bump continues from the durable watermark"
795        );
796        drop(fresh);
797        drop(handle);
798        drop(second);
799        let _ = std::fs::remove_file(&path);
800    }
801
802    /// put_repo seeds `HEAD → refs/heads/main` in the record
803    /// transaction (substrate.md §refs): a fresh repo lists the unborn
804    /// HEAD line via the refs twin; update leaves the row alone;
805    /// remove clears it with the record.
806    #[tokio::test]
807    async fn put_repo_seeds_the_unborn_head_line() {
808        let path = unique_db_path("sqlite", "seed-head");
809        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
810        let repo = record("owner/seeded");
811
812        assembly.durable().put(&repo).await.unwrap();
813        let listed = assembly.refs().list_refs(&repo).await.unwrap();
814        assert_eq!(
815            listed,
816            vec![alkgit::RefLine {
817                name: "HEAD".to_owned(),
818                oid: None,
819                peeled: None,
820                symref_target: Some(HEAD_SEED_TARGET.to_owned()),
821            }],
822            "the fresh repo lists the unborn HEAD line (the OQ-19 rider (a) fixture)"
823        );
824
825        assembly
826            .durable()
827            .update(&private_record("owner/seeded"))
828            .await
829            .unwrap();
830        assert_eq!(
831            assembly.refs().list_refs(&repo).await.unwrap().len(),
832            1,
833            "the update's transaction does not touch the seeded row"
834        );
835
836        assembly.durable().remove("owner/seeded").await.unwrap();
837        assert!(
838            assembly.refs().list_refs(&repo).await.unwrap().is_empty(),
839            "the removal cleared the seeded row with the record"
840        );
841    }
842
843    /// remove_repo removes the registry row + the repo's ref rows +
844    /// the repo's marker rows in the same removal (backend.md:
845    /// deletion is a GC consequence — objects stay for the sweep; the
846    /// only durable changes are the co-tenant rows).
847    #[tokio::test]
848    async fn remove_repo_drops_the_rows_the_removal_owes_together() {
849        let path = unique_db_path("sqlite", "remove");
850        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
851
852        assembly.durable().put(&record("survivor")).await.unwrap();
853        assembly.durable().put(&record("victim")).await.unwrap();
854        assembly.durable().record_mutation("victim").await.unwrap();
855        assembly
856            .refs()
857            .apply_updates(
858                &record("victim"),
859                vec![alkgit::RefUpdate {
860                    name: "refs/heads/main".to_owned(),
861                    expected: None,
862                    new: Some(gix_hash::ObjectId::from([7; 20])),
863                }],
864                false,
865            )
866            .await
867            .unwrap();
868
869        let engine_event = assembly
870            .store()
871            .stream(crate::reactor::EVENTS_STREAM)
872            .await
873            .unwrap()
874            .publish(serde_json::json!("kept — no object deletion exists"))
875            .await
876            .unwrap();
877
878        assembly.backend().remove_repo("victim").await.unwrap();
879
880        let handle = CoTenantSqlite::open(&path).unwrap();
881        assert_eq!(
882            count(
883                &handle,
884                "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
885                "victim"
886            ),
887            0
888        );
889        assert_eq!(
890            count(
891                &handle,
892                "SELECT count(*) FROM git_refs WHERE repo_id = ?1",
893                "victim"
894            ),
895            0
896        );
897        assert_eq!(
898            count(
899                &handle,
900                "SELECT count(*) FROM repo_mutation_markers WHERE repo_id = ?1",
901                "victim"
902            ),
903            0
904        );
905        assert_eq!(
906            count(
907                &handle,
908                "SELECT count(*) FROM git_repos WHERE repo_id = ?1",
909                "survivor"
910            ),
911            1
912        );
913        drop(handle);
914
915        let events = assembly
916            .store()
917            .stream(crate::reactor::EVENTS_STREAM)
918            .await
919            .unwrap()
920            .read_since(0, 100)
921            .await
922            .unwrap();
923        assert!(
924            events.iter().any(|event: &StreamEvent| {
925                event.payload_as::<Value>().is_ok_and(|payload| {
926                    payload == serde_json::json!("kept — no object deletion exists")
927                })
928            }),
929            "the engine-side data is untouched by the removal"
930        );
931        assert!(engine_event > 0);
932    }
933
934    /// The LFS liveness rows go with the removed repo — the
935    /// unconditional `lfs_objects` leg inside the record-removal
936    /// transaction (the rows only exist when the `lfs` surface wrote
937    /// them; the leg is unconditional so a mixed deployment that
938    /// turned the feature off must not leak). The rows seed directly
939    /// here (the leg's SQL is unconditional — the test holds with the
940    /// surface off too), a shared oid's surviving row recomputes, and
941    /// the victim's oids leave no row at all.
942    #[tokio::test]
943    async fn remove_repo_takes_the_lfs_rows_and_recounts_the_survivors() {
944        let path = unique_db_path("sqlite", "remove-lfs");
945        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
946        assembly.durable().put(&record("victim")).await.unwrap();
947        assembly.durable().put(&record("survivor")).await.unwrap();
948
949        let shared_oid = "01".repeat(32);
950        let victim_only_oid = "02".repeat(32);
951        {
952            let conn = assembly.durable().handle.lock();
953            conn.execute(
954                "INSERT INTO lfs_objects (repo_id, oid, size, refcount) \
955                 VALUES ('victim', ?1, 8, 2), ('survivor', ?1, 8, 2), \
956                         ('victim', ?2, 4, 1)",
957                rusqlite::params![shared_oid, victim_only_oid],
958            )
959            .unwrap();
960        }
961
962        assembly.backend().remove_repo("victim").await.unwrap();
963
964        {
965            let conn = assembly.durable().handle.lock();
966            let victim_only: i64 = conn
967                .query_row(
968                    "SELECT count(*) FROM lfs_objects WHERE oid = ?1",
969                    rusqlite::params![victim_only_oid],
970                    |r| r.get(0),
971                )
972                .unwrap();
973            assert_eq!(victim_only, 0, "the victim's own oid left no row at all");
974            let shared_count: i64 = conn
975                .query_row(
976                    "SELECT refcount FROM lfs_objects WHERE repo_id = 'survivor' AND oid = ?1",
977                    rusqlite::params![shared_oid],
978                    |r| r.get(0),
979                )
980                .unwrap();
981            assert_eq!(
982                shared_count, 1,
983                "the surviving row carries the recomputed count (the re-protection contract's data)"
984            );
985        }
986
987        #[cfg(feature = "lfs")]
988        assert_eq!(
989            crate::lfs::LfsRows::repo_rows(assembly.lfs_rows().as_ref(), "survivor")
990                .await
991                .unwrap(),
992            vec![crate::lfs::LfsRow {
993                repo_id: "survivor".to_owned(),
994                oid: shared_oid,
995                size: 8,
996                refcount: 1,
997            }],
998            "the surface reads back the same state (the composition's row store)"
999        );
1000    }
1001
1002    /// The per-push mutation-marker write: the seq/watermark legs move
1003    /// without touching the record (ADR-019 §7 — the receive flow's
1004    /// watermark leg; rows_since never carries markers).
1005    #[tokio::test]
1006    async fn record_mutation_bumps_the_watermark_without_touching_the_record() {
1007        let (handle, twin) = durable_twin("marker").await;
1008
1009        let put_seq = twin.put(&record("owner/m")).await.unwrap();
1010        assert_eq!(put_seq, 1);
1011        let seq = twin.record_mutation("owner/m").await.unwrap();
1012
1013        assert_eq!(seq, 2);
1014        assert_eq!(twin.watermark().await.unwrap(), 2);
1015        let rows = twin.rows_since(0).await.unwrap();
1016        assert_eq!(
1017            rows.iter().map(|row| row.seq).collect::<Vec<_>>(),
1018            vec![put_seq],
1019            "the marker left no record row — the record schema is untouched"
1020        );
1021        let marker: i64 = handle
1022            .lock()
1023            .query_row(
1024                "SELECT seq FROM repo_mutation_markers WHERE repo_id = 'owner/m'",
1025                [],
1026                |r| r.get(0),
1027            )
1028            .unwrap();
1029        assert_eq!(marker, seq, "the marker row carries its seq");
1030
1031        twin.remove("owner/m").await.unwrap();
1032        let left: i64 = handle
1033            .lock()
1034            .query_row(
1035                "SELECT count(*) FROM repo_mutation_markers WHERE repo_id = 'owner/m'",
1036                [],
1037                |r| r.get(0),
1038            )
1039            .unwrap();
1040        assert_eq!(left, 0, "the removal took the repo's marker rows with it");
1041    }
1042
1043    /// The announcement through the composed store binding fires
1044    /// post-commit: the Put event is keyed in the feed, the wake lands
1045    /// on a listener, the pack-housekeeping job is on the maintenance
1046    /// queue, and the resolve rides the write-through provider.
1047    #[tokio::test]
1048    async fn announced_store_binding_fires_the_composition_post_commit() {
1049        use crate::reactor::MaintenanceJob;
1050        use crate::reactor::{RegistryEvent, MAINTENANCE_QUEUE};
1051
1052        let path = unique_db_path("sqlite", "announce");
1053        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
1054        let mut listener = assembly
1055            .store()
1056            .listen(crate::reactor::WAKE_CHANNEL)
1057            .await
1058            .unwrap();
1059        let mut granted = record("owner/a");
1060        granted
1061            .grants
1062            .insert("u-1".to_owned(), vec![AccessAction::Read]);
1063
1064        assembly.backend().put_repo(&granted).await.unwrap();
1065
1066        let events = assembly
1067            .store()
1068            .stream(crate::reactor::EVENTS_STREAM)
1069            .await
1070            .unwrap()
1071            .read_since(0, 10)
1072            .await
1073            .unwrap();
1074        assert_eq!(events.len(), 1, "one announced put = one feed event");
1075        assert_eq!(events[0].key.as_deref(), Some("owner/a"));
1076        assert_eq!(
1077            crate::reactor::RegistryEvent::decode_event(&events[0]),
1078            Some(RegistryEvent::Put {
1079                seq: 1,
1080                record: granted.clone(),
1081            })
1082        );
1083        let wake = listener
1084            .recv_timeout(Duration::from_secs(2))
1085            .await
1086            .unwrap()
1087            .expect("wake source open");
1088        assert_eq!(wake.channel, crate::reactor::WAKE_CHANNEL);
1089
1090        let queue = assembly
1091            .store()
1092            .queue(MAINTENANCE_QUEUE, QueueOpts::default())
1093            .await
1094            .unwrap();
1095        let claimed = queue
1096            .claim_one("probe")
1097            .await
1098            .unwrap()
1099            .expect("the pack-housekeeping job");
1100        assert_eq!(
1101            claimed.job().payload_as::<MaintenanceJob>().unwrap(),
1102            MaintenanceJob::PackHousekeeping {
1103                repo_id: "owner/a".to_owned(),
1104            }
1105        );
1106
1107        assert_eq!(
1108            assembly.backend().resolve("owner/a").await.unwrap(),
1109            granted,
1110            "the write-through provider serves the mutation"
1111        );
1112
1113        assembly.backend().remove_repo("owner/a").await.unwrap();
1114        let events = assembly
1115            .store()
1116            .stream(crate::reactor::EVENTS_STREAM)
1117            .await
1118            .unwrap()
1119            .read_since(1, 10)
1120            .await
1121            .unwrap();
1122        assert_eq!(
1123            events.first().and_then(RegistryEvent::decode_event),
1124            Some(RegistryEvent::Removed {
1125                seq: 2,
1126                repo_id: "owner/a".to_owned(),
1127            })
1128        );
1129    }
1130
1131    /// A store double with every announce leg's entry point failing
1132    /// (the rest rides the mem engine).
1133    struct LegsDeadStore {
1134        inner: Box<dyn Store>,
1135    }
1136
1137    impl Store for LegsDeadStore {
1138        fn begin_tx(
1139            &self,
1140        ) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>>
1141        {
1142            self.inner.begin_tx()
1143        }
1144
1145        fn notify<'a>(
1146            &'a self,
1147            channel: &str,
1148            payload: Value,
1149        ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1150            self.inner.notify(channel, payload)
1151        }
1152
1153        fn listen<'a>(
1154            &'a self,
1155            channel: &str,
1156        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn WakeReceiver>>> {
1157            self.inner.listen(channel)
1158        }
1159
1160        fn stream<'a>(
1161            &'a self,
1162            _name: &str,
1163        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::StreamHandle>>> {
1164            Box::pin(async { Err(alkstore::Error::database("publish leg suppressed (probe)")) })
1165        }
1166
1167        fn queue<'a>(
1168            &'a self,
1169            _name: &str,
1170            _opts: alkstore::QueueOpts,
1171        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
1172            Box::pin(async { Err(alkstore::Error::database("enqueue leg suppressed (probe)")) })
1173        }
1174
1175        fn outbox<'a>(
1176            &'a self,
1177            name: &str,
1178        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
1179            self.inner.outbox(name)
1180        }
1181
1182        fn try_lock<'a>(
1183            &'a self,
1184            name: &str,
1185            owner: &str,
1186            ttl: i64,
1187        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
1188            self.inner.try_lock(name, owner, ttl)
1189        }
1190
1191        fn schedule<'a>(
1192            &'a self,
1193            name: &str,
1194            spec: &str,
1195            queue: &str,
1196            payload: Value,
1197            opts: alkstore::ScheduleOpts,
1198        ) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
1199            self.inner.schedule(name, spec, queue, payload, opts)
1200        }
1201
1202        fn unschedule<'a>(
1203            &'a self,
1204            name: &str,
1205        ) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
1206            self.inner.unschedule(name)
1207        }
1208
1209        fn run_schedules<'a>(
1210            &'a self,
1211            stop: StopToken,
1212        ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1213            self.inner.run_schedules(stop)
1214        }
1215    }
1216
1217    /// The announce's legs are fail-soft and the write's seq is
1218    /// transactionally exact: every leg dead still lands the durable
1219    /// commit (a read between write and announce sees the bumped seq),
1220    /// and the reconcile sweep closes the window.
1221    #[tokio::test]
1222    async fn dead_announce_legs_fail_soft_and_the_seq_stays_exact() {
1223        let path = unique_db_path("sqlite", "fail-soft");
1224        let handle = CoTenantSqlite::open(&path).unwrap();
1225        let durable: Arc<dyn RegistryWrite> = Arc::new(SqliteRegistry::new(handle));
1226        let provider = Arc::new(CachedRegistryProvider::new());
1227        let reactor = Arc::new(Reactor::new(
1228            Arc::new(LegsDeadStore {
1229                inner: Box::new(alkstore_mem::MemStore::new()),
1230            }) as Arc<dyn Store>,
1231            durable.clone(),
1232            provider.clone(),
1233        ));
1234        let announced: Arc<dyn RegistryWrite> =
1235            Arc::new(AnnouncingRegistry::new(durable.clone(), reactor.clone()));
1236        let backend = RegistryBackend::with_cached_provider(announced.clone(), provider.clone());
1237
1238        backend.put_repo(&record("owner/x")).await.unwrap();
1239        assert_eq!(
1240            durable.watermark().await.unwrap(),
1241            1,
1242            "the seq is transactionally exact — not announce-dependent"
1243        );
1244        assert_eq!(durable.rows_since(0).await.unwrap().len(), 1);
1245        assert_eq!(
1246            provider.resolve("owner/x"),
1247            Some(record("owner/x")),
1248            "the write-through install is separate machinery and still landed"
1249        );
1250
1251        let stream = alkstore_sqlite::open(path.to_str().unwrap(), Default::default()).unwrap();
1252        let events: Vec<StreamEvent> = stream
1253            .stream(crate::reactor::EVENTS_STREAM)
1254            .await
1255            .unwrap()
1256            .read_since(0, 10)
1257            .await
1258            .unwrap();
1259        assert!(
1260            events.is_empty(),
1261            "the suppressed publish leg left no event — the crash window"
1262        );
1263
1264        let applied = crate::reactor::AppliedWatermark::new();
1265        let caught = reactor.reconcile(&applied).await.unwrap();
1266        assert_eq!(caught.len(), 1, "the crash window closes in one pass");
1267        assert_eq!(provider.resolve("owner/x"), Some(record("owner/x")));
1268        drop(stream);
1269        drop(backend);
1270        drop(durable);
1271        drop(reactor);
1272        let _ = std::fs::remove_file(&path);
1273    }
1274
1275    /// The co-tenant file is itself the invalidation bus (substrate.md
1276    /// §the reactive halves, POC-R's backstop): an unannounced
1277    /// registry write on its own connection wakes a listener on a
1278    /// second engine connection, and the wake runner's refresh
1279    /// surfaces the row in the provider — milliseconds, not the
1280    /// reconcile cadence.
1281    #[tokio::test]
1282    async fn the_co_tenant_file_is_the_invalidation_bus() {
1283        let path = unique_db_path("sqlite", "backstop");
1284        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
1285        let mut listener = assembly
1286            .store()
1287            .listen(crate::reactor::WAKE_CHANNEL)
1288            .await
1289            .unwrap();
1290        let (stop, runner) = spawn_wake_runner(assembly.reactor().clone());
1291        tokio::time::sleep(Duration::from_millis(200)).await;
1292
1293        let seq = assembly
1294            .durable()
1295            .put(&private_record("owner/silent"))
1296            .await
1297            .unwrap();
1298
1299        let wake = listener
1300            .recv_timeout(Duration::from_secs(2))
1301            .await
1302            .unwrap()
1303            .expect(
1304                "the file-wide data_version bump fans the wake cross-connection — \
1305                 no notify involved",
1306            );
1307        assert_eq!(wake.channel, crate::reactor::WAKE_CHANNEL);
1308        drop(listener);
1309
1310        wait_until(|| assembly.provider().resolve("owner/silent").is_some()).await;
1311        assert_eq!(
1312            assembly.provider().resolve("owner/silent"),
1313            Some(private_record("owner/silent")),
1314            "the unannounced write surfaced through the wake-driven refresh"
1315        );
1316        assert!(assembly.provider().refresh_count() >= 1);
1317
1318        assert_eq!(
1319            assembly.durable().watermark().await.unwrap(),
1320            seq,
1321            "the watermark advanced though nothing announced"
1322        );
1323        stop_and_join(stop, runner).await;
1324    }
1325
1326    /// The assembly constructor is loud and loads the persisted
1327    /// registry: a reopened assembly resolves the stored record with
1328    /// no refresh, the seq singleton persists, and `:memory:` is
1329    /// rejected before the engine opens (DQ-7).
1330    #[tokio::test]
1331    async fn assembly_open_is_loud_and_loads_the_persisted_registry() {
1332        let path = unique_db_path("sqlite", "reopen");
1333        {
1334            let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
1335            assembly
1336                .backend()
1337                .put_repo(&private_record("owner/kept"))
1338                .await
1339                .unwrap();
1340            assert_eq!(assembly.durable().watermark().await.unwrap(), 1);
1341        }
1342
1343        let reopened = SqliteRegistryAssembly::open(&path).await.unwrap();
1344        assert_eq!(
1345            reopened.backend().resolve("owner/kept").await.unwrap(),
1346            private_record("owner/kept"),
1347            "the initial snapshot load carried the persisted table"
1348        );
1349        assert_eq!(reopened.durable().watermark().await.unwrap(), 1);
1350        assert_eq!(reopened.provider().refresh_count(), 1, "load only");
1351
1352        // the initial load is loud: a broken durable side refuses
1353        // assembly (the twin's reads fail as the provider load maps
1354        // into OpenError)
1355
1356        assert!(matches!(
1357            SqliteRegistryAssembly::open(":memory:").await,
1358            Err(OpenError::Invalid(_))
1359        ));
1360
1361        let _ = std::fs::remove_file(&path);
1362    }
1363
1364    /// The corrupt row defenses: a row the composition did not write
1365    /// (malformed grants JSON) is structurally invalid row state —
1366    /// `RegistryError::Invalid` on the catch-up read, never a poison
1367    /// row wedging the reconcile.
1368    #[tokio::test]
1369    async fn corrupt_record_rows_map_registry_invalid() {
1370        let path = unique_db_path("sqlite", "corrupt");
1371        let handle = CoTenantSqlite::open(&path).unwrap();
1372        let twin = SqliteRegistry::new(handle.clone());
1373        twin.put(&record("owner/ok")).await.unwrap();
1374        handle
1375            .lock()
1376            .execute(
1377                "INSERT INTO git_repos (repo_id, visibility, grants, seq) \
1378             VALUES ('owner/bent', 'public', 'not-json', 2)",
1379                [],
1380            )
1381            .unwrap();
1382
1383        let err = twin.rows_since(0).await.unwrap_err();
1384        assert_eq!(err, RegistryError::Invalid, "{err:?}");
1385
1386        let _ = std::fs::remove_file(&path);
1387    }
1388
1389    /// The core-traits error map through the assembled binding — the
1390    /// closed registry family set exactly as the mem binding's.
1391    #[tokio::test]
1392    async fn the_assembled_binding_matches_the_family_error_map() {
1393        let path = unique_db_path("sqlite", "binding");
1394        let assembly = SqliteRegistryAssembly::open(&path).await.unwrap();
1395        let backend = assembly.backend().clone();
1396
1397        assert_eq!(
1398            backend.update_repo(&record("ghost")).await.unwrap_err(),
1399            RegistryError::NotFound
1400        );
1401        assert_eq!(
1402            backend.remove_repo("ghost").await.unwrap_err(),
1403            RegistryError::NotFound
1404        );
1405        backend.put_repo(&record("owner/b")).await.unwrap();
1406        assert_eq!(
1407            backend.put_repo(&record("owner/b")).await.unwrap_err(),
1408            RegistryError::AlreadyExists
1409        );
1410        let malformed = record("Bad/id");
1411        assert_eq!(
1412            backend.put_repo(&malformed).await.unwrap_err(),
1413            RegistryError::Invalid
1414        );
1415
1416        backend.remove_repo("owner/b").await.unwrap();
1417        assert_eq!(
1418            backend.resolve("owner/b").await.unwrap_err(),
1419            RegistryError::NotFound,
1420            "the unknown ≡ unauthorized collapse is at resolve's caller"
1421        );
1422    }
1423}