···1313PDS_HOSTNAME=localhost:3000
1414PLC_URL=plc.directory
15151616+# A comma-separated list of WebSocket URLs for firehose relays to push updates to.
1717+# e.g., RELAYS=wss://relay.bsky.social,wss://another-relay.com
1818+RELAYS=
1919+1620# Notification Service Configuration
1721# At least one notification channel should be configured for user notifications to work.
1822# Email notifications (via sendmail/msmtp)
···11+CREATE TABLE repo_seq (
22+ seq BIGSERIAL PRIMARY KEY,
33+ did TEXT NOT NULL,
44+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
55+ event_type TEXT NOT NULL,
66+ commit_cid TEXT,
77+ prev_cid TEXT,
88+ ops JSONB,
99+ blobs TEXT[]
1010+);
1111+1212+CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
1313+CREATE INDEX idx_repo_seq_did ON repo_seq(did);
···11+use crate::api::repo::record::utils::{commit_and_log, RecordOp};
22+use crate::api::repo::record::write::prepare_repo_write;
33+use crate::repo::tracking::TrackingBlockStore;
14use crate::state::AppState;
25use axum::{
33- Json,
46 extract::State,
55- http::StatusCode,
77+ http::{HeaderMap, StatusCode},
68 response::{IntoResponse, Response},
99+ Json,
710};
811use cid::Cid;
99-use jacquard::types::{
1010- did::Did,
1111- integer::LimitedU32,
1212- string::{Nsid, Tid},
1313-};
1212+use jacquard::types::string::Nsid;
1413use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
1514use serde::Deserialize;
1615use serde_json::json;
···31303231pub async fn delete_record(
3332 State(state): State<AppState>,
3434- headers: axum::http::HeaderMap,
3333+ headers: HeaderMap,
3534 Json(input): Json<DeleteRecordInput>,
3635) -> Response {
3737- let auth_header = headers.get("Authorization");
3838- if auth_header.is_none() {
3939- return (
4040- StatusCode::UNAUTHORIZED,
4141- Json(json!({"error": "AuthenticationRequired"})),
4242- )
4343- .into_response();
4444- }
4545- let token = auth_header
4646- .unwrap()
4747- .to_str()
4848- .unwrap_or("")
4949- .replace("Bearer ", "");
5050-5151- let session = sqlx::query!(
5252- "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
5353- token
5454- )
5555- .fetch_optional(&state.db)
5656- .await
5757- .unwrap_or(None);
3636+ let (did, user_id, current_root_cid) =
3737+ match prepare_repo_write(&state, &headers, &input.repo).await {
3838+ Ok(res) => res,
3939+ Err(err_res) => return err_res,
4040+ };
58415959- let (did, key_bytes) = match session {
6060- Some(row) => (
6161- row.did,
6262- row.key_bytes,
6363- ),
6464- None => {
4242+ if let Some(swap_commit) = &input.swap_commit {
4343+ if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
6544 return (
6666- StatusCode::UNAUTHORIZED,
6767- Json(json!({"error": "AuthenticationFailed"})),
4545+ StatusCode::CONFLICT,
4646+ Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
6847 )
6948 .into_response();
7049 }
7171- };
7272-7373- if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
7474- return (
7575- StatusCode::UNAUTHORIZED,
7676- Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
7777- )
7878- .into_response();
7979- }
8080-8181- if input.repo != did {
8282- return (StatusCode::FORBIDDEN, Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"}))).into_response();
8350 }
84518585- let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
8686- .fetch_optional(&state.db)
8787- .await;
8888-8989- let user_id: uuid::Uuid = match user_query {
9090- Ok(Some(row)) => row.id,
9191- _ => {
9292- return (
9393- StatusCode::INTERNAL_SERVER_ERROR,
9494- Json(json!({"error": "InternalError", "message": "User not found"})),
9595- )
9696- .into_response();
9797- }
9898- };
9999-100100- let repo_root_query = sqlx::query!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
101101- .fetch_optional(&state.db)
102102- .await;
103103-104104- let current_root_cid = match repo_root_query {
105105- Ok(Some(row)) => {
106106- let cid_str: String = row.repo_root_cid;
107107- Cid::from_str(&cid_str).ok()
108108- }
109109- _ => None,
110110- };
5252+ let tracking_store = TrackingBlockStore::new(state.block_store.clone());
11153112112- if current_root_cid.is_none() {
113113- return (
114114- StatusCode::INTERNAL_SERVER_ERROR,
115115- Json(json!({"error": "InternalError", "message": "Repo root not found"})),
116116- )
117117- .into_response();
118118- }
119119- let current_root_cid = current_root_cid.unwrap();
120120-121121- let commit_bytes = match state.block_store.get(¤t_root_cid).await {
5454+ let commit_bytes = match tracking_store.get(¤t_root_cid).await {
12255 Ok(Some(b)) => b,
123123- Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
124124- Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": format!("Failed to load commit block: {:?}", e)}))).into_response(),
5656+ _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
12557 };
126126-12758 let commit = match Commit::from_cbor(&commit_bytes) {
12859 Ok(c) => c,
129129- Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": format!("Failed to parse commit: {:?}", e)}))).into_response(),
6060+ _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
13061 };
13162132132- let mst_root = commit.data;
133133- let store = Arc::new(state.block_store.clone());
134134- let mst = Mst::load(store.clone(), mst_root, None);
135135-6363+ let mst = Mst::load(
6464+ Arc::new(tracking_store.clone()),
6565+ commit.data,
6666+ None,
6767+ );
13668 let collection_nsid = match input.collection.parse::<Nsid>() {
13769 Ok(n) => n,
138138- Err(_) => {
139139- return (
140140- StatusCode::BAD_REQUEST,
141141- Json(json!({"error": "InvalidCollection"})),
142142- )
143143- .into_response();
144144- }
7070+ Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
14571 };
146146-14772 let key = format!("{}/{}", collection_nsid, input.rkey);
14873149149- // TODO: Check swapRecord if provided? Skipping for brevity/robustness
7474+ if let Some(swap_record_str) = &input.swap_record {
7575+ let expected_cid = Cid::from_str(swap_record_str).ok();
7676+ let actual_cid = mst.get(&key).await.ok().flatten();
7777+ if expected_cid != actual_cid {
7878+ return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response();
7979+ }
8080+ }
8181+8282+ if mst.get(&key).await.ok().flatten().is_none() {
8383+ return (StatusCode::OK, Json(json!({}))).into_response();
8484+ }
1508515186 let new_mst = match mst.delete(&key).await {
15287 Ok(m) => m,
···16095 Ok(c) => c,
16196 Err(e) => {
16297 error!("Failed to persist MST: {:?}", e);
163163- return (
164164- StatusCode::INTERNAL_SERVER_ERROR,
165165- Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
166166- )
167167- .into_response();
9898+ return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response();
16899 }
169100 };
170101171171- let did_obj = match Did::new(&did) {
172172- Ok(d) => d,
173173- Err(_) => {
174174- return (
175175- StatusCode::INTERNAL_SERVER_ERROR,
176176- Json(json!({"error": "InternalError", "message": "Invalid DID"})),
177177- )
178178- .into_response();
179179- }
180180- };
102102+ let op = RecordOp::Delete { collection: input.collection, rkey: input.rkey };
103103+ let written_cids = tracking_store.get_written_cids();
104104+ let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
181105182182- let rev = Tid::now(LimitedU32::MIN);
183183-184184- let new_commit = Commit::new_unsigned(did_obj, new_mst_root, rev, Some(current_root_cid));
185185-186186- let new_commit_bytes =
187187- match new_commit.to_cbor() {
188188- Ok(b) => b,
189189- Err(_e) => return (
190190- StatusCode::INTERNAL_SERVER_ERROR,
191191- Json(
192192- json!({"error": "InternalError", "message": "Failed to serialize new commit"}),
193193- ),
194194- )
195195- .into_response(),
196196- };
197197-198198- let new_root_cid = match state.block_store.put(&new_commit_bytes).await {
199199- Ok(c) => c,
200200- Err(_e) => {
201201- return (
202202- StatusCode::INTERNAL_SERVER_ERROR,
203203- Json(json!({"error": "InternalError", "message": "Failed to save new commit"})),
204204- )
205205- .into_response();
206206- }
106106+ if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), new_mst_root, vec![op], &written_cids_str).await {
107107+ return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
207108 };
208208-209209- let update_repo = sqlx::query!("UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), user_id)
210210- .execute(&state.db)
211211- .await;
212212-213213- if let Err(e) = update_repo {
214214- error!("Failed to update repo root in DB: {:?}", e);
215215- return (
216216- StatusCode::INTERNAL_SERVER_ERROR,
217217- Json(json!({"error": "InternalError", "message": "Failed to update repo root in DB"})),
218218- )
219219- .into_response();
220220- }
221221-222222- let record_delete =
223223- sqlx::query!("DELETE FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3", user_id, input.collection, input.rkey)
224224- .execute(&state.db)
225225- .await;
226226-227227- if let Err(e) = record_delete {
228228- error!("Error deleting record index: {:?}", e);
229229- }
230109231110 (StatusCode::OK, Json(json!({}))).into_response()
232111}
+3-1
src/api/repo/record/mod.rs
···11pub mod batch;
22pub mod delete;
33pub mod read;
44+pub mod utils;
45pub mod write;
5667pub use batch::apply_writes;
78pub use delete::{DeleteRecordInput, delete_record};
89pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
1010+pub use utils::*;
911pub use write::{
1012 CreateRecordInput, CreateRecordOutput, PutRecordInput, PutRecordOutput, create_record,
1111- put_record,
1313+ put_record, prepare_repo_write,
1214};
···11+use crate::state::AppState;
22+use crate::sync::firehose::SequencedEvent;
33+use sqlx::postgres::PgListener;
44+use tracing::{error, info, warn};
55+66+pub async fn start_sequencer_listener(state: AppState) {
77+ tokio::spawn(async move {
88+ info!("Starting sequencer listener background task");
99+ loop {
1010+ if let Err(e) = listen_loop(state.clone()).await {
1111+ error!("Sequencer listener failed: {}. Restarting in 5s...", e);
1212+ tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
1313+ }
1414+ }
1515+ });
1616+}
1717+1818+async fn listen_loop(state: AppState) -> anyhow::Result<()> {
1919+ let mut listener = PgListener::connect_with(&state.db).await?;
2020+ listener.listen("repo_updates").await?;
2121+ info!("Connected to Postgres and listening for 'repo_updates'");
2222+2323+ loop {
2424+ let notification = listener.recv().await?;
2525+ let payload = notification.payload();
2626+2727+ let seq_id: i64 = match payload.parse() {
2828+ Ok(id) => id,
2929+ Err(e) => {
3030+ warn!("Received invalid payload in repo_updates: '{}'. Error: {}", payload, e);
3131+ continue;
3232+ }
3333+ };
3434+3535+ let event = sqlx::query_as!(
3636+ SequencedEvent,
3737+ r#"
3838+ SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids
3939+ FROM repo_seq
4040+ WHERE seq = $1
4141+ "#,
4242+ seq_id
4343+ )
4444+ .fetch_optional(&state.db)
4545+ .await?;
4646+4747+ if let Some(event) = event {
4848+ let _ = state.firehose_tx.send(event);
4949+ } else {
5050+ warn!("Received notification for seq {} but could not find row in repo_seq", seq_id);
5151+ }
5252+ }
5353+}
+8-1
src/sync/mod.rs
···22pub mod car;
33pub mod commit;
44pub mod crawl;
55+pub mod firehose;
66+pub mod frame;
77+pub mod listener;
88+pub mod relay_client;
59pub mod repo;
1010+pub mod subscribe_repos;
1111+pub mod util;
612713pub use blob::{get_blob, list_blobs};
814pub use commit::{get_latest_commit, get_repo_status, list_repos};
915pub use crawl::{notify_of_update, request_crawl};
1010-pub use repo::{get_blocks, get_record, get_repo};
1616+pub use repo::{get_blocks, get_repo, get_record};
1717+pub use subscribe_repos::subscribe_repos;