Skip to main content

alkgit_substrate/
registry.rs

1//! The registry: the co-tenant tables behind the family adapter pattern
2//! (ADR-019 §3–4, substrate.md §registry, POC-R's validated shape) —
3//! the engine-generic half of the family.
4//!
5//! One trait pair, two implements per surface:
6//!
7//! - [`RegistryRead`] — the sync read side (`resolve`): the
8//!   advertisement path's hot read. No `async`, no I/O — implementors
9//!   serve in-memory state. A miss is *definitive*: implementations
10//!   promise the whole record table as their cached set, so
11//!   `resolve` never falls through to backing-store I/O. The
12//!   unknown-repo ≡ unauthorized rule (ADR-008) is evaluated at
13//!   resolve's caller — this trait returns `None` and leaks no more
14//!   than that.
15//! - [`RegistryWrite`] — the async write side: [`RegistryWrite::put`]
16//!   / [`RegistryWrite::update`] / [`RegistryWrite::remove`] plus the
17//!   reconcile machinery's [`RegistryWrite::watermark`] /
18//!   [`RegistryWrite::rows_since`]. Every mutation assigns the
19//!   mutation's monotone sequence number *inside* the row-write
20//!   transaction (transactionally exact — the watermark's anchor,
21//!   ADR-019 §5) and returns it; write-side announce (publish/notify/
22//!   enqueue) is the reactor's post-commit business, never the pair's.
23//!
24//! The complete in-memory implementations:
25//!
26//! - [`InMemoryRegistry`] — the in-memory default adapter, the
27//!   batteries-included degenerate case: always-populated,
28//!   always-correct, single-process, stamp-free (ADR-024 §6), no
29//!   engine dependency; the completeness shape in the crate's base
30//!   (no feature gates — the ADR-025 placement rule).
31//! - [`CachedRegistryProvider`] — the coherence contract's read side
32//!   (ADR-019 §4, POC-R's named deliverable): the whole record table
33//!   as one immutable snapshot swapped under a short lock; the hot
34//!   path is one lock read + `Arc` clone + lookup with zero I/O;
35//!   invalidation is write-through on the store's own writes plus
36//!   wake-driven/tick-driven whole-table refreshes (the delivery
37//!   wiring is the reactor module's); refresh failures leave the
38//!   previous snapshot serving.
39//! - [`RegistryBackend`] — the binding of the pair onto the core's
40//!   published registry traits ([`GitRegistry`] /
41//!   [`GitRegistryStore`]): resolve over the
42//!   read side (a miss on the whole-table snapshot is a definitive
43//!   unresolved id — [`RegistryError::NotFound`]; the unknown ≡
44//!   unauthorized collapse stays at resolve's wire-layer caller),
45//!   `put_repo`/`update_repo`/`remove_repo` over the write side with
46//!   write-through on the optional provider. The in-memory adapter is
47//!   the first complete impl (bound via
48//!   [`RegistryBackend::with_cached_provider`] over
49//!   [`InMemoryRegistry`] — the mem pair is test-only now, review 006
50//!   R-4; the production mem composition is the entry's mem shape);
51//!   the engine twins implement the same pair so the same wrapper
52//!   binds over them.
53//!
54//! The exact Rust names pinned here are the OQ-03 substrate-side
55//! inventory for this wave's members: `RegistryRead`, `RegistryWrite`,
56//! [`RegistryRow`], `InMemoryRegistry`, `CachedRegistryProvider`,
57//! `RegistryBackend`.
58
59use std::collections::HashMap;
60use std::sync::atomic::{AtomicU64, Ordering};
61use std::sync::Arc;
62
63use alkgit::validate_repo_id;
64#[cfg(any(feature = "sqlite", feature = "postgres"))]
65use alkgit::{AccessAction, Visibility};
66use alkgit::{GitRegistry, GitRegistryStore, RegistryError, RepoRecord};
67use async_trait::async_trait;
68#[cfg(any(feature = "sqlite", feature = "postgres"))]
69use std::collections::BTreeMap;
70
71/// The v1 HEAD seed target — the unborn symref row both engine twins'
72/// record-write transactions insert (substrate.md §refs pins the value;
73/// a constructor knob is additive later, two-way — the row is
74/// CAS-rewriteable).
75pub const HEAD_SEED_TARGET: &str = "refs/heads/main";
76
77/// One durable registry row — the record plus its mutation sequence
78/// number.
79///
80/// `seq` is monotone per table (assigned inside the row's own
81/// transaction); it is the reconcile sweep's watermark currency and
82/// the per-seq idempotency key of every consumer downstream. First
83/// mutation carries 1; sequence 0 means "everything" to
84/// [`RegistryWrite::rows_since`].
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct RegistryRow {
87    /// The record the row carries.
88    pub record: RepoRecord,
89    /// The mutation's monotone sequence number.
90    pub seq: i64,
91}
92
93/// The sync read side of the family adapter pair — the advertisement
94/// path's hot read (the alkcall `OwnershipProvider` posture, POC-R's
95/// validated shape).
96///
97/// Resolve serves in-memory state: no await, no round trip, no backing
98/// I/O (the coherence contract's hot path). A `None` return is
99/// definitive — implementations cache/present the whole record table,
100/// so a miss is not a "not in my cache, go ask the store" signal.
101pub trait RegistryRead: Send + Sync {
102    /// Resolve a repo id to its record; `None` is a definitive miss.
103    fn resolve(&self, repo_id: &str) -> Option<RepoRecord>;
104}
105
106/// The async write side of the family adapter pair (the alkcall
107/// `OwnershipStore` posture).
108///
109/// Every mutation is transactional on the implementor's durable
110/// surface: engine twins bump the durable `registry_seq` singleton
111/// inside the row-write transaction and their row commit also carries
112/// the refs-table legs the composition owes (HEAD seeding at record
113/// creation, ref-row removal at repo removal — substrate.md §refs);
114/// the in-memory adapter bumps a process-local counter. The returned
115/// sequence number is the mutation's watermark currency; failed
116/// mutations consume none.
117///
118/// Post-commit announce (publish/notify/enqueue) and the reactive
119/// halves live in the reactor module — never inside the pair.
120#[async_trait]
121pub trait RegistryWrite: Send + Sync {
122    /// Create a repo record. [`RegistryError::Invalid`] on a malformed
123    /// id or record, [`RegistryError::AlreadyExists`] when the id is
124    /// taken. Returns the mutation's sequence number.
125    async fn put(&self, record: &RepoRecord) -> Result<i64, RegistryError>;
126
127    /// Replace a repo record (the grant-mutation path, ADR-011).
128    /// [`RegistryError::Invalid`] on a malformed record,
129    /// [`RegistryError::NotFound`] when the id is absent. Returns the
130    /// mutation's sequence number.
131    async fn update(&self, record: &RepoRecord) -> Result<i64, RegistryError>;
132
133    /// Remove a repo record. [`RegistryError::NotFound`] when the id
134    /// is absent. Returns the removal's sequence number.
135    async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError>;
136
137    /// The table's current watermark: the max sequence number ever
138    /// assigned (0 on a fresh table). Monotone — a committed removal
139    /// advances it even though it leaves no row.
140    async fn watermark(&self) -> Result<i64, RegistryError>;
141
142    /// The reconcile catch-up read: every row mutated after `seq`, in
143    /// sequence order. `rows_since(0)` is the whole table — the cached
144    /// provider's snapshot-refresh read (whole-table by contract).
145    async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError>;
146}
147
148/// The store-side record validation shared by the family
149/// ([`RegistryError::Invalid`] is closed over exactly these rejections,
150/// backend.md §`RegistryError`): the repo id passes the pinned grammar
151/// (the id is an opaque registry key — rejected against the grammar,
152/// never decomposed; ADR-008) and grant keys are non-empty opaque
153/// identity-id strings.
154pub(crate) fn validate_record(record: &RepoRecord) -> Result<(), RegistryError> {
155    validate_repo_id(&record.repo_id).map_err(|_| RegistryError::Invalid)?;
156    if record.grants.keys().any(String::is_empty) {
157        return Err(RegistryError::Invalid);
158    }
159    Ok(())
160}
161
162/// A committed row's record reconstruction — the serde shape's members
163/// from their columns. A row the composition did not write (corrupt
164/// visibility text, malformed grants JSON) is structurally invalid row
165/// state: [`RegistryError::Invalid`]. Engine-neutral — both engine
166/// twins decode their `git_repos` rows through it.
167#[cfg(any(feature = "sqlite", feature = "postgres"))]
168pub(crate) fn row_to_record(
169    repo_id: &str,
170    visibility_col: &str,
171    grants_col: &str,
172    seq: i64,
173) -> Result<RegistryRow, RegistryError> {
174    let visibility = match visibility_col {
175        "public" => Visibility::Public,
176        "private" => Visibility::Private,
177        _ => return Err(RegistryError::Invalid),
178    };
179    let grants: BTreeMap<String, Vec<AccessAction>> =
180        serde_json::from_str(grants_col).map_err(|_| RegistryError::Invalid)?;
181    Ok(RegistryRow {
182        record: RepoRecord {
183            repo_id: repo_id.to_owned(),
184            visibility,
185            grants,
186        },
187        seq,
188    })
189}
190
191/// The row's visibility column spelling (the writes' inverse of
192/// [`row_to_record`]'s read) — the CHECK's domain, shared by both
193/// twins.
194#[cfg(any(feature = "sqlite", feature = "postgres"))]
195pub(crate) fn visibility_str(visibility: Visibility) -> &'static str {
196    match visibility {
197        Visibility::Public => "public",
198        Visibility::Private => "private",
199    }
200}
201
202/// The in-memory default adapter — the batteries-included degenerate
203/// case of the family: always-populated, always-correct,
204/// single-process; the completeness shape in the crate's base (no
205/// engine dependency, stamp-free per ADR-024 §6 — it builds fresh
206/// every construction).
207///
208/// Sequence numbers are process-local but hold the same shape the
209/// engine twins carry: monotone over the adapter's lifetime, assigned
210/// only inside a successful mutation, never decreasing — a committed
211/// removal advances the watermark while leaving no row.
212pub struct InMemoryRegistry {
213    rows: parking_lot::RwLock<HashMap<String, RegistryRow>>,
214    seq: AtomicU64,
215}
216
217impl Default for InMemoryRegistry {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl InMemoryRegistry {
224    /// A fresh, empty registry.
225    pub fn new() -> Self {
226        InMemoryRegistry {
227            rows: parking_lot::RwLock::new(HashMap::new()),
228            seq: AtomicU64::new(0),
229        }
230    }
231
232    fn next_seq(&self) -> i64 {
233        self.seq.fetch_add(1, Ordering::Relaxed) as i64 + 1
234    }
235
236    /// The per-push mutation-marker write's durable leg (ADR-019 §7,
237    /// composition-internal — the engine twins' same-named leg, the
238    /// mem adapter's shape): the process-local sequence pointer moves
239    /// (the watermark's anchor); no row changes and `rows_since` never
240    /// carries markers. The record must exist (the receive flow
241    /// resolved it) — an absent repo's marker is refused
242    /// ([`RegistryError::NotFound`], the twins' posture). Consuming
243    /// composition: the mem shape's fused
244    /// [`crate::pins::MemMutationLegs`].
245    pub(crate) async fn record_mutation(&self, repo_id: &str) -> Result<i64, RegistryError> {
246        if !self.rows.read().contains_key(repo_id) {
247            return Err(RegistryError::NotFound);
248        }
249        Ok(self.next_seq())
250    }
251}
252
253#[async_trait]
254impl RegistryWrite for InMemoryRegistry {
255    async fn put(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
256        validate_record(record)?;
257        let mut rows = self.rows.write();
258        if rows.contains_key(&record.repo_id) {
259            return Err(RegistryError::AlreadyExists);
260        }
261        let seq = self.next_seq();
262        rows.insert(
263            record.repo_id.clone(),
264            RegistryRow {
265                record: record.clone(),
266                seq,
267            },
268        );
269        Ok(seq)
270    }
271
272    async fn update(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
273        validate_record(record)?;
274        let mut rows = self.rows.write();
275        if !rows.contains_key(&record.repo_id) {
276            return Err(RegistryError::NotFound);
277        }
278        let seq = self.next_seq();
279        rows.insert(
280            record.repo_id.clone(),
281            RegistryRow {
282                record: record.clone(),
283                seq,
284            },
285        );
286        Ok(seq)
287    }
288
289    async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError> {
290        let mut rows = self.rows.write();
291        if rows.remove(repo_id).is_none() {
292            return Err(RegistryError::NotFound);
293        }
294        Ok(self.next_seq())
295    }
296
297    async fn watermark(&self) -> Result<i64, RegistryError> {
298        Ok(self.seq.load(Ordering::Relaxed) as i64)
299    }
300
301    async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError> {
302        let mut rows: Vec<RegistryRow> = self
303            .rows
304            .read()
305            .values()
306            .filter(|row| row.seq > seq)
307            .cloned()
308            .collect();
309        rows.sort_by_key(|row| row.seq);
310        Ok(rows)
311    }
312}
313
314impl RegistryRead for InMemoryRegistry {
315    fn resolve(&self, repo_id: &str) -> Option<RepoRecord> {
316        self.rows.read().get(repo_id).map(|row| row.record.clone())
317    }
318}
319
320/// **The coherence contract as a type** (ADR-019 §4, POC-R's named
321/// deliverable; substrate.md §the coherence contract) — the cached
322/// read provider the advertisement path rides:
323///
324/// - *Cached set*: the whole record table as one immutable snapshot —
325///   an `Arc` map swapped under a short lock. Whole-table is
326///   deliberate: record tables are small, whole snapshots make
327///   coalesced refreshes trivially correct (a refresh re-reads the
328///   table; there is no delta application to get wrong), and readers
329///   never block a writer.
330/// - *Hot path*: one lock read + `Arc` clone + lookup. Zero I/O on the
331///   steady state — the provider holds no durable-side handle at all;
332///   it serves last-installed state and nothing else.
333/// - *Invalidation triggers*: (1) alkstore wakes (the post-commit
334///   notify hint, or sqlite's file-wide `data_version` bump from any
335///   writer — the delivery wiring is the reactor's wake runner); (2)
336///   the provider's own writes (write-through, via
337///   [`CachedRegistryProvider::install_one`] /
338///   [`CachedRegistryProvider::install_removal`] — the
339///   [`RegistryBackend`] store methods call these); (3) the reconcile
340///   tick (driven through
341///   [`CachedRegistryProvider::refresh_from`], the same body the wake
342///   handler runs).
343/// - *Staleness bound*: mutation visibility ≤ one wake delivery + one
344///   refresh. One `refresh_from` after a mutation is always enough to
345///   surface it — the snapshot is whole-table, so there is no partial
346///   state between installs.
347/// - *Refresh coalescing*: correct by the snapshot swap — a burst of
348///   mutations collapses into one refresh, and one refresh carries the
349///   whole burst.
350/// - *Failure posture*: refresh failures leave the previous snapshot
351///   serving; the next wake, write, or tick retries.
352pub struct CachedRegistryProvider {
353    snapshot: parking_lot::RwLock<Arc<HashMap<String, RepoRecord>>>,
354    refreshes: AtomicU64,
355}
356
357impl Default for CachedRegistryProvider {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363impl CachedRegistryProvider {
364    /// A provider with an empty snapshot.
365    pub fn new() -> Self {
366        CachedRegistryProvider {
367            snapshot: parking_lot::RwLock::new(Arc::new(HashMap::new())),
368            refreshes: AtomicU64::new(0),
369        }
370    }
371
372    /// Install a whole-table snapshot (the wake handler's and the
373    /// reconcile tick's body — a swap, not a delta).
374    pub fn install(&self, rows: Vec<RegistryRow>) {
375        let map: HashMap<String, RepoRecord> = rows
376            .into_iter()
377            .map(|row| (row.record.repo_id.clone(), row.record))
378            .collect();
379        *self.snapshot.write() = Arc::new(map);
380        self.refreshes.fetch_add(1, Ordering::Relaxed);
381    }
382
383    /// Write-through install of one row — the store's own write path
384    /// (no table read involved).
385    pub fn install_one(&self, row: RegistryRow) {
386        let mut guard = self.snapshot.write();
387        let mut map = (**guard).clone();
388        map.insert(row.record.repo_id.clone(), row.record);
389        *guard = Arc::new(map);
390        self.refreshes.fetch_add(1, Ordering::Relaxed);
391    }
392
393    /// Write-through removal — the store's own removal path.
394    pub fn install_removal(&self, repo_id: &str) {
395        let mut guard = self.snapshot.write();
396        let mut map = (**guard).clone();
397        map.remove(repo_id);
398        *guard = Arc::new(map);
399        self.refreshes.fetch_add(1, Ordering::Relaxed);
400    }
401
402    /// Pull-and-install from the durable side — the body the wake
403    /// handler and the reconcile tick both run. The whole table is
404    /// re-read (`rows_since(0)`) and swapped in; on failure nothing is
405    /// installed and the previous snapshot keeps serving.
406    pub async fn refresh_from(&self, registry: &dyn RegistryWrite) -> Result<(), RegistryError> {
407        let rows = registry.rows_since(0).await?;
408        self.install(rows);
409        Ok(())
410    }
411
412    /// The installed snapshot (observation without refresh —
413    /// replication-style consumers and the tests' coherence asserts).
414    pub fn snapshot(&self) -> Arc<HashMap<String, RepoRecord>> {
415        self.snapshot.read().clone()
416    }
417
418    /// How many installs have landed (test-observation for the
419    /// refresh-count pins: idle-stability across idle windows, one
420    /// refresh per coalesced burst).
421    pub fn refresh_count(&self) -> u64 {
422        self.refreshes.load(Ordering::Relaxed)
423    }
424}
425
426impl RegistryRead for CachedRegistryProvider {
427    fn resolve(&self, repo_id: &str) -> Option<RepoRecord> {
428        self.snapshot.read().get(repo_id).cloned()
429    }
430}
431
432/// The registry backend — the pair bound onto the core's published
433/// registry traits ([`GitRegistry`] /
434/// [`GitRegistryStore`]); the first complete impl is the
435/// in-memory binding (test-only now — review 006 R-4, the bare mem
436/// pair is not a production surface; the production mem composition
437/// is the entry's mem shape) and the engine twins bind through the
438/// same type.
439///
440/// Resolve rides the read side ([`CachedRegistryProvider`] in the
441/// composed assemblies; the miss maps to [`RegistryError::NotFound`]
442/// — a definitive unresolved id, with the unknown ≡ unauthorized
443/// collapse at the wire-layer caller, exactly as before). The store
444/// methods ride the write side, announcing nothing (the reactor owns
445/// post-commit announce) and, when a provider is bound with
446/// [`RegistryBackend::with_cached_provider`], writing through so the
447/// this-process mutation is visible to the hot path without waiting
448/// for the wake-driven refresh.
449pub struct RegistryBackend {
450    write: Arc<dyn RegistryWrite>,
451    read: Arc<dyn RegistryRead>,
452    write_through: Option<Arc<CachedRegistryProvider>>,
453}
454
455impl RegistryBackend {
456    /// Bind the pair as-is (no write-through leg — a resolver over an
457    /// external cached provider's refresh cadence, or direct state).
458    pub fn new(write: Arc<dyn RegistryWrite>, read: Arc<dyn RegistryRead>) -> Self {
459        RegistryBackend {
460            write,
461            read,
462            write_through: None,
463        }
464    }
465
466    /// Bind the pair with the cached provider on both legs: resolves
467    /// serve the provider, the store methods write through to it. The
468    /// engine-twin binding.
469    pub fn with_cached_provider(
470        write: Arc<dyn RegistryWrite>,
471        provider: Arc<CachedRegistryProvider>,
472    ) -> Self {
473        RegistryBackend {
474            write: write.clone(),
475            read: provider.clone(),
476            write_through: Some(provider),
477        }
478    }
479
480    /// The durable write side (the reactor's announce composition and
481    /// reconcile sweep consume it directly).
482    pub fn write(&self) -> &Arc<dyn RegistryWrite> {
483        &self.write
484    }
485
486    /// The read side (the hot read).
487    pub fn read(&self) -> &Arc<dyn RegistryRead> {
488        &self.read
489    }
490
491    /// The write-through provider, when bound with
492    /// [`RegistryBackend::with_cached_provider`].
493    pub fn provider(&self) -> Option<&Arc<CachedRegistryProvider>> {
494        self.write_through.as_ref()
495    }
496}
497
498#[async_trait]
499impl GitRegistry for RegistryBackend {
500    async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError> {
501        self.read.resolve(repo_id).ok_or(RegistryError::NotFound)
502    }
503}
504
505#[async_trait]
506impl GitRegistryStore for RegistryBackend {
507    async fn put_repo(&self, record: &RepoRecord) -> Result<(), RegistryError> {
508        let seq = self.write.put(record).await?;
509        if let Some(provider) = &self.write_through {
510            provider.install_one(RegistryRow {
511                record: record.clone(),
512                seq,
513            });
514        }
515        Ok(())
516    }
517
518    async fn update_repo(&self, record: &RepoRecord) -> Result<(), RegistryError> {
519        let seq = self.write.update(record).await?;
520        if let Some(provider) = &self.write_through {
521            provider.install_one(RegistryRow {
522                record: record.clone(),
523                seq,
524            });
525        }
526        Ok(())
527    }
528
529    async fn remove_repo(&self, repo_id: &str) -> Result<(), RegistryError> {
530        self.write.remove(repo_id).await?;
531        if let Some(provider) = &self.write_through {
532            provider.install_removal(repo_id);
533        }
534        Ok(())
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    use crate::testutil::{mem_registry_pair, private_record, record};
543
544    #[tokio::test]
545    async fn mem_adapter_is_complete_put_resolve_update_remove() {
546        let registry = InMemoryRegistry::new();
547
548        let put_seq = registry.put(&record("demo")).await.unwrap();
549        assert_eq!(put_seq, 1);
550        assert_eq!(registry.resolve("demo"), Some(record("demo")));
551
552        let mut updated = record("demo");
553        updated.visibility = alkgit::Visibility::Private;
554        let update_seq = registry.update(&updated).await.unwrap();
555        assert_eq!(update_seq, 2);
556        assert_eq!(registry.resolve("demo"), Some(updated));
557
558        let remove_seq = registry.remove("demo").await.unwrap();
559        assert_eq!(remove_seq, 3);
560        assert_eq!(registry.resolve("demo"), None);
561    }
562
563    #[tokio::test]
564    async fn mem_adapter_error_map_is_pinned() {
565        let registry = InMemoryRegistry::new();
566        assert_eq!(
567            registry.update(&record("ghost")).await.unwrap_err(),
568            RegistryError::NotFound
569        );
570        assert_eq!(
571            registry.remove("ghost").await.unwrap_err(),
572            RegistryError::NotFound
573        );
574        assert_eq!(
575            registry.put(&record("demo")).await.unwrap(),
576            1,
577            "first mutation carries seq 1"
578        );
579        assert_eq!(
580            registry.put(&record("demo")).await.unwrap_err(),
581            RegistryError::AlreadyExists
582        );
583
584        let mut bad_id = record("Owner/name");
585        bad_id.repo_id = "Owner/name".to_string();
586        assert_eq!(
587            registry.put(&bad_id).await.unwrap_err(),
588            RegistryError::Invalid
589        );
590        let mut empty_repo_id = record("");
591        empty_repo_id.repo_id = String::new();
592        assert_eq!(
593            registry.put(&empty_repo_id).await.unwrap_err(),
594            RegistryError::Invalid
595        );
596    }
597
598    #[tokio::test]
599    async fn mem_adapter_failed_mutations_consume_no_seq() {
600        let registry = InMemoryRegistry::new();
601        assert!(registry.put(&record("a")).await.is_ok());
602        assert!(registry.put(&record("a")).await.is_err());
603        assert_eq!(registry.watermark().await.unwrap(), 1);
604        let seq = registry.put(&record("b")).await.unwrap();
605        assert_eq!(seq, 2, "the failed duplicate put consumed nothing");
606    }
607
608    #[tokio::test]
609    async fn mem_adapter_watermark_is_monotone_across_removal() {
610        let registry = InMemoryRegistry::new();
611        assert_eq!(registry.watermark().await.unwrap(), 0, "fresh table");
612        registry.put(&record("a")).await.unwrap();
613        registry.put(&record("b")).await.unwrap();
614        assert_eq!(registry.watermark().await.unwrap(), 2);
615        registry.remove("a").await.unwrap();
616        assert_eq!(
617            registry.watermark().await.unwrap(),
618            3,
619            "a committed removal advances the watermark though it leaves no row"
620        );
621        registry.remove("never-was").await.unwrap_err();
622        assert_eq!(
623            registry.watermark().await.unwrap(),
624            3,
625            "a failed removal consumes no seq"
626        );
627    }
628
629    #[tokio::test]
630    async fn mem_adapter_rows_since_covers_the_reconcile_recipe() {
631        let registry = InMemoryRegistry::new();
632        let seq_a = registry.put(&record("a")).await.unwrap();
633        let seq_b = registry.put(&private_record("b")).await.unwrap();
634        assert_eq!(seq_b, seq_a + 1);
635
636        let all = registry.rows_since(0).await.unwrap();
637        assert_eq!(
638            all.iter().map(|row| row.seq).collect::<Vec<_>>(),
639            vec![seq_a, seq_b],
640            "rows_since(0) is the whole table, in sequence order"
641        );
642
643        registry.update(&record("a")).await.unwrap();
644        registry.remove("b").await.unwrap();
645
646        let catchup = registry.rows_since(seq_b).await.unwrap();
647        assert_eq!(
648            catchup
649                .iter()
650                .map(|row| (row.record.repo_id.as_str(), row.seq))
651                .collect::<Vec<_>>(),
652            vec![("a", seq_a + 2)],
653            "the committed removal leaves no row for the catch-up read"
654        );
655    }
656
657    #[tokio::test]
658    async fn mem_adapter_rows_since_is_seq_ordered_and_exclusive() {
659        let registry = InMemoryRegistry::new();
660        let first = registry.put(&record("a")).await.unwrap();
661        registry.put(&record("b")).await.unwrap();
662        registry.put(&record("c")).await.unwrap();
663
664        let rows = registry.rows_since(first).await.unwrap();
665        assert_eq!(
666            rows.iter()
667                .map(|row| row.record.repo_id.as_str())
668                .collect::<Vec<_>>(),
669            vec!["b", "c"],
670            "rows after `seq` only, in sequence order"
671        );
672        assert!(registry.rows_since(first).await.unwrap()[0].seq > first);
673    }
674
675    #[tokio::test]
676    async fn resolve_hits_are_cloned_not_aliased() {
677        let registry = InMemoryRegistry::new();
678        registry.put(&record("demo")).await.unwrap();
679        let resolved = registry.resolve("demo").unwrap();
680        let via_provider = {
681            let provider = CachedRegistryProvider::new();
682            provider.install(registry.rows_since(0).await.unwrap());
683            provider.resolve("demo").unwrap()
684        };
685        assert_eq!(resolved, record("demo"));
686        assert_eq!(via_provider, record("demo"));
687    }
688
689    #[tokio::test]
690    async fn provider_snapshot_swap_serves_the_whole_table() {
691        let registry = InMemoryRegistry::new();
692        let provider = CachedRegistryProvider::new();
693
694        assert_eq!(provider.resolve("demo"), None, "fresh provider misses");
695        assert_eq!(provider.refresh_count(), 0, "no installs at construction");
696
697        registry.put(&record("demo")).await.unwrap();
698        provider.install(registry.rows_since(0).await.unwrap());
699
700        assert_eq!(provider.resolve("demo"), Some(record("demo")));
701        assert_eq!(provider.snapshot().len(), 1, "whole-table install");
702    }
703
704    #[tokio::test]
705    async fn provider_hot_path_never_touches_the_durable_side() {
706        let registry = InMemoryRegistry::new();
707        registry.put(&record("demo")).await.unwrap();
708        let provider = CachedRegistryProvider::new();
709        provider.install(registry.rows_since(0).await.unwrap());
710
711        let before = registry.watermark().await.unwrap();
712        for _ in 0..1_000 {
713            assert_eq!(provider.resolve("demo"), Some(record("demo")));
714        }
715        assert_eq!(
716            registry.watermark().await.unwrap(),
717            before,
718            "resolve is served entirely from the installed snapshot — \
719             zero durable-side reads on the hot path"
720        );
721    }
722
723    #[tokio::test]
724    async fn provider_write_through_sees_its_own_writes_immediately() {
725        let registry = Arc::new(InMemoryRegistry::new());
726        let provider = Arc::new(CachedRegistryProvider::new());
727        let backend = RegistryBackend::with_cached_provider(registry.clone(), provider.clone());
728        let write = backend.write().clone();
729        drop(backend);
730
731        let seq = write.put(&record("demo")).await.unwrap();
732        provider.install_one(RegistryRow {
733            record: record("demo"),
734            seq,
735        });
736        assert_eq!(
737            provider.resolve("demo"),
738            Some(record("demo")),
739            "write-through install surfaces the mutation with no refresh"
740        );
741        assert_eq!(provider.refresh_count(), 1, "one install, no table read");
742
743        let seq = write.put(&record("other")).await.unwrap();
744        provider.install_one(RegistryRow {
745            record: record("other"),
746            seq,
747        });
748        assert_eq!(provider.resolve("demo"), Some(record("demo")));
749        assert_eq!(provider.resolve("other"), Some(record("other")));
750
751        write.remove("demo").await.unwrap();
752        provider.install_removal("demo");
753        assert_eq!(provider.resolve("demo"), None);
754        assert_eq!(
755            provider.resolve("other"),
756            Some(record("other")),
757            "removal is an install, not a re-read"
758        );
759    }
760
761    struct FlakyCatchUp {
762        registry: InMemoryRegistry,
763        fail_first_rows_since: std::sync::atomic::AtomicBool,
764    }
765
766    #[async_trait]
767    impl RegistryWrite for FlakyCatchUp {
768        async fn put(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
769            self.registry.put(record).await
770        }
771        async fn update(&self, record: &RepoRecord) -> Result<i64, RegistryError> {
772            self.registry.update(record).await
773        }
774        async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError> {
775            self.registry.remove(repo_id).await
776        }
777        async fn watermark(&self) -> Result<i64, RegistryError> {
778            self.registry.watermark().await
779        }
780        async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError> {
781            if self.fail_first_rows_since.swap(false, Ordering::Relaxed) {
782                return Err(RegistryError::Io("refresh transport failed".into()));
783            }
784            self.registry.rows_since(seq).await
785        }
786    }
787
788    #[tokio::test]
789    async fn provider_refresh_failure_leaves_the_previous_snapshot_serving() {
790        let flaky = FlakyCatchUp {
791            registry: InMemoryRegistry::new(),
792            fail_first_rows_since: std::sync::atomic::AtomicBool::new(false),
793        };
794        let provider = CachedRegistryProvider::new();
795        let seq = flaky.put(&record("good")).await.unwrap();
796        provider.install_one(RegistryRow {
797            record: record("good"),
798            seq,
799        });
800
801        flaky.fail_first_rows_since.store(true, Ordering::Relaxed);
802        assert!(provider.refresh_from(&flaky).await.is_err());
803        assert_eq!(
804            provider.resolve("good"),
805            Some(record("good")),
806            "the failed refresh left the previous snapshot serving"
807        );
808
809        assert!(provider.refresh_from(&flaky).await.is_ok());
810        assert_eq!(provider.resolve("good"), Some(record("good")));
811
812        flaky.put(&record("late")).await.unwrap();
813        assert!(provider.refresh_from(&flaky).await.is_ok());
814        assert_eq!(
815            provider.resolve("late"),
816            Some(record("late")),
817            "the next tick's refresh retries and succeeds"
818        );
819    }
820
821    #[tokio::test]
822    async fn provider_one_refresh_surfaces_a_burst_and_is_idempotent() {
823        let registry = InMemoryRegistry::new();
824        let provider = CachedRegistryProvider::new();
825
826        for id in ["a", "b", "c"] {
827            registry.put(&record(id)).await.unwrap();
828        }
829        provider.refresh_from(&registry).await.unwrap();
830        assert_eq!(provider.refresh_count(), 1);
831        for id in ["a", "b", "c"] {
832            assert_eq!(
833                provider.resolve(id),
834                Some(record(id)),
835                "one refresh carries the whole burst — coalescing is \
836                 correct by the snapshot swap"
837            );
838        }
839        assert_eq!(provider.snapshot().len(), 3);
840
841        registry.remove("b").await.unwrap();
842        provider.refresh_from(&registry).await.unwrap();
843        provider.refresh_from(&registry).await.unwrap();
844        assert_eq!(provider.resolve("a"), Some(record("a")));
845        assert_eq!(provider.resolve("b"), None);
846        assert_eq!(provider.resolve("c"), Some(record("c")));
847        assert_eq!(
848            provider.refresh_count(),
849            3,
850            "reconcile ticks re-install whole-table state — \
851             idempotent by construction"
852        );
853    }
854
855    #[tokio::test]
856    async fn mem_backend_binds_end_to_end_put_resolve_remove() {
857        let (_, store) = mem_registry_pair();
858
859        store.put_repo(&record("demo")).await.unwrap();
860        assert_eq!(
861            store.resolve("demo").await.unwrap(),
862            record("demo"),
863            "mem-backed end-to-end: put is hot-visible through the \
864             write-through provider"
865        );
866
867        let mut granted = record("demo");
868        granted
869            .grants
870            .insert("u-7731".to_string(), vec![alkgit::AccessAction::Read]);
871        store.update_repo(&granted).await.unwrap();
872        assert_eq!(store.resolve("demo").await.unwrap(), granted);
873
874        store.remove_repo("demo").await.unwrap();
875        assert_eq!(
876            store.resolve("demo").await.unwrap_err(),
877            RegistryError::NotFound,
878            "a resolve miss after removal maps to the collapse posture \
879             — NotFound, the definitive unresolved id"
880        );
881    }
882
883    #[tokio::test]
884    async fn mem_backend_error_map_matches_the_core_traits() {
885        let (_, store) = mem_registry_pair();
886        assert_eq!(
887            store.update_repo(&record("ghost")).await.unwrap_err(),
888            RegistryError::NotFound
889        );
890        assert_eq!(
891            store.remove_repo("ghost").await.unwrap_err(),
892            RegistryError::NotFound
893        );
894        store.put_repo(&record("demo")).await.unwrap();
895        assert_eq!(
896            store.put_repo(&record("demo")).await.unwrap_err(),
897            RegistryError::AlreadyExists
898        );
899
900        let mut malformed = record("Bad/id");
901        malformed.repo_id = "Bad/id".to_string();
902        assert_eq!(
903            store.put_repo(&malformed).await.unwrap_err(),
904            RegistryError::Invalid
905        );
906        assert_eq!(
907            store.resolve("demo").await.unwrap(),
908            record("demo"),
909            "the rejected mutation left the hot path untouched"
910        );
911    }
912
913    #[tokio::test]
914    async fn mem_backend_store_methods_flow_through_the_pair() {
915        let registry = Arc::new(InMemoryRegistry::new());
916        let provider = Arc::new(CachedRegistryProvider::new());
917        let store = RegistryBackend::with_cached_provider(
918            registry.clone() as Arc<dyn RegistryWrite>,
919            provider.clone(),
920        );
921
922        store.put_repo(&record("demo")).await.unwrap();
923        assert_eq!(registry.resolve("demo"), Some(record("demo")));
924        assert_eq!(registry.watermark().await.unwrap(), 1);
925        assert_eq!(
926            provider.resolve("demo"),
927            Some(record("demo")),
928            "the provider was written through by the store, not by a \
929             durable-side refresh"
930        );
931        assert_eq!(provider.refresh_count(), 1);
932
933        store.remove_repo("demo").await.unwrap();
934        assert_eq!(registry.resolve("demo"), None);
935        assert_eq!(registry.watermark().await.unwrap(), 2);
936        assert_eq!(provider.resolve("demo"), None);
937    }
938
939    #[tokio::test]
940    async fn backend_binds_over_an_external_read_side_without_write_through() {
941        let registry = Arc::new(InMemoryRegistry::new());
942        registry.put(&record("demo")).await.unwrap();
943        let store = RegistryBackend::new(registry.clone(), registry.clone());
944
945        assert_eq!(store.resolve("demo").await.unwrap(), record("demo"));
946        assert!(
947            store.provider().is_none(),
948            "no write-through target on the plain binding"
949        );
950    }
951
952    #[tokio::test]
953    async fn all_public_shapes_are_dyn_compatible() {
954        let (_, store) = mem_registry_pair();
955        let registry: Arc<dyn GitRegistry> = store.clone();
956        let store: Arc<dyn GitRegistryStore> = store;
957
958        let resolved = registry.resolve("nope").await;
959        assert_eq!(resolved.unwrap_err(), RegistryError::NotFound);
960        store.put_repo(&record("demo")).await.unwrap();
961        assert_eq!(registry.resolve("demo").await.unwrap(), record("demo"));
962    }
963
964    #[tokio::test]
965    async fn mem_registry_pair_exposes_the_write_side_for_the_reactor() {
966        let (write, store) = mem_registry_pair();
967        let seq = write.put(&record("demo")).await.unwrap();
968        assert_eq!(seq, 1);
969        assert_eq!(write.watermark().await.unwrap(), 1);
970        assert_eq!(
971            store.resolve("demo").await.unwrap_err(),
972            RegistryError::NotFound,
973            "a direct pair write rides the wake-driven refresh cadence — \
974             never write-through"
975        );
976    }
977
978    #[test]
979    fn repo_record_validation_rejects_the_pinned_shapes() {
980        let mut empty_key = record("demo");
981        empty_key
982            .grants
983            .insert(String::new(), vec![alkgit::AccessAction::Read]);
984        assert_eq!(validate_record(&empty_key), Err(RegistryError::Invalid));
985
986        let mut long_id = record("x");
987        long_id.repo_id = "a".repeat(101);
988        assert_eq!(validate_record(&long_id), Err(RegistryError::Invalid));
989
990        assert_eq!(validate_record(&record("owner/name")), Ok(()));
991    }
992}