1#[cfg(feature = "sqlite")]
118use crate::error::OpenError;
119
120#[cfg(feature = "sqlite")]
121mod sqlite_twin {
122 use super::OpenError;
123 use std::path::Path;
124 use std::sync::Arc;
125
126 const STAMP_TABLE: &str = "alkgit_schema_stamp";
129
130 pub(crate) const BUSY_TIMEOUT_MS: i64 = 5_000;
134
135 pub(crate) const STAMP_TABLE_BOOTSTRAP: &str = "
141 CREATE TABLE IF NOT EXISTS alkgit_schema_stamp (
142 id INTEGER PRIMARY KEY CHECK (id = 1),
143 stamp INTEGER NOT NULL
144 );
145 INSERT OR IGNORE INTO alkgit_schema_stamp (id, stamp) VALUES (1, 0);";
146
147 pub(crate) struct Step {
156 pub(crate) version: i64,
157 pub(crate) name: &'static str,
158 pub(crate) sql: &'static str,
159 pub(crate) verify_sql: &'static str,
160 }
161
162 const SQLITE_STEPS: &[Step] = &[Step {
166 version: 1,
167 name: "initial co-tenant schema (records, refs, seq, mutation markers, LFS liveness rows)",
168 sql: "CREATE TABLE git_repos (
169 repo_id TEXT PRIMARY KEY,
170 visibility TEXT NOT NULL CHECK (visibility IN ('public', 'private')),
171 grants TEXT NOT NULL,
172 seq INTEGER NOT NULL
173 );
174 CREATE TABLE git_refs (
175 repo_id TEXT NOT NULL,
176 name TEXT NOT NULL,
177 oid_hex TEXT,
178 symref_target TEXT,
179 PRIMARY KEY (repo_id, name)
180 );
181 CREATE TABLE registry_seq (
182 id INTEGER PRIMARY KEY CHECK (id = 1),
183 value INTEGER NOT NULL
184 );
185 INSERT INTO registry_seq (id, value) VALUES (1, 0);
186 CREATE TABLE repo_mutation_markers (
187 repo_id TEXT PRIMARY KEY,
188 seq INTEGER NOT NULL
189 );
190 CREATE TABLE lfs_objects (
191 repo_id TEXT NOT NULL,
192 oid TEXT NOT NULL,
193 size INTEGER NOT NULL,
194 refcount INTEGER NOT NULL,
195 PRIMARY KEY (repo_id, oid)
196 );",
197 verify_sql: "SELECT count(*) = 5 FROM sqlite_master WHERE type = 'table' \
198 AND name IN ('git_repos', 'git_refs', 'registry_seq', \
199 'repo_mutation_markers', 'lfs_objects')",
200 }];
201
202 #[derive(Clone)]
218 pub struct CoTenantSqlite {
219 conn: Arc<parking_lot::Mutex<rusqlite::Connection>>,
220 }
221
222 impl CoTenantSqlite {
223 pub fn open(path: impl AsRef<Path>) -> Result<Self, OpenError> {
230 let path = path.as_ref();
231 if path == Path::new(":memory:") {
232 return Err(OpenError::Invalid(
233 "sqlite memory mode confers no co-tenancy — compose the \
234 mem engines for ephemeral assemblies"
235 .into(),
236 ));
237 }
238 let conn = rusqlite::Connection::open(path).map_err(|e| {
239 OpenError::Engine(format!("co-tenant sqlite open ({}): {e}", path.display()))
240 })?;
241 conn.pragma_update(None, "busy_timeout", BUSY_TIMEOUT_MS)
242 .map_err(|e| OpenError::Engine(format!("co-tenant sqlite busy_timeout: {e}")))?;
243 if let Err(e) = conn.pragma_update(None, "journal_mode", "WAL") {
244 const VERIFY_RETRIES: usize = 25;
252 let mut became_wal = false;
253 for _ in 0..VERIFY_RETRIES {
254 let mode: Result<String, _> =
255 conn.query_row("PRAGMA journal_mode", [], |r| r.get(0));
256 if mode.is_ok_and(|m| m.eq_ignore_ascii_case("wal")) {
257 became_wal = true;
258 break;
259 }
260 if conn.pragma_update(None, "journal_mode", "WAL").is_ok() {
261 became_wal = true;
262 break;
263 }
264 std::thread::sleep(std::time::Duration::from_millis(10));
265 }
266 if !became_wal {
267 return Err(OpenError::Engine(format!(
268 "co-tenant sqlite journal_mode: {e}"
269 )));
270 }
271 }
272 conn.pragma_update(None, "synchronous", "NORMAL")
273 .map_err(|e| OpenError::Engine(format!("co-tenant sqlite synchronous: {e}")))?;
274 let mut conn = conn;
275 migrate(&mut conn)?;
276 Ok(CoTenantSqlite {
277 conn: Arc::new(parking_lot::Mutex::new(conn)),
278 })
279 }
280
281 pub fn lock(&self) -> parking_lot::MutexGuard<'_, rusqlite::Connection> {
285 self.conn.lock()
286 }
287
288 pub fn stamp(&self) -> Result<i64, OpenError> {
294 read_stamp(&self.lock())
295 }
296
297 pub fn step_count() -> usize {
301 SQLITE_STEPS.len()
302 }
303 }
304
305 pub(crate) fn migrate(conn: &mut rusqlite::Connection) -> Result<i64, OpenError> {
309 run_steps(conn, SQLITE_STEPS)
310 }
311
312 pub(crate) fn run_steps(
316 conn: &mut rusqlite::Connection,
317 steps: &[Step],
318 ) -> Result<i64, OpenError> {
319 conn.execute_batch(STAMP_TABLE_BOOTSTRAP)
320 .map_err(|e| OpenError::Migration {
321 step: 0,
322 reason: format!("stamp-table bootstrap ({STAMP_TABLE}): {e}"),
323 })?;
324 for step in steps {
325 apply_step(conn, step)?;
326 }
327 read_stamp(conn)
328 }
329
330 fn apply_step(conn: &mut rusqlite::Connection, step: &Step) -> Result<(), OpenError> {
335 let step_no = step.version as usize;
336 let tx = conn
337 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
338 .map_err(|e| OpenError::Migration {
339 step: step_no,
340 reason: format!("step {step_no} ({}): BEGIN IMMEDIATE: {e}", step.name),
341 })?;
342 let stamp_now: i64 = tx
343 .query_row(read_stamp_sql().as_str(), [], |r| r.get(0))
344 .map_err(|e| OpenError::Migration {
345 step: step_no,
346 reason: format!("step {step_no} ({}): stamp read: {e}", step.name),
347 })?;
348 if stamp_now >= step.version {
349 return Ok(());
350 }
351 let outcome = tx
352 .execute_batch(step.sql)
353 .and_then(|_| tx.execute(write_stamp_sql().as_str(), rusqlite::params![step.version]))
354 .and_then(|_| tx.commit());
355 match outcome {
356 Ok(()) => Ok(()),
357 Err(e) => verify_race(conn, step, &e),
358 }
359 }
360
361 fn verify_race(
369 conn: &rusqlite::Connection,
370 step: &Step,
371 failure: &rusqlite::Error,
372 ) -> Result<(), OpenError> {
373 if step_effect_present(conn, step)? {
374 let step_no = step.version as usize;
375 conn.execute(mark_stamp_sql().as_str(), rusqlite::params![step.version])
376 .map_err(|e| OpenError::Migration {
377 step: step_no,
378 reason: format!(
379 "step {step_no} ({}): race-swallow stamp heal: {e}",
380 step.name
381 ),
382 })?;
383 Ok(())
384 } else {
385 let step_no = step.version as usize;
386 Err(OpenError::Migration {
387 step: step_no,
388 reason: format!("step {step_no} ({}): {failure}", step.name),
389 })
390 }
391 }
392
393 fn step_effect_present(conn: &rusqlite::Connection, step: &Step) -> Result<bool, OpenError> {
397 let step_no = step.version as usize;
398 match conn.query_row(step.verify_sql, [], |r| r.get::<_, i64>(0)) {
399 Ok(truthy) => Ok(truthy > 0),
400 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
401 Err(e) => Err(OpenError::Migration {
402 step: step_no,
403 reason: format!("step {step_no} ({}): verification query: {e}", step.name),
404 }),
405 }
406 }
407
408 fn read_stamp_sql() -> String {
409 format!("SELECT stamp FROM {STAMP_TABLE} WHERE id = 1")
410 }
411
412 fn write_stamp_sql() -> String {
413 format!("UPDATE {STAMP_TABLE} SET stamp = ?1 WHERE id = 1")
414 }
415
416 fn mark_stamp_sql() -> String {
417 format!("UPDATE {STAMP_TABLE} SET stamp = ?1 WHERE id = 1 AND stamp < ?1")
418 }
419
420 pub(crate) fn read_stamp(conn: &rusqlite::Connection) -> Result<i64, OpenError> {
421 conn.query_row(read_stamp_sql().as_str(), [], |r| r.get(0))
422 .map_err(|e| OpenError::Migration {
423 step: 0,
424 reason: format!("stamp read ({STAMP_TABLE}): {e}"),
425 })
426 }
427
428 #[cfg(test)]
429 pub(crate) fn presence_sql(table: &str) -> &'static str {
430 Box::leak(
431 format!(
432 "SELECT count(*) = 1 FROM sqlite_master \
433 WHERE type = 'table' AND name = '{table}'"
434 )
435 .into_boxed_str(),
436 )
437 }
438
439 #[cfg(test)]
440 pub(crate) fn step_probe(table: &str, name: &'static str, version: i64) -> Step {
441 Step {
442 version,
443 name,
444 sql: Box::leak(format!("CREATE TABLE {table} (x TEXT, y TEXT)").into_boxed_str()),
445 verify_sql: presence_sql(table),
446 }
447 }
448
449 #[cfg(test)]
450 pub(crate) fn failing_step_probe(table: &str, name: &'static str, version: i64) -> Step {
451 Step {
452 version,
453 name,
454 sql: Box::leak(
455 format!(
456 "CREATE TABLE {table} (x TEXT, y TEXT); CREATE TABLE {table} (x TEXT, y TEXT)"
457 )
458 .into_boxed_str(),
459 ),
460 verify_sql: presence_sql(table),
461 }
462 }
463}
464
465#[cfg(feature = "sqlite")]
466pub use sqlite_twin::CoTenantSqlite;
467
468#[cfg(feature = "postgres")]
469pub(crate) mod pg_twin {
470 use std::sync::Arc;
471
472 use alkstore_postgres::quote_identifier;
473 use tokio_postgres::NoTls;
474
475 use crate::error::OpenError;
476
477 pub const DEFAULT_SCHEMA: &str = "alkgit";
483
484 pub(crate) const STAMP_TABLE: &str = "alkgit_schema_stamp";
488
489 pub(crate) const STEP_VERSIONS: &[i64] = &[1];
493
494 fn validated_schema(schema: &str) -> Result<Arc<str>, OpenError> {
500 let schema = if schema.is_empty() {
501 DEFAULT_SCHEMA
502 } else {
503 schema
504 };
505 if schema.len() > 63
506 || schema.starts_with(|c: char| c.is_ascii_digit())
507 || schema
508 .chars()
509 .any(|c| c.is_control() || c == '"' || c == '\0')
510 {
511 return Err(OpenError::Invalid(format!(
512 "postgres co-tenant schema name is not a usable identifier: {schema:?}"
513 )));
514 }
515 Ok(Arc::from(schema))
516 }
517
518 pub(crate) fn qualify(schema: &str, table: &str) -> String {
521 format!("{}.{}", quote_identifier(schema), quote_identifier(table))
522 }
523
524 fn advisory_lock_key(scope: &str) -> i64 {
529 let mut hash: u64 = 0xcbf29ce484222325;
530 for byte in scope.as_bytes() {
531 hash ^= u64::from(*byte);
532 hash = hash.wrapping_mul(0x100000001b3);
533 }
534 hash as i64
535 }
536
537 fn migration_lock_key(schema: &str) -> i64 {
544 advisory_lock_key(&format!("{schema}\0{STAMP_TABLE}"))
545 }
546
547 pub(crate) fn git_refs_lock_key(schema: &str) -> i64 {
552 advisory_lock_key(&format!("{schema}\0git_refs"))
553 }
554
555 pub(crate) fn lfs_objects_lock_key(schema: &str) -> i64 {
560 advisory_lock_key(&format!("{schema}\0lfs_objects"))
561 }
562
563 #[derive(Clone)]
578 pub struct CoTenantPg {
579 client: Arc<tokio::sync::Mutex<tokio_postgres::Client>>,
580 schema: Arc<str>,
581 }
582
583 impl CoTenantPg {
584 pub async fn open(config: &str, schema: &str) -> Result<Self, OpenError> {
591 let schema = validated_schema(schema)?;
592 let cfg = config
593 .parse::<tokio_postgres::Config>()
594 .map_err(|e| OpenError::Engine(format!("co-tenant postgres config: {e}")))?;
595 let (client, conn) = cfg
596 .connect(NoTls)
597 .await
598 .map_err(|e| OpenError::Engine(format!("co-tenant postgres connect: {e}")))?;
599 tokio::spawn(async move {
600 let _ = conn.await;
601 });
602 let mut client = client;
603 migrate(&mut client, &schema).await?;
604 Ok(CoTenantPg {
605 client: Arc::new(tokio::sync::Mutex::new(client)),
606 schema,
607 })
608 }
609
610 pub fn schema(&self) -> &str {
613 &self.schema
614 }
615
616 pub async fn lock(&self) -> tokio::sync::MutexGuard<'_, tokio_postgres::Client> {
621 self.client.lock().await
622 }
623
624 pub async fn stamp(&self) -> Result<i64, OpenError> {
630 let read = format!(
631 "SELECT stamp FROM {} WHERE id = 1",
632 qualify(&self.schema, STAMP_TABLE)
633 );
634 let guard = self.client.lock().await;
635 stamp_of(&guard, &read).await
636 }
637
638 pub fn step_count() -> usize {
642 STEP_VERSIONS.len()
643 }
644 }
645
646 async fn stamp_of<R: tokio_postgres::ToStatement>(
649 client: &tokio_postgres::Client,
650 read: &R,
651 ) -> Result<i64, OpenError> {
652 client
653 .query_one(read, &[])
654 .await
655 .map_err(|e| OpenError::Migration {
656 step: 0,
657 reason: format!("stamp read ({STAMP_TABLE}): {e}"),
658 })
659 .and_then(|row| {
660 row.try_get::<_, i64>(0).map_err(|e| OpenError::Migration {
661 step: 0,
662 reason: format!("stamp read ({STAMP_TABLE}): {e}"),
663 })
664 })
665 }
666
667 pub(crate) struct PgStep {
673 pub(crate) version: i64,
674 name: &'static str,
675 sql: String,
676 tables: &'static [&'static str],
677 }
678
679 pub(crate) fn build_steps(schema: &str) -> Vec<PgStep> {
685 let q = |table: &str| qualify(schema, table);
686 vec![PgStep {
687 version: 1,
688 name:
689 "initial co-tenant schema (records, refs, seq, mutation markers, LFS liveness rows)",
690 sql: format!(
691 "CREATE TABLE {} (
692 repo_id TEXT PRIMARY KEY,
693 visibility TEXT NOT NULL CHECK (visibility IN ('public', 'private')),
694 grants TEXT NOT NULL,
695 seq BIGINT NOT NULL
696 );
697 CREATE TABLE {} (
698 repo_id TEXT NOT NULL,
699 name TEXT NOT NULL,
700 oid_hex TEXT,
701 symref_target TEXT,
702 PRIMARY KEY (repo_id, name)
703 );
704 CREATE TABLE {} (
705 id INTEGER PRIMARY KEY CHECK (id = 1),
706 value BIGINT NOT NULL
707 );
708 INSERT INTO {} (id, value) VALUES (1, 0);
709 CREATE TABLE {} (
710 repo_id TEXT PRIMARY KEY,
711 seq BIGINT NOT NULL
712 );
713 CREATE TABLE {} (
714 repo_id TEXT NOT NULL,
715 oid TEXT NOT NULL,
716 size BIGINT NOT NULL,
717 refcount BIGINT NOT NULL,
718 PRIMARY KEY (repo_id, oid)
719 );",
720 q("git_repos"),
721 q("git_refs"),
722 q("registry_seq"),
723 q("registry_seq"),
724 q("repo_mutation_markers"),
725 q("lfs_objects"),
726 ),
727 tables: &[
728 "git_repos",
729 "git_refs",
730 "registry_seq",
731 "repo_mutation_markers",
732 "lfs_objects",
733 ],
734 }]
735 }
736
737 async fn bootstrap_stamp_table(
745 client: &mut tokio_postgres::Client,
746 schema: &str,
747 ) -> Result<(), OpenError> {
748 let stamp_table = qualify(schema, STAMP_TABLE);
749 let bootstrap = format!(
750 "CREATE SCHEMA IF NOT EXISTS {};
751 CREATE TABLE IF NOT EXISTS {} (
752 id INTEGER PRIMARY KEY CHECK (id = 1),
753 stamp BIGINT NOT NULL
754 );
755 INSERT INTO {} (id, stamp) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;",
756 quote_identifier(schema),
757 stamp_table,
758 stamp_table,
759 );
760 let ctx = || format!("stamp-table bootstrap ({STAMP_TABLE})");
761 let tx = client
762 .transaction()
763 .await
764 .map_err(|e| OpenError::Migration {
765 step: 0,
766 reason: format!("{}: BEGIN: {e}", ctx()),
767 })?;
768 tx.execute(
769 "SELECT pg_advisory_xact_lock($1)",
770 &[&migration_lock_key(schema)],
771 )
772 .await
773 .map_err(|e| OpenError::Migration {
774 step: 0,
775 reason: format!("{}: {e}", ctx()),
776 })?;
777 match tx.batch_execute(&bootstrap).await {
778 Ok(()) => tx.commit().await.map_err(|e| OpenError::Migration {
779 step: 0,
780 reason: format!("{}: commit: {e}", ctx()),
781 }),
782 Err(e) => {
783 drop(tx);
784 if stamp_table_present(client, schema).await? {
785 return Ok(());
786 }
787 Err(OpenError::Migration {
788 step: 0,
789 reason: format!("{}: {e}", ctx()),
790 })
791 }
792 }
793 }
794
795 async fn stamp_table_present(
797 client: &tokio_postgres::Client,
798 schema: &str,
799 ) -> Result<bool, OpenError> {
800 client
801 .query_one(
802 "SELECT to_regclass($1) IS NOT NULL",
803 &[&format!("{schema}.{STAMP_TABLE}")],
804 )
805 .await
806 .map(|row| row.get::<_, bool>(0))
807 .map_err(|e| OpenError::Migration {
808 step: 0,
809 reason: format!("stamp-table presence query ({STAMP_TABLE}): {e}"),
810 })
811 }
812
813 async fn step_effect_present(
817 client: &tokio_postgres::Client,
818 schema: &str,
819 step: &PgStep,
820 ) -> Result<bool, OpenError> {
821 client
822 .query_one(
823 "SELECT (SELECT count(*) FROM pg_class c \
824 JOIN pg_namespace n ON n.oid = c.relnamespace \
825 WHERE n.nspname = $1 AND c.relname = ANY($2)) = $3",
826 &[&schema, &step.tables, &(step.tables.len() as i64)],
827 )
828 .await
829 .map(|row| row.get::<_, bool>(0))
830 .map_err(|e| OpenError::Migration {
831 step: step.version as usize,
832 reason: format!(
833 "step {} ({}): verification query: {e}",
834 step.version, step.name
835 ),
836 })
837 }
838
839 async fn migrate(client: &mut tokio_postgres::Client, schema: &str) -> Result<i64, OpenError> {
843 bootstrap_stamp_table(client, schema).await?;
844 for step in build_steps(schema) {
845 apply_step(client, schema, &step).await?;
846 }
847 let read = format!(
848 "SELECT stamp FROM {} WHERE id = 1",
849 qualify(schema, STAMP_TABLE)
850 );
851 client
852 .query_one(&read, &[])
853 .await
854 .map(|row| row.get::<_, i64>(0))
855 .map_err(|e| OpenError::Migration {
856 step: 0,
857 reason: format!("stamp read ({STAMP_TABLE}) in {schema}: {e}"),
858 })
859 }
860
861 async fn apply_step(
870 client: &mut tokio_postgres::Client,
871 schema: &str,
872 step: &PgStep,
873 ) -> Result<(), OpenError> {
874 let step_no = step.version as usize;
875 let stamp_read = format!(
876 "SELECT stamp FROM {} WHERE id = 1",
877 qualify(schema, STAMP_TABLE)
878 );
879 let stamp_advance = format!(
880 "UPDATE {} SET stamp = $1 WHERE id = 1",
881 qualify(schema, STAMP_TABLE)
882 );
883 let stamp_heal = format!(
884 "UPDATE {} SET stamp = $1 WHERE id = 1 AND stamp < $1",
885 qualify(schema, STAMP_TABLE)
886 );
887 let step_ctx = || format!("step {step_no} ({})", step.name);
888 let tx = client
889 .transaction()
890 .await
891 .map_err(|e| OpenError::Migration {
892 step: step_no,
893 reason: format!("{}: BEGIN: {e}", step_ctx()),
894 })?;
895 tx.execute(
896 "SELECT pg_advisory_xact_lock($1)",
897 &[&migration_lock_key(schema)],
898 )
899 .await
900 .map_err(|e| OpenError::Migration {
901 step: step_no,
902 reason: format!("{}: {e}", step_ctx()),
903 })?;
904 let stamp_now: i64 = tx
905 .query_one(&stamp_read, &[])
906 .await
907 .map_err(|e| OpenError::Migration {
908 step: step_no,
909 reason: format!("{}: stamp read: {e}", step_ctx()),
910 })?
911 .try_get(0)
912 .map_err(|e| OpenError::Migration {
913 step: step_no,
914 reason: format!("{}: stamp read: {e}", step_ctx()),
915 })?;
916 if stamp_now >= step.version {
917 tx.commit().await.ok();
918 return Ok(());
919 }
920 let outcome = tx.batch_execute(&step.sql).await;
921 let outcome = match outcome {
922 Ok(()) => tx.execute(&stamp_advance, &[&step.version]).await,
923 Err(e) => Err(e),
924 };
925 match outcome {
926 Ok(_) => tx.commit().await.map_err(|e| OpenError::Migration {
927 step: step_no,
928 reason: format!("{}: commit: {e}", step_ctx()),
929 }),
930 Err(e) => {
931 drop(tx);
932 if step_effect_present(client, schema, step).await? {
933 client
934 .execute(&stamp_heal, &[&step.version])
935 .await
936 .map_err(|e| OpenError::Migration {
937 step: step_no,
938 reason: format!("{}: race-swallow stamp heal: {e}", step_ctx()),
939 })?;
940 return Ok(());
941 }
942 Err(OpenError::Migration {
943 step: step_no,
944 reason: format!("{}: {e}", step_ctx()),
945 })
946 }
947 }
948 }
949}
950
951#[cfg(feature = "postgres")]
952pub use pg_twin::{CoTenantPg, DEFAULT_SCHEMA};
953
954#[cfg(feature = "postgres")]
955pub(crate) use pg_twin::{git_refs_lock_key, lfs_objects_lock_key, qualify};
956
957#[cfg(all(test, feature = "sqlite"))]
958mod tests {
959 use super::sqlite_twin::{
960 failing_step_probe, read_stamp, run_steps, step_probe, BUSY_TIMEOUT_MS,
961 STAMP_TABLE_BOOTSTRAP,
962 };
963 use super::*;
964 use crate::registry::{InMemoryRegistry, RegistryRead, RegistryWrite};
965 use crate::testutil::{mem_registry_pair, unique_db_path};
966
967 fn columns_of(conn: &rusqlite::Connection, table: &str) -> Vec<String> {
968 let mut stmt = conn
969 .prepare(&format!("PRAGMA table_info({table})"))
970 .unwrap();
971 stmt.query_map([], |r| r.get::<_, String>(1))
972 .unwrap()
973 .map(|c| c.unwrap())
974 .collect()
975 }
976
977 fn table_exists(conn: &rusqlite::Connection, table: &str) -> bool {
978 conn.query_row(
979 "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
980 [table],
981 |r| r.get::<_, i64>(0),
982 )
983 .unwrap()
984 > 0
985 }
986
987 #[test]
988 fn fresh_create_stamps_one_and_builds_the_full_co_tenant_schema() {
989 let path = unique_db_path("migrations", "fresh-create");
990 let handle = CoTenantSqlite::open(&path).unwrap();
991 assert_eq!(handle.stamp().unwrap(), 1);
992 assert_eq!(CoTenantSqlite::step_count(), 1);
993
994 let db = handle.lock();
995 assert_eq!(
996 db.query_row("PRAGMA busy_timeout", [], |r| r.get::<_, i64>(0))
997 .unwrap(),
998 BUSY_TIMEOUT_MS,
999 "the pinned busy timeout rides the co-tenant connection"
1000 );
1001 assert_eq!(
1002 db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
1003 .unwrap(),
1004 "wal"
1005 );
1006 assert_eq!(
1007 db.query_row("SELECT value FROM registry_seq WHERE id = 1", [], |r| r
1008 .get::<_, i64>(0))
1009 .unwrap(),
1010 0,
1011 "the watermark singleton seeds at 0 on a fresh store"
1012 );
1013 for table in [
1014 "git_repos",
1015 "git_refs",
1016 "registry_seq",
1017 "repo_mutation_markers",
1018 "lfs_objects",
1019 ] {
1020 assert!(table_exists(&db, table));
1021 }
1022 assert_eq!(
1023 columns_of(&db, "git_repos"),
1024 vec![
1025 "repo_id".to_string(),
1026 "visibility".to_string(),
1027 "grants".to_string(),
1028 "seq".to_string()
1029 ],
1030 "the record rows carry the RepoRecord serde shape's members plus the mutation seq"
1031 );
1032 assert_eq!(
1033 columns_of(&db, "git_refs"),
1034 vec![
1035 "repo_id".to_string(),
1036 "name".to_string(),
1037 "oid_hex".to_string(),
1038 "symref_target".to_string()
1039 ],
1040 "HEAD lands as the symref row shape: oid_hex NULL + symref_target set"
1041 );
1042 assert_eq!(
1043 columns_of(&db, "lfs_objects"),
1044 vec![
1045 "repo_id".to_string(),
1046 "oid".to_string(),
1047 "size".to_string(),
1048 "refcount".to_string()
1049 ],
1050 "the LFS liveness rows carry the day-one refcount column (ADR-024 §5)"
1051 );
1052 assert_eq!(
1053 columns_of(&db, "repo_mutation_markers"),
1054 vec!["repo_id".to_string(), "seq".to_string()],
1055 );
1056 drop(db);
1057
1058 let reopened = rusqlite::Connection::open(&path).expect("second opener on one file");
1059 assert_eq!(read_stamp(&reopened).unwrap(), 1, "the stamp is durable");
1060 assert!(
1061 table_exists(&reopened, "git_repos"),
1062 "the schema is durable"
1063 );
1064 drop(reopened);
1065 let _ = std::fs::remove_file(&path);
1066 }
1067
1068 #[test]
1069 fn fresh_create_pins_the_visibility_domain() {
1070 let path = unique_db_path("migrations", "visibility-check");
1071 let handle = CoTenantSqlite::open(&path).unwrap();
1072 let db = handle.lock();
1073 assert!(
1074 db.execute(
1075 "INSERT INTO git_repos (repo_id, visibility, grants, seq) \
1076 VALUES ('r', 'weird', '{}', 1)",
1077 []
1078 )
1079 .is_err(),
1080 "the visibility CHECK pins the record domain at the table"
1081 );
1082 drop(db);
1083 let _ = std::fs::remove_file(&path);
1084 }
1085
1086 #[test]
1087 fn additive_append_applies_step_two_then_is_idempotent() {
1088 let mut db = rusqlite::Connection::open_in_memory().unwrap();
1089 let steps = vec![
1090 step_probe("m_probe_one", "probe one", 1),
1091 step_probe("m_probe_two", "probe two — additive append", 2),
1092 ];
1093 let stamp = run_steps(&mut db, &steps).unwrap();
1094 assert_eq!(stamp, 2, "both steps applied and stamped");
1095 assert!(table_exists(&db, "m_probe_one"));
1096 assert!(table_exists(&db, "m_probe_two"));
1097
1098 let again = run_steps(&mut db, &steps).unwrap();
1099 assert_eq!(again, 2, "a re-run is a no-op — steps skip on the stamp");
1100 assert_eq!(
1101 db.query_row(
1102 "SELECT count(*) FROM alkgit_schema_stamp WHERE id = 1",
1103 [],
1104 |r| r.get::<_, i64>(0)
1105 )
1106 .unwrap(),
1107 1,
1108 "one stamp row, always"
1109 );
1110
1111 let appended = vec![step_probe(
1112 "m_probe_three",
1113 "probe three — appended later",
1114 3,
1115 )];
1116 let stamp = run_steps(&mut db, &appended).unwrap();
1117 assert_eq!(stamp, 3, "an additive append advances the stamp one step");
1118 assert!(table_exists(&db, "m_probe_three"));
1119 }
1120
1121 #[test]
1122 fn concurrent_open_race_on_one_file_swallows_the_benign_race() {
1123 let path = unique_db_path("migrations", "concurrent-open");
1124 let stamps = std::thread::scope(|scope| {
1125 let mut joins = Vec::new();
1126 for _ in 0..4 {
1127 joins.push(scope.spawn(|| {
1128 CoTenantSqlite::open(&path)
1129 .map(|handle| handle.stamp().expect("stamped handle reads"))
1130 }));
1131 }
1132 joins
1133 .into_iter()
1134 .map(|join| join.join().expect("no opener thread panics"))
1135 .collect::<Vec<_>>()
1136 });
1137 for stamp in &stamps {
1138 assert_eq!(
1139 stamp.as_ref().expect("every opener succeeds"),
1140 &1,
1141 "the benign race is swallowed — all openers converge on the stamped schema"
1142 );
1143 }
1144 let _ = std::fs::remove_file(&path);
1145 }
1146
1147 #[test]
1148 fn a_pre_applied_effect_swallows_and_heals_the_stamp() {
1149 let mut db = rusqlite::Connection::open_in_memory().unwrap();
1150 db.execute_batch(STAMP_TABLE_BOOTSTRAP).unwrap();
1151 db.execute("CREATE TABLE m_probe_one (x TEXT, y TEXT)", [])
1152 .unwrap();
1153 assert_eq!(
1154 read_stamp(&db).unwrap(),
1155 0,
1156 "the concurrent applier's effect is present, its stamp write not landed"
1157 );
1158
1159 let steps = vec![step_probe("m_probe_one", "probe one", 1)];
1160 run_steps(&mut db, &steps).unwrap();
1161 assert_eq!(
1162 read_stamp(&db).unwrap(),
1163 1,
1164 "the race swallowed on the presence re-key and healed the stamp"
1165 );
1166 assert!(table_exists(&db, "m_probe_one"));
1167
1168 let again = run_steps(&mut db, &steps).unwrap();
1169 assert_eq!(again, 1, "the healed store is idempotent on reopen");
1170 }
1171
1172 #[test]
1173 fn a_failing_step_fails_loudly_and_propagates() {
1174 let path = unique_db_path("migrations", "loud-failure");
1175 let mut db = rusqlite::Connection::open(&path).unwrap();
1176 let steps = vec![
1177 step_probe("m_probe_good", "probe good", 1),
1178 failing_step_probe("m_probe_bad", "probe bad — fails inside its transaction", 2),
1179 step_probe("m_probe_after", "probe after — never reached", 3),
1180 ];
1181 let err = run_steps(&mut db, &steps).unwrap_err();
1182 assert!(matches!(err, OpenError::Migration { step: 2, .. }));
1183 match &err {
1184 OpenError::Migration { reason, .. } => {
1185 assert!(reason.contains("already exists"), "{reason}");
1186 }
1187 other => panic!("unexpected variant: {other:?}"),
1188 }
1189 assert_eq!(
1190 read_stamp(&db).unwrap(),
1191 1,
1192 "step 1 committed, step 2 did not"
1193 );
1194 assert!(table_exists(&db, "m_probe_good"));
1195 assert!(
1196 !table_exists(&db, "m_probe_bad"),
1197 "the failed tx rolled back whole"
1198 );
1199 assert!(!table_exists(&db, "m_probe_after"), "later steps never ran");
1200 drop(db);
1201
1202 let mut retry = rusqlite::Connection::open(&path).unwrap();
1203 let retry_err = run_steps(&mut retry, &steps).expect_err("the run fails again");
1204 assert!(
1205 matches!(retry_err, OpenError::Migration { step: 2, .. }),
1206 "the half-migrated store refuses service again — loud on every construction"
1207 );
1208 drop(retry);
1209 let _ = std::fs::remove_file(&path);
1210 }
1211
1212 #[test]
1213 fn an_unknown_forward_stamp_is_safe_to_open_against() {
1214 let path = unique_db_path("migrations", "forward-stamp");
1215 let handle = CoTenantSqlite::open(&path).unwrap();
1216 {
1217 let db = handle.lock();
1218 db.execute("UPDATE alkgit_schema_stamp SET stamp = 99 WHERE id = 1", [])
1219 .unwrap();
1220 }
1221 let reopened = CoTenantSqlite::open(&path).unwrap();
1222 assert_eq!(
1223 reopened.stamp().unwrap(),
1224 99,
1225 "no refuse-gate ships in v1 — the forward stamp is left untouched"
1226 );
1227 assert!(
1228 table_exists(&reopened.lock(), "git_repos"),
1229 "the schema is intact"
1230 );
1231
1232 let mut db = rusqlite::Connection::open_in_memory().unwrap();
1233 let steps = vec![step_probe("m_probe_one", "probe one", 1)];
1234 run_steps(&mut db, &steps).unwrap();
1235 db.execute("UPDATE alkgit_schema_stamp SET stamp = 42 WHERE id = 1", [])
1236 .unwrap();
1237 let forward_steps = vec![
1238 step_probe("m_probe_two", "probe two", 2),
1239 step_probe("m_probe_three", "probe three", 3),
1240 ];
1241 let stamp = run_steps(&mut db, &forward_steps).unwrap();
1242 assert_eq!(stamp, 42, "pending steps skip on the forward stamp");
1243 assert!(
1244 !table_exists(&db, "m_probe_two"),
1245 "nothing applied ahead of the stamp"
1246 );
1247 drop(handle);
1248 let _ = std::fs::remove_file(&path);
1249 }
1250
1251 #[test]
1252 fn memory_mode_is_rejected_at_the_handle() {
1253 assert!(
1254 matches!(CoTenantSqlite::open(":memory:"), Err(OpenError::Invalid(_))),
1255 ":memory: confers no co-tenancy — the handle rejects the shape (DQ-7)"
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn in_memory_adapter_is_stamp_free_fresh_on_every_construction() {
1261 let record = alkgit::RepoRecord {
1262 repo_id: "a".to_string(),
1263 visibility: alkgit::Visibility::Public,
1264 grants: Default::default(),
1265 };
1266
1267 let first = InMemoryRegistry::new();
1268 assert_eq!(first.put(&record).await.unwrap(), 1);
1269
1270 let second = InMemoryRegistry::new();
1271 assert_eq!(
1272 second.resolve("a"),
1273 None,
1274 "a fresh construction builds the current state empty — nothing durable is versioned"
1275 );
1276 assert_eq!(second.watermark().await.unwrap(), 0);
1277
1278 let (_, store_one) = mem_registry_pair();
1279 let (_, store_two) = mem_registry_pair();
1280 store_one.put_repo(&record).await.unwrap();
1281 assert_eq!(
1282 store_two.resolve("a").await.unwrap_err(),
1283 alkgit::RegistryError::NotFound,
1284 "two in-memory constructions are independent stores, not two views of one"
1285 );
1286 }
1287}