1use 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
75pub use crate::registry::HEAD_SEED_TARGET;
79
80pub struct SqliteRegistry {
89 handle: CoTenantSqlite,
90}
91
92impl SqliteRegistry {
93 pub fn new(handle: CoTenantSqlite) -> Self {
95 SqliteRegistry { handle }
96 }
97
98 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
149async 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
166fn 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
213fn 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
255fn 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
358fn 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
394pub 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 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 pub fn store(&self) -> &Arc<dyn alkstore::Store> {
521 &self.store
522 }
523
524 pub fn write(&self) -> &Arc<dyn RegistryWrite> {
528 &self.registry
529 }
530
531 pub fn durable(&self) -> &Arc<SqliteRegistry> {
534 &self.durable
535 }
536
537 pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
539 &self.provider
540 }
541
542 pub fn reactor(&self) -> &Arc<Reactor> {
545 &self.reactor
546 }
547
548 pub fn refs(&self) -> &Arc<VersionedRefTable<SqliteRefTable>> {
555 &self.refs
556 }
557
558 pub fn refs_version(&self) -> &Arc<AtomicU64> {
562 &self.refs_version
563 }
564
565 pub fn backend(&self) -> &Arc<RegistryBackend> {
568 &self.backend
569 }
570
571 #[cfg(feature = "lfs")]
582 pub fn lfs_rows(&self) -> &Arc<crate::lfs::VersionedLfsRows<crate::lfs::SqliteLfsRows>> {
583 &self.lfs
584 }
585
586 #[cfg(feature = "lfs")]
591 pub fn lfs_version(&self) -> &Arc<AtomicU64> {
592 &self.lfs_version
593 }
594}
595
596#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 assert!(matches!(
1357 SqliteRegistryAssembly::open(":memory:").await,
1358 Err(OpenError::Invalid(_))
1359 ));
1360
1361 let _ = std::fs::remove_file(&path);
1362 }
1363
1364 #[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 #[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}