···11+-- Create logs table for sync jobs and jetstream activity
22+CREATE TABLE logs (
33+ id BIGSERIAL PRIMARY KEY,
44+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
55+ log_type VARCHAR(50) NOT NULL, -- 'sync_job', 'jetstream', etc.
66+ job_id UUID NULL, -- For sync job logs, null for jetstream logs
77+ user_did TEXT NULL, -- User associated with the log (for filtering)
88+ slice_uri TEXT NULL, -- Slice associated with the log (for filtering)
99+ level VARCHAR(20) NOT NULL DEFAULT 'info', -- 'debug', 'info', 'warn', 'error'
1010+ message TEXT NOT NULL,
1111+ metadata JSONB NULL -- Additional structured data (counts, errors, etc.)
1212+);
1313+1414+-- Create indexes for efficient queries
1515+CREATE INDEX idx_logs_type_job_id ON logs (log_type, job_id);
1616+CREATE INDEX idx_logs_type_created_at ON logs (log_type, created_at);
1717+CREATE INDEX idx_logs_user_did ON logs (user_did);
1818+CREATE INDEX idx_logs_slice_uri ON logs (slice_uri);
1919+2020+-- Add some helpful comments
2121+COMMENT ON TABLE logs IS 'Unified logging table for sync jobs, jetstream, and other system activities';
2222+COMMENT ON COLUMN logs.log_type IS 'Type of log entry: sync_job, jetstream, system, etc.';
2323+COMMENT ON COLUMN logs.job_id IS 'Associated job ID for sync job logs, null for other log types';
2424+COMMENT ON COLUMN logs.level IS 'Log level: debug, info, warn, error';
2525+COMMENT ON COLUMN logs.metadata IS 'Additional structured data as JSON (progress, errors, counts, etc.)';
···11+-- Change record table to use composite primary key (uri, slice_uri)
22+-- This allows the same record to exist in multiple slices
33+44+-- First, drop the existing primary key constraint
55+ALTER TABLE record DROP CONSTRAINT record_pkey;
66+77+-- Add the new composite primary key
88+ALTER TABLE record ADD CONSTRAINT record_pkey PRIMARY KEY (uri, slice_uri);
99+1010+-- Update the unique index on URI to be non-unique since URIs can now appear multiple times
1111+-- (The existing idx_record_* indexes should still work fine for queries)
···2323 StatusCode::INTERNAL_SERVER_ERROR => "Internal server error",
2424 _ => "Request failed",
2525 };
2626-2626+2727 (status, Json(serde_json::json!({
2828 "error": status.as_str(),
2929 "message": message
···3737 pub uri: String,
3838 pub slice: String,
3939}
4040-4141-4242-4343-44404541// Dynamic XRPC handler that routes based on method name (for GET requests)
4642pub async fn dynamic_xrpc_handler(
···10399 .and_then(|v| v.as_str())
104100 .ok_or(StatusCode::BAD_REQUEST)?
105101 .to_string();
106106-102102+107103 let limit = params.get("limit").and_then(|v| {
108104 if let Some(s) = v.as_str() {
109105 s.parse::<i32>().ok()
···111107 v.as_i64().map(|i| i as i32)
112108 }
113109 });
114114-110110+115111 let cursor = params.get("cursor")
116112 .and_then(|v| v.as_str())
117113 .map(|s| s.to_string());
118118-114114+119115 // Parse sortBy from params - convert legacy sort string to new array format if present
120116 let sort_by = params.get("sort")
121117 .and_then(|v| v.as_str())
···139135 }
140136 sort_fields
141137 });
142142-138138+143139 // Parse where conditions from query params if present
144140 let mut where_conditions = HashMap::new();
145145-141141+146142 // Handle legacy author/authors params by converting to where clause
147143 if let Some(author_str) = params.get("author").and_then(|v| v.as_str()) {
148144 where_conditions.insert("did".to_string(), WhereCondition {
···161157 contains: None,
162158 });
163159 }
164164-160160+165161 // Handle legacy query param by converting to where clause with contains
166162 if let Some(query_str) = params.get("query").and_then(|v| v.as_str()) {
167163 let field = params.get("field")
···173169 contains: Some(query_str.to_string()),
174170 });
175171 }
176176-172172+177173 let where_clause = if where_conditions.is_empty() {
178174 None
179175 } else {
···182178 or_conditions: None,
183179 })
184180 };
185185-181181+186182 let records_params = SliceRecordsParams {
187183 slice,
188184 limit,
···190186 where_clause,
191187 sort_by: sort_by.clone(),
192188 };
193193-189189+194190 // First verify the collection belongs to this slice
195191 let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
196192 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
197197-193193+198194 // Special handling: social.slices.lexicon is always allowed as it defines the schema
199195 if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
200196 return Err(StatusCode::NOT_FOUND);
201197 }
202202-198198+203199 // Use the unified database method
204200 match state.database.get_slice_collections_records(
205201 &records_params.slice,
···211207 Ok((mut records, cursor)) => {
212208 // Filter records to only include the specific collection
213209 records.retain(|record| record.collection == collection);
214214-210210+215211 let indexed_records: Vec<IndexedRecord> = records.into_iter().map(|record| IndexedRecord {
216212 uri: record.uri,
217213 cid: record.cid,
···220216 value: record.json,
221217 indexed_at: record.indexed_at.to_rfc3339(),
222218 }).collect();
223223-219219+224220 let output = SliceRecordsOutput {
225221 success: true,
226222 records: indexed_records,
227223 cursor,
228224 message: None,
229225 };
230230-226226+231227 Ok(Json(serde_json::to_value(output)
232228 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?))
233229 },
···249245 // First verify the collection belongs to this slice
250246 let slice_collections = state.database.get_slice_collections_list(&get_params.slice).await
251247 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
252252-248248+253249 // Special handling: social.slices.lexicon is always allowed as it defines the schema
254250 if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
255251 return Err(StatusCode::NOT_FOUND);
···284280 // First verify the collection belongs to this slice
285281 let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
286282 .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?;
287287-283283+288284 // Special handling: social.slices.lexicon is always allowed as it defines the schema
289285 if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
290286 return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"}))));
291287 }
292292-288288+293289 // Add collection filter to where conditions
294290 let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
295291 conditions: HashMap::new(),
···301297 contains: None,
302298 });
303299 records_params.where_clause = Some(where_clause);
304304-300300+305301 // Use the unified database method
306302 match state.database.get_slice_collections_records(
307303 &records_params.slice,
···312308 ).await {
313309 Ok((records, cursor)) => {
314310 // No need to filter - collection filter is in the SQL query now
315315-311311+316312 // Transform Record to IndexedRecord for the response
317313 let indexed_records: Vec<IndexedRecord> = records.into_iter().map(|record| IndexedRecord {
318314 uri: record.uri,
···329325 cursor,
330326 message: None,
331327 };
332332-328328+333329 Ok(Json(serde_json::to_value(output)
334330 .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization error"}))))?))
335331 },
···350346 // First verify the collection belongs to this slice
351347 let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
352348 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
353353-349349+354350 // Special handling: social.slices.lexicon is always allowed as it defines the schema
355351 if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
356352 return Err(StatusCode::NOT_FOUND);
357353 }
358358-354354+359355 // Add collection filter to where conditions
360356 let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
361357 conditions: HashMap::new(),
···395391 // First verify the collection belongs to this slice
396392 let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
397393 .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?;
398398-394394+399395 // Special handling: social.slices.lexicon is always allowed as it defines the schema
400396 if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
401397 return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"}))));
402398 }
403403-399399+404400 // Add collection filter to where conditions
405401 let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
406402 conditions: HashMap::new(),
···454450 .and_then(|v| v.as_str())
455451 .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
456452 .to_string();
457457-453453+458454 let record_key = body.get("rkey")
459455 .and_then(|v| v.as_str())
460456 .filter(|s| !s.is_empty()) // Filter out empty strings
461457 .map(|s| s.to_string());
462462-458458+463459 let record_data = body.get("record")
464460 .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
465461 .clone();
466466-467467-462462+463463+468464 // Validate the record against its lexicon
469469-465465+470466 // For social.slices.lexicon collection, validate against the system slice
471467 let validation_slice_uri = if collection == "social.slices.lexicon" {
472468 "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q"
473469 } else {
474470 &slice_uri
475471 };
476476-477477-472472+473473+478474 match LexiconValidator::for_slice(&state.database, validation_slice_uri).await {
479475 Ok(validator) => {
480480-476476+481477 // Debug: Get lexicons from the system slice to see what's there
482478 if collection == "social.slices.lexicon" {
483479 }
484484-480480+485481 if let Err(e) = validator.validate_record(&collection, &record_data) {
486482 return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
487483 "error": "ValidationError",
···496492 }
497493498494 // Create record using AT Protocol functions with DPoP
499499-495495+500496 let create_request = CreateRecordRequest {
501497 repo: repo.clone(),
502498 collection: collection.clone(),
···566562 .and_then(|v| v.as_str())
567563 .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
568564 .to_string();
569569-565565+570566 let rkey = body.get("rkey")
571567 .and_then(|v| v.as_str())
572568 .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
573569 .to_string();
574574-570570+575571 let record_data = body.get("record")
576572 .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
577573 .clone();
578574579575 // Extract repo from user info
580576 let repo = user_info.did.unwrap_or(user_info.sub);
581581-577577+582578 // Validate the record against its lexicon
583579 match LexiconValidator::for_slice(&state.database, &slice_uri).await {
584580 Ok(validator) => {
···677673 .await
678674 .map_err(|_| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?;
679675680680- // Also delete from local database
676676+ // Also delete from local database (from all slices)
681677 let uri = format!("at://{}/{}/{}", repo, collection, rkey);
682682- let _ = state.database.delete_record(&uri).await;
678678+ let _ = state.database.delete_record_by_uri(&uri, None).await;
683679684680 Ok(Json(serde_json::json!({})))
685681}
···782778783779 let result = validator.validate_record("social.slices.testRecord", &invalid_record);
784780 assert!(result.is_err(), "Invalid record should fail validation");
785785-781781+786782 if let Err(e) = result {
787783 let error_message = format!("{}", e);
788784 assert!(!error_message.is_empty(), "Error message should not be empty");
789785 // Error message should be user-friendly and descriptive
790790- assert!(error_message.contains("aspectRatio") || error_message.contains("required"),
786786+ assert!(error_message.contains("aspectRatio") || error_message.contains("required"),
791787 "Error message should indicate what's wrong: {}", error_message);
792788 }
793789 }
···808804809805 let result = validator.validate_record("social.slices.testRecord", &invalid_record);
810806 assert!(result.is_err(), "Constraint violation should fail validation");
811811-807807+812808 if let Err(e) = result {
813809 let error_message = format!("{}", e);
814810 // Should indicate the specific constraint that was violated
815815- assert!(error_message.contains("length") || error_message.contains("maximum") || error_message.contains("100"),
811811+ assert!(error_message.contains("length") || error_message.contains("maximum") || error_message.contains("100"),
816812 "Error message should indicate length constraint: {}", error_message);
817813 }
818814 }
+1-1
api/src/jetstream.rs
···264264 // DID is an actor in our system, delete the record globally
265265 let uri = format!("at://{}/{}/{}", did, commit.collection, commit.rkey);
266266267267- match self.database.delete_record_by_uri(&uri).await {
267267+ match self.database.delete_record_by_uri(&uri, None).await {
268268 Ok(rows_affected) => {
269269 if rows_affected > 0 {
270270 info!("✓ Deleted record globally: {} ({} rows)", uri, rows_affected);
+108-5
api/src/jobs.rs
···44use uuid::Uuid;
55use crate::sync::SyncService;
66use crate::models::BulkSyncParams;
77+use crate::logging::LogLevel;
88+use serde_json::json;
79use tracing::{info, error};
810911/// Payload for sync jobs
···2628 pub message: String,
2729}
28302929-/// Initialize the job registry with all job handlers
3131+/// Initialize the job registry with all job handlers
3032pub fn registry() -> JobRegistry {
3133 JobRegistry::new(&[sync_job])
3234}
···4143 payload.job_id, payload.user_did, payload.slice_uri
4244 );
43454444- // Get database pool from job context
4646+ // Get database pool and logger
4547 let pool = current_job.pool();
4848+ let logger = crate::logging::Logger::global();
46494747- // Create sync service
5050+ // Log job start
5151+ logger.log_sync_job(
5252+ payload.job_id,
5353+ &payload.user_did,
5454+ &payload.slice_uri,
5555+ LogLevel::Info,
5656+ &format!("Starting sync job for {} collections",
5757+ payload.params.collections.as_ref().map(|c| c.len()).unwrap_or(0) +
5858+ payload.params.external_collections.as_ref().map(|c| c.len()).unwrap_or(0)
5959+ ),
6060+ Some(json!({
6161+ "collections": payload.params.collections,
6262+ "external_collections": payload.params.external_collections,
6363+ "repos": payload.params.repos,
6464+ "skip_validation": payload.params.skip_validation
6565+ }))
6666+ );
6767+6868+ // Create sync service with logging
4869 let database = crate::database::Database::from_pool(pool.clone());
4970 let relay_endpoint = std::env::var("RELAY_ENDPOINT")
5071 .unwrap_or_else(|_| "https://relay1.us-west.bsky.network".to_string());
5151- let sync_service = SyncService::new(database.clone(), relay_endpoint);
7272+ let sync_service = SyncService::with_logging(
7373+ database.clone(),
7474+ relay_endpoint,
7575+ logger.clone(),
7676+ payload.job_id,
7777+ payload.user_did.clone()
7878+ );
52795380 // Track progress
5481 let start_time = std::time::Instant::now();
···6592 .await
6693 {
6794 Ok((repos_processed, records_synced)) => {
9595+ let elapsed = start_time.elapsed();
6896 let result = SyncJobResult {
6997 success: true,
7098 total_records: records_synced,
···75103 repos_processed,
76104 message: format!(
77105 "Sync completed successfully in {:?}",
7878- start_time.elapsed()
106106+ elapsed
79107 ),
80108 };
81109110110+ // Log successful completion
111111+ logger.log_sync_job(
112112+ payload.job_id,
113113+ &payload.user_did,
114114+ &payload.slice_uri,
115115+ LogLevel::Info,
116116+ &format!("Sync completed successfully: {} repos, {} records in {:?}",
117117+ repos_processed, records_synced, elapsed),
118118+ Some(json!({
119119+ "repos_processed": repos_processed,
120120+ "records_synced": records_synced,
121121+ "duration_secs": elapsed.as_secs_f64(),
122122+ "collections_synced": result.collections_synced
123123+ }))
124124+ );
125125+82126 // Store result in database before completing the job
83127 store_job_result(
84128 pool,
···107151 Err(e) => {
108152 error!("Sync job {} failed: {}", payload.job_id, e);
109153154154+ // Log error
155155+ logger.log_sync_job(
156156+ payload.job_id,
157157+ &payload.user_did,
158158+ &payload.slice_uri,
159159+ LogLevel::Error,
160160+ &format!("Sync job failed: {}", e),
161161+ Some(json!({
162162+ "error": e.to_string(),
163163+ "duration_secs": start_time.elapsed().as_secs_f64()
164164+ }))
165165+ );
166166+110167 let result = SyncJobResult {
111168 success: false,
112169 total_records: 0,
···191248 slice_uri: String,
192249 params: BulkSyncParams,
193250) -> Result<Uuid, Box<dyn std::error::Error + Send + Sync>> {
251251+ // Check if there's already a running sync job for this user+slice combination
252252+ // We do this by checking:
253253+ // 1. If there are any jobs in mq_msgs for sync_queue channel that haven't been processed yet
254254+ // 2. If there are any recent job_results entries that indicate a job might still be running
255255+ let existing_running_msg = sqlx::query!(
256256+ r#"
257257+ SELECT m.id
258258+ FROM mq_msgs m
259259+ JOIN mq_payloads p ON m.id = p.id
260260+ WHERE m.channel_name = 'sync_queue'
261261+ AND m.id != '00000000-0000-0000-0000-000000000000'
262262+ AND p.payload_json->>'user_did' = $1
263263+ AND p.payload_json->>'slice_uri' = $2
264264+ AND m.attempt_at <= NOW()
265265+ "#,
266266+ user_did,
267267+ slice_uri
268268+ )
269269+ .fetch_optional(pool)
270270+ .await?;
271271+272272+ // Also check if there's a very recent job that might still be running
273273+ // (within the last 10 minutes and no completion record)
274274+ let recent_start = sqlx::query!(
275275+ r#"
276276+ SELECT m.id
277277+ FROM mq_msgs m
278278+ JOIN mq_payloads p ON m.id = p.id
279279+ LEFT JOIN job_results jr ON (p.payload_json->>'job_id')::uuid = jr.job_id
280280+ WHERE m.channel_name = 'sync_queue'
281281+ AND m.id != '00000000-0000-0000-0000-000000000000'
282282+ AND p.payload_json->>'user_did' = $1
283283+ AND p.payload_json->>'slice_uri' = $2
284284+ AND m.created_at > NOW() - INTERVAL '10 minutes'
285285+ AND jr.job_id IS NULL
286286+ "#,
287287+ user_did,
288288+ slice_uri
289289+ )
290290+ .fetch_optional(pool)
291291+ .await?;
292292+293293+ if existing_running_msg.is_some() || recent_start.is_some() {
294294+ return Err("A sync job is already running for this slice. Please wait for it to complete before starting another.".into());
295295+ }
296296+194297 let job_id = Uuid::new_v4();
195298196299 let payload = SyncJobPayload {
+353
api/src/logging.rs
···11+use serde_json::Value;
22+use sqlx::PgPool;
33+use uuid::Uuid;
44+use tokio::sync::mpsc;
55+use tokio::time::{interval, Duration};
66+use tracing::{info, warn, error};
77+use chrono::Utc;
88+use std::sync::OnceLock;
99+1010+#[derive(Debug, Clone)]
1111+pub enum LogLevel {
1212+ Info,
1313+ Warn,
1414+ Error,
1515+}
1616+1717+impl LogLevel {
1818+ pub fn as_str(&self) -> &'static str {
1919+ match self {
2020+ LogLevel::Info => "info",
2121+ LogLevel::Warn => "warn",
2222+ LogLevel::Error => "error",
2323+ }
2424+ }
2525+}
2626+2727+#[derive(Debug, Clone)]
2828+#[allow(dead_code)]
2929+pub enum LogType {
3030+ SyncJob,
3131+ Jetstream,
3232+ System,
3333+}
3434+3535+impl LogType {
3636+ pub fn as_str(&self) -> &'static str {
3737+ match self {
3838+ LogType::SyncJob => "sync_job",
3939+ LogType::Jetstream => "jetstream",
4040+ LogType::System => "system",
4141+ }
4242+ }
4343+}
4444+4545+/// Global logger instance
4646+static GLOBAL_LOGGER: OnceLock<Logger> = OnceLock::new();
4747+4848+/// Log entry to be queued for batch insertion
4949+#[derive(Debug, Clone)]
5050+struct QueuedLogEntry {
5151+ log_type: String,
5252+ job_id: Option<Uuid>,
5353+ user_did: Option<String>,
5454+ slice_uri: Option<String>,
5555+ level: String,
5656+ message: String,
5757+ metadata: Option<Value>,
5858+ created_at: chrono::DateTime<chrono::Utc>,
5959+}
6060+6161+/// Logger that queues log entries and flushes them periodically
6262+#[derive(Clone)]
6363+pub struct Logger {
6464+ sender: mpsc::UnboundedSender<QueuedLogEntry>,
6565+}
6666+6767+impl Logger {
6868+ /// Create a new batched logger and spawn the background worker
6969+ pub fn new(pool: PgPool) -> Self {
7070+ let (sender, receiver) = mpsc::unbounded_channel();
7171+7272+ // Spawn background worker
7373+ tokio::spawn(Self::background_worker(receiver, pool));
7474+7575+ Self { sender }
7676+ }
7777+7878+ /// Initialize the global logger (call once at startup)
7979+ pub fn init_global(pool: PgPool) {
8080+ let logger = Self::new(pool);
8181+ if GLOBAL_LOGGER.set(logger).is_err() {
8282+ warn!("Global logger was already initialized");
8383+ }
8484+ }
8585+8686+ /// Get the global logger instance
8787+ pub fn global() -> &'static Logger {
8888+ GLOBAL_LOGGER.get().expect("Global logger not initialized - call Logger::init_global() first")
8989+ }
9090+9191+ /// Log a sync job message (queued for batch insertion)
9292+ pub fn log_sync_job(
9393+ &self,
9494+ job_id: Uuid,
9595+ user_did: &str,
9696+ slice_uri: &str,
9797+ level: LogLevel,
9898+ message: &str,
9999+ metadata: Option<Value>,
100100+ ) {
101101+ let entry = QueuedLogEntry {
102102+ log_type: LogType::SyncJob.as_str().to_string(),
103103+ job_id: Some(job_id),
104104+ user_did: Some(user_did.to_string()),
105105+ slice_uri: Some(slice_uri.to_string()),
106106+ level: level.as_str().to_string(),
107107+ message: message.to_string(),
108108+ metadata,
109109+ created_at: Utc::now(),
110110+ };
111111+112112+ // Also log to tracing for immediate console output
113113+ match level {
114114+ LogLevel::Info => info!("[sync_job] {}", message),
115115+ LogLevel::Warn => warn!("[sync_job] {}", message),
116116+ LogLevel::Error => error!("[sync_job] {}", message),
117117+ }
118118+119119+ // Queue for database insertion (ignore send errors if channel closed)
120120+ let _ = self.sender.send(entry);
121121+ }
122122+123123+ /// Background worker that processes the log queue
124124+ async fn background_worker(
125125+ mut receiver: mpsc::UnboundedReceiver<QueuedLogEntry>,
126126+ pool: PgPool,
127127+ ) {
128128+ let mut batch = Vec::new();
129129+ let mut flush_interval = interval(Duration::from_secs(5)); // Flush every 5 seconds
130130+131131+ info!("Started batched logging background worker");
132132+133133+ loop {
134134+ tokio::select! {
135135+ // Receive log entries
136136+ Some(entry) = receiver.recv() => {
137137+ batch.push(entry);
138138+139139+ // Flush if batch is large enough
140140+ if batch.len() >= 100 {
141141+ Self::flush_batch(&pool, &mut batch).await;
142142+ }
143143+ }
144144+145145+ // Periodic flush
146146+ _ = flush_interval.tick() => {
147147+ if !batch.is_empty() {
148148+ Self::flush_batch(&pool, &mut batch).await;
149149+ }
150150+ }
151151+152152+ // Channel closed, flush remaining and exit
153153+ else => {
154154+ if !batch.is_empty() {
155155+ Self::flush_batch(&pool, &mut batch).await;
156156+ }
157157+ break;
158158+ }
159159+ }
160160+ }
161161+162162+ info!("Batched logging background worker shut down");
163163+ }
164164+165165+ /// Flush a batch of log entries to the database
166166+ async fn flush_batch(pool: &PgPool, batch: &mut Vec<QueuedLogEntry>) {
167167+ if batch.is_empty() {
168168+ return;
169169+ }
170170+171171+ let batch_size = batch.len();
172172+ let start = std::time::Instant::now();
173173+174174+ // Build bulk INSERT query
175175+ let mut query = String::from(
176176+ "INSERT INTO logs (log_type, job_id, user_did, slice_uri, level, message, metadata, created_at) VALUES "
177177+ );
178178+179179+ // Add placeholders for each record
180180+ for i in 0..batch_size {
181181+ if i > 0 {
182182+ query.push_str(", ");
183183+ }
184184+ let base = i * 8 + 1; // 8 fields per log entry
185185+ query.push_str(&format!(
186186+ "(${}, ${}, ${}, ${}, ${}, ${}, ${}, ${})",
187187+ base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7
188188+ ));
189189+ }
190190+191191+ // Bind parameters
192192+ let mut sqlx_query = sqlx::query(&query);
193193+ for entry in batch.iter() {
194194+ sqlx_query = sqlx_query
195195+ .bind(&entry.log_type)
196196+ .bind(&entry.job_id)
197197+ .bind(&entry.user_did)
198198+ .bind(&entry.slice_uri)
199199+ .bind(&entry.level)
200200+ .bind(&entry.message)
201201+ .bind(&entry.metadata)
202202+ .bind(&entry.created_at);
203203+ }
204204+205205+ // Execute batch insert
206206+ match sqlx_query.execute(pool).await {
207207+ Ok(_) => {
208208+ let elapsed = start.elapsed();
209209+ if elapsed.as_millis() > 100 {
210210+ warn!("Slow log batch insert: {} entries in {:?}", batch_size, elapsed);
211211+ } else {
212212+ info!("Flushed {} log entries in {:?}", batch_size, elapsed);
213213+ }
214214+ }
215215+ Err(e) => {
216216+ error!("Failed to flush log batch of {} entries: {}", batch_size, e);
217217+ // Continue processing - logs are lost but system keeps running
218218+ }
219219+ }
220220+221221+ batch.clear();
222222+ }
223223+}
224224+225225+/// Log entry struct for database queries
226226+#[derive(Debug, serde::Serialize, sqlx::FromRow)]
227227+#[serde(rename_all = "camelCase")]
228228+pub struct LogEntry {
229229+ pub id: i64,
230230+ pub created_at: chrono::DateTime<chrono::Utc>,
231231+ pub log_type: String,
232232+ pub job_id: Option<Uuid>,
233233+ pub user_did: Option<String>,
234234+ pub slice_uri: Option<String>,
235235+ pub level: String,
236236+ pub message: String,
237237+ pub metadata: Option<serde_json::Value>,
238238+}
239239+240240+/// Get logs for a specific sync job
241241+pub async fn get_sync_job_logs(
242242+ pool: &PgPool,
243243+ job_id: Uuid,
244244+ limit: Option<i64>,
245245+) -> Result<Vec<LogEntry>, sqlx::Error> {
246246+ let limit = limit.unwrap_or(100);
247247+248248+ let rows = sqlx::query_as!(
249249+ LogEntry,
250250+ r#"
251251+ SELECT id, created_at, log_type, job_id, user_did, slice_uri, level, message, metadata
252252+ FROM logs
253253+ WHERE log_type = 'sync_job' AND job_id = $1
254254+ ORDER BY created_at ASC
255255+ LIMIT $2
256256+ "#,
257257+ job_id,
258258+ limit
259259+ )
260260+ .fetch_all(pool)
261261+ .await?;
262262+263263+ Ok(rows)
264264+}
265265+266266+/// Get jetstream logs
267267+#[allow(dead_code)]
268268+pub async fn get_jetstream_logs(
269269+ pool: &PgPool,
270270+ limit: Option<i64>,
271271+) -> Result<Vec<LogEntry>, sqlx::Error> {
272272+ let limit = limit.unwrap_or(100);
273273+274274+ let rows = sqlx::query_as!(
275275+ LogEntry,
276276+ r#"
277277+ SELECT id, created_at, log_type, job_id, user_did, slice_uri, level, message, metadata
278278+ FROM logs
279279+ WHERE log_type = 'jetstream'
280280+ ORDER BY created_at DESC
281281+ LIMIT $1
282282+ "#,
283283+ limit
284284+ )
285285+ .fetch_all(pool)
286286+ .await?;
287287+288288+ Ok(rows)
289289+}
290290+291291+/// Get logs for a specific slice
292292+#[allow(dead_code)]
293293+pub async fn get_slice_logs(
294294+ pool: &PgPool,
295295+ slice_uri: &str,
296296+ log_type_filter: Option<&str>,
297297+ limit: Option<i64>,
298298+) -> Result<Vec<LogEntry>, sqlx::Error> {
299299+ let limit = limit.unwrap_or(100);
300300+301301+ let rows = if let Some(log_type) = log_type_filter {
302302+ sqlx::query_as!(
303303+ LogEntry,
304304+ r#"
305305+ SELECT id, created_at, log_type, job_id, user_did, slice_uri, level, message, metadata
306306+ FROM logs
307307+ WHERE slice_uri = $1 AND log_type = $2
308308+ ORDER BY created_at DESC
309309+ LIMIT $3
310310+ "#,
311311+ slice_uri,
312312+ log_type,
313313+ limit
314314+ )
315315+ .fetch_all(pool)
316316+ .await?
317317+ } else {
318318+ sqlx::query_as!(
319319+ LogEntry,
320320+ r#"
321321+ SELECT id, created_at, log_type, job_id, user_did, slice_uri, level, message, metadata
322322+ FROM logs
323323+ WHERE slice_uri = $1
324324+ ORDER BY created_at DESC
325325+ LIMIT $2
326326+ "#,
327327+ slice_uri,
328328+ limit
329329+ )
330330+ .fetch_all(pool)
331331+ .await?
332332+ };
333333+334334+ Ok(rows)
335335+}
336336+337337+/// Clean up old logs (keep last 30 days for jetstream, 7 days for completed sync jobs)
338338+#[allow(dead_code)]
339339+pub async fn cleanup_old_logs(pool: &PgPool) -> Result<u64, sqlx::Error> {
340340+ let result = sqlx::query!(
341341+ r#"
342342+ DELETE FROM logs
343343+ WHERE
344344+ (log_type = 'jetstream' AND created_at < NOW() - INTERVAL '30 days')
345345+ OR (log_type = 'sync_job' AND created_at < NOW() - INTERVAL '7 days')
346346+ OR (log_type = 'system' AND created_at < NOW() - INTERVAL '7 days')
347347+ "#,
348348+ )
349349+ .execute(pool)
350350+ .await?;
351351+352352+ Ok(result.rows_affected())
353353+}
···8484 : "Enter external collections (not matching your domain), one per line:\n\napp.bsky.feed.post\napp.bsky.actor.profile"
8585 }
8686 >
8787- {externalCollections.length > 0 ? externalCollections.join("\n") : ""}
8787+ {externalCollections.length > 0
8888+ ? externalCollections.join("\n")
8989+ : ""}
8890 </textarea>
8991 <p className="mt-1 text-xs text-gray-500">
9090- External collections are those that don't match your slice's domain.
9292+ External collections are those that don't match your slice's
9393+ domain.
9194 </p>
9295 </div>
9396···135138 hx-swap="innerHTML"
136139 className="mb-6"
137140 >
138138- <JobHistory jobs={[]} />
141141+ <JobHistory jobs={[]} sliceId={sliceId} />
139142 </div>
140143141144 <div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
···144147 </h3>
145148 <ul className="text-blue-700 space-y-1 text-sm">
146149 <li>
147147- • Primary collections matching your slice domain are automatically loaded
148148- in the first field
150150+ • Primary collections matching your slice domain are automatically
151151+ loaded in the first field
149152 </li>
150153 <li>
151151- • External collections from other domains are loaded in the second field
154154+ • External collections from other domains are loaded in the second
155155+ field
152156 </li>
153157 <li>
154158 • Use External Collections to sync popular collections like{" "}