From 6f9241e346ff16f047d03efe5dfc0e18c46b8a51 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 12:55:41 -0400 Subject: [PATCH] feat(db): port git repo reservations to SQLite Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 34 +++++++- crates/buzz-db/src/sqlite.rs | 153 ++++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 6 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 7a58d96f1..7a1e754e3 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4928,7 +4928,12 @@ impl Db { community: CommunityId, repo_id: &str, ) -> Result> { - git_repo::repo_name_owner(self.pg_pool()?, community, repo_id).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::repo_name_owner(pool, community, repo_id).await, + DbBackend::Postgres => { + git_repo::repo_name_owner(self.pg_pool()?, community, repo_id).await + } + } } /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). @@ -4941,7 +4946,14 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::reserve_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::reserve_repo_name(pool, community, repo_id, owner_pubkey).await + } + DbBackend::Postgres => { + git_repo::reserve_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + } + } } /// Count git repos reserved by `owner_pubkey` in `community` (quota check). @@ -4950,7 +4962,14 @@ impl Db { community: CommunityId, owner_pubkey: &str, ) -> Result { - git_repo::count_repos_for_owner(self.pg_pool()?, community, owner_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::count_repos_for_owner(pool, community, owner_pubkey).await + } + DbBackend::Postgres => { + git_repo::count_repos_for_owner(self.pg_pool()?, community, owner_pubkey).await + } + } } /// Release a git repo name reservation held by `owner_pubkey` (rollback). @@ -4962,7 +4981,14 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::release_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::release_repo_name(pool, community, repo_id, owner_pubkey).await + } + DbBackend::Postgres => { + git_repo::release_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + } + } } /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index 0bbb6063f..27b970fc7 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -230,7 +230,32 @@ pub(crate) async fn migrate(pool: &SqlitePool) -> Result<()> { tx.commit().await?; version = 4; } - if version != 4 { + if version < 5 { + let mut tx = pool.begin().await?; + for statement in [ + "DROP TRIGGER IF EXISTS events_fts_insert", + "DROP TRIGGER IF EXISTS events_fts_delete", + "DROP TRIGGER IF EXISTS events_fts_update", + "DROP TABLE IF EXISTS events_fts", + "DROP VIEW IF EXISTS searchable_events", + "CREATE VIEW searchable_events AS SELECT rowid, content FROM events WHERE kind IN (0, 9, 40002, 45001, 45003)", + "CREATE VIRTUAL TABLE events_fts USING fts5(content, content='searchable_events', content_rowid='rowid', tokenize='unicode61')", + "INSERT INTO events_fts(events_fts) VALUES ('rebuild')", + "CREATE TRIGGER events_fts_insert AFTER INSERT ON events WHEN new.kind IN (0, 9, 40002, 45001, 45003) BEGIN INSERT INTO events_fts(rowid, content) VALUES (new.rowid, new.content); END", + "CREATE TRIGGER events_fts_delete AFTER DELETE ON events WHEN old.kind IN (0, 9, 40002, 45001, 45003) BEGIN INSERT INTO events_fts(events_fts, rowid, content) VALUES ('delete', old.rowid, old.content); END", + "CREATE TRIGGER events_fts_update AFTER UPDATE OF content, kind ON events BEGIN INSERT INTO events_fts(events_fts, rowid, content) SELECT 'delete', old.rowid, old.content WHERE old.kind IN (0, 9, 40002, 45001, 45003); INSERT INTO events_fts(rowid, content) SELECT new.rowid, new.content WHERE new.kind IN (0, 9, 40002, 45001, 45003); END", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_dm_hash ON channels (community_id, participant_hash) WHERE channel_type = 'dm' AND deleted_at IS NULL", + "CREATE TABLE IF NOT EXISTS git_repo_names (community_id TEXT NOT NULL, repo_id TEXT NOT NULL, owner_pubkey TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (community_id, repo_id), FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE)", + ] { + sqlx::query(statement).execute(&mut *tx).await?; + } + sqlx::query("UPDATE schema_version SET version = 5 WHERE singleton = 1") + .execute(&mut *tx) + .await?; + tx.commit().await?; + version = 5; + } + if version != 5 { return Err(crate::DbError::InvalidData(format!( "unsupported SQLite schema version {version}" ))); @@ -1017,6 +1042,68 @@ pub(crate) async fn list_hidden_dms( .collect() } +pub(crate) async fn repo_name_owner( + pool: &SqlitePool, + community: CommunityId, + repo_id: &str, +) -> Result> { + Ok(sqlx::query_scalar( + "SELECT owner_pubkey FROM git_repo_names WHERE community_id = ?1 AND repo_id = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind(repo_id) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn reserve_repo_name( + pool: &SqlitePool, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, +) -> Result { + let inserted = sqlx::query("INSERT INTO git_repo_names (community_id,repo_id,owner_pubkey) VALUES (?1,?2,?3) ON CONFLICT (community_id,repo_id) DO NOTHING") + .bind(community.as_uuid().to_string()).bind(repo_id).bind(owner_pubkey).execute(pool).await?.rows_affected(); + if inserted != 0 { + return Ok(crate::git_repo::ReserveOutcome::Reserved); + } + match repo_name_owner(pool, community, repo_id).await? { + Some(holder) if holder == owner_pubkey => Ok(crate::git_repo::ReserveOutcome::AlreadyOwned), + _ => Ok(crate::git_repo::ReserveOutcome::TakenByOther), + } +} + +pub(crate) async fn count_repos_for_owner( + pool: &SqlitePool, + community: CommunityId, + owner_pubkey: &str, +) -> Result { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM git_repo_names WHERE community_id = ?1 AND owner_pubkey = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind(owner_pubkey) + .fetch_one(pool) + .await?) +} + +pub(crate) async fn release_repo_name( + pool: &SqlitePool, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, +) -> Result { + Ok(sqlx::query( + "DELETE FROM git_repo_names WHERE community_id = ?1 AND repo_id = ?2 AND owner_pubkey = ?3", + ) + .bind(community.as_uuid().to_string()) + .bind(repo_id) + .bind(owner_pubkey) + .execute(pool) + .await? + .rows_affected()) +} + pub(crate) async fn is_archived( pool: &SqlitePool, community: CommunityId, @@ -1642,7 +1729,7 @@ mod tests { .fetch_one(&upgraded) .await .unwrap(), - 4 + 5 ); for (table, column) in [ ("channels", "participant_hash"), @@ -1732,6 +1819,68 @@ mod tests { .is_err()); } + #[tokio::test] + async fn git_repo_names_are_scoped_idempotent_and_owner_released() { + let pool = connect("sqlite::memory:").await.unwrap(); + let a = ensure_configured_community(&pool, "git-a.example") + .await + .unwrap() + .id; + let b = ensure_configured_community(&pool, "git-b.example") + .await + .unwrap() + .id; + assert_eq!( + reserve_repo_name(&pool, a, "project", "alice") + .await + .unwrap(), + crate::git_repo::ReserveOutcome::Reserved + ); + assert_eq!( + reserve_repo_name(&pool, a, "project", "alice") + .await + .unwrap(), + crate::git_repo::ReserveOutcome::AlreadyOwned + ); + assert_eq!( + reserve_repo_name(&pool, a, "project", "bob").await.unwrap(), + crate::git_repo::ReserveOutcome::TakenByOther + ); + assert_eq!( + reserve_repo_name(&pool, b, "project", "bob").await.unwrap(), + crate::git_repo::ReserveOutcome::Reserved + ); + assert_eq!( + repo_name_owner(&pool, a, "project") + .await + .unwrap() + .as_deref(), + Some("alice") + ); + assert_eq!(count_repos_for_owner(&pool, a, "alice").await.unwrap(), 1); + assert_eq!( + release_repo_name(&pool, a, "project", "bob").await.unwrap(), + 0 + ); + assert_eq!( + release_repo_name(&pool, a, "project", "alice") + .await + .unwrap(), + 1 + ); + assert!(repo_name_owner(&pool, a, "project") + .await + .unwrap() + .is_none()); + assert_eq!( + repo_name_owner(&pool, b, "project") + .await + .unwrap() + .as_deref(), + Some("bob") + ); + } + #[tokio::test] async fn archived_identities_are_idempotent_and_community_scoped() { let pool = connect("sqlite::memory:").await.unwrap();