From f7deb3ffc7cc9c6f7382012bf5c33ebe465e83f8 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 12:38:17 +0800 Subject: [PATCH 01/16] fix(sync): commit remote-created notes locally --- flicknote-core/src/lib.rs | 1 + flicknote-core/src/schema.rs | 21 + flicknote-sync/src/ipc.rs | 23 - flicknote-sync/src/lib.rs | 954 +++++++++++++++++++++++++++++++---- 4 files changed, 870 insertions(+), 129 deletions(-) diff --git a/flicknote-core/src/lib.rs b/flicknote-core/src/lib.rs index 787ec57..9269176 100644 --- a/flicknote-core/src/lib.rs +++ b/flicknote-core/src/lib.rs @@ -13,3 +13,4 @@ pub mod types; pub const TOPIC_EXTRACTION_KEY: &str = "::topic"; pub const ENTITY_EXTRACTION_KEYS: &[&str] = &["::person", "::company", "::location", "::product"]; +pub const REMOTE_COMMITTED_INSERT_METADATA: &str = r#"{"flicknote":"remote_committed_insert_v1"}"#; diff --git a/flicknote-core/src/schema.rs b/flicknote-core/src/schema.rs index 4a0c3bb..6661b83 100644 --- a/flicknote-core/src/schema.rs +++ b/flicknote-core/src/schema.rs @@ -22,6 +22,7 @@ pub fn app_schema() -> Schema { Column::text("deleted_at"), ], |t| { + t.options.track_metadata = true; t.indexes = vec![ Index { name: "notes_user_short_id_idx".into(), @@ -134,6 +135,7 @@ pub fn app_schema() -> Schema { Column::text("value"), ], |t| { + t.options.track_metadata = true; t.indexes = vec![ Index { name: "note_extractions_note_id_idx".into(), @@ -239,3 +241,22 @@ pub fn app_schema() -> Schema { schema } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_committed_tables_track_crud_metadata() { + let schema = app_schema(); + + for name in ["notes", "note_extractions"] { + let table = schema + .tables + .iter() + .find(|table| table.name == name) + .unwrap_or_else(|| panic!("missing {name} table")); + assert!(table.options.track_metadata, "{name} must track metadata"); + } + } +} diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 5746977..ca17972 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -12,8 +12,6 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixStream; -pub const LOCAL_SYNC_TIMEOUT_SECS: u64 = 10; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum DaemonRequest { @@ -82,7 +80,6 @@ pub struct CreatedNote { #[serde(tag = "code", rename_all = "snake_case")] pub enum DaemonError { Unavailable { path: String, message: String }, - RemoteCreatedLocalSyncTimeout { short_id: i64, timeout_secs: u64 }, Other { message: String }, } @@ -92,13 +89,6 @@ impl fmt::Display for DaemonError { Self::Unavailable { path, message } => { write!(f, "Sync daemon is not available at {path}: {message}") } - Self::RemoteCreatedLocalSyncTimeout { - short_id, - timeout_secs, - } => write!( - f, - "Created note remotely as #{short_id}, but PowerSync did not update the local database within {timeout_secs}s.\nDo not create it again. Check `flicknote sync status`; note #{short_id} should appear after sync catches up." - ), Self::Other { message } => f.write_str(message), } } @@ -417,19 +407,6 @@ mod tests { assert!(serde_json::from_value::(value).is_ok()); } - #[test] - fn local_sync_timeout_message_warns_not_to_create_again() { - let err = DaemonError::RemoteCreatedLocalSyncTimeout { - short_id: 123, - timeout_secs: 10, - }; - - assert_eq!( - err.to_string(), - "Created note remotely as #123, but PowerSync did not update the local database within 10s.\nDo not create it again. Check `flicknote sync status`; note #123 should appear after sync catches up." - ); - } - #[tokio::test] async fn daemon_client_maps_missing_socket_to_retryable_unavailable() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 92cd473..eaebac1 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use async_trait::async_trait; use flicknote_auth::client::GoTrueClient; -use flicknote_core::{TOPIC_EXTRACTION_KEY, config::Config, schema::app_schema}; +use flicknote_core::{ + REMOTE_COMMITTED_INSERT_METADATA, TOPIC_EXTRACTION_KEY, config::Config, schema::app_schema, +}; use futures_lite::StreamExt; use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use powersync::{ @@ -152,6 +154,22 @@ async fn run_upload( let mut transient_msg: Option = None; for crud in std::mem::take(&mut tx.crud) { + if crud.metadata.as_deref() == Some(REMOTE_COMMITTED_INSERT_METADATA) { + let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); + let is_put = matches!(&crud.update_type, UpdateType::Put); + if !allowed_table || !is_put { + let operation = match &crud.update_type { + UpdateType::Put => "PUT", + UpdateType::Patch => "PATCH", + UpdateType::Delete => "DELETE", + }; + return Err(ps_err(format!( + "invalid remote-committed marker on {operation} operation for table {}", + crud.table, + ))); + } + continue; + } let table = &crud.table; let id = &crud.id; @@ -508,10 +526,168 @@ async fn shutdown_daemon( log::info!("Sync daemon stopped"); } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct RemoteNoteRow { id: String, short_id: Option, + user_id: String, + #[serde(rename = "type")] + note_type: String, + status: String, + title: Option, + content: Option, + summary: Option, + #[serde(default)] + is_flagged: bool, + project_id: Option, + metadata: Option, + source: Option, + created_at: Option, + updated_at: Option, + deleted_at: Option, +} + +fn json_column(value: &Option) -> Result, DaemonError> { + value + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|error| DaemonError::Other { + message: format!("Failed to serialize canonical remote JSON: {error}"), + }) +} + +async fn commit_remote_note( + db: &PowerSyncDatabase, + note: &RemoteNoteRow, +) -> Result { + let metadata = json_column(¬e.metadata)?; + let source = json_column(¬e.source)?; + let mut writer = db.writer().await.map_err(|error| DaemonError::Other { + message: format!("Failed to open local PowerSync writer: {error}"), + })?; + let tx = writer + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| DaemonError::Other { + message: format!("Failed to begin local note transaction: {error}"), + })?; + let exists = tx + .query_row( + "SELECT 1 FROM notes WHERE id = ? LIMIT 1", + params![note.id], + |_| Ok(()), + ) + .optional() + .map_err(|error| DaemonError::Other { + message: format!("Failed to check local note {}: {error}", note.id), + })? + .is_some(); + if exists { + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local note transaction: {error}"), + })?; + return Ok(false); + } + + tx.execute( + r#"INSERT INTO notes ( + id, short_id, user_id, type, status, title, content, summary, + is_flagged, project_id, metadata, source, created_at, updated_at, + deleted_at, _metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + params![ + note.id, + note.short_id, + note.user_id, + note.note_type, + note.status, + note.title, + note.content, + note.summary, + note.is_flagged, + note.project_id, + metadata, + source, + note.created_at, + note.updated_at, + note.deleted_at, + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .map_err(|error| DaemonError::Other { + message: format!("Failed to commit remote note {} locally: {error}", note.id), + })?; + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local note transaction: {error}"), + })?; + Ok(true) +} + +#[derive(Debug, Clone, serde::Serialize, Deserialize)] +struct RemoteExtractionRow { + id: String, + note_id: String, + user_id: String, + key: String, + value: String, +} + +async fn commit_remote_extractions( + db: &PowerSyncDatabase, + rows: &[RemoteExtractionRow], +) -> Result { + if rows.is_empty() { + return Ok(0); + } + let mut writer = db.writer().await.map_err(|error| DaemonError::Other { + message: format!("Failed to open local PowerSync writer: {error}"), + })?; + let tx = writer + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| DaemonError::Other { + message: format!("Failed to begin local extraction transaction: {error}"), + })?; + let mut inserted = 0; + for row in rows { + let exists = tx + .query_row( + "SELECT 1 FROM note_extractions WHERE id = ? LIMIT 1", + params![row.id], + |_| Ok(()), + ) + .optional() + .map_err(|error| DaemonError::Other { + message: format!("Failed to check local extraction {}: {error}", row.id), + })? + .is_some(); + if exists { + continue; + } + tx.execute( + r#"INSERT INTO note_extractions ( + id, note_id, user_id, key, value, _metadata + ) VALUES (?, ?, ?, ?, ?, ?)"#, + params![ + row.id, + row.note_id, + row.user_id, + row.key, + row.value, + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .map_err(|error| DaemonError::Other { + message: format!( + "Failed to commit remote extraction {} locally: {error}", + row.id + ), + })?; + inserted += 1; + } + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local extraction transaction: {error}"), + })?; + Ok(inserted) } fn attachment_endpoint(base_url: &str, path: &str) -> String { @@ -800,7 +976,7 @@ async fn delete_attachment( }) } -async fn create_note_remotely_and_wait( +async fn create_note_remotely( db: &PowerSyncDatabase, http: &reqwest::Client, auth: &GoTrueClient, @@ -811,6 +987,36 @@ async fn create_note_remotely_and_wait( message: format!("Auth error: {e}"), })?; + create_note_with_token( + db, + http, + config, + &session.access_token, + &session.user.id, + req, + ) + .await +} + +async fn create_note_with_token( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + user_id: &str, + req: CreateNoteRequest, +) -> Result { + let extraction_rows = req + .topics + .iter() + .map(|value| RemoteExtractionRow { + id: uuid::Uuid::new_v4().to_string(), + note_id: req.id.clone(), + user_id: user_id.to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: value.clone(), + }) + .collect::>(); let metadata = match req.metadata.as_deref() { Some(raw) => { serde_json::from_str::(raw).map_err(|e| DaemonError::Other { @@ -822,12 +1028,12 @@ async fn create_note_remotely_and_wait( let attachment_path = req.attachment_path.as_deref().map(Path::new); if let Some(path) = attachment_path { - upload_attachment(http, config, &session.access_token, &req.id, path).await?; + upload_attachment(http, config, access_token, &req.id, path).await?; } let payload = serde_json::json!({ "id": req.id, - "user_id": session.user.id, + "user_id": user_id, "type": req.note_type, "status": req.status, "title": req.title, @@ -839,19 +1045,29 @@ async fn create_note_remotely_and_wait( }); let resp = match http - .post(format!("{}/rest/v1/notes", config.supabase_url)) + .post(format!( + "{}/rest/v1/notes?on_conflict=id", + config.supabase_url + )) .header("apikey", &config.supabase_anon_key) - .bearer_auth(&session.access_token) - .header("Prefer", "return=representation") + .bearer_auth(access_token) + .header( + "Prefer", + "resolution=ignore-duplicates,return=representation", + ) .json(&payload) .send() .await { Ok(resp) => resp, Err(e) => { + if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { + return finish_remote_create(db, http, config, access_token, row, &extraction_rows) + .await; + } if attachment_path.is_some() && let Err(cleanup_err) = - delete_attachment(http, config, &session.access_token, &req.id).await + delete_attachment(http, config, access_token, &req.id).await { log::warn!( "Failed to clean up uploaded attachment after note create request failure: {cleanup_err}" @@ -866,8 +1082,12 @@ async fn create_note_remotely_and_wait( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); + if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { + return finish_remote_create(db, http, config, access_token, row, &extraction_rows) + .await; + } if attachment_path.is_some() - && let Err(e) = delete_attachment(http, config, &session.access_token, &req.id).await + && let Err(e) = delete_attachment(http, config, access_token, &req.id).await { log::warn!("Failed to clean up uploaded attachment after note create failure: {e}"); } @@ -882,134 +1102,178 @@ async fn create_note_remotely_and_wait( .map_err(|e| DaemonError::Other { message: format!("Failed to parse remote note create response: {e}"), })?; - let row = rows.pop().ok_or_else(|| DaemonError::Other { - message: "Remote note create returned no row".to_string(), - })?; + let row = match rows.pop() { + Some(row) => row, + None => lookup_remote_note(http, config, access_token, &req.id) + .await? + .ok_or_else(|| DaemonError::Other { + message: format!( + "Remote note create returned no row and note {} was not found", + req.id + ), + })?, + }; + finish_remote_create(db, http, config, access_token, row, &extraction_rows).await +} + +async fn finish_remote_create( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + row: RemoteNoteRow, + extraction_rows: &[RemoteExtractionRow], +) -> Result { let short_id = row.short_id.ok_or_else(|| DaemonError::Other { message: "Remote note create returned no short id".to_string(), })?; - - create_extractions_remotely( - http, - config, - &session.access_token, - &session.user.id, - &row.id, - &req, - ) - .await?; - wait_for_local_note(db, &row.id, short_id, &req, ipc::LOCAL_SYNC_TIMEOUT_SECS).await?; + commit_remote_note(db, &row).await?; + create_extractions_with_token(db, http, config, access_token, extraction_rows).await?; Ok(CreatedNote { uuid: row.id, short_id, }) } -async fn create_extractions_remotely( +async fn lookup_remote_note( http: &reqwest::Client, config: &Config, access_token: &str, - user_id: &str, - note_id: &str, - req: &CreateNoteRequest, -) -> Result<(), DaemonError> { - let mut rows = Vec::new(); - for value in &req.topics { - rows.push(serde_json::json!({ - "id": uuid::Uuid::new_v4().to_string(), - "note_id": note_id, - "user_id": user_id, - "key": TOPIC_EXTRACTION_KEY, - "value": value, - })); + id: &str, +) -> Result, DaemonError> { + let response = http + .get(format!( + "{}/rest/v1/notes?id=eq.{id}&select=*", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to reconcile remote note {id}: {error}"), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("Failed to reconcile remote note {id} ({status}): {body}"), + }); } - if rows.is_empty() { - return Ok(()); + let mut rows = response + .json::>() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse remote note reconciliation response: {error}"), + })?; + Ok(rows.pop()) +} + +async fn create_extractions_with_token( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + requested: &[RemoteExtractionRow], +) -> Result, DaemonError> { + if requested.is_empty() { + return Ok(Vec::new()); } let resp = http - .post(format!("{}/rest/v1/note_extractions", config.supabase_url)) + .post(format!( + "{}/rest/v1/note_extractions?on_conflict=id", + config.supabase_url + )) .header("apikey", &config.supabase_anon_key) .bearer_auth(access_token) - .json(&rows) + .header( + "Prefer", + "resolution=ignore-duplicates,return=representation", + ) + .json(requested) .send() .await .map_err(|e| DaemonError::Other { message: format!("Remote note extraction create failed: {e}"), })?; - if resp.status().is_success() { - return Ok(()); + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!( + "Created note remotely, but failed to create note extractions ({status}): {body}\nDo not create it again; retry with the same note UUID." + ), + }); } - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - Err(DaemonError::Other { - message: format!( - "Created note remotely, but failed to create note extractions ({status}): {body}\nDo not create it again. Check `flicknote sync status`; the note should appear after sync catches up." - ), - }) -} - -async fn wait_for_local_note( - db: &PowerSyncDatabase, - id: &str, - short_id: i64, - req: &CreateNoteRequest, - timeout_secs: u64, -) -> Result<(), DaemonError> { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); - loop { - let found = { - let reader = db.reader().await.map_err(|e| DaemonError::Other { - message: format!("Failed to read local PowerSync database: {e}"), - })?; - let mut stmt = reader - .prepare_cached("SELECT short_id FROM notes WHERE id = ? LIMIT 1") - .map_err(|e| DaemonError::Other { - message: format!("Failed to prepare local sync check: {e}"), - })?; - stmt.query_row(params![id], |row| row.get::<_, Option>(0)) - .optional() - .map_err(|e| DaemonError::Other { - message: format!("Failed to query local sync check: {e}"), - })? - .flatten() - }; - let topics_synced = - local_extraction_count(db, id, TOPIC_EXTRACTION_KEY).await? >= req.topics.len(); - if found == Some(short_id) && topics_synced { - return Ok(()); + let mut rows = resp + .json::>() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse remote extraction create response: {error}"), + })?; + let mut confirmed_ids = rows + .iter() + .map(|row| row.id.clone()) + .collect::>(); + for expected in requested { + if confirmed_ids.contains(&expected.id) { + continue; } - if tokio::time::Instant::now() >= deadline { - return Err(DaemonError::RemoteCreatedLocalSyncTimeout { - short_id, - timeout_secs, - }); + if let Some(row) = + lookup_remote_extraction(http, config, access_token, &expected.id).await? + { + rows.push(row); + confirmed_ids.insert(expected.id.clone()); } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; } + if rows.len() != requested.len() { + return Err(DaemonError::Other { + message: format!( + "Created note remotely, but only {} of {} extraction rows were confirmed", + rows.len(), + requested.len() + ), + }); + } + commit_remote_extractions(db, &rows).await?; + Ok(rows) } -async fn local_extraction_count( - db: &PowerSyncDatabase, - note_id: &str, - extraction_key: &str, -) -> Result { - let reader = db.reader().await.map_err(|e| DaemonError::Other { - message: format!("Failed to read local PowerSync database: {e}"), - })?; - let mut stmt = reader - .prepare_cached("SELECT COUNT(*) FROM note_extractions WHERE note_id = ? AND key = ?") - .map_err(|e| DaemonError::Other { - message: format!("Failed to prepare local extraction sync check: {e}"), +async fn lookup_remote_extraction( + http: &reqwest::Client, + config: &Config, + access_token: &str, + id: &str, +) -> Result, DaemonError> { + let response = http + .get(format!( + "{}/rest/v1/note_extractions?id=eq.{id}&select=*", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to reconcile remote extraction {id}: {error}"), })?; - let count = stmt - .query_row(params![note_id, extraction_key], |row| row.get::<_, i64>(0)) - .map_err(|e| DaemonError::Other { - message: format!("Failed to query local extraction sync check: {e}"), + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("Failed to reconcile remote extraction {id} ({status}): {body}"), + }); + } + let mut rows = response + .json::>() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse extraction reconciliation response: {error}"), })?; - Ok(count as usize) + Ok(rows.pop()) } async fn serve_socket( @@ -1036,7 +1300,7 @@ async fn serve_socket( tokio::spawn(async move { let response = match ipc::read_request(&mut stream).await { Ok(DaemonRequest::CreateNote(req)) => { - match create_note_remotely_and_wait(&db, &http, &auth, &config, *req).await { + match create_note_remotely(&db, &http, &auth, &config, *req).await { Ok(note) => DaemonResponse::NoteCreated(note), Err(e) => DaemonResponse::Error(e), } @@ -1318,6 +1582,239 @@ mod tests { use super::*; + async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let pool = ConnectionPool::open(directory.path().join("test.db")).unwrap(); + let env = PowerSyncEnvironment::custom( + reqwest::Client::new(), + pool, + PowerSyncEnvironment::tokio_timer(), + ); + let db = PowerSyncDatabase::new(env, app_schema()); + db.writer().await.unwrap(); + (directory, db) + } + + async fn insert_marked_note(db: &PowerSyncDatabase) { + let writer = db.writer().await.unwrap(); + writer + .execute( + r#"INSERT INTO notes ( + id, short_id, user_id, type, status, title, content, + is_flagged, created_at, updated_at, _metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + params![ + "note-1", + 42, + "user-1", + "normal", + "ai_queued", + "Title", + "Body", + 0, + "2026-08-09T00:00:00Z", + "2026-08-09T00:00:00Z", + r#"{"flicknote":"remote_committed_insert_v1"}"#, + ], + ) + .unwrap(); + } + + fn remote_note(id: &str, title: &str) -> RemoteNoteRow { + RemoteNoteRow { + id: id.to_string(), + short_id: Some(42), + user_id: "user-1".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some(title.to_string()), + content: Some("Canonical body".to_string()), + summary: Some("Canonical summary".to_string()), + is_flagged: false, + project_id: Some("project-1".to_string()), + metadata: Some(serde_json::json!({"source": "remote"})), + source: Some(serde_json::json!({"kind": "plain"})), + created_at: Some("2026-08-09T00:00:00Z".to_string()), + updated_at: Some("2026-08-09T00:00:01Z".to_string()), + deleted_at: None, + } + } + + #[tokio::test] + async fn remote_committed_note_is_fully_visible_before_return() { + let (_directory, db) = test_powersync_db().await; + let inserted = commit_remote_note(&db, &remote_note("note-full", "Remote title")) + .await + .unwrap(); + + assert!(inserted); + let reader = db.reader().await.unwrap(); + let row = reader + .query_row( + "SELECT short_id, title, summary, metadata, source FROM notes WHERE id = ?", + params!["note-full"], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + ) + .unwrap(); + assert_eq!(row.0, 42); + assert_eq!(row.1, "Remote title"); + assert_eq!(row.2, "Canonical summary"); + assert_eq!(row.3, r#"{"source":"remote"}"#); + assert_eq!(row.4, r#"{"kind":"plain"}"#); + + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); + } + + #[tokio::test] + async fn remote_committed_note_does_not_replace_row_downloaded_first() { + let (_directory, db) = test_powersync_db().await; + { + let writer = db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO notes (id, short_id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?, ?)", + params!["note-race", 42, "user-1", "normal", "ready", "Newer title"], + ) + .unwrap(); + writer.execute("DELETE FROM ps_crud", []).unwrap(); + } + + let inserted = commit_remote_note(&db, &remote_note("note-race", "Older title")) + .await + .unwrap(); + + assert!(!inserted); + let reader = db.reader().await.unwrap(); + let title: String = reader + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["note-race"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Newer title"); + assert!(db.next_crud_transaction().await.unwrap().is_none()); + } + + #[tokio::test] + async fn remote_committed_insert_records_marker_in_crud() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!(transaction.crud.len(), 1); + assert_eq!(transaction.crud[0].table, "notes"); + assert!(matches!( + transaction.crud.first().map(|entry| &entry.update_type), + Some(UpdateType::Put) + )); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(r#"{"flicknote":"remote_committed_insert_v1"}"#) + ); + } + + #[tokio::test] + async fn remote_committed_put_completes_without_http_request() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + + let uploaded = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap(); + + assert!(uploaded); + assert!(db.next_crud_transaction().await.unwrap().is_none()); + } + + #[tokio::test] + async fn remote_committed_marker_on_patch_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + db.next_crud_transaction() + .await + .unwrap() + .unwrap() + .complete() + .await + .unwrap(); + { + let writer = db.writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET title = ?, _metadata = ? WHERE id = ?", + params!["Changed", REMOTE_COMMITTED_INSERT_METADATA, "note-1"], + ) + .unwrap(); + } + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("invalid remote-committed marker") + ); + assert!(db.next_crud_transaction().await.unwrap().is_some()); + } + + #[tokio::test] + async fn remote_committed_extractions_are_visible_before_return() { + let (_directory, db) = test_powersync_db().await; + let rows = vec![RemoteExtractionRow { + id: "extraction-1".to_string(), + note_id: "note-1".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let inserted = commit_remote_extractions(&db, &rows).await.unwrap(); + + assert_eq!(inserted, 1); + let reader = db.reader().await.unwrap(); + let value: String = reader + .query_row( + "SELECT value FROM note_extractions WHERE id = ?", + params!["extraction-1"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(value, "rust"); + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); + } + #[tokio::test] async fn share_request_lock_serializes_operations() { let lock = Arc::new(ShareRequestLock::default()); @@ -1387,6 +1884,251 @@ mod tests { (format!("http://{address}"), handle) } + fn spawn_disconnected_response_then_server( + status: &'static str, + body: &'static str, + ) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + let (mut first, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 4096]; + let count = first.read(&mut buffer).unwrap(); + requests.push( + String::from_utf8_lossy(&buffer[..count]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + ); + drop(first); + + let (mut second, _) = listener.accept().unwrap(); + let count = second.read(&mut buffer).unwrap(); + requests.push( + String::from_utf8_lossy(&buffer[..count]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + ); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + second.write_all(response.as_bytes()).unwrap(); + requests + }); + (format!("http://{address}"), handle) + } + + #[tokio::test] + async fn remote_create_returns_after_canonical_note_is_committed_locally() { + let body = r#"[{"id":"note-create","short_id":77,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-create".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.uuid, "note-create"); + assert_eq!(created.short_id, 77); + let reader = db.reader().await.unwrap(); + let title: String = reader + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["note-create"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Remote title"); + assert_eq!( + server.join().unwrap(), + ["POST /rest/v1/notes?on_conflict=id HTTP/1.1"] + ); + } + + #[tokio::test] + async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { + let body = r#"[{"id":"note-retry","short_id":78,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-retry".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 78); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/notes?on_conflict=id HTTP/1.1", + "GET /rest/v1/notes?id=eq.note-retry&select=* HTTP/1.1", + ] + ); + } + + #[tokio::test] + async fn remote_create_recovers_lost_response_by_stable_uuid() { + let body = r#"[{"id":"note-lost","short_id":79,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_disconnected_response_then_server("200 OK", body); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-lost".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 79); + assert_eq!(server.join().unwrap().len(), 2); + } + + #[tokio::test] + async fn remote_extraction_create_commits_confirmed_rows_locally() { + let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![RemoteExtractionRow { + id: "extraction-create".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let confirmed = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await + .unwrap(); + + assert_eq!(confirmed.len(), 1); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM note_extractions WHERE id = ?", + params!["extraction-create"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + server.join().unwrap(), + ["POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1"] + ); + } + + #[tokio::test] + async fn remote_extraction_create_recovers_by_stable_uuid() { + let body = r#"[{"id":"extraction-retry","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![RemoteExtractionRow { + id: "extraction-retry".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let confirmed = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await + .unwrap(); + + assert_eq!(confirmed.len(), 1); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1", + "GET /rest/v1/note_extractions?id=eq.extraction-retry&select=* HTTP/1.1", + ] + ); + } + #[tokio::test] async fn returns_existing_note_share_without_replacing_it() { let (api_origin, server) = spawn_server(vec![( From dbf5759afa0a4059516ca6d9a859ada0fda49f7b Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 13:01:52 +0800 Subject: [PATCH 02/16] feat(sync): expose daemon application rpc --- Cargo.lock | 1 + flicknote-cli/src/commands/add.rs | 2 + flicknote-cli/src/mcp/server.rs | 2 + flicknote-core/src/backend.rs | 6 +- flicknote-core/src/pgwire/mod.rs | 2 +- flicknote-core/src/services/dto.rs | 4 + .../src/services/editable_document.rs | 30 +- flicknote-core/src/services/error.rs | 16 +- flicknote-core/src/services/mod.rs | 1 + flicknote-core/src/services/note.rs | 49 +- flicknote-core/src/services/ports.rs | 19 +- flicknote-core/src/services/project.rs | 11 +- flicknote-core/src/services/upload.rs | 101 ++++ flicknote-sync/Cargo.toml | 3 +- flicknote-sync/src/app.rs | 499 ++++++++++++++++++ flicknote-sync/src/ipc.rs | 457 +++++++++++++++- flicknote-sync/src/lib.rs | 193 ++++++- flicknote-sync/tests/app_contract.rs | 325 ++++++++++++ 18 files changed, 1661 insertions(+), 60 deletions(-) create mode 100644 flicknote-core/src/services/upload.rs create mode 100644 flicknote-sync/src/app.rs create mode 100644 flicknote-sync/tests/app_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 7865e78..c3a64b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -833,6 +833,7 @@ name = "flicknote-sync" version = "0.4.3" dependencies = [ "async-trait", + "chrono", "flicknote-auth", "flicknote-core", "futures-lite", diff --git a/flicknote-cli/src/commands/add.rs b/flicknote-cli/src/commands/add.rs index 8cfee6a..4144b9a 100644 --- a/flicknote-cli/src/commands/add.rs +++ b/flicknote-cli/src/commands/add.rs @@ -70,6 +70,8 @@ pub(crate) async fn run( content, project: project.clone(), interpret_as_url: args.value.is_some(), + topics: Vec::new(), + created_at: None, }, ) .await?; diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index df40012..9b29ab4 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -245,6 +245,8 @@ impl FlickNoteMcp { content: params.content, project: Self::effective_project(params.project), interpret_as_url: true, + topics: Vec::new(), + created_at: None, }, ) .await diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index 4918858..827c933 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -71,8 +71,8 @@ pub(crate) fn parse_note_lookup(input: &str) -> Result, CliError> // ─── NoteDb trait ──────────────────────────────────────────────────────────── -#[async_trait(?Send)] -pub trait NoteDb { +#[async_trait] +pub trait NoteDb: Send + Sync { fn user_id(&self) -> &str; // Note resolution @@ -397,7 +397,7 @@ async fn sqlite_exists( Ok(exists.is_some()) } #[cfg(feature = "powersync")] -#[async_trait(?Send)] +#[async_trait] impl NoteDb for SqliteBackend { fn user_id(&self) -> &str { &self.user_id diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs index ec34211..9f13271 100644 --- a/flicknote-core/src/pgwire/mod.rs +++ b/flicknote-core/src/pgwire/mod.rs @@ -203,7 +203,7 @@ impl PgWireBackend { } } -#[async_trait(?Send)] +#[async_trait] impl NoteDb for PgWireBackend { fn user_id(&self) -> &str { "" diff --git a/flicknote-core/src/services/dto.rs b/flicknote-core/src/services/dto.rs index 4f6ae98..1c37a39 100644 --- a/flicknote-core/src/services/dto.rs +++ b/flicknote-core/src/services/dto.rs @@ -176,6 +176,10 @@ pub struct NoteAddInput { pub project: Option, #[serde(default)] pub interpret_as_url: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub topics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/flicknote-core/src/services/editable_document.rs b/flicknote-core/src/services/editable_document.rs index 7052e62..1a0d0c8 100644 --- a/flicknote-core/src/services/editable_document.rs +++ b/flicknote-core/src/services/editable_document.rs @@ -4,6 +4,7 @@ use crate::TOPIC_EXTRACTION_KEY; use crate::backend::NoteDb; use crate::error::CliError; use crate::types::Note; +use serde::{Deserialize, Serialize}; use super::frontmatter::{self, EditableDoc}; @@ -14,7 +15,7 @@ pub struct ParsedEditableNote { pub topics: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EditableSaveResult { pub title_changed: bool, pub content_changed: bool, @@ -127,14 +128,14 @@ mod tests { use super::*; use crate::backend::{InsertNoteReq, InsertedNote, NoteFilter, NoteSearch}; use crate::types::{Keyterm, Project}; - use std::cell::RefCell; use std::collections::HashMap; + use std::sync::Mutex; const NOTE_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; struct FakeNoteDb { - note: RefCell, - extractions: RefCell>>, + note: Mutex, + extractions: Mutex>>, } impl FakeNoteDb { @@ -142,18 +143,19 @@ mod tests { let mut map = HashMap::new(); map.insert(note.id.clone(), extractions); Self { - note: RefCell::new(note), - extractions: RefCell::new(map), + note: Mutex::new(note), + extractions: Mutex::new(map), } } fn note(&self) -> Note { - self.note.borrow().clone() + self.note.lock().unwrap().clone() } fn extraction_values(&self, extraction_type: &str) -> Vec { self.extractions - .borrow() + .lock() + .unwrap() .get(NOTE_ID) .into_iter() .flatten() @@ -395,7 +397,7 @@ mod tests { assert_eq!(normal_note_content_ref(&parsed), Some("")); } - #[async_trait::async_trait(?Send)] + #[async_trait::async_trait] impl NoteDb for FakeNoteDb { fn user_id(&self) -> &str { "user" @@ -410,7 +412,7 @@ mod tests { } async fn find_note(&self, id: &str) -> Result { - let note = self.note.borrow(); + let note = self.note.lock().unwrap(); if note.id == id { return Ok(note.clone()); } @@ -455,7 +457,7 @@ mod tests { content: &str, _requeue: bool, ) -> Result<(), CliError> { - let mut note = self.note.borrow_mut(); + let mut note = self.note.lock().unwrap(); if note.id != id { return Err(CliError::NoteNotFound { id: id.to_string() }); } @@ -526,7 +528,7 @@ mod tests { } async fn update_note_title(&self, id: &str, title: &str) -> Result<(), CliError> { - let mut note = self.note.borrow_mut(); + let mut note = self.note.lock().unwrap(); if note.id != id { return Err(CliError::NoteNotFound { id: id.to_string() }); } @@ -555,7 +557,7 @@ mod tests { extraction_types: &[&str], ) -> Result>, CliError> { let mut result = HashMap::new(); - let store = self.extractions.borrow(); + let store = self.extractions.lock().unwrap(); for note_id in note_ids { let Some(rows) = store.get(*note_id) else { continue; @@ -584,7 +586,7 @@ mod tests { extraction_type: &str, values: &[String], ) -> Result<(), CliError> { - let mut store = self.extractions.borrow_mut(); + let mut store = self.extractions.lock().unwrap(); let rows = store.entry(note_id.to_string()).or_default(); rows.retain(|(kind, _)| kind != extraction_type); rows.extend( diff --git a/flicknote-core/src/services/error.rs b/flicknote-core/src/services/error.rs index 1b1d57b..46e65d1 100644 --- a/flicknote-core/src/services/error.rs +++ b/flicknote-core/src/services/error.rs @@ -26,6 +26,13 @@ pub enum ServiceError { DaemonUnavailable(String), #[error("Sync daemon request failed: {0}")] Daemon(String), + #[error("{message}")] + Remote { + code: String, + message: String, + retryable: bool, + details: Option, + }, #[error("Missing configuration: {0}")] ConfigMissing(String), #[error("I/O error: {0}")] @@ -37,7 +44,7 @@ pub enum ServiceError { } impl ServiceError { - pub const fn code(&self) -> &'static str { + pub fn code(&self) -> &str { match self { Self::InvalidArgument(_) => "invalid_argument", Self::NoteNotFound(_) => "note_not_found", @@ -50,6 +57,7 @@ impl ServiceError { Self::NothingToModify => "nothing_to_modify", Self::DaemonUnavailable(_) => "daemon_unavailable", Self::Daemon(_) => "daemon_error", + Self::Remote { code, .. } => code, Self::ConfigMissing(_) => "config_missing", Self::Io(_) => "io_error", Self::Internal(_) | Self::Backend(_) => "internal_error", @@ -57,7 +65,11 @@ impl ServiceError { } pub const fn retryable(&self) -> bool { - matches!(self, Self::DaemonUnavailable(_)) + match self { + Self::DaemonUnavailable(_) => true, + Self::Remote { retryable, .. } => *retryable, + _ => false, + } } } diff --git a/flicknote-core/src/services/mod.rs b/flicknote-core/src/services/mod.rs index 72f92ea..eafa91b 100644 --- a/flicknote-core/src/services/mod.rs +++ b/flicknote-core/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod ports; pub mod project; pub mod sections; pub mod source; +pub mod upload; #[cfg(all(test, feature = "powersync"))] pub(crate) mod test_support; diff --git a/flicknote-core/src/services/note.rs b/flicknote-core/src/services/note.rs index f5f351e..22f58fa 100644 --- a/flicknote-core/src/services/note.rs +++ b/flicknote-core/src/services/note.rs @@ -125,7 +125,10 @@ impl<'a> NoteService<'a> { .resolve_project_filter(input.project.as_deref()) .await?; let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = input + .created_at + .clone() + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); let link_url = input.content.trim(); let is_url = input.interpret_as_url && (link_url.starts_with("http://") || link_url.starts_with("https://")) @@ -140,7 +143,8 @@ impl<'a> NoteService<'a> { metadata: Some(serde_json::json!({ "link": { "url": link_url } }).to_string()), project_id, now, - topics: Vec::new(), + topics: input.topics.clone(), + attachment_path: None, } } else { let (title, content) = extract_title_and_strip(&input.content); @@ -153,7 +157,8 @@ impl<'a> NoteService<'a> { metadata: None, project_id, now, - topics: Vec::new(), + topics: input.topics, + attachment_path: None, } }; let inserted = creator.create(request).await?; @@ -585,7 +590,6 @@ impl<'a> NoteService<'a> { #[cfg(all(test, feature = "powersync"))] mod tests { - use std::cell::RefCell; use crate::backend::NoteDb; use crate::services::dto::NoteAddInput; @@ -839,17 +843,17 @@ mod tests { struct DbCreator<'a> { db: &'a dyn NoteDb, - request: RefCell>, + request: std::sync::Mutex>, } - #[async_trait(?Send)] + #[async_trait] impl NoteCreator for DbCreator<'_> { async fn create( &self, request: CreateNote, ) -> Result { let inserted = self.db.insert_note(&request.as_insert_request()).await?; - self.request.replace(Some(request)); + *self.request.lock().unwrap() = Some(request); Ok(inserted) } } @@ -859,7 +863,7 @@ mod tests { let backend = make_backend().await; let creator = DbCreator { db: &backend, - request: RefCell::new(None), + request: std::sync::Mutex::new(None), }; let service = NoteService::new(&backend); @@ -870,12 +874,14 @@ mod tests { content: "# Title\n\nBody".to_string(), project: None, interpret_as_url: true, + topics: Vec::new(), + created_at: None, }, ) .await .unwrap(); - let request = creator.request.borrow(); + let request = creator.request.lock().unwrap(); let request = request.as_ref().unwrap(); assert_eq!(request.note_type, "normal"); assert_eq!(request.title.as_deref(), Some("Title")); @@ -888,7 +894,7 @@ mod tests { let backend = make_backend().await; let creator = DbCreator { db: &backend, - request: RefCell::new(None), + request: std::sync::Mutex::new(None), }; NoteService::new(&backend) @@ -898,12 +904,14 @@ mod tests { content: "https://example.com with context".to_string(), project: None, interpret_as_url: true, + topics: Vec::new(), + created_at: None, }, ) .await .unwrap(); - let request = creator.request.borrow(); + let request = creator.request.lock().unwrap(); let request = request.as_ref().unwrap(); assert_eq!(request.note_type, "normal"); assert_eq!( @@ -943,18 +951,18 @@ mod tests { #[derive(Default)] struct FakeSideEffects { - shared: RefCell>, - opened: RefCell>, + shared: std::sync::Mutex>, + opened: std::sync::Mutex>, } - #[async_trait(?Send)] + #[async_trait] impl ShareGateway for FakeSideEffects { async fn share( &self, resource: ShareResource, id: &str, ) -> Result { - self.shared.borrow_mut().push((resource, id.to_string())); + self.shared.lock().unwrap().push((resource, id.to_string())); Ok(format!("https://share.example/{id}")) } @@ -963,14 +971,14 @@ mod tests { resource: ShareResource, id: &str, ) -> Result<(), crate::services::error::ServiceError> { - self.shared.borrow_mut().push((resource, id.to_string())); + self.shared.lock().unwrap().push((resource, id.to_string())); Ok(()) } } impl BrowserOpener for FakeSideEffects { fn open(&self, url: &str) -> Result<(), crate::services::error::ServiceError> { - self.opened.borrow_mut().push(url.to_string()); + self.opened.lock().unwrap().push(url.to_string()); Ok(()) } } @@ -990,7 +998,7 @@ mod tests { let shared = service.share(&side_effects, &id).await.unwrap(); assert_eq!(shared.url, format!("https://share.example/{id}")); assert_eq!( - side_effects.shared.borrow().as_slice(), + side_effects.shared.lock().unwrap().as_slice(), &[(ShareResource::Note, id.clone())] ); @@ -1004,6 +1012,9 @@ mod tests { assert_eq!(opened.url, "https://app.example/notes/42"); assert!(!opened.url.contains(&id)); assert!(opened.opened); - assert_eq!(side_effects.opened.borrow().as_slice(), &[opened.url]); + assert_eq!( + side_effects.opened.lock().unwrap().as_slice(), + &[opened.url] + ); } } diff --git a/flicknote-core/src/services/ports.rs b/flicknote-core/src/services/ports.rs index a7dc303..d97a98e 100644 --- a/flicknote-core/src/services/ports.rs +++ b/flicknote-core/src/services/ports.rs @@ -17,6 +17,7 @@ pub struct CreateNote { pub project_id: Option, pub now: String, pub topics: Vec, + pub attachment_path: Option, } impl CreateNote { @@ -34,8 +35,8 @@ impl CreateNote { } } -#[async_trait(?Send)] -pub trait NoteCreator { +#[async_trait] +pub trait NoteCreator: Send + Sync { async fn create(&self, request: CreateNote) -> Result; } @@ -49,10 +50,16 @@ impl<'a> DirectNoteCreator<'a> { } } -#[async_trait(?Send)] +#[async_trait] impl NoteCreator for DirectNoteCreator<'_> { async fn create(&self, request: CreateNote) -> Result { - Ok(self.db.insert_note(&request.as_insert_request()).await?) + let inserted = self.db.insert_note(&request.as_insert_request()).await?; + if !request.topics.is_empty() { + self.db + .set_note_extractions(&inserted.uuid, crate::TOPIC_EXTRACTION_KEY, &request.topics) + .await?; + } + Ok(inserted) } } @@ -62,8 +69,8 @@ pub enum ShareResource { Project, } -#[async_trait(?Send)] -pub trait ShareGateway { +#[async_trait] +pub trait ShareGateway: Send + Sync { async fn share(&self, resource: ShareResource, id: &str) -> Result; async fn unshare(&self, resource: ShareResource, id: &str) -> Result<(), ServiceError>; } diff --git a/flicknote-core/src/services/project.rs b/flicknote-core/src/services/project.rs index b480372..2ded321 100644 --- a/flicknote-core/src/services/project.rs +++ b/flicknote-core/src/services/project.rs @@ -149,7 +149,6 @@ impl From for ProjectDto { #[cfg(all(test, feature = "powersync"))] mod tests { - use std::cell::RefCell; use crate::backend::NoteDb; use crate::services::dto::{Patch, ProjectAddInput, ProjectModifyInput}; @@ -207,16 +206,16 @@ mod tests { } #[derive(Default)] - struct FakeGateway(RefCell>); + struct FakeGateway(std::sync::Mutex>); - #[async_trait(?Send)] + #[async_trait] impl ShareGateway for FakeGateway { async fn share( &self, resource: ShareResource, id: &str, ) -> Result { - self.0.borrow_mut().push((resource, id.to_string())); + self.0.lock().unwrap().push((resource, id.to_string())); Ok("https://share.example/project".to_string()) } @@ -225,7 +224,7 @@ mod tests { resource: ShareResource, id: &str, ) -> Result<(), crate::services::error::ServiceError> { - self.0.borrow_mut().push((resource, id.to_string())); + self.0.lock().unwrap().push((resource, id.to_string())); Ok(()) } } @@ -243,7 +242,7 @@ mod tests { ); assert!(service.unshare(&gateway, &id).await.unwrap().revoked); assert_eq!( - gateway.0.borrow().as_slice(), + gateway.0.lock().unwrap().as_slice(), &[ (ShareResource::Project, id.clone()), (ShareResource::Project, id) diff --git a/flicknote-core/src/services/upload.rs b/flicknote-core/src/services/upload.rs new file mode 100644 index 0000000..55d08f8 --- /dev/null +++ b/flicknote-core/src/services/upload.rs @@ -0,0 +1,101 @@ +use std::path::Path; + +use crate::error::CliError; + +const ATTACHMENT_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "webp", "svg", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", + "ogg", "mp3", "wav", "m4a", "mp4", "mov", "avi", "webm", "csv", +]; +const TEXT_EXTENSIONS: &[&str] = &["md", "markdown", "txt"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UploadKind { + Text, + Attachment { + note_type: &'static str, + metadata: String, + }, +} + +pub fn classify(path: &Path) -> Result { + if !path.is_file() { + return Err(CliError::Other(format!( + "File not found or unsupported: {}", + path.display() + ))); + } + let extension = extension_of(path); + if TEXT_EXTENSIONS.contains(&extension.as_str()) { + return Ok(UploadKind::Text); + } + if !ATTACHMENT_EXTENSIONS.contains(&extension.as_str()) { + return Err(CliError::Other(format!( + "File not found or unsupported: {}", + path.display() + ))); + } + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| CliError::Other("Invalid filename".to_string()))?; + let note_type = note_type_for_extension(filename); + Ok(UploadKind::Attachment { + note_type, + metadata: metadata_for_upload(filename), + }) +} + +pub fn note_type_for_extension(filename: &str) -> &'static str { + match extension_of(Path::new(filename)).as_str() { + "ogg" | "mp3" | "wav" | "m4a" => "meeting", + "png" => "scan", + _ => "file", + } +} + +pub fn metadata_for_upload(filename: &str) -> String { + if note_type_for_extension(filename) == "meeting" { + return serde_json::json!({ "meeting": { "duration": 0 } }).to_string(); + } + serde_json::json!({ + "file": { + "name": filename, + "type": mime_from_extension(filename), + } + }) + .to_string() +} + +pub fn mime_from_extension(filename: &str) -> &'static str { + match extension_of(Path::new(filename)).as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "ogg" => "audio/ogg", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "avi" => "video/x-msvideo", + "webm" => "video/webm", + "pdf" => "application/pdf", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "csv" => "text/csv", + _ => "application/octet-stream", + } +} + +fn extension_of(path: &Path) -> String { + path.extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("") + .to_lowercase() +} diff --git a/flicknote-sync/Cargo.toml b/flicknote-sync/Cargo.toml index 5a57623..8c7f185 100644 --- a/flicknote-sync/Cargo.toml +++ b/flicknote-sync/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" dist = false [dependencies] -flicknote-core = { path = "../flicknote-core" } +flicknote-core = { path = "../flicknote-core", features = ["storage-pgwire"] } flicknote-auth = { path = "../flicknote-auth" } powersync = { workspace = true } rusqlite = { workspace = true } @@ -25,6 +25,7 @@ log = "0.4" libc = "0.2.182" notify = "8" uuid = { workspace = true } +chrono = "0.4" [lints] workspace = true diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs new file mode 100644 index 0000000..e6500f5 --- /dev/null +++ b/flicknote-sync/src/app.rs @@ -0,0 +1,499 @@ +use std::sync::Arc; + +use flicknote_core::backend::NoteDb; +use flicknote_core::services::dto::NoteAddInput; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::note::NoteService; +use flicknote_core::services::ports::{CreateNote, DirectNoteCreator, NoteCreator, ShareGateway}; +use flicknote_core::services::project::ProjectService; +use flicknote_core::services::upload::{self, UploadKind}; + +use crate::ipc::{AppRequest, AppResponse, BackendMode, WireError}; + +pub struct Application { + db: Arc, + mode: BackendMode, + creator: Option>, + share_gateway: Option>, + web_url: Option, +} + +impl Application { + pub fn new(db: Arc, mode: BackendMode) -> Self { + Self { + db, + mode, + creator: None, + share_gateway: None, + web_url: None, + } + } + + pub fn with_creator(mut self, creator: Arc) -> Self { + self.creator = Some(creator); + self + } + + pub fn with_share_gateway(mut self, gateway: Arc) -> Self { + self.share_gateway = Some(gateway); + self + } + + pub fn with_web_url(mut self, web_url: Option) -> Self { + self.web_url = web_url; + self + } + + pub fn mode(&self) -> BackendMode { + self.mode + } + + pub async fn handle(&self, request: AppRequest) -> Result { + let notes = NoteService::new(self.db.as_ref()); + let projects = ProjectService::new(self.db.as_ref()); + match request { + AppRequest::NoteAdd(input) => { + if let Some(creator) = self.creator.as_deref() { + return notes + .add(creator, input) + .await + .map(AppResponse::NoteSummary) + .map_err(WireError::from_service); + } + if self.mode == BackendMode::Managed { + return notes + .add(&DirectNoteCreator::new(self.db.as_ref()), input) + .await + .map(AppResponse::NoteSummary) + .map_err(WireError::from_service); + } + Err(Self::unsupported("note_add")) + } + AppRequest::NoteAddEditable { document, project } => { + let parsed = + flicknote_core::services::editable_document::parse_editable_note(&document) + .map_err(Self::db_error)?; + let project_id = match project.as_deref() { + Some(name) => Some( + self.db + .find_project_by_name(name) + .await + .map_err(Self::db_error)? + .ok_or_else(|| { + WireError::from_service(ServiceError::ProjectNotFound( + name.to_string(), + )) + })?, + ), + None => None, + }; + let request = CreateNote { + id: uuid::Uuid::new_v4().to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some(parsed.title), + content: Some(parsed.stored_content), + metadata: None, + project_id, + now: chrono::Utc::now().to_rfc3339(), + topics: parsed.topics, + attachment_path: None, + }; + let inserted = if let Some(creator) = self.creator.as_deref() { + creator.create(request).await + } else if self.mode == BackendMode::Managed { + DirectNoteCreator::new(self.db.as_ref()) + .create(request) + .await + } else { + return Err(Self::unsupported("note_add_editable")); + } + .map_err(WireError::from_service)?; + notes + .get(&inserted.uuid, false) + .await + .map(|detail| AppResponse::NoteSummary(detail.note)) + .map_err(WireError::from_service) + } + AppRequest::NoteUpload { + path, + project, + created_at, + } => { + let path = std::path::PathBuf::from(path); + match upload::classify(&path).map_err(Self::db_error)? { + UploadKind::Text => { + let content = std::fs::read_to_string(&path) + .map_err(|error| WireError::from_service(ServiceError::Io(error)))?; + if content.trim().is_empty() { + return Err(WireError::from_service(ServiceError::InvalidArgument( + "content must not be empty".to_string(), + ))); + } + let input = NoteAddInput { + content: content.trim_end().to_string(), + project, + interpret_as_url: false, + topics: Vec::new(), + created_at, + }; + if let Some(creator) = self.creator.as_deref() { + notes.add(creator, input).await + } else if self.mode == BackendMode::Managed { + notes + .add(&DirectNoteCreator::new(self.db.as_ref()), input) + .await + } else { + return Err(Self::unsupported("note_upload")); + } + .map(AppResponse::NoteSummary) + .map_err(WireError::from_service) + } + UploadKind::Attachment { + note_type, + metadata, + } => { + let creator = self + .creator + .as_deref() + .ok_or_else(|| Self::unsupported("attachment"))?; + let project_id = match project.as_deref() { + Some(name) => Some( + self.db + .find_project_by_name(name) + .await + .map_err(Self::db_error)? + .ok_or_else(|| { + WireError::from_service(ServiceError::ProjectNotFound( + name.to_string(), + )) + })?, + ), + None => None, + }; + let inserted = creator + .create(CreateNote { + id: uuid::Uuid::new_v4().to_string(), + note_type: note_type.to_string(), + status: "source_queued".to_string(), + title: None, + content: None, + metadata: Some(metadata), + project_id, + now: created_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + topics: Vec::new(), + attachment_path: Some(path.to_string_lossy().into_owned()), + }) + .await + .map_err(WireError::from_service)?; + notes + .get(&inserted.uuid, false) + .await + .map(|detail| AppResponse::NoteSummary(detail.note)) + .map_err(WireError::from_service) + } + } + } + AppRequest::NoteList(input) => notes + .list(input) + .await + .map(AppResponse::NoteSummaries) + .map_err(WireError::from_service), + AppRequest::NoteAppend { id, content } => notes + .append(&id, &content) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteSaveEditable { id, document } => { + let id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; + flicknote_core::services::editable_document::save_editable_note( + self.db.as_ref(), + &id, + &document, + ) + .await + .map(AppResponse::EditableSave) + .map_err(Self::db_error) + } + AppRequest::NoteFind(input) => notes + .find(input) + .await + .map(AppResponse::NoteSummaries) + .map_err(WireError::from_service), + AppRequest::NoteCount(input) => notes + .count(input) + .await + .map(|count| AppResponse::NoteCount { count }) + .map_err(WireError::from_service), + AppRequest::NoteGet { id, archived } => notes + .get(&id, archived) + .await + .map(AppResponse::NoteDetail) + .map_err(WireError::from_service), + AppRequest::NoteRecord { id, archived } => { + let id = if archived { + self.db.resolve_archived_note_id(&id).await + } else { + self.db.resolve_note_id(&id).await + } + .map_err(Self::db_error)?; + let note = if archived { + self.db.find_archived_note(&id).await + } else { + self.db.find_note(&id).await + } + .map_err(Self::db_error)?; + Ok(AppResponse::NoteRecord(note)) + } + AppRequest::NoteGetSection { id, section } => notes + .get_section(&id, §ion) + .await + .map(AppResponse::NoteSection) + .map_err(WireError::from_service), + AppRequest::NoteSource { + id, + archived, + view, + range, + } => notes + .source(&id, archived, view, range.as_deref()) + .await + .map(AppResponse::Source) + .map_err(WireError::from_service), + AppRequest::NoteReplaceSection { + id, + section, + content, + } => notes + .replace_section(&id, §ion, &content) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteRenameSection { id, section, name } => notes + .rename_section(&id, §ion, &name) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteInsert { + id, + section, + position, + content, + } => notes + .insert(&id, §ion, position, &content) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteDeleteSection { id, section } => notes + .delete_section(&id, §ion) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteModify(input) => notes + .modify(input) + .await + .map(AppResponse::NoteMutation) + .map_err(WireError::from_service), + AppRequest::NoteArchive { id } => notes + .archive(&id) + .await + .map(AppResponse::NoteArchive) + .map_err(WireError::from_service), + AppRequest::NoteRestore { id } => notes + .restore(&id) + .await + .map(AppResponse::NoteArchive) + .map_err(WireError::from_service), + AppRequest::NoteShare { id } => { + let gateway = self + .share_gateway + .as_deref() + .ok_or_else(|| Self::unsupported("note_share"))?; + notes + .share(gateway, &id) + .await + .map(AppResponse::Share) + .map_err(WireError::from_service) + } + AppRequest::NoteUnshare { id } => { + let gateway = self + .share_gateway + .as_deref() + .ok_or_else(|| Self::unsupported("note_unshare"))?; + notes + .unshare(gateway, &id) + .await + .map(AppResponse::Unshare) + .map_err(WireError::from_service) + } + AppRequest::NoteOpen { id } => { + let web_url = self.web_url.as_deref().ok_or_else(|| { + WireError::from_service(ServiceError::ConfigMissing("webUrl".to_string())) + })?; + let full_id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; + let note = self.db.find_note(&full_id).await.map_err(Self::db_error)?; + let url_id = note.short_id.map_or(full_id, |value| value.to_string()); + Ok(AppResponse::Open( + flicknote_core::services::dto::OpenResult { + url: format!("{}/notes/{url_id}", web_url.trim_end_matches('/')), + opened: false, + }, + )) + } + AppRequest::ProjectList { include_archived } => projects + .list(include_archived) + .await + .map(AppResponse::Projects) + .map_err(WireError::from_service), + AppRequest::ProjectRecords { include_archived } => { + let mut records = self.db.list_projects(false).await.map_err(Self::db_error)?; + if include_archived { + records.extend(self.db.list_projects(true).await.map_err(Self::db_error)?); + records.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + } + Ok(AppResponse::ProjectRecords(records)) + } + AppRequest::ProjectGet { id } => projects + .get(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectAdd(input) => projects + .add(input) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectModify(input) => projects + .modify(input) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectArchive { id } => projects + .archive(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectShare { id } => { + let gateway = self + .share_gateway + .as_deref() + .ok_or_else(|| Self::unsupported("project_share"))?; + projects + .share(gateway, &id) + .await + .map(AppResponse::Share) + .map_err(WireError::from_service) + } + AppRequest::ProjectUnshare { id } => { + let gateway = self + .share_gateway + .as_deref() + .ok_or_else(|| Self::unsupported("project_unshare"))?; + projects + .unshare(gateway, &id) + .await + .map(AppResponse::Unshare) + .map_err(WireError::from_service) + } + AppRequest::KeytermAdd { + name, + description, + content, + } => { + if name.trim().is_empty() { + return Err(WireError::from_service(ServiceError::InvalidArgument( + "keyterm name must not be empty".to_string(), + ))); + } + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + self.db + .insert_keyterm(&id, &name, description.as_deref(), content.as_deref(), &now) + .await + .map_err(Self::db_error)?; + self.db + .find_keyterm(&id) + .await + .map(AppResponse::Keyterm) + .map_err(Self::db_error) + } + AppRequest::KeytermList => self + .db + .list_keyterms() + .await + .map(AppResponse::Keyterms) + .map_err(Self::db_error), + AppRequest::KeytermGet { id } => { + let id = self + .db + .resolve_keyterm_id(&id) + .await + .map_err(Self::db_error)?; + self.db + .find_keyterm(&id) + .await + .map(AppResponse::Keyterm) + .map_err(Self::db_error) + } + AppRequest::KeytermModify { + id, + name, + description, + content, + } => { + if name.is_none() && description.is_none() && content.is_none() { + return Err(WireError::from_service(ServiceError::NothingToModify)); + } + let id = self + .db + .resolve_keyterm_id(&id) + .await + .map_err(Self::db_error)?; + self.db + .update_keyterm( + &id, + name.as_deref(), + description.as_deref(), + content.as_deref(), + ) + .await + .map_err(Self::db_error)?; + self.db + .find_keyterm(&id) + .await + .map(AppResponse::Keyterm) + .map_err(Self::db_error) + } + AppRequest::KeytermDelete { id } => { + let id = self + .db + .resolve_keyterm_id(&id) + .await + .map_err(Self::db_error)?; + self.db.delete_keyterm(&id).await.map_err(Self::db_error)?; + Ok(AppResponse::Id { id }) + } + AppRequest::ExtractionValues { keys, archived } => { + let refs = keys.iter().map(String::as_str).collect::>(); + self.db + .list_extraction_values(&refs, archived) + .await + .map(AppResponse::Values) + .map_err(Self::db_error) + } + } + } + + fn unsupported(operation: &str) -> WireError { + WireError { + code: "unsupported_operation".to_string(), + message: format!("{operation} is not available in this daemon mode"), + retryable: false, + details: None, + } + } + + fn db_error(error: flicknote_core::error::CliError) -> WireError { + WireError::from_service(ServiceError::from(error)) + } +} diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index ca17972..aa69890 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -4,17 +4,253 @@ use std::path::PathBuf; use async_trait::async_trait; use flicknote_core::backend::InsertedNote; use flicknote_core::config::Config; +use flicknote_core::services::dto::{ + InsertPosition, NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, + NoteListInput, NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, + ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, +}; +use flicknote_core::services::editable_document::EditableSaveResult; use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{ CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource, }; +use flicknote_core::services::source::{SourceResult, SourceView}; +use flicknote_core::types::{Keyterm, Note, Project}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixListener; use tokio::net::UnixStream; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +use crate::app::Application; + +pub const PROTOCOL_VERSION: u16 = 1; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BackendMode { + Local, + Managed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + Data, + NoteAdd, + Attachment, + Share, + LocalSync, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub protocol: u16, + pub backend: BackendMode, + pub capabilities: Vec, +} + +impl ServerInfo { + pub fn local() -> Self { + Self { + protocol: PROTOCOL_VERSION, + backend: BackendMode::Local, + capabilities: vec![ + Capability::Data, + Capability::NoteAdd, + Capability::Attachment, + Capability::Share, + Capability::LocalSync, + ], + } + } + + pub fn managed() -> Self { + Self { + protocol: PROTOCOL_VERSION, + backend: BackendMode::Managed, + capabilities: vec![Capability::Data, Capability::NoteAdd], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum AppRequest { + NoteAdd(NoteAddInput), + NoteAddEditable { + document: String, + project: Option, + }, + NoteUpload { + path: String, + project: Option, + created_at: Option, + }, + NoteList(NoteListInput), + NoteFind(NoteFindInput), + NoteCount(NoteCountInput), + NoteGet { + id: String, + archived: bool, + }, + NoteRecord { + id: String, + archived: bool, + }, + NoteGetSection { + id: String, + section: String, + }, + NoteSource { + id: String, + archived: bool, + view: SourceView, + range: Option, + }, + NoteAppend { + id: String, + content: String, + }, + NoteSaveEditable { + id: String, + document: String, + }, + NoteReplaceSection { + id: String, + section: String, + content: String, + }, + NoteRenameSection { + id: String, + section: String, + name: String, + }, + NoteInsert { + id: String, + section: String, + position: InsertPosition, + content: String, + }, + NoteDeleteSection { + id: String, + section: String, + }, + NoteModify(NoteModifyInput), + NoteArchive { + id: String, + }, + NoteRestore { + id: String, + }, + NoteShare { + id: String, + }, + NoteUnshare { + id: String, + }, + NoteOpen { + id: String, + }, + ProjectList { + include_archived: bool, + }, + ProjectRecords { + include_archived: bool, + }, + ProjectGet { + id: String, + }, + ProjectAdd(ProjectAddInput), + ProjectModify(ProjectModifyInput), + ProjectArchive { + id: String, + }, + ProjectShare { + id: String, + }, + ProjectUnshare { + id: String, + }, + KeytermAdd { + name: String, + description: Option, + content: Option, + }, + KeytermList, + KeytermGet { + id: String, + }, + KeytermModify { + id: String, + name: Option, + description: Option, + content: Option, + }, + KeytermDelete { + id: String, + }, + ExtractionValues { + keys: Vec, + archived: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum AppResponse { + NoteSummary(NoteSummary), + NoteSummaries(Vec), + NoteCount { count: u64 }, + NoteDetail(NoteDetail), + NoteRecord(Note), + NoteSection(NoteSectionResult), + NoteMutation(NoteMutationResult), + EditableSave(EditableSaveResult), + NoteArchive(NoteArchiveResult), + Source(SourceResult), + Share(ShareResult), + Unshare(UnshareResult), + Open(OpenResult), + Projects(Vec), + ProjectRecords(Vec), + Project(ProjectDto), + Keyterms(Vec), + Keyterm(Keyterm), + Id { id: String }, + Values(Vec), + Unit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WireError { + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl WireError { + pub fn from_service(error: ServiceError) -> Self { + Self { + code: error.code().to_string(), + message: error.to_string(), + retryable: error.retryable(), + details: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum DaemonRequest { + Health { + protocol: u16, + }, + App { + protocol: u16, + request: Box, + }, CreateNote(Box), GetOrCreateShare(ShareRequest), RevokeShare(ShareRequest), @@ -56,9 +292,12 @@ impl CreateNoteRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum DaemonResponse { + ServerInfo(ServerInfo), + App(Box), + AppError(WireError), NoteCreated(CreatedNote), ShareUrl(ShareUrlResponse), ShareRevoked, @@ -144,9 +383,54 @@ impl<'a> DaemonClient<'a> { other => ServiceError::Daemon(other.to_string()), }) } + + pub async fn health(&self) -> Result { + match self + .request(DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }) + .await? + { + DaemonResponse::ServerInfo(info) if info.protocol == PROTOCOL_VERSION => Ok(info), + DaemonResponse::AppError(error) => Err(Self::remote_error(error)), + _ => Err(Self::protocol_mismatch()), + } + } + + pub async fn app(&self, request: AppRequest) -> Result { + match self + .request(DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(request), + }) + .await? + { + DaemonResponse::App(response) => Ok(*response), + DaemonResponse::AppError(error) => Err(Self::remote_error(error)), + _ => Err(Self::protocol_mismatch()), + } + } + + fn remote_error(error: WireError) -> ServiceError { + ServiceError::Remote { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + } + } + + fn protocol_mismatch() -> ServiceError { + ServiceError::Remote { + code: "daemon_protocol_mismatch".to_string(), + message: "The running sync daemon uses an incompatible protocol. Restart it with `flicknote sync stop && flicknote sync start`.".to_string(), + retryable: false, + details: None, + } + } } -#[async_trait(?Send)] +#[async_trait] impl NoteCreator for DaemonClient<'_> { async fn create(&self, request: CreateNote) -> Result { let response = self @@ -176,7 +460,7 @@ impl NoteCreator for DaemonClient<'_> { } } -#[async_trait(?Send)] +#[async_trait] impl ShareGateway for DaemonClient<'_> { async fn share(&self, resource: CoreShareResource, id: &str) -> Result { let response = self @@ -240,6 +524,77 @@ pub async fn write_response( write_json(stream, response).await } +pub async fn serve_app_once( + listener: UnixListener, + app: std::sync::Arc, + info: ServerInfo, +) -> Result<(), DaemonError> { + let (mut stream, _) = listener + .accept() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to accept daemon request: {error}"), + })?; + serve_app_stream(&mut stream, &app, &info).await +} + +pub async fn serve_app( + listener: UnixListener, + app: std::sync::Arc, + info: ServerInfo, +) -> Result<(), DaemonError> { + loop { + let (mut stream, _) = listener + .accept() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to accept daemon request: {error}"), + })?; + let app = std::sync::Arc::clone(&app); + let info = info.clone(); + tokio::spawn(async move { + if let Err(error) = serve_app_stream(&mut stream, &app, &info).await { + log::warn!("application IPC request failed: {error}"); + } + }); + } +} + +async fn serve_app_stream( + stream: &mut UnixStream, + app: &Application, + info: &ServerInfo, +) -> Result<(), DaemonError> { + let response = match read_request(stream).await? { + DaemonRequest::Health { protocol } if protocol == PROTOCOL_VERSION => { + DaemonResponse::ServerInfo(info.clone()) + } + DaemonRequest::App { protocol, request } if protocol == PROTOCOL_VERSION => { + match app.handle(*request).await { + Ok(response) => DaemonResponse::App(Box::new(response)), + Err(error) => DaemonResponse::AppError(error), + } + } + DaemonRequest::Health { protocol } | DaemonRequest::App { protocol, .. } => { + DaemonResponse::AppError(WireError { + code: "daemon_protocol_mismatch".to_string(), + message: format!( + "daemon protocol {PROTOCOL_VERSION} does not support client protocol {protocol}" + ), + retryable: false, + details: None, + }) + } + _ => DaemonResponse::AppError(WireError { + code: "daemon_protocol_mismatch".to_string(), + message: "legacy application request is not supported by this daemon".to_string(), + retryable: false, + details: None, + }), + }; + write_response(stream, &response).await +} + async fn write_json(stream: &mut UnixStream, value: &T) -> Result<(), DaemonError> { let bytes = serde_json::to_vec(value).map_err(|e| DaemonError::Other { message: format!("Failed to serialize daemon message: {e}"), @@ -381,6 +736,43 @@ mod tests { assert!(value["payload"].get("entities").is_none()); } + #[test] + fn versioned_health_and_app_requests_have_stable_contracts() { + let health = DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }; + assert_eq!( + serde_json::to_value(health).unwrap(), + json!({ + "type": "health", + "payload": { "protocol": PROTOCOL_VERSION } + }) + ); + + let request = DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(AppRequest::NoteList(NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + })), + }; + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["type"], "app"); + assert_eq!(value["payload"]["protocol"], PROTOCOL_VERSION); + assert_eq!(value["payload"]["request"]["type"], "note_list"); + } + + #[test] + fn server_info_reports_backend_mode_and_capabilities() { + let info = ServerInfo::local(); + assert_eq!(info.protocol, PROTOCOL_VERSION); + assert_eq!(info.backend, BackendMode::Local); + assert!(info.capabilities.contains(&Capability::NoteAdd)); + assert!(info.capabilities.contains(&Capability::Share)); + } + #[test] fn share_request_deserializes() { let value = json!({ @@ -444,6 +836,7 @@ mod tests { project_id: None, now: "2026-08-05T00:00:00Z".to_string(), topics: Vec::new(), + attachment_path: None, }; let created = DaemonClient::new(&config) @@ -519,4 +912,60 @@ mod tests { assert!(error.to_string().contains("remote failure")); server.await.unwrap(); } + + #[tokio::test] + async fn daemon_client_preserves_versioned_app_results_and_errors() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 7 })), + ) + .await; + let response = DaemonClient::new(&config) + .app(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap(); + assert!(matches!(response, AppResponse::NoteCount { count: 7 })); + assert!(matches!(server.await.unwrap(), DaemonRequest::App { .. })); + + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::AppError(WireError { + code: "note_not_found".to_string(), + message: "missing".to_string(), + retryable: false, + details: Some(json!({ "id": "42" })), + }), + ) + .await; + let error = DaemonClient::new(&config) + .app(AppRequest::NoteGet { + id: "42".to_string(), + archived: false, + }) + .await + .unwrap_err(); + assert_eq!(error.code(), "note_not_found"); + assert_eq!(error.to_string(), "missing"); + server.await.unwrap(); + } + + #[tokio::test] + async fn health_rejects_legacy_or_unexpected_daemon_responses() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response(&config, DaemonResponse::ShareRevoked).await; + let error = DaemonClient::new(&config).health().await.unwrap_err(); + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(error.to_string().contains("sync stop")); + server.await.unwrap(); + } } diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index eaebac1..c2590ec 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -6,7 +6,12 @@ use std::sync::Arc; use async_trait::async_trait; use flicknote_auth::client::GoTrueClient; use flicknote_core::{ - REMOTE_COMMITTED_INSERT_METADATA, TOPIC_EXTRACTION_KEY, config::Config, schema::app_schema, + REMOTE_COMMITTED_INSERT_METADATA, TOPIC_EXTRACTION_KEY, + backend::{NoteDb, SqliteBackend}, + config::Config, + db::Database, + schema::app_schema, + services::ports::{CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource}, }; use futures_lite::StreamExt; use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; @@ -18,7 +23,9 @@ use rusqlite::{OptionalExtension, params}; use serde::Deserialize; use tokio::{net::UnixListener, sync::mpsc}; +pub mod app; pub mod ipc; +use app::Application; use ipc::{ CreateNoteRequest, CreatedNote, DaemonError, DaemonRequest, DaemonResponse, ShareRequest, ShareResource, ShareUrlResponse, @@ -1276,6 +1283,104 @@ async fn lookup_remote_extraction( Ok(rows.pop()) } +struct RemoteNoteCreator { + db: PowerSyncDatabase, + auth: Arc, + http: reqwest::Client, + config: Arc, +} + +#[async_trait] +impl NoteCreator for RemoteNoteCreator { + async fn create( + &self, + request: CreateNote, + ) -> Result + { + let created = create_note_remotely( + &self.db, + &self.http, + &self.auth, + &self.config, + CreateNoteRequest { + id: request.id, + note_type: request.note_type, + status: request.status, + title: request.title, + content: request.content, + metadata: request.metadata, + project_id: request.project_id, + now: request.now, + topics: request.topics, + attachment_path: request.attachment_path, + }, + ) + .await + .map_err(|error| { + flicknote_core::services::error::ServiceError::Daemon(error.to_string()) + })?; + Ok(flicknote_core::backend::InsertedNote { + uuid: created.uuid, + short_id: Some(created.short_id), + }) + } +} + +struct RemoteShareGateway { + http: reqwest::Client, + auth: Arc, + config: Arc, + lock: Arc, +} + +#[async_trait] +impl ShareGateway for RemoteShareGateway { + async fn share( + &self, + resource: CoreShareResource, + id: &str, + ) -> Result { + let request = ShareRequest { + resource: match resource { + CoreShareResource::Note => ShareResource::Note, + CoreShareResource::Project => ShareResource::Project, + }, + id: id.to_string(), + }; + self.lock + .run(get_or_create_share( + &self.http, + &self.auth, + &self.config, + &request, + )) + .await + .map_err(|error| { + flicknote_core::services::error::ServiceError::Daemon(error.to_string()) + }) + } + + async fn unshare( + &self, + resource: CoreShareResource, + id: &str, + ) -> Result<(), flicknote_core::services::error::ServiceError> { + let request = ShareRequest { + resource: match resource { + CoreShareResource::Note => ShareResource::Note, + CoreShareResource::Project => ShareResource::Project, + }, + id: id.to_string(), + }; + self.lock + .run(revoke_share(&self.http, &self.auth, &self.config, &request)) + .await + .map_err(|error| { + flicknote_core::services::error::ServiceError::Daemon(error.to_string()) + }) + } +} + async fn serve_socket( listener: UnixListener, db: PowerSyncDatabase, @@ -1283,6 +1388,7 @@ async fn serve_socket( http: reqwest::Client, config: Arc, share_lock: Arc, + app: Arc, ) { loop { let (mut stream, _) = match listener.accept().await { @@ -1297,8 +1403,42 @@ async fn serve_socket( let http = http.clone(); let config = Arc::clone(&config); let share_lock = Arc::clone(&share_lock); + let app = Arc::clone(&app); tokio::spawn(async move { let response = match ipc::read_request(&mut stream).await { + Ok(DaemonRequest::Health { protocol }) => { + if protocol == ipc::PROTOCOL_VERSION { + DaemonResponse::ServerInfo(ipc::ServerInfo::local()) + } else { + DaemonResponse::AppError(ipc::WireError { + code: "daemon_protocol_mismatch".to_string(), + message: format!( + "daemon protocol {} does not support client protocol {protocol}", + ipc::PROTOCOL_VERSION + ), + retryable: false, + details: None, + }) + } + } + Ok(DaemonRequest::App { protocol, request }) => { + if protocol != ipc::PROTOCOL_VERSION { + DaemonResponse::AppError(ipc::WireError { + code: "daemon_protocol_mismatch".to_string(), + message: format!( + "daemon protocol {} does not support client protocol {protocol}", + ipc::PROTOCOL_VERSION + ), + retryable: false, + details: None, + }) + } else { + match app.handle(*request).await { + Ok(response) => DaemonResponse::App(Box::new(response)), + Err(error) => DaemonResponse::AppError(error), + } + } + } Ok(DaemonRequest::CreateNote(req)) => { match create_note_remotely(&db, &http, &auth, &config, *req).await { Ok(note) => DaemonResponse::NoteCreated(note), @@ -1333,13 +1473,18 @@ async fn serve_socket( } pub async fn run() -> Result<(), Box> { - let config = Config::load()?; - config.validate()?; + let config = Arc::new(Config::load()?); let pid_file = pid_path(&config); let _pid_guard = check_and_write_pid(&pid_file)?; let (socket_listener, _socket_guard) = bind_socket(&config)?; + if let Ok(database_url) = std::env::var("DATABASE_URL") { + return run_managed(socket_listener, database_url).await; + } + + config.validate()?; + PowerSyncEnvironment::powersync_auto_extension()?; let pool = ConnectionPool::open(&config.paths.db_file)?; @@ -1513,12 +1658,35 @@ pub async fn run() -> Result<(), Box> { } }); - let socket_config = Arc::new(config); + let user_id = flicknote_core::session::get_user_id(&config)?; + let backend: Arc = Arc::new(SqliteBackend { + db: Database::open_local(&config).await?, + user_id, + }); + let socket_config = Arc::clone(&config); let socket_config_for_task = Arc::clone(&socket_config); let socket_db = db.clone(); let socket_auth = Arc::clone(&auth); let socket_http = reqwest::Client::new(); let socket_share_lock = Arc::new(ShareRequestLock::default()); + let creator: Arc = Arc::new(RemoteNoteCreator { + db: db.clone(), + auth: Arc::clone(&auth), + http: socket_http.clone(), + config: Arc::clone(&config), + }); + let gateway: Arc = Arc::new(RemoteShareGateway { + http: socket_http.clone(), + auth: Arc::clone(&auth), + config: Arc::clone(&config), + lock: Arc::clone(&socket_share_lock), + }); + let app = Arc::new( + Application::new(backend, ipc::BackendMode::Local) + .with_creator(creator) + .with_share_gateway(gateway) + .with_web_url(config.web_url.clone()), + ); let mut socket_handle = tokio::spawn(async move { serve_socket( socket_listener, @@ -1527,6 +1695,7 @@ pub async fn run() -> Result<(), Box> { socket_http, socket_config_for_task, socket_share_lock, + app, ) .await; }); @@ -1571,6 +1740,22 @@ pub async fn run() -> Result<(), Box> { Ok(()) } +async fn run_managed( + listener: UnixListener, + database_url: String, +) -> Result<(), Box> { + let backend: Arc = + Arc::new(flicknote_core::pgwire::PgWireBackend::connect(&database_url).await?); + let app = Arc::new(Application::new(backend, ipc::BackendMode::Managed)); + log::info!("Managed daemon ready (pid {})", std::process::id()); + tokio::select! { + _ = tokio::signal::ctrl_c() => Ok(()), + result = ipc::serve_app(listener, app, ipc::ServerInfo::managed()) => { + result.map_err(Into::into) + } + } +} + #[cfg(test)] mod tests { use std::io::{Read, Write}; diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs new file mode 100644 index 0000000..78e6d5e --- /dev/null +++ b/flicknote-sync/tests/app_contract.rs @@ -0,0 +1,325 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use flicknote_core::backend::{InsertNoteReq, InsertedNote, NoteDb, SqliteBackend}; +use flicknote_core::config::{Config, ConfigPaths}; +use flicknote_core::db::Database; +use flicknote_core::services::dto::{NoteAddInput, NoteListInput, ProjectAddInput}; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::ports::{CreateNote, NoteCreator}; +use flicknote_sync::app::Application; +use flicknote_sync::ipc::{ + AppRequest, AppResponse, BackendMode, DaemonClient, ServerInfo, serve_app_once, +}; + +fn test_config(directory: &std::path::Path) -> Config { + Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: String::new(), + web_url: None, + paths: ConfigPaths { + config_dir: directory.to_path_buf(), + data_dir: directory.to_path_buf(), + config_file: directory.join("config.json"), + session_file: directory.join("session.json"), + db_file: directory.join("flicknote.db"), + log_file: directory.join("sync.log"), + }, + } +} + +#[test] +fn application_is_safe_to_share_between_daemon_request_tasks() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +struct RecordingCreator { + db: Arc, + request: std::sync::Mutex>, +} + +#[async_trait] +impl NoteCreator for RecordingCreator { + async fn create(&self, request: CreateNote) -> Result { + let inserted = self.db.insert_note(&request.as_insert_request()).await?; + *self.request.lock().unwrap() = Some(request); + Ok(inserted) + } +} + +#[tokio::test] +async fn app_routes_note_list_and_append_through_services() { + const NOTE_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + backend + .insert_note(&InsertNoteReq { + id: NOTE_ID, + note_type: "normal", + status: "ready", + title: Some("Title"), + content: Some("Body"), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z", + }) + .await + .unwrap(); + let app = Application::new(backend.clone(), BackendMode::Local); + + let listed = app + .handle(AppRequest::NoteList(NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + })) + .await + .unwrap(); + let AppResponse::NoteSummaries(notes) = listed else { + panic!("unexpected list response") + }; + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].uuid, NOTE_ID); + + let raw = app + .handle(AppRequest::NoteRecord { + id: NOTE_ID.to_string(), + archived: false, + }) + .await + .unwrap(); + let AppResponse::NoteRecord(raw) = raw else { + panic!("unexpected raw note response") + }; + assert_eq!(raw.content.as_deref(), Some("Body")); + + let appended = app + .handle(AppRequest::NoteAppend { + id: NOTE_ID.to_string(), + content: "More".to_string(), + }) + .await + .unwrap(); + let AppResponse::NoteMutation(result) = appended else { + panic!("unexpected append response") + }; + assert_eq!(result.note.uuid, NOTE_ID); + assert_eq!( + backend.find_note_content(NOTE_ID).await.unwrap(), + Some("Body\n\nMore".to_string()) + ); +} + +#[tokio::test] +async fn app_owns_project_keyterm_and_catalog_domain_operations() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Application::new(backend, BackendMode::Local); + + let keyterm = app + .handle(AppRequest::KeytermAdd { + name: "Rust".to_string(), + description: Some("Language".to_string()), + content: Some("ownership".to_string()), + }) + .await + .unwrap(); + let AppResponse::Keyterm(keyterm) = keyterm else { + panic!("unexpected keyterm response") + }; + assert_eq!(keyterm.name, "Rust"); + + let project = app + .handle(AppRequest::ProjectAdd(ProjectAddInput { + name: "work".to_string(), + keyterm: Some(keyterm.id.clone()), + color: Some("#123456".to_string()), + })) + .await + .unwrap(); + let AppResponse::Project(project) = project else { + panic!("unexpected project response") + }; + assert_eq!(project.name, "work"); + assert_eq!(project.keyterm_id.as_deref(), Some(keyterm.id.as_str())); + + let values = app + .handle(AppRequest::ExtractionValues { + keys: vec!["::topic".to_string()], + archived: false, + }) + .await + .unwrap(); + let AppResponse::Values(values) = values else { + panic!("unexpected catalog response") + }; + assert!(values.is_empty()); +} + +#[tokio::test] +async fn versioned_socket_routes_client_requests_through_application() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Arc::new(Application::new(backend, BackendMode::Local)); + let listener = + tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)).unwrap(); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::local())); + + let client = DaemonClient::new(&config); + let info = client.health().await.unwrap(); + assert_eq!(info.backend, BackendMode::Local); + server.await.unwrap().unwrap(); + + std::fs::remove_file(flicknote_sync::ipc::socket_path(&config)).unwrap(); + let listener = + tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)).unwrap(); + let directory2 = tempfile::tempdir().unwrap(); + let config2 = test_config(directory2.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config2).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Arc::new(Application::new(backend, BackendMode::Local)); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::local())); + let response = client + .app(AppRequest::NoteList(NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + })) + .await + .unwrap(); + assert!(matches!(response, AppResponse::NoteSummaries(notes) if notes.is_empty())); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn managed_app_adds_note_and_topics_through_the_backend() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Application::new(backend.clone(), BackendMode::Managed); + + let response = app + .handle(AppRequest::NoteAdd(NoteAddInput { + content: "# Title\n\nBody".to_string(), + project: None, + interpret_as_url: false, + topics: vec!["rust".to_string()], + created_at: Some("2026-01-02T03:04:05Z".to_string()), + })) + .await + .unwrap(); + let AppResponse::NoteSummary(note) = response else { + panic!("unexpected add response") + }; + assert_eq!(note.title.as_deref(), Some("Title")); + assert_eq!(note.created_at.as_deref(), Some("2026-01-02T03:04:05Z")); + assert_eq!( + backend + .list_note_topics(&[note.uuid.as_str()]) + .await + .unwrap() + .get(¬e.uuid) + .cloned() + .unwrap_or_default(), + vec!["rust".to_string()] + ); +} + +#[tokio::test] +async fn local_app_owns_attachment_normalization_and_creator_call() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let path = directory.path().join("report.pdf"); + std::fs::write(&path, b"pdf").unwrap(); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let creator = Arc::new(RecordingCreator { + db: backend.clone(), + request: std::sync::Mutex::new(None), + }); + let app = Application::new(backend, BackendMode::Local).with_creator(creator.clone()); + + let response = app + .handle(AppRequest::NoteUpload { + path: path.to_string_lossy().into_owned(), + project: None, + created_at: None, + }) + .await + .unwrap(); + assert!(matches!(response, AppResponse::NoteSummary(_))); + let request = creator.request.lock().unwrap(); + let request = request.as_ref().unwrap(); + assert_eq!(request.note_type, "file"); + assert_eq!(request.attachment_path.as_deref(), path.to_str()); + assert!(request.metadata.as_deref().unwrap().contains("report.pdf")); +} + +#[tokio::test] +async fn app_owns_editable_document_parsing_and_persistence() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Application::new(backend.clone(), BackendMode::Managed); + + let created = app + .handle(AppRequest::NoteAddEditable { + document: "---\ntitle: First\ntopics: [rust]\n---\n\nBody".to_string(), + project: None, + }) + .await + .unwrap(); + let AppResponse::NoteSummary(created) = created else { + panic!("unexpected editable create response") + }; + assert_eq!(created.title.as_deref(), Some("First")); + + let saved = app + .handle(AppRequest::NoteSaveEditable { + id: created.uuid.clone(), + document: "---\ntitle: Second\ntopics: [rust, daemon]\n---\n\nChanged".to_string(), + }) + .await + .unwrap(); + let AppResponse::EditableSave(saved) = saved else { + panic!("unexpected editable save response") + }; + assert!(saved.title_changed); + assert!(saved.content_changed); + assert_eq!( + backend + .find_note(&created.uuid) + .await + .unwrap() + .title + .as_deref(), + Some("Second") + ); +} From f0cac3665589575857f434afe256626b3fd33439 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 13:37:08 +0800 Subject: [PATCH 03/16] refactor(cli): route data operations through daemon --- AGENTS.md | 4 +- Cargo.lock | 112 +---- README.md | 22 +- flicknote-cli/Cargo.toml | 9 +- flicknote-cli/src/commands/add.rs | 168 +------- flicknote-cli/src/commands/append.rs | 19 +- flicknote-cli/src/commands/content.rs | 27 +- flicknote-cli/src/commands/count.rs | 15 +- flicknote-cli/src/commands/delete.rs | 24 +- flicknote-cli/src/commands/detail.rs | 30 +- flicknote-cli/src/commands/edit.rs | 113 ++--- flicknote-cli/src/commands/entity.rs | 24 +- flicknote-cli/src/commands/find.rs | 14 +- flicknote-cli/src/commands/import.rs | 85 ++-- flicknote-cli/src/commands/insert.rs | 21 +- flicknote-cli/src/commands/keyterm.rs | 87 ++-- flicknote-cli/src/commands/list.rs | 17 +- flicknote-cli/src/commands/mod.rs | 1 - flicknote-cli/src/commands/modify.rs | 17 +- flicknote-cli/src/commands/open.rs | 20 +- flicknote-cli/src/commands/project.rs | 93 +++-- flicknote-cli/src/commands/rename.rs | 19 +- flicknote-cli/src/commands/replace.rs | 19 +- flicknote-cli/src/commands/restore.rs | 17 +- flicknote-cli/src/commands/share.rs | 45 +- flicknote-cli/src/commands/source.rs | 14 +- flicknote-cli/src/commands/sync.rs | 43 +- flicknote-cli/src/commands/topic.rs | 15 +- flicknote-cli/src/commands/upload.rs | 143 +------ flicknote-cli/src/commands/upload_util.rs | 256 ------------ flicknote-cli/src/commands/util.rs | 74 +--- flicknote-cli/src/help/root.md | 3 +- flicknote-cli/src/main.rs | 277 +++--------- flicknote-cli/src/mcp/error.rs | 24 ++ flicknote-cli/src/mcp/server.rs | 343 ++++++++------- flicknote-cli/tests/mcp_stdio.rs | 133 ++++-- flicknote-sync/Cargo.toml | 1 - flicknote-sync/src/app.rs | 48 +++ flicknote-sync/src/ipc.rs | 487 +++++++--------------- flicknote-sync/src/lib.rs | 331 ++++++++------- flicknote-sync/tests/app_contract.rs | 34 ++ skills/flicknote.md | 6 +- 42 files changed, 1198 insertions(+), 2056 deletions(-) delete mode 100644 flicknote-cli/src/commands/upload_util.rs diff --git a/AGENTS.md b/AGENTS.md index 383fd29..383b27f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,10 @@ Local-first note management CLI with cloud sync via PowerSync and Supabase. Rust workspace with 4 crates: -- **flicknote-cli** — CLI package (`flicknote`, `flicknote-sync`): CLI commands and local stdio MCP server +- **flicknote-cli** — CLI package (`flicknote`, `flicknote-sync`): thin CLI/MCP clients and daemon entrypoint; data commands never open SQLite or Postgres - **flicknote-core** — Shared library (db, config, schema, types, session, services, DTOs, errors) - **flicknote-auth** — Supabase GoTrue authentication (OTP + OAuth2/PKCE) -- **flicknote-sync** — Background sync daemon library (PowerSync ↔ Supabase) +- **flicknote-sync** — Daemon application host, typed RPC boundary, backend ownership, and PowerSync ↔ Supabase sync ### modify vs replace diff --git a/Cargo.lock b/Cargo.lock index c3a64b6..c6a15a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cexpr", "clang-sys", "itertools", @@ -223,12 +223,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -779,7 +773,6 @@ dependencies = [ name = "flicknote-cli" version = "0.4.3" dependencies = [ - "async-trait", "chrono", "clap", "dirs", @@ -791,7 +784,6 @@ dependencies = [ "httpdate", "libc", "log", - "mime_guess", "open", "reqwest 0.12.28", "rmcp", @@ -839,7 +831,6 @@ dependencies = [ "futures-lite", "libc", "log", - "notify", "powersync", "reqwest 0.13.2", "rusqlite", @@ -888,15 +879,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.32" @@ -1450,26 +1432,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1614,26 +1576,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1677,7 +1619,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "plain", "redox_syscall 0.7.5", @@ -1778,7 +1720,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -1793,33 +1734,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2152,7 +2066,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ - "bitflags 2.11.0", + "bitflags", "memchr", "unicase", ] @@ -2299,7 +2213,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -2308,7 +2222,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -2534,7 +2448,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.11.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -2554,7 +2468,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2717,7 +2631,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -3024,7 +2938,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags 2.11.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3068,7 +2982,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags 2.11.0", + "bitflags", "byteorder", "chrono", "crc", @@ -3387,7 +3301,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", "futures-util", "http", @@ -3715,7 +3629,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -4183,7 +4097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap 2.13.0", "log", "serde", diff --git a/README.md b/README.md index 7bcbd46..ab93699 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # flicknote-cli -Local-first note management CLI with cloud sync. Captures, queries, and manages notes stored in a local SQLite database synced to the cloud via PowerSync and Supabase. +Daemon-backed note management CLI with local-first sync. The CLI and MCP server use a typed Unix-socket API; the daemon owns SQLite/PowerSync or the configured managed Postgres backend. ## Features @@ -95,8 +95,8 @@ flicknote list --type link --limit 10 flicknote find rust flicknote find rust effect # OR match across multiple keywords -# Note IDs are numeric short IDs from list/detail. Pending-sync notes may show -# a UUID prefix; full UUIDs are also accepted for compatibility. +# Note IDs are numeric short IDs from list/detail. Full UUIDs are also accepted +# for compatibility. # Get a specific note (use --tree to see section IDs) flicknote detail @@ -156,12 +156,11 @@ The MCP server exposes typed note, note-source, and project tools. Note content and exact `before`/`after` edits are JSON fields, so callers do not need shell heredocs. Note tools accept numeric short IDs and do not expose internal UUIDs; project tools use project names. `note_source` reads stored source data, while -`note_get` reads editable note content. It only supports the local PowerSync -workspace; if `DATABASE_URL` is set, startup fails before MCP protocol output -begins. `note_add`, note and -project sharing, and unsharing use the running sync daemon because those -operations require an account and network access. Other tools operate directly -on the local database. The server does not start the daemon automatically. +`note_get` reads editable note content. Every data tool uses the running daemon; +the MCP process never opens SQLite or connects to Postgres. The daemon chooses +one backend at startup: local PowerSync by default, or managed Postgres when +`DATABASE_URL` is set in the daemon environment. The server does not start the +daemon automatically. ## Configuration @@ -171,6 +170,7 @@ Environment variables: - `FLICKNOTE_SUPABASE_URL` - `FLICKNOTE_SUPABASE_KEY` - `FLICKNOTE_POWERSYNC_URL` +- `DATABASE_URL` (daemon-only managed backend selection) Data directory: `~/.local/share/flicknote/` @@ -180,10 +180,10 @@ Rust workspace with 4 crates: | Crate | Type | Purpose | |-------|------|---------| -| `flicknote-cli` | binary | CLI commands and installable sync daemon binary | +| `flicknote-cli` | binary | Thin CLI/MCP clients and installable daemon binary | | `flicknote-core` | library | Database, config, shared services, DTOs, types, schema | | `flicknote-auth` | library | Supabase auth (OTP + OAuth2/PKCE) | -| `flicknote-sync` | library | Background sync daemon implementation | +| `flicknote-sync` | library | Application RPC host, backend ownership, and PowerSync implementation | ## License diff --git a/flicknote-cli/Cargo.toml b/flicknote-cli/Cargo.toml index ea55799..5f1a1a6 100644 --- a/flicknote-cli/Cargo.toml +++ b/flicknote-cli/Cargo.toml @@ -18,17 +18,11 @@ path = "src/bin/flicknote-sync.rs" [package.metadata.dist] formula = "flicknote" -[features] -default = ["powersync", "storage-pgwire"] -powersync = ["flicknote-core/powersync"] -storage-pgwire = ["flicknote-core/storage-pgwire"] - [dependencies] flicknote-core = { path = "../flicknote-core", default-features = false } flicknote-auth = { path = "../flicknote-auth" } flicknote-sync = { path = "../flicknote-sync" } clap = { version = "4", features = ["derive"] } -uuid = { workspace = true } chrono = "0.4" url = "2" serde_json = { workspace = true } @@ -36,7 +30,6 @@ tokio = { workspace = true } libc = "0.2" dirs = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "charset", "http2", "rustls-tls"] } -mime_guess = "2" futures-util = "0.3" serde = { workspace = true } open = "5" @@ -48,8 +41,8 @@ schemars = "1" httpdate = "1.0.3" [dev-dependencies] -async-trait = { workspace = true } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite"] } +uuid = { workspace = true } [lints] workspace = true diff --git a/flicknote-cli/src/commands/add.rs b/flicknote-cli/src/commands/add.rs index 4144b9a..844b7fe 100644 --- a/flicknote-cli/src/commands/add.rs +++ b/flicknote-cli/src/commands/add.rs @@ -1,12 +1,7 @@ use clap::Args; -use flicknote_core::backend::{InsertNoteReq, InsertedNote, NoteDb}; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::dto::NoteAddInput; -use flicknote_core::services::note::NoteService; -use flicknote_core::services::ports::{DirectNoteCreator, NoteCreator}; -use flicknote_sync::ipc::DaemonClient; -use flicknote_sync::ipc::{CreateNoteRequest, DaemonRequest, DaemonResponse}; +use flicknote_core::services::dto::{NoteAddInput, NoteSummary}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use std::io::{IsTerminal, Read}; use super::util::{display_summary_id, resolve_project_arg}; @@ -23,24 +18,7 @@ pub(crate) struct AddArgs { project: Option, } -#[derive(Clone, Copy)] -pub(crate) enum AddCreateMode { - Local, - Daemon, -} - -impl AddCreateMode { - pub(crate) fn uses_daemon(self) -> bool { - matches!(self, Self::Daemon) - } -} - -pub(crate) async fn run( - db: &dyn NoteDb, - config: &Config, - args: &AddArgs, - mode: AddCreateMode, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &AddArgs) -> Result<(), CliError> { let content = match &args.value { Some(v) => v.to_owned(), None => { @@ -60,20 +38,14 @@ pub(crate) async fn run( }; let project = resolve_project_arg(&args.project); - let direct = DirectNoteCreator::new(db); - let daemon = DaemonClient::new(config); - let creator: &dyn NoteCreator = if mode.uses_daemon() { &daemon } else { &direct }; - let note = NoteService::new(db) - .add( - creator, - NoteAddInput { - content, - project: project.clone(), - interpret_as_url: args.value.is_some(), - topics: Vec::new(), - created_at: None, - }, - ) + let note: NoteSummary = daemon + .call(AppRequest::NoteAdd(NoteAddInput { + content, + project: project.clone(), + interpret_as_url: args.value.is_some(), + topics: Vec::new(), + created_at: None, + })) .await?; match project.as_deref() { Some(name) => println!( @@ -84,121 +56,3 @@ pub(crate) async fn run( } Ok(()) } - -pub(crate) fn daemon_create_request(req: &InsertNoteReq<'_>) -> CreateNoteRequest { - daemon_create_request_with_topics(req, &[]) -} - -pub(crate) fn daemon_create_request_with_topics( - req: &InsertNoteReq<'_>, - topics: &[String], -) -> CreateNoteRequest { - CreateNoteRequest { - id: req.id.to_string(), - note_type: req.note_type.to_string(), - status: req.status.to_string(), - title: req.title.map(str::to_string), - content: req.content.map(str::to_string), - metadata: req.metadata.map(str::to_string), - project_id: req.project_id.map(str::to_string), - now: req.now.to_string(), - topics: topics.to_vec(), - attachment_path: None, - } -} - -pub(crate) async fn create_note_with_daemon( - config: &Config, - req: CreateNoteRequest, -) -> Result { - match flicknote_sync::ipc::send_request(config, &DaemonRequest::CreateNote(Box::new(req))) - .await - .map_err(|e| CliError::Other(e.to_string()))? - { - DaemonResponse::NoteCreated(note) => Ok(InsertedNote { - uuid: note.uuid, - short_id: Some(note.short_id), - }), - DaemonResponse::Error(e) => Err(CliError::Other(e.to_string())), - _ => Err(CliError::Other( - "Sync daemon returned an unexpected response to the create request".into(), - )), - } -} - -/// Resolve project by name. Returns an error with a hint if the project doesn't exist. -pub(crate) async fn resolve_project(db: &dyn NoteDb, name: &str) -> Result { - match db.find_project_by_name(name).await? { - Some(id) => Ok(id), - None => Err(CliError::ProjectNotFound { - name: name.to_string(), - }), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn daemon_create_request_keeps_normal_note_fields() { - let req = daemon_create_request(&InsertNoteReq { - id: "note-id", - note_type: "normal", - status: "ai_queued", - title: Some("Title"), - content: Some("Body"), - metadata: None, - project_id: Some("project-id"), - now: "2026-06-26T00:00:00Z", - }); - - assert_eq!(req.id, "note-id"); - assert_eq!(req.note_type, "normal"); - assert_eq!(req.status, "ai_queued"); - assert_eq!(req.title.as_deref(), Some("Title")); - assert_eq!(req.content.as_deref(), Some("Body")); - assert_eq!(req.metadata, None); - assert_eq!(req.project_id.as_deref(), Some("project-id")); - assert_eq!(req.now, "2026-06-26T00:00:00Z"); - } - - #[test] - fn daemon_create_request_keeps_link_metadata() { - let metadata = serde_json::json!({ "link": { "url": "https://example.com" } }).to_string(); - let req = daemon_create_request(&InsertNoteReq { - id: "note-id", - note_type: "link", - status: "source_queued", - title: None, - content: None, - metadata: Some(&metadata), - project_id: None, - now: "2026-06-26T00:00:00Z", - }); - - assert_eq!(req.note_type, "link"); - assert_eq!(req.status, "source_queued"); - assert_eq!(req.metadata.as_deref(), Some(metadata.as_str())); - } - - #[test] - fn daemon_create_request_can_include_topics() { - let topics = vec!["rust".to_string()]; - let req = daemon_create_request_with_topics( - &InsertNoteReq { - id: "note-id", - note_type: "normal", - status: "ai_queued", - title: Some("Title"), - content: Some("Body"), - metadata: None, - project_id: None, - now: "2026-06-26T00:00:00Z", - }, - &topics, - ); - - assert_eq!(req.topics, topics); - } -} diff --git a/flicknote-cli/src/commands/append.rs b/flicknote-cli/src/commands/append.rs index e814a72..5a04835 100644 --- a/flicknote-cli/src/commands/append.rs +++ b/flicknote-cli/src/commands/append.rs @@ -1,9 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; - -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::NoteMutationResult; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, read_stdin_required}; @@ -16,13 +14,14 @@ pub(crate) struct AppendArgs { id: String, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &AppendArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &AppendArgs) -> Result<(), CliError> { let new_content = read_stdin_required()?; - let result = NoteService::new(db).append(&args.id, &new_content).await?; + let result: NoteMutationResult = daemon + .call(AppRequest::NoteAppend { + id: args.id.clone(), + content: new_content, + }) + .await?; println!("Appended to note {}.", display_summary_id(&result.note)); Ok(()) } diff --git a/flicknote-cli/src/commands/content.rs b/flicknote-cli/src/commands/content.rs index 723a57b..de248be 100644 --- a/flicknote-cli/src/commands/content.rs +++ b/flicknote-cli/src/commands/content.rs @@ -1,7 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::{NoteDetail, NoteSectionResult}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const CONTENT_HELP: &str = include_str!("../help/content.md"); @@ -15,11 +15,26 @@ pub(crate) struct ContentArgs { section: Option, } -pub(crate) async fn run(db: &dyn NoteDb, args: &ContentArgs) -> Result<(), CliError> { - let service = NoteService::new(db); +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ContentArgs) -> Result<(), CliError> { let output = match args.section.as_deref() { - Some(section) => service.get_section(&args.id, section).await?.content, - None => service.get(&args.id, false).await?.content, + Some(section) => { + daemon + .call::(AppRequest::NoteGetSection { + id: args.id.clone(), + section: section.to_string(), + }) + .await? + .content + } + None => { + daemon + .call::(AppRequest::NoteGet { + id: args.id.clone(), + archived: false, + }) + .await? + .content + } }; print!("{output}"); Ok(()) diff --git a/flicknote-cli/src/commands/count.rs b/flicknote-cli/src/commands/count.rs index 085b1f0..34afea4 100644 --- a/flicknote-cli/src/commands/count.rs +++ b/flicknote-cli/src/commands/count.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; -use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::{NoteCountInput, NoteService}; +use flicknote_core::services::dto::NoteCountInput; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::resolve_project_arg; @@ -21,19 +20,19 @@ pub(crate) struct CountArgs { keywords: Vec, } -pub(crate) async fn run(db: &dyn NoteDb, args: &CountArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &CountArgs) -> Result<(), CliError> { let project = resolve_project_arg(&args.project); - let count = match NoteService::new(db) - .count(NoteCountInput { + let count: u64 = match daemon + .call(AppRequest::NoteCount(NoteCountInput { keywords: args.keywords.clone(), project: project.clone(), note_type: args.r#type.clone(), archived: args.archived, - }) + })) .await { Ok(count) => count, - Err(ServiceError::ProjectNotFound(_)) => { + Err(error) if error.code() == "project_not_found" => { eprintln!( "Warning: no project found with name \"{}\".", project.as_deref().unwrap_or_default() diff --git a/flicknote-cli/src/commands/delete.rs b/flicknote-cli/src/commands/delete.rs index 4d819ba..71f85bd 100644 --- a/flicknote-cli/src/commands/delete.rs +++ b/flicknote-cli/src/commands/delete.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::{NoteArchiveResult, NoteMutationResult}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, print_section_tree}; @@ -15,14 +14,13 @@ pub(crate) struct DeleteArgs { section: Option, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &DeleteArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &DeleteArgs) -> Result<(), CliError> { if let Some(ref section_id) = args.section { - let result = NoteService::new(db) - .delete_section(&args.id, section_id) + let result: NoteMutationResult = daemon + .call(AppRequest::NoteDeleteSection { + id: args.id.clone(), + section: section_id.clone(), + }) .await?; println!( "Removed section {} from note {}.\n", @@ -31,7 +29,11 @@ pub(crate) async fn run( ); print_section_tree(&result.sections); } else { - let result = NoteService::new(db).archive(&args.id).await?; + let result: NoteArchiveResult = daemon + .call(AppRequest::NoteArchive { + id: args.id.clone(), + }) + .await?; let display_id = result .short_id .map(|id| id.to_string()) diff --git a/flicknote-cli/src/commands/detail.rs b/flicknote-cli/src/commands/detail.rs index 352b4c6..942d4dc 100644 --- a/flicknote-cli/src/commands/detail.rs +++ b/flicknote-cli/src/commands/detail.rs @@ -1,8 +1,8 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::NoteDetail; +use flicknote_core::types::Note; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, note_json, print_section_tree}; @@ -24,12 +24,13 @@ pub(crate) struct DetailArgs { archived: bool, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &DetailArgs, -) -> Result<(), CliError> { - let detail = NoteService::new(db).get(&args.id, args.archived).await?; +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &DetailArgs) -> Result<(), CliError> { + let detail: NoteDetail = daemon + .call(AppRequest::NoteGet { + id: args.id.clone(), + archived: args.archived, + }) + .await?; if args.tree { if detail.sections.is_empty() { println!("(no headings found)"); @@ -39,11 +40,12 @@ pub(crate) async fn run( return Ok(()); } if args.json { - let note = if args.archived { - db.find_archived_note(&detail.note.uuid).await? - } else { - db.find_note(&detail.note.uuid).await? - }; + let note: Note = daemon + .call(AppRequest::NoteRecord { + id: detail.note.uuid.clone(), + archived: args.archived, + }) + .await?; let value = note_json(¬e, detail.note.project.as_deref()); println!( "{}", diff --git a/flicknote-cli/src/commands/edit.rs b/flicknote-cli/src/commands/edit.rs index 70f0ed7..f16f45c 100644 --- a/flicknote-cli/src/commands/edit.rs +++ b/flicknote-cli/src/commands/edit.rs @@ -1,13 +1,9 @@ -use super::add::resolve_project; -use super::add::{AddCreateMode, create_note_with_daemon, daemon_create_request_with_topics}; -use super::util::{ - display_inserted_note_id, display_note_id, resolve_note_id, resolve_project_arg, -}; +use super::util::{display_summary_id, resolve_project_arg}; use clap::Args; -use flicknote_core::TOPIC_EXTRACTION_KEY; -use flicknote_core::backend::{InsertNoteReq, NoteDb}; -use flicknote_core::config::Config; use flicknote_core::error::CliError; +use flicknote_core::services::dto::{NoteDetail, NoteSummary}; +use flicknote_core::services::editable_document::EditableSaveResult; +use flicknote_sync::ipc::{AppRequest, DaemonClient, EditableDocument}; use std::io::Write; #[derive(Args)] pub(crate) struct EditArgs { @@ -70,10 +66,11 @@ fn open_in_editor(initial_content: &str) -> Result { Ok(content.trim_end().to_string()) } /// Edit an existing note. -async fn edit_existing(db: &dyn NoteDb, _config: &Config, id: &str) -> Result<(), CliError> { - let full_id = resolve_note_id(db, id).await?; - let display_content = - flicknote_core::services::editable_document::load_editable_note(db, &full_id).await?; +async fn edit_existing(daemon: &DaemonClient<'_>, id: &str) -> Result<(), CliError> { + let display_content = daemon + .call::(AppRequest::NoteLoadEditable { id: id.to_string() }) + .await? + .document; let edited = open_in_editor(&display_content)?; if edited == display_content.trim_end() { println!("No changes."); @@ -84,100 +81,64 @@ async fn edit_existing(db: &dyn NoteDb, _config: &Config, id: &str) -> Result<() "Edited content is empty — aborting. Use `flicknote delete` to remove a note.".into(), )); } - let result = - flicknote_core::services::editable_document::save_editable_note(db, &full_id, &edited) - .await?; + let result: EditableSaveResult = daemon + .call(AppRequest::NoteSaveEditable { + id: id.to_string(), + document: edited, + }) + .await?; + let note: NoteDetail = daemon + .call(AppRequest::NoteGet { + id: id.to_string(), + archived: false, + }) + .await?; if result.title_changed { - let note = db.find_note(&full_id).await?; - println!("Updated title for note {}.", display_note_id(¬e)); + println!("Updated title for note {}.", display_summary_id(¬e.note)); } if result.content_changed { - let note = db.find_note(&full_id).await?; - println!("Updated content for note {}.", display_note_id(¬e)); + println!( + "Updated content for note {}.", + display_summary_id(¬e.note) + ); } Ok(()) } /// Create a new note from editor. async fn create_from_editor( - db: &dyn NoteDb, - config: &Config, + daemon: &DaemonClient<'_>, project_arg: &Option, - mode: AddCreateMode, ) -> Result<(), CliError> { let edited = open_in_editor("")?; if edited.is_empty() { println!("Empty buffer — no note created."); return Ok(()); } - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let parsed = flicknote_core::services::editable_document::parse_editable_note(&edited)?; let effective_project = resolve_project_arg(project_arg); - let project_id = if let Some(ref name) = effective_project { - Some(resolve_project(db, name).await?) - } else { - None - }; - let inserted = if mode.uses_daemon() { - create_note_with_daemon( - config, - daemon_create_request_with_topics( - &InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some(parsed.title.as_str()), - content: flicknote_core::services::editable_document::normal_note_content_ref( - &parsed, - ), - metadata: None, - project_id: project_id.as_deref(), - now: &now, - }, - &parsed.topics, - ), - ) - .await? - } else { - db.insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some(parsed.title.as_str()), - content: flicknote_core::services::editable_document::normal_note_content_ref(&parsed), - metadata: None, - project_id: project_id.as_deref(), - now: &now, + let inserted: NoteSummary = daemon + .call(AppRequest::NoteAddEditable { + document: edited, + project: effective_project.clone(), }) - .await? - }; - if matches!(mode, AddCreateMode::Local) && !parsed.topics.is_empty() { - db.set_note_extractions(&id, TOPIC_EXTRACTION_KEY, &parsed.topics) - .await?; - } + .await?; match effective_project.as_deref() { Some(name) => println!( "Created note {} in project \"{name}\".", - display_inserted_note_id(&inserted) + display_summary_id(&inserted) ), - None => println!("Created note {}.", display_inserted_note_id(&inserted)), + None => println!("Created note {}.", display_summary_id(&inserted)), } Ok(()) } -pub(crate) async fn run( - db: &dyn NoteDb, - config: &Config, - args: &EditArgs, - mode: AddCreateMode, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &EditArgs) -> Result<(), CliError> { if args.id.is_some() && args.project.is_some() { return Err(CliError::Other( "--project is only valid when creating a new note (omit the ID)".into(), )); } match &args.id { - Some(id) => edit_existing(db, config, id).await, - None => create_from_editor(db, config, &args.project, mode).await, + Some(id) => edit_existing(daemon, id).await, + None => create_from_editor(daemon, &args.project).await, } } #[cfg(test)] diff --git a/flicknote-cli/src/commands/entity.rs b/flicknote-cli/src/commands/entity.rs index 8dd1e53..b7de14b 100644 --- a/flicknote-cli/src/commands/entity.rs +++ b/flicknote-cli/src/commands/entity.rs @@ -1,7 +1,7 @@ use clap::{Args, Subcommand}; use flicknote_core::ENTITY_EXTRACTION_KEYS; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const ENTITY_HELP: &str = include_str!("../help/entity.md"); const ENTITY_LIST_HELP: &str = "Examples: @@ -32,21 +32,27 @@ struct ListArgs { entity_type: Option, } -pub(crate) async fn run(db: &dyn NoteDb, args: &EntityArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &EntityArgs) -> Result<(), CliError> { match &args.command { - EntityCommands::List(args) => list(db, args).await, + EntityCommands::List(args) => list(daemon, args).await, } } -async fn list(db: &dyn NoteDb, args: &ListArgs) -> Result<(), CliError> { - let typed_key; +async fn list(daemon: &DaemonClient<'_>, args: &ListArgs) -> Result<(), CliError> { let keys = if let Some(ref entity_type) = args.entity_type { - typed_key = format!("::{entity_type}"); - vec![typed_key.as_str()] + vec![format!("::{entity_type}")] } else { - ENTITY_EXTRACTION_KEYS.to_vec() + ENTITY_EXTRACTION_KEYS + .iter() + .map(|key| (*key).to_string()) + .collect() }; - let values = db.list_extraction_values(&keys, false).await?; + let values: Vec = daemon + .call(AppRequest::ExtractionValues { + keys, + archived: false, + }) + .await?; println!("{}", values.join(", ")); Ok(()) } diff --git a/flicknote-cli/src/commands/find.rs b/flicknote-cli/src/commands/find.rs index d68e703..3ad9657 100644 --- a/flicknote-cli/src/commands/find.rs +++ b/flicknote-cli/src/commands/find.rs @@ -1,7 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; -use flicknote_core::services::note::{ExtractionFilterDto, NoteFindInput, NoteService}; +use flicknote_core::services::dto::{ExtractionFilterDto, NoteFindInput, NoteSummary}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{note_summaries_json, print_summaries_table, resolve_project_arg}; @@ -60,7 +60,7 @@ fn parse_search_input(args: &[String]) -> Result { }) } -pub(crate) async fn run(db: &dyn NoteDb, args: &FindArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &FindArgs) -> Result<(), CliError> { let project = resolve_project_arg(&args.project); if args.project.is_none() && let Some(name) = project.as_deref() @@ -68,17 +68,17 @@ pub(crate) async fn run(db: &dyn NoteDb, args: &FindArgs) -> Result<(), CliError eprintln!("Filtering by project \"{name}\" from $FLICKNOTE_PROJECT."); } let parsed = parse_search_input(&args.keywords)?; - let notes = NoteService::new(db) - .find(NoteFindInput { + let notes: Vec = daemon + .call(AppRequest::NoteFind(NoteFindInput { keywords: parsed.keywords, extractions: parsed.extractions, project, archived: args.archived, limit: args.limit, - }) + })) .await?; if args.json { - let values = note_summaries_json(db, ¬es, args.archived).await?; + let values = note_summaries_json(daemon, ¬es, args.archived).await?; println!( "{}", serde_json::to_string_pretty(&values).map_err(CliError::Json)? diff --git a/flicknote-cli/src/commands/import.rs b/flicknote-cli/src/commands/import.rs index 53a178b..31a9efc 100644 --- a/flicknote-cli/src/commands/import.rs +++ b/flicknote-cli/src/commands/import.rs @@ -1,12 +1,11 @@ use std::path::{Path, PathBuf}; use clap::Args; -use flicknote_core::backend::{InsertNoteReq, NoteDb}; -use flicknote_core::config::Config; use flicknote_core::error::CliError; +use flicknote_core::services::dto::NoteSummary; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; -use super::add::{AddCreateMode, create_note_with_daemon, daemon_create_request, resolve_project}; -use super::util::{display_inserted_note_id, resolve_project_arg}; +use super::util::{display_summary_id, resolve_project_arg}; #[derive(Args)] pub(crate) struct ImportArgs { @@ -17,12 +16,7 @@ pub(crate) struct ImportArgs { project: Option, } -pub(crate) async fn run( - db: &dyn NoteDb, - config: &Config, - args: &ImportArgs, - mode: AddCreateMode, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ImportArgs) -> Result<(), CliError> { // Collect .md files let files = collect_md_files(&args.path)?; if files.is_empty() { @@ -30,68 +24,41 @@ pub(crate) async fn run( return Ok(()); } - // Resolve project if specified let effective_project = resolve_project_arg(&args.project); - let project_id = if let Some(ref name) = effective_project { - Some(resolve_project(db, name).await?) - } else { - None - }; - let mut imported = Vec::new(); for file in &files { - let content = std::fs::read_to_string(file) - .map_err(|e| CliError::Other(format!("Failed to read {}: {}", file.display(), e)))?; - - if content.trim().is_empty() { - continue; - } - - let id = uuid::Uuid::new_v4().to_string(); - let (title, stripped_content) = - flicknote_core::services::note_content::extract_title_and_strip(&content); let created_at = file_created_time(file); - - let inserted = if mode.uses_daemon() { - create_note_with_daemon( - config, - daemon_create_request(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: title.as_deref(), - content: Some(&stripped_content), - metadata: None, - project_id: project_id.as_deref(), - now: &created_at, - }), - ) - .await? - } else { - db.insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: title.as_deref(), - content: Some(&stripped_content), - metadata: None, - project_id: project_id.as_deref(), - now: &created_at, + let path = std::fs::canonicalize(file).map_err(|error| { + CliError::Other(format!("Failed to resolve {}: {error}", file.display())) + })?; + let inserted: NoteSummary = match daemon + .call(AppRequest::NoteUpload { + path: path.to_string_lossy().into_owned(), + project: effective_project.clone(), + created_at: Some(created_at), }) - .await? + .await + { + Ok(note) => note, + Err(error) + if error.code() == "invalid_argument" + && error.to_string().contains("content must not be empty") => + { + continue; + } + Err(error) => return Err(error.into()), }; - - imported.push((inserted, title, file.clone())); + imported.push((inserted, file.clone())); } - for (inserted, title, file) in &imported { + for (inserted, file) in &imported { let filename = file.file_name().and_then(|s| s.to_str()).unwrap_or("?"); - let display_title = title.as_deref().unwrap_or("(untitled)"); + let display_title = inserted.title.as_deref().unwrap_or("(untitled)"); println!( "Imported {} → {} — {}", filename, - display_inserted_note_id(inserted), + display_summary_id(inserted), display_title ); } diff --git a/flicknote-cli/src/commands/insert.rs b/flicknote-cli/src/commands/insert.rs index 11bb599..52361c3 100644 --- a/flicknote-cli/src/commands/insert.rs +++ b/flicknote-cli/src/commands/insert.rs @@ -1,9 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::dto::InsertPosition; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::{InsertPosition, NoteMutationResult}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, print_section_tree, read_stdin_required}; @@ -25,11 +23,7 @@ pub(crate) struct InsertArgs { after: Option, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &InsertArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &InsertArgs) -> Result<(), CliError> { let (section, position) = match (&args.before, &args.after) { (Some(section), None) => (section.as_str(), InsertPosition::Before), (None, Some(section)) => (section.as_str(), InsertPosition::After), @@ -41,8 +35,13 @@ pub(crate) async fn run( }; let insert_content = read_stdin_required()?; - let result = NoteService::new(db) - .insert(&args.id, section, position, &insert_content) + let result: NoteMutationResult = daemon + .call(AppRequest::NoteInsert { + id: args.id.clone(), + section: section.to_string(), + position, + content: insert_content, + }) .await?; let position = match position { InsertPosition::Before => "before", diff --git a/flicknote-cli/src/commands/keyterm.rs b/flicknote-cli/src/commands/keyterm.rs index 8e3b09f..4983548 100644 --- a/flicknote-cli/src/commands/keyterm.rs +++ b/flicknote-cli/src/commands/keyterm.rs @@ -1,6 +1,7 @@ use clap::{Args, Subcommand}; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; +use flicknote_core::types::Keyterm; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const KEYTERM_HELP: &str = include_str!("../help/keyterm.md"); @@ -65,33 +66,30 @@ struct DeleteKeytermArgs { id: String, } -pub(crate) async fn run(db: &dyn NoteDb, args: &KeytermArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &KeytermArgs) -> Result<(), CliError> { match &args.command { - KeytermCommands::Add(a) => add(db, a).await, - KeytermCommands::List => list(db).await, - KeytermCommands::Detail(a) => detail(db, a).await, - KeytermCommands::Modify(a) => modify(db, a).await, - KeytermCommands::Delete(a) => delete(db, a).await, + KeytermCommands::Add(a) => add(daemon, a).await, + KeytermCommands::List => list(daemon).await, + KeytermCommands::Detail(a) => detail(daemon, a).await, + KeytermCommands::Modify(a) => modify(daemon, a).await, + KeytermCommands::Delete(a) => delete(daemon, a).await, } } -async fn add(db: &dyn NoteDb, args: &AddKeytermArgs) -> Result<(), CliError> { - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - db.insert_keyterm( - &id, - &args.name, - args.description.as_deref(), - args.content.as_deref(), - &now, - ) - .await?; - println!("Created keyterm \"{}\" ({}).", args.name, id); +async fn add(daemon: &DaemonClient<'_>, args: &AddKeytermArgs) -> Result<(), CliError> { + let keyterm: Keyterm = daemon + .call(AppRequest::KeytermAdd { + name: args.name.clone(), + description: args.description.clone(), + content: args.content.clone(), + }) + .await?; + println!("Created keyterm \"{}\" ({}).", keyterm.name, keyterm.id); Ok(()) } -async fn list(db: &dyn NoteDb) -> Result<(), CliError> { - let keyterms = db.list_keyterms().await?; +async fn list(daemon: &DaemonClient<'_>) -> Result<(), CliError> { + let keyterms: Vec = daemon.call(AppRequest::KeytermList).await?; if keyterms.is_empty() { println!("No keyterms found."); return Ok(()); @@ -110,9 +108,12 @@ async fn list(db: &dyn NoteDb) -> Result<(), CliError> { Ok(()) } -async fn detail(db: &dyn NoteDb, args: &DetailKeytermArgs) -> Result<(), CliError> { - let full_id = db.resolve_keyterm_id(&args.id).await?; - let keyterm = db.find_keyterm(&full_id).await?; +async fn detail(daemon: &DaemonClient<'_>, args: &DetailKeytermArgs) -> Result<(), CliError> { + let keyterm: Keyterm = daemon + .call(AppRequest::KeytermGet { + id: args.id.clone(), + }) + .await?; println!("ID: {}", keyterm.id); println!("Name: {}", keyterm.name); @@ -141,29 +142,25 @@ async fn detail(db: &dyn NoteDb, args: &DetailKeytermArgs) -> Result<(), CliErro Ok(()) } -async fn modify(db: &dyn NoteDb, args: &ModifyKeytermArgs) -> Result<(), CliError> { - let full_id = db.resolve_keyterm_id(&args.id).await?; - - if args.name.is_none() && args.content.is_none() && args.description.is_none() { - return Err(CliError::Other( - "Nothing to modify. Use --name, --content, or --description.".into(), - )); - } - - db.update_keyterm( - &full_id, - args.name.as_deref(), - args.description.as_deref(), - args.content.as_deref(), - ) - .await?; - println!("Updated keyterm {}.", full_id); +async fn modify(daemon: &DaemonClient<'_>, args: &ModifyKeytermArgs) -> Result<(), CliError> { + let keyterm: Keyterm = daemon + .call(AppRequest::KeytermModify { + id: args.id.clone(), + name: args.name.clone(), + description: args.description.clone(), + content: args.content.clone(), + }) + .await?; + println!("Updated keyterm {}.", keyterm.id); Ok(()) } -async fn delete(db: &dyn NoteDb, args: &DeleteKeytermArgs) -> Result<(), CliError> { - let full_id = db.resolve_keyterm_id(&args.id).await?; - db.delete_keyterm(&full_id).await?; - println!("Deleted keyterm {}.", full_id); +async fn delete(daemon: &DaemonClient<'_>, args: &DeleteKeytermArgs) -> Result<(), CliError> { + let id: String = daemon + .call(AppRequest::KeytermDelete { + id: args.id.clone(), + }) + .await?; + println!("Deleted keyterm {}.", id); Ok(()) } diff --git a/flicknote-cli/src/commands/list.rs b/flicknote-cli/src/commands/list.rs index f589dec..d797aab 100644 --- a/flicknote-cli/src/commands/list.rs +++ b/flicknote-cli/src/commands/list.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; -use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::{NoteListInput, NoteService}; +use flicknote_core::services::dto::{NoteListInput, NoteSummary}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{note_summaries_json, print_summaries_table, resolve_project_arg}; @@ -28,24 +27,24 @@ pub(crate) struct ListArgs { json: bool, } -pub(crate) async fn run(db: &dyn NoteDb, args: &ListArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ListArgs) -> Result<(), CliError> { let project = resolve_project_arg(&args.project); if args.project.is_none() && let Some(name) = project.as_deref() { eprintln!("Filtering by project \"{name}\" from $FLICKNOTE_PROJECT."); } - let notes = match NoteService::new(db) - .list(NoteListInput { + let notes: Vec = match daemon + .call(AppRequest::NoteList(NoteListInput { note_type: args.r#type.clone(), project: project.clone(), archived: args.archived, limit: args.limit, - }) + })) .await { Ok(notes) => notes, - Err(ServiceError::ProjectNotFound(_)) => { + Err(error) if error.code() == "project_not_found" => { eprintln!( "Warning: no project found with name \"{}\".", project.as_deref().unwrap_or_default() @@ -55,7 +54,7 @@ pub(crate) async fn run(db: &dyn NoteDb, args: &ListArgs) -> Result<(), CliError Err(error) => return Err(error.into()), }; if args.json { - let values = note_summaries_json(db, ¬es, args.archived).await?; + let values = note_summaries_json(daemon, ¬es, args.archived).await?; println!( "{}", serde_json::to_string_pretty(&values).map_err(CliError::Json)? diff --git a/flicknote-cli/src/commands/mod.rs b/flicknote-cli/src/commands/mod.rs index 254dff3..4aa0f78 100644 --- a/flicknote-cli/src/commands/mod.rs +++ b/flicknote-cli/src/commands/mod.rs @@ -27,5 +27,4 @@ pub(crate) mod source; pub(crate) mod sync; pub(crate) mod topic; pub(crate) mod upload; -pub(crate) mod upload_util; pub(crate) mod util; diff --git a/flicknote-cli/src/commands/modify.rs b/flicknote-cli/src/commands/modify.rs index e363e7e..07fa9fd 100644 --- a/flicknote-cli/src/commands/modify.rs +++ b/flicknote-cli/src/commands/modify.rs @@ -1,9 +1,8 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; +use flicknote_core::services::dto::{NoteModifyInput, NoteMutationResult}; use flicknote_core::services::edit_match::{is_edit_mode, parse_edit_input}; -use flicknote_core::services::note::{NoteModifyInput, NoteService}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, print_section_tree, try_read_stdin}; @@ -28,11 +27,7 @@ pub(crate) struct ModifyArgs { unflagged: bool, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &ModifyArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ModifyArgs) -> Result<(), CliError> { let piped = try_read_stdin()?; if let Some(input) = piped.as_deref() && !is_edit_mode(input) @@ -57,15 +52,15 @@ pub(crate) async fn run( } else { None }; - let result = NoteService::new(db) - .modify(NoteModifyInput { + let result: NoteMutationResult = daemon + .call(AppRequest::NoteModify(NoteModifyInput { id: args.id.clone(), before, after, section: args.section.clone(), project: args.project.clone(), flagged, - }) + })) .await?; println!("Modified note {}.\n", display_summary_id(&result.note)); diff --git a/flicknote-cli/src/commands/open.rs b/flicknote-cli/src/commands/open.rs index 2cf5ed2..660e288 100644 --- a/flicknote-cli/src/commands/open.rs +++ b/flicknote-cli/src/commands/open.rs @@ -1,10 +1,9 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; +use flicknote_core::services::dto::OpenResult; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::NoteService; use flicknote_core::services::ports::BrowserOpener; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; #[derive(Args)] pub(crate) struct OpenArgs { @@ -20,16 +19,13 @@ impl BrowserOpener for SystemBrowserOpener { } } -pub(crate) async fn run(db: &dyn NoteDb, config: &Config, args: &OpenArgs) -> Result<(), CliError> { - let web_url = config.web_url.as_deref().ok_or_else(|| { - CliError::Other( - "webUrl not configured. Set it in ~/.config/flicknote/config.json or FLICKNOTE_WEB_URL." - .into(), - ) - })?; - let result = NoteService::new(db) - .open(&SystemBrowserOpener, web_url, &args.id) +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &OpenArgs) -> Result<(), CliError> { + let result: OpenResult = daemon + .call(AppRequest::NoteOpen { + id: args.id.clone(), + }) .await?; + SystemBrowserOpener.open(&result.url)?; println!("Opened {}", result.url); Ok(()) } diff --git a/flicknote-cli/src/commands/project.rs b/flicknote-cli/src/commands/project.rs index a2d8743..c89984f 100644 --- a/flicknote-cli/src/commands/project.rs +++ b/flicknote-cli/src/commands/project.rs @@ -1,9 +1,8 @@ use clap::{Args, Subcommand}; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::dto::{Patch, ProjectAddInput, ProjectModifyInput}; -use flicknote_core::services::project::ProjectService; +use flicknote_core::services::dto::{Patch, ProjectAddInput, ProjectDto, ProjectModifyInput}; +use flicknote_core::types::{Keyterm, Project}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const PROJECT_HELP: &str = include_str!("../help/project.md"); @@ -14,16 +13,6 @@ pub(crate) struct ProjectArgs { command: ProjectCommands, } -impl ProjectArgs { - pub(crate) fn local_workspace_command_name(&self) -> Option<&'static str> { - match &self.command { - ProjectCommands::Share(_) => Some("project share"), - ProjectCommands::Unshare(_) => Some("project unshare"), - _ => None, - } - } -} - #[derive(Subcommand)] enum ProjectCommands { /// List projects @@ -94,42 +83,43 @@ struct DeleteProjectArgs { id: String, } -pub(crate) async fn run( - db: &dyn NoteDb, - config: &Config, - args: &ProjectArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ProjectArgs) -> Result<(), CliError> { match &args.command { - ProjectCommands::List(a) => list(db, a).await, - ProjectCommands::Add(a) => add(db, a).await, - ProjectCommands::Detail(a) => detail(db, a).await, - ProjectCommands::Share(a) => super::share::run_project(db, config, &a.id).await, - ProjectCommands::Unshare(a) => super::share::run_unshare_project(db, config, &a.id).await, - ProjectCommands::Modify(a) => modify(db, a).await, - ProjectCommands::Delete(a) => delete(db, a).await, + ProjectCommands::List(a) => list(daemon, a).await, + ProjectCommands::Add(a) => add(daemon, a).await, + ProjectCommands::Detail(a) => detail(daemon, a).await, + ProjectCommands::Share(a) => super::share::run_project(daemon, &a.id).await, + ProjectCommands::Unshare(a) => super::share::run_unshare_project(daemon, &a.id).await, + ProjectCommands::Modify(a) => modify(daemon, a).await, + ProjectCommands::Delete(a) => delete(daemon, a).await, } } -async fn add(db: &dyn NoteDb, args: &AddProjectArgs) -> Result<(), CliError> { - let project = ProjectService::new(db) - .add(ProjectAddInput { +async fn add(daemon: &DaemonClient<'_>, args: &AddProjectArgs) -> Result<(), CliError> { + let project: ProjectDto = daemon + .call(AppRequest::ProjectAdd(ProjectAddInput { name: args.name.clone(), keyterm: args.keyterm.clone(), color: args.color.clone(), - }) + })) .await?; println!("Created project \"{}\" ({}).", project.name, project.id); Ok(()) } -async fn list(db: &dyn NoteDb, args: &ListArgs) -> Result<(), CliError> { - let projects = ProjectService::new(db).list(args.include_archived).await?; +async fn list(daemon: &DaemonClient<'_>, args: &ListArgs) -> Result<(), CliError> { + let projects: Vec = daemon + .call(AppRequest::ProjectList { + include_archived: args.include_archived, + }) + .await?; if args.json { - let mut values = Vec::with_capacity(projects.len()); - for project in &projects { - values.push(db.find_project(&project.id).await?); - } + let values: Vec = daemon + .call(AppRequest::ProjectRecords { + include_archived: args.include_archived, + }) + .await?; println!( "{}", serde_json::to_string_pretty(&values).map_err(CliError::Json)? @@ -162,8 +152,12 @@ async fn list(db: &dyn NoteDb, args: &ListArgs) -> Result<(), CliError> { Ok(()) } -async fn detail(db: &dyn NoteDb, args: &DetailArgs) -> Result<(), CliError> { - let project = ProjectService::new(db).get(&args.id).await?; +async fn detail(daemon: &DaemonClient<'_>, args: &DetailArgs) -> Result<(), CliError> { + let project: ProjectDto = daemon + .call(AppRequest::ProjectGet { + id: args.id.clone(), + }) + .await?; println!("ID: {}", project.id); println!("Name: {}", project.name); @@ -171,7 +165,12 @@ async fn detail(db: &dyn NoteDb, args: &DetailArgs) -> Result<(), CliError> { println!("Color: {color}"); } if let Some(ref keyterm_id) = project.keyterm_id { - match db.find_keyterm(keyterm_id).await { + match daemon + .call::(AppRequest::KeytermGet { + id: keyterm_id.clone(), + }) + .await + { Ok(keyterm) => println!("Keyterm: {} ({keyterm_id})", keyterm.name), Err(error) => { eprintln!("warning: could not look up keyterm {keyterm_id} ({error})") @@ -196,25 +195,29 @@ async fn detail(db: &dyn NoteDb, args: &DetailArgs) -> Result<(), CliError> { Ok(()) } -async fn modify(db: &dyn NoteDb, args: &ModifyProjectArgs) -> Result<(), CliError> { +async fn modify(daemon: &DaemonClient<'_>, args: &ModifyProjectArgs) -> Result<(), CliError> { let patch = |value: &Option| match value.as_deref() { None => Patch::Missing, Some("none") => Patch::Null, Some(value) => Patch::Value(value.to_string()), }; - let project = ProjectService::new(db) - .modify(ProjectModifyInput { + let project: ProjectDto = daemon + .call(AppRequest::ProjectModify(ProjectModifyInput { id: args.id.clone(), keyterm: patch(&args.keyterm), color: patch(&args.color), - }) + })) .await?; println!("Updated project {}.", project.id); Ok(()) } -async fn delete(db: &dyn NoteDb, args: &DeleteProjectArgs) -> Result<(), CliError> { - let project = ProjectService::new(db).archive(&args.id).await?; +async fn delete(daemon: &DaemonClient<'_>, args: &DeleteProjectArgs) -> Result<(), CliError> { + let project: ProjectDto = daemon + .call(AppRequest::ProjectArchive { + id: args.id.clone(), + }) + .await?; println!("Deleted project {}.", project.id); Ok(()) } diff --git a/flicknote-cli/src/commands/rename.rs b/flicknote-cli/src/commands/rename.rs index 90f1328..89d4457 100644 --- a/flicknote-cli/src/commands/rename.rs +++ b/flicknote-cli/src/commands/rename.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::NoteMutationResult; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, print_section_tree}; @@ -17,13 +16,13 @@ pub(crate) struct RenameArgs { name: String, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &RenameArgs, -) -> Result<(), CliError> { - let result = NoteService::new(db) - .rename_section(&args.id, &args.section, &args.name) +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &RenameArgs) -> Result<(), CliError> { + let result: NoteMutationResult = daemon + .call(AppRequest::NoteRenameSection { + id: args.id.clone(), + section: args.section.clone(), + name: args.name.clone(), + }) .await?; println!( "Renamed section {} → '{}' in note {}.\n", diff --git a/flicknote-cli/src/commands/replace.rs b/flicknote-cli/src/commands/replace.rs index 9f7f7e7..a2b2a62 100644 --- a/flicknote-cli/src/commands/replace.rs +++ b/flicknote-cli/src/commands/replace.rs @@ -1,10 +1,9 @@ //! `flicknote replace` — overwrite a whole section. use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::NoteMutationResult; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, print_section_tree, try_read_stdin}; @@ -20,18 +19,18 @@ pub(crate) struct ReplaceArgs { section: String, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &ReplaceArgs, -) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ReplaceArgs) -> Result<(), CliError> { let Some(content) = try_read_stdin()? else { return Err(CliError::Other( "--section requires content from stdin".into(), )); }; - let result = NoteService::new(db) - .replace_section(&args.id, &args.section, &content) + let result: NoteMutationResult = daemon + .call(AppRequest::NoteReplaceSection { + id: args.id.clone(), + section: args.section.clone(), + content, + }) .await?; println!( "Replaced section in note {}.\n", diff --git a/flicknote-cli/src/commands/restore.rs b/flicknote-cli/src/commands/restore.rs index 4a7692d..3d63f2d 100644 --- a/flicknote-cli/src/commands/restore.rs +++ b/flicknote-cli/src/commands/restore.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::dto::NoteArchiveResult; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; #[derive(Args)] pub(crate) struct RestoreArgs { @@ -10,12 +9,12 @@ pub(crate) struct RestoreArgs { id: String, } -pub(crate) async fn run( - db: &dyn NoteDb, - _config: &Config, - args: &RestoreArgs, -) -> Result<(), CliError> { - let result = NoteService::new(db).restore(&args.id).await?; +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &RestoreArgs) -> Result<(), CliError> { + let result: NoteArchiveResult = daemon + .call(AppRequest::NoteRestore { + id: args.id.clone(), + }) + .await?; let display_id = result .short_id .map(|id| id.to_string()) diff --git a/flicknote-cli/src/commands/share.rs b/flicknote-cli/src/commands/share.rs index 3150906..7cb59ea 100644 --- a/flicknote-cli/src/commands/share.rs +++ b/flicknote-cli/src/commands/share.rs @@ -1,10 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; -use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; -use flicknote_core::services::project::ProjectService; -use flicknote_sync::ipc::DaemonClient; +use flicknote_core::services::dto::{ShareResult, UnshareResult}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const SHARE_HELP: &str = include_str!("../help/share.md"); const UNSHARE_HELP: &str = include_str!("../help/unshare.md"); @@ -23,49 +20,43 @@ pub(crate) struct UnshareArgs { pub(crate) id: String, } -pub(crate) async fn run_note( - db: &dyn NoteDb, - config: &Config, - args: &ShareArgs, -) -> Result<(), CliError> { - let result = NoteService::new(db) - .share(&DaemonClient::new(config), &args.id) +pub(crate) async fn run_note(daemon: &DaemonClient<'_>, args: &ShareArgs) -> Result<(), CliError> { + let result: ShareResult = daemon + .call(AppRequest::NoteShare { + id: args.id.clone(), + }) .await?; println!("{}", result.url); Ok(()) } -pub(crate) async fn run_project( - db: &dyn NoteDb, - config: &Config, - id: &str, -) -> Result<(), CliError> { - let result = ProjectService::new(db) - .share(&DaemonClient::new(config), id) +pub(crate) async fn run_project(daemon: &DaemonClient<'_>, id: &str) -> Result<(), CliError> { + let result: ShareResult = daemon + .call(AppRequest::ProjectShare { id: id.to_string() }) .await?; println!("{}", result.url); Ok(()) } pub(crate) async fn run_unshare_note( - db: &dyn NoteDb, - config: &Config, + daemon: &DaemonClient<'_>, args: &UnshareArgs, ) -> Result<(), CliError> { - NoteService::new(db) - .unshare(&DaemonClient::new(config), &args.id) + let _: UnshareResult = daemon + .call(AppRequest::NoteUnshare { + id: args.id.clone(), + }) .await?; println!("Share link revoked."); Ok(()) } pub(crate) async fn run_unshare_project( - db: &dyn NoteDb, - config: &Config, + daemon: &DaemonClient<'_>, id: &str, ) -> Result<(), CliError> { - ProjectService::new(db) - .unshare(&DaemonClient::new(config), id) + let _: UnshareResult = daemon + .call(AppRequest::ProjectUnshare { id: id.to_string() }) .await?; println!("Share link revoked."); Ok(()) diff --git a/flicknote-cli/src/commands/source.rs b/flicknote-cli/src/commands/source.rs index d6bba98..7503679 100644 --- a/flicknote-cli/src/commands/source.rs +++ b/flicknote-cli/src/commands/source.rs @@ -1,8 +1,7 @@ use clap::Args; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; -use flicknote_core::services::note::NoteService; use flicknote_core::services::source::{SourceResult, SourceView}; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const SOURCE_HELP: &str = include_str!("../help/source.md"); @@ -24,7 +23,7 @@ pub(crate) struct SourceArgs { archived: bool, } -pub(crate) async fn run(db: &dyn NoteDb, args: &SourceArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &SourceArgs) -> Result<(), CliError> { if args.info && args.json { return Err(CliError::Other( "--info cannot be used with --json source output".into(), @@ -37,8 +36,13 @@ pub(crate) async fn run(db: &dyn NoteDb, args: &SourceArgs) -> Result<(), CliErr } else { SourceView::Rendered }; - let result = NoteService::new(db) - .source(&args.id, args.archived, view, args.range.as_deref()) + let result: SourceResult = daemon + .call(AppRequest::NoteSource { + id: args.id.clone(), + archived: args.archived, + view, + range: args.range.clone(), + }) .await?; let output = match result { SourceResult::Rendered { content, .. } => content, diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 6ec88d6..dc0f366 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -12,15 +12,15 @@ pub(crate) struct SyncArgs { #[derive(Subcommand)] enum SyncCommand { - /// Start local sync service in background + /// Start the FlickNote daemon in background Start, - /// Stop local sync service + /// Stop the FlickNote daemon Stop, - /// Check local sync service status + /// Check daemon status Status, - /// Install local sync service + /// Install the local PowerSync daemon Install, - /// Uninstall local sync service + /// Uninstall the local PowerSync daemon Uninstall, } @@ -36,7 +36,7 @@ pub(crate) fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError> { fn start(config: &Config) -> Result<(), CliError> { if let Some(pid) = super::daemon::read_pid(config) { - println!("Local sync service already running (pid {pid})"); + println!("FlickNote daemon already running (pid {pid})"); return Ok(()); } @@ -63,34 +63,44 @@ fn start_with_binary(config: &Config, daemon_binary: &Path) -> Result<(), CliErr .spawn()?; let pid = child.id(); - println!("Local sync service started (pid {pid})"); + println!("FlickNote daemon started (pid {pid})"); Ok(()) } fn stop(config: &Config) -> Result<(), CliError> { if super::daemon::read_pid(config).is_none() { - println!("Local sync service not running"); + println!("FlickNote daemon not running"); return Ok(()); } super::daemon::stop(config)?; - println!("Local sync service stopped"); + println!("FlickNote daemon stopped"); Ok(()) } fn status(config: &Config) -> Result<(), CliError> { match super::daemon::read_pid(config) { - Some(pid) => println!("Local sync service: running (pid {pid})"), - None => println!("Local sync service: not running"), + Some(pid) => println!("FlickNote daemon: running (pid {pid})"), + None => println!("FlickNote daemon: not running"), } Ok(()) } fn install(config: &Config) -> Result<(), CliError> { + validate_install_mode(std::env::var("DATABASE_URL").ok().as_deref())?; super::daemon::install(config)?; println!("Installed and started: io.guion.flicknote.sync"); Ok(()) } +fn validate_install_mode(database_url: Option<&str>) -> Result<(), CliError> { + if database_url.is_some() { + return Err(CliError::Other( + "`flicknote sync install` only installs the local PowerSync daemon; start a managed daemon explicitly with `flicknote sync start`.".to_string(), + )); + } + Ok(()) +} + fn uninstall() -> Result<(), CliError> { super::daemon::uninstall()?; println!("Uninstalled: io.guion.flicknote.sync"); @@ -137,4 +147,15 @@ mod tests { assert!(!super::super::daemon::pid_file(&config).exists()); } + + #[test] + fn launchd_install_is_local_only() { + validate_install_mode(None).unwrap(); + let error = validate_install_mode(Some("postgres://managed")).unwrap_err(); + assert!( + error + .to_string() + .contains("only installs the local PowerSync daemon") + ); + } } diff --git a/flicknote-cli/src/commands/topic.rs b/flicknote-cli/src/commands/topic.rs index 7698290..88ee63d 100644 --- a/flicknote-cli/src/commands/topic.rs +++ b/flicknote-cli/src/commands/topic.rs @@ -1,7 +1,7 @@ use clap::{Args, Subcommand}; use flicknote_core::TOPIC_EXTRACTION_KEY; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; const TOPIC_HELP: &str = include_str!("../help/topic.md"); const TOPIC_LIST_HELP: &str = "Examples: @@ -26,15 +26,18 @@ enum TopicCommands { #[command(after_help = TOPIC_LIST_HELP)] struct ListArgs {} -pub(crate) async fn run(db: &dyn NoteDb, args: &TopicArgs) -> Result<(), CliError> { +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &TopicArgs) -> Result<(), CliError> { match &args.command { - TopicCommands::List(args) => list(db, args).await, + TopicCommands::List(args) => list(daemon, args).await, } } -async fn list(db: &dyn NoteDb, _args: &ListArgs) -> Result<(), CliError> { - let values = db - .list_extraction_values(&[TOPIC_EXTRACTION_KEY], false) +async fn list(daemon: &DaemonClient<'_>, _args: &ListArgs) -> Result<(), CliError> { + let values: Vec = daemon + .call(AppRequest::ExtractionValues { + keys: vec![TOPIC_EXTRACTION_KEY.to_string()], + archived: false, + }) .await?; println!("{}", values.join(", ")); Ok(()) diff --git a/flicknote-cli/src/commands/upload.rs b/flicknote-cli/src/commands/upload.rs index 9bae082..c84f79c 100644 --- a/flicknote-cli/src/commands/upload.rs +++ b/flicknote-cli/src/commands/upload.rs @@ -1,13 +1,9 @@ use clap::Args; -use flicknote_core::backend::{InsertNoteReq, NoteDb}; -use flicknote_core::config::Config; use flicknote_core::error::CliError; +use flicknote_core::services::dto::NoteSummary; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; -use super::add::{AddCreateMode, create_note_with_daemon, daemon_create_request, resolve_project}; -use super::upload_util::{ - is_text_import_file, is_uploadable_file, metadata_for_upload, note_type_for_extension, -}; -use super::util::{display_inserted_note_id, resolve_project_arg}; +use super::util::{display_summary_id, resolve_project_arg}; const UPLOAD_HELP: &str = include_str!("../help/upload.md"); @@ -21,133 +17,24 @@ pub(crate) struct UploadArgs { project: Option, } -pub(crate) async fn run( - db: &dyn NoteDb, - config: &Config, - args: &UploadArgs, - mode: AddCreateMode, -) -> Result<(), CliError> { - let is_text_import = is_text_import_file(&args.path); - let is_attachment = is_uploadable_file(&args.path); - if !(is_text_import || is_attachment) { - return Err(CliError::Other(format!( - "File not found or unsupported: {}", - args.path - ))); - } - - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let file_path = std::path::PathBuf::from(&args.path); +pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &UploadArgs) -> Result<(), CliError> { let effective_project = resolve_project_arg(&args.project); - let project_id = if let Some(ref name) = effective_project { - Some(resolve_project(db, name).await?) - } else { - None - }; - - let inserted = if is_text_import { - let content = std::fs::read_to_string(&file_path).map_err(|e| { - CliError::Other(format!("Failed to read {}: {}", file_path.display(), e)) - })?; - let content = content.trim_end().to_string(); - if content.is_empty() { - return Err(CliError::Other("No content provided".into())); - } - let (title, stripped_content) = - flicknote_core::services::note_content::extract_title_and_strip(&content); - let title_ref = title.as_deref(); - let req = InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: title_ref, - content: Some(&stripped_content), - metadata: None, - project_id: project_id.as_deref(), - now: &now, - }; - if mode.uses_daemon() { - create_note_with_daemon(config, daemon_create_request(&req)).await? - } else { - db.insert_note(&req).await? - } - } else { - validate_attachment_upload_supported(mode)?; - let filename = file_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| CliError::Other("Invalid filename".into()))? - .to_string(); - let note_type = note_type_for_extension(&filename); - let metadata = metadata_for_upload(&filename); - let req = InsertNoteReq { - id: &id, - note_type, - status: "source_queued", - title: None, - content: None, - metadata: Some(&metadata), - project_id: project_id.as_deref(), - now: &now, - }; - create_note_with_daemon( - config, - daemon_create_request(&req).with_attachment_path(file_path.to_string_lossy()), - ) - .await? - }; + let path = std::fs::canonicalize(&args.path) + .map_err(|_| CliError::Other(format!("File not found or unsupported: {}", args.path)))?; + let inserted: NoteSummary = daemon + .call(AppRequest::NoteUpload { + path: path.to_string_lossy().into_owned(), + project: effective_project.clone(), + created_at: None, + }) + .await?; match effective_project.as_deref() { Some(name) => println!( "Created note {} in project \"{name}\".", - display_inserted_note_id(&inserted) + display_summary_id(&inserted) ), - None => println!("Created note {}.", display_inserted_note_id(&inserted)), + None => println!("Created note {}.", display_summary_id(&inserted)), } Ok(()) } - -fn validate_attachment_upload_supported(mode: AddCreateMode) -> Result<(), CliError> { - if mode.uses_daemon() { - return Ok(()); - } - Err(CliError::Other( - "File uploads require the local sync daemon.".to_string(), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn daemon_request_for_upload_carries_attachment_path() { - let metadata = metadata_for_upload("report.pdf"); - let req = daemon_create_request(&InsertNoteReq { - id: "note-id", - note_type: note_type_for_extension("report.pdf"), - status: "source_queued", - title: None, - content: None, - metadata: Some(&metadata), - project_id: Some("project-id"), - now: "2026-06-26T00:00:00Z", - }) - .with_attachment_path("/tmp/report.pdf"); - - assert_eq!(req.note_type, "file"); - assert_eq!(req.status, "source_queued"); - assert_eq!(req.content, None); - assert_eq!(req.metadata.as_deref(), Some(metadata.as_str())); - assert_eq!(req.project_id.as_deref(), Some("project-id")); - assert_eq!(req.attachment_path.as_deref(), Some("/tmp/report.pdf")); - } - - #[test] - fn local_mode_rejects_attachment_uploads() { - let err = validate_attachment_upload_supported(AddCreateMode::Local).unwrap_err(); - - assert!(format!("{err}").contains("File uploads require the local sync daemon")); - } -} diff --git a/flicknote-cli/src/commands/upload_util.rs b/flicknote-cli/src/commands/upload_util.rs deleted file mode 100644 index 8c37ca6..0000000 --- a/flicknote-cli/src/commands/upload_util.rs +++ /dev/null @@ -1,256 +0,0 @@ -pub(crate) fn mime_from_extension(filename: &str) -> &'static str { - let ext = extension_of(filename); - match ext.as_str() { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "ogg" => "audio/ogg", - "mp3" => "audio/mpeg", - "wav" => "audio/wav", - "m4a" => "audio/mp4", - "mp4" => "video/mp4", - "mov" => "video/quicktime", - "avi" => "video/x-msvideo", - "webm" => "video/webm", - "pdf" => "application/pdf", - "doc" => "application/msword", - "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "ppt" => "application/vnd.ms-powerpoint", - "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "xls" => "application/vnd.ms-excel", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "csv" => "text/csv", - _ => "application/octet-stream", - } -} - -pub(crate) fn note_type_for_extension(filename: &str) -> &'static str { - let ext = extension_of(filename); - match ext.as_str() { - "ogg" | "mp3" | "wav" | "m4a" => "meeting", - "png" => "scan", - _ => "file", - } -} - -pub(crate) fn metadata_for_upload(filename: &str) -> String { - if note_type_for_extension(filename) == "meeting" { - return serde_json::json!({ - "meeting": { - "duration": 0 - } - }) - .to_string(); - } - - serde_json::json!({ - "file": { - "name": filename, - "type": mime_from_extension(filename) - } - }) - .to_string() -} - -const UPLOADABLE_EXTENSIONS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "webp", "svg", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", - "ogg", "mp3", "wav", "m4a", "mp4", "mov", "avi", "webm", "csv", -]; - -fn extension_of(filename: &str) -> String { - std::path::Path::new(filename) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase() -} - -fn file_has_extension(value: &str, allowed: &[&str]) -> bool { - let path = std::path::Path::new(value); - if !path.exists() || !path.is_file() { - return false; - } - allowed.contains(&extension_of(value).as_str()) -} - -pub(crate) fn is_uploadable_file(value: &str) -> bool { - file_has_extension(value, UPLOADABLE_EXTENSIONS) -} - -const TEXT_IMPORT_EXTENSIONS: &[&str] = &["md", "markdown", "txt"]; - -pub(crate) fn is_text_import_file(value: &str) -> bool { - file_has_extension(value, TEXT_IMPORT_EXTENSIONS) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_common_image_types() { - assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg"); - assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg"); - assert_eq!(mime_from_extension("image.png"), "image/png"); - assert_eq!(mime_from_extension("anim.gif"), "image/gif"); - assert_eq!(mime_from_extension("pic.webp"), "image/webp"); - assert_eq!(mime_from_extension("icon.svg"), "image/svg+xml"); - } - - #[test] - fn test_audio_types() { - assert_eq!(mime_from_extension("song.mp3"), "audio/mpeg"); - assert_eq!(mime_from_extension("clip.wav"), "audio/wav"); - assert_eq!(mime_from_extension("voice.m4a"), "audio/mp4"); - assert_eq!(mime_from_extension("track.ogg"), "audio/ogg"); - } - - #[test] - fn test_video_types() { - assert_eq!(mime_from_extension("movie.mp4"), "video/mp4"); - assert_eq!(mime_from_extension("clip.mov"), "video/quicktime"); - assert_eq!(mime_from_extension("old.avi"), "video/x-msvideo"); - assert_eq!(mime_from_extension("stream.webm"), "video/webm"); - } - - #[test] - fn test_document_types() { - assert_eq!(mime_from_extension("file.pdf"), "application/pdf"); - assert_eq!( - mime_from_extension("doc.docx"), - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ); - assert_eq!(mime_from_extension("data.csv"), "text/csv"); - } - - #[test] - fn test_is_uploadable_csv_file() { - let path = std::env::temp_dir().join("test_upload_util.csv"); - std::fs::write(&path, b"a,b,c").unwrap(); - let result = is_uploadable_file(path.to_str().unwrap()); - std::fs::remove_file(&path).unwrap(); - assert!(result, "should detect real csv file as uploadable"); - } - - #[test] - fn test_markdown_and_text_files_are_text_imports() { - let dir = tempfile::tempdir().unwrap(); - let md_path = dir.path().join("notes.md"); - let txt_path = dir.path().join("notes.txt"); - let markdown_path = dir.path().join("notes.markdown"); - std::fs::write(&md_path, "# Note\n\nBody").unwrap(); - std::fs::write(&txt_path, "Plain text").unwrap(); - std::fs::write(&markdown_path, "# Note\n\nBody").unwrap(); - - assert!(is_text_import_file(md_path.to_str().unwrap())); - assert!(is_text_import_file(txt_path.to_str().unwrap())); - assert!(is_text_import_file(markdown_path.to_str().unwrap())); - assert!(!is_uploadable_file(md_path.to_str().unwrap())); - assert!(!is_uploadable_file(txt_path.to_str().unwrap())); - assert!(!is_uploadable_file(markdown_path.to_str().unwrap())); - } - - #[test] - fn test_unknown_extension() { - assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream"); - } - - #[test] - fn test_no_extension() { - assert_eq!(mime_from_extension("README"), "application/octet-stream"); - } - - #[test] - fn test_case_insensitive() { - assert_eq!(mime_from_extension("photo.JPG"), "image/jpeg"); - assert_eq!(mime_from_extension("file.PDF"), "application/pdf"); - } - - #[test] - fn test_dotfile() { - assert_eq!( - mime_from_extension(".gitignore"), - "application/octet-stream" - ); - } - - #[test] - fn test_multiple_dots() { - assert_eq!( - mime_from_extension("archive.tar.gz"), - "application/octet-stream" - ); - } - - #[test] - fn test_note_type_scan_for_png() { - assert_eq!(note_type_for_extension("photo.png"), "scan"); - assert_eq!(note_type_for_extension("photo.PNG"), "scan"); - } - - #[test] - fn test_note_type_meeting_for_audio() { - assert_eq!(note_type_for_extension("song.mp3"), "meeting"); - assert_eq!(note_type_for_extension("clip.wav"), "meeting"); - assert_eq!(note_type_for_extension("voice.m4a"), "meeting"); - assert_eq!(note_type_for_extension("track.ogg"), "meeting"); - } - - #[test] - fn test_note_type_file_for_others() { - assert_eq!(note_type_for_extension("doc.pdf"), "file"); - assert_eq!(note_type_for_extension("slides.pptx"), "file"); - assert_eq!(note_type_for_extension("photo.jpg"), "file"); - } - - #[test] - fn test_upload_metadata_meeting_for_audio() { - assert_eq!( - metadata_for_upload("clip.wav"), - serde_json::json!({ "meeting": { "duration": 0 } }).to_string() - ); - } - - #[test] - fn test_upload_metadata_file_for_documents() { - assert_eq!( - metadata_for_upload("doc.pdf"), - serde_json::json!({ - "file": { - "name": "doc.pdf", - "type": "application/pdf" - } - }) - .to_string() - ); - } - - #[test] - fn test_is_uploadable_file_with_real_file() { - let path = std::env::temp_dir().join("test_upload_util.png"); - std::fs::write(&path, b"fake").unwrap(); - let result = is_uploadable_file(path.to_str().unwrap()); - std::fs::remove_file(&path).unwrap(); - assert!(result, "should detect real png file as uploadable"); - } - - #[test] - fn test_is_uploadable_file_nonexistent() { - assert!(!is_uploadable_file("nonexistent_file_12345.png")); - assert!(!is_uploadable_file("")); - assert!(!is_uploadable_file("just some text")); - assert!(!is_uploadable_file("https://example.com")); - } - - #[test] - fn test_png_not_readable_text() { - let dir = tempfile::tempdir().unwrap(); - let png_path = dir.path().join("image.png"); - std::fs::write(&png_path, [0x89, 0x50, 0x4E, 0x47]).unwrap(); - - let path_str = png_path.to_str().unwrap(); - assert!(is_uploadable_file(path_str)); - } -} diff --git a/flicknote-cli/src/commands/util.rs b/flicknote-cli/src/commands/util.rs index d895970..5be0520 100644 --- a/flicknote-cli/src/commands/util.rs +++ b/flicknote-cli/src/commands/util.rs @@ -1,26 +1,9 @@ -use flicknote_core::backend::InsertedNote; -use flicknote_core::backend::NoteDb; use flicknote_core::error::CliError; use flicknote_core::services::dto::{NoteSummary, SectionDto}; use flicknote_core::types::Note; +use flicknote_sync::ipc::{AppRequest, DaemonClient}; use std::io::{IsTerminal, Read}; -pub(crate) async fn resolve_note_id(db: &dyn NoteDb, prefix: &str) -> Result { - db.resolve_note_id(prefix).await -} - -pub(crate) fn display_note_id(note: &Note) -> String { - note.short_id - .map(|id| id.to_string()) - .unwrap_or_else(|| note.id.clone()) -} - -pub(crate) fn display_inserted_note_id(note: &InsertedNote) -> String { - note.short_id - .map(|id| id.to_string()) - .unwrap_or_else(|| note.uuid.clone()) -} - pub(crate) fn display_summary_id(note: &NoteSummary) -> String { note.short_id .map(|id| id.to_string()) @@ -46,17 +29,18 @@ pub(crate) fn note_json(note: &Note, project_name: Option<&str>) -> serde_json:: } pub(crate) async fn note_summaries_json( - db: &dyn NoteDb, + daemon: &DaemonClient<'_>, notes: &[NoteSummary], archived: bool, ) -> Result, CliError> { let mut values = Vec::with_capacity(notes.len()); for summary in notes { - let note = if archived { - db.find_archived_note(&summary.uuid).await? - } else { - db.find_note(&summary.uuid).await? - }; + let note: Note = daemon + .call(AppRequest::NoteRecord { + id: summary.uuid.clone(), + archived, + }) + .await?; values.push(note_json(¬e, None)); } Ok(values) @@ -179,45 +163,3 @@ pub(crate) fn classify_stdin_buf(buf: &str) -> Option { Some(trimmed.to_string()) } } - -#[cfg(test)] -mod tests { - use super::*; - - fn note_with_ids(id: &str, short_id: Option) -> Note { - Note { - id: id.to_string(), - short_id, - user_id: "test-user".to_string(), - r#type: "normal".to_string(), - status: "ai_queued".to_string(), - title: None, - content: None, - summary: None, - is_flagged: None, - project_id: None, - metadata: None, - source: None, - created_at: None, - updated_at: None, - deleted_at: None, - } - } - - #[test] - fn test_display_note_id_prefers_short_id() { - let note = note_with_ids("550e8400-e29b-41d4-a716-446655440000", Some(42)); - - assert_eq!(display_note_id(¬e), "42"); - } - - #[test] - fn test_display_note_id_uses_full_uuid_without_short_id() { - let note = note_with_ids("550e8400-e29b-41d4-a716-446655440000", None); - - assert_eq!( - display_note_id(¬e), - "550e8400-e29b-41d4-a716-446655440000" - ); - } -} diff --git a/flicknote-cli/src/help/root.md b/flicknote-cli/src/help/root.md index 1d13240..d78ae20 100644 --- a/flicknote-cli/src/help/root.md +++ b/flicknote-cli/src/help/root.md @@ -1,6 +1,7 @@ FlickNote works with local and managed workspaces. Managed workspaces support data commands that do not require local files or services. -Local workspaces are required for file, editor, browser, sharing, sync, sign-in, and skill commands. +Data commands require the FlickNote daemon. Start it with `flicknote sync start`. +The daemon selects local PowerSync or managed Postgres once at startup. Run `flicknote --help` for exact flags and examples. Common workflows: diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index c8bef06..9717e92 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -1,13 +1,9 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use clap::{CommandFactory, Parser, Subcommand}; -use flicknote_core::backend::NoteDb; -#[cfg(feature = "powersync")] -use flicknote_core::backend::SqliteBackend; use flicknote_core::config::Config; -#[cfg(feature = "powersync")] -use flicknote_core::db::Database; use flicknote_core::error::CliError; +use flicknote_sync::ipc::DaemonClient; const ROOT_HELP: &str = include_str!("help/root.md"); @@ -73,7 +69,7 @@ enum Commands { Login(commands::login::LoginArgs), /// Log out — remove saved session Logout, - /// Manage local workspace sync + /// Manage the FlickNote daemon Sync(commands::sync::SyncArgs), /// Install agent skills Skill(commands::skill::SkillArgs), @@ -91,66 +87,6 @@ enum Commands { Open(commands::open::OpenArgs), } -#[derive(Clone, Copy, PartialEq, Eq)] -enum WorkspaceMode { - Local, - Managed, -} - -impl WorkspaceMode { - fn detect() -> Self { - if std::env::var("DATABASE_URL").is_ok() { - Self::Managed - } else { - Self::Local - } - } -} - -impl Commands { - fn local_workspace_command_name(&self) -> Option<&'static str> { - match self { - Self::Mcp => Some("mcp"), - Self::Gateway(_) => Some("gateway"), - Self::Upload(_) => Some("upload"), - Self::Edit(_) => Some("edit"), - Self::Login(_) => Some("login"), - Self::Logout => Some("logout"), - Self::Sync(_) => Some("sync"), - Self::Skill(_) => Some("skill"), - Self::Import(_) => Some("import"), - Self::Open(_) => Some("open"), - Self::Share(_) => Some("share"), - Self::Unshare(_) => Some("unshare"), - Self::Project(args) => args.local_workspace_command_name(), - _ => None, - } - } -} - -fn enforce_workspace_gate(cli: &Cli, mode: WorkspaceMode) -> Result<(), CliError> { - if mode == WorkspaceMode::Local { - return Ok(()); - } - - let Some(ref command) = cli.command else { - return Ok(()); - }; - - if let Some(name) = command.local_workspace_command_name() { - return Err(local_workspace_required_error(name)); - } - - Ok(()) -} - -fn local_workspace_required_error(command: &str) -> CliError { - CliError::Other(format!( - "`flicknote {command}` is not available in managed workspaces.\n\ - Use a local workspace for file, editor, browser, Gateway, sharing, sync, sign-in, and skill commands." - )) -} - #[tokio::main(flavor = "current_thread")] async fn main() { if let Err(e) = run().await { @@ -161,8 +97,12 @@ async fn main() { async fn run() -> Result<(), CliError> { let cli = Cli::parse(); - let workspace_mode = WorkspaceMode::detect(); - enforce_workspace_gate(&cli, workspace_mode)?; + if cli.command.is_none() { + Cli::command() + .print_help() + .map_err(|error| CliError::Other(error.to_string()))?; + return Ok(()); + } let config = Config::load()?; // Commands that don't need a database connection or session @@ -177,45 +117,17 @@ async fn run() -> Result<(), CliError> { } } - // Backend selection: DATABASE_URL set → pgwire, else → SQLite (powersync) - #[cfg(feature = "storage-pgwire")] - if let Ok(database_url) = std::env::var("DATABASE_URL") { - let backend = flicknote_core::pgwire::PgWireBackend::connect(&database_url).await?; - return dispatch(&cli, &config, &backend, commands::add::AddCreateMode::Local).await; - } - - #[cfg(not(feature = "powersync"))] - return Err(CliError::Other( - "No storage backend available — set DATABASE_URL for pgwire, or build with powersync feature" - .into(), - )); - - #[cfg(feature = "powersync")] - { - let db = Database::open_local(&config).await?; - let user_id = flicknote_core::session::get_user_id(&config)?; - let backend: std::rc::Rc = std::rc::Rc::new(SqliteBackend { db, user_id }); - if matches!(cli.command, Some(Commands::Mcp)) { - return tokio::task::LocalSet::new() - .run_until(mcp::serve(backend, std::rc::Rc::new(config))) - .await; - } - dispatch( - &cli, - &config, - backend.as_ref(), - commands::add::AddCreateMode::Daemon, - ) - .await + let daemon = DaemonClient::new(&config); + daemon.health().await?; + if matches!(cli.command, Some(Commands::Mcp)) { + return tokio::task::LocalSet::new() + .run_until(mcp::serve(std::rc::Rc::new(config))) + .await; } + dispatch(&cli, &daemon).await } -async fn dispatch( - cli: &Cli, - config: &Config, - db: &dyn NoteDb, - add_mode: commands::add::AddCreateMode, -) -> Result<(), CliError> { +async fn dispatch(cli: &Cli, daemon: &DaemonClient<'_>) -> Result<(), CliError> { let Some(ref command) = cli.command else { Cli::command() .print_help() @@ -225,31 +137,31 @@ async fn dispatch( match command { Commands::Mcp => unreachable!("MCP is dispatched before regular CLI commands"), - Commands::Add(args) => commands::add::run(db, config, args, add_mode).await, - Commands::Upload(args) => commands::upload::run(db, config, args, add_mode).await, - Commands::Append(args) => commands::append::run(db, config, args).await, - Commands::Delete(args) => commands::delete::run(db, config, args).await, - Commands::Edit(args) => commands::edit::run(db, config, args, add_mode).await, - Commands::Restore(args) => commands::restore::run(db, config, args).await, - Commands::List(args) => commands::list::run(db, args).await, - Commands::Count(args) => commands::count::run(db, args).await, - Commands::Find(args) => commands::find::run(db, args).await, - Commands::Topic(args) => commands::topic::run(db, args).await, - Commands::Entity(args) => commands::entity::run(db, args).await, + Commands::Add(args) => commands::add::run(daemon, args).await, + Commands::Upload(args) => commands::upload::run(daemon, args).await, + Commands::Append(args) => commands::append::run(daemon, args).await, + Commands::Delete(args) => commands::delete::run(daemon, args).await, + Commands::Edit(args) => commands::edit::run(daemon, args).await, + Commands::Restore(args) => commands::restore::run(daemon, args).await, + Commands::List(args) => commands::list::run(daemon, args).await, + Commands::Count(args) => commands::count::run(daemon, args).await, + Commands::Find(args) => commands::find::run(daemon, args).await, + Commands::Topic(args) => commands::topic::run(daemon, args).await, + Commands::Entity(args) => commands::entity::run(daemon, args).await, Commands::Gateway(_) => unreachable!("Gateway is dispatched before database setup"), - Commands::Source(args) => commands::source::run(db, args).await, - Commands::Detail(args) => commands::detail::run(db, config, args).await, - Commands::Content(args) => commands::content::run(db, args).await, - Commands::Share(args) => commands::share::run_note(db, config, args).await, - Commands::Unshare(args) => commands::share::run_unshare_note(db, config, args).await, - Commands::Project(args) => commands::project::run(db, config, args).await, - Commands::Keyterm(args) => commands::keyterm::run(db, args).await, - Commands::Rename(args) => commands::rename::run(db, config, args).await, - Commands::Insert(args) => commands::insert::run(db, config, args).await, - Commands::Replace(args) => commands::replace::run(db, config, args).await, - Commands::Modify(args) => commands::modify::run(db, config, args).await, - Commands::Open(args) => commands::open::run(db, config, args).await, - Commands::Import(args) => commands::import::run(db, config, args, add_mode).await, + Commands::Source(args) => commands::source::run(daemon, args).await, + Commands::Detail(args) => commands::detail::run(daemon, args).await, + Commands::Content(args) => commands::content::run(daemon, args).await, + Commands::Share(args) => commands::share::run_note(daemon, args).await, + Commands::Unshare(args) => commands::share::run_unshare_note(daemon, args).await, + Commands::Project(args) => commands::project::run(daemon, args).await, + Commands::Keyterm(args) => commands::keyterm::run(daemon, args).await, + Commands::Rename(args) => commands::rename::run(daemon, args).await, + Commands::Insert(args) => commands::insert::run(daemon, args).await, + Commands::Replace(args) => commands::replace::run(daemon, args).await, + Commands::Modify(args) => commands::modify::run(daemon, args).await, + Commands::Open(args) => commands::open::run(daemon, args).await, + Commands::Import(args) => commands::import::run(daemon, args).await, // Login/Logout/Sync/Skill are handled before dispatch() is called Commands::Login(_) | Commands::Logout | Commands::Sync(_) | Commands::Skill(_) => { unreachable!() @@ -261,7 +173,6 @@ async fn dispatch( mod tests { use super::*; - #[cfg(feature = "powersync")] async fn call_mcp_tool( writer: &mut tokio::io::WriteHalf, reader: &mut tokio::io::BufReader>, @@ -286,7 +197,6 @@ mod tests { serde_json::from_str(&response).unwrap() } - #[cfg(feature = "powersync")] fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str) { match value { serde_json::Value::String(actual) => assert_ne!(actual, excluded), @@ -306,10 +216,14 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - #[cfg(feature = "powersync")] async fn mcp_server_lists_contract_and_calls_note_list() { + use flicknote_core::backend::{NoteDb, SqliteBackend}; + use flicknote_core::db::Database; + use flicknote_sync::app::Application; + use flicknote_sync::ipc::{BackendMode, ServerInfo, serve_app, socket_path}; use rmcp::ServiceExt; use std::rc::Rc; + use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; tokio::task::LocalSet::new() @@ -331,10 +245,10 @@ mod tests { }, }; let database = Database::open_local(&config).await.unwrap(); - let backend = SqliteBackend { + let backend = Arc::new(SqliteBackend { db: database, user_id: "test-user".to_string(), - }; + }); let project_id = backend.create_project("MCP Project").await.unwrap(); let note_id = uuid::Uuid::new_v4().to_string(); backend @@ -386,8 +300,17 @@ mod tests { .headings[0] .id .clone(); - let backend: Rc = Rc::new(backend); - let server = mcp::FlickNoteMcp::new(backend, Rc::new(config)); + let app = Arc::new( + Application::new(backend, BackendMode::Managed) + .with_web_url(config.web_url.clone()), + ); + let daemon_listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + let daemon_server = tokio::spawn(serve_app( + daemon_listener, + app, + ServerInfo::managed(), + )); + let server = mcp::FlickNoteMcp::new(Rc::new(config)); let (server_io, client_io) = tokio::io::duplex(8 * 1024); let server = tokio::task::spawn_local(async move { server.serve(server_io).await.unwrap().waiting().await @@ -697,7 +620,7 @@ mod tests { "Note has no source data" ); - let daemon_error = call_mcp_tool( + let added = call_mcp_tool( &mut client_write, &mut client_read, 10, @@ -705,15 +628,8 @@ mod tests { serde_json::json!({ "content": "daemon-backed note" }), ) .await; - assert_eq!(daemon_error["result"]["isError"], true); - assert_eq!( - daemon_error["result"]["structuredContent"]["code"], - "daemon_unavailable" - ); - assert_eq!( - daemon_error["result"]["structuredContent"]["retryable"], - true - ); + assert_eq!(added["result"]["isError"], false); + assert_eq!(added["result"]["structuredContent"]["title"], serde_json::Value::Null); let archived = call_mcp_tool( &mut client_write, @@ -741,6 +657,7 @@ mod tests { drop(client_write); drop(client_read); server.await.unwrap().unwrap(); + daemon_server.abort(); }) .await; } @@ -841,74 +758,4 @@ mod tests { fn mcp_subcommand_parses() { assert!(Cli::try_parse_from(["flicknote", "mcp"]).is_ok()); } - - #[test] - fn managed_workspace_blocks_local_workspace_commands() { - for argv in [ - ["flicknote", "mcp"].as_slice(), - [ - "flicknote", - "gateway", - "request", - "--path", - "/web/v1/search", - ] - .as_slice(), - ["flicknote", "upload", "file.pdf"].as_slice(), - ["flicknote", "edit"].as_slice(), - ["flicknote", "login"].as_slice(), - ["flicknote", "logout"].as_slice(), - ["flicknote", "sync", "status"].as_slice(), - ["flicknote", "skill", "install"].as_slice(), - ["flicknote", "import", "notes"].as_slice(), - ["flicknote", "open", "123"].as_slice(), - ["flicknote", "share", "123"].as_slice(), - ["flicknote", "unshare", "123"].as_slice(), - [ - "flicknote", - "project", - "share", - "550e8400-e29b-41d4-a716-446655440000", - ] - .as_slice(), - [ - "flicknote", - "project", - "unshare", - "550e8400-e29b-41d4-a716-446655440000", - ] - .as_slice(), - ] { - let cli = Cli::try_parse_from(argv).unwrap(); - let err = enforce_workspace_gate(&cli, WorkspaceMode::Managed).unwrap_err(); - assert!(format!("{err}").contains("not available in managed workspaces")); - } - } - - #[test] - fn managed_workspace_allows_data_commands() { - for argv in [ - ["flicknote", "add", "note"].as_slice(), - ["flicknote", "append", "1"].as_slice(), - ["flicknote", "delete", "1"].as_slice(), - ["flicknote", "restore", "1"].as_slice(), - ["flicknote", "list"].as_slice(), - ["flicknote", "count"].as_slice(), - ["flicknote", "find", "keyword"].as_slice(), - ["flicknote", "topic", "list"].as_slice(), - ["flicknote", "entity", "list"].as_slice(), - ["flicknote", "source", "1"].as_slice(), - ["flicknote", "detail", "1"].as_slice(), - ["flicknote", "content", "1"].as_slice(), - ["flicknote", "project", "list"].as_slice(), - ["flicknote", "keyterm", "list"].as_slice(), - ["flicknote", "rename", "--section", "a1", "1", "New"].as_slice(), - ["flicknote", "insert", "1", "--after", "a1"].as_slice(), - ["flicknote", "replace", "1", "--section", "a1"].as_slice(), - ["flicknote", "modify", "1"].as_slice(), - ] { - let cli = Cli::try_parse_from(argv).unwrap(); - enforce_workspace_gate(&cli, WorkspaceMode::Managed).unwrap(); - } - } } diff --git a/flicknote-cli/src/mcp/error.rs b/flicknote-cli/src/mcp/error.rs index c502f96..6b09dc2 100644 --- a/flicknote-cli/src/mcp/error.rs +++ b/flicknote-cli/src/mcp/error.rs @@ -16,6 +16,10 @@ pub(super) fn tool_error(error: &ServiceError) -> CallToolResult { ServiceError::BeforeAmbiguous { matches, .. } => { serde_json::json!({ "matches": matches }) } + ServiceError::Remote { + details: Some(details), + .. + } => details.clone(), _ => serde_json::json!({}), }; let payload = serde_json::to_value(ToolErrorPayload { @@ -29,3 +33,23 @@ pub(super) fn tool_error(error: &ServiceError) -> CallToolResult { result.structured_content = Some(payload); result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_partial_success_details_are_structured_for_mcp_callers() { + let result = tool_error(&ServiceError::Remote { + code: "note_create_partial".to_string(), + message: "created with pending topics".to_string(), + retryable: false, + details: Some(serde_json::json!({"created": true, "short_id": 80})), + }); + + assert_eq!( + result.structured_content.unwrap()["details"], + serde_json::json!({"created": true, "short_id": 80}) + ); + } +} diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index 9b29ab4..60a89f0 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -1,17 +1,16 @@ use std::rc::Rc; -use flicknote_core::backend::NoteDb; use flicknote_core::config::Config; use flicknote_core::error::CliError; use flicknote_core::services::dto::{ - NoteAddInput, NoteModifyInput, NoteSectionResult, OpenResult, Patch, ProjectAddInput, - ProjectModifyInput, ShareResult, UnshareResult, + NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, NoteListInput, + NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, Patch, + ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, }; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::{NoteCountInput, NoteFindInput, NoteListInput, NoteService}; -use flicknote_core::services::project::ProjectService; +use flicknote_core::services::ports::BrowserOpener; use flicknote_core::services::source::SourceResult; -use flicknote_sync::ipc::DaemonClient; +use flicknote_sync::ipc::{AppRequest, AppResult, DaemonClient}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo}; @@ -63,26 +62,20 @@ struct CountResult { #[derive(Clone)] pub(crate) struct FlickNoteMcp { - db: Rc, config: Rc, tool_router: ToolRouter, } impl FlickNoteMcp { - pub(crate) fn new(db: Rc, config: Rc) -> Self { + pub(crate) fn new(config: Rc) -> Self { Self { - db, config, tool_router: Self::tool_router(), } } - fn note_service(&self) -> NoteService<'_> { - NoteService::new(self.db.as_ref()) - } - - fn project_service(&self) -> ProjectService<'_> { - ProjectService::new(self.db.as_ref()) + async fn call(&self, request: AppRequest) -> Result { + DaemonClient::new(&self.config).call(request).await } fn effective_project(project: Option) -> Option { @@ -94,10 +87,11 @@ impl FlickNoteMcp { } async fn resolve_project_name(&self, name: &str) -> Result { - self.db - .find_project_by_name(name) - .await? - .ok_or_else(|| ServiceError::ProjectNotFound(name.to_string())) + self.call::(AppRequest::ProjectGetByName { + name: name.to_string(), + }) + .await + .map(|project| project.id) } } @@ -117,15 +111,14 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result>, CallToolResult> { structured( - self.note_service() - .list(NoteListInput { - note_type: params.note_type.map(|value| value.as_str().to_string()), - project: Self::effective_project(params.project), - archived: params.archived, - limit: params.limit, - }) - .await - .map(|notes| notes.into_iter().map(Into::into).collect()), + self.call::>(AppRequest::NoteList(NoteListInput { + note_type: params.note_type.map(|value| value.as_str().to_string()), + project: Self::effective_project(params.project), + archived: params.archived, + limit: params.limit, + })) + .await + .map(|notes| notes.into_iter().map(Into::into).collect()), ) } @@ -139,16 +132,15 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result>, CallToolResult> { structured( - self.note_service() - .find(NoteFindInput { - keywords: params.keywords, - extractions: params.extractions, - project: Self::effective_project(params.project), - archived: params.archived, - limit: params.limit, - }) - .await - .map(|notes| notes.into_iter().map(Into::into).collect()), + self.call::>(AppRequest::NoteFind(NoteFindInput { + keywords: params.keywords, + extractions: params.extractions, + project: Self::effective_project(params.project), + archived: params.archived, + limit: params.limit, + })) + .await + .map(|notes| notes.into_iter().map(Into::into).collect()), ) } @@ -162,15 +154,14 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .count(NoteCountInput { - keywords: params.keywords, - project: Self::effective_project(params.project), - note_type: params.note_type.map(|value| value.as_str().to_string()), - archived: params.archived, - }) - .await - .map(|count| CountResult { count }), + self.call::(AppRequest::NoteCount(NoteCountInput { + keywords: params.keywords, + project: Self::effective_project(params.project), + note_type: params.note_type.map(|value| value.as_str().to_string()), + archived: params.archived, + })) + .await + .map(|count| CountResult { count }), ) } @@ -184,10 +175,12 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .get(¶ms.id.to_string(), params.archived) - .await - .map(Into::into), + self.call::(AppRequest::NoteGet { + id: params.id.to_string(), + archived: params.archived, + }) + .await + .map(Into::into), ) } @@ -201,9 +194,11 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .get_section(¶ms.id.to_string(), ¶ms.section) - .await, + self.call(AppRequest::NoteGetSection { + id: params.id.to_string(), + section: params.section, + }) + .await, ) } @@ -217,14 +212,13 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .source( - ¶ms.id.to_string(), - params.archived, - params.view, - params.range.as_deref(), - ) - .await, + self.call(AppRequest::NoteSource { + id: params.id.to_string(), + archived: params.archived, + view: params.view, + range: params.range, + }) + .await, ) } @@ -238,19 +232,15 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .add( - &DaemonClient::new(&self.config), - NoteAddInput { - content: params.content, - project: Self::effective_project(params.project), - interpret_as_url: true, - topics: Vec::new(), - created_at: None, - }, - ) - .await - .map(Into::into), + self.call::(AppRequest::NoteAdd(NoteAddInput { + content: params.content, + project: Self::effective_project(params.project), + interpret_as_url: true, + topics: Vec::new(), + created_at: None, + })) + .await + .map(Into::into), ) } @@ -263,17 +253,16 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .modify(NoteModifyInput { - id: params.id.to_string(), - before: params.before, - after: params.after, - section: params.section, - project: params.project, - flagged: params.flagged, - }) - .await - .map(Into::into), + self.call::(AppRequest::NoteModify(NoteModifyInput { + id: params.id.to_string(), + before: params.before, + after: params.after, + section: params.section, + project: params.project, + flagged: params.flagged, + })) + .await + .map(Into::into), ) } @@ -286,10 +275,12 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .append(¶ms.id.to_string(), ¶ms.content) - .await - .map(Into::into), + self.call::(AppRequest::NoteAppend { + id: params.id.to_string(), + content: params.content, + }) + .await + .map(Into::into), ) } @@ -302,15 +293,14 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .insert( - ¶ms.id.to_string(), - ¶ms.section, - params.position, - ¶ms.content, - ) - .await - .map(Into::into), + self.call::(AppRequest::NoteInsert { + id: params.id.to_string(), + section: params.section, + position: params.position, + content: params.content, + }) + .await + .map(Into::into), ) } @@ -323,10 +313,13 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .replace_section(¶ms.id.to_string(), ¶ms.section, ¶ms.content) - .await - .map(Into::into), + self.call::(AppRequest::NoteReplaceSection { + id: params.id.to_string(), + section: params.section, + content: params.content, + }) + .await + .map(Into::into), ) } @@ -339,10 +332,13 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .rename_section(¶ms.id.to_string(), ¶ms.section, ¶ms.name) - .await - .map(Into::into), + self.call::(AppRequest::NoteRenameSection { + id: params.id.to_string(), + section: params.section, + name: params.name, + }) + .await + .map(Into::into), ) } @@ -356,10 +352,12 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .delete_section(¶ms.id.to_string(), ¶ms.section) - .await - .map(Into::into), + self.call::(AppRequest::NoteDeleteSection { + id: params.id.to_string(), + section: params.section, + }) + .await + .map(Into::into), ) } @@ -373,10 +371,11 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .archive(¶ms.id.to_string()) - .await - .map(Into::into), + self.call::(AppRequest::NoteArchive { + id: params.id.to_string(), + }) + .await + .map(Into::into), ) } @@ -389,10 +388,11 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .restore(¶ms.id.to_string()) - .await - .map(Into::into), + self.call::(AppRequest::NoteRestore { + id: params.id.to_string(), + }) + .await + .map(Into::into), ) } @@ -406,9 +406,10 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .share(&DaemonClient::new(&self.config), ¶ms.id.to_string()) - .await, + self.call(AppRequest::NoteShare { + id: params.id.to_string(), + }) + .await, ) } @@ -422,9 +423,10 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.note_service() - .unshare(&DaemonClient::new(&self.config), ¶ms.id.to_string()) - .await, + self.call(AppRequest::NoteUnshare { + id: params.id.to_string(), + }) + .await, ) } @@ -437,16 +439,17 @@ impl FlickNoteMcp { &self, Parameters(params): Parameters, ) -> Result, CallToolResult> { - let Some(web_url) = self.config.web_url.as_deref() else { - return Err(tool_error(&ServiceError::ConfigMissing( - "webUrl".to_string(), - ))); - }; - structured( - self.note_service() - .open(&SystemBrowserOpener, web_url, ¶ms.id.to_string()) - .await, - ) + let mut result: OpenResult = self + .call(AppRequest::NoteOpen { + id: params.id.to_string(), + }) + .await + .map_err(|error| tool_error(&error))?; + SystemBrowserOpener + .open(&result.url) + .map_err(|error| tool_error(&error))?; + result.opened = true; + Ok(Json(result)) } #[tool( @@ -459,10 +462,11 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result>, CallToolResult> { structured( - self.project_service() - .list(params.include_archived) - .await - .map(|projects| projects.into_iter().map(Into::into).collect()), + self.call::>(AppRequest::ProjectList { + include_archived: params.include_archived, + }) + .await + .map(|projects| projects.into_iter().map(Into::into).collect()), ) } @@ -475,15 +479,12 @@ impl FlickNoteMcp { &self, Parameters(params): Parameters, ) -> Result, CallToolResult> { - let project_id = self - .resolve_project_name(¶ms.project) - .await - .map_err(|error| tool_error(&error))?; structured( - self.project_service() - .get(&project_id) - .await - .map(Into::into), + self.call::(AppRequest::ProjectGetByName { + name: params.project, + }) + .await + .map(Into::into), ) } @@ -496,14 +497,13 @@ impl FlickNoteMcp { Parameters(params): Parameters, ) -> Result, CallToolResult> { structured( - self.project_service() - .add(ProjectAddInput { - name: params.name, - keyterm: None, - color: params.color, - }) - .await - .map(Into::into), + self.call::(AppRequest::ProjectAdd(ProjectAddInput { + name: params.name, + keyterm: None, + color: params.color, + })) + .await + .map(Into::into), ) } @@ -520,14 +520,13 @@ impl FlickNoteMcp { .await .map_err(|error| tool_error(&error))?; structured( - self.project_service() - .modify(ProjectModifyInput { - id: project_id, - keyterm: Patch::Missing, - color: params.color, - }) - .await - .map(Into::into), + self.call::(AppRequest::ProjectModify(ProjectModifyInput { + id: project_id, + keyterm: Patch::Missing, + color: params.color, + })) + .await + .map(Into::into), ) } @@ -545,8 +544,7 @@ impl FlickNoteMcp { .await .map_err(|error| tool_error(&error))?; structured( - self.project_service() - .archive(&project_id) + self.call::(AppRequest::ProjectArchive { id: project_id }) .await .map(Into::into), ) @@ -565,11 +563,7 @@ impl FlickNoteMcp { .resolve_project_name(¶ms.project) .await .map_err(|error| tool_error(&error))?; - structured( - self.project_service() - .share(&DaemonClient::new(&self.config), &project_id) - .await, - ) + structured(self.call(AppRequest::ProjectShare { id: project_id }).await) } #[tool( @@ -586,8 +580,7 @@ impl FlickNoteMcp { .await .map_err(|error| tool_error(&error))?; structured( - self.project_service() - .unshare(&DaemonClient::new(&self.config), &project_id) + self.call(AppRequest::ProjectUnshare { id: project_id }) .await, ) } @@ -599,13 +592,13 @@ impl ServerHandler for FlickNoteMcp { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("flicknote", env!("CARGO_PKG_VERSION"))) .with_instructions( - "Local-first FlickNote note and project tools. Network-backed add/share/unshare require the running sync daemon.", + "Daemon-backed FlickNote note and project tools. Every data tool requires the running FlickNote daemon.", ) } } -pub(crate) async fn serve(db: Rc, config: Rc) -> Result<(), CliError> { - FlickNoteMcp::new(db, config) +pub(crate) async fn serve(config: Rc) -> Result<(), CliError> { + FlickNoteMcp::new(config) .serve(rmcp::transport::stdio()) .await .map_err(|error| CliError::Other(format!("failed to initialize MCP server: {error}")))? diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index f2e828e..a09bcfd 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -7,6 +7,75 @@ use std::time::{Duration, Instant}; use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; +use flicknote_sync::app::Application; +use flicknote_sync::ipc::{BackendMode, ServerInfo, serve_app, socket_path}; + +fn test_config(config_root: &std::path::Path, data_root: &std::path::Path) -> Config { + let config_dir = config_root.join("flicknote"); + let data_dir = data_root.join("flicknote"); + Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: String::new(), + web_url: None, + paths: ConfigPaths { + config_file: config_dir.join("config.json"), + session_file: config_dir.join("session.json"), + db_file: data_dir.join("flicknote.db"), + log_file: data_dir.join("flicknote.log"), + config_dir, + data_dir, + }, + } +} + +struct DaemonGuard { + shutdown: Option>, + thread: Option>, +} + +impl Drop for DaemonGuard { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _shutdown_result = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +fn spawn_test_daemon(config_root: &std::path::Path, data_root: &std::path::Path) -> DaemonGuard { + let config = test_config(config_root, data_root); + std::fs::create_dir_all(&config.paths.data_dir).unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let backend = std::sync::Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "test-user".to_string(), + }); + let app = std::sync::Arc::new(Application::new(backend, BackendMode::Managed)); + let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + ready_tx.send(()).unwrap(); + tokio::select! { + _ = shutdown_rx => {} + result = serve_app(listener, app, ServerInfo::managed()) => result.unwrap(), + } + }); + }); + ready_rx.recv().unwrap(); + DaemonGuard { + shutdown: Some(shutdown_tx), + thread: Some(thread), + } +} fn write_session(config_root: &std::path::Path) { write_session_with_expiry(config_root, None); @@ -36,24 +105,8 @@ async fn seed_workspace( data_root: &std::path::Path, ) -> (String, String) { write_session(config_root); - let config_dir = config_root.join("flicknote"); - let data_dir = data_root.join("flicknote"); - std::fs::create_dir_all(&data_dir).unwrap(); - let config = Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url: String::new(), - web_url: None, - paths: ConfigPaths { - config_file: config_dir.join("config.json"), - session_file: config_dir.join("session.json"), - db_file: data_dir.join("flicknote.db"), - log_file: data_dir.join("flicknote.log"), - config_dir, - data_dir, - }, - }; + let config = test_config(config_root, data_root); + std::fs::create_dir_all(&config.paths.data_dir).unwrap(); let database = Database::open_local(&config).await.unwrap(); let backend = SqliteBackend { db: database, @@ -512,6 +565,7 @@ async fn cli_json_commands_preserve_the_existing_machine_contracts() { let config_root = directory.path().join("config"); let data_root = directory.path().join("data"); let (note_id, _) = seed_workspace(&config_root, &data_root).await; + let _daemon = spawn_test_daemon(&config_root, &data_root); let listed = run_cli_json(&config_root, &data_root, &["list", "--json"]); assert_legacy_note_shape(&listed[0], &serde_json::Value::Null); @@ -538,6 +592,7 @@ fn mcp_binary_keeps_stdout_as_json_rpc_frames() { let config_root = directory.path().join("config"); let data_root = directory.path().join("data"); write_session(&config_root); + let _daemon = spawn_test_daemon(&config_root, &data_root); let mut child = Command::new(env!("CARGO_BIN_EXE_flicknote")) .arg("mcp") @@ -586,17 +641,47 @@ fn mcp_binary_keeps_stdout_as_json_rpc_frames() { } #[test] -fn managed_workspace_rejects_mcp_before_protocol_output() { +fn mcp_requires_daemon_before_protocol_output() { + let directory = tempfile::tempdir().unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_flicknote")) .arg("mcp") - .env("DATABASE_URL", "postgres://unused") + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_DATA_HOME", directory.path().join("data")) + .env_remove("DATABASE_URL") .output() .unwrap(); assert!(!output.status.success()); assert!(output.stdout.is_empty()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("`flicknote mcp` is not available in managed workspaces") - ); + assert!(String::from_utf8_lossy(&output.stderr).contains("Sync daemon is unavailable")); +} + +#[test] +fn data_commands_require_the_daemon() { + let directory = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .arg("list") + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_DATA_HOME", directory.path().join("data")) + .env_remove("DATABASE_URL") + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Sync daemon is unavailable")); +} + +#[test] +fn root_help_does_not_require_the_daemon() { + let directory = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_DATA_HOME", directory.path().join("data")) + .env_remove("DATABASE_URL") + .output() + .unwrap(); + + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); } diff --git a/flicknote-sync/Cargo.toml b/flicknote-sync/Cargo.toml index 8c7f185..cc18d27 100644 --- a/flicknote-sync/Cargo.toml +++ b/flicknote-sync/Cargo.toml @@ -23,7 +23,6 @@ serde_json = { workspace = true } futures-lite = { workspace = true } log = "0.4" libc = "0.2.182" -notify = "8" uuid = { workspace = true } chrono = "0.4" diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index e6500f5..d30f7dd 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -16,6 +16,7 @@ pub struct Application { creator: Option>, share_gateway: Option>, web_url: Option, + write_signal: Option>, } impl Application { @@ -26,6 +27,7 @@ impl Application { creator: None, share_gateway: None, web_url: None, + write_signal: None, } } @@ -44,11 +46,30 @@ impl Application { self } + pub fn with_write_signal(mut self, signal: tokio::sync::mpsc::Sender<()>) -> Self { + self.write_signal = Some(signal); + self + } + pub fn mode(&self) -> BackendMode { self.mode } pub async fn handle(&self, request: AppRequest) -> Result { + let may_write = request.may_write(); + let result = self.handle_inner(request).await; + if may_write + && let Some(signal) = &self.write_signal + && signal.try_send(()).is_err() + { + log::debug!( + "Upload trigger channel full or closed; startup/next write will drain CRUD" + ); + } + result + } + + async fn handle_inner(&self, request: AppRequest) -> Result { let notes = NoteService::new(self.db.as_ref()); let projects = ProjectService::new(self.db.as_ref()); match request { @@ -230,6 +251,18 @@ impl Application { .await .map(AppResponse::NoteDetail) .map_err(WireError::from_service), + AppRequest::NoteLoadEditable { id } => { + let id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; + flicknote_core::services::editable_document::load_editable_note( + self.db.as_ref(), + &id, + ) + .await + .map(|document| { + AppResponse::EditableDocument(crate::ipc::EditableDocument { document }) + }) + .map_err(Self::db_error) + } AppRequest::NoteRecord { id, archived } => { let id = if archived { self.db.resolve_archived_note_id(&id).await @@ -358,6 +391,21 @@ impl Application { .await .map(AppResponse::Project) .map_err(WireError::from_service), + AppRequest::ProjectGetByName { name } => { + let id = self + .db + .find_project_by_name(&name) + .await + .map_err(Self::db_error)? + .ok_or_else(|| { + WireError::from_service(ServiceError::ProjectNotFound(name.clone())) + })?; + projects + .get(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service) + } AppRequest::ProjectAdd(input) => projects .add(input) .await diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index aa69890..6e3053f 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -1,8 +1,6 @@ use std::fmt; use std::path::PathBuf; -use async_trait::async_trait; -use flicknote_core::backend::InsertedNote; use flicknote_core::config::Config; use flicknote_core::services::dto::{ InsertPosition, NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, @@ -11,9 +9,6 @@ use flicknote_core::services::dto::{ }; use flicknote_core::services::editable_document::EditableSaveResult; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::ports::{ - CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource, -}; use flicknote_core::services::source::{SourceResult, SourceView}; use flicknote_core::types::{Keyterm, Note, Project}; use serde::{Deserialize, Serialize}; @@ -93,6 +88,9 @@ pub enum AppRequest { id: String, archived: bool, }, + NoteLoadEditable { + id: String, + }, NoteRecord { id: String, archived: bool, @@ -160,6 +158,9 @@ pub enum AppRequest { ProjectGet { id: String, }, + ProjectGetByName { + name: String, + }, ProjectAdd(ProjectAddInput), ProjectModify(ProjectModifyInput), ProjectArchive { @@ -195,6 +196,30 @@ pub enum AppRequest { }, } +impl AppRequest { + pub fn may_write(&self) -> bool { + !matches!( + self, + Self::NoteList(_) + | Self::NoteFind(_) + | Self::NoteCount(_) + | Self::NoteGet { .. } + | Self::NoteLoadEditable { .. } + | Self::NoteRecord { .. } + | Self::NoteGetSection { .. } + | Self::NoteSource { .. } + | Self::NoteOpen { .. } + | Self::ProjectList { .. } + | Self::ProjectRecords { .. } + | Self::ProjectGet { .. } + | Self::ProjectGetByName { .. } + | Self::KeytermList + | Self::KeytermGet { .. } + | Self::ExtractionValues { .. } + ) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum AppResponse { @@ -202,6 +227,7 @@ pub enum AppResponse { NoteSummaries(Vec), NoteCount { count: u64 }, NoteDetail(NoteDetail), + EditableDocument(EditableDocument), NoteRecord(Note), NoteSection(NoteSectionResult), NoteMutation(NoteMutationResult), @@ -221,6 +247,79 @@ pub enum AppResponse { Unit, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EditableDocument { + pub document: String, +} + +pub trait AppResult: Sized { + fn from_response(response: AppResponse) -> Result; +} + +macro_rules! app_result { + ($type:ty, $variant:path) => { + impl AppResult for $type { + fn from_response(response: AppResponse) -> Result { + match response { + $variant(value) => Ok(value), + _ => Err(unexpected_app_response()), + } + } + } + }; +} + +app_result!(NoteSummary, AppResponse::NoteSummary); +app_result!(Vec, AppResponse::NoteSummaries); +app_result!(NoteDetail, AppResponse::NoteDetail); +app_result!(EditableDocument, AppResponse::EditableDocument); +app_result!(Note, AppResponse::NoteRecord); +app_result!(NoteSectionResult, AppResponse::NoteSection); +app_result!(NoteMutationResult, AppResponse::NoteMutation); +app_result!(EditableSaveResult, AppResponse::EditableSave); +app_result!(NoteArchiveResult, AppResponse::NoteArchive); +app_result!(SourceResult, AppResponse::Source); +app_result!(ShareResult, AppResponse::Share); +app_result!(UnshareResult, AppResponse::Unshare); +app_result!(OpenResult, AppResponse::Open); +app_result!(Vec, AppResponse::Projects); +app_result!(Vec, AppResponse::ProjectRecords); +app_result!(ProjectDto, AppResponse::Project); +app_result!(Vec, AppResponse::Keyterms); +app_result!(Keyterm, AppResponse::Keyterm); +app_result!(Vec, AppResponse::Values); + +impl AppResult for u64 { + fn from_response(response: AppResponse) -> Result { + match response { + AppResponse::NoteCount { count } => Ok(count), + _ => Err(unexpected_app_response()), + } + } +} + +impl AppResult for String { + fn from_response(response: AppResponse) -> Result { + match response { + AppResponse::Id { id } => Ok(id), + _ => Err(unexpected_app_response()), + } + } +} + +impl AppResult for () { + fn from_response(response: AppResponse) -> Result { + match response { + AppResponse::Unit => Ok(()), + _ => Err(unexpected_app_response()), + } + } +} + +fn unexpected_app_response() -> ServiceError { + ServiceError::Internal("daemon returned an unexpected application response".to_string()) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WireError { pub code: String, @@ -232,11 +331,24 @@ pub struct WireError { impl WireError { pub fn from_service(error: ServiceError) -> Self { - Self { - code: error.code().to_string(), - message: error.to_string(), - retryable: error.retryable(), - details: None, + match error { + ServiceError::Remote { + code, + message, + retryable, + details, + } => Self { + code, + message, + retryable, + details, + }, + error => Self { + code: error.code().to_string(), + message: error.to_string(), + retryable: error.retryable(), + details: None, + }, } } } @@ -251,45 +363,6 @@ pub enum DaemonRequest { protocol: u16, request: Box, }, - CreateNote(Box), - GetOrCreateShare(ShareRequest), - RevokeShare(ShareRequest), -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ShareResource { - Note, - Project, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShareRequest { - pub resource: ShareResource, - pub id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CreateNoteRequest { - pub id: String, - pub note_type: String, - pub status: String, - pub title: Option, - pub content: Option, - pub metadata: Option, - pub project_id: Option, - pub now: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub topics: Vec, - #[serde(default)] - pub attachment_path: Option, -} - -impl CreateNoteRequest { - pub fn with_attachment_path(mut self, path: impl Into) -> Self { - self.attachment_path = Some(path.into()); - self - } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -298,28 +371,24 @@ pub enum DaemonResponse { ServerInfo(ServerInfo), App(Box), AppError(WireError), - NoteCreated(CreatedNote), - ShareUrl(ShareUrlResponse), - ShareRevoked, - Error(DaemonError), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ShareUrlResponse { - pub url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CreatedNote { - pub uuid: String, - pub short_id: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "code", rename_all = "snake_case")] pub enum DaemonError { - Unavailable { path: String, message: String }, - Other { message: String }, + Unavailable { + path: String, + message: String, + }, + PartialCreate { + message: String, + note_id: String, + short_id: i64, + pending_extraction_ids: Vec, + }, + Other { + message: String, + }, } impl fmt::Display for DaemonError { @@ -328,7 +397,7 @@ impl fmt::Display for DaemonError { Self::Unavailable { path, message } => { write!(f, "Sync daemon is not available at {path}: {message}") } - Self::Other { message } => f.write_str(message), + Self::PartialCreate { message, .. } | Self::Other { message } => f.write_str(message), } } } @@ -411,6 +480,10 @@ impl<'a> DaemonClient<'a> { } } + pub async fn call(&self, request: AppRequest) -> Result { + T::from_response(self.app(request).await?) + } + fn remote_error(error: WireError) -> ServiceError { ServiceError::Remote { code: error.code, @@ -430,80 +503,6 @@ impl<'a> DaemonClient<'a> { } } -#[async_trait] -impl NoteCreator for DaemonClient<'_> { - async fn create(&self, request: CreateNote) -> Result { - let response = self - .request(DaemonRequest::CreateNote(Box::new(CreateNoteRequest { - id: request.id, - note_type: request.note_type, - status: request.status, - title: request.title, - content: request.content, - metadata: request.metadata, - project_id: request.project_id, - now: request.now, - topics: request.topics, - attachment_path: None, - }))) - .await?; - match response { - DaemonResponse::NoteCreated(note) => Ok(InsertedNote { - uuid: note.uuid, - short_id: Some(note.short_id), - }), - DaemonResponse::Error(error) => Err(ServiceError::Daemon(error.to_string())), - _ => Err(ServiceError::Internal( - "sync daemon returned an unexpected create response".to_string(), - )), - } - } -} - -#[async_trait] -impl ShareGateway for DaemonClient<'_> { - async fn share(&self, resource: CoreShareResource, id: &str) -> Result { - let response = self - .request(DaemonRequest::GetOrCreateShare(ShareRequest { - resource: resource.into(), - id: id.to_string(), - })) - .await?; - match response { - DaemonResponse::ShareUrl(response) => Ok(response.url), - DaemonResponse::Error(error) => Err(ServiceError::Daemon(error.to_string())), - _ => Err(ServiceError::Internal( - "sync daemon returned an unexpected share response".to_string(), - )), - } - } - - async fn unshare(&self, resource: CoreShareResource, id: &str) -> Result<(), ServiceError> { - let response = self - .request(DaemonRequest::RevokeShare(ShareRequest { - resource: resource.into(), - id: id.to_string(), - })) - .await?; - match response { - DaemonResponse::ShareRevoked => Ok(()), - DaemonResponse::Error(error) => Err(ServiceError::Daemon(error.to_string())), - _ => Err(ServiceError::Internal( - "sync daemon returned an unexpected unshare response".to_string(), - )), - } - } -} - -impl From for ShareResource { - fn from(resource: CoreShareResource) -> Self { - match resource { - CoreShareResource::Note => Self::Note, - CoreShareResource::Project => Self::Project, - } - } -} - pub async fn read_request(stream: &mut UnixStream) -> Result { let mut buf = Vec::new(); stream @@ -585,12 +584,6 @@ async fn serve_app_stream( details: None, }) } - _ => DaemonResponse::AppError(WireError { - code: "daemon_protocol_mismatch".to_string(), - message: "legacy application request is not supported by this daemon".to_string(), - retryable: false, - details: None, - }), }; write_response(stream, &response).await } @@ -613,9 +606,6 @@ async fn write_json(stream: &mut UnixStream, value: &T) -> Result< #[cfg(test)] mod tests { use flicknote_core::config::{Config, ConfigPaths}; - use flicknote_core::services::ports::{ - CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource, - }; use serde_json::json; use tokio::net::UnixListener; @@ -681,61 +671,6 @@ mod tests { assert_eq!(socket_path(&config), dir.join("sync.sock")); } - #[test] - fn create_note_request_serializes_as_tagged_json() { - let req = DaemonRequest::CreateNote(Box::new(CreateNoteRequest { - id: "note-id".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: Some("project-id".to_string()), - now: "2026-06-26T00:00:00Z".to_string(), - topics: vec!["rust".to_string()], - attachment_path: None, - })); - - assert_eq!( - serde_json::to_value(req).unwrap(), - json!({ - "type": "create_note", - "payload": { - "id": "note-id", - "note_type": "normal", - "status": "ai_queued", - "title": "Title", - "content": "Body", - "metadata": null, - "project_id": "project-id", - "now": "2026-06-26T00:00:00Z", - "topics": ["rust"], - "attachment_path": null - } - }) - ); - } - - #[test] - fn create_note_request_does_not_serialize_entities() { - let req = DaemonRequest::CreateNote(Box::new(CreateNoteRequest { - id: "note-id".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-06-26T00:00:00Z".to_string(), - topics: vec!["rust".to_string()], - attachment_path: None, - })); - - let value = serde_json::to_value(req).unwrap(); - assert_eq!(value["payload"]["topics"], json!(["rust"])); - assert!(value["payload"].get("entities").is_none()); - } - #[test] fn versioned_health_and_app_requests_have_stable_contracts() { let health = DaemonRequest::Health { @@ -774,29 +709,17 @@ mod tests { } #[test] - fn share_request_deserializes() { - let value = json!({ - "type": "get_or_create_share", - "payload": { - "resource": "note", - "id": "550e8400-e29b-41d4-a716-446655440000" - } - }); - - assert!(serde_json::from_value::(value).is_ok()); - } - - #[test] - fn unshare_request_deserializes() { - let value = json!({ - "type": "revoke_share", - "payload": { - "resource": "project", - "id": "550e8400-e29b-41d4-a716-446655440000" - } + fn wire_error_preserves_partial_success_details() { + let details = json!({"created": true, "short_id": 80}); + let wire = WireError::from_service(ServiceError::Remote { + code: "note_create_partial".to_string(), + message: "note created; topics pending".to_string(), + retryable: false, + details: Some(details.clone()), }); - assert!(serde_json::from_value::(value).is_ok()); + assert_eq!(wire.code, "note_create_partial"); + assert_eq!(wire.details, Some(details)); } #[tokio::test] @@ -804,115 +727,13 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let error = DaemonClient::new(&config) - .share(CoreShareResource::Note, "note-id") - .await - .unwrap_err(); + let error = DaemonClient::new(&config).health().await.unwrap_err(); assert_eq!(error.code(), "daemon_unavailable"); assert!(error.retryable()); assert!(error.to_string().contains("flicknote sync start")); } - #[tokio::test] - async fn daemon_client_maps_create_response_and_unexpected_variant() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::NoteCreated(CreatedNote { - uuid: "created-id".to_string(), - short_id: 42, - }), - ) - .await; - let request = CreateNote { - id: "request-id".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-05T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }; - - let created = DaemonClient::new(&config) - .create(request.clone()) - .await - .unwrap(); - assert_eq!(created.uuid, "created-id"); - assert_eq!(created.short_id, Some(42)); - assert!(matches!( - server.await.unwrap(), - DaemonRequest::CreateNote(_) - )); - - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::ShareRevoked).await; - let error = DaemonClient::new(&config) - .create(request) - .await - .unwrap_err(); - assert_eq!(error.code(), "internal_error"); - assert!(error.to_string().contains("unexpected create response")); - server.await.unwrap(); - } - - #[tokio::test] - async fn daemon_client_maps_share_and_unshare_responses() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::ShareUrl(ShareUrlResponse { - url: "https://share.example/note".to_string(), - }), - ) - .await; - let url = DaemonClient::new(&config) - .share(CoreShareResource::Note, "note-id") - .await - .unwrap(); - assert_eq!(url, "https://share.example/note"); - assert!(matches!( - server.await.unwrap(), - DaemonRequest::GetOrCreateShare(_) - )); - - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::ShareRevoked).await; - DaemonClient::new(&config) - .unshare(CoreShareResource::Project, "project-id") - .await - .unwrap(); - assert!(matches!( - server.await.unwrap(), - DaemonRequest::RevokeShare(_) - )); - - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::Error(DaemonError::Other { - message: "remote failure".to_string(), - }), - ) - .await; - let error = DaemonClient::new(&config) - .share(CoreShareResource::Note, "note-id") - .await - .unwrap_err(); - assert_eq!(error.code(), "daemon_error"); - assert!(error.to_string().contains("remote failure")); - server.await.unwrap(); - } - #[tokio::test] async fn daemon_client_preserves_versioned_app_results_and_errors() { let directory = tempfile::tempdir().unwrap(); @@ -959,10 +780,14 @@ mod tests { } #[tokio::test] - async fn health_rejects_legacy_or_unexpected_daemon_responses() { + async fn health_rejects_unexpected_daemon_responses() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::ShareRevoked).await; + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 0 })), + ) + .await; let error = DaemonClient::new(&config).health().await.unwrap_err(); assert_eq!(error.code(), "daemon_protocol_mismatch"); assert!(error.to_string().contains("sync stop")); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index c2590ec..dbcbab9 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -14,7 +14,6 @@ use flicknote_core::{ services::ports::{CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource}, }; use futures_lite::StreamExt; -use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use powersync::{ BackendConnector, ConnectionPool, PowerSyncCredentials, PowerSyncDatabase, SyncOptions, UpdateType, env::PowerSyncEnvironment, error::PowerSyncError, @@ -26,10 +25,39 @@ use tokio::{net::UnixListener, sync::mpsc}; pub mod app; pub mod ipc; use app::Application; -use ipc::{ - CreateNoteRequest, CreatedNote, DaemonError, DaemonRequest, DaemonResponse, ShareRequest, - ShareResource, ShareUrlResponse, -}; +use ipc::DaemonError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShareResource { + Note, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShareRequest { + resource: ShareResource, + id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreateNoteRequest { + id: String, + note_type: String, + status: String, + title: Option, + content: Option, + metadata: Option, + project_id: Option, + now: String, + topics: Vec, + attachment_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreatedNote { + uuid: String, + short_id: i64, +} /// Helper to convert arbitrary errors into PowerSyncError. fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { @@ -137,7 +165,7 @@ fn unwrap_json_strings(data: &mut serde_json::Map) { } } -/// Inner upload logic shared by the BackendConnector impl and the fsnotify watcher. +/// Inner upload logic shared by the BackendConnector and application-triggered drain. /// Caller is responsible for holding `upload_guard` before calling. /// /// Returns `true` if at least one CRUD transaction was processed and committed, @@ -345,7 +373,7 @@ fn checkpoint_wal_standalone(db_path: &Path, label: &str, mode: WalCheckpointMod } /// Acquire the upload guard, get a fresh token, run_upload, and checkpoint. -/// Shared by both the startup path and the watcher loop to avoid divergence. +/// Shared by the startup drain and application-triggered drain. /// `context` is used as a log prefix (e.g. "Startup upload", "Upload"). /// /// A PASSIVE checkpoint is run after a successful upload to reclaim WAL space @@ -409,7 +437,7 @@ impl BackendConnector for FlickNoteConnector { async fn upload_data(&self) -> Result<(), PowerSyncError> { let _guard = self.upload_guard.lock().await; let token = self.get_token().await?; - // Ignore the bool — checkpoint is only safe to call from the watcher path, + // Ignore the bool — checkpoint is only safe to call from the serialized drain path, // not here (SDK callback fires during active sync alongside the download actor). run_upload( &self.db, @@ -1135,7 +1163,18 @@ async fn finish_remote_create( message: "Remote note create returned no short id".to_string(), })?; commit_remote_note(db, &row).await?; - create_extractions_with_token(db, http, config, access_token, extraction_rows).await?; + if let Err(error) = + create_extractions_with_token(db, http, config, access_token, extraction_rows).await + { + return Err(DaemonError::PartialCreate { + message: format!( + "Note {short_id} was created, but its topics were not fully confirmed: {error}" + ), + note_id: row.id, + short_id, + pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), + }); + } Ok(CreatedNote { uuid: row.id, short_id, @@ -1290,6 +1329,30 @@ struct RemoteNoteCreator { config: Arc, } +fn remote_create_service_error( + error: DaemonError, +) -> flicknote_core::services::error::ServiceError { + match error { + DaemonError::PartialCreate { + message, + note_id, + short_id, + pending_extraction_ids, + } => flicknote_core::services::error::ServiceError::Remote { + code: "note_create_partial".to_string(), + message, + retryable: false, + details: Some(serde_json::json!({ + "created": true, + "note_id": note_id, + "short_id": short_id, + "pending_extraction_ids": pending_extraction_ids, + })), + }, + error => flicknote_core::services::error::ServiceError::Daemon(error.to_string()), + } +} + #[async_trait] impl NoteCreator for RemoteNoteCreator { async fn create( @@ -1316,9 +1379,7 @@ impl NoteCreator for RemoteNoteCreator { }, ) .await - .map_err(|error| { - flicknote_core::services::error::ServiceError::Daemon(error.to_string()) - })?; + .map_err(remote_create_service_error)?; Ok(flicknote_core::backend::InsertedNote { uuid: created.uuid, short_id: Some(created.short_id), @@ -1381,97 +1442,6 @@ impl ShareGateway for RemoteShareGateway { } } -async fn serve_socket( - listener: UnixListener, - db: PowerSyncDatabase, - auth: Arc, - http: reqwest::Client, - config: Arc, - share_lock: Arc, - app: Arc, -) { - loop { - let (mut stream, _) = match listener.accept().await { - Ok(v) => v, - Err(e) => { - log::error!("IPC accept failed: {e}"); - continue; - } - }; - let db = db.clone(); - let auth = Arc::clone(&auth); - let http = http.clone(); - let config = Arc::clone(&config); - let share_lock = Arc::clone(&share_lock); - let app = Arc::clone(&app); - tokio::spawn(async move { - let response = match ipc::read_request(&mut stream).await { - Ok(DaemonRequest::Health { protocol }) => { - if protocol == ipc::PROTOCOL_VERSION { - DaemonResponse::ServerInfo(ipc::ServerInfo::local()) - } else { - DaemonResponse::AppError(ipc::WireError { - code: "daemon_protocol_mismatch".to_string(), - message: format!( - "daemon protocol {} does not support client protocol {protocol}", - ipc::PROTOCOL_VERSION - ), - retryable: false, - details: None, - }) - } - } - Ok(DaemonRequest::App { protocol, request }) => { - if protocol != ipc::PROTOCOL_VERSION { - DaemonResponse::AppError(ipc::WireError { - code: "daemon_protocol_mismatch".to_string(), - message: format!( - "daemon protocol {} does not support client protocol {protocol}", - ipc::PROTOCOL_VERSION - ), - retryable: false, - details: None, - }) - } else { - match app.handle(*request).await { - Ok(response) => DaemonResponse::App(Box::new(response)), - Err(error) => DaemonResponse::AppError(error), - } - } - } - Ok(DaemonRequest::CreateNote(req)) => { - match create_note_remotely(&db, &http, &auth, &config, *req).await { - Ok(note) => DaemonResponse::NoteCreated(note), - Err(e) => DaemonResponse::Error(e), - } - } - Ok(DaemonRequest::GetOrCreateShare(req)) => { - match share_lock - .run(get_or_create_share(&http, &auth, &config, &req)) - .await - { - Ok(url) => DaemonResponse::ShareUrl(ShareUrlResponse { url }), - Err(e) => DaemonResponse::Error(e), - } - } - Ok(DaemonRequest::RevokeShare(req)) => { - match share_lock - .run(revoke_share(&http, &auth, &config, &req)) - .await - { - Ok(()) => DaemonResponse::ShareRevoked, - Err(e) => DaemonResponse::Error(e), - } - } - Err(e) => DaemonResponse::Error(e), - }; - if let Err(e) = ipc::write_response(&mut stream, &response).await { - log::warn!("IPC response failed: {e}"); - } - }); - } -} - pub async fn run() -> Result<(), Box> { let config = Arc::new(Config::load()?); @@ -1537,53 +1507,11 @@ pub async fn run() -> Result<(), Box> { db.connect(SyncOptions::new(connector)).await; log::info!("Sync daemon connected (pid {})", std::process::id()); - // Watch the WAL file for cross-process writes from the CLI. - // PowerSync's in-process ps_crud watch can't detect writes from a separate process - // (e.g. `flicknote add`). Watching the WAL file catches any SQLite write regardless - // of which process wrote it, with ~200ms trailing-debounce latency. + // Application writes happen in this process. Each may-write request sends a + // best-effort trigger; the startup drain recovers committed writes whose signal + // was lost because of a crash or a full channel. let (trigger_tx, mut trigger_rx) = mpsc::channel::<()>(16); - // Build the WAL filename ("-wal") for event filtering. - // The WAL may not exist yet on a fresh DB, so watch the parent dir and - // filter by filename — handles both cases without runtime switching. - let wal_filename = { - let mut name = config - .paths - .db_file - .file_name() - .ok_or("db_file path has no filename component")? - .to_os_string(); - name.push("-wal"); - name - }; - let db_dir = config - .paths - .db_file - .parent() - .ok_or("db_file path has no parent directory")? - .to_path_buf(); - let wal_fname_clone = wal_filename.clone(); - - let mut fs_watcher = RecommendedWatcher::new( - move |res: Result| match res { - Err(e) => log::error!("fs_watcher error (uploads may stall): {e}"), - Ok(event) => { - if !matches!(event.kind, EventKind::Modify(_)) { - return; - } - let is_wal = event - .paths - .iter() - .any(|p| p.file_name().is_some_and(|f| f == wal_fname_clone)); - if is_wal && trigger_tx.try_send(()).is_err() { - log::debug!("WAL trigger channel full — event dropped (burst in progress)"); - } - } - }, - NotifyConfig::default(), - )?; - fs_watcher.watch(&db_dir, RecursiveMode::NonRecursive)?; - let upload_db = db.clone(); let upload_supabase_url = config.supabase_url.clone(); let upload_anon_key = config.supabase_anon_key.clone(); @@ -1592,8 +1520,8 @@ pub async fn run() -> Result<(), Box> { let upload_db_path = config.paths.db_file.clone(); let mut upload_handle = tokio::spawn(async move { - // Initial upload on startup — pick up any ps_crud entries written before - // the daemon started (e.g. CLI ran while daemon was down). + // Initial upload on startup recovers committed CRUD left by a crash, + // a lost in-process signal, or a pre-upgrade CLI writer. try_upload_and_checkpoint( &upload_db, &upload_client, @@ -1607,9 +1535,9 @@ pub async fn run() -> Result<(), Box> { .await; loop { - // Block until a WAL change is detected. + // Block until the application host reports a may-write request. if trigger_rx.recv().await.is_none() { - break; // watcher dropped — daemon shutting down + break; } // Trailing debounce: collapse burst writes (e.g. bulk import) into a @@ -1664,9 +1592,6 @@ pub async fn run() -> Result<(), Box> { user_id, }); let socket_config = Arc::clone(&config); - let socket_config_for_task = Arc::clone(&socket_config); - let socket_db = db.clone(); - let socket_auth = Arc::clone(&auth); let socket_http = reqwest::Client::new(); let socket_share_lock = Arc::new(ShareRequestLock::default()); let creator: Arc = Arc::new(RemoteNoteCreator { @@ -1679,25 +1604,19 @@ pub async fn run() -> Result<(), Box> { http: socket_http.clone(), auth: Arc::clone(&auth), config: Arc::clone(&config), - lock: Arc::clone(&socket_share_lock), + lock: socket_share_lock, }); let app = Arc::new( Application::new(backend, ipc::BackendMode::Local) .with_creator(creator) .with_share_gateway(gateway) - .with_web_url(config.web_url.clone()), + .with_web_url(config.web_url.clone()) + .with_write_signal(trigger_tx), ); let mut socket_handle = tokio::spawn(async move { - serve_socket( - socket_listener, - socket_db, - socket_auth, - socket_http, - socket_config_for_task, - socket_share_lock, - app, - ) - .await; + if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::local()).await { + log::error!("Application socket server failed: {error}"); + } }); tokio::select! { @@ -2109,6 +2028,23 @@ mod tests { (format!("http://{address}"), handle) } + #[test] + fn partial_remote_create_maps_to_non_retryable_structured_service_error() { + let error = remote_create_service_error(DaemonError::PartialCreate { + message: "note created; topics pending".to_string(), + note_id: "note-partial".to_string(), + short_id: 80, + pending_extraction_ids: vec!["extraction-1".to_string()], + }); + + assert_eq!(error.code(), "note_create_partial"); + assert!(!error.retryable()); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = error else { + panic!("expected remote service error") + }; + assert_eq!(details.unwrap()["short_id"], 80); + } + #[tokio::test] async fn remote_create_returns_after_canonical_note_is_committed_locally() { let body = r#"[{"id":"note-create","short_id":77,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; @@ -2158,6 +2094,67 @@ mod tests { ); } + #[tokio::test] + async fn remote_create_reports_typed_partial_success_after_note_commit() { + let note = r#"[{"id":"note-partial","short_id":80,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![ + ("201 Created", note), + ( + "500 Internal Server Error", + r#"{"message":"topic failure"}"#, + ), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-partial".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: vec!["rust".to_string()], + attachment_path: None, + }, + ) + .await + .unwrap_err(); + + let DaemonError::PartialCreate { + note_id, + short_id, + pending_extraction_ids, + .. + } = error + else { + panic!("expected partial create error") + }; + assert_eq!(note_id, "note-partial"); + assert_eq!(short_id, 80); + assert_eq!(pending_extraction_ids.len(), 1); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM notes WHERE id = ?", + params!["note-partial"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(server.join().unwrap().len(), 2); + } + #[tokio::test] async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { let body = r#"[{"id":"note-retry","short_id":78,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 78e6d5e..8c692ae 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -36,6 +36,40 @@ fn application_is_safe_to_share_between_daemon_request_tasks() { assert_send_sync::(); } +#[tokio::test] +async fn application_signals_every_may_write_request_even_when_it_fails() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let (signal, mut receiver) = tokio::sync::mpsc::channel(4); + let app = Application::new(backend, BackendMode::Local).with_write_signal(signal); + + app.handle(AppRequest::NoteList(NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + })) + .await + .unwrap(); + assert!(receiver.try_recv().is_err()); + + let error = app + .handle(AppRequest::KeytermModify { + id: "missing".to_string(), + name: None, + description: None, + content: None, + }) + .await + .unwrap_err(); + assert_eq!(error.code, "nothing_to_modify"); + receiver.try_recv().unwrap(); +} + struct RecordingCreator { db: Arc, request: std::sync::Mutex>, diff --git a/skills/flicknote.md b/skills/flicknote.md index f37ea48..7ad9ab5 100644 --- a/skills/flicknote.md +++ b/skills/flicknote.md @@ -5,7 +5,7 @@ description: "FlickNote CLI for managing notes - add, find, detail, modify, and # FlickNote CLI -Use FlickNote to save and retrieve local-first notes from the command line. +Use FlickNote to save and retrieve daemon-backed, local-first notes from the command line. Run `flicknote --help` for exact flags and examples. ## Project Use @@ -75,8 +75,8 @@ exact `before`/`after` edits are JSON fields, so MCP callers do not use shell heredocs or edit-mode delimiters. Note tools use numeric short IDs and hide internal UUIDs; project tools use project names. Use `note_get` for editable content and `note_source` only for stored source data. The server supports only -the local PowerSync workspace. Note creation and note/project share or unshare -require the sync daemon; local reads and edits do not. +the daemon-selected workspace. Every data tool requires the running sync daemon; +the CLI and MCP server never open the local database directly. `flicknote mcp` does not expose Gateway tools. Use the CLI command below for authenticated Gateway access. From 64c04ec434787c13c83de3039cbe762dc88cf7ef Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 16:12:23 +0800 Subject: [PATCH 04/16] fix(sync): address daemon review races --- flicknote-sync/src/ipc.rs | 43 ++++- flicknote-sync/src/lib.rs | 340 ++++++++++++++++++++++++++++++++++---- 2 files changed, 348 insertions(+), 35 deletions(-) diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 6e3053f..b662640 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -383,9 +383,12 @@ pub enum DaemonError { PartialCreate { message: String, note_id: String, - short_id: i64, + short_id: Option, pending_extraction_ids: Vec, }, + InvalidResponse { + message: String, + }, Other { message: String, }, @@ -397,7 +400,9 @@ impl fmt::Display for DaemonError { Self::Unavailable { path, message } => { write!(f, "Sync daemon is not available at {path}: {message}") } - Self::PartialCreate { message, .. } | Self::Other { message } => f.write_str(message), + Self::PartialCreate { message, .. } + | Self::InvalidResponse { message } + | Self::Other { message } => f.write_str(message), } } } @@ -428,7 +433,7 @@ pub async fn send_request( .map_err(|e| DaemonError::Other { message: format!("Failed to read daemon response: {e}"), })?; - serde_json::from_slice(&buf).map_err(|e| DaemonError::Other { + serde_json::from_slice(&buf).map_err(|e| DaemonError::InvalidResponse { message: format!("Failed to parse daemon response: {e}"), }) } @@ -443,12 +448,14 @@ impl<'a> DaemonClient<'a> { } async fn request(&self, request: DaemonRequest) -> Result { + let is_health = matches!(request, DaemonRequest::Health { .. }); send_request(self.config, &request) .await .map_err(|error| match error { DaemonError::Unavailable { .. } => ServiceError::DaemonUnavailable(format!( "{error}. Start it with `flicknote sync start`." )), + DaemonError::InvalidResponse { .. } if is_health => Self::protocol_mismatch(), other => ServiceError::Daemon(other.to_string()), }) } @@ -793,4 +800,34 @@ mod tests { assert!(error.to_string().contains("sync stop")); server.await.unwrap(); } + + #[tokio::test] + async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await.unwrap(); + let response = json!({ + "type": "error", + "payload": { + "code": "other", + "message": "Failed to parse daemon request: unknown variant `health`" + } + }); + write_json(&mut stream, &response).await.unwrap(); + request + }); + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(!error.retryable()); + assert!(error.to_string().contains("sync stop")); + assert!(matches!( + server.await.unwrap(), + DaemonRequest::Health { .. } + )); + } } diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index dbcbab9..63f9c22 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -392,14 +392,14 @@ async fn try_upload_and_checkpoint( supabase_anon_key: &str, context: &str, db_path: &Path, -) { +) -> bool { let _guard = guard.lock().await; let token = match auth.get_session().await { Ok(s) => s.access_token, Err(e) => { log::warn!("{context}: auth error: {e}"); - return; + return false; } }; match run_upload(db, client, &token, supabase_url, supabase_anon_key).await { @@ -414,11 +414,60 @@ async fn try_upload_and_checkpoint( { log::error!("Post-upload WAL checkpoint task panicked: {e}"); } + true + } + Err(e) => { + log::warn!("{context}: upload failed: {e}"); + false } - Err(e) => log::warn!("{context}: upload failed: {e}"), } } +async fn retry_with_backoff( + mut attempt: F, + initial_delay: std::time::Duration, + maximum_delay: std::time::Duration, +) where + F: FnMut() -> Fut, + Fut: Future, +{ + let mut delay = initial_delay; + while !attempt().await { + tokio::time::sleep(delay).await; + delay = delay.saturating_mul(2).min(maximum_delay); + } +} + +#[allow(clippy::too_many_arguments)] +async fn retry_upload_until_success( + db: &PowerSyncDatabase, + client: &reqwest::Client, + auth: &GoTrueClient, + guard: &tokio::sync::Mutex<()>, + supabase_url: &str, + supabase_anon_key: &str, + context: &str, + db_path: &Path, +) { + retry_with_backoff( + || { + try_upload_and_checkpoint( + db, + client, + auth, + guard, + supabase_url, + supabase_anon_key, + context, + db_path, + ) + }, + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(30), + ) + .await; +} + #[async_trait] impl BackendConnector for FlickNoteConnector { async fn fetch_credentials(&self) -> Result { @@ -1131,26 +1180,79 @@ async fn create_note_with_token( }); } - let mut rows = resp - .json::>() - .await - .map_err(|e| DaemonError::Other { - message: format!("Failed to parse remote note create response: {e}"), - })?; - let row = match rows.pop() { - Some(row) => row, - None => lookup_remote_note(http, config, access_token, &req.id) + let row = match resp.json::>().await { + Ok(mut rows) => match rows.pop() { + Some(row) => row, + None => { + reconcile_confirmed_remote_note( + http, + config, + access_token, + &req.id, + &extraction_rows, + format!("Remote note create returned no row for note {}", req.id), + ) + .await? + } + }, + Err(error) => { + reconcile_confirmed_remote_note( + http, + config, + access_token, + &req.id, + &extraction_rows, + format!("Failed to parse remote note create response: {error}"), + ) .await? - .ok_or_else(|| DaemonError::Other { - message: format!( - "Remote note create returned no row and note {} was not found", - req.id - ), - })?, + } }; finish_remote_create(db, http, config, access_token, row, &extraction_rows).await } +fn confirmed_create_error( + message: String, + note_id: String, + short_id: Option, + extraction_rows: &[RemoteExtractionRow], +) -> DaemonError { + DaemonError::PartialCreate { + message, + note_id, + short_id, + pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), + } +} + +async fn reconcile_confirmed_remote_note( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + extraction_rows: &[RemoteExtractionRow], + original_error: String, +) -> Result { + match lookup_remote_note(http, config, access_token, note_id).await { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(confirmed_create_error( + format!( + "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}. Do not create it again." + ), + note_id.to_string(), + None, + extraction_rows, + )), + Err(error) => Err(confirmed_create_error( + format!( + "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}; reconciliation failed: {error}. Do not create it again." + ), + note_id.to_string(), + None, + extraction_rows, + )), + } +} + async fn finish_remote_create( db: &PowerSyncDatabase, http: &reqwest::Client, @@ -1159,21 +1261,41 @@ async fn finish_remote_create( row: RemoteNoteRow, extraction_rows: &[RemoteExtractionRow], ) -> Result { - let short_id = row.short_id.ok_or_else(|| DaemonError::Other { - message: "Remote note create returned no short id".to_string(), - })?; - commit_remote_note(db, &row).await?; + let short_id = match row.short_id { + Some(short_id) => short_id, + None => { + return Err(confirmed_create_error( + format!( + "Note {} was created remotely, but the backend returned no short id. Do not create it again.", + row.id + ), + row.id, + None, + extraction_rows, + )); + } + }; + if let Err(error) = commit_remote_note(db, &row).await { + return Err(confirmed_create_error( + format!( + "Note {short_id} was created remotely, but could not be committed locally: {error}. Do not create it again." + ), + row.id, + Some(short_id), + extraction_rows, + )); + } if let Err(error) = create_extractions_with_token(db, http, config, access_token, extraction_rows).await { - return Err(DaemonError::PartialCreate { - message: format!( + return Err(confirmed_create_error( + format!( "Note {short_id} was created, but its topics were not fully confirmed: {error}" ), - note_id: row.id, - short_id, - pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), - }); + row.id, + Some(short_id), + extraction_rows, + )); } Ok(CreatedNote { uuid: row.id, @@ -1522,7 +1644,7 @@ pub async fn run() -> Result<(), Box> { let mut upload_handle = tokio::spawn(async move { // Initial upload on startup recovers committed CRUD left by a crash, // a lost in-process signal, or a pre-upgrade CLI writer. - try_upload_and_checkpoint( + retry_upload_until_success( &upload_db, &upload_client, &upload_auth_clone, @@ -1552,7 +1674,7 @@ pub async fn run() -> Result<(), Box> { } } - try_upload_and_checkpoint( + retry_upload_until_success( &upload_db, &upload_client, &upload_auth_clone, @@ -1686,6 +1808,24 @@ mod tests { use super::*; + #[tokio::test] + async fn failed_upload_is_retried_without_a_second_write_trigger() { + let attempts = Arc::new(AtomicUsize::new(0)); + let attempt_counter = Arc::clone(&attempts); + + retry_with_backoff( + move || { + let attempt_counter = Arc::clone(&attempt_counter); + async move { attempt_counter.fetch_add(1, Ordering::SeqCst) > 0 } + }, + std::time::Duration::from_millis(1), + std::time::Duration::from_millis(2), + ) + .await; + + assert_eq!(attempts.load(Ordering::SeqCst), 2); + } + async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { PowerSyncEnvironment::powersync_auto_extension().unwrap(); let directory = tempfile::tempdir().unwrap(); @@ -2033,7 +2173,7 @@ mod tests { let error = remote_create_service_error(DaemonError::PartialCreate { message: "note created; topics pending".to_string(), note_id: "note-partial".to_string(), - short_id: 80, + short_id: Some(80), pending_extraction_ids: vec!["extraction-1".to_string()], }); @@ -2141,7 +2281,7 @@ mod tests { panic!("expected partial create error") }; assert_eq!(note_id, "note-partial"); - assert_eq!(short_id, 80); + assert_eq!(short_id, Some(80)); assert_eq!(pending_extraction_ids.len(), 1); let reader = db.reader().await.unwrap(); let count: i64 = reader @@ -2197,6 +2337,142 @@ mod tests { ); } + #[tokio::test] + async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { + let body = r#"[{"id":"note-malformed","short_id":81,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", "{"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-malformed".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 81); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/notes?on_conflict=id HTTP/1.1", + "GET /rest/v1/notes?id=eq.note-malformed&select=* HTTP/1.1", + ] + ); + } + + #[tokio::test] + async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() { + let (origin, server) = spawn_server(vec![ + ("201 Created", "{"), + ("503 Service Unavailable", r#"{"message":"try later"}"#), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-confirmed".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_partial"); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["note_id"], "note-confirmed"); + assert!(details["short_id"].is_null()); + assert_eq!(server.join().unwrap().len(), 2); + } + + #[tokio::test] + async fn local_commit_failure_after_remote_create_reports_partial_success() { + let note = r#"[{"id":"note-local-failure","short_id":82,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", note)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + db.writer() + .await + .unwrap() + .execute("DROP VIEW notes", []) + .unwrap(); + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-local-failure".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_partial"); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["note_id"], "note-local-failure"); + assert_eq!(details["short_id"], 82); + assert_eq!(server.join().unwrap().len(), 1); + } + #[tokio::test] async fn remote_create_recovers_lost_response_by_stable_uuid() { let body = r#"[{"id":"note-lost","short_id":79,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; From edfeda3020ce6548543df7e526bbabd3dea64e33 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 16:41:24 +0800 Subject: [PATCH 05/16] fix(sync): reconcile ambiguous creates and daemon readiness --- flicknote-cli/src/commands/sync.rs | 70 +++++++- flicknote-cli/src/main.rs | 2 +- flicknote-sync/src/ipc.rs | 108 ++++++++++-- flicknote-sync/src/lib.rs | 257 +++++++++++++++++++++++++---- 4 files changed, 388 insertions(+), 49 deletions(-) diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index dc0f366..b42de6a 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -4,6 +4,9 @@ use flicknote_core::error::CliError; use std::fs; use std::path::Path; +const DAEMON_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const HEALTH_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); + #[derive(Args)] pub(crate) struct SyncArgs { #[command(subcommand)] @@ -24,9 +27,9 @@ enum SyncCommand { Uninstall, } -pub(crate) fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError> { +pub(crate) async fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError> { match &args.command { - SyncCommand::Start => start(config), + SyncCommand::Start => start(config).await, SyncCommand::Stop => stop(config), SyncCommand::Status => status(config), SyncCommand::Install => install(config), @@ -34,24 +37,33 @@ pub(crate) fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError> { } } -fn start(config: &Config) -> Result<(), CliError> { +async fn start(config: &Config) -> Result<(), CliError> { if let Some(pid) = super::daemon::read_pid(config) { + wait_for_daemon_ready(config, DAEMON_START_TIMEOUT, HEALTH_POLL_INTERVAL).await?; println!("FlickNote daemon already running (pid {pid})"); return Ok(()); } let daemon_binary = super::daemon::daemon_binary()?; - start_with_binary(config, &daemon_binary) + start_with_binary(config, &daemon_binary).await +} + +async fn start_with_binary(config: &Config, daemon_binary: &Path) -> Result<(), CliError> { + start_with_binary_and_timeout(config, daemon_binary, DAEMON_START_TIMEOUT).await } -fn start_with_binary(config: &Config, daemon_binary: &Path) -> Result<(), CliError> { +async fn start_with_binary_and_timeout( + config: &Config, + daemon_binary: &Path, + timeout: std::time::Duration, +) -> Result<(), CliError> { let log = fs::OpenOptions::new() .create(true) .append(true) .open(&config.paths.log_file)?; let log2 = log.try_clone()?; - let child = std::process::Command::new(daemon_binary) + let mut child = std::process::Command::new(daemon_binary) .env( "RUST_LOG", std::env::var("RUST_LOG") @@ -63,10 +75,46 @@ fn start_with_binary(config: &Config, daemon_binary: &Path) -> Result<(), CliErr .spawn()?; let pid = child.id(); + if let Err(error) = wait_for_daemon_ready(config, timeout, HEALTH_POLL_INTERVAL).await { + if let Err(kill_error) = child.kill() + && kill_error.kind() != std::io::ErrorKind::InvalidInput + { + log::warn!("Failed to stop unready daemon process {pid}: {kill_error}"); + } + if let Err(wait_error) = child.wait() { + log::warn!("Failed to reap unready daemon process {pid}: {wait_error}"); + } + return Err(error); + } println!("FlickNote daemon started (pid {pid})"); Ok(()) } +async fn wait_for_daemon_ready( + config: &Config, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), CliError> { + let wait = async { + loop { + match flicknote_sync::ipc::DaemonClient::new(config) + .health() + .await + { + Ok(_) => return Ok(()), + Err(error) if !error.retryable() => return Err(CliError::from(error)), + Err(_) => tokio::time::sleep(interval).await, + } + } + }; + tokio::time::timeout(timeout, wait).await.map_err(|_| { + CliError::Other(format!( + "Sync daemon did not become ready within {timeout:?}; check {}", + config.paths.log_file.display() + )) + })? +} + fn stop(config: &Config) -> Result<(), CliError> { if super::daemon::read_pid(config).is_none() { println!("FlickNote daemon not running"); @@ -134,8 +182,8 @@ mod tests { } #[cfg(unix)] - #[test] - fn parent_process_does_not_write_daemon_pid_file() { + #[tokio::test] + async fn start_does_not_report_success_before_daemon_health_is_ready() { let dir = tempfile::tempdir().expect("temp dir"); let config = test_config(dir.path()); let daemon = dir.path().join("fake-daemon"); @@ -143,9 +191,13 @@ mod tests { #[cfg(unix)] fs::set_permissions(&daemon, fs::Permissions::from_mode(0o700)).expect("chmod fake daemon"); - start_with_binary(&config, &daemon).expect("start fake daemon"); + let error = + start_with_binary_and_timeout(&config, &daemon, std::time::Duration::from_millis(50)) + .await + .expect_err("a process that exits without serving health is not ready"); assert!(!super::super::daemon::pid_file(&config).exists()); + assert!(error.to_string().contains("did not become ready")); } #[test] diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index 9717e92..c95b00d 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -110,7 +110,7 @@ async fn run() -> Result<(), CliError> { match cmd { Commands::Login(args) => return commands::login::run(&config, args).await, Commands::Logout => return commands::logout::run(&config), - Commands::Sync(args) => return commands::sync::run(&config, args), + Commands::Sync(args) => return commands::sync::run(&config, args).await, Commands::Skill(args) => return commands::skill::run(args), Commands::Gateway(args) => return commands::gateway::run(&config, args).await, _ => {} diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index b662640..fdab327 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -19,6 +19,10 @@ use tokio::net::UnixStream; use crate::app::Application; pub const PROTOCOL_VERSION: u16 = 1; +const IPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_HEALTH_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_APP_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -386,6 +390,11 @@ pub enum DaemonError { short_id: Option, pending_extraction_ids: Vec, }, + AmbiguousCreate { + message: String, + note_id: String, + pending_extraction_ids: Vec, + }, InvalidResponse { message: String, }, @@ -401,6 +410,7 @@ impl fmt::Display for DaemonError { write!(f, "Sync daemon is not available at {path}: {message}") } Self::PartialCreate { message, .. } + | Self::AmbiguousCreate { message, .. } | Self::InvalidResponse { message } | Self::Other { message } => f.write_str(message), } @@ -413,23 +423,53 @@ pub fn socket_path(config: &Config) -> PathBuf { config.paths.data_dir.join("sync.sock") } +fn unavailable(path: &std::path::Path, stage: &str) -> DaemonError { + DaemonError::Unavailable { + path: path.display().to_string(), + message: format!("timed out while {stage}"), + } +} + +fn request_timeout_error( + request: &DaemonRequest, + path: &std::path::Path, + stage: &str, +) -> DaemonError { + if matches!(request, DaemonRequest::Health { .. }) { + return unavailable(path, stage); + } + DaemonError::Other { + message: format!( + "Timed out while {stage} from the sync daemon at {}; the application request outcome is unknown. Do not retry it automatically.", + path.display() + ), + } +} + pub async fn send_request( config: &Config, request: &DaemonRequest, ) -> Result { let path = socket_path(config); - let mut stream = - UnixStream::connect(&path) - .await - .map_err(|error| DaemonError::Unavailable { - path: path.display().to_string(), - message: error.to_string(), - })?; - write_json(&mut stream, request).await?; + let mut stream = tokio::time::timeout(IPC_CONNECT_TIMEOUT, UnixStream::connect(&path)) + .await + .map_err(|_| unavailable(&path, "connecting"))? + .map_err(|error| DaemonError::Unavailable { + path: path.display().to_string(), + message: error.to_string(), + })?; + tokio::time::timeout(IPC_WRITE_TIMEOUT, write_json(&mut stream, request)) + .await + .map_err(|_| request_timeout_error(request, &path, "sending a request"))??; let mut buf = Vec::new(); - stream - .read_to_end(&mut buf) + let response_timeout = if matches!(request, DaemonRequest::Health { .. }) { + IPC_HEALTH_RESPONSE_TIMEOUT + } else { + IPC_APP_RESPONSE_TIMEOUT + }; + tokio::time::timeout(response_timeout, stream.read_to_end(&mut buf)) .await + .map_err(|_| request_timeout_error(request, &path, "waiting for a response"))? .map_err(|e| DaemonError::Other { message: format!("Failed to read daemon response: {e}"), })?; @@ -741,6 +781,54 @@ mod tests { assert!(error.to_string().contains("flicknote sync start")); } + #[tokio::test] + async fn health_request_has_a_bounded_response_wait() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(1_200), + send_request( + &config, + &DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }, + ), + ) + .await; + server.abort(); + + let response = result.expect("IPC must enforce its own response timeout"); + assert!(matches!(response, Err(DaemonError::Unavailable { .. }))); + } + + #[test] + fn application_timeout_is_non_retryable_because_its_outcome_is_unknown() { + let request = DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })), + }; + + let error = request_timeout_error( + &request, + std::path::Path::new("/tmp/flicknote.sock"), + "waiting for a response", + ); + + assert!(matches!(error, DaemonError::Other { .. })); + assert!(error.to_string().contains("outcome is unknown")); + } + #[tokio::test] async fn daemon_client_preserves_versioned_app_results_and_errors() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 63f9c22..680668c 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -1145,21 +1145,45 @@ async fn create_note_with_token( { Ok(resp) => resp, Err(e) => { - if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { - return finish_remote_create(db, http, config, access_token, row, &extraction_rows) + match reconcile_ambiguous_remote_create(http, config, access_token, &req.id).await { + AmbiguousCreateReconciliation::Found(row) => { + return finish_remote_create( + db, + http, + config, + access_token, + *row, + &extraction_rows, + ) .await; + } + AmbiguousCreateReconciliation::Absent => { + if attachment_path.is_some() + && let Err(cleanup_error) = + delete_attachment(http, config, access_token, &req.id).await + { + log::warn!( + "Failed to clean up uploaded attachment after confirming note absence: {cleanup_error}" + ); + } + return Err(DaemonError::Other { + message: format!( + "Remote note create failed and note {} was confirmed absent: {e}", + req.id + ), + }); + } + AmbiguousCreateReconciliation::Unknown(reconciliation_error) => { + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after a transport failure ({e}); reconciliation could not confirm creation or absence: {reconciliation_error}. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } } - if attachment_path.is_some() - && let Err(cleanup_err) = - delete_attachment(http, config, access_token, &req.id).await - { - log::warn!( - "Failed to clean up uploaded attachment after note create request failure: {cleanup_err}" - ); - } - return Err(DaemonError::Other { - message: format!("Remote note create failed: {e}"), - }); } }; @@ -1210,6 +1234,42 @@ async fn create_note_with_token( finish_remote_create(db, http, config, access_token, row, &extraction_rows).await } +enum AmbiguousCreateReconciliation { + Found(Box), + Absent, + Unknown(String), +} + +async fn reconcile_ambiguous_remote_create( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, +) -> AmbiguousCreateReconciliation { + const ATTEMPTS: usize = 3; + let mut confirmed_absent = 0; + let mut last_error = None; + + for attempt in 0..ATTEMPTS { + match lookup_remote_note(http, config, access_token, note_id).await { + Ok(Some(row)) => return AmbiguousCreateReconciliation::Found(Box::new(row)), + Ok(None) => confirmed_absent += 1, + Err(error) => last_error = Some(error.to_string()), + } + if attempt + 1 < ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + + if confirmed_absent == ATTEMPTS { + AmbiguousCreateReconciliation::Absent + } else { + AmbiguousCreateReconciliation::Unknown( + last_error.unwrap_or_else(|| "inconsistent reconciliation responses".to_string()), + ) + } +} + fn confirmed_create_error( message: String, note_id: String, @@ -1224,6 +1284,18 @@ fn confirmed_create_error( } } +fn ambiguous_create_error( + message: String, + note_id: String, + extraction_rows: &[RemoteExtractionRow], +) -> DaemonError { + DaemonError::AmbiguousCreate { + message, + note_id, + pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), + } +} + async fn reconcile_confirmed_remote_note( http: &reqwest::Client, config: &Config, @@ -1471,6 +1543,21 @@ fn remote_create_service_error( "pending_extraction_ids": pending_extraction_ids, })), }, + DaemonError::AmbiguousCreate { + message, + note_id, + pending_extraction_ids, + } => flicknote_core::services::error::ServiceError::Remote { + code: "note_create_unknown".to_string(), + message, + retryable: false, + details: Some(serde_json::json!({ + "created": serde_json::Value::Null, + "note_id": note_id, + "short_id": serde_json::Value::Null, + "pending_extraction_ids": pending_extraction_ids, + })), + }, error => flicknote_core::services::error::ServiceError::Daemon(error.to_string()), } } @@ -2131,12 +2218,35 @@ mod tests { fn spawn_disconnected_response_then_server( status: &'static str, body: &'static str, + ) -> (String, thread::JoinHandle>) { + spawn_disconnected_then_retry_responses(vec![(status, body)]) + } + + fn spawn_disconnected_then_retry_responses( + responses: Vec<(&'static str, &'static str)>, ) -> (String, thread::JoinHandle>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let handle = thread::spawn(move || { - let mut requests = Vec::new(); let (mut first, _) = listener.accept().unwrap(); + listener.set_nonblocking(true).unwrap(); + let accept = || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match listener.accept() { + Ok(pair) => return Some(pair), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return None; + } + thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("accept failed: {error}"), + } + } + }; + + let mut requests = Vec::new(); let mut buffer = [0_u8; 4096]; let count = first.read(&mut buffer).unwrap(); requests.push( @@ -2148,21 +2258,25 @@ mod tests { ); drop(first); - let (mut second, _) = listener.accept().unwrap(); - let count = second.read(&mut buffer).unwrap(); - requests.push( - String::from_utf8_lossy(&buffer[..count]) - .lines() - .next() - .unwrap_or_default() - .to_string(), - ); - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - second.write_all(response.as_bytes()).unwrap(); + for (status, body) in responses { + let Some((mut stream, _)) = accept() else { + break; + }; + let count = stream.read(&mut buffer).unwrap(); + requests.push( + String::from_utf8_lossy(&buffer[..count]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + ); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } requests }); (format!("http://{address}"), handle) @@ -2509,6 +2623,91 @@ mod tests { assert_eq!(server.join().unwrap().len(), 2); } + #[tokio::test] + async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { + let (origin, server) = spawn_disconnected_response_then_server( + "503 Service Unavailable", + r#"{"message":"try later"}"#, + ); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-unknown".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_unknown"); + assert!(!service_error.retryable()); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert!(details["created"].is_null()); + assert_eq!(details["note_id"], "note-unknown"); + assert_eq!(server.join().unwrap().len(), 2); + } + + #[tokio::test] + async fn ambiguous_transport_failure_retries_reconciliation_before_returning() { + let body = r#"[{"id":"note-recovered-after-retry","short_id":83,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_disconnected_then_retry_responses(vec![ + ("503 Service Unavailable", r#"{"message":"try later"}"#), + ("200 OK", body), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let result = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-recovered-after-retry".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await; + let requests = server.join().unwrap(); + + let created = result.unwrap(); + assert_eq!(created.short_id, 83); + assert_eq!(requests.len(), 3); + } + #[tokio::test] async fn remote_extraction_create_commits_confirmed_rows_locally() { let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; From abeb9fd9360584bfdeaa22270b15e1fc2b7981d2 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 17:26:32 +0800 Subject: [PATCH 06/16] fix(sync): enforce daemon capability and readiness contracts --- README.md | 11 +- flicknote-cli/src/commands/daemon.rs | 56 ------- flicknote-cli/src/commands/login.rs | 6 + flicknote-cli/src/commands/sync.rs | 62 +++++++- flicknote-cli/src/help/root.md | 1 + flicknote-cli/src/main.rs | 7 +- flicknote-cli/tests/mcp_stdio.rs | 4 +- flicknote-sync/src/app.rs | 80 ++++++---- flicknote-sync/src/ipc.rs | 229 ++++++++++++++++++++++----- flicknote-sync/src/lib.rs | 113 +++++-------- flicknote-sync/tests/app_contract.rs | 43 ++++- skills/flicknote.md | 10 +- 12 files changed, 399 insertions(+), 223 deletions(-) diff --git a/README.md b/README.md index ab93699..ad4e8ea 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ flicknote delete # Manage sync daemon flicknote sync start +# Reports the running daemon version and selected backend mode flicknote sync status flicknote sync stop @@ -152,15 +153,15 @@ start it as a subprocess: } ``` -The MCP server exposes typed note, note-source, and project tools. Note content +The MCP server is available with a local daemon; managed daemons return an +`unsupported_capability` error before stdio protocol output. It exposes typed +note, note-source, and project tools. Note content and exact `before`/`after` edits are JSON fields, so callers do not need shell heredocs. Note tools accept numeric short IDs and do not expose internal UUIDs; project tools use project names. `note_source` reads stored source data, while `note_get` reads editable note content. Every data tool uses the running daemon; -the MCP process never opens SQLite or connects to Postgres. The daemon chooses -one backend at startup: local PowerSync by default, or managed Postgres when -`DATABASE_URL` is set in the daemon environment. The server does not start the -daemon automatically. +the MCP process never opens SQLite. The server does not start the daemon +automatically. ## Configuration diff --git a/flicknote-cli/src/commands/daemon.rs b/flicknote-cli/src/commands/daemon.rs index 5e83586..72b87fe 100644 --- a/flicknote-cli/src/commands/daemon.rs +++ b/flicknote-cli/src/commands/daemon.rs @@ -152,13 +152,6 @@ pub(crate) fn install(config: &Config) -> Result<(), CliError> { } } - wait_for_path( - &flicknote_sync::ipc::socket_path(config), - std::time::Duration::from_secs(5), - std::time::Duration::from_millis(100), - ) - .map_err(|e| CliError::Other(format!("Sync daemon did not become ready: {e}")))?; - Ok(()) } @@ -209,26 +202,6 @@ fn launchd_install_commands( ] } -#[cfg(any(target_os = "macos", test))] -fn wait_for_path( - path: &std::path::Path, - timeout: std::time::Duration, - interval: std::time::Duration, -) -> Result<(), String> { - let start = std::time::Instant::now(); - while start.elapsed() < timeout { - if path.exists() { - return Ok(()); - } - std::thread::sleep(interval); - } - Err(format!( - "{} did not appear within {:?}", - path.display(), - timeout - )) -} - /// Run `launchctl bootout`, warning on unexpected errors (not-loaded is expected and silent). #[cfg(target_os = "macos")] fn bootout_service(uid: u32, label: &str) { @@ -273,33 +246,4 @@ mod tests { ] ); } - - #[test] - fn wait_for_path_returns_when_socket_appears() { - let dir = tempfile::tempdir().expect("temp dir"); - let socket = dir.path().join("sync.sock"); - fs::write(&socket, "").expect("write socket marker"); - - wait_for_path( - &socket, - std::time::Duration::from_secs(1), - std::time::Duration::from_millis(1), - ) - .expect("socket ready"); - } - - #[test] - fn wait_for_path_errors_when_socket_never_appears() { - let dir = tempfile::tempdir().expect("temp dir"); - let socket = dir.path().join("sync.sock"); - - let err = wait_for_path( - &socket, - std::time::Duration::from_millis(1), - std::time::Duration::from_millis(1), - ) - .expect_err("missing socket should fail"); - - assert!(err.contains("sync.sock")); - } } diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index 21b3003..c52204b 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -82,6 +82,12 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro // then bootstraps fresh. The daemon starts immediately (KeepAlive + RunAtLoad) // and creates the local DB on startup. super::daemon::install(config)?; + super::sync::wait_for_daemon_ready( + config, + std::time::Duration::from_secs(10), + std::time::Duration::from_millis(100), + ) + .await?; println!("Sync daemon installed and started"); Ok(()) diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index b42de6a..9e1a6af 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -31,8 +31,8 @@ pub(crate) async fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError match &args.command { SyncCommand::Start => start(config).await, SyncCommand::Stop => stop(config), - SyncCommand::Status => status(config), - SyncCommand::Install => install(config), + SyncCommand::Status => status(config).await, + SyncCommand::Install => install(config).await, SyncCommand::Uninstall => uninstall(), } } @@ -90,7 +90,7 @@ async fn start_with_binary_and_timeout( Ok(()) } -async fn wait_for_daemon_ready( +pub(super) async fn wait_for_daemon_ready( config: &Config, timeout: std::time::Duration, interval: std::time::Duration, @@ -125,17 +125,44 @@ fn stop(config: &Config) -> Result<(), CliError> { Ok(()) } -fn status(config: &Config) -> Result<(), CliError> { +async fn status(config: &Config) -> Result<(), CliError> { match super::daemon::read_pid(config) { - Some(pid) => println!("FlickNote daemon: running (pid {pid})"), + Some(pid) => { + let info = flicknote_sync::ipc::DaemonClient::new(config) + .health() + .await?; + println!("{}", format_running_status(pid, &info)); + } None => println!("FlickNote daemon: not running"), } Ok(()) } -fn install(config: &Config) -> Result<(), CliError> { - validate_install_mode(std::env::var("DATABASE_URL").ok().as_deref())?; +fn format_running_status(pid: u32, info: &flicknote_sync::ipc::ServerInfo) -> String { + let backend = info.backend.as_str(); + format!( + "FlickNote daemon: running (pid {pid}, version {}, backend {backend}, protocol {})", + info.version, info.protocol + ) +} + +async fn install(config: &Config) -> Result<(), CliError> { + install_with_timeout( + config, + std::env::var("DATABASE_URL").ok().as_deref(), + DAEMON_START_TIMEOUT, + ) + .await +} + +async fn install_with_timeout( + config: &Config, + database_url: Option<&str>, + timeout: std::time::Duration, +) -> Result<(), CliError> { + validate_install_mode(database_url)?; super::daemon::install(config)?; + wait_for_daemon_ready(config, timeout, HEALTH_POLL_INTERVAL).await?; println!("Installed and started: io.guion.flicknote.sync"); Ok(()) } @@ -210,4 +237,25 @@ mod tests { .contains("only installs the local PowerSync daemon") ); } + + #[tokio::test] + async fn install_does_not_report_success_before_health_is_ready() { + let dir = tempfile::tempdir().expect("temp dir"); + let config = test_config(dir.path()); + + let error = install_with_timeout(&config, None, std::time::Duration::from_millis(20)) + .await + .expect_err("socket absence must not count as launchd readiness"); + + assert!(error.to_string().contains("did not become ready")); + } + + #[test] + fn status_line_reports_runtime_version_and_backend() { + let line = format_running_status(42, &flicknote_sync::ipc::ServerInfo::managed()); + + assert!(line.contains("pid 42")); + assert!(line.contains(env!("CARGO_PKG_VERSION"))); + assert!(line.contains("managed")); + } } diff --git a/flicknote-cli/src/help/root.md b/flicknote-cli/src/help/root.md index d78ae20..4ed7ec8 100644 --- a/flicknote-cli/src/help/root.md +++ b/flicknote-cli/src/help/root.md @@ -1,5 +1,6 @@ FlickNote works with local and managed workspaces. Managed workspaces support data commands that do not require local files or services. +File upload/import, editor, browser, sharing, and MCP workflows require a local workspace. Data commands require the FlickNote daemon. Start it with `flicknote sync start`. The daemon selects local PowerSync or managed Postgres once at startup. Run `flicknote --help` for exact flags and examples. diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index c95b00d..f58c738 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -3,7 +3,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_sync::ipc::DaemonClient; +use flicknote_sync::ipc::{Capability, DaemonClient}; const ROOT_HELP: &str = include_str!("help/root.md"); @@ -118,8 +118,9 @@ async fn run() -> Result<(), CliError> { } let daemon = DaemonClient::new(&config); - daemon.health().await?; + let server_info = daemon.health().await?; if matches!(cli.command, Some(Commands::Mcp)) { + server_info.require(Capability::Mcp, "mcp")?; return tokio::task::LocalSet::new() .run_until(mcp::serve(std::rc::Rc::new(config))) .await; @@ -308,7 +309,7 @@ mod tests { let daemon_server = tokio::spawn(serve_app( daemon_listener, app, - ServerInfo::managed(), + ServerInfo::local(), )); let server = mcp::FlickNoteMcp::new(Rc::new(config)); let (server_io, client_io) = tokio::io::duplex(8 * 1024); diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index a09bcfd..367a680 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -61,12 +61,12 @@ fn spawn_test_daemon(config_root: &std::path::Path, data_root: &std::path::Path) db: Database::open_local(&config).await.unwrap(), user_id: "test-user".to_string(), }); - let app = std::sync::Arc::new(Application::new(backend, BackendMode::Managed)); + let app = std::sync::Arc::new(Application::new(backend, BackendMode::Local)); let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); ready_tx.send(()).unwrap(); tokio::select! { _ = shutdown_rx => {} - result = serve_app(listener, app, ServerInfo::managed()) => result.unwrap(), + result = serve_app(listener, app, ServerInfo::local()) => result.unwrap(), } }); }); diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index d30f7dd..e5cac49 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -56,6 +56,14 @@ impl Application { } pub async fn handle(&self, request: AppRequest) -> Result { + let required = request.required_capability(); + if !self.mode.supports(required) { + return Err(Self::unsupported( + request.operation_name(), + required, + self.mode, + )); + } let may_write = request.may_write(); let result = self.handle_inner(request).await; if may_write @@ -88,7 +96,11 @@ impl Application { .map(AppResponse::NoteSummary) .map_err(WireError::from_service); } - Err(Self::unsupported("note_add")) + Err(Self::unsupported( + "note_add", + crate::ipc::Capability::NoteAdd, + self.mode, + )) } AppRequest::NoteAddEditable { document, project } => { let parsed = @@ -127,7 +139,11 @@ impl Application { .create(request) .await } else { - return Err(Self::unsupported("note_add_editable")); + return Err(Self::unsupported( + "note_add_editable", + crate::ipc::Capability::Editor, + self.mode, + )); } .map_err(WireError::from_service)?; notes @@ -165,7 +181,11 @@ impl Application { .add(&DirectNoteCreator::new(self.db.as_ref()), input) .await } else { - return Err(Self::unsupported("note_upload")); + return Err(Self::unsupported( + "note_upload", + crate::ipc::Capability::Attachment, + self.mode, + )); } .map(AppResponse::NoteSummary) .map_err(WireError::from_service) @@ -174,10 +194,13 @@ impl Application { note_type, metadata, } => { - let creator = self - .creator - .as_deref() - .ok_or_else(|| Self::unsupported("attachment"))?; + let creator = self.creator.as_deref().ok_or_else(|| { + Self::unsupported( + "attachment", + crate::ipc::Capability::Attachment, + self.mode, + ) + })?; let project_id = match project.as_deref() { Some(name) => Some( self.db @@ -338,10 +361,9 @@ impl Application { .map(AppResponse::NoteArchive) .map_err(WireError::from_service), AppRequest::NoteShare { id } => { - let gateway = self - .share_gateway - .as_deref() - .ok_or_else(|| Self::unsupported("note_share"))?; + let gateway = self.share_gateway.as_deref().ok_or_else(|| { + Self::unsupported("note_share", crate::ipc::Capability::Share, self.mode) + })?; notes .share(gateway, &id) .await @@ -349,10 +371,9 @@ impl Application { .map_err(WireError::from_service) } AppRequest::NoteUnshare { id } => { - let gateway = self - .share_gateway - .as_deref() - .ok_or_else(|| Self::unsupported("note_unshare"))?; + let gateway = self.share_gateway.as_deref().ok_or_else(|| { + Self::unsupported("note_unshare", crate::ipc::Capability::Share, self.mode) + })?; notes .unshare(gateway, &id) .await @@ -422,10 +443,9 @@ impl Application { .map(AppResponse::Project) .map_err(WireError::from_service), AppRequest::ProjectShare { id } => { - let gateway = self - .share_gateway - .as_deref() - .ok_or_else(|| Self::unsupported("project_share"))?; + let gateway = self.share_gateway.as_deref().ok_or_else(|| { + Self::unsupported("project_share", crate::ipc::Capability::Share, self.mode) + })?; projects .share(gateway, &id) .await @@ -433,10 +453,9 @@ impl Application { .map_err(WireError::from_service) } AppRequest::ProjectUnshare { id } => { - let gateway = self - .share_gateway - .as_deref() - .ok_or_else(|| Self::unsupported("project_unshare"))?; + let gateway = self.share_gateway.as_deref().ok_or_else(|| { + Self::unsupported("project_unshare", crate::ipc::Capability::Share, self.mode) + })?; projects .unshare(gateway, &id) .await @@ -532,13 +551,14 @@ impl Application { } } - fn unsupported(operation: &str) -> WireError { - WireError { - code: "unsupported_operation".to_string(), - message: format!("{operation} is not available in this daemon mode"), - retryable: false, - details: None, - } + fn unsupported( + operation: &str, + capability: crate::ipc::Capability, + mode: BackendMode, + ) -> WireError { + WireError::from_service(crate::ipc::unsupported_capability( + mode, capability, operation, + )) } fn db_error(error: flicknote_core::error::CliError) -> WireError { diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index fdab327..48ee777 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -37,13 +37,69 @@ pub enum Capability { Data, NoteAdd, Attachment, + Editor, + Browser, + Mcp, Share, LocalSync, } +const LOCAL_CAPABILITIES: &[Capability] = &[ + Capability::Data, + Capability::NoteAdd, + Capability::Attachment, + Capability::Editor, + Capability::Browser, + Capability::Mcp, + Capability::Share, + Capability::LocalSync, +]; +const MANAGED_CAPABILITIES: &[Capability] = &[Capability::Data, Capability::NoteAdd]; + +impl BackendMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Managed => "managed", + } + } + + pub const fn capabilities(self) -> &'static [Capability] { + match self { + Self::Local => LOCAL_CAPABILITIES, + Self::Managed => MANAGED_CAPABILITIES, + } + } + + pub fn supports(self, capability: Capability) -> bool { + self.capabilities().contains(&capability) + } +} + +pub fn unsupported_capability( + mode: BackendMode, + capability: Capability, + operation: &str, +) -> ServiceError { + ServiceError::Remote { + code: "unsupported_capability".to_string(), + message: format!( + "{operation} is not available in {} daemon mode", + mode.as_str() + ), + retryable: false, + details: Some(serde_json::json!({ + "operation": operation, + "backend": mode, + "required_capability": capability, + })), + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerInfo { pub protocol: u16, + pub version: String, pub backend: BackendMode, pub capabilities: Vec, } @@ -52,24 +108,27 @@ impl ServerInfo { pub fn local() -> Self { Self { protocol: PROTOCOL_VERSION, + version: env!("CARGO_PKG_VERSION").to_string(), backend: BackendMode::Local, - capabilities: vec![ - Capability::Data, - Capability::NoteAdd, - Capability::Attachment, - Capability::Share, - Capability::LocalSync, - ], + capabilities: BackendMode::Local.capabilities().to_vec(), } } pub fn managed() -> Self { Self { protocol: PROTOCOL_VERSION, + version: env!("CARGO_PKG_VERSION").to_string(), backend: BackendMode::Managed, - capabilities: vec![Capability::Data, Capability::NoteAdd], + capabilities: BackendMode::Managed.capabilities().to_vec(), } } + + pub fn require(&self, capability: Capability, operation: &str) -> Result<(), ServiceError> { + if self.capabilities.contains(&capability) { + return Ok(()); + } + Err(unsupported_capability(self.backend, capability, operation)) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -222,6 +281,38 @@ impl AppRequest { | Self::ExtractionValues { .. } ) } + + pub fn required_capability(&self) -> Capability { + match self { + Self::NoteAdd(_) => Capability::NoteAdd, + Self::NoteAddEditable { .. } + | Self::NoteLoadEditable { .. } + | Self::NoteSaveEditable { .. } => Capability::Editor, + Self::NoteUpload { .. } => Capability::Attachment, + Self::NoteOpen { .. } => Capability::Browser, + Self::NoteShare { .. } + | Self::NoteUnshare { .. } + | Self::ProjectShare { .. } + | Self::ProjectUnshare { .. } => Capability::Share, + _ => Capability::Data, + } + } + + pub fn operation_name(&self) -> &'static str { + match self { + Self::NoteAdd(_) => "note_add", + Self::NoteAddEditable { .. } => "note_add_editable", + Self::NoteUpload { .. } => "note_upload", + Self::NoteLoadEditable { .. } => "note_load_editable", + Self::NoteSaveEditable { .. } => "note_save_editable", + Self::NoteOpen { .. } => "note_open", + Self::NoteShare { .. } => "note_share", + Self::NoteUnshare { .. } => "note_unshare", + Self::ProjectShare { .. } => "project_share", + Self::ProjectUnshare { .. } => "project_unshare", + _ => "data", + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -398,6 +489,9 @@ pub enum DaemonError { InvalidResponse { message: String, }, + IncompleteResponse { + message: String, + }, Other { message: String, }, @@ -412,6 +506,7 @@ impl fmt::Display for DaemonError { Self::PartialCreate { message, .. } | Self::AmbiguousCreate { message, .. } | Self::InvalidResponse { message } + | Self::IncompleteResponse { message } | Self::Other { message } => f.write_str(message), } } @@ -435,7 +530,7 @@ fn request_timeout_error( path: &std::path::Path, stage: &str, ) -> DaemonError { - if matches!(request, DaemonRequest::Health { .. }) { + if !is_mutating_app_request(request) { return unavailable(path, stage); } DaemonError::Other { @@ -446,6 +541,18 @@ fn request_timeout_error( } } +fn is_mutating_app_request(request: &DaemonRequest) -> bool { + matches!(request, DaemonRequest::App { request, .. } if request.may_write()) +} + +fn response_timeout_for(request: &DaemonRequest) -> Option { + match request { + DaemonRequest::Health { .. } => Some(IPC_HEALTH_RESPONSE_TIMEOUT), + DaemonRequest::App { request, .. } if request.may_write() => None, + DaemonRequest::App { .. } => Some(IPC_APP_RESPONSE_TIMEOUT), + } +} + pub async fn send_request( config: &Config, request: &DaemonRequest, @@ -458,23 +565,42 @@ pub async fn send_request( path: path.display().to_string(), message: error.to_string(), })?; - tokio::time::timeout(IPC_WRITE_TIMEOUT, write_json(&mut stream, request)) - .await - .map_err(|_| request_timeout_error(request, &path, "sending a request"))??; - let mut buf = Vec::new(); - let response_timeout = if matches!(request, DaemonRequest::Health { .. }) { - IPC_HEALTH_RESPONSE_TIMEOUT + if is_mutating_app_request(request) { + write_json(&mut stream, request).await?; } else { - IPC_APP_RESPONSE_TIMEOUT - }; - tokio::time::timeout(response_timeout, stream.read_to_end(&mut buf)) - .await - .map_err(|_| request_timeout_error(request, &path, "waiting for a response"))? - .map_err(|e| DaemonError::Other { + tokio::time::timeout(IPC_WRITE_TIMEOUT, write_json(&mut stream, request)) + .await + .map_err(|_| request_timeout_error(request, &path, "sending a request"))??; + } + let mut buf = Vec::new(); + match response_timeout_for(request) { + Some(response_timeout) => { + tokio::time::timeout(response_timeout, stream.read_to_end(&mut buf)) + .await + .map_err(|_| request_timeout_error(request, &path, "waiting for a response"))? + } + None => stream.read_to_end(&mut buf).await, + } + .map_err(|e| { + if matches!(request, DaemonRequest::Health { .. }) { + return DaemonError::Unavailable { + path: path.display().to_string(), + message: format!("daemon closed the health connection: {e}"), + }; + } + DaemonError::Other { message: format!("Failed to read daemon response: {e}"), - })?; - serde_json::from_slice(&buf).map_err(|e| DaemonError::InvalidResponse { - message: format!("Failed to parse daemon response: {e}"), + } + })?; + serde_json::from_slice(&buf).map_err(|e| { + if e.is_eof() { + return DaemonError::IncompleteResponse { + message: format!("Daemon closed the connection before a complete response: {e}"), + }; + } + DaemonError::InvalidResponse { + message: format!("Failed to parse daemon response: {e}"), + } }) } @@ -495,6 +621,11 @@ impl<'a> DaemonClient<'a> { DaemonError::Unavailable { .. } => ServiceError::DaemonUnavailable(format!( "{error}. Start it with `flicknote sync start`." )), + DaemonError::IncompleteResponse { .. } if is_health => { + ServiceError::DaemonUnavailable(format!( + "Sync daemon is not ready: {error}. Start it with `flicknote sync start`." + )) + } DaemonError::InvalidResponse { .. } if is_health => Self::protocol_mismatch(), other => ServiceError::Daemon(other.to_string()), }) @@ -750,9 +881,21 @@ mod tests { fn server_info_reports_backend_mode_and_capabilities() { let info = ServerInfo::local(); assert_eq!(info.protocol, PROTOCOL_VERSION); + assert!(!info.version.is_empty()); assert_eq!(info.backend, BackendMode::Local); assert!(info.capabilities.contains(&Capability::NoteAdd)); assert!(info.capabilities.contains(&Capability::Share)); + assert!( + serde_json::to_value(&info).unwrap()["capabilities"] + .as_array() + .unwrap() + .contains(&json!("mcp")) + ); + + let error = ServerInfo::managed() + .require(Capability::Mcp, "mcp") + .unwrap_err(); + assert_eq!(error.code(), "unsupported_capability"); } #[test] @@ -808,25 +951,15 @@ mod tests { } #[test] - fn application_timeout_is_non_retryable_because_its_outcome_is_unknown() { + fn mutating_application_requests_do_not_have_an_automatic_response_timeout() { let request = DaemonRequest::App { protocol: PROTOCOL_VERSION, - request: Box::new(AppRequest::NoteCount(NoteCountInput { - keywords: Vec::new(), - project: None, - note_type: None, - archived: false, - })), + request: Box::new(AppRequest::NoteArchive { + id: "note-1".to_string(), + }), }; - let error = request_timeout_error( - &request, - std::path::Path::new("/tmp/flicknote.sock"), - "waiting for a response", - ); - - assert!(matches!(error, DaemonError::Other { .. })); - assert!(error.to_string().contains("outcome is unknown")); + assert_eq!(response_timeout_for(&request), None); } #[tokio::test] @@ -918,4 +1051,22 @@ mod tests { DaemonRequest::Health { .. } )); } + + #[tokio::test] + async fn health_maps_empty_startup_response_to_retryable_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + drop(stream); + }); + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_unavailable"); + assert!(error.retryable()); + server.await.unwrap(); + } } diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 680668c..f8a0ac7 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -1128,8 +1128,8 @@ async fn create_note_with_token( "updated_at": req.now, }); - let resp = match http - .post(format!( + let send_create = || { + http.post(format!( "{}/rest/v1/notes?on_conflict=id", config.supabase_url )) @@ -1141,42 +1141,30 @@ async fn create_note_with_token( ) .json(&payload) .send() - .await - { - Ok(resp) => resp, - Err(e) => { - match reconcile_ambiguous_remote_create(http, config, access_token, &req.id).await { - AmbiguousCreateReconciliation::Found(row) => { - return finish_remote_create( - db, - http, - config, - access_token, - *row, - &extraction_rows, - ) - .await; - } - AmbiguousCreateReconciliation::Absent => { - if attachment_path.is_some() - && let Err(cleanup_error) = - delete_attachment(http, config, access_token, &req.id).await + }; + let (resp, initial_transport_error) = match send_create().await { + Ok(resp) => (resp, None), + Err(initial_error) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match send_create().await { + Ok(resp) => (resp, Some(initial_error.to_string())), + Err(retry_error) => { + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await { - log::warn!( - "Failed to clean up uploaded attachment after confirming note absence: {cleanup_error}" - ); + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; } - return Err(DaemonError::Other { - message: format!( - "Remote note create failed and note {} was confirmed absent: {e}", - req.id - ), - }); - } - AmbiguousCreateReconciliation::Unknown(reconciliation_error) => { return Err(ambiguous_create_error( format!( - "Remote note create outcome is unknown for note {} after a transport failure ({e}); reconciliation could not confirm creation or absence: {reconciliation_error}. Do not create it again.", + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", req.id ), req.id, @@ -1194,6 +1182,16 @@ async fn create_note_with_token( return finish_remote_create(db, http, config, access_token, row, &extraction_rows) .await; } + if let Some(initial_error) = initial_transport_error { + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID: the first attempt failed in transport ({initial_error}) and the retry returned {status}: {body}. The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } if attachment_path.is_some() && let Err(e) = delete_attachment(http, config, access_token, &req.id).await { @@ -1234,42 +1232,6 @@ async fn create_note_with_token( finish_remote_create(db, http, config, access_token, row, &extraction_rows).await } -enum AmbiguousCreateReconciliation { - Found(Box), - Absent, - Unknown(String), -} - -async fn reconcile_ambiguous_remote_create( - http: &reqwest::Client, - config: &Config, - access_token: &str, - note_id: &str, -) -> AmbiguousCreateReconciliation { - const ATTEMPTS: usize = 3; - let mut confirmed_absent = 0; - let mut last_error = None; - - for attempt in 0..ATTEMPTS { - match lookup_remote_note(http, config, access_token, note_id).await { - Ok(Some(row)) => return AmbiguousCreateReconciliation::Found(Box::new(row)), - Ok(None) => confirmed_absent += 1, - Err(error) => last_error = Some(error.to_string()), - } - if attempt + 1 < ATTEMPTS { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - - if confirmed_absent == ATTEMPTS { - AmbiguousCreateReconciliation::Absent - } else { - AmbiguousCreateReconciliation::Unknown( - last_error.unwrap_or_else(|| "inconsistent reconciliation responses".to_string()), - ) - } -} - fn confirmed_create_error( message: String, note_id: String, @@ -2670,12 +2632,9 @@ mod tests { } #[tokio::test] - async fn ambiguous_transport_failure_retries_reconciliation_before_returning() { + async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() { let body = r#"[{"id":"note-recovered-after-retry","short_id":83,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_disconnected_then_retry_responses(vec![ - ("503 Service Unavailable", r#"{"message":"try later"}"#), - ("200 OK", body), - ]); + let (origin, server) = spawn_disconnected_then_retry_responses(vec![("201 Created", body)]); let mut config = test_config(String::new()); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); @@ -2705,7 +2664,9 @@ mod tests { let created = result.unwrap(); assert_eq!(created.short_id, 83); - assert_eq!(requests.len(), 3); + assert_eq!(requests.len(), 2); + assert!(requests[0].starts_with("POST /rest/v1/notes")); + assert!(requests[1].starts_with("POST /rest/v1/notes")); } #[tokio::test] diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 8c692ae..cee1dc3 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -281,6 +281,43 @@ async fn managed_app_adds_note_and_topics_through_the_backend() { ); } +#[tokio::test] +async fn managed_app_rejects_local_only_workflows() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let upload = directory.path().join("note.md"); + std::fs::write(&upload, "# imported").unwrap(); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Application::new(backend, BackendMode::Managed); + + for request in [ + AppRequest::NoteAddEditable { + document: "# editor-created".to_string(), + project: None, + }, + AppRequest::NoteUpload { + path: upload.to_string_lossy().into_owned(), + project: None, + created_at: None, + }, + AppRequest::NoteLoadEditable { + id: "missing".to_string(), + }, + AppRequest::NoteOpen { + id: "missing".to_string(), + }, + AppRequest::NoteShare { + id: "missing".to_string(), + }, + ] { + let error = app.handle(request).await.unwrap_err(); + assert_eq!(error.code, "unsupported_capability"); + } +} + #[tokio::test] async fn local_app_owns_attachment_normalization_and_creator_call() { let directory = tempfile::tempdir().unwrap(); @@ -321,7 +358,11 @@ async fn app_owns_editable_document_parsing_and_persistence() { db: Database::open_local(&config).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Application::new(backend.clone(), BackendMode::Managed); + let creator = Arc::new(RecordingCreator { + db: backend.clone(), + request: std::sync::Mutex::new(None), + }); + let app = Application::new(backend.clone(), BackendMode::Local).with_creator(creator); let created = app .handle(AppRequest::NoteAddEditable { diff --git a/skills/flicknote.md b/skills/flicknote.md index 7ad9ab5..ce1a7a3 100644 --- a/skills/flicknote.md +++ b/skills/flicknote.md @@ -69,14 +69,16 @@ Mutating section commands print the updated tree after the change. ## MCP Server -`flicknote mcp` serves typed note, source, and project tools over local stdio. +`flicknote mcp` serves typed note, source, and project tools over local stdio +and requires a daemon running in local PowerSync mode. Managed daemons reject +MCP startup with `unsupported_capability` before protocol output. Configure an MCP client to run `flicknote` with `args: ["mcp"]`. Content and exact `before`/`after` edits are JSON fields, so MCP callers do not use shell heredocs or edit-mode delimiters. Note tools use numeric short IDs and hide internal UUIDs; project tools use project names. Use `note_get` for editable -content and `note_source` only for stored source data. The server supports only -the daemon-selected workspace. Every data tool requires the running sync daemon; -the CLI and MCP server never open the local database directly. +content and `note_source` only for stored source data. Every data tool requires +the running sync daemon; the CLI and MCP server never open the local database +directly. `flicknote mcp` does not expose Gateway tools. Use the CLI command below for authenticated Gateway access. From d3a24f8a01df84cabbb7965e1b6c3f6449f4f5ea Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 18:14:14 +0800 Subject: [PATCH 07/16] fix(sync): harden create and daemon lifecycle --- flicknote-cli/src/commands/daemon.rs | 50 +++- flicknote-cli/src/commands/login.rs | 35 ++- flicknote-cli/src/commands/sync.rs | 70 +++++- flicknote-core/src/backend.rs | 54 +++++ flicknote-core/src/pgwire/mod.rs | 50 ++++ flicknote-core/src/services/note.rs | 74 +++++- flicknote-core/src/services/ports.rs | 54 ++++- flicknote-sync/src/app.rs | 12 +- flicknote-sync/src/ipc.rs | 4 + flicknote-sync/src/lib.rs | 335 ++++++++++++++++++++++----- flicknote-sync/tests/app_contract.rs | 44 ++++ 11 files changed, 687 insertions(+), 95 deletions(-) diff --git a/flicknote-cli/src/commands/daemon.rs b/flicknote-cli/src/commands/daemon.rs index 72b87fe..0e31d48 100644 --- a/flicknote-cli/src/commands/daemon.rs +++ b/flicknote-cli/src/commands/daemon.rs @@ -40,6 +40,13 @@ pub(crate) fn daemon_binary() -> Result { /// Stop the sync daemon if running. Returns Ok(()) even if not running. pub(crate) fn stop(config: &Config) -> Result<(), CliError> { + #[cfg(target_os = "macos")] + { + #[allow(unsafe_code)] + let uid = unsafe { libc::getuid() }; + bootout_service(uid, service_label())?; + } + let Some(pid) = read_pid(config) else { return Ok(()); }; @@ -69,7 +76,7 @@ pub(crate) fn uninstall() -> Result<(), CliError> { #[allow(unsafe_code)] let uid = unsafe { libc::getuid() }; - bootout_service(uid, label); + bootout_service(uid, label)?; if plist_path.exists() { fs::remove_file(&plist_path)?; @@ -131,7 +138,7 @@ pub(crate) fn install(config: &Config) -> Result<(), CliError> { #[allow(unsafe_code)] let uid = unsafe { libc::getuid() }; - bootout_service(uid, label); + bootout_service(uid, label)?; for args in launchd_install_commands(uid, label, &plist_path) { let command_name = args @@ -202,23 +209,33 @@ fn launchd_install_commands( ] } -/// Run `launchctl bootout`, warning on unexpected errors (not-loaded is expected and silent). +#[cfg(any(target_os = "macos", test))] +fn launchd_stop_command(uid: u32, label: &str) -> Vec { + vec!["bootout".to_string(), format!("gui/{uid}/{label}")] +} + +/// Run `launchctl bootout`; an already-unloaded service is an idempotent success. #[cfg(target_os = "macos")] -fn bootout_service(uid: u32, label: &str) { +fn bootout_service(uid: u32, label: &str) -> Result<(), CliError> { + let args = launchd_stop_command(uid, label); let result = Command::new("launchctl") - .args(["bootout", &format!("gui/{uid}/{label}")]) - .output(); - if let Ok(out) = result - && !out.status.success() - { + .args(&args) + .output() + .map_err(|error| CliError::Other(format!("launchctl bootout failed: {error}")))?; + if !result.status.success() { + let out = result; let stderr = String::from_utf8_lossy(&out.stderr); let is_expected = stderr.contains("No such process") || stderr.contains("not loaded") || stderr.contains("Could not find"); - if !is_expected && !stderr.trim().is_empty() { - eprintln!("Warning: launchctl bootout: {}", stderr.trim()); + if !is_expected { + return Err(CliError::Other(format!( + "launchctl bootout failed: {}", + stderr.trim() + ))); } } + Ok(()) } #[cfg(test)] @@ -246,4 +263,15 @@ mod tests { ] ); } + + #[test] + fn launchd_stop_boots_out_the_keepalive_service() { + assert_eq!( + launchd_stop_command(501, "io.guion.flicknote.sync"), + vec![ + "bootout".to_string(), + "gui/501/io.guion.flicknote.sync".to_string(), + ] + ); + } } diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index c52204b..6705341 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -78,17 +78,30 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro println!("Authenticated"); - // Install launchd service — this boots out any existing service first, - // then bootstraps fresh. The daemon starts immediately (KeepAlive + RunAtLoad) - // and creates the local DB on startup. - super::daemon::install(config)?; - super::sync::wait_for_daemon_ready( - config, - std::time::Duration::from_secs(10), - std::time::Duration::from_millis(100), - ) - .await?; - println!("Sync daemon installed and started"); + if manages_daemon_after_login() { + // The macOS login flow owns the per-user LaunchAgent lifecycle. + super::daemon::install(config)?; + super::sync::wait_for_daemon_ready( + config, + std::time::Duration::from_secs(10), + std::time::Duration::from_millis(100), + ) + .await?; + println!("Sync daemon installed and started"); + } Ok(()) } + +const fn manages_daemon_after_login() -> bool { + cfg!(target_os = "macos") +} + +#[cfg(test)] +mod tests { + #[cfg(not(target_os = "macos"))] + #[test] + fn non_macos_login_does_not_wait_for_a_launchd_daemon() { + assert!(!super::manages_daemon_after_login()); + } +} diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 9e1a6af..f1c8257 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -30,7 +30,7 @@ enum SyncCommand { pub(crate) async fn run(config: &Config, args: &SyncArgs) -> Result<(), CliError> { match &args.command { SyncCommand::Start => start(config).await, - SyncCommand::Stop => stop(config), + SyncCommand::Stop => stop(config).await, SyncCommand::Status => status(config).await, SyncCommand::Install => install(config).await, SyncCommand::Uninstall => uninstall(), @@ -115,16 +115,47 @@ pub(super) async fn wait_for_daemon_ready( })? } -fn stop(config: &Config) -> Result<(), CliError> { - if super::daemon::read_pid(config).is_none() { +async fn stop(config: &Config) -> Result<(), CliError> { + let was_running = super::daemon::read_pid(config).is_some() + || flicknote_sync::ipc::socket_path(config).exists(); + super::daemon::stop(config)?; + if !was_running { println!("FlickNote daemon not running"); return Ok(()); } - super::daemon::stop(config)?; + wait_for_daemon_stopped(config, DAEMON_START_TIMEOUT, HEALTH_POLL_INTERVAL).await?; + let socket = flicknote_sync::ipc::socket_path(config); + if socket.exists() { + fs::remove_file(socket)?; + } println!("FlickNote daemon stopped"); Ok(()) } +async fn wait_for_daemon_stopped( + config: &Config, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), CliError> { + let wait = async { + loop { + let health = flicknote_sync::ipc::DaemonClient::new(config) + .health() + .await; + if matches!(health, Err(ref error) if error.code() == "daemon_unavailable") { + return; + } + tokio::time::sleep(interval).await; + } + }; + tokio::time::timeout(timeout, wait).await.map_err(|_| { + CliError::Other(format!( + "Sync daemon did not stop within {timeout:?}; check {}", + config.paths.log_file.display() + )) + }) +} + async fn status(config: &Config) -> Result<(), CliError> { match super::daemon::read_pid(config) { Some(pid) => { @@ -250,6 +281,37 @@ mod tests { assert!(error.to_string().contains("did not become ready")); } + #[tokio::test] + async fn stop_waits_until_daemon_health_is_unavailable() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + let dir = tempfile::tempdir().expect("temp dir"); + let config = test_config(dir.path()); + let listener = tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)) + .expect("bind socket"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + let mut request = String::new(); + reader.read_line(&mut request).await.unwrap(); + let response = serde_json::to_vec(&flicknote_sync::ipc::DaemonResponse::ServerInfo( + flicknote_sync::ipc::ServerInfo::local(), + )) + .unwrap(); + writer.write_all(&response).await.unwrap(); + }); + + wait_for_daemon_stopped( + &config, + std::time::Duration::from_millis(500), + std::time::Duration::from_millis(10), + ) + .await + .unwrap(); + server.await.unwrap(); + } + #[test] fn status_line_reports_runtime_version_and_backend() { let line = format_running_status(42, &flicknote_sync::ipc::ServerInfo::managed()); diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index 827c933..d9dbce2 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -97,6 +97,17 @@ pub trait NoteDb: Send + Sync { // Note writes async fn insert_note(&self, req: &InsertNoteReq<'_>) -> Result; + async fn insert_note_with_extractions( + &self, + req: &InsertNoteReq<'_>, + extraction_key: &str, + values: &[String], + ) -> Result { + let inserted = self.insert_note(req).await?; + self.set_note_extractions(&inserted.uuid, extraction_key, values) + .await?; + Ok(inserted) + } /// Update content. When `requeue` is true, also sets status = 'ai_queued'. async fn update_note_content( &self, @@ -654,6 +665,49 @@ impl NoteDb for SqliteBackend { }) } + async fn insert_note_with_extractions( + &self, + req: &InsertNoteReq<'_>, + extraction_key: &str, + values: &[String], + ) -> Result { + let mut transaction = self.db.pool.begin().await?; + sqlx::query(SQ_INSERT) + .bind(req.id) + .bind(&self.user_id) + .bind(req.note_type) + .bind(req.status) + .bind(req.title) + .bind(req.content) + .bind(req.metadata) + .bind(req.project_id) + .bind(req.now) + .bind(req.now) + .execute(&mut *transaction) + .await?; + sqlx::query(SQ_CLEAR_EXTRACTIONS) + .bind(&self.user_id) + .bind(req.id) + .bind(extraction_key) + .execute(&mut *transaction) + .await?; + for value in values { + sqlx::query(SQ_INSERT_EXTRACTION) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(req.id) + .bind(&self.user_id) + .bind(extraction_key) + .bind(value) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(InsertedNote { + uuid: req.id.to_string(), + short_id: None, + }) + } + async fn update_note_content( &self, id: &str, diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs index 9f13271..8cbd31d 100644 --- a/flicknote-core/src/pgwire/mod.rs +++ b/flicknote-core/src/pgwire/mod.rs @@ -459,6 +459,56 @@ impl NoteDb for PgWireBackend { }) } + async fn insert_note_with_extractions( + &self, + req: &InsertNoteReq<'_>, + extraction_key: &str, + values: &[String], + ) -> Result { + let metadata: Option = req + .metadata + .map(serde_json::from_str) + .transpose() + .map_err(|e| CliError::Database(format!("invalid metadata JSON: {e}")))?; + let now = parse_iso_utc(req.now)?; + let note_id = parse_uuid(req.id)?; + let mut transaction = self.pool.begin().await?; + let row = sqlx::query( + "INSERT INTO notes \ + (id, type, status, title, content, metadata, project_id, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ + RETURNING id::text, short_id", + ) + .bind(note_id) + .bind(req.note_type) + .bind(req.status) + .bind(req.title) + .bind(req.content) + .bind(metadata) + .bind(parse_uuid_opt(req.project_id)?) + .bind(now) + .bind(now) + .fetch_one(&mut *transaction) + .await?; + for value in values { + sqlx::query( + "INSERT INTO note_extractions (id, note_id, user_id, key, value) \ + VALUES ($1, $2, (SELECT user_id FROM notes WHERE id = $2), $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(note_id) + .bind(extraction_key) + .bind(value) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(InsertedNote { + uuid: row.try_get::(0)?, + short_id: row.try_get::, _>(1)?.map(i64::from), + }) + } + async fn update_note_content( &self, id: &str, diff --git a/flicknote-core/src/services/note.rs b/flicknote-core/src/services/note.rs index 22f58fa..49413d5 100644 --- a/flicknote-core/src/services/note.rs +++ b/flicknote-core/src/services/note.rs @@ -162,8 +162,12 @@ impl<'a> NoteService<'a> { } }; let inserted = creator.create(request).await?; - let note = self.db.find_note(&inserted.uuid).await?; - self.summary(note).await + let summary = async { + let note = self.db.find_note(&inserted.uuid).await?; + self.summary(note).await + } + .await; + summary.map_err(|error| confirmed_create_followup_error(&inserted, &error)) } pub async fn get(&self, note_id: &str, archived: bool) -> Result { @@ -588,6 +592,29 @@ impl<'a> NoteService<'a> { } } +pub fn confirmed_create_followup_error( + inserted: &crate::backend::InsertedNote, + error: &ServiceError, +) -> ServiceError { + ServiceError::Remote { + code: "note_create_partial".to_string(), + message: format!( + "Note {} was created, but its canonical result could not be loaded: {error}. Do not create it again.", + inserted + .short_id + .map_or_else(|| inserted.uuid.clone(), |id| id.to_string()) + ), + retryable: false, + details: Some(serde_json::json!({ + "created": true, + "note_id": inserted.uuid, + "short_id": inserted.short_id, + "confirmed_extraction_ids": [], + "pending_extraction_ids": [], + })), + } +} + #[cfg(all(test, feature = "powersync"))] mod tests { @@ -858,6 +885,49 @@ mod tests { } } + struct DetachedCreator; + + #[async_trait] + impl NoteCreator for DetachedCreator { + async fn create( + &self, + request: CreateNote, + ) -> Result { + Ok(crate::backend::InsertedNote { + uuid: request.id, + short_id: Some(42), + }) + } + } + + #[tokio::test] + async fn add_reports_structured_partial_when_summary_read_fails_after_create() { + let backend = make_backend().await; + let error = NoteService::new(&backend) + .add( + &DetachedCreator, + NoteAddInput { + content: "Body".to_string(), + project: None, + interpret_as_url: false, + topics: Vec::new(), + created_at: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), "note_create_partial"); + assert!(!error.retryable()); + let crate::services::error::ServiceError::Remote { details, .. } = error else { + panic!("expected structured post-create error") + }; + let details = details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["short_id"], 42); + assert!(details["note_id"].as_str().is_some()); + } + #[tokio::test] async fn add_normalizes_h1_before_calling_creator() { let backend = make_backend().await; diff --git a/flicknote-core/src/services/ports.rs b/flicknote-core/src/services/ports.rs index d97a98e..91dd388 100644 --- a/flicknote-core/src/services/ports.rs +++ b/flicknote-core/src/services/ports.rs @@ -53,13 +53,14 @@ impl<'a> DirectNoteCreator<'a> { #[async_trait] impl NoteCreator for DirectNoteCreator<'_> { async fn create(&self, request: CreateNote) -> Result { - let inserted = self.db.insert_note(&request.as_insert_request()).await?; - if !request.topics.is_empty() { - self.db - .set_note_extractions(&inserted.uuid, crate::TOPIC_EXTRACTION_KEY, &request.topics) - .await?; - } - Ok(inserted) + self.db + .insert_note_with_extractions( + &request.as_insert_request(), + crate::TOPIC_EXTRACTION_KEY, + &request.topics, + ) + .await + .map_err(ServiceError::from) } } @@ -78,3 +79,42 @@ pub trait ShareGateway: Send + Sync { pub trait BrowserOpener { fn open(&self, url: &str) -> Result<(), ServiceError>; } + +#[cfg(all(test, feature = "powersync"))] +mod tests { + use super::*; + use crate::backend::NoteDb; + use crate::services::test_support::make_backend; + + #[tokio::test] + async fn direct_create_rolls_back_note_when_topic_persistence_fails() { + let backend = make_backend().await; + sqlx::query( + "CREATE TRIGGER reject_bad_topic INSTEAD OF INSERT ON note_extractions \ + WHEN NEW.value = 'fail' BEGIN SELECT RAISE(ABORT, 'topic failure'); END", + ) + .execute(&backend.db.pool) + .await + .unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + + let error = DirectNoteCreator::new(&backend) + .create(CreateNote { + id: id.clone(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: chrono::Utc::now().to_rfc3339(), + topics: vec!["fail".to_string()], + attachment_path: None, + }) + .await + .unwrap_err(); + + assert!(error.to_string().contains("topic failure")); + assert!(backend.find_note(&id).await.is_err()); + } +} diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index e5cac49..7a92f90 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use flicknote_core::backend::NoteDb; use flicknote_core::services::dto::NoteAddInput; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::NoteService; +use flicknote_core::services::note::{NoteService, confirmed_create_followup_error}; use flicknote_core::services::ports::{CreateNote, DirectNoteCreator, NoteCreator, ShareGateway}; use flicknote_core::services::project::ProjectService; use flicknote_core::services::upload::{self, UploadKind}; @@ -150,7 +150,9 @@ impl Application { .get(&inserted.uuid, false) .await .map(|detail| AppResponse::NoteSummary(detail.note)) - .map_err(WireError::from_service) + .map_err(|error| { + WireError::from_service(confirmed_create_followup_error(&inserted, &error)) + }) } AppRequest::NoteUpload { path, @@ -234,7 +236,11 @@ impl Application { .get(&inserted.uuid, false) .await .map(|detail| AppResponse::NoteSummary(detail.note)) - .map_err(WireError::from_service) + .map_err(|error| { + WireError::from_service(confirmed_create_followup_error( + &inserted, &error, + )) + }) } } } diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 48ee777..81a69b4 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -479,6 +479,7 @@ pub enum DaemonError { message: String, note_id: String, short_id: Option, + confirmed_extraction_ids: Vec, pending_extraction_ids: Vec, }, AmbiguousCreate { @@ -548,6 +549,9 @@ fn is_mutating_app_request(request: &DaemonRequest) -> bool { fn response_timeout_for(request: &DaemonRequest) -> Option { match request { DaemonRequest::Health { .. } => Some(IPC_HEALTH_RESPONSE_TIMEOUT), + // Once a write request may have reached the daemon, a transport timeout cannot tell + // whether it committed. Keep waiting for the authoritative response until the protocol + // has durable operation IDs and status reconciliation (tracked as FlickNote #1785). DaemonRequest::App { request, .. } if request.may_write() => None, DaemonRequest::App { .. } => Some(IPC_APP_RESPONSE_TIMEOUT), } diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index f8a0ac7..5867643 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -59,6 +59,14 @@ struct CreatedNote { short_id: i64, } +#[derive(Debug, Default, PartialEq, Eq)] +struct ExtractionCreateOutcome { + confirmed_ids: Vec, + pending_ids: Vec, + diagnostic: Option, + local_commit_error: Option, +} + /// Helper to convert arbitrary errors into PowerSyncError. fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { std::io::Error::other(msg.to_string()).into() @@ -1142,8 +1150,67 @@ async fn create_note_with_token( .json(&payload) .send() }; - let (resp, initial_transport_error) = match send_create().await { - Ok(resp) => (resp, None), + let (resp, initial_ambiguous_error) = match send_create().await { + Ok(resp) if !is_ambiguous_create_status(resp.status()) => (resp, None), + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let initial_error = format!("the first attempt returned {status}: {body}"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match send_create().await { + Ok(resp) if !is_ambiguous_create_status(resp.status()) => { + (resp, Some(initial_error)) + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await + { + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; + } + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry returned {status}: {body}). The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + Err(retry_error) => { + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await + { + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; + } + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + } + } Err(initial_error) => { tokio::time::sleep(std::time::Duration::from_millis(100)).await; match send_create().await { @@ -1182,10 +1249,10 @@ async fn create_note_with_token( return finish_remote_create(db, http, config, access_token, row, &extraction_rows) .await; } - if let Some(initial_error) = initial_transport_error { + if let Some(initial_error) = initial_ambiguous_error { return Err(ambiguous_create_error( format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID: the first attempt failed in transport ({initial_error}) and the retry returned {status}: {body}. The attachment was retained. Do not create it again.", + "Remote note create outcome is unknown for note {} after retrying the same stable UUID: {initial_error}; the retry returned {status}: {body}. The attachment was retained. Do not create it again.", req.id ), req.id, @@ -1232,6 +1299,12 @@ async fn create_note_with_token( finish_remote_create(db, http, config, access_token, row, &extraction_rows).await } +fn is_ambiguous_create_status(status: reqwest::StatusCode) -> bool { + status.is_server_error() + || status == reqwest::StatusCode::REQUEST_TIMEOUT + || status == reqwest::StatusCode::TOO_MANY_REQUESTS +} + fn confirmed_create_error( message: String, note_id: String, @@ -1242,10 +1315,27 @@ fn confirmed_create_error( message, note_id, short_id, + confirmed_extraction_ids: Vec::new(), pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), } } +fn partial_create_error( + message: String, + note_id: String, + short_id: Option, + confirmed_extraction_ids: Vec, + pending_extraction_ids: Vec, +) -> DaemonError { + DaemonError::PartialCreate { + message, + note_id, + short_id, + confirmed_extraction_ids, + pending_extraction_ids, + } +} + fn ambiguous_create_error( message: String, note_id: String, @@ -1319,16 +1409,23 @@ async fn finish_remote_create( extraction_rows, )); } - if let Err(error) = - create_extractions_with_token(db, http, config, access_token, extraction_rows).await + let extraction_outcome = + create_extractions_with_token(db, http, config, access_token, extraction_rows).await; + if !extraction_outcome.pending_ids.is_empty() || extraction_outcome.local_commit_error.is_some() { - return Err(confirmed_create_error( + let reason = extraction_outcome + .local_commit_error + .as_deref() + .or(extraction_outcome.diagnostic.as_deref()) + .unwrap_or("one or more extraction rows could not be confirmed"); + return Err(partial_create_error( format!( - "Note {short_id} was created, but its topics were not fully confirmed: {error}" + "Note {short_id} was created, but its topics were not fully committed: {reason}" ), row.id, Some(short_id), - extraction_rows, + extraction_outcome.confirmed_ids, + extraction_outcome.pending_ids, )); } Ok(CreatedNote { @@ -1377,12 +1474,12 @@ async fn create_extractions_with_token( config: &Config, access_token: &str, requested: &[RemoteExtractionRow], -) -> Result, DaemonError> { +) -> ExtractionCreateOutcome { if requested.is_empty() { - return Ok(Vec::new()); + return ExtractionCreateOutcome::default(); } - let resp = http + let response = http .post(format!( "{}/rest/v1/note_extractions?on_conflict=id", config.supabase_url @@ -1395,27 +1492,36 @@ async fn create_extractions_with_token( ) .json(requested) .send() - .await - .map_err(|e| DaemonError::Other { - message: format!("Remote note extraction create failed: {e}"), - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(DaemonError::Other { - message: format!( - "Created note remotely, but failed to create note extractions ({status}): {body}\nDo not create it again; retry with the same note UUID." - ), - }); - } - - let mut rows = resp - .json::>() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to parse remote extraction create response: {error}"), - })?; + .await; + let (mut rows, mut diagnostics) = match response { + Ok(response) if response.status().is_success() => { + match response.json::>().await { + Ok(rows) => (rows, Vec::new()), + Err(error) => ( + Vec::new(), + vec![format!( + "failed to parse remote extraction create response: {error}" + )], + ), + } + } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + ( + Vec::new(), + vec![format!( + "remote extraction create returned {status}: {body}" + )], + ) + } + Err(error) => ( + Vec::new(), + vec![format!( + "remote extraction create failed in transport: {error}" + )], + ), + }; let mut confirmed_ids = rows .iter() .map(|row| row.id.clone()) @@ -1424,24 +1530,39 @@ async fn create_extractions_with_token( if confirmed_ids.contains(&expected.id) { continue; } - if let Some(row) = - lookup_remote_extraction(http, config, access_token, &expected.id).await? - { - rows.push(row); - confirmed_ids.insert(expected.id.clone()); + match lookup_remote_extraction(http, config, access_token, &expected.id).await { + Ok(Some(row)) => { + rows.push(row); + confirmed_ids.insert(expected.id.clone()); + } + Ok(None) => {} + Err(error) => diagnostics.push(error.to_string()), } } - if rows.len() != requested.len() { - return Err(DaemonError::Other { - message: format!( - "Created note remotely, but only {} of {} extraction rows were confirmed", - rows.len(), - requested.len() - ), - }); + let confirmed_ids = requested + .iter() + .filter(|row| confirmed_ids.contains(&row.id)) + .map(|row| row.id.clone()) + .collect::>(); + let pending_ids = requested + .iter() + .filter(|row| !confirmed_ids.contains(&row.id)) + .map(|row| row.id.clone()) + .collect::>(); + let local_commit_error = if rows.is_empty() { + None + } else { + commit_remote_extractions(db, &rows) + .await + .err() + .map(|error| error.to_string()) + }; + ExtractionCreateOutcome { + confirmed_ids, + pending_ids, + diagnostic: (!diagnostics.is_empty()).then(|| diagnostics.join("; ")), + local_commit_error, } - commit_remote_extractions(db, &rows).await?; - Ok(rows) } async fn lookup_remote_extraction( @@ -1493,6 +1614,7 @@ fn remote_create_service_error( message, note_id, short_id, + confirmed_extraction_ids, pending_extraction_ids, } => flicknote_core::services::error::ServiceError::Remote { code: "note_create_partial".to_string(), @@ -1502,6 +1624,7 @@ fn remote_create_service_error( "created": true, "note_id": note_id, "short_id": short_id, + "confirmed_extraction_ids": confirmed_extraction_ids, "pending_extraction_ids": pending_extraction_ids, })), }, @@ -2250,6 +2373,7 @@ mod tests { message: "note created; topics pending".to_string(), note_id: "note-partial".to_string(), short_id: Some(80), + confirmed_extraction_ids: vec!["extraction-confirmed".to_string()], pending_extraction_ids: vec!["extraction-1".to_string()], }); @@ -2258,7 +2382,12 @@ mod tests { let flicknote_core::services::error::ServiceError::Remote { details, .. } = error else { panic!("expected remote service error") }; - assert_eq!(details.unwrap()["short_id"], 80); + let details = details.unwrap(); + assert_eq!(details["short_id"], 80); + assert_eq!( + details["confirmed_extraction_ids"], + serde_json::json!(["extraction-confirmed"]) + ); } #[tokio::test] @@ -2669,6 +2798,50 @@ mod tests { assert!(requests[1].starts_with("POST /rest/v1/notes")); } + #[tokio::test] + async fn retryable_status_retries_create_with_the_same_stable_uuid() { + let body = r#"[{"id":"note-retryable-status","short_id":84,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![ + ("503 Service Unavailable", r#"{"message":"try later"}"#), + ("201 Created", body), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-retryable-status".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap(); + let requests = server.join().unwrap(); + + assert_eq!(created.short_id, 84); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.starts_with("POST /rest/v1/notes")) + ); + } + #[tokio::test] async fn remote_extraction_create_commits_confirmed_rows_locally() { let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; @@ -2685,17 +2858,17 @@ mod tests { value: "rust".to_string(), }]; - let confirmed = create_extractions_with_token( + let outcome = create_extractions_with_token( &db, &reqwest::Client::new(), &config, "access-token", &requested, ) - .await - .unwrap(); + .await; - assert_eq!(confirmed.len(), 1); + assert_eq!(outcome.confirmed_ids, ["extraction-create"]); + assert!(outcome.pending_ids.is_empty()); let reader = db.reader().await.unwrap(); let count: i64 = reader .query_row( @@ -2727,17 +2900,17 @@ mod tests { value: "rust".to_string(), }]; - let confirmed = create_extractions_with_token( + let outcome = create_extractions_with_token( &db, &reqwest::Client::new(), &config, "access-token", &requested, ) - .await - .unwrap(); + .await; - assert_eq!(confirmed.len(), 1); + assert_eq!(outcome.confirmed_ids, ["extraction-retry"]); + assert!(outcome.pending_ids.is_empty()); assert_eq!( server.join().unwrap(), [ @@ -2747,6 +2920,54 @@ mod tests { ); } + #[tokio::test] + async fn remote_extraction_create_commits_confirmed_subset_and_reports_exact_pending_ids() { + let body = r#"[{"id":"extraction-confirmed","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body), ("200 OK", "[]")]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![ + RemoteExtractionRow { + id: "extraction-confirmed".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }, + RemoteExtractionRow { + id: "extraction-pending".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "sqlite".to_string(), + }, + ]; + + let outcome = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await; + + assert_eq!(outcome.confirmed_ids, ["extraction-confirmed"]); + assert_eq!(outcome.pending_ids, ["extraction-pending"]); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM note_extractions WHERE id = ?", + params!["extraction-confirmed"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(server.join().unwrap().len(), 2); + } + #[tokio::test] async fn returns_existing_note_share_without_replacing_it() { let (api_origin, server) = spawn_server(vec![( diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index cee1dc3..54ff897 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -84,6 +84,50 @@ impl NoteCreator for RecordingCreator { } } +struct DetachedCreator; + +#[async_trait] +impl NoteCreator for DetachedCreator { + async fn create(&self, request: CreateNote) -> Result { + Ok(InsertedNote { + uuid: request.id, + short_id: Some(91), + }) + } +} + +#[tokio::test] +async fn app_preserves_created_identity_when_editor_or_attachment_summary_fails() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let attachment = directory.path().join("report.pdf"); + std::fs::write(&attachment, b"pdf").unwrap(); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Application::new(backend, BackendMode::Local).with_creator(Arc::new(DetachedCreator)); + + for request in [ + AppRequest::NoteAddEditable { + document: "# editor-created".to_string(), + project: None, + }, + AppRequest::NoteUpload { + path: attachment.to_string_lossy().into_owned(), + project: None, + created_at: None, + }, + ] { + let error = app.handle(request).await.unwrap_err(); + assert_eq!(error.code, "note_create_partial"); + let details = error.details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["short_id"], 91); + assert!(details["note_id"].as_str().is_some()); + } +} + #[tokio::test] async fn app_routes_note_list_and_append_through_services() { const NOTE_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; From 0007f37d27e522dc427f7c8135432a442f8958e1 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 18:48:58 +0800 Subject: [PATCH 08/16] fix(sync): preserve daemon create invariants --- flicknote-cli/src/commands/daemon.rs | 5 - flicknote-cli/src/commands/login.rs | 8 +- flicknote-cli/src/commands/sync.rs | 118 ++++++++++++++-- flicknote-core/src/backend.rs | 29 +++- flicknote-core/src/pgwire/mod.rs | 21 ++- flicknote-core/src/services/note.rs | 35 +++-- flicknote-core/src/services/ports.rs | 53 +++++++- flicknote-sync/src/app.rs | 12 +- flicknote-sync/src/lib.rs | 196 ++++++++++++++++++++++++--- flicknote-sync/tests/app_contract.rs | 20 ++- 10 files changed, 418 insertions(+), 79 deletions(-) diff --git a/flicknote-cli/src/commands/daemon.rs b/flicknote-cli/src/commands/daemon.rs index 0e31d48..a0b4230 100644 --- a/flicknote-cli/src/commands/daemon.rs +++ b/flicknote-cli/src/commands/daemon.rs @@ -162,11 +162,6 @@ pub(crate) fn install(config: &Config) -> Result<(), CliError> { Ok(()) } -#[cfg(not(target_os = "macos"))] -pub(crate) fn install(_config: &Config) -> Result<(), CliError> { - Ok(()) -} - #[cfg(target_os = "macos")] fn service_label() -> &'static str { "io.guion.flicknote.sync" diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index 6705341..b94ea0e 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -80,13 +80,7 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro if manages_daemon_after_login() { // The macOS login flow owns the per-user LaunchAgent lifecycle. - super::daemon::install(config)?; - super::sync::wait_for_daemon_ready( - config, - std::time::Duration::from_secs(10), - std::time::Duration::from_millis(100), - ) - .await?; + super::sync::install_local_daemon(config, std::time::Duration::from_secs(10)).await?; println!("Sync daemon installed and started"); } diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index f1c8257..64eccc3 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -21,9 +21,9 @@ enum SyncCommand { Stop, /// Check daemon status Status, - /// Install the local PowerSync daemon + /// Install the local PowerSync daemon as a launchd service (macOS only) Install, - /// Uninstall the local PowerSync daemon + /// Uninstall the local PowerSync launchd service (macOS only) Uninstall, } @@ -94,6 +94,25 @@ pub(super) async fn wait_for_daemon_ready( config: &Config, timeout: std::time::Duration, interval: std::time::Duration, +) -> Result<(), CliError> { + wait_for_daemon_ready_matching(config, timeout, interval, None).await +} + +#[cfg(any(target_os = "macos", test))] +async fn wait_for_daemon_ready_for_mode( + config: &Config, + timeout: std::time::Duration, + interval: std::time::Duration, + expected_mode: flicknote_sync::ipc::BackendMode, +) -> Result<(), CliError> { + wait_for_daemon_ready_matching(config, timeout, interval, Some(expected_mode)).await +} + +async fn wait_for_daemon_ready_matching( + config: &Config, + timeout: std::time::Duration, + interval: std::time::Duration, + expected_mode: Option, ) -> Result<(), CliError> { let wait = async { loop { @@ -101,7 +120,18 @@ pub(super) async fn wait_for_daemon_ready( .health() .await { - Ok(_) => return Ok(()), + Ok(info) => { + if let Some(expected) = expected_mode + && info.backend != expected + { + return Err(CliError::Other(format!( + "Expected a {} daemon, but the endpoint is owned by a {} daemon; stop it and retry", + expected.as_str(), + info.backend.as_str(), + ))); + } + return Ok(()); + } Err(error) if !error.retryable() => return Err(CliError::from(error)), Err(_) => tokio::time::sleep(interval).await, } @@ -192,12 +222,39 @@ async fn install_with_timeout( timeout: std::time::Duration, ) -> Result<(), CliError> { validate_install_mode(database_url)?; - super::daemon::install(config)?; - wait_for_daemon_ready(config, timeout, HEALTH_POLL_INTERVAL).await?; + install_local_daemon(config, timeout).await?; println!("Installed and started: io.guion.flicknote.sync"); Ok(()) } +pub(super) async fn install_local_daemon( + config: &Config, + timeout: std::time::Duration, +) -> Result<(), CliError> { + #[cfg(not(target_os = "macos"))] + { + let (_config, _timeout) = (config, timeout); + validate_launchd_platform() + } + + #[cfg(target_os = "macos")] + { + validate_launchd_platform()?; + // Prove that the shared endpoint is no longer owned by an old launchd or + // standalone daemon before starting the new local LaunchAgent. + super::daemon::stop(config)?; + wait_for_daemon_stopped(config, timeout, HEALTH_POLL_INTERVAL).await?; + super::daemon::install(config)?; + wait_for_daemon_ready_for_mode( + config, + timeout, + HEALTH_POLL_INTERVAL, + flicknote_sync::ipc::BackendMode::Local, + ) + .await + } +} + fn validate_install_mode(database_url: Option<&str>) -> Result<(), CliError> { if database_url.is_some() { return Err(CliError::Other( @@ -207,7 +264,17 @@ fn validate_install_mode(database_url: Option<&str>) -> Result<(), CliError> { Ok(()) } +fn validate_launchd_platform() -> Result<(), CliError> { + if !cfg!(target_os = "macos") { + return Err(CliError::Other( + "launchd installation is only supported on macOS; use `flicknote sync start` on this platform".to_string(), + )); + } + Ok(()) +} + fn uninstall() -> Result<(), CliError> { + validate_launchd_platform()?; super::daemon::uninstall()?; println!("Uninstalled: io.guion.flicknote.sync"); Ok(()) @@ -269,16 +336,51 @@ mod tests { ); } + #[cfg(not(target_os = "macos"))] #[tokio::test] - async fn install_does_not_report_success_before_health_is_ready() { + async fn install_rejects_unsupported_platform_without_waiting() { let dir = tempfile::tempdir().expect("temp dir"); let config = test_config(dir.path()); let error = install_with_timeout(&config, None, std::time::Duration::from_millis(20)) .await - .expect_err("socket absence must not count as launchd readiness"); + .expect_err("non-macOS install must be rejected immediately"); - assert!(error.to_string().contains("did not become ready")); + assert!(error.to_string().contains("only supported on macOS")); + } + + #[tokio::test] + async fn local_install_readiness_rejects_a_managed_daemon() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + let dir = tempfile::tempdir().expect("temp dir"); + let config = test_config(dir.path()); + let listener = tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)) + .expect("bind socket"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + let mut request = String::new(); + reader.read_line(&mut request).await.unwrap(); + let response = serde_json::to_vec(&flicknote_sync::ipc::DaemonResponse::ServerInfo( + flicknote_sync::ipc::ServerInfo::managed(), + )) + .unwrap(); + writer.write_all(&response).await.unwrap(); + }); + + let error = wait_for_daemon_ready_for_mode( + &config, + std::time::Duration::from_millis(500), + std::time::Duration::from_millis(10), + flicknote_sync::ipc::BackendMode::Local, + ) + .await + .expect_err("a pre-existing managed daemon is not local install readiness"); + + assert!(error.to_string().contains("managed daemon")); + server.await.unwrap(); } #[tokio::test] diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index d9dbce2..c1cdd5a 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -47,6 +47,12 @@ pub struct InsertedNote { pub short_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InsertedNoteWithExtractions { + pub note: InsertedNote, + pub extraction_ids: Vec, +} + pub(crate) enum NoteLookup<'a> { ShortId(i64), Uuid(&'a str), @@ -102,11 +108,14 @@ pub trait NoteDb: Send + Sync { req: &InsertNoteReq<'_>, extraction_key: &str, values: &[String], - ) -> Result { + ) -> Result { let inserted = self.insert_note(req).await?; self.set_note_extractions(&inserted.uuid, extraction_key, values) .await?; - Ok(inserted) + Ok(InsertedNoteWithExtractions { + note: inserted, + extraction_ids: Vec::new(), + }) } /// Update content. When `requeue` is true, also sets status = 'ai_queued'. async fn update_note_content( @@ -670,7 +679,7 @@ impl NoteDb for SqliteBackend { req: &InsertNoteReq<'_>, extraction_key: &str, values: &[String], - ) -> Result { + ) -> Result { let mut transaction = self.db.pool.begin().await?; sqlx::query(SQ_INSERT) .bind(req.id) @@ -691,20 +700,26 @@ impl NoteDb for SqliteBackend { .bind(extraction_key) .execute(&mut *transaction) .await?; + let mut extraction_ids = Vec::with_capacity(values.len()); for value in values { + let extraction_id = uuid::Uuid::new_v4().to_string(); sqlx::query(SQ_INSERT_EXTRACTION) - .bind(uuid::Uuid::new_v4().to_string()) + .bind(&extraction_id) .bind(req.id) .bind(&self.user_id) .bind(extraction_key) .bind(value) .execute(&mut *transaction) .await?; + extraction_ids.push(extraction_id); } transaction.commit().await?; - Ok(InsertedNote { - uuid: req.id.to_string(), - short_id: None, + Ok(InsertedNoteWithExtractions { + note: InsertedNote { + uuid: req.id.to_string(), + short_id: None, + }, + extraction_ids, }) } diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs index 8cbd31d..b44a62a 100644 --- a/flicknote-core/src/pgwire/mod.rs +++ b/flicknote-core/src/pgwire/mod.rs @@ -10,7 +10,10 @@ use sqlx::{PgPool, Row, postgres::PgPoolOptions}; use uuid::Uuid; use crate::TOPIC_EXTRACTION_KEY; -use crate::backend::{InsertNoteReq, InsertedNote, NoteDb, NoteFilter, NoteLookup, NoteSearch}; +use crate::backend::{ + InsertNoteReq, InsertedNote, InsertedNoteWithExtractions, NoteDb, NoteFilter, NoteLookup, + NoteSearch, +}; use crate::error::CliError; use crate::types::{Keyterm, Note, Project}; @@ -464,7 +467,7 @@ impl NoteDb for PgWireBackend { req: &InsertNoteReq<'_>, extraction_key: &str, values: &[String], - ) -> Result { + ) -> Result { let metadata: Option = req .metadata .map(serde_json::from_str) @@ -490,22 +493,28 @@ impl NoteDb for PgWireBackend { .bind(now) .fetch_one(&mut *transaction) .await?; + let mut extraction_ids = Vec::with_capacity(values.len()); for value in values { + let extraction_id = Uuid::new_v4(); sqlx::query( "INSERT INTO note_extractions (id, note_id, user_id, key, value) \ VALUES ($1, $2, (SELECT user_id FROM notes WHERE id = $2), $3, $4)", ) - .bind(Uuid::new_v4()) + .bind(extraction_id) .bind(note_id) .bind(extraction_key) .bind(value) .execute(&mut *transaction) .await?; + extraction_ids.push(extraction_id.to_string()); } transaction.commit().await?; - Ok(InsertedNote { - uuid: row.try_get::(0)?, - short_id: row.try_get::, _>(1)?.map(i64::from), + Ok(InsertedNoteWithExtractions { + note: InsertedNote { + uuid: row.try_get::(0)?, + short_id: row.try_get::, _>(1)?.map(i64::from), + }, + extraction_ids, }) } diff --git a/flicknote-core/src/services/note.rs b/flicknote-core/src/services/note.rs index 49413d5..c2a4ead 100644 --- a/flicknote-core/src/services/note.rs +++ b/flicknote-core/src/services/note.rs @@ -161,13 +161,13 @@ impl<'a> NoteService<'a> { attachment_path: None, } }; - let inserted = creator.create(request).await?; + let created = creator.create(request).await?; let summary = async { - let note = self.db.find_note(&inserted.uuid).await?; + let note = self.db.find_note(&created.inserted.uuid).await?; self.summary(note).await } .await; - summary.map_err(|error| confirmed_create_followup_error(&inserted, &error)) + summary.map_err(|error| confirmed_create_followup_error(&created, &error)) } pub async fn get(&self, note_id: &str, archived: bool) -> Result { @@ -593,9 +593,10 @@ impl<'a> NoteService<'a> { } pub fn confirmed_create_followup_error( - inserted: &crate::backend::InsertedNote, + created: &crate::services::ports::CreatedNote, error: &ServiceError, ) -> ServiceError { + let inserted = &created.inserted; ServiceError::Remote { code: "note_create_partial".to_string(), message: format!( @@ -609,7 +610,7 @@ pub fn confirmed_create_followup_error( "created": true, "note_id": inserted.uuid, "short_id": inserted.short_id, - "confirmed_extraction_ids": [], + "confirmed_extraction_ids": created.confirmed_extraction_ids, "pending_extraction_ids": [], })), } @@ -621,7 +622,7 @@ mod tests { use crate::backend::NoteDb; use crate::services::dto::NoteAddInput; use crate::services::ports::{ - BrowserOpener, CreateNote, NoteCreator, ShareGateway, ShareResource, + BrowserOpener, CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, }; use crate::services::test_support::{insert_normal_note, make_backend}; use async_trait::async_trait; @@ -878,10 +879,13 @@ mod tests { async fn create( &self, request: CreateNote, - ) -> Result { + ) -> Result { let inserted = self.db.insert_note(&request.as_insert_request()).await?; *self.request.lock().unwrap() = Some(request); - Ok(inserted) + Ok(CreatedNote { + inserted, + confirmed_extraction_ids: Vec::new(), + }) } } @@ -892,10 +896,13 @@ mod tests { async fn create( &self, request: CreateNote, - ) -> Result { - Ok(crate::backend::InsertedNote { - uuid: request.id, - short_id: Some(42), + ) -> Result { + Ok(CreatedNote { + inserted: crate::backend::InsertedNote { + uuid: request.id, + short_id: Some(42), + }, + confirmed_extraction_ids: vec!["extraction-confirmed".to_string()], }) } } @@ -926,6 +933,10 @@ mod tests { assert_eq!(details["created"], true); assert_eq!(details["short_id"], 42); assert!(details["note_id"].as_str().is_some()); + assert_eq!( + details["confirmed_extraction_ids"], + serde_json::json!(["extraction-confirmed"]) + ); } #[tokio::test] diff --git a/flicknote-core/src/services/ports.rs b/flicknote-core/src/services/ports.rs index 91dd388..3def0ec 100644 --- a/flicknote-core/src/services/ports.rs +++ b/flicknote-core/src/services/ports.rs @@ -37,7 +37,13 @@ impl CreateNote { #[async_trait] pub trait NoteCreator: Send + Sync { - async fn create(&self, request: CreateNote) -> Result; + async fn create(&self, request: CreateNote) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedNote { + pub inserted: InsertedNote, + pub confirmed_extraction_ids: Vec, } pub struct DirectNoteCreator<'a> { @@ -52,15 +58,20 @@ impl<'a> DirectNoteCreator<'a> { #[async_trait] impl NoteCreator for DirectNoteCreator<'_> { - async fn create(&self, request: CreateNote) -> Result { - self.db + async fn create(&self, request: CreateNote) -> Result { + let created = self + .db .insert_note_with_extractions( &request.as_insert_request(), crate::TOPIC_EXTRACTION_KEY, &request.topics, ) .await - .map_err(ServiceError::from) + .map_err(ServiceError::from)?; + Ok(CreatedNote { + inserted: created.note, + confirmed_extraction_ids: created.extraction_ids, + }) } } @@ -117,4 +128,38 @@ mod tests { assert!(error.to_string().contains("topic failure")); assert!(backend.find_note(&id).await.is_err()); } + + #[tokio::test] + async fn direct_create_reports_the_committed_extraction_ids() { + let backend = make_backend().await; + let created = DirectNoteCreator::new(&backend) + .create(CreateNote { + id: uuid::Uuid::new_v4().to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: chrono::Utc::now().to_rfc3339(), + topics: vec!["rust".to_string(), "sqlite".to_string()], + attachment_path: None, + }) + .await + .unwrap(); + + let mut stored_ids = sqlx::query_scalar::<_, String>( + "SELECT id FROM note_extractions WHERE note_id = ? ORDER BY id", + ) + .bind(&created.inserted.uuid) + .fetch_all(&backend.db.pool) + .await + .unwrap(); + let mut reported_ids = created.confirmed_extraction_ids; + reported_ids.sort(); + stored_ids.sort(); + + assert_eq!(reported_ids, stored_ids); + assert_eq!(reported_ids.len(), 2); + } } diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index 7a92f90..5833799 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -132,7 +132,7 @@ impl Application { topics: parsed.topics, attachment_path: None, }; - let inserted = if let Some(creator) = self.creator.as_deref() { + let created = if let Some(creator) = self.creator.as_deref() { creator.create(request).await } else if self.mode == BackendMode::Managed { DirectNoteCreator::new(self.db.as_ref()) @@ -147,11 +147,11 @@ impl Application { } .map_err(WireError::from_service)?; notes - .get(&inserted.uuid, false) + .get(&created.inserted.uuid, false) .await .map(|detail| AppResponse::NoteSummary(detail.note)) .map_err(|error| { - WireError::from_service(confirmed_create_followup_error(&inserted, &error)) + WireError::from_service(confirmed_create_followup_error(&created, &error)) }) } AppRequest::NoteUpload { @@ -217,7 +217,7 @@ impl Application { ), None => None, }; - let inserted = creator + let created = creator .create(CreateNote { id: uuid::Uuid::new_v4().to_string(), note_type: note_type.to_string(), @@ -233,12 +233,12 @@ impl Application { .await .map_err(WireError::from_service)?; notes - .get(&inserted.uuid, false) + .get(&created.inserted.uuid, false) .await .map(|detail| AppResponse::NoteSummary(detail.note)) .map_err(|error| { WireError::from_service(confirmed_create_followup_error( - &inserted, &error, + &created, &error, )) }) } diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 5867643..314fecd 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -54,9 +54,10 @@ struct CreateNoteRequest { } #[derive(Debug, Clone, PartialEq, Eq)] -struct CreatedNote { +struct RemoteCreatedNote { uuid: String, short_id: i64, + confirmed_extraction_ids: Vec, } #[derive(Debug, Default, PartialEq, Eq)] @@ -173,6 +174,30 @@ fn unwrap_json_strings(data: &mut serde_json::Map) { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlickNoteCrudMarker { + RemoteCommittedInsert, +} + +fn parse_flicknote_crud_marker( + metadata: Option<&str>, +) -> Result, PowerSyncError> { + let Some(metadata) = metadata else { + return Ok(None); + }; + let value: serde_json::Value = serde_json::from_str(metadata) + .map_err(|error| ps_err(format!("invalid CRUD metadata: {error}")))?; + let Some(marker) = value.as_object().and_then(|object| object.get("flicknote")) else { + return Ok(None); + }; + match marker.as_str() { + Some("remote_committed_insert_v1") => Ok(Some(FlickNoteCrudMarker::RemoteCommittedInsert)), + _ => Err(ps_err(format!( + "unsupported FlickNote CRUD marker: {marker}" + ))), + } +} + /// Inner upload logic shared by the BackendConnector and application-triggered drain. /// Caller is responsible for holding `upload_guard` before calling. /// @@ -197,7 +222,9 @@ async fn run_upload( let mut transient_msg: Option = None; for crud in std::mem::take(&mut tx.crud) { - if crud.metadata.as_deref() == Some(REMOTE_COMMITTED_INSERT_METADATA) { + if parse_flicknote_crud_marker(crud.metadata.as_deref())? + == Some(FlickNoteCrudMarker::RemoteCommittedInsert) + { let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); let is_put = matches!(&crud.update_type, UpdateType::Put); if !allowed_table || !is_put { @@ -1074,7 +1101,7 @@ async fn create_note_remotely( auth: &GoTrueClient, config: &Config, req: CreateNoteRequest, -) -> Result { +) -> Result { let session = auth.get_session().await.map_err(|e| DaemonError::Other { message: format!("Auth error: {e}"), })?; @@ -1097,7 +1124,7 @@ async fn create_note_with_token( access_token: &str, user_id: &str, req: CreateNoteRequest, -) -> Result { +) -> Result { let extraction_rows = req .topics .iter() @@ -1384,7 +1411,7 @@ async fn finish_remote_create( access_token: &str, row: RemoteNoteRow, extraction_rows: &[RemoteExtractionRow], -) -> Result { +) -> Result { let short_id = match row.short_id { Some(short_id) => short_id, None => { @@ -1428,9 +1455,10 @@ async fn finish_remote_create( extraction_outcome.pending_ids, )); } - Ok(CreatedNote { + Ok(RemoteCreatedNote { uuid: row.id, short_id, + confirmed_extraction_ids: extraction_outcome.confirmed_ids, }) } @@ -1652,8 +1680,10 @@ impl NoteCreator for RemoteNoteCreator { async fn create( &self, request: CreateNote, - ) -> Result - { + ) -> Result< + flicknote_core::services::ports::CreatedNote, + flicknote_core::services::error::ServiceError, + > { let created = create_note_remotely( &self.db, &self.http, @@ -1674,9 +1704,12 @@ impl NoteCreator for RemoteNoteCreator { ) .await .map_err(remote_create_service_error)?; - Ok(flicknote_core::backend::InsertedNote { - uuid: created.uuid, - short_id: Some(created.short_id), + Ok(flicknote_core::services::ports::CreatedNote { + inserted: flicknote_core::backend::InsertedNote { + uuid: created.uuid, + short_id: Some(created.short_id), + }, + confirmed_extraction_ids: created.confirmed_extraction_ids, }) } } @@ -2001,18 +2034,26 @@ mod tests { async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { PowerSyncEnvironment::powersync_auto_extension().unwrap(); let directory = tempfile::tempdir().unwrap(); - let pool = ConnectionPool::open(directory.path().join("test.db")).unwrap(); + let db = test_powersync_db_at(directory.path().join("test.db"), app_schema()); + db.writer().await.unwrap(); + (directory, db) + } + + fn test_powersync_db_at( + path: impl AsRef, + schema: powersync::schema::Schema, + ) -> PowerSyncDatabase { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::open(path).unwrap(); let env = PowerSyncEnvironment::custom( reqwest::Client::new(), pool, PowerSyncEnvironment::tokio_timer(), ); - let db = PowerSyncDatabase::new(env, app_schema()); - db.writer().await.unwrap(); - (directory, db) + PowerSyncDatabase::new(env, schema) } - async fn insert_marked_note(db: &PowerSyncDatabase) { + async fn insert_note_with_metadata(db: &PowerSyncDatabase, metadata: &str) { let writer = db.writer().await.unwrap(); writer .execute( @@ -2031,12 +2072,16 @@ mod tests { 0, "2026-08-09T00:00:00Z", "2026-08-09T00:00:00Z", - r#"{"flicknote":"remote_committed_insert_v1"}"#, + metadata, ], ) .unwrap(); } + async fn insert_marked_note(db: &PowerSyncDatabase) { + insert_note_with_metadata(db, REMOTE_COMMITTED_INSERT_METADATA).await; + } + fn remote_note(id: &str, title: &str) -> RemoteNoteRow { RemoteNoteRow { id: id.to_string(), @@ -2143,6 +2188,63 @@ mod tests { ); } + #[tokio::test] + async fn existing_database_upgrades_to_metadata_tracking_without_losing_rows() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("upgrade.db"); + let mut legacy_schema = app_schema(); + for table in &mut legacy_schema.tables { + if matches!(table.name.as_ref(), "notes" | "note_extractions") { + table.options.track_metadata = false; + } + } + { + let legacy_db = test_powersync_db_at(&path, legacy_schema); + let writer = legacy_db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO notes (id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?)", + params!["existing-note", "user-1", "normal", "ready", "Preserved"], + ) + .unwrap(); + writer.execute("DELETE FROM ps_crud", []).unwrap(); + } + + let upgraded_db = test_powersync_db_at(&path, app_schema()); + { + let writer = upgraded_db.writer().await.unwrap(); + let title: String = writer + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["existing-note"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Preserved"); + writer + .execute( + "INSERT INTO notes (id, user_id, type, status, title, _metadata) VALUES (?, ?, ?, ?, ?, ?)", + params![ + "marked-after-upgrade", + "user-1", + "normal", + "ready", + "Marked", + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .unwrap(); + } + + let transaction = upgraded_db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!(transaction.crud.len(), 1); + assert_eq!(transaction.crud[0].id, "marked-after-upgrade"); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); + } + #[tokio::test] async fn remote_committed_put_completes_without_http_request() { let (_directory, db) = test_powersync_db().await; @@ -2162,6 +2264,66 @@ mod tests { assert!(db.next_crud_transaction().await.unwrap().is_none()); } + #[tokio::test] + async fn remote_committed_marker_is_matched_as_json_not_raw_text() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{ "flicknote" : "remote_committed_insert_v1" }"#).await; + + run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap(); + + assert!(db.next_crud_transaction().await.unwrap().is_none()); + } + + #[tokio::test] + async fn unsupported_flicknote_marker_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v2"}"#).await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported FlickNote CRUD marker") + ); + assert!(db.next_crud_transaction().await.unwrap().is_some()); + } + + #[tokio::test] + async fn malformed_crud_metadata_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v1""#).await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("invalid CRUD metadata")); + assert!(db.next_crud_transaction().await.unwrap().is_some()); + } + #[tokio::test] async fn remote_committed_marker_on_patch_is_rejected_and_retained() { let (_directory, db) = test_powersync_db().await; diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 54ff897..1602269 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -6,7 +6,7 @@ use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; use flicknote_core::services::dto::{NoteAddInput, NoteListInput, ProjectAddInput}; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::ports::{CreateNote, NoteCreator}; +use flicknote_core::services::ports::{CreateNote, CreatedNote, NoteCreator}; use flicknote_sync::app::Application; use flicknote_sync::ipc::{ AppRequest, AppResponse, BackendMode, DaemonClient, ServerInfo, serve_app_once, @@ -77,10 +77,13 @@ struct RecordingCreator { #[async_trait] impl NoteCreator for RecordingCreator { - async fn create(&self, request: CreateNote) -> Result { + async fn create(&self, request: CreateNote) -> Result { let inserted = self.db.insert_note(&request.as_insert_request()).await?; *self.request.lock().unwrap() = Some(request); - Ok(inserted) + Ok(CreatedNote { + inserted, + confirmed_extraction_ids: Vec::new(), + }) } } @@ -88,10 +91,13 @@ struct DetachedCreator; #[async_trait] impl NoteCreator for DetachedCreator { - async fn create(&self, request: CreateNote) -> Result { - Ok(InsertedNote { - uuid: request.id, - short_id: Some(91), + async fn create(&self, request: CreateNote) -> Result { + Ok(CreatedNote { + inserted: InsertedNote { + uuid: request.id, + short_id: Some(91), + }, + confirmed_extraction_ids: Vec::new(), }) } } From 2cbdba94e9bf807bfeb80c9073487d139fc58815 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 19:34:45 +0800 Subject: [PATCH 09/16] fix(sync): harden daemon identity and result contracts --- flicknote-cli/src/commands/daemon.rs | 86 ++++++++++++++++++- flicknote-cli/src/commands/login.rs | 27 ++++-- flicknote-cli/src/main.rs | 34 +++++++- flicknote-core/src/backend.rs | 37 +++++++- flicknote-core/src/pgwire/mod.rs | 11 +-- .../src/services/editable_document.rs | 2 +- flicknote-sync/src/app.rs | 9 +- flicknote-sync/src/ipc.rs | 63 +++++++++++++- flicknote-sync/src/lib.rs | 37 +++++++- 9 files changed, 279 insertions(+), 27 deletions(-) diff --git a/flicknote-cli/src/commands/daemon.rs b/flicknote-cli/src/commands/daemon.rs index a0b4230..0339060 100644 --- a/flicknote-cli/src/commands/daemon.rs +++ b/flicknote-cli/src/commands/daemon.rs @@ -1,10 +1,13 @@ use flicknote_core::config::Config; use flicknote_core::error::CliError; +use std::ffi::OsStr; use std::fs; use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::Command; +const DAEMON_BINARY_NAME: &str = "flicknote-sync"; + pub(crate) fn pid_file(config: &Config) -> PathBuf { config.paths.data_dir.join("sync.pid") } @@ -14,7 +17,9 @@ pub(crate) fn read_pid(config: &Config) -> Option { let content = fs::read_to_string(&path).ok()?; let pid: u32 = content.trim().parse().ok()?; #[allow(unsafe_code)] - if unsafe { libc::kill(pid as i32, 0) } == 0 { + if unsafe { libc::kill(pid as i32, 0) } == 0 + && process_matches_executable(pid, std::path::Path::new(DAEMON_BINARY_NAME)) + { return Some(pid); } #[allow(clippy::let_underscore_must_use, clippy::let_underscore_untyped)] @@ -22,13 +27,48 @@ pub(crate) fn read_pid(config: &Config) -> Option { None } +#[cfg(target_os = "linux")] +fn process_executable(pid: u32) -> Option { + fs::read_link(format!("/proc/{pid}/exe")).ok() +} + +#[cfg(target_os = "macos")] +fn process_executable(pid: u32) -> Option { + use std::os::unix::ffi::OsStrExt; + + let mut buffer = vec![0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + #[allow(unsafe_code)] + let length = unsafe { + libc::proc_pidpath( + pid as libc::c_int, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + ) + }; + if length <= 0 { + return None; + } + buffer.truncate(length as usize); + Some(PathBuf::from(OsStr::from_bytes(&buffer))) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_executable(_pid: u32) -> Option { + None +} + +fn process_matches_executable(pid: u32, expected: &std::path::Path) -> bool { + process_executable(pid).and_then(|path| path.file_name().map(OsStr::to_owned)) + == expected.file_name().map(OsStr::to_owned) +} + pub(crate) fn daemon_binary() -> Result { let exe = std::env::current_exe() .map_err(|e| CliError::Other(format!("Could not determine executable path: {e}")))?; let dir = exe .parent() .ok_or_else(|| CliError::Other("Could not determine executable directory".into()))?; - let binary = dir.join("flicknote-sync"); + let binary = dir.join(DAEMON_BINARY_NAME); if !binary.exists() { return Err(CliError::Other(format!( "Sync daemon binary not found at {}: ensure flicknote-sync is installed alongside flicknote", @@ -235,8 +275,28 @@ fn bootout_service(uid: u32, label: &str) -> Result<(), CliError> { #[cfg(test)] mod tests { + use flicknote_core::config::ConfigPaths; + use super::*; + fn test_config(dir: &std::path::Path) -> Config { + Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: String::new(), + web_url: None, + paths: ConfigPaths { + config_dir: dir.to_path_buf(), + data_dir: dir.to_path_buf(), + config_file: dir.join("config.json"), + session_file: dir.join("session.json"), + db_file: dir.join("flicknote.db"), + log_file: dir.join("flicknote.log"), + }, + } + } + #[test] fn launchd_install_runs_bootstrap_then_kickstart() { let plist = PathBuf::from("/Users/neil/Library/LaunchAgents/io.guion.flicknote.sync.plist"); @@ -269,4 +329,26 @@ mod tests { ] ); } + + #[test] + fn process_identity_must_match_expected_executable_before_signalling() { + let current = std::env::current_exe().unwrap(); + assert!(process_matches_executable(std::process::id(), ¤t)); + + let unrelated = tempfile::NamedTempFile::new().unwrap(); + assert!(!process_matches_executable( + std::process::id(), + unrelated.path(), + )); + } + + #[test] + fn stale_pid_for_an_unrelated_live_process_is_removed_without_being_accepted() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + fs::write(pid_file(&config), std::process::id().to_string()).unwrap(); + + assert_eq!(read_pid(&config), None); + assert!(!pid_file(&config).exists()); + } } diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index b94ea0e..740d421 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -17,6 +17,9 @@ pub(crate) struct LoginArgs { } pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliError> { + let database_url = std::env::var("DATABASE_URL").ok(); + let manage_local_daemon = + manages_daemon_after_login_for(cfg!(target_os = "macos"), database_url.as_deref()); if config.paths.session_file.exists() { if !args.force { return Err(CliError::Other( @@ -24,8 +27,10 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro )); } // --force: stop daemon and clear stale session before re-auth - super::daemon::stop(config)?; - super::daemon::uninstall()?; + if manage_local_daemon { + super::daemon::stop(config)?; + super::daemon::uninstall()?; + } std::fs::remove_file(&config.paths.session_file)?; } @@ -78,7 +83,7 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro println!("Authenticated"); - if manages_daemon_after_login() { + if manage_local_daemon { // The macOS login flow owns the per-user LaunchAgent lifecycle. super::sync::install_local_daemon(config, std::time::Duration::from_secs(10)).await?; println!("Sync daemon installed and started"); @@ -87,8 +92,8 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro Ok(()) } -const fn manages_daemon_after_login() -> bool { - cfg!(target_os = "macos") +const fn manages_daemon_after_login_for(target_is_macos: bool, database_url: Option<&str>) -> bool { + target_is_macos && database_url.is_none() } #[cfg(test)] @@ -96,6 +101,16 @@ mod tests { #[cfg(not(target_os = "macos"))] #[test] fn non_macos_login_does_not_wait_for_a_launchd_daemon() { - assert!(!super::manages_daemon_after_login()); + assert!(!super::manages_daemon_after_login_for(false, None)); + } + + #[test] + fn managed_login_never_manages_the_local_launch_agent() { + assert!(!super::manages_daemon_after_login_for( + true, + Some("postgres://managed") + )); + assert!(super::manages_daemon_after_login_for(true, None)); + assert!(!super::manages_daemon_after_login_for(false, None)); } } diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index f58c738..7aebcd3 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -125,16 +125,31 @@ async fn run() -> Result<(), CliError> { .run_until(mcp::serve(std::rc::Rc::new(config))) .await; } - dispatch(&cli, &daemon).await + dispatch(&cli, &daemon, &server_info).await } -async fn dispatch(cli: &Cli, daemon: &DaemonClient<'_>) -> Result<(), CliError> { +fn preflight_command( + command: &Commands, + server_info: &flicknote_sync::ipc::ServerInfo, +) -> Result<(), CliError> { + if matches!(command, Commands::Edit(_)) { + server_info.require(Capability::Editor, "note_edit")?; + } + Ok(()) +} + +async fn dispatch( + cli: &Cli, + daemon: &DaemonClient<'_>, + server_info: &flicknote_sync::ipc::ServerInfo, +) -> Result<(), CliError> { let Some(ref command) = cli.command else { Cli::command() .print_help() .map_err(|e| CliError::Other(e.to_string()))?; return Ok(()); }; + preflight_command(command, server_info)?; match command { Commands::Mcp => unreachable!("MCP is dispatched before regular CLI commands"), @@ -719,6 +734,21 @@ mod tests { assert!(Cli::try_parse_from(["flicknote", "upload", "file.pdf"]).is_ok()); } + #[test] + fn managed_edit_is_rejected_before_dispatching_to_the_editor() { + let cli = Cli::try_parse_from(["flicknote", "edit"]).unwrap(); + let command = cli.command.as_ref().unwrap(); + + let error = + preflight_command(command, &flicknote_sync::ipc::ServerInfo::managed()).unwrap_err(); + + assert!( + error + .to_string() + .contains("not available in managed daemon mode") + ); + } + #[test] fn metadata_discovery_and_source_commands_parse() { assert!(Cli::try_parse_from(["flicknote", "topic", "list"]).is_ok()); diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index c1cdd5a..1adeae4 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -213,7 +213,7 @@ pub trait NoteDb: Send + Sync { description: Option<&str>, content: Option<&str>, now: &str, - ) -> Result<(), CliError>; + ) -> Result; async fn find_keyterm(&self, id: &str) -> Result; async fn list_keyterms(&self) -> Result, CliError>; async fn update_keyterm( @@ -1117,7 +1117,7 @@ impl NoteDb for SqliteBackend { description: Option<&str>, content: Option<&str>, now: &str, - ) -> Result<(), CliError> { + ) -> Result { sqlx::query(SQ_INSERT_KEYTERM) .bind(id) .bind(&self.user_id) @@ -1128,7 +1128,15 @@ impl NoteDb for SqliteBackend { .bind(now) .execute(&self.db.pool) .await?; - Ok(()) + Ok(Keyterm { + id: id.to_string(), + user_id: self.user_id.clone(), + name: name.to_string(), + description: description.map(str::to_string), + content: content.map(str::to_string), + created_at: Some(now.to_string()), + updated_at: Some(now.to_string()), + }) } async fn find_keyterm(&self, id: &str) -> Result { @@ -2047,4 +2055,27 @@ mod tests { assert!(backend.resolve_project_id(project_prefix).await.is_err()); assert!(backend.resolve_keyterm_id(keyterm_prefix).await.is_err()); } + + #[tokio::test] + async fn insert_keyterm_returns_the_committed_record() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let keyterm_id = uuid::Uuid::new_v4().to_string(); + + let inserted = backend + .insert_keyterm( + &keyterm_id, + "Rust", + Some("Language"), + Some("ownership"), + &now, + ) + .await + .unwrap(); + + assert_eq!(inserted.id, keyterm_id); + assert_eq!(inserted.name, "Rust"); + assert_eq!(inserted.description.as_deref(), Some("Language")); + assert_eq!(inserted.content.as_deref(), Some("ownership")); + } } diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs index b44a62a..4597851 100644 --- a/flicknote-core/src/pgwire/mod.rs +++ b/flicknote-core/src/pgwire/mod.rs @@ -890,11 +890,12 @@ impl NoteDb for PgWireBackend { description: Option<&str>, content: Option<&str>, now: &str, - ) -> Result<(), CliError> { + ) -> Result { let now = parse_iso_utc(now)?; - sqlx::query( + let row = sqlx::query_as::<_, KeytermPgRow>( "INSERT INTO keyterms (id, name, description, content, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6)", + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING id, user_id, name, description, content, created_at, updated_at", ) .bind(parse_uuid(id)?) .bind(name) @@ -902,9 +903,9 @@ impl NoteDb for PgWireBackend { .bind(content) .bind(now) .bind(now) - .execute(&self.pool) + .fetch_one(&self.pool) .await?; - Ok(()) + Ok(row.into()) } async fn find_keyterm(&self, id: &str) -> Result { diff --git a/flicknote-core/src/services/editable_document.rs b/flicknote-core/src/services/editable_document.rs index 1a0d0c8..5d7e819 100644 --- a/flicknote-core/src/services/editable_document.rs +++ b/flicknote-core/src/services/editable_document.rs @@ -608,7 +608,7 @@ mod tests { _description: Option<&str>, _content: Option<&str>, _now: &str, - ) -> Result<(), CliError> { + ) -> Result { unimplemented!() } diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index 5833799..ef119ad 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -480,15 +480,12 @@ impl Application { } let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); - self.db + let keyterm = self + .db .insert_keyterm(&id, &name, description.as_deref(), content.as_deref(), &now) .await .map_err(Self::db_error)?; - self.db - .find_keyterm(&id) - .await - .map(AppResponse::Keyterm) - .map_err(Self::db_error) + Ok(AppResponse::Keyterm(keyterm)) } AppRequest::KeytermList => self .db diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 81a69b4..3e5fc1b 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -619,6 +619,7 @@ impl<'a> DaemonClient<'a> { async fn request(&self, request: DaemonRequest) -> Result { let is_health = matches!(request, DaemonRequest::Health { .. }); + let is_mutating = is_mutating_app_request(&request); send_request(self.config, &request) .await .map_err(|error| match error { @@ -630,7 +631,15 @@ impl<'a> DaemonClient<'a> { "Sync daemon is not ready: {error}. Start it with `flicknote sync start`." )) } - DaemonError::InvalidResponse { .. } if is_health => Self::protocol_mismatch(), + DaemonError::IncompleteResponse { message } if is_mutating => { + ServiceError::Remote { + code: "daemon_request_outcome_unknown".to_string(), + message, + retryable: false, + details: None, + } + } + DaemonError::InvalidResponse { .. } => Self::protocol_mismatch(), other => ServiceError::Daemon(other.to_string()), }) } @@ -1026,6 +1035,58 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn application_maps_unknown_envelope_to_protocol_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + write_json(&mut stream, &json!({"type":"legacy_result","payload":{}})) + .await + .unwrap(); + }); + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(!error.retryable()); + server.await.unwrap(); + } + + #[tokio::test] + async fn mutating_application_maps_incomplete_response_to_unknown_outcome() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + stream.write_all(br#"{"type":"app""#).await.unwrap(); + stream.shutdown().await.unwrap(); + }); + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + assert!(!error.retryable()); + server.await.unwrap(); + } + #[tokio::test] async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 314fecd..96f68e8 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -187,9 +187,17 @@ fn parse_flicknote_crud_marker( }; let value: serde_json::Value = serde_json::from_str(metadata) .map_err(|error| ps_err(format!("invalid CRUD metadata: {error}")))?; - let Some(marker) = value.as_object().and_then(|object| object.get("flicknote")) else { + let Some(object) = value.as_object() else { return Ok(None); }; + let Some(marker) = object.get("flicknote") else { + return Ok(None); + }; + if object.len() != 1 { + return Err(ps_err( + "invalid FlickNote CRUD metadata: expected exactly one marker field", + )); + } match marker.as_str() { Some("remote_committed_insert_v1") => Ok(Some(FlickNoteCrudMarker::RemoteCommittedInsert)), _ => Err(ps_err(format!( @@ -2282,6 +2290,33 @@ mod tests { assert!(db.next_crud_transaction().await.unwrap().is_none()); } + #[tokio::test] + async fn remote_committed_marker_rejects_extra_metadata_fields() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata( + &db, + r#"{"flicknote":"remote_committed_insert_v1","other":true}"#, + ) + .await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("invalid FlickNote CRUD metadata") + ); + assert!(db.crud_transactions().try_next().await.unwrap().is_some()); + } + #[tokio::test] async fn unsupported_flicknote_marker_is_rejected_and_retained() { let (_directory, db) = test_powersync_db().await; From ce582c983f2f73face45011f33c4fc8d20710136 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 20:27:12 +0800 Subject: [PATCH 10/16] fix(sync): enforce client and daemon boundaries --- flicknote-cli/src/commands/login.rs | 66 ++++-- flicknote-cli/src/commands/logout.rs | 40 +++- flicknote-cli/src/commands/sync.rs | 36 +++- flicknote-cli/src/main.rs | 2 +- flicknote-cli/src/mcp/server.rs | 2 +- flicknote-cli/tests/mcp_stdio.rs | 289 ++++++++++++++++++++++++++- flicknote-sync/src/ipc.rs | 239 +++++++++++++++++----- flicknote-sync/src/lib.rs | 15 +- flicknote-sync/tests/app_contract.rs | 30 ++- 9 files changed, 635 insertions(+), 84 deletions(-) diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index 740d421..852cba7 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -18,15 +18,20 @@ pub(crate) struct LoginArgs { pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliError> { let database_url = std::env::var("DATABASE_URL").ok(); - let manage_local_daemon = - manages_daemon_after_login_for(cfg!(target_os = "macos"), database_url.as_deref()); + if config.paths.session_file.exists() && !args.force { + return Err(CliError::Other( + "Already logged in. Use `flicknote login --force` to re-authenticate (e.g. after sync issues).".into(), + )); + } + + let running = super::sync::running_server_info(config).await?; + let manage_local_daemon = manages_daemon_after_login_for( + cfg!(target_os = "macos"), + database_url.as_deref(), + running.as_ref().map(|info| info.backend), + )?; if config.paths.session_file.exists() { - if !args.force { - return Err(CliError::Other( - "Already logged in. Use `flicknote login --force` to re-authenticate (e.g. after sync issues).".into(), - )); - } - // --force: stop daemon and clear stale session before re-auth + // --force: stop only a confirmed local daemon and clear the stale session. if manage_local_daemon { super::daemon::stop(config)?; super::daemon::uninstall()?; @@ -92,8 +97,18 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro Ok(()) } -const fn manages_daemon_after_login_for(target_is_macos: bool, database_url: Option<&str>) -> bool { - target_is_macos && database_url.is_none() +fn manages_daemon_after_login_for( + target_is_macos: bool, + database_url: Option<&str>, + running_backend: Option, +) -> Result { + match running_backend { + Some(flicknote_sync::ipc::BackendMode::Managed) => Err(CliError::Other( + "A managed daemon is running. Stop it explicitly before logging into the local PowerSync workspace.".to_string(), + )), + Some(flicknote_sync::ipc::BackendMode::Local) => Ok(target_is_macos), + None => Ok(target_is_macos && database_url.is_none()), + } } #[cfg(test)] @@ -101,16 +116,33 @@ mod tests { #[cfg(not(target_os = "macos"))] #[test] fn non_macos_login_does_not_wait_for_a_launchd_daemon() { - assert!(!super::manages_daemon_after_login_for(false, None)); + assert!(!super::manages_daemon_after_login_for(false, None, None).unwrap()); } #[test] fn managed_login_never_manages_the_local_launch_agent() { - assert!(!super::manages_daemon_after_login_for( - true, - Some("postgres://managed") - )); - assert!(super::manages_daemon_after_login_for(true, None)); - assert!(!super::manages_daemon_after_login_for(false, None)); + assert!( + !super::manages_daemon_after_login_for(true, Some("postgres://managed"), None,) + .unwrap() + ); + assert!(super::manages_daemon_after_login_for(true, None, None).unwrap()); + assert!(!super::manages_daemon_after_login_for(false, None, None).unwrap()); + } + + #[test] + fn running_daemon_backend_is_the_login_lifecycle_source_of_truth() { + use flicknote_sync::ipc::BackendMode; + + let error = super::manages_daemon_after_login_for(true, None, Some(BackendMode::Managed)) + .unwrap_err(); + assert!(error.to_string().contains("managed daemon")); + assert!( + super::manages_daemon_after_login_for( + true, + Some("postgres://managed"), + Some(BackendMode::Local), + ) + .unwrap() + ); } } diff --git a/flicknote-cli/src/commands/logout.rs b/flicknote-cli/src/commands/logout.rs index aaccf21..f45d2cc 100644 --- a/flicknote-cli/src/commands/logout.rs +++ b/flicknote-cli/src/commands/logout.rs @@ -2,17 +2,18 @@ use flicknote_core::config::Config; use flicknote_core::error::CliError; use std::fs; -pub(crate) fn run(config: &Config) -> Result<(), CliError> { +pub(crate) async fn run(config: &Config) -> Result<(), CliError> { if !config.paths.session_file.exists() { println!("Already logged out"); return Ok(()); } - // 1. Stop the sync daemon (silently succeeds if not running) - super::daemon::stop(config)?; - - // 2. Uninstall the launchd service - super::daemon::uninstall()?; + let running = super::sync::running_server_info(config).await?; + let manages_local_daemon = manages_local_daemon_for(running.as_ref().map(|info| info.backend)); + if manages_local_daemon { + super::daemon::stop(config)?; + super::daemon::uninstall()?; + } // 3. Delete local DB files — collect errors so session is always cleared let db_base = config.paths.db_file.with_extension(""); @@ -36,6 +37,31 @@ pub(crate) fn run(config: &Config) -> Result<(), CliError> { ))); } - println!("Logged out (session, daemon, and local data cleared)"); + if manages_local_daemon { + println!("Logged out (session, daemon, and local data cleared)"); + } else { + println!("Logged out (local session and data cleared; managed daemon left running)"); + } Ok(()) } + +const fn manages_local_daemon_for( + running_backend: Option, +) -> bool { + !matches!( + running_backend, + Some(flicknote_sync::ipc::BackendMode::Managed) + ) +} + +#[cfg(test)] +mod tests { + use flicknote_sync::ipc::BackendMode; + + #[test] + fn logout_never_manages_a_live_managed_daemon() { + assert!(!super::manages_local_daemon_for(Some(BackendMode::Managed))); + assert!(super::manages_local_daemon_for(Some(BackendMode::Local))); + assert!(super::manages_local_daemon_for(None)); + } +} diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 64eccc3..5f70076 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -63,7 +63,8 @@ async fn start_with_binary_and_timeout( .open(&config.paths.log_file)?; let log2 = log.try_clone()?; - let mut child = std::process::Command::new(daemon_binary) + let mut command = std::process::Command::new(daemon_binary); + command .env( "RUST_LOG", std::env::var("RUST_LOG") @@ -71,8 +72,24 @@ async fn start_with_binary_and_timeout( ) .stdin(std::process::Stdio::null()) .stdout(log) - .stderr(log2) - .spawn()?; + .stderr(log2); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + // Manual background start must survive the invoking terminal/session. + // launchd already owns this responsibility for installed macOS services. + #[allow(unsafe_code)] + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + let mut child = command.spawn()?; let pid = child.id(); if let Err(error) = wait_for_daemon_ready(config, timeout, HEALTH_POLL_INTERVAL).await { @@ -98,6 +115,19 @@ pub(super) async fn wait_for_daemon_ready( wait_for_daemon_ready_matching(config, timeout, interval, None).await } +pub(super) async fn running_server_info( + config: &Config, +) -> Result, CliError> { + match flicknote_sync::ipc::DaemonClient::new(config) + .health() + .await + { + Ok(info) => Ok(Some(info)), + Err(error) if error.code() == "daemon_unavailable" => Ok(None), + Err(error) => Err(CliError::from(error)), + } +} + #[cfg(any(target_os = "macos", test))] async fn wait_for_daemon_ready_for_mode( config: &Config, diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index 7aebcd3..db1750d 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -109,7 +109,7 @@ async fn run() -> Result<(), CliError> { if let Some(ref cmd) = cli.command { match cmd { Commands::Login(args) => return commands::login::run(&config, args).await, - Commands::Logout => return commands::logout::run(&config), + Commands::Logout => return commands::logout::run(&config).await, Commands::Sync(args) => return commands::sync::run(&config, args).await, Commands::Skill(args) => return commands::skill::run(args), Commands::Gateway(args) => return commands::gateway::run(&config, args).await, diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index 60a89f0..a979548 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -75,7 +75,7 @@ impl FlickNoteMcp { } async fn call(&self, request: AppRequest) -> Result { - DaemonClient::new(&self.config).call(request).await + DaemonClient::for_mcp(&self.config).call(request).await } fn effective_project(project: Option) -> Option { diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index 367a680..1cffd8c 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -1,4 +1,4 @@ -use std::io::{Read, Write}; +use std::io::{BufRead, Read, Write}; use std::net::TcpListener; use std::process::{Command, Stdio}; use std::thread; @@ -8,7 +8,10 @@ use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; use flicknote_sync::app::Application; -use flicknote_sync::ipc::{BackendMode, ServerInfo, serve_app, socket_path}; +use flicknote_sync::ipc::{ + AppRequest, AppResponse, BackendMode, ClientSurface, DaemonRequest, DaemonResponse, ServerInfo, + WireError, read_request, serve_app, socket_path, write_response, +}; fn test_config(config_root: &std::path::Path, data_root: &std::path::Path) -> Config { let config_dir = config_root.join("flicknote"); @@ -35,6 +38,82 @@ struct DaemonGuard { thread: Option>, } +struct ScriptedDaemonGuard { + shutdown: Option>, + thread: Option>, + requests: std::sync::Arc>>, + socket: std::path::PathBuf, +} + +impl ScriptedDaemonGuard { + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +impl Drop for ScriptedDaemonGuard { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _shutdown_result = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + let _remove_result = std::fs::remove_file(&self.socket); + } +} + +fn spawn_scripted_daemon( + config_root: &std::path::Path, + data_root: &std::path::Path, + info: ServerInfo, + responder: impl Fn(ClientSurface, &AppRequest) -> DaemonResponse + Send + Sync + 'static, +) -> ScriptedDaemonGuard { + let config = test_config(config_root, data_root); + std::fs::create_dir_all(&config.paths.data_dir).unwrap(); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = std::sync::Arc::clone(&requests); + let responder = std::sync::Arc::new(responder); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + ready_tx.send(()).unwrap(); + tokio::pin!(shutdown_rx); + loop { + let accepted = tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => accepted.unwrap(), + }; + let (mut stream, _) = accepted; + let request = read_request(&mut stream).await.unwrap(); + let response = match request { + DaemonRequest::Health { .. } => DaemonResponse::ServerInfo(info.clone()), + DaemonRequest::App { + surface, request, .. + } => { + recorded.lock().unwrap().push((*request).clone()); + responder(surface, &request) + } + }; + write_response(&mut stream, &response).await.unwrap(); + } + }); + }); + ready_rx.recv().unwrap(); + ScriptedDaemonGuard { + shutdown: Some(shutdown_tx), + thread: Some(thread), + requests, + socket: socket_path(&test_config(config_root, data_root)), + } +} + impl Drop for DaemonGuard { fn drop(&mut self) { if let Some(shutdown) = self.shutdown.take() { @@ -152,6 +231,49 @@ fn run_cli_json( serde_json::from_slice(&output.stdout).unwrap() } +fn run_cli_with_input( + config_root: &std::path::Path, + data_root: &std::path::Path, + args: &[&str], + input: &str, +) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .args(args) + .env("XDG_CONFIG_HOME", config_root) + .env("XDG_DATA_HOME", data_root) + .env_remove("DATABASE_URL") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn fake_note_summary() -> flicknote_core::services::dto::NoteSummary { + flicknote_core::services::dto::NoteSummary { + short_id: Some(77), + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + note_type: "normal".to_string(), + status: "synced".to_string(), + title: Some("Adapter note".to_string()), + project_id: None, + project: None, + topics: Vec::new(), + summary: None, + flagged: false, + created_at: None, + updated_at: None, + deleted_at: None, + } +} + fn assert_legacy_note_shape(note: &serde_json::Value, project: &serde_json::Value) { let object = note.as_object().unwrap(); let keys = object @@ -586,6 +708,169 @@ async fn cli_json_commands_preserve_the_existing_machine_contracts() { assert!(!project.contains_key("archived")); } +#[test] +fn cli_mutation_adapter_sends_typed_request_and_preserves_output_contract() { + let directory = tempfile::tempdir().unwrap(); + let config_root = directory.path().join("config"); + let data_root = directory.path().join("data"); + let daemon = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::local(), |_, _| { + DaemonResponse::App(Box::new(AppResponse::NoteMutation( + flicknote_core::services::dto::NoteMutationResult { + note: fake_note_summary(), + sections: Vec::new(), + }, + ))) + }); + + let output = run_cli_with_input( + &config_root, + &data_root, + &["append", "77"], + "new paragraph\n", + ); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "Appended to note 77.\n" + ); + let requests = daemon.requests(); + assert!(matches!( + requests.as_slice(), + [AppRequest::NoteAppend { id, content }] + if id == "77" && content == "new paragraph" + )); +} + +#[test] +fn managed_file_and_editor_boundaries_fail_without_losing_local_input() { + let directory = tempfile::tempdir().unwrap(); + let config_root = directory.path().join("config"); + let data_root = directory.path().join("data"); + let uploaded = directory.path().join("draft.md"); + std::fs::write(&uploaded, "draft body").unwrap(); + let editor_marker = directory.path().join("editor-ran"); + let editor = directory.path().join("editor.sh"); + std::fs::write( + &editor, + format!("#!/bin/sh\ntouch '{}'\n", editor_marker.display()), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&editor, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let daemon = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::managed(), |_, _| { + DaemonResponse::AppError(WireError { + code: "unsupported_capability".to_string(), + message: "operation unavailable".to_string(), + retryable: false, + details: None, + }) + }); + + let edit = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .arg("edit") + .env("XDG_CONFIG_HOME", &config_root) + .env("XDG_DATA_HOME", &data_root) + .env("EDITOR", &editor) + .env_remove("DATABASE_URL") + .output() + .unwrap(); + assert!(!edit.status.success()); + assert!(!editor_marker.exists()); + + let upload = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .args(["upload", uploaded.to_str().unwrap()]) + .env("XDG_CONFIG_HOME", &config_root) + .env("XDG_DATA_HOME", &data_root) + .env_remove("DATABASE_URL") + .output() + .unwrap(); + assert!(!upload.status.success()); + assert_eq!(std::fs::read_to_string(&uploaded).unwrap(), "draft body"); + assert!(matches!( + daemon.requests().as_slice(), + [AppRequest::NoteUpload { .. }] + )); +} + +#[test] +fn long_lived_mcp_rechecks_surface_after_daemon_mode_changes() { + let directory = tempfile::tempdir().unwrap(); + let config_root = directory.path().join("config"); + let data_root = directory.path().join("data"); + let local = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::local(), |_, _| { + DaemonResponse::App(Box::new(AppResponse::NoteSummaries(Vec::new()))) + }); + let mut child = Command::new(env!("CARGO_BIN_EXE_flicknote")) + .arg("mcp") + .env("XDG_CONFIG_HOME", &config_root) + .env("XDG_DATA_HOME", &data_root) + .env_remove("DATABASE_URL") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = std::io::BufReader::new(child.stdout.take().unwrap()); + writeln!( + input, + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-11-25","capabilities":{{}},"clientInfo":{{"name":"integration-test","version":"0"}}}}}}"# + ) + .unwrap(); + input.flush().unwrap(); + let mut frame = String::new(); + output.read_line(&mut frame).unwrap(); + assert_eq!( + serde_json::from_str::(&frame).unwrap()["id"], + 1 + ); + writeln!( + input, + r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# + ) + .unwrap(); + input.flush().unwrap(); + + drop(local); + let managed = spawn_scripted_daemon( + &config_root, + &data_root, + ServerInfo::managed(), + |surface, _| { + assert_eq!(surface, ClientSurface::Mcp); + DaemonResponse::AppError(WireError { + code: "unsupported_capability".to_string(), + message: "MCP is not available in managed mode".to_string(), + retryable: false, + details: None, + }) + }, + ); + writeln!( + input, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"note_list","arguments":{{}}}}}}"# + ) + .unwrap(); + input.flush().unwrap(); + frame.clear(); + output.read_line(&mut frame).unwrap(); + let response: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(response["id"], 2); + assert_eq!(response["result"]["isError"], true); + assert!(matches!( + managed.requests().as_slice(), + [AppRequest::NoteList(_)] + )); + + drop(input); + let result = child.wait().unwrap(); + assert!(result.success()); +} + #[test] fn mcp_binary_keeps_stdout_as_json_rpc_frames() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 3e5fc1b..9a7c3d7 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -31,6 +31,14 @@ pub enum BackendMode { Managed, } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClientSurface { + #[default] + Cli, + Mcp, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Capability { @@ -348,16 +356,16 @@ pub struct EditableDocument { } pub trait AppResult: Sized { - fn from_response(response: AppResponse) -> Result; + fn from_response(response: AppResponse) -> Option; } macro_rules! app_result { ($type:ty, $variant:path) => { impl AppResult for $type { - fn from_response(response: AppResponse) -> Result { + fn from_response(response: AppResponse) -> Option { match response { - $variant(value) => Ok(value), - _ => Err(unexpected_app_response()), + $variant(value) => Some(value), + _ => None, } } } @@ -385,36 +393,32 @@ app_result!(Keyterm, AppResponse::Keyterm); app_result!(Vec, AppResponse::Values); impl AppResult for u64 { - fn from_response(response: AppResponse) -> Result { + fn from_response(response: AppResponse) -> Option { match response { - AppResponse::NoteCount { count } => Ok(count), - _ => Err(unexpected_app_response()), + AppResponse::NoteCount { count } => Some(count), + _ => None, } } } impl AppResult for String { - fn from_response(response: AppResponse) -> Result { + fn from_response(response: AppResponse) -> Option { match response { - AppResponse::Id { id } => Ok(id), - _ => Err(unexpected_app_response()), + AppResponse::Id { id } => Some(id), + _ => None, } } } impl AppResult for () { - fn from_response(response: AppResponse) -> Result { + fn from_response(response: AppResponse) -> Option { match response { - AppResponse::Unit => Ok(()), - _ => Err(unexpected_app_response()), + AppResponse::Unit => Some(()), + _ => None, } } } -fn unexpected_app_response() -> ServiceError { - ServiceError::Internal("daemon returned an unexpected application response".to_string()) -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WireError { pub code: String, @@ -456,6 +460,8 @@ pub enum DaemonRequest { }, App { protocol: u16, + #[serde(default)] + surface: ClientSurface, request: Box, }, } @@ -493,6 +499,12 @@ pub enum DaemonError { IncompleteResponse { message: String, }, + MalformedResponse { + message: String, + }, + PostConnectTransport { + message: String, + }, Other { message: String, }, @@ -508,6 +520,8 @@ impl fmt::Display for DaemonError { | Self::AmbiguousCreate { message, .. } | Self::InvalidResponse { message } | Self::IncompleteResponse { message } + | Self::MalformedResponse { message } + | Self::PostConnectTransport { message } | Self::Other { message } => f.write_str(message), } } @@ -562,6 +576,9 @@ pub async fn send_request( request: &DaemonRequest, ) -> Result { let path = socket_path(config); + let request_bytes = serde_json::to_vec(request).map_err(|e| DaemonError::Other { + message: format!("Failed to serialize daemon request: {e}"), + })?; let mut stream = tokio::time::timeout(IPC_CONNECT_TIMEOUT, UnixStream::connect(&path)) .await .map_err(|_| unavailable(&path, "connecting"))? @@ -569,12 +586,23 @@ pub async fn send_request( path: path.display().to_string(), message: error.to_string(), })?; + let write_request = async { + stream.write_all(&request_bytes).await?; + stream.shutdown().await + }; if is_mutating_app_request(request) { - write_json(&mut stream, request).await?; + write_request + .await + .map_err(|error| DaemonError::PostConnectTransport { + message: format!("Failed to send daemon request: {error}"), + })?; } else { - tokio::time::timeout(IPC_WRITE_TIMEOUT, write_json(&mut stream, request)) + tokio::time::timeout(IPC_WRITE_TIMEOUT, write_request) .await - .map_err(|_| request_timeout_error(request, &path, "sending a request"))??; + .map_err(|_| request_timeout_error(request, &path, "sending a request"))? + .map_err(|error| DaemonError::PostConnectTransport { + message: format!("Failed to send daemon request: {error}"), + })?; } let mut buf = Vec::new(); match response_timeout_for(request) { @@ -585,16 +613,8 @@ pub async fn send_request( } None => stream.read_to_end(&mut buf).await, } - .map_err(|e| { - if matches!(request, DaemonRequest::Health { .. }) { - return DaemonError::Unavailable { - path: path.display().to_string(), - message: format!("daemon closed the health connection: {e}"), - }; - } - DaemonError::Other { - message: format!("Failed to read daemon response: {e}"), - } + .map_err(|e| DaemonError::PostConnectTransport { + message: format!("Failed to read daemon response: {e}"), })?; serde_json::from_slice(&buf).map_err(|e| { if e.is_eof() { @@ -602,23 +622,38 @@ pub async fn send_request( message: format!("Daemon closed the connection before a complete response: {e}"), }; } - DaemonError::InvalidResponse { - message: format!("Failed to parse daemon response: {e}"), + match serde_json::from_slice::(&buf) { + Ok(_) => DaemonError::InvalidResponse { + message: format!("Daemon returned an incompatible response: {e}"), + }, + Err(raw_error) => DaemonError::MalformedResponse { + message: format!("Daemon returned a malformed response: {raw_error}"), + }, } }) } pub struct DaemonClient<'a> { config: &'a Config, + surface: ClientSurface, } impl<'a> DaemonClient<'a> { pub fn new(config: &'a Config) -> Self { - Self { config } + Self { + config, + surface: ClientSurface::Cli, + } + } + + pub fn for_mcp(config: &'a Config) -> Self { + Self { + config, + surface: ClientSurface::Mcp, + } } async fn request(&self, request: DaemonRequest) -> Result { - let is_health = matches!(request, DaemonRequest::Health { .. }); let is_mutating = is_mutating_app_request(&request); send_request(self.config, &request) .await @@ -626,18 +661,22 @@ impl<'a> DaemonClient<'a> { DaemonError::Unavailable { .. } => ServiceError::DaemonUnavailable(format!( "{error}. Start it with `flicknote sync start`." )), - DaemonError::IncompleteResponse { .. } if is_health => { + DaemonError::IncompleteResponse { .. } + | DaemonError::MalformedResponse { .. } + | DaemonError::PostConnectTransport { .. } + if !is_mutating => + { ServiceError::DaemonUnavailable(format!( "Sync daemon is not ready: {error}. Start it with `flicknote sync start`." )) } - DaemonError::IncompleteResponse { message } if is_mutating => { - ServiceError::Remote { - code: "daemon_request_outcome_unknown".to_string(), - message, - retryable: false, - details: None, - } + DaemonError::IncompleteResponse { message } + | DaemonError::MalformedResponse { message } + | DaemonError::PostConnectTransport { message } + | DaemonError::InvalidResponse { message } + if is_mutating => + { + Self::outcome_unknown(message) } DaemonError::InvalidResponse { .. } => Self::protocol_mismatch(), other => ServiceError::Daemon(other.to_string()), @@ -661,6 +700,7 @@ impl<'a> DaemonClient<'a> { match self .request(DaemonRequest::App { protocol: PROTOCOL_VERSION, + surface: self.surface, request: Box::new(request), }) .await? @@ -672,7 +712,17 @@ impl<'a> DaemonClient<'a> { } pub async fn call(&self, request: AppRequest) -> Result { - T::from_response(self.app(request).await?) + let may_write = request.may_write(); + let response = self.app(request).await?; + T::from_response(response).ok_or_else(|| { + if may_write { + Self::outcome_unknown( + "The daemon returned an unexpected response after a mutating request; the operation outcome is unknown.".to_string(), + ) + } else { + Self::protocol_mismatch() + } + }) } fn remote_error(error: WireError) -> ServiceError { @@ -692,6 +742,15 @@ impl<'a> DaemonClient<'a> { details: None, } } + + fn outcome_unknown(message: String) -> ServiceError { + ServiceError::Remote { + code: "daemon_request_outcome_unknown".to_string(), + message, + retryable: false, + details: None, + } + } } pub async fn read_request(stream: &mut UnixStream) -> Result { @@ -759,10 +818,22 @@ async fn serve_app_stream( DaemonRequest::Health { protocol } if protocol == PROTOCOL_VERSION => { DaemonResponse::ServerInfo(info.clone()) } - DaemonRequest::App { protocol, request } if protocol == PROTOCOL_VERSION => { - match app.handle(*request).await { - Ok(response) => DaemonResponse::App(Box::new(response)), - Err(error) => DaemonResponse::AppError(error), + DaemonRequest::App { + protocol, + surface, + request, + } if protocol == PROTOCOL_VERSION => { + if surface == ClientSurface::Mcp && !info.backend.supports(Capability::Mcp) { + DaemonResponse::AppError(WireError::from_service(unsupported_capability( + info.backend, + Capability::Mcp, + "mcp", + ))) + } else { + match app.handle(*request).await { + Ok(response) => DaemonResponse::App(Box::new(response)), + Err(error) => DaemonResponse::AppError(error), + } } } DaemonRequest::Health { protocol } | DaemonRequest::App { protocol, .. } => { @@ -877,6 +948,7 @@ mod tests { let request = DaemonRequest::App { protocol: PROTOCOL_VERSION, + surface: ClientSurface::Cli, request: Box::new(AppRequest::NoteList(NoteListInput { note_type: None, project: None, @@ -887,6 +959,7 @@ mod tests { let value = serde_json::to_value(request).unwrap(); assert_eq!(value["type"], "app"); assert_eq!(value["payload"]["protocol"], PROTOCOL_VERSION); + assert_eq!(value["payload"]["surface"], "cli"); assert_eq!(value["payload"]["request"]["type"], "note_list"); } @@ -967,6 +1040,7 @@ mod tests { fn mutating_application_requests_do_not_have_an_automatic_response_timeout() { let request = DaemonRequest::App { protocol: PROTOCOL_VERSION, + surface: ClientSurface::Cli, request: Box::new(AppRequest::NoteArchive { id: "note-1".to_string(), }), @@ -1087,6 +1161,77 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn malformed_transport_responses_are_classified_by_mutation_safety() { + for (request, expected_code, retryable) in [ + ( + AppRequest::NoteArchive { + id: "note-1".to_string(), + }, + "daemon_request_outcome_unknown", + false, + ), + ( + AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + }), + "daemon_unavailable", + true, + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + stream.write_all(b"not-json").await.unwrap(); + stream.shutdown().await.unwrap(); + }); + + let error = DaemonClient::new(&config).app(request).await.unwrap_err(); + + assert_eq!(error.code(), expected_code); + assert_eq!(error.retryable(), retryable); + server.await.unwrap(); + } + } + + #[tokio::test] + async fn unexpected_typed_responses_are_classified_by_mutation_safety() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = + serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let error = DaemonClient::new(&config) + .call::(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap_err(); + assert_eq!(error.code(), "daemon_protocol_mismatch"); + server.await.unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = + serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let error = DaemonClient::new(&config) + .call::(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + server.await.unwrap(); + } + #[tokio::test] async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 96f68e8..a1f8064 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -1838,6 +1838,16 @@ pub async fn run() -> Result<(), Box> { log::error!("Startup WAL checkpoint task panicked: {e}"); } + // Finish schema replacement through the application pool before PowerSync + // starts its download/upload actors. Replacing tracking views after connect + // races the actor-held SQLite connections and can fail with SQLITE_BUSY on + // an existing database. + let user_id = flicknote_core::session::get_user_id(&config)?; + let backend: Arc = Arc::new(SqliteBackend { + db: Database::open_local(&config).await?, + user_id, + }); + log::info!("Sync daemon connecting (pid {})", std::process::id()); db.connect(SyncOptions::new(connector)).await; log::info!("Sync daemon connected (pid {})", std::process::id()); @@ -1921,11 +1931,6 @@ pub async fn run() -> Result<(), Box> { } }); - let user_id = flicknote_core::session::get_user_id(&config)?; - let backend: Arc = Arc::new(SqliteBackend { - db: Database::open_local(&config).await?, - user_id, - }); let socket_config = Arc::clone(&config); let socket_http = reqwest::Client::new(); let socket_share_lock = Arc::new(ShareRequestLock::default()); diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 1602269..689a494 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -9,7 +9,7 @@ use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{CreateNote, CreatedNote, NoteCreator}; use flicknote_sync::app::Application; use flicknote_sync::ipc::{ - AppRequest, AppResponse, BackendMode, DaemonClient, ServerInfo, serve_app_once, + AppRequest, AppResponse, BackendMode, DaemonClient, ServerInfo, serve_app_once, socket_path, }; fn test_config(directory: &std::path::Path) -> Config { @@ -36,6 +36,34 @@ fn application_is_safe_to_share_between_daemon_request_tasks() { assert_send_sync::(); } +#[tokio::test] +async fn mcp_surface_is_enforced_on_every_daemon_request() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let backend = Arc::new(SqliteBackend { + db: Database::open_local(&config).await.unwrap(), + user_id: "user-1".to_string(), + }); + let app = Arc::new(Application::new(backend, BackendMode::Managed)); + let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::managed())); + + let error = DaemonClient::for_mcp(&config) + .call::>(AppRequest::NoteList( + NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + }, + )) + .await + .unwrap_err(); + + assert_eq!(error.code(), "unsupported_capability"); + server.await.unwrap().unwrap(); +} + #[tokio::test] async fn application_signals_every_may_write_request_even_when_it_fails() { let directory = tempfile::tempdir().unwrap(); From 78ba058eb322904d5ae9ed8f226fc8ca8f340a69 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 21:06:50 +0800 Subject: [PATCH 11/16] refactor(core): remove retired keyterm domain --- ...a4ab032805fbbede24f294f9a6e806d43ce79.json | 18 -- ...cdf40ccead74ee22c3d8fd4b1a43bc9375d2e.json | 21 -- ...a7c69b53dfa43002c63f01784c533186b9567.json | 16 ++ ...66e9142edb76a8f61f866e36d8d18787f5115.json | 12 + ...3edfe6b1dd9bea60442252753c4b566553701.json | 12 - ...fc2dfaf53816d8063fe2dc90ae6c604b2129f.json | 12 - flicknote-cli/src/commands/keyterm.rs | 166 -------------- flicknote-cli/src/commands/mod.rs | 1 - flicknote-cli/src/commands/project.rs | 23 +- flicknote-cli/src/help/keyterm.md | 8 - flicknote-cli/src/help/project.md | 1 - flicknote-cli/src/main.rs | 21 -- flicknote-cli/src/mcp/server.rs | 4 +- flicknote-core/src/backend.rs | 206 +----------------- flicknote-core/src/pgwire/mod.rs | 181 +-------------- flicknote-core/src/schema.rs | 23 -- flicknote-core/src/services/dto.rs | 9 - .../src/services/editable_document.rs | 40 +--- flicknote-core/src/services/project.rs | 65 +----- flicknote-core/src/types.rs | 12 - flicknote-sync/src/app.rs | 75 ------- flicknote-sync/src/ipc.rs | 26 +-- flicknote-sync/src/lib.rs | 89 ++++++++ flicknote-sync/tests/app_contract.rs | 30 +-- scripts/sqlx-sqlite-schema.sql | 11 - skills/flicknote.md | 1 - 26 files changed, 150 insertions(+), 933 deletions(-) delete mode 100644 .sqlx/query-2e96d379b4a133b10492b9f3dc9a4ab032805fbbede24f294f9a6e806d43ce79.json delete mode 100644 .sqlx/query-43926e656a397dd279d6982a2a4cdf40ccead74ee22c3d8fd4b1a43bc9375d2e.json create mode 100644 .sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json create mode 100644 .sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json delete mode 100644 .sqlx/query-90bc742216766bda020d7264c7a3edfe6b1dd9bea60442252753c4b566553701.json delete mode 100644 .sqlx/query-f13cbea4a3af8a290186dce46e0fc2dfaf53816d8063fe2dc90ae6c604b2129f.json delete mode 100644 flicknote-cli/src/commands/keyterm.rs delete mode 100644 flicknote-cli/src/help/keyterm.md diff --git a/.sqlx/query-2e96d379b4a133b10492b9f3dc9a4ab032805fbbede24f294f9a6e806d43ce79.json b/.sqlx/query-2e96d379b4a133b10492b9f3dc9a4ab032805fbbede24f294f9a6e806d43ce79.json deleted file mode 100644 index f4a5be0..0000000 --- a/.sqlx/query-2e96d379b4a133b10492b9f3dc9a4ab032805fbbede24f294f9a6e806d43ce79.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE projects SET\n keyterm_id = CASE WHEN $2::bool THEN $3::uuid ELSE keyterm_id END,\n color = CASE WHEN $4::bool THEN $5::text ELSE color END\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Uuid", - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "2e96d379b4a133b10492b9f3dc9a4ab032805fbbede24f294f9a6e806d43ce79" -} diff --git a/.sqlx/query-43926e656a397dd279d6982a2a4cdf40ccead74ee22c3d8fd4b1a43bc9375d2e.json b/.sqlx/query-43926e656a397dd279d6982a2a4cdf40ccead74ee22c3d8fd4b1a43bc9375d2e.json deleted file mode 100644 index 99b1268..0000000 --- a/.sqlx/query-43926e656a397dd279d6982a2a4cdf40ccead74ee22c3d8fd4b1a43bc9375d2e.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE keyterms SET\n name = CASE WHEN $2::bool THEN $3::text ELSE name END,\n description = CASE WHEN $4::bool THEN $5::text ELSE description END,\n content = CASE WHEN $6::bool THEN $7::text ELSE content END,\n updated_at = CASE WHEN ($2::bool OR $4::bool OR $6::bool) THEN $8::timestamptz ELSE updated_at END\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text", - "Bool", - "Text", - "Bool", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "43926e656a397dd279d6982a2a4cdf40ccead74ee22c3d8fd4b1a43bc9375d2e" -} diff --git a/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json b/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json new file mode 100644 index 0000000..97b5933 --- /dev/null +++ b/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE projects SET\n color = CASE WHEN $2::bool THEN $3::text ELSE color END\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567" +} diff --git a/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json b/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json new file mode 100644 index 0000000..cf23926 --- /dev/null +++ b/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE projects SET\n color = CASE WHEN ? THEN ? ELSE color END\n WHERE user_id = ? AND id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115" +} diff --git a/.sqlx/query-90bc742216766bda020d7264c7a3edfe6b1dd9bea60442252753c4b566553701.json b/.sqlx/query-90bc742216766bda020d7264c7a3edfe6b1dd9bea60442252753c4b566553701.json deleted file mode 100644 index 5dc437e..0000000 --- a/.sqlx/query-90bc742216766bda020d7264c7a3edfe6b1dd9bea60442252753c4b566553701.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE keyterms SET\n name = CASE WHEN ? THEN ? ELSE name END,\n description = CASE WHEN ? THEN ? ELSE description END,\n content = CASE WHEN ? THEN ? ELSE content END,\n updated_at = CASE WHEN (? OR ? OR ?) THEN ? ELSE updated_at END\n WHERE user_id = ? AND id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "90bc742216766bda020d7264c7a3edfe6b1dd9bea60442252753c4b566553701" -} diff --git a/.sqlx/query-f13cbea4a3af8a290186dce46e0fc2dfaf53816d8063fe2dc90ae6c604b2129f.json b/.sqlx/query-f13cbea4a3af8a290186dce46e0fc2dfaf53816d8063fe2dc90ae6c604b2129f.json deleted file mode 100644 index 37a1635..0000000 --- a/.sqlx/query-f13cbea4a3af8a290186dce46e0fc2dfaf53816d8063fe2dc90ae6c604b2129f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE projects SET\n keyterm_id = CASE WHEN ? THEN ? ELSE keyterm_id END,\n color = CASE WHEN ? THEN ? ELSE color END\n WHERE user_id = ? AND id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "f13cbea4a3af8a290186dce46e0fc2dfaf53816d8063fe2dc90ae6c604b2129f" -} diff --git a/flicknote-cli/src/commands/keyterm.rs b/flicknote-cli/src/commands/keyterm.rs deleted file mode 100644 index 4983548..0000000 --- a/flicknote-cli/src/commands/keyterm.rs +++ /dev/null @@ -1,166 +0,0 @@ -use clap::{Args, Subcommand}; -use flicknote_core::error::CliError; -use flicknote_core::types::Keyterm; -use flicknote_sync::ipc::{AppRequest, DaemonClient}; - -const KEYTERM_HELP: &str = include_str!("../help/keyterm.md"); - -#[derive(Args)] -#[command(after_help = KEYTERM_HELP)] -pub(crate) struct KeytermArgs { - #[command(subcommand)] - command: KeytermCommands, -} - -#[derive(Subcommand)] -enum KeytermCommands { - /// Create a new keyterm set - Add(AddKeytermArgs), - /// List all keyterm sets - List, - /// Show keyterm set details - Detail(DetailKeytermArgs), - /// Modify a keyterm set - Modify(ModifyKeytermArgs), - /// Delete a keyterm set - Delete(DeleteKeytermArgs), -} - -#[derive(Args)] -struct AddKeytermArgs { - /// Keyterm name - #[arg(long)] - name: String, - /// Keyterm content - #[arg(long)] - content: Option, - /// Optional description - #[arg(long)] - description: Option, -} - -#[derive(Args)] -struct DetailKeytermArgs { - /// Keyterm ID (full UUID) - id: String, -} - -#[derive(Args)] -struct ModifyKeytermArgs { - /// Keyterm ID (full UUID) - id: String, - /// New name - #[arg(long)] - name: Option, - /// New content - #[arg(long)] - content: Option, - /// New description - #[arg(long)] - description: Option, -} - -#[derive(Args)] -struct DeleteKeytermArgs { - /// Keyterm ID (full UUID) - id: String, -} - -pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &KeytermArgs) -> Result<(), CliError> { - match &args.command { - KeytermCommands::Add(a) => add(daemon, a).await, - KeytermCommands::List => list(daemon).await, - KeytermCommands::Detail(a) => detail(daemon, a).await, - KeytermCommands::Modify(a) => modify(daemon, a).await, - KeytermCommands::Delete(a) => delete(daemon, a).await, - } -} - -async fn add(daemon: &DaemonClient<'_>, args: &AddKeytermArgs) -> Result<(), CliError> { - let keyterm: Keyterm = daemon - .call(AppRequest::KeytermAdd { - name: args.name.clone(), - description: args.description.clone(), - content: args.content.clone(), - }) - .await?; - println!("Created keyterm \"{}\" ({}).", keyterm.name, keyterm.id); - Ok(()) -} - -async fn list(daemon: &DaemonClient<'_>) -> Result<(), CliError> { - let keyterms: Vec = daemon.call(AppRequest::KeytermList).await?; - if keyterms.is_empty() { - println!("No keyterms found."); - return Ok(()); - } - println!("{:<36} {:<30} Name", "ID", "Updated"); - println!("{}", "-".repeat(76)); - for k in &keyterms { - let date = k - .updated_at - .as_deref() - .or(k.created_at.as_deref()) - .and_then(|d| d.get(..10)) - .unwrap_or("-"); - println!("{:<36} {:<30} {}", k.id, date, k.name); - } - Ok(()) -} - -async fn detail(daemon: &DaemonClient<'_>, args: &DetailKeytermArgs) -> Result<(), CliError> { - let keyterm: Keyterm = daemon - .call(AppRequest::KeytermGet { - id: args.id.clone(), - }) - .await?; - - println!("ID: {}", keyterm.id); - println!("Name: {}", keyterm.name); - if let Some(ref desc) = keyterm.description { - println!("Description: {desc}"); - } - println!( - "Created: {}", - keyterm - .created_at - .as_deref() - .and_then(|d| d.get(..10)) - .unwrap_or("-") - ); - println!( - "Updated: {}", - keyterm - .updated_at - .as_deref() - .and_then(|d| d.get(..10)) - .unwrap_or("-") - ); - if let Some(ref content) = keyterm.content { - println!("\nContent:\n{content}"); - } - Ok(()) -} - -async fn modify(daemon: &DaemonClient<'_>, args: &ModifyKeytermArgs) -> Result<(), CliError> { - let keyterm: Keyterm = daemon - .call(AppRequest::KeytermModify { - id: args.id.clone(), - name: args.name.clone(), - description: args.description.clone(), - content: args.content.clone(), - }) - .await?; - println!("Updated keyterm {}.", keyterm.id); - Ok(()) -} - -async fn delete(daemon: &DaemonClient<'_>, args: &DeleteKeytermArgs) -> Result<(), CliError> { - let id: String = daemon - .call(AppRequest::KeytermDelete { - id: args.id.clone(), - }) - .await?; - println!("Deleted keyterm {}.", id); - Ok(()) -} diff --git a/flicknote-cli/src/commands/mod.rs b/flicknote-cli/src/commands/mod.rs index 4aa0f78..d26c005 100644 --- a/flicknote-cli/src/commands/mod.rs +++ b/flicknote-cli/src/commands/mod.rs @@ -11,7 +11,6 @@ pub(crate) mod find; pub(crate) mod gateway; pub(crate) mod import; pub(crate) mod insert; -pub(crate) mod keyterm; pub(crate) mod list; pub(crate) mod login; pub(crate) mod logout; diff --git a/flicknote-cli/src/commands/project.rs b/flicknote-cli/src/commands/project.rs index c89984f..16f5767 100644 --- a/flicknote-cli/src/commands/project.rs +++ b/flicknote-cli/src/commands/project.rs @@ -1,7 +1,7 @@ use clap::{Args, Subcommand}; use flicknote_core::error::CliError; use flicknote_core::services::dto::{Patch, ProjectAddInput, ProjectDto, ProjectModifyInput}; -use flicknote_core::types::{Keyterm, Project}; +use flicknote_core::types::Project; use flicknote_sync::ipc::{AppRequest, DaemonClient}; const PROJECT_HELP: &str = include_str!("../help/project.md"); @@ -35,9 +35,6 @@ enum ProjectCommands { struct AddProjectArgs { /// Project name name: String, - /// Associate a keyterm set by ID - #[arg(long)] - keyterm: Option, /// Color hex code (e.g. #FF5733) #[arg(long)] color: Option, @@ -69,9 +66,6 @@ struct ShareProjectArgs { struct ModifyProjectArgs { /// Project ID (full UUID) id: String, - /// Associate a keyterm set by ID (use "none" to clear) - #[arg(long)] - keyterm: Option, /// Color hex code (use "none" to clear) #[arg(long)] color: Option, @@ -99,7 +93,6 @@ async fn add(daemon: &DaemonClient<'_>, args: &AddProjectArgs) -> Result<(), Cli let project: ProjectDto = daemon .call(AppRequest::ProjectAdd(ProjectAddInput { name: args.name.clone(), - keyterm: args.keyterm.clone(), color: args.color.clone(), })) .await?; @@ -164,19 +157,6 @@ async fn detail(daemon: &DaemonClient<'_>, args: &DetailArgs) -> Result<(), CliE if let Some(ref color) = project.color { println!("Color: {color}"); } - if let Some(ref keyterm_id) = project.keyterm_id { - match daemon - .call::(AppRequest::KeytermGet { - id: keyterm_id.clone(), - }) - .await - { - Ok(keyterm) => println!("Keyterm: {} ({keyterm_id})", keyterm.name), - Err(error) => { - eprintln!("warning: could not look up keyterm {keyterm_id} ({error})") - } - } - } let status = if project.archived { "archived" } else { @@ -204,7 +184,6 @@ async fn modify(daemon: &DaemonClient<'_>, args: &ModifyProjectArgs) -> Result<( let project: ProjectDto = daemon .call(AppRequest::ProjectModify(ProjectModifyInput { id: args.id.clone(), - keyterm: patch(&args.keyterm), color: patch(&args.color), })) .await?; diff --git a/flicknote-cli/src/help/keyterm.md b/flicknote-cli/src/help/keyterm.md deleted file mode 100644 index ad13b45..0000000 --- a/flicknote-cli/src/help/keyterm.md +++ /dev/null @@ -1,8 +0,0 @@ -Examples: - flicknote keyterm add --name "My Terms" --content "term1: definition" - flicknote keyterm list - flicknote keyterm detail - flicknote keyterm modify --content "updated content" - flicknote keyterm delete - -Keyterm sets can be associated with projects using `flicknote project modify`. diff --git a/flicknote-cli/src/help/project.md b/flicknote-cli/src/help/project.md index c087cab..0031c5d 100644 --- a/flicknote-cli/src/help/project.md +++ b/flicknote-cli/src/help/project.md @@ -4,7 +4,6 @@ Examples: flicknote project detail flicknote project share flicknote project unshare - flicknote project modify --keyterm flicknote project modify --color "#FF5733" flicknote project delete diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index db1750d..a8e4fdb 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -63,8 +63,6 @@ enum Commands { Unshare(commands::share::UnshareArgs), /// Manage projects Project(commands::project::ProjectArgs), - /// Manage keyterm sets - Keyterm(commands::keyterm::KeytermArgs), /// Authenticate with FlickNote Login(commands::login::LoginArgs), /// Log out — remove saved session @@ -171,7 +169,6 @@ async fn dispatch( Commands::Share(args) => commands::share::run_note(daemon, args).await, Commands::Unshare(args) => commands::share::run_unshare_note(daemon, args).await, Commands::Project(args) => commands::project::run(daemon, args).await, - Commands::Keyterm(args) => commands::keyterm::run(daemon, args).await, Commands::Rename(args) => commands::rename::run(daemon, args).await, Commands::Insert(args) => commands::insert::run(daemon, args).await, Commands::Replace(args) => commands::replace::run(daemon, args).await, @@ -397,18 +394,6 @@ mod tests { tool["name"] ); } - for tool in tools.iter().filter(|tool| { - tool["name"] - .as_str() - .is_some_and(|name| name.starts_with("project_")) - }) { - assert!( - !tool["inputSchema"].to_string().contains("keyterm") - && !tool["outputSchema"].to_string().contains("keyterm"), - "{} must not expose keyterm functionality", - tool["name"] - ); - } let project_get_schema = tools .iter() .find(|tool| tool["name"] == "project_get") @@ -557,12 +542,6 @@ mod tests { .get("id") .is_none() ); - assert!( - projects["result"]["structuredContent"][0] - .get("keyterm_id") - .is_none() - ); - let project = call_mcp_tool( &mut client_write, &mut client_read, diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index a979548..af20855 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -4,7 +4,7 @@ use flicknote_core::config::Config; use flicknote_core::error::CliError; use flicknote_core::services::dto::{ NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, NoteListInput, - NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, Patch, + NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, }; use flicknote_core::services::error::ServiceError; @@ -499,7 +499,6 @@ impl FlickNoteMcp { structured( self.call::(AppRequest::ProjectAdd(ProjectAddInput { name: params.name, - keyterm: None, color: params.color, })) .await @@ -522,7 +521,6 @@ impl FlickNoteMcp { structured( self.call::(AppRequest::ProjectModify(ProjectModifyInput { id: project_id, - keyterm: Patch::Missing, color: params.color, })) .await diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index 1adeae4..f5f6b44 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -7,7 +7,7 @@ use crate::TOPIC_EXTRACTION_KEY; #[cfg(feature = "powersync")] use crate::db::Database; use crate::error::CliError; -use crate::types::{Keyterm, Note, Project}; +use crate::types::{Note, Project}; // ─── Filter / request types ────────────────────────────────────────────────── @@ -158,13 +158,8 @@ pub trait NoteDb: Send + Sync { old_project_id: Option<&str>, ) -> Result, CliError>; - /// Update project metadata. `None` = don't change, `Some(None)` = clear, `Some(Some(v))` = set. - async fn update_project( - &self, - id: &str, - keyterm_id: Option>, - color: Option>, - ) -> Result<(), CliError>; + /// Update project color. `None` = don't change, `Some(None)` = clear, `Some(Some(v))` = set. + async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError>; /// Delete (archive) a project by ID. Returns `ProjectNotFound` if no such project exists. async fn delete_project(&self, id: &str) -> Result<(), CliError>; @@ -203,27 +198,6 @@ pub trait NoteDb: Send + Sync { extraction_key: &str, values: &[String], ) -> Result<(), CliError>; - - // Keyterm operations - async fn resolve_keyterm_id(&self, prefix: &str) -> Result; - async fn insert_keyterm( - &self, - id: &str, - name: &str, - description: Option<&str>, - content: Option<&str>, - now: &str, - ) -> Result; - async fn find_keyterm(&self, id: &str) -> Result; - async fn list_keyterms(&self) -> Result, CliError>; - async fn update_keyterm( - &self, - id: &str, - name: Option<&str>, - description: Option<&str>, - content: Option<&str>, - ) -> Result<(), CliError>; - async fn delete_keyterm(&self, id: &str) -> Result<(), CliError>; } // ─── SqliteBackend ─────────────────────────────────────────────────────────── @@ -284,10 +258,10 @@ const SQ_FIND_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND nam #[cfg(feature = "powersync")] const SQ_FIND_PROJECT_NAME: &str = "SELECT name FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; #[cfg(feature = "powersync")] -const SQ_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at FROM projects \ +const SQ_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ WHERE user_id = ? AND (is_archived = 0 OR is_archived IS NULL) ORDER BY name"; #[cfg(feature = "powersync")] -const SQ_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at FROM projects \ +const SQ_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ WHERE user_id = ? AND is_archived = 1 ORDER BY name"; #[cfg(feature = "powersync")] const SQ_CREATE_PROJECT: &str = @@ -330,22 +304,12 @@ const SQ_INSERT_EXTRACTION: &str = "INSERT INTO note_extractions (id, note_id, user_id, key, value) VALUES (?, ?, ?, ?, ?)"; #[cfg(feature = "powersync")] -const SQ_FIND_PROJECT_BY_ID: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; +const SQ_FIND_PROJECT_BY_ID: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; #[cfg(feature = "powersync")] const SQ_RESOLVE_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; #[cfg(feature = "powersync")] const SQ_ARCHIVE_PROJECT: &str = "UPDATE projects SET is_archived = 1 WHERE user_id = ? AND id = ?"; #[cfg(feature = "powersync")] -const SQ_RESOLVE_KEYTERM: &str = "SELECT id FROM keyterms WHERE user_id = ? AND id = ? LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_INSERT_KEYTERM: &str = "INSERT INTO keyterms (id, user_id, name, description, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; -#[cfg(feature = "powersync")] -const SQ_FIND_KEYTERM: &str = "SELECT id, user_id, name, description, content, created_at, updated_at FROM keyterms WHERE user_id = ? AND id = ? LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_LIST_KEYTERMS: &str = "SELECT id, user_id, name, description, content, created_at, updated_at FROM keyterms WHERE user_id = ? ORDER BY name"; -#[cfg(feature = "powersync")] -const SQ_DELETE_KEYTERM: &str = "DELETE FROM keyterms WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] async fn resolve_sqlite_uuid_id( pool: &SqlitePool, sql: &str, @@ -897,29 +861,19 @@ impl NoteDb for SqliteBackend { .await } - async fn update_project( - &self, - id: &str, - keyterm_id: Option>, - color: Option>, - ) -> Result<(), CliError> { - let update_keyterm = keyterm_id.is_some(); + async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError> { let update_color = color.is_some(); - if !(update_keyterm || update_color) { + if !update_color { return Ok(()); } - let keyterm_value = keyterm_id.flatten(); let color_value = color.flatten(); sqlx::query!( r#" UPDATE projects SET - keyterm_id = CASE WHEN ? THEN ? ELSE keyterm_id END, color = CASE WHEN ? THEN ? ELSE color END WHERE user_id = ? AND id = ? "#, - update_keyterm, - keyterm_value, update_color, color_value, self.user_id, @@ -1098,113 +1052,6 @@ impl NoteDb for SqliteBackend { } Ok(()) } - - async fn resolve_keyterm_id(&self, prefix: &str) -> Result { - resolve_sqlite_uuid_id( - &self.db.pool, - SQ_RESOLVE_KEYTERM, - &self.user_id, - prefix, - || CliError::Other(format!("Keyterm not found: {prefix}")), - ) - .await - } - - async fn insert_keyterm( - &self, - id: &str, - name: &str, - description: Option<&str>, - content: Option<&str>, - now: &str, - ) -> Result { - sqlx::query(SQ_INSERT_KEYTERM) - .bind(id) - .bind(&self.user_id) - .bind(name) - .bind(description) - .bind(content) - .bind(now) - .bind(now) - .execute(&self.db.pool) - .await?; - Ok(Keyterm { - id: id.to_string(), - user_id: self.user_id.clone(), - name: name.to_string(), - description: description.map(str::to_string), - content: content.map(str::to_string), - created_at: Some(now.to_string()), - updated_at: Some(now.to_string()), - }) - } - - async fn find_keyterm(&self, id: &str) -> Result { - sqlx::query_as::<_, Keyterm>(SQ_FIND_KEYTERM) - .bind(&self.user_id) - .bind(id) - .fetch_optional(&self.db.pool) - .await? - .ok_or_else(|| CliError::Other(format!("Keyterm not found: {id}"))) - } - - async fn list_keyterms(&self) -> Result, CliError> { - Ok(sqlx::query_as::<_, Keyterm>(SQ_LIST_KEYTERMS) - .bind(&self.user_id) - .fetch_all(&self.db.pool) - .await?) - } - - async fn update_keyterm( - &self, - id: &str, - name: Option<&str>, - description: Option<&str>, - content: Option<&str>, - ) -> Result<(), CliError> { - let now = chrono::Utc::now().to_rfc3339(); - let update_name = name.is_some(); - let update_description = description.is_some(); - let update_content = content.is_some(); - if !(update_name || update_description || update_content) { - return Ok(()); - } - - sqlx::query!( - r#" - UPDATE keyterms SET - name = CASE WHEN ? THEN ? ELSE name END, - description = CASE WHEN ? THEN ? ELSE description END, - content = CASE WHEN ? THEN ? ELSE content END, - updated_at = CASE WHEN (? OR ? OR ?) THEN ? ELSE updated_at END - WHERE user_id = ? AND id = ? - "#, - update_name, - name, - update_description, - description, - update_content, - content, - update_name, - update_description, - update_content, - now, - self.user_id, - id, - ) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn delete_keyterm(&self, id: &str) -> Result<(), CliError> { - sqlx::query(SQ_DELETE_KEYTERM) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - Ok(()) - } } // ─── Tests ─────────────────────────────────────────────────────────────────── @@ -2029,53 +1876,18 @@ mod tests { } #[tokio::test] - async fn test_project_and_keyterm_resolvers_reject_uuid_prefixes() { + async fn test_project_resolver_rejects_uuid_prefixes() { let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); let project_id = backend.create_project("Exact Project").await.unwrap(); - let keyterm_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_keyterm(&keyterm_id, "Keyterm", None, None, &now) - .await - .unwrap(); assert_eq!( backend.resolve_project_id(&project_id).await.unwrap(), project_id ); - assert_eq!( - backend.resolve_keyterm_id(&keyterm_id).await.unwrap(), - keyterm_id - ); let project_prefix = &project_id[..8]; - let keyterm_prefix = &keyterm_id[..8]; assert!(backend.resolve_project_id(project_prefix).await.is_err()); - assert!(backend.resolve_keyterm_id(keyterm_prefix).await.is_err()); - } - - #[tokio::test] - async fn insert_keyterm_returns_the_committed_record() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let keyterm_id = uuid::Uuid::new_v4().to_string(); - - let inserted = backend - .insert_keyterm( - &keyterm_id, - "Rust", - Some("Language"), - Some("ownership"), - &now, - ) - .await - .unwrap(); - - assert_eq!(inserted.id, keyterm_id); - assert_eq!(inserted.name, "Rust"); - assert_eq!(inserted.description.as_deref(), Some("Language")); - assert_eq!(inserted.content.as_deref(), Some("ownership")); } } diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs index 4597851..404e0f1 100644 --- a/flicknote-core/src/pgwire/mod.rs +++ b/flicknote-core/src/pgwire/mod.rs @@ -15,7 +15,7 @@ use crate::backend::{ NoteSearch, }; use crate::error::CliError; -use crate::types::{Keyterm, Note, Project}; +use crate::types::{Note, Project}; const PG_FIND_NOTE: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ project_id, metadata, source, created_at, updated_at, deleted_at \ @@ -23,17 +23,12 @@ const PG_FIND_NOTE: &str = "SELECT id, short_id, user_id, type, status, title, c const PG_FIND_ARCHIVED_NOTE: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ project_id, metadata, source, created_at, updated_at, deleted_at \ FROM notes WHERE id = $1 AND deleted_at IS NOT NULL LIMIT 1"; -const PG_FIND_PROJECT: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at \ +const PG_FIND_PROJECT: &str = "SELECT id, user_id, name, color, is_archived, created_at \ FROM projects WHERE id = $1 LIMIT 1"; -const PG_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at \ +const PG_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, is_archived, created_at \ FROM projects WHERE COALESCE(is_archived, false) = false ORDER BY name"; -const PG_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, keyterm_id, is_archived, created_at \ +const PG_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, is_archived, created_at \ FROM projects WHERE COALESCE(is_archived, false) = true ORDER BY name"; -const PG_FIND_KEYTERM: &str = "SELECT id, user_id, name, description, content, created_at, updated_at \ - FROM keyterms WHERE id = $1 LIMIT 1"; -const PG_LIST_KEYTERMS: &str = "SELECT id, user_id, name, description, content, created_at, updated_at \ - FROM keyterms ORDER BY name"; - #[derive(sqlx::FromRow)] struct NotePgRow { pub id: Uuid, @@ -60,22 +55,10 @@ struct ProjectPgRow { pub user_id: Uuid, pub name: String, pub color: Option, - pub keyterm_id: Option, pub is_archived: Option, pub created_at: Option>, } -#[derive(sqlx::FromRow)] -struct KeytermPgRow { - pub id: Uuid, - pub user_id: Uuid, - pub name: String, - pub description: Option, - pub content: Option, - pub created_at: Option>, - pub updated_at: Option>, -} - impl From for Note { fn from(r: NotePgRow) -> Self { Self { @@ -105,27 +88,12 @@ impl From for Project { user_id: r.user_id.to_string(), name: r.name, color: r.color, - keyterm_id: r.keyterm_id.map(|u| u.to_string()), is_archived: r.is_archived.map(|b| if b { 1 } else { 0 }), created_at: r.created_at.map(|t| t.to_rfc3339()), } } } -impl From for Keyterm { - fn from(r: KeytermPgRow) -> Self { - Self { - id: r.id.to_string(), - user_id: r.user_id.to_string(), - name: r.name, - description: r.description, - content: r.content, - created_at: r.created_at.map(|t| t.to_rfc3339()), - updated_at: r.updated_at.map(|t| t.to_rfc3339()), - } - } -} - fn parse_uuid(s: &str) -> Result { Uuid::parse_str(s).map_err(|e| CliError::Database(format!("invalid UUID {s:?}: {e}"))) } @@ -684,31 +652,21 @@ impl NoteDb for PgWireBackend { .await } - async fn update_project( - &self, - id: &str, - keyterm_id: Option>, - color: Option>, - ) -> Result<(), CliError> { - let update_keyterm = keyterm_id.is_some(); + async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError> { let update_color = color.is_some(); - if !(update_keyterm || update_color) { + if !update_color { return Ok(()); } - let keyterm_value = keyterm_id.map(parse_uuid_opt).transpose()?.flatten(); let project_id = parse_uuid(id)?; let color_value = color.flatten(); let result = sqlx::query!( r#" UPDATE projects SET - keyterm_id = CASE WHEN $2::bool THEN $3::uuid ELSE keyterm_id END, - color = CASE WHEN $4::bool THEN $5::text ELSE color END + color = CASE WHEN $2::bool THEN $3::text ELSE color END WHERE id = $1 "#, project_id, - update_keyterm, - keyterm_value, update_color, color_value, ) @@ -872,110 +830,6 @@ impl NoteDb for PgWireBackend { } Ok(()) } - - async fn resolve_keyterm_id(&self, prefix: &str) -> Result { - resolve_pg_uuid_id( - &self.pool, - "SELECT id::text FROM keyterms WHERE id = $1 LIMIT 1", - prefix, - || CliError::Other(format!("Keyterm not found: {prefix}")), - ) - .await - } - - async fn insert_keyterm( - &self, - id: &str, - name: &str, - description: Option<&str>, - content: Option<&str>, - now: &str, - ) -> Result { - let now = parse_iso_utc(now)?; - let row = sqlx::query_as::<_, KeytermPgRow>( - "INSERT INTO keyterms (id, name, description, content, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - RETURNING id, user_id, name, description, content, created_at, updated_at", - ) - .bind(parse_uuid(id)?) - .bind(name) - .bind(description) - .bind(content) - .bind(now) - .bind(now) - .fetch_one(&self.pool) - .await?; - Ok(row.into()) - } - - async fn find_keyterm(&self, id: &str) -> Result { - sqlx::query_as::<_, KeytermPgRow>(PG_FIND_KEYTERM) - .bind(parse_uuid(id)?) - .fetch_optional(&self.pool) - .await? - .map(Keyterm::from) - .ok_or_else(|| CliError::Other(format!("Keyterm not found: {id}"))) - } - - async fn list_keyterms(&self) -> Result, CliError> { - let rows = sqlx::query_as::<_, KeytermPgRow>(PG_LIST_KEYTERMS) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(Keyterm::from).collect()) - } - - async fn update_keyterm( - &self, - id: &str, - name: Option<&str>, - description: Option<&str>, - content: Option<&str>, - ) -> Result<(), CliError> { - let update_name = name.is_some(); - let update_description = description.is_some(); - let update_content = content.is_some(); - if !(update_name || update_description || update_content) { - return Ok(()); - } - - let keyterm_id = parse_uuid(id)?; - let now = Utc::now(); - let result = sqlx::query!( - r#" - UPDATE keyterms SET - name = CASE WHEN $2::bool THEN $3::text ELSE name END, - description = CASE WHEN $4::bool THEN $5::text ELSE description END, - content = CASE WHEN $6::bool THEN $7::text ELSE content END, - updated_at = CASE WHEN ($2::bool OR $4::bool OR $6::bool) THEN $8::timestamptz ELSE updated_at END - WHERE id = $1 - "#, - keyterm_id, - update_name, - name, - update_description, - description, - update_content, - content, - now, - ) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::Other(format!("Keyterm not found: {id}"))); - } - Ok(()) - } - - async fn delete_keyterm(&self, id: &str) -> Result<(), CliError> { - let result = sqlx::query("DELETE FROM keyterms WHERE id = $1") - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::Other(format!("Keyterm not found: {id}"))); - } - Ok(()) - } } #[cfg(test)] @@ -1077,7 +931,6 @@ mod tests { user_id: Uuid::nil(), name: "My Project".into(), color: Some("#ff0000".into()), - keyterm_id: None, is_archived: Some(false), created_at: Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).single(), }; @@ -1087,24 +940,4 @@ mod tests { assert_eq!(project.is_archived, Some(0)); assert!(project.created_at.is_some()); } - - #[test] - fn test_keyterm_pg_row_from() { - use chrono::TimeZone; - let pg_row = KeytermPgRow { - id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440003").unwrap(), - user_id: Uuid::nil(), - name: "TODO".into(), - description: Some("Action items".into()), - content: Some("topics".into()), - created_at: Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).single(), - updated_at: Utc.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).single(), - }; - let k: Keyterm = pg_row.into(); - assert_eq!(k.id, "550e8400-e29b-41d4-a716-446655440003"); - assert_eq!(k.name, "TODO"); - assert!(k.description.is_some()); - assert!(k.created_at.is_some()); - assert!(k.updated_at.is_some()); - } } diff --git a/flicknote-core/src/schema.rs b/flicknote-core/src/schema.rs index 6661b83..cf94df6 100644 --- a/flicknote-core/src/schema.rs +++ b/flicknote-core/src/schema.rs @@ -99,33 +99,10 @@ pub fn app_schema() -> Schema { Column::text("color"), Column::integer("is_archived"), Column::text("created_at"), - Column::text("keyterm_id"), ], |_| {}, )); - schema.tables.push(Table::create( - "keyterms", - vec![ - Column::text("user_id"), - Column::text("name"), - Column::text("description"), - Column::text("content"), - Column::text("created_at"), - Column::text("updated_at"), - ], - |t| { - t.indexes = vec![Index { - name: "keyterms_user".into(), - columns: vec![IndexedColumn { - name: "user_id".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }]; - }, - )); - schema.tables.push(Table::create( "note_extractions", vec![ diff --git a/flicknote-core/src/services/dto.rs b/flicknote-core/src/services/dto.rs index 1c37a39..7d27b05 100644 --- a/flicknote-core/src/services/dto.rs +++ b/flicknote-core/src/services/dto.rs @@ -37,15 +37,12 @@ where pub struct ProjectModifyInput { pub id: String, #[serde(default, skip_serializing_if = "Patch::is_missing")] - pub keyterm: Patch, - #[serde(default, skip_serializing_if = "Patch::is_missing")] pub color: Patch, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ProjectAddInput { pub name: String, - pub keyterm: Option, pub color: Option, } @@ -54,7 +51,6 @@ pub struct ProjectDto { pub id: String, pub name: String, pub color: Option, - pub keyterm_id: Option, pub archived: bool, pub created_at: Option, } @@ -232,25 +228,20 @@ mod tests { fn project_patch_distinguishes_missing_null_and_value() { let missing: ProjectModifyInput = serde_json::from_value(serde_json::json!({ "id": "project-id" })).unwrap(); - assert_eq!(missing.keyterm, Patch::Missing); assert_eq!(missing.color, Patch::Missing); let clear: ProjectModifyInput = serde_json::from_value(serde_json::json!({ "id": "project-id", - "keyterm": null, "color": null })) .unwrap(); - assert_eq!(clear.keyterm, Patch::Null); assert_eq!(clear.color, Patch::Null); let set: ProjectModifyInput = serde_json::from_value(serde_json::json!({ "id": "project-id", - "keyterm": "keyterm-id", "color": "#336699" })) .unwrap(); - assert_eq!(set.keyterm, Patch::Value("keyterm-id".to_string())); assert_eq!(set.color, Patch::Value("#336699".to_string())); } diff --git a/flicknote-core/src/services/editable_document.rs b/flicknote-core/src/services/editable_document.rs index 5d7e819..a66b335 100644 --- a/flicknote-core/src/services/editable_document.rs +++ b/flicknote-core/src/services/editable_document.rs @@ -127,7 +127,7 @@ async fn load_managed_topics(db: &dyn NoteDb, note_id: &str) -> Result>, _color: Option>, ) -> Result<(), CliError> { unimplemented!() @@ -596,42 +595,5 @@ mod tests { ); Ok(()) } - - async fn resolve_keyterm_id(&self, _prefix: &str) -> Result { - unimplemented!() - } - - async fn insert_keyterm( - &self, - _id: &str, - _name: &str, - _description: Option<&str>, - _content: Option<&str>, - _now: &str, - ) -> Result { - unimplemented!() - } - - async fn find_keyterm(&self, _id: &str) -> Result { - unimplemented!() - } - - async fn list_keyterms(&self) -> Result, CliError> { - unimplemented!() - } - - async fn update_keyterm( - &self, - _id: &str, - _name: Option<&str>, - _description: Option<&str>, - _content: Option<&str>, - ) -> Result<(), CliError> { - unimplemented!() - } - - async fn delete_keyterm(&self, _id: &str) -> Result<(), CliError> { - unimplemented!() - } } } diff --git a/flicknote-core/src/services/project.rs b/flicknote-core/src/services/project.rs index 2ded321..c753c10 100644 --- a/flicknote-core/src/services/project.rs +++ b/flicknote-core/src/services/project.rs @@ -45,44 +45,27 @@ impl<'a> ProjectService<'a> { input.name ))); } - let keyterm_id = match input.keyterm.as_deref() { - Some(keyterm) => Some(self.resolve_keyterm_id(keyterm).await?), - None => None, - }; let id = self.db.create_project(&input.name).await?; - if keyterm_id.is_some() || input.color.is_some() { + if input.color.is_some() { self.db - .update_project( - &id, - keyterm_id.as_deref().map(Some), - input.color.as_deref().map(Some), - ) + .update_project(&id, input.color.as_deref().map(Some)) .await?; } Ok(self.db.find_project(&id).await?.into()) } pub async fn modify(&self, input: ProjectModifyInput) -> Result { - if input.keyterm.is_missing() && input.color.is_missing() { + if input.color.is_missing() { return Err(ServiceError::NothingToModify); } let id = self.resolve_project_id(&input.id).await?; - let resolved_keyterm = match input.keyterm { - Patch::Missing => None, - Patch::Null => Some(None), - Patch::Value(keyterm) => Some(Some(self.resolve_keyterm_id(&keyterm).await?)), - }; let color = match input.color { Patch::Missing => None, Patch::Null => Some(None), Patch::Value(color) => Some(Some(color)), }; self.db - .update_project( - &id, - resolved_keyterm.as_ref().map(|value| value.as_deref()), - color.as_ref().map(|value| value.as_deref()), - ) + .update_project(&id, color.as_ref().map(|value| value.as_deref())) .await?; Ok(self.db.find_project(&id).await?.into()) } @@ -122,16 +105,6 @@ impl<'a> ProjectService<'a> { other => ServiceError::from(other), }) } - - async fn resolve_keyterm_id(&self, input: &str) -> Result { - self.db - .resolve_keyterm_id(input) - .await - .map_err(|error| match error { - CliError::Other(message) => ServiceError::InvalidArgument(message), - other => ServiceError::from(other), - }) - } } impl From for ProjectDto { @@ -140,7 +113,6 @@ impl From for ProjectDto { id: project.id, name: project.name, color: project.color, - keyterm_id: project.keyterm_id, archived: project.is_archived.unwrap_or(0) != 0, created_at: project.created_at, } @@ -161,27 +133,19 @@ mod tests { #[tokio::test] async fn add_get_modify_and_archive_share_one_typed_contract() { let backend = make_backend().await; - let keyterm_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_keyterm(&keyterm_id, "Rust", None, None, "2026-08-05T00:00:00Z") - .await - .unwrap(); let service = ProjectService::new(&backend); let created = service .add(ProjectAddInput { name: "work".to_string(), - keyterm: Some(keyterm_id.clone()), color: Some("#123456".to_string()), }) .await .unwrap(); assert_eq!(created.name, "work"); - assert_eq!(created.keyterm_id.as_deref(), Some(keyterm_id.as_str())); let duplicate = service .add(ProjectAddInput { name: "work".to_string(), - keyterm: None, color: None, }) .await @@ -191,12 +155,10 @@ mod tests { let modified = service .modify(ProjectModifyInput { id: created.id.clone(), - keyterm: Patch::Null, color: Patch::Value("#abcdef".to_string()), }) .await .unwrap(); - assert_eq!(modified.keyterm_id, None); assert_eq!(modified.color.as_deref(), Some("#abcdef")); let archived = service.archive(&created.id).await.unwrap(); @@ -251,7 +213,7 @@ mod tests { } #[tokio::test] - async fn project_lookup_and_keyterm_validation_use_domain_error_codes() { + async fn project_lookup_uses_domain_error_code() { let backend = make_backend().await; let service = ProjectService::new(&backend); @@ -260,22 +222,5 @@ mod tests { .await .unwrap_err(); assert_eq!(missing.code(), "project_not_found"); - - let invalid_keyterm = service - .add(ProjectAddInput { - name: "must-not-be-created".to_string(), - keyterm: Some("550e8400-e29b-41d4-a716-446655440000".to_string()), - color: None, - }) - .await - .unwrap_err(); - assert_eq!(invalid_keyterm.code(), "invalid_argument"); - assert!( - backend - .find_project_by_name("must-not-be-created") - .await - .unwrap() - .is_none() - ); } } diff --git a/flicknote-core/src/types.rs b/flicknote-core/src/types.rs index a67148a..8a975fa 100644 --- a/flicknote-core/src/types.rs +++ b/flicknote-core/src/types.rs @@ -36,18 +36,6 @@ pub struct Project { pub user_id: String, pub name: String, pub color: Option, - pub keyterm_id: Option, pub is_archived: Option, pub created_at: Option, } - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct Keyterm { - pub id: String, - pub user_id: String, - pub name: String, - pub description: Option, - pub content: Option, - pub created_at: Option, - pub updated_at: Option, -} diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index ef119ad..53ea134 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -468,81 +468,6 @@ impl Application { .map(AppResponse::Unshare) .map_err(WireError::from_service) } - AppRequest::KeytermAdd { - name, - description, - content, - } => { - if name.trim().is_empty() { - return Err(WireError::from_service(ServiceError::InvalidArgument( - "keyterm name must not be empty".to_string(), - ))); - } - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let keyterm = self - .db - .insert_keyterm(&id, &name, description.as_deref(), content.as_deref(), &now) - .await - .map_err(Self::db_error)?; - Ok(AppResponse::Keyterm(keyterm)) - } - AppRequest::KeytermList => self - .db - .list_keyterms() - .await - .map(AppResponse::Keyterms) - .map_err(Self::db_error), - AppRequest::KeytermGet { id } => { - let id = self - .db - .resolve_keyterm_id(&id) - .await - .map_err(Self::db_error)?; - self.db - .find_keyterm(&id) - .await - .map(AppResponse::Keyterm) - .map_err(Self::db_error) - } - AppRequest::KeytermModify { - id, - name, - description, - content, - } => { - if name.is_none() && description.is_none() && content.is_none() { - return Err(WireError::from_service(ServiceError::NothingToModify)); - } - let id = self - .db - .resolve_keyterm_id(&id) - .await - .map_err(Self::db_error)?; - self.db - .update_keyterm( - &id, - name.as_deref(), - description.as_deref(), - content.as_deref(), - ) - .await - .map_err(Self::db_error)?; - self.db - .find_keyterm(&id) - .await - .map(AppResponse::Keyterm) - .map_err(Self::db_error) - } - AppRequest::KeytermDelete { id } => { - let id = self - .db - .resolve_keyterm_id(&id) - .await - .map_err(Self::db_error)?; - self.db.delete_keyterm(&id).await.map_err(Self::db_error)?; - Ok(AppResponse::Id { id }) - } AppRequest::ExtractionValues { keys, archived } => { let refs = keys.iter().map(String::as_str).collect::>(); self.db diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index 9a7c3d7..e4e1bb4 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -10,7 +10,7 @@ use flicknote_core::services::dto::{ use flicknote_core::services::editable_document::EditableSaveResult; use flicknote_core::services::error::ServiceError; use flicknote_core::services::source::{SourceResult, SourceView}; -use flicknote_core::types::{Keyterm, Note, Project}; +use flicknote_core::types::{Note, Project}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixListener; @@ -243,24 +243,6 @@ pub enum AppRequest { ProjectUnshare { id: String, }, - KeytermAdd { - name: String, - description: Option, - content: Option, - }, - KeytermList, - KeytermGet { - id: String, - }, - KeytermModify { - id: String, - name: Option, - description: Option, - content: Option, - }, - KeytermDelete { - id: String, - }, ExtractionValues { keys: Vec, archived: bool, @@ -284,8 +266,6 @@ impl AppRequest { | Self::ProjectRecords { .. } | Self::ProjectGet { .. } | Self::ProjectGetByName { .. } - | Self::KeytermList - | Self::KeytermGet { .. } | Self::ExtractionValues { .. } ) } @@ -343,8 +323,6 @@ pub enum AppResponse { Projects(Vec), ProjectRecords(Vec), Project(ProjectDto), - Keyterms(Vec), - Keyterm(Keyterm), Id { id: String }, Values(Vec), Unit, @@ -388,8 +366,6 @@ app_result!(OpenResult, AppResponse::Open); app_result!(Vec, AppResponse::Projects); app_result!(Vec, AppResponse::ProjectRecords); app_result!(ProjectDto, AppResponse::Project); -app_result!(Vec, AppResponse::Keyterms); -app_result!(Keyterm, AppResponse::Keyterm); app_result!(Vec, AppResponse::Values); impl AppResult for u64 { diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index a1f8064..5fa3f85 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -2258,6 +2258,95 @@ mod tests { ); } + #[tokio::test] + async fn existing_database_retires_keyterm_schema_without_losing_projects() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("keyterm-retirement.db"); + let mut legacy_schema = app_schema(); + let projects = legacy_schema + .tables + .iter_mut() + .find(|table| table.name.as_ref() == "projects") + .unwrap(); + if !projects + .columns + .iter() + .any(|column| column.name.as_ref() == "keyterm_id") + { + projects + .columns + .push(powersync::schema::Column::text("keyterm_id")); + } + if !legacy_schema + .tables + .iter() + .any(|table| table.name.as_ref() == "keyterms") + { + legacy_schema.tables.push(powersync::schema::Table::create( + "keyterms", + vec![ + powersync::schema::Column::text("user_id"), + powersync::schema::Column::text("name"), + powersync::schema::Column::text("description"), + powersync::schema::Column::text("content"), + powersync::schema::Column::text("created_at"), + powersync::schema::Column::text("updated_at"), + ], + |_| {}, + )); + } + + { + let legacy_db = test_powersync_db_at(&path, legacy_schema); + let writer = legacy_db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO keyterms (id, user_id, name) VALUES (?, ?, ?)", + params!["retired-keyterm", "user-1", "Retired"], + ) + .unwrap(); + writer + .execute( + "INSERT INTO projects (id, user_id, name, keyterm_id) VALUES (?, ?, ?, ?)", + params![ + "preserved-project", + "user-1", + "Preserved", + "retired-keyterm" + ], + ) + .unwrap(); + writer.execute("DELETE FROM ps_crud", []).unwrap(); + } + + let upgraded_db = test_powersync_db_at(&path, app_schema()); + let writer = upgraded_db.writer().await.unwrap(); + let project_name: String = writer + .query_row( + "SELECT name FROM projects WHERE id = ?", + params!["preserved-project"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(project_name, "Preserved"); + let retired_view_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'keyterms'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_view_count, 0); + let retired_column_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name = 'keyterm_id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_column_count, 0); + } + #[tokio::test] async fn remote_committed_put_completes_without_http_request() { let (_directory, db) = test_powersync_db().await; diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 689a494..39d989b 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -4,7 +4,9 @@ use async_trait::async_trait; use flicknote_core::backend::{InsertNoteReq, InsertedNote, NoteDb, SqliteBackend}; use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; -use flicknote_core::services::dto::{NoteAddInput, NoteListInput, ProjectAddInput}; +use flicknote_core::services::dto::{ + NoteAddInput, NoteListInput, Patch, ProjectAddInput, ProjectModifyInput, +}; use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{CreateNote, CreatedNote, NoteCreator}; use flicknote_sync::app::Application; @@ -86,12 +88,10 @@ async fn application_signals_every_may_write_request_even_when_it_fails() { assert!(receiver.try_recv().is_err()); let error = app - .handle(AppRequest::KeytermModify { + .handle(AppRequest::ProjectModify(ProjectModifyInput { id: "missing".to_string(), - name: None, - description: None, - content: None, - }) + color: Patch::Missing, + })) .await .unwrap_err(); assert_eq!(error.code, "nothing_to_modify"); @@ -231,7 +231,7 @@ async fn app_routes_note_list_and_append_through_services() { } #[tokio::test] -async fn app_owns_project_keyterm_and_catalog_domain_operations() { +async fn app_owns_project_and_catalog_domain_operations() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); let backend = Arc::new(SqliteBackend { @@ -240,23 +240,9 @@ async fn app_owns_project_keyterm_and_catalog_domain_operations() { }); let app = Application::new(backend, BackendMode::Local); - let keyterm = app - .handle(AppRequest::KeytermAdd { - name: "Rust".to_string(), - description: Some("Language".to_string()), - content: Some("ownership".to_string()), - }) - .await - .unwrap(); - let AppResponse::Keyterm(keyterm) = keyterm else { - panic!("unexpected keyterm response") - }; - assert_eq!(keyterm.name, "Rust"); - let project = app .handle(AppRequest::ProjectAdd(ProjectAddInput { name: "work".to_string(), - keyterm: Some(keyterm.id.clone()), color: Some("#123456".to_string()), })) .await @@ -265,7 +251,7 @@ async fn app_owns_project_keyterm_and_catalog_domain_operations() { panic!("unexpected project response") }; assert_eq!(project.name, "work"); - assert_eq!(project.keyterm_id.as_deref(), Some(keyterm.id.as_str())); + assert_eq!(project.color.as_deref(), Some("#123456")); let values = app .handle(AppRequest::ExtractionValues { diff --git a/scripts/sqlx-sqlite-schema.sql b/scripts/sqlx-sqlite-schema.sql index f1d16fb..924fd04 100644 --- a/scripts/sqlx-sqlite-schema.sql +++ b/scripts/sqlx-sqlite-schema.sql @@ -21,21 +21,10 @@ CREATE TABLE projects ( user_id TEXT, name TEXT, color TEXT, - keyterm_id TEXT, is_archived INTEGER, created_at TEXT ); -CREATE TABLE keyterms ( - id TEXT PRIMARY KEY, - user_id TEXT, - name TEXT, - description TEXT, - content TEXT, - created_at TEXT, - updated_at TEXT -); - CREATE TABLE note_extractions ( id TEXT, note_id TEXT, diff --git a/skills/flicknote.md b/skills/flicknote.md index ce1a7a3..15e9212 100644 --- a/skills/flicknote.md +++ b/skills/flicknote.md @@ -110,5 +110,4 @@ flicknote content --help flicknote modify --help flicknote replace --help flicknote project --help -flicknote keyterm --help ``` From dc774344aee135f42598be99a4d56b5929e3c91f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 9 Aug 2026 21:36:42 +0800 Subject: [PATCH 12/16] fix(sync): migrate retired queued writes safely --- flicknote-cli/src/commands/login.rs | 3 + flicknote-cli/src/commands/sync.rs | 29 ++++++ flicknote-sync/src/ipc.rs | 23 +++++ flicknote-sync/src/lib.rs | 140 ++++++++++++++++++++++------ 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index 852cba7..fa4a19d 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -90,6 +90,9 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro if manage_local_daemon { // The macOS login flow owns the per-user LaunchAgent lifecycle. + // install_local_daemon re-checks the live backend immediately before stop, + // because authentication may have left enough time for another shell to + // start a managed daemon. super::sync::install_local_daemon(config, std::time::Duration::from_secs(10)).await?; println!("Sync daemon installed and started"); } diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 5f70076..76805d2 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -270,6 +270,11 @@ pub(super) async fn install_local_daemon( #[cfg(target_os = "macos")] { validate_launchd_platform()?; + // This probe must sit immediately before the destructive stop. Login may + // spend minutes in interactive authentication, and another shell may have + // started a managed daemon since its initial lifecycle decision. + let running = running_server_info(config).await?; + validate_local_install_endpoint(running.as_ref().map(|info| info.backend))?; // Prove that the shared endpoint is no longer owned by an old launchd or // standalone daemon before starting the new local LaunchAgent. super::daemon::stop(config)?; @@ -285,6 +290,19 @@ pub(super) async fn install_local_daemon( } } +#[cfg(any(target_os = "macos", test))] +fn validate_local_install_endpoint( + running_backend: Option, +) -> Result<(), CliError> { + if running_backend == Some(flicknote_sync::ipc::BackendMode::Managed) { + return Err(CliError::Other( + "A managed daemon is running. Stop it explicitly before installing the local PowerSync daemon." + .to_string(), + )); + } + Ok(()) +} + fn validate_install_mode(database_url: Option<&str>) -> Result<(), CliError> { if database_url.is_some() { return Err(CliError::Other( @@ -413,6 +431,17 @@ mod tests { server.await.unwrap(); } + #[test] + fn managed_daemon_started_during_auth_blocks_local_install() { + let error = + validate_local_install_endpoint(Some(flicknote_sync::ipc::BackendMode::Managed)) + .unwrap_err(); + + assert!(error.to_string().contains("managed daemon")); + validate_local_install_endpoint(Some(flicknote_sync::ipc::BackendMode::Local)).unwrap(); + validate_local_install_endpoint(None).unwrap(); + } + #[tokio::test] async fn stop_waits_until_daemon_health_is_unavailable() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index e4e1bb4..a59c582 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -673,6 +673,7 @@ impl<'a> DaemonClient<'a> { } pub async fn app(&self, request: AppRequest) -> Result { + let may_write = request.may_write(); match self .request(DaemonRequest::App { protocol: PROTOCOL_VERSION, @@ -683,6 +684,10 @@ impl<'a> DaemonClient<'a> { { DaemonResponse::App(response) => Ok(*response), DaemonResponse::AppError(error) => Err(Self::remote_error(error)), + _ if may_write => Err(Self::outcome_unknown( + "The daemon returned an unexpected envelope after a mutating request; the operation outcome is unknown." + .to_string(), + )), _ => Err(Self::protocol_mismatch()), } } @@ -1208,6 +1213,24 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn unexpected_outer_responses_are_classified_by_mutation_safety() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response(&config, DaemonResponse::ServerInfo(ServerInfo::local())).await; + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + assert!(!error.retryable()); + server.await.unwrap(); + } + #[tokio::test] async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { let directory = tempfile::tempdir().unwrap(); diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 5fa3f85..9eb7a82 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -229,7 +229,23 @@ async fn run_upload( let mut fatal_msg: Option = None; let mut transient_msg: Option = None; - for crud in std::mem::take(&mut tx.crud) { + for mut crud in std::mem::take(&mut tx.crud) { + // The backend retired the keyterm domain. Old offline databases may still + // have queued writes for the removed table or the removed project column. + // Consume those retired fields locally so they cannot block the FIFO or + // cause an otherwise valid project mutation to be discarded by PostgREST. + if crud.table == "keyterms" { + log::info!( + "Discarding queued CRUD for retired keyterms row {}", + crud.id + ); + continue; + } + if crud.table == "projects" + && let Some(data) = crud.data.as_mut() + { + data.remove("keyterm_id"); + } if parse_flicknote_crud_marker(crud.metadata.as_deref())? == Some(FlickNoteCrudMarker::RemoteCommittedInsert) { @@ -2316,35 +2332,57 @@ mod tests { ], ) .unwrap(); - writer.execute("DELETE FROM ps_crud", []).unwrap(); } let upgraded_db = test_powersync_db_at(&path, app_schema()); - let writer = upgraded_db.writer().await.unwrap(); - let project_name: String = writer - .query_row( - "SELECT name FROM projects WHERE id = ?", - params!["preserved-project"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(project_name, "Preserved"); - let retired_view_count: i64 = writer - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'keyterms'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(retired_view_count, 0); - let retired_column_count: i64 = writer - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name = 'keyterm_id'", - [], - |row| row.get(0), + { + let writer = upgraded_db.writer().await.unwrap(); + let project_name: String = writer + .query_row( + "SELECT name FROM projects WHERE id = ?", + params!["preserved-project"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(project_name, "Preserved"); + let retired_view_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'keyterms'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_view_count, 0); + let retired_column_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name = 'keyterm_id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_column_count, 0); + } + + let (server_url, server) = spawn_capture_server(1); + assert!( + run_upload( + &upgraded_db, + &reqwest::Client::new(), + "token", + &server_url, + "anon-key", ) - .unwrap(); - assert_eq!(retired_column_count, 0); + .await + .unwrap() + ); + assert!(upgraded_db.next_crud_transaction().await.unwrap().is_none()); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /rest/v1/projects ")); + let (_, body) = requests[0].split_once("\r\n\r\n").unwrap(); + let payload: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(payload["name"], "Preserved"); + assert!(payload.get("keyterm_id").is_none()); } #[tokio::test] @@ -2591,6 +2629,56 @@ mod tests { (format!("http://{address}"), handle) } + fn read_complete_http_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 4096]; + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + let Some(headers_end) = request.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= headers_end + 4 + content_length { + break; + } + } + String::from_utf8(request).unwrap() + } + + fn spawn_capture_server(expected_requests: usize) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().unwrap(); + requests.push(read_complete_http_request(&mut stream)); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } + requests + }); + (format!("http://{address}"), handle) + } + fn spawn_disconnected_response_then_server( status: &'static str, body: &'static str, From d5587a5c9be47a8aff0c2ccaa6efa3d5f061fe35 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 00:45:18 +0800 Subject: [PATCH 13/16] refactor(sync): retire managed pgwire mode --- ...e965f6c6bd544ef0b6c18edc0d1f6e5a6127c.json | 110 -- ...a7c69b53dfa43002c63f01784c533186b9567.json | 16 - ...1e4e51f23e01f5e57cbe07582184624a7ef1b.json | 109 -- ...a35384fb683ec63422f516f3417f8d91c04b0.json | 24 - AGENTS.md | 16 +- Cargo.lock | 1 + README.md | 12 +- flicknote-cli/Cargo.toml | 1 + flicknote-cli/src/commands/login.rs | 56 +- flicknote-cli/src/commands/logout.rs | 35 +- flicknote-cli/src/commands/sync.rs | 158 +-- flicknote-cli/src/help/root.md | 5 +- flicknote-cli/src/main.rs | 87 +- flicknote-cli/src/mcp/server.rs | 2 +- flicknote-cli/tests/mcp_stdio.rs | 194 +--- flicknote-core/Cargo.toml | 3 +- flicknote-core/src/backend.rs | 69 -- flicknote-core/src/error.rs | 4 - flicknote-core/src/lib.rs | 2 - flicknote-core/src/pgwire/mod.rs | 943 ------------------ flicknote-core/src/services/ports.rs | 104 +- flicknote-sync/Cargo.toml | 2 +- flicknote-sync/src/app.rs | 187 +--- flicknote-sync/src/ipc.rs | 223 +---- flicknote-sync/src/lib.rs | 26 +- flicknote-sync/tests/app_contract.rs | 169 ++-- scripts/sqlx-prepare.sh | 25 - skills/flicknote.md | 3 +- 28 files changed, 278 insertions(+), 2308 deletions(-) delete mode 100644 .sqlx/query-30ed0fd6fc6bf71bab1bd74c902e965f6c6bd544ef0b6c18edc0d1f6e5a6127c.json delete mode 100644 .sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json delete mode 100644 .sqlx/query-5ed6b0c2b04c2e639e76e9f79ba1e4e51f23e01f5e57cbe07582184624a7ef1b.json delete mode 100644 .sqlx/query-8d574555cb3ef3a7201c3536ae0a35384fb683ec63422f516f3417f8d91c04b0.json delete mode 100644 flicknote-core/src/pgwire/mod.rs diff --git a/.sqlx/query-30ed0fd6fc6bf71bab1bd74c902e965f6c6bd544ef0b6c18edc0d1f6e5a6127c.json b/.sqlx/query-30ed0fd6fc6bf71bab1bd74c902e965f6c6bd544ef0b6c18edc0d1f6e5a6127c.json deleted file mode 100644 index d6edf5b..0000000 --- a/.sqlx/query-30ed0fd6fc6bf71bab1bd74c902e965f6c6bd544ef0b6c18edc0d1f6e5a6127c.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id as \"id!\",\n short_id,\n user_id as \"user_id!\",\n type as \"type!\",\n status as \"status!\",\n title,\n content,\n summary,\n is_flagged,\n project_id,\n metadata as \"metadata: _\",\n source as \"source: _\",\n created_at,\n updated_at,\n deleted_at\n FROM notes\n WHERE (deleted_at IS NOT NULL) = $1\n AND ($2::text IS NULL OR type = $2)\n AND ($3::uuid IS NULL OR project_id = $3)\n AND EXISTS (\n SELECT 1 FROM unnest($4::text[]) AS kw(term)\n WHERE title ILIKE '%' || kw.term || '%'\n OR content ILIKE '%' || kw.term || '%'\n OR summary ILIKE '%' || kw.term || '%'\n )\n ORDER BY updated_at DESC\n LIMIT $5\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "short_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "user_id!", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "type!", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "status!", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "title", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "content", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "summary", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "is_flagged", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "project_id", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "metadata: _", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "source: _", - "type_info": "Jsonb" - }, - { - "ordinal": 12, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 13, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 14, - "name": "deleted_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Bool", - "Text", - "Uuid", - "TextArray", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - true, - true, - false, - true, - true, - true, - false, - false, - true - ] - }, - "hash": "30ed0fd6fc6bf71bab1bd74c902e965f6c6bd544ef0b6c18edc0d1f6e5a6127c" -} diff --git a/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json b/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json deleted file mode 100644 index 97b5933..0000000 --- a/.sqlx/query-4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE projects SET\n color = CASE WHEN $2::bool THEN $3::text ELSE color END\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "4c6561e5ece46423c098193d358a7c69b53dfa43002c63f01784c533186b9567" -} diff --git a/.sqlx/query-5ed6b0c2b04c2e639e76e9f79ba1e4e51f23e01f5e57cbe07582184624a7ef1b.json b/.sqlx/query-5ed6b0c2b04c2e639e76e9f79ba1e4e51f23e01f5e57cbe07582184624a7ef1b.json deleted file mode 100644 index addb7a7..0000000 --- a/.sqlx/query-5ed6b0c2b04c2e639e76e9f79ba1e4e51f23e01f5e57cbe07582184624a7ef1b.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id as \"id!\",\n short_id,\n user_id as \"user_id!\",\n type as \"type!\",\n status as \"status!\",\n title,\n content,\n summary,\n is_flagged,\n project_id,\n metadata as \"metadata: _\",\n source as \"source: _\",\n created_at,\n updated_at,\n deleted_at\n FROM notes\n WHERE (deleted_at IS NOT NULL) = $1\n AND ($2::text IS NULL OR type = $2)\n AND ($3::uuid IS NULL OR project_id = $3)\n ORDER BY created_at DESC\n LIMIT $4\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "short_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "user_id!", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "type!", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "status!", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "title", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "content", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "summary", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "is_flagged", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "project_id", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "metadata: _", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "source: _", - "type_info": "Jsonb" - }, - { - "ordinal": 12, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 13, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 14, - "name": "deleted_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Bool", - "Text", - "Uuid", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - true, - true, - false, - true, - true, - true, - false, - false, - true - ] - }, - "hash": "5ed6b0c2b04c2e639e76e9f79ba1e4e51f23e01f5e57cbe07582184624a7ef1b" -} diff --git a/.sqlx/query-8d574555cb3ef3a7201c3536ae0a35384fb683ec63422f516f3417f8d91c04b0.json b/.sqlx/query-8d574555cb3ef3a7201c3536ae0a35384fb683ec63422f516f3417f8d91c04b0.json deleted file mode 100644 index 767bc7e..0000000 --- a/.sqlx/query-8d574555cb3ef3a7201c3536ae0a35384fb683ec63422f516f3417f8d91c04b0.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT COUNT(*) as \"count!\"\n FROM notes\n WHERE (deleted_at IS NOT NULL) = $1\n AND ($2::text IS NULL OR type = $2)\n AND ($3::uuid IS NULL OR project_id = $3)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Bool", - "Text", - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "8d574555cb3ef3a7201c3536ae0a35384fb683ec63422f516f3417f8d91c04b0" -} diff --git a/AGENTS.md b/AGENTS.md index 383b27f..1914e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,16 +40,9 @@ After changing any `sqlx::query!`, `query_as!`, or `query_scalar!` macro, run `just sqlx-prepare` and commit the `.sqlx` changes. Do not hand-edit `.sqlx` files. -For pgwire metadata, `just sqlx-prepare` must run against a local Postgres -schema that already has the matching FlickNote backend migrations applied. If -prepare reports a missing column or relation, update the local prepare DB from -the backend migrations first, then rerun prepare. Keep -`scripts/sqlx-sqlite-schema.sql` in sync with SQLite macro-selected columns. - -When the CLI depends on a fresh backend schema change, confirm the local prepare -database has that backend migration applied before trusting generated metadata. -For short-id work, this means the local database must include the backend -`add_user_short_ids` migration before regenerating pgwire metadata. +`just sqlx-prepare` validates SQLite macros against the local fixture schema. +Keep `scripts/sqlx-sqlite-schema.sql` in sync with SQLite macro-selected +columns. ## Git Hooks (lefthook) @@ -73,8 +66,7 @@ lefthook run pre-push # run pre-push hooks - **tokio** — async runtime - **reqwest** — HTTP client (auth + PostgREST backend) - **serde/serde_json** — serialization -- **postgres** — sync Postgres client for pgwire backend -- **sea-query** — SQL query builder (1.0.0-rc.32 + sea-query-postgres 0.6.0-rc.3 for pgwire) +- **sqlx** — typed local SQLite access pending migration to the shared PowerSync pool ## Project Conventions diff --git a/Cargo.lock b/Cargo.lock index c6a15a7..9f3bffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,7 @@ dependencies = [ name = "flicknote-cli" version = "0.4.3" dependencies = [ + "async-trait", "chrono", "clap", "dirs", diff --git a/README.md b/README.md index ad4e8ea..8091e7f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # flicknote-cli -Daemon-backed note management CLI with local-first sync. The CLI and MCP server use a typed Unix-socket API; the daemon owns SQLite/PowerSync or the configured managed Postgres backend. +Daemon-backed note management CLI with local-first sync. The CLI and MCP server use a typed Unix-socket API; the daemon owns SQLite and PowerSync. ## Features @@ -38,9 +38,7 @@ just install CI sets `SQLX_OFFLINE=true`. After adding or changing `sqlx::query!`, `query_as!`, or `query_scalar!` macros, run `just sqlx-prepare` and commit the generated `.sqlx` metadata. The prepare script checks SQLite against a -local fixture DB and pgwire against the local Supabase Postgres used by -`flicknote-services` sqlc (`localhost:30432/supabase` by default), then merges -both metadata sets. +local fixture database. Runtime-built `sqlx::query` calls are checked at build time for Rust types, but sqlx does not emit offline metadata for them. @@ -153,9 +151,8 @@ start it as a subprocess: } ``` -The MCP server is available with a local daemon; managed daemons return an -`unsupported_capability` error before stdio protocol output. It exposes typed -note, note-source, and project tools. Note content +The MCP server requires the local daemon. It exposes typed note, note-source, +and project tools. Note content and exact `before`/`after` edits are JSON fields, so callers do not need shell heredocs. Note tools accept numeric short IDs and do not expose internal UUIDs; project tools use project names. `note_source` reads stored source data, while @@ -171,7 +168,6 @@ Environment variables: - `FLICKNOTE_SUPABASE_URL` - `FLICKNOTE_SUPABASE_KEY` - `FLICKNOTE_POWERSYNC_URL` -- `DATABASE_URL` (daemon-only managed backend selection) Data directory: `~/.local/share/flicknote/` diff --git a/flicknote-cli/Cargo.toml b/flicknote-cli/Cargo.toml index 5f1a1a6..dafef7b 100644 --- a/flicknote-cli/Cargo.toml +++ b/flicknote-cli/Cargo.toml @@ -41,6 +41,7 @@ schemars = "1" httpdate = "1.0.3" [dev-dependencies] +async-trait = { workspace = true } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite"] } uuid = { workspace = true } diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index fa4a19d..4122d5c 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -17,21 +17,15 @@ pub(crate) struct LoginArgs { } pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliError> { - let database_url = std::env::var("DATABASE_URL").ok(); if config.paths.session_file.exists() && !args.force { return Err(CliError::Other( "Already logged in. Use `flicknote login --force` to re-authenticate (e.g. after sync issues).".into(), )); } - let running = super::sync::running_server_info(config).await?; - let manage_local_daemon = manages_daemon_after_login_for( - cfg!(target_os = "macos"), - database_url.as_deref(), - running.as_ref().map(|info| info.backend), - )?; + let manage_local_daemon = manages_daemon_after_login_for(cfg!(target_os = "macos")); if config.paths.session_file.exists() { - // --force: stop only a confirmed local daemon and clear the stale session. + // --force: stop the macOS LaunchAgent before clearing the stale session. if manage_local_daemon { super::daemon::stop(config)?; super::daemon::uninstall()?; @@ -90,9 +84,6 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro if manage_local_daemon { // The macOS login flow owns the per-user LaunchAgent lifecycle. - // install_local_daemon re-checks the live backend immediately before stop, - // because authentication may have left enough time for another shell to - // start a managed daemon. super::sync::install_local_daemon(config, std::time::Duration::from_secs(10)).await?; println!("Sync daemon installed and started"); } @@ -100,18 +91,8 @@ pub(crate) async fn run(config: &Config, args: &LoginArgs) -> Result<(), CliErro Ok(()) } -fn manages_daemon_after_login_for( - target_is_macos: bool, - database_url: Option<&str>, - running_backend: Option, -) -> Result { - match running_backend { - Some(flicknote_sync::ipc::BackendMode::Managed) => Err(CliError::Other( - "A managed daemon is running. Stop it explicitly before logging into the local PowerSync workspace.".to_string(), - )), - Some(flicknote_sync::ipc::BackendMode::Local) => Ok(target_is_macos), - None => Ok(target_is_macos && database_url.is_none()), - } +const fn manages_daemon_after_login_for(target_is_macos: bool) -> bool { + target_is_macos } #[cfg(test)] @@ -119,33 +100,12 @@ mod tests { #[cfg(not(target_os = "macos"))] #[test] fn non_macos_login_does_not_wait_for_a_launchd_daemon() { - assert!(!super::manages_daemon_after_login_for(false, None, None).unwrap()); + assert!(!super::manages_daemon_after_login_for(false)); } #[test] - fn managed_login_never_manages_the_local_launch_agent() { - assert!( - !super::manages_daemon_after_login_for(true, Some("postgres://managed"), None,) - .unwrap() - ); - assert!(super::manages_daemon_after_login_for(true, None, None).unwrap()); - assert!(!super::manages_daemon_after_login_for(false, None, None).unwrap()); - } - - #[test] - fn running_daemon_backend_is_the_login_lifecycle_source_of_truth() { - use flicknote_sync::ipc::BackendMode; - - let error = super::manages_daemon_after_login_for(true, None, Some(BackendMode::Managed)) - .unwrap_err(); - assert!(error.to_string().contains("managed daemon")); - assert!( - super::manages_daemon_after_login_for( - true, - Some("postgres://managed"), - Some(BackendMode::Local), - ) - .unwrap() - ); + fn macos_login_manages_the_local_launch_agent() { + assert!(super::manages_daemon_after_login_for(true)); + assert!(!super::manages_daemon_after_login_for(false)); } } diff --git a/flicknote-cli/src/commands/logout.rs b/flicknote-cli/src/commands/logout.rs index f45d2cc..c39a684 100644 --- a/flicknote-cli/src/commands/logout.rs +++ b/flicknote-cli/src/commands/logout.rs @@ -8,12 +8,8 @@ pub(crate) async fn run(config: &Config) -> Result<(), CliError> { return Ok(()); } - let running = super::sync::running_server_info(config).await?; - let manages_local_daemon = manages_local_daemon_for(running.as_ref().map(|info| info.backend)); - if manages_local_daemon { - super::daemon::stop(config)?; - super::daemon::uninstall()?; - } + super::daemon::stop(config)?; + super::daemon::uninstall()?; // 3. Delete local DB files — collect errors so session is always cleared let db_base = config.paths.db_file.with_extension(""); @@ -37,31 +33,6 @@ pub(crate) async fn run(config: &Config) -> Result<(), CliError> { ))); } - if manages_local_daemon { - println!("Logged out (session, daemon, and local data cleared)"); - } else { - println!("Logged out (local session and data cleared; managed daemon left running)"); - } + println!("Logged out (session, daemon, and local data cleared)"); Ok(()) } - -const fn manages_local_daemon_for( - running_backend: Option, -) -> bool { - !matches!( - running_backend, - Some(flicknote_sync::ipc::BackendMode::Managed) - ) -} - -#[cfg(test)] -mod tests { - use flicknote_sync::ipc::BackendMode; - - #[test] - fn logout_never_manages_a_live_managed_daemon() { - assert!(!super::manages_local_daemon_for(Some(BackendMode::Managed))); - assert!(super::manages_local_daemon_for(Some(BackendMode::Local))); - assert!(super::manages_local_daemon_for(None)); - } -} diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 76805d2..1219bd6 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -111,38 +111,6 @@ pub(super) async fn wait_for_daemon_ready( config: &Config, timeout: std::time::Duration, interval: std::time::Duration, -) -> Result<(), CliError> { - wait_for_daemon_ready_matching(config, timeout, interval, None).await -} - -pub(super) async fn running_server_info( - config: &Config, -) -> Result, CliError> { - match flicknote_sync::ipc::DaemonClient::new(config) - .health() - .await - { - Ok(info) => Ok(Some(info)), - Err(error) if error.code() == "daemon_unavailable" => Ok(None), - Err(error) => Err(CliError::from(error)), - } -} - -#[cfg(any(target_os = "macos", test))] -async fn wait_for_daemon_ready_for_mode( - config: &Config, - timeout: std::time::Duration, - interval: std::time::Duration, - expected_mode: flicknote_sync::ipc::BackendMode, -) -> Result<(), CliError> { - wait_for_daemon_ready_matching(config, timeout, interval, Some(expected_mode)).await -} - -async fn wait_for_daemon_ready_matching( - config: &Config, - timeout: std::time::Duration, - interval: std::time::Duration, - expected_mode: Option, ) -> Result<(), CliError> { let wait = async { loop { @@ -150,18 +118,7 @@ async fn wait_for_daemon_ready_matching( .health() .await { - Ok(info) => { - if let Some(expected) = expected_mode - && info.backend != expected - { - return Err(CliError::Other(format!( - "Expected a {} daemon, but the endpoint is owned by a {} daemon; stop it and retry", - expected.as_str(), - info.backend.as_str(), - ))); - } - return Ok(()); - } + Ok(_) => return Ok(()), Err(error) if !error.retryable() => return Err(CliError::from(error)), Err(_) => tokio::time::sleep(interval).await, } @@ -230,28 +187,20 @@ async fn status(config: &Config) -> Result<(), CliError> { } fn format_running_status(pid: u32, info: &flicknote_sync::ipc::ServerInfo) -> String { - let backend = info.backend.as_str(); format!( - "FlickNote daemon: running (pid {pid}, version {}, backend {backend}, protocol {})", + "FlickNote daemon: running (pid {pid}, version {}, protocol {})", info.version, info.protocol ) } async fn install(config: &Config) -> Result<(), CliError> { - install_with_timeout( - config, - std::env::var("DATABASE_URL").ok().as_deref(), - DAEMON_START_TIMEOUT, - ) - .await + install_with_timeout(config, DAEMON_START_TIMEOUT).await } async fn install_with_timeout( config: &Config, - database_url: Option<&str>, timeout: std::time::Duration, ) -> Result<(), CliError> { - validate_install_mode(database_url)?; install_local_daemon(config, timeout).await?; println!("Installed and started: io.guion.flicknote.sync"); Ok(()) @@ -270,46 +219,13 @@ pub(super) async fn install_local_daemon( #[cfg(target_os = "macos")] { validate_launchd_platform()?; - // This probe must sit immediately before the destructive stop. Login may - // spend minutes in interactive authentication, and another shell may have - // started a managed daemon since its initial lifecycle decision. - let running = running_server_info(config).await?; - validate_local_install_endpoint(running.as_ref().map(|info| info.backend))?; // Prove that the shared endpoint is no longer owned by an old launchd or // standalone daemon before starting the new local LaunchAgent. super::daemon::stop(config)?; wait_for_daemon_stopped(config, timeout, HEALTH_POLL_INTERVAL).await?; super::daemon::install(config)?; - wait_for_daemon_ready_for_mode( - config, - timeout, - HEALTH_POLL_INTERVAL, - flicknote_sync::ipc::BackendMode::Local, - ) - .await - } -} - -#[cfg(any(target_os = "macos", test))] -fn validate_local_install_endpoint( - running_backend: Option, -) -> Result<(), CliError> { - if running_backend == Some(flicknote_sync::ipc::BackendMode::Managed) { - return Err(CliError::Other( - "A managed daemon is running. Stop it explicitly before installing the local PowerSync daemon." - .to_string(), - )); + wait_for_daemon_ready(config, timeout, HEALTH_POLL_INTERVAL).await } - Ok(()) -} - -fn validate_install_mode(database_url: Option<&str>) -> Result<(), CliError> { - if database_url.is_some() { - return Err(CliError::Other( - "`flicknote sync install` only installs the local PowerSync daemon; start a managed daemon explicitly with `flicknote sync start`.".to_string(), - )); - } - Ok(()) } fn validate_launchd_platform() -> Result<(), CliError> { @@ -373,75 +289,19 @@ mod tests { assert!(error.to_string().contains("did not become ready")); } - #[test] - fn launchd_install_is_local_only() { - validate_install_mode(None).unwrap(); - let error = validate_install_mode(Some("postgres://managed")).unwrap_err(); - assert!( - error - .to_string() - .contains("only installs the local PowerSync daemon") - ); - } - #[cfg(not(target_os = "macos"))] #[tokio::test] async fn install_rejects_unsupported_platform_without_waiting() { let dir = tempfile::tempdir().expect("temp dir"); let config = test_config(dir.path()); - let error = install_with_timeout(&config, None, std::time::Duration::from_millis(20)) + let error = install_with_timeout(&config, std::time::Duration::from_millis(20)) .await .expect_err("non-macOS install must be rejected immediately"); assert!(error.to_string().contains("only supported on macOS")); } - #[tokio::test] - async fn local_install_readiness_rejects_a_managed_daemon() { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - - let dir = tempfile::tempdir().expect("temp dir"); - let config = test_config(dir.path()); - let listener = tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)) - .expect("bind socket"); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, mut writer) = stream.into_split(); - let mut reader = tokio::io::BufReader::new(reader); - let mut request = String::new(); - reader.read_line(&mut request).await.unwrap(); - let response = serde_json::to_vec(&flicknote_sync::ipc::DaemonResponse::ServerInfo( - flicknote_sync::ipc::ServerInfo::managed(), - )) - .unwrap(); - writer.write_all(&response).await.unwrap(); - }); - - let error = wait_for_daemon_ready_for_mode( - &config, - std::time::Duration::from_millis(500), - std::time::Duration::from_millis(10), - flicknote_sync::ipc::BackendMode::Local, - ) - .await - .expect_err("a pre-existing managed daemon is not local install readiness"); - - assert!(error.to_string().contains("managed daemon")); - server.await.unwrap(); - } - - #[test] - fn managed_daemon_started_during_auth_blocks_local_install() { - let error = - validate_local_install_endpoint(Some(flicknote_sync::ipc::BackendMode::Managed)) - .unwrap_err(); - - assert!(error.to_string().contains("managed daemon")); - validate_local_install_endpoint(Some(flicknote_sync::ipc::BackendMode::Local)).unwrap(); - validate_local_install_endpoint(None).unwrap(); - } - #[tokio::test] async fn stop_waits_until_daemon_health_is_unavailable() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -457,7 +317,7 @@ mod tests { let mut request = String::new(); reader.read_line(&mut request).await.unwrap(); let response = serde_json::to_vec(&flicknote_sync::ipc::DaemonResponse::ServerInfo( - flicknote_sync::ipc::ServerInfo::local(), + flicknote_sync::ipc::ServerInfo::current(), )) .unwrap(); writer.write_all(&response).await.unwrap(); @@ -474,11 +334,11 @@ mod tests { } #[test] - fn status_line_reports_runtime_version_and_backend() { - let line = format_running_status(42, &flicknote_sync::ipc::ServerInfo::managed()); + fn status_line_reports_runtime_version_and_protocol() { + let line = format_running_status(42, &flicknote_sync::ipc::ServerInfo::current()); assert!(line.contains("pid 42")); assert!(line.contains(env!("CARGO_PKG_VERSION"))); - assert!(line.contains("managed")); + assert!(line.contains("protocol 2")); } } diff --git a/flicknote-cli/src/help/root.md b/flicknote-cli/src/help/root.md index 4ed7ec8..aba2142 100644 --- a/flicknote-cli/src/help/root.md +++ b/flicknote-cli/src/help/root.md @@ -1,8 +1,5 @@ -FlickNote works with local and managed workspaces. -Managed workspaces support data commands that do not require local files or services. -File upload/import, editor, browser, sharing, and MCP workflows require a local workspace. Data commands require the FlickNote daemon. Start it with `flicknote sync start`. -The daemon selects local PowerSync or managed Postgres once at startup. +The daemon owns the local PowerSync database and remote synchronization. Run `flicknote --help` for exact flags and examples. Common workflows: diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index a8e4fdb..729b8b4 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -3,7 +3,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use flicknote_core::config::Config; use flicknote_core::error::CliError; -use flicknote_sync::ipc::{Capability, DaemonClient}; +use flicknote_sync::ipc::DaemonClient; const ROOT_HELP: &str = include_str!("help/root.md"); @@ -116,39 +116,22 @@ async fn run() -> Result<(), CliError> { } let daemon = DaemonClient::new(&config); - let server_info = daemon.health().await?; + daemon.health().await?; if matches!(cli.command, Some(Commands::Mcp)) { - server_info.require(Capability::Mcp, "mcp")?; return tokio::task::LocalSet::new() .run_until(mcp::serve(std::rc::Rc::new(config))) .await; } - dispatch(&cli, &daemon, &server_info).await + dispatch(&cli, &daemon).await } -fn preflight_command( - command: &Commands, - server_info: &flicknote_sync::ipc::ServerInfo, -) -> Result<(), CliError> { - if matches!(command, Commands::Edit(_)) { - server_info.require(Capability::Editor, "note_edit")?; - } - Ok(()) -} - -async fn dispatch( - cli: &Cli, - daemon: &DaemonClient<'_>, - server_info: &flicknote_sync::ipc::ServerInfo, -) -> Result<(), CliError> { +async fn dispatch(cli: &Cli, daemon: &DaemonClient<'_>) -> Result<(), CliError> { let Some(ref command) = cli.command else { Cli::command() .print_help() .map_err(|e| CliError::Other(e.to_string()))?; return Ok(()); }; - preflight_command(command, server_info)?; - match command { Commands::Mcp => unreachable!("MCP is dispatched before regular CLI commands"), Commands::Add(args) => commands::add::run(daemon, args).await, @@ -184,8 +167,42 @@ async fn dispatch( #[cfg(test)] mod tests { + use async_trait::async_trait; + use flicknote_core::services::error::ServiceError; + use flicknote_core::services::ports::{ + CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, + }; + use super::*; + struct PersistingCreator { + db: std::sync::Arc, + } + + #[async_trait] + impl NoteCreator for PersistingCreator { + async fn create(&self, request: CreateNote) -> Result { + let inserted = self.db.insert_note(&request.as_insert_request()).await?; + Ok(CreatedNote { + inserted, + confirmed_extraction_ids: Vec::new(), + }) + } + } + + struct UnusedShareGateway; + + #[async_trait] + impl ShareGateway for UnusedShareGateway { + async fn share(&self, _resource: ShareResource, _id: &str) -> Result { + Err(ServiceError::Daemon("unexpected share".to_string())) + } + + async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { + Err(ServiceError::Daemon("unexpected unshare".to_string())) + } + } + async fn call_mcp_tool( writer: &mut tokio::io::WriteHalf, reader: &mut tokio::io::BufReader>, @@ -233,7 +250,7 @@ mod tests { use flicknote_core::backend::{NoteDb, SqliteBackend}; use flicknote_core::db::Database; use flicknote_sync::app::Application; - use flicknote_sync::ipc::{BackendMode, ServerInfo, serve_app, socket_path}; + use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; use rmcp::ServiceExt; use std::rc::Rc; use std::sync::Arc; @@ -313,15 +330,22 @@ mod tests { .headings[0] .id .clone(); + let creator: Arc = Arc::new(PersistingCreator { + db: backend.clone(), + }); let app = Arc::new( - Application::new(backend, BackendMode::Managed) + Application::new( + backend, + creator, + Arc::new(UnusedShareGateway), + ) .with_web_url(config.web_url.clone()), ); let daemon_listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); let daemon_server = tokio::spawn(serve_app( daemon_listener, app, - ServerInfo::local(), + ServerInfo::current(), )); let server = mcp::FlickNoteMcp::new(Rc::new(config)); let (server_io, client_io) = tokio::io::duplex(8 * 1024); @@ -713,21 +737,6 @@ mod tests { assert!(Cli::try_parse_from(["flicknote", "upload", "file.pdf"]).is_ok()); } - #[test] - fn managed_edit_is_rejected_before_dispatching_to_the_editor() { - let cli = Cli::try_parse_from(["flicknote", "edit"]).unwrap(); - let command = cli.command.as_ref().unwrap(); - - let error = - preflight_command(command, &flicknote_sync::ipc::ServerInfo::managed()).unwrap_err(); - - assert!( - error - .to_string() - .contains("not available in managed daemon mode") - ); - } - #[test] fn metadata_discovery_and_source_commands_parse() { assert!(Cli::try_parse_from(["flicknote", "topic", "list"]).is_ok()); diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index af20855..60bdab0 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -75,7 +75,7 @@ impl FlickNoteMcp { } async fn call(&self, request: AppRequest) -> Result { - DaemonClient::for_mcp(&self.config).call(request).await + DaemonClient::new(&self.config).call(request).await } fn effective_project(project: Option) -> Option { diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index 1cffd8c..e127cfa 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -1,18 +1,45 @@ -use std::io::{BufRead, Read, Write}; +use std::io::{Read, Write}; use std::net::TcpListener; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; +use async_trait::async_trait; use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::ports::{ + CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, +}; use flicknote_sync::app::Application; use flicknote_sync::ipc::{ - AppRequest, AppResponse, BackendMode, ClientSurface, DaemonRequest, DaemonResponse, ServerInfo, - WireError, read_request, serve_app, socket_path, write_response, + AppRequest, AppResponse, DaemonRequest, DaemonResponse, ServerInfo, read_request, serve_app, + socket_path, write_response, }; +struct UnusedCreator; + +#[async_trait] +impl NoteCreator for UnusedCreator { + async fn create(&self, _request: CreateNote) -> Result { + Err(ServiceError::Daemon("unexpected create".to_string())) + } +} + +struct UnusedShareGateway; + +#[async_trait] +impl ShareGateway for UnusedShareGateway { + async fn share(&self, _resource: ShareResource, _id: &str) -> Result { + Err(ServiceError::Daemon("unexpected share".to_string())) + } + + async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { + Err(ServiceError::Daemon("unexpected unshare".to_string())) + } +} + fn test_config(config_root: &std::path::Path, data_root: &std::path::Path) -> Config { let config_dir = config_root.join("flicknote"); let data_dir = data_root.join("flicknote"); @@ -67,7 +94,7 @@ fn spawn_scripted_daemon( config_root: &std::path::Path, data_root: &std::path::Path, info: ServerInfo, - responder: impl Fn(ClientSurface, &AppRequest) -> DaemonResponse + Send + Sync + 'static, + responder: impl Fn(&AppRequest) -> DaemonResponse + Send + Sync + 'static, ) -> ScriptedDaemonGuard { let config = test_config(config_root, data_root); std::fs::create_dir_all(&config.paths.data_dir).unwrap(); @@ -94,11 +121,9 @@ fn spawn_scripted_daemon( let request = read_request(&mut stream).await.unwrap(); let response = match request { DaemonRequest::Health { .. } => DaemonResponse::ServerInfo(info.clone()), - DaemonRequest::App { - surface, request, .. - } => { + DaemonRequest::App { request, .. } => { recorded.lock().unwrap().push((*request).clone()); - responder(surface, &request) + responder(&request) } }; write_response(&mut stream, &response).await.unwrap(); @@ -140,12 +165,16 @@ fn spawn_test_daemon(config_root: &std::path::Path, data_root: &std::path::Path) db: Database::open_local(&config).await.unwrap(), user_id: "test-user".to_string(), }); - let app = std::sync::Arc::new(Application::new(backend, BackendMode::Local)); + let app = std::sync::Arc::new(Application::new( + backend, + std::sync::Arc::new(UnusedCreator), + std::sync::Arc::new(UnusedShareGateway), + )); let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); ready_tx.send(()).unwrap(); tokio::select! { _ = shutdown_rx => {} - result = serve_app(listener, app, ServerInfo::local()) => result.unwrap(), + result = serve_app(listener, app, ServerInfo::current()) => result.unwrap(), } }); }); @@ -220,7 +249,6 @@ fn run_cli_json( .args(args) .env("XDG_CONFIG_HOME", config_root) .env("XDG_DATA_HOME", data_root) - .env_remove("DATABASE_URL") .output() .unwrap(); assert!( @@ -241,7 +269,6 @@ fn run_cli_with_input( .args(args) .env("XDG_CONFIG_HOME", config_root) .env("XDG_DATA_HOME", data_root) - .env_remove("DATABASE_URL") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -393,7 +420,6 @@ fn gateway_request_writes_a_chunked_sse_response_to_stdout_without_exposing_its_ .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -432,7 +458,6 @@ fn gateway_request_forwards_piped_request_body_without_rewriting_it() { .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) - .env_remove("DATABASE_URL") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -477,7 +502,6 @@ fn gateway_request_rejects_invalid_piped_json_before_sending_it() { .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", "http://127.0.0.1:9/api/v1") - .env_remove("DATABASE_URL") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -521,7 +545,6 @@ fn gateway_request_bypasses_system_proxies() { .env_remove("all_proxy") .env_remove("NO_PROXY") .env_remove("no_proxy") - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -567,7 +590,6 @@ fn gateway_request_refreshes_sessions_without_using_system_proxies() { .env_remove("all_proxy") .env_remove("NO_PROXY") .env_remove("no_proxy") - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -599,7 +621,6 @@ fn gateway_request_does_not_forward_session_refresh_to_redirect_target() { .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) .env("FLICKNOTE_SUPABASE_URL", &origin) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -636,7 +657,6 @@ fn gateway_request_does_not_echo_an_upstream_error_body() { .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -664,7 +684,6 @@ fn gateway_request_reports_http_date_retry_after() { .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -713,7 +732,7 @@ fn cli_mutation_adapter_sends_typed_request_and_preserves_output_contract() { let directory = tempfile::tempdir().unwrap(); let config_root = directory.path().join("config"); let data_root = directory.path().join("data"); - let daemon = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::local(), |_, _| { + let daemon = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::current(), |_| { DaemonResponse::App(Box::new(AppResponse::NoteMutation( flicknote_core::services::dto::NoteMutationResult { note: fake_note_summary(), @@ -742,135 +761,6 @@ fn cli_mutation_adapter_sends_typed_request_and_preserves_output_contract() { )); } -#[test] -fn managed_file_and_editor_boundaries_fail_without_losing_local_input() { - let directory = tempfile::tempdir().unwrap(); - let config_root = directory.path().join("config"); - let data_root = directory.path().join("data"); - let uploaded = directory.path().join("draft.md"); - std::fs::write(&uploaded, "draft body").unwrap(); - let editor_marker = directory.path().join("editor-ran"); - let editor = directory.path().join("editor.sh"); - std::fs::write( - &editor, - format!("#!/bin/sh\ntouch '{}'\n", editor_marker.display()), - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&editor, std::fs::Permissions::from_mode(0o700)).unwrap(); - } - let daemon = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::managed(), |_, _| { - DaemonResponse::AppError(WireError { - code: "unsupported_capability".to_string(), - message: "operation unavailable".to_string(), - retryable: false, - details: None, - }) - }); - - let edit = Command::new(env!("CARGO_BIN_EXE_flicknote")) - .arg("edit") - .env("XDG_CONFIG_HOME", &config_root) - .env("XDG_DATA_HOME", &data_root) - .env("EDITOR", &editor) - .env_remove("DATABASE_URL") - .output() - .unwrap(); - assert!(!edit.status.success()); - assert!(!editor_marker.exists()); - - let upload = Command::new(env!("CARGO_BIN_EXE_flicknote")) - .args(["upload", uploaded.to_str().unwrap()]) - .env("XDG_CONFIG_HOME", &config_root) - .env("XDG_DATA_HOME", &data_root) - .env_remove("DATABASE_URL") - .output() - .unwrap(); - assert!(!upload.status.success()); - assert_eq!(std::fs::read_to_string(&uploaded).unwrap(), "draft body"); - assert!(matches!( - daemon.requests().as_slice(), - [AppRequest::NoteUpload { .. }] - )); -} - -#[test] -fn long_lived_mcp_rechecks_surface_after_daemon_mode_changes() { - let directory = tempfile::tempdir().unwrap(); - let config_root = directory.path().join("config"); - let data_root = directory.path().join("data"); - let local = spawn_scripted_daemon(&config_root, &data_root, ServerInfo::local(), |_, _| { - DaemonResponse::App(Box::new(AppResponse::NoteSummaries(Vec::new()))) - }); - let mut child = Command::new(env!("CARGO_BIN_EXE_flicknote")) - .arg("mcp") - .env("XDG_CONFIG_HOME", &config_root) - .env("XDG_DATA_HOME", &data_root) - .env_remove("DATABASE_URL") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let mut input = child.stdin.take().unwrap(); - let mut output = std::io::BufReader::new(child.stdout.take().unwrap()); - writeln!( - input, - r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-11-25","capabilities":{{}},"clientInfo":{{"name":"integration-test","version":"0"}}}}}}"# - ) - .unwrap(); - input.flush().unwrap(); - let mut frame = String::new(); - output.read_line(&mut frame).unwrap(); - assert_eq!( - serde_json::from_str::(&frame).unwrap()["id"], - 1 - ); - writeln!( - input, - r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# - ) - .unwrap(); - input.flush().unwrap(); - - drop(local); - let managed = spawn_scripted_daemon( - &config_root, - &data_root, - ServerInfo::managed(), - |surface, _| { - assert_eq!(surface, ClientSurface::Mcp); - DaemonResponse::AppError(WireError { - code: "unsupported_capability".to_string(), - message: "MCP is not available in managed mode".to_string(), - retryable: false, - details: None, - }) - }, - ); - writeln!( - input, - r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"note_list","arguments":{{}}}}}}"# - ) - .unwrap(); - input.flush().unwrap(); - frame.clear(); - output.read_line(&mut frame).unwrap(); - let response: serde_json::Value = serde_json::from_str(&frame).unwrap(); - assert_eq!(response["id"], 2); - assert_eq!(response["result"]["isError"], true); - assert!(matches!( - managed.requests().as_slice(), - [AppRequest::NoteList(_)] - )); - - drop(input); - let result = child.wait().unwrap(); - assert!(result.success()); -} - #[test] fn mcp_binary_keeps_stdout_as_json_rpc_frames() { let directory = tempfile::tempdir().unwrap(); @@ -883,7 +773,6 @@ fn mcp_binary_keeps_stdout_as_json_rpc_frames() { .arg("mcp") .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env_remove("DATABASE_URL") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -932,7 +821,6 @@ fn mcp_requires_daemon_before_protocol_output() { .arg("mcp") .env("XDG_CONFIG_HOME", directory.path().join("config")) .env("XDG_DATA_HOME", directory.path().join("data")) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -948,7 +836,6 @@ fn data_commands_require_the_daemon() { .arg("list") .env("XDG_CONFIG_HOME", directory.path().join("config")) .env("XDG_DATA_HOME", directory.path().join("data")) - .env_remove("DATABASE_URL") .output() .unwrap(); @@ -963,7 +850,6 @@ fn root_help_does_not_require_the_daemon() { let output = Command::new(env!("CARGO_BIN_EXE_flicknote")) .env("XDG_CONFIG_HOME", directory.path().join("config")) .env("XDG_DATA_HOME", directory.path().join("data")) - .env_remove("DATABASE_URL") .output() .unwrap(); diff --git a/flicknote-core/Cargo.toml b/flicknote-core/Cargo.toml index f9305a6..498f4cc 100644 --- a/flicknote-core/Cargo.toml +++ b/flicknote-core/Cargo.toml @@ -6,7 +6,6 @@ edition = "2024" [features] default = ["powersync"] powersync = ["dep:powersync"] -storage-pgwire = [] [dependencies] serde = { workspace = true } @@ -19,7 +18,7 @@ flicknote-auth = { path = "../flicknote-auth" } dirs = { workspace = true } chrono = "0.4" uuid = { workspace = true } -sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "postgres", "uuid", "chrono", "json", "macros"] } +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json", "macros"] } pulldown-cmark = { version = "0.13.1", default-features = false } sha2 = "0.10" yaml_serde = "0.10" diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index f5f6b44..ce321c3 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -47,12 +47,6 @@ pub struct InsertedNote { pub short_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InsertedNoteWithExtractions { - pub note: InsertedNote, - pub extraction_ids: Vec, -} - pub(crate) enum NoteLookup<'a> { ShortId(i64), Uuid(&'a str), @@ -103,20 +97,6 @@ pub trait NoteDb: Send + Sync { // Note writes async fn insert_note(&self, req: &InsertNoteReq<'_>) -> Result; - async fn insert_note_with_extractions( - &self, - req: &InsertNoteReq<'_>, - extraction_key: &str, - values: &[String], - ) -> Result { - let inserted = self.insert_note(req).await?; - self.set_note_extractions(&inserted.uuid, extraction_key, values) - .await?; - Ok(InsertedNoteWithExtractions { - note: inserted, - extraction_ids: Vec::new(), - }) - } /// Update content. When `requeue` is true, also sets status = 'ai_queued'. async fn update_note_content( &self, @@ -638,55 +618,6 @@ impl NoteDb for SqliteBackend { }) } - async fn insert_note_with_extractions( - &self, - req: &InsertNoteReq<'_>, - extraction_key: &str, - values: &[String], - ) -> Result { - let mut transaction = self.db.pool.begin().await?; - sqlx::query(SQ_INSERT) - .bind(req.id) - .bind(&self.user_id) - .bind(req.note_type) - .bind(req.status) - .bind(req.title) - .bind(req.content) - .bind(req.metadata) - .bind(req.project_id) - .bind(req.now) - .bind(req.now) - .execute(&mut *transaction) - .await?; - sqlx::query(SQ_CLEAR_EXTRACTIONS) - .bind(&self.user_id) - .bind(req.id) - .bind(extraction_key) - .execute(&mut *transaction) - .await?; - let mut extraction_ids = Vec::with_capacity(values.len()); - for value in values { - let extraction_id = uuid::Uuid::new_v4().to_string(); - sqlx::query(SQ_INSERT_EXTRACTION) - .bind(&extraction_id) - .bind(req.id) - .bind(&self.user_id) - .bind(extraction_key) - .bind(value) - .execute(&mut *transaction) - .await?; - extraction_ids.push(extraction_id); - } - transaction.commit().await?; - Ok(InsertedNoteWithExtractions { - note: InsertedNote { - uuid: req.id.to_string(), - short_id: None, - }, - extraction_ids, - }) - } - async fn update_note_content( &self, id: &str, diff --git a/flicknote-core/src/error.rs b/flicknote-core/src/error.rs index 8307387..acec17b 100644 --- a/flicknote-core/src/error.rs +++ b/flicknote-core/src/error.rs @@ -29,10 +29,6 @@ pub enum CliError { #[error("Database error: {0}")] Sqlx(#[from] sqlx::Error), - #[cfg(feature = "storage-pgwire")] - #[error("Database error: {0}")] - Database(String), - #[error("HTTP error: {0}")] Http(String), diff --git a/flicknote-core/src/lib.rs b/flicknote-core/src/lib.rs index 9269176..5fe9e7c 100644 --- a/flicknote-core/src/lib.rs +++ b/flicknote-core/src/lib.rs @@ -3,8 +3,6 @@ pub mod config; #[cfg(feature = "powersync")] pub mod db; pub mod error; -#[cfg(feature = "storage-pgwire")] -pub mod pgwire; #[cfg(feature = "powersync")] pub mod schema; pub mod services; diff --git a/flicknote-core/src/pgwire/mod.rs b/flicknote-core/src/pgwire/mod.rs deleted file mode 100644 index 404e0f1..0000000 --- a/flicknote-core/src/pgwire/mod.rs +++ /dev/null @@ -1,943 +0,0 @@ -//! PgWire backend for Supabase Postgres. -//! -//! This backend assumes the connection is routed through pgwire-supabase-proxy -//! (or equivalent) which sets the JWT/RLS context for the session. Tenant -//! isolation is enforced by RLS, so queries do not add user_id predicates. - -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row, postgres::PgPoolOptions}; -use uuid::Uuid; - -use crate::TOPIC_EXTRACTION_KEY; -use crate::backend::{ - InsertNoteReq, InsertedNote, InsertedNoteWithExtractions, NoteDb, NoteFilter, NoteLookup, - NoteSearch, -}; -use crate::error::CliError; -use crate::types::{Note, Project}; - -const PG_FIND_NOTE: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ - project_id, metadata, source, created_at, updated_at, deleted_at \ - FROM notes WHERE id = $1 AND deleted_at IS NULL LIMIT 1"; -const PG_FIND_ARCHIVED_NOTE: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ - project_id, metadata, source, created_at, updated_at, deleted_at \ - FROM notes WHERE id = $1 AND deleted_at IS NOT NULL LIMIT 1"; -const PG_FIND_PROJECT: &str = "SELECT id, user_id, name, color, is_archived, created_at \ - FROM projects WHERE id = $1 LIMIT 1"; -const PG_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, is_archived, created_at \ - FROM projects WHERE COALESCE(is_archived, false) = false ORDER BY name"; -const PG_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, is_archived, created_at \ - FROM projects WHERE COALESCE(is_archived, false) = true ORDER BY name"; -#[derive(sqlx::FromRow)] -struct NotePgRow { - pub id: Uuid, - pub short_id: Option, - pub user_id: Uuid, - #[sqlx(rename = "type")] - pub r#type: String, - pub status: String, - pub title: Option, - pub content: Option, - pub summary: Option, - pub is_flagged: Option, - pub project_id: Option, - pub metadata: Option, - pub source: Option, - pub created_at: Option>, - pub updated_at: Option>, - pub deleted_at: Option>, -} - -#[derive(sqlx::FromRow)] -struct ProjectPgRow { - pub id: Uuid, - pub user_id: Uuid, - pub name: String, - pub color: Option, - pub is_archived: Option, - pub created_at: Option>, -} - -impl From for Note { - fn from(r: NotePgRow) -> Self { - Self { - id: r.id.to_string(), - short_id: r.short_id.map(i64::from), - user_id: r.user_id.to_string(), - r#type: r.r#type, - status: r.status, - title: r.title, - content: r.content, - summary: r.summary, - is_flagged: r.is_flagged.map(|b| if b { 1 } else { 0 }), - project_id: r.project_id.map(|u| u.to_string()), - metadata: r.metadata.map(|v| v.to_string()), - source: r.source.map(|v| v.to_string()), - created_at: r.created_at.map(|t| t.to_rfc3339()), - updated_at: r.updated_at.map(|t| t.to_rfc3339()), - deleted_at: r.deleted_at.map(|t| t.to_rfc3339()), - } - } -} - -impl From for Project { - fn from(r: ProjectPgRow) -> Self { - Self { - id: r.id.to_string(), - user_id: r.user_id.to_string(), - name: r.name, - color: r.color, - is_archived: r.is_archived.map(|b| if b { 1 } else { 0 }), - created_at: r.created_at.map(|t| t.to_rfc3339()), - } - } -} - -fn parse_uuid(s: &str) -> Result { - Uuid::parse_str(s).map_err(|e| CliError::Database(format!("invalid UUID {s:?}: {e}"))) -} - -fn parse_uuid_opt(s: Option<&str>) -> Result, CliError> { - s.map(parse_uuid).transpose() -} - -fn parse_iso_utc(s: &str) -> Result, CliError> { - DateTime::parse_from_rfc3339(s) - .map(|dt| dt.with_timezone(&Utc)) - .map_err(|e| CliError::Database(format!("invalid ISO timestamp {s:?}: {e}"))) -} - -async fn resolve_pg_note_id( - pool: &PgPool, - input: &str, - uuid_sql: &str, - short_id_sql: &str, -) -> Result { - match crate::backend::parse_note_lookup(input)? { - NoteLookup::ShortId(short_id) => { - if let Some(id) = sqlx::query_scalar::<_, String>(short_id_sql) - .bind(i32::try_from(short_id).map_err(|_| CliError::NoteNotFound { - id: input.to_string(), - })?) - .fetch_optional(pool) - .await? - { - return Ok(id); - } - Err(CliError::NoteNotFound { - id: input.to_string(), - }) - } - NoteLookup::Uuid(uuid) => sqlx::query_scalar::<_, String>(uuid_sql) - .bind(parse_uuid(uuid)?) - .fetch_optional(pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { - id: input.to_string(), - }), - } -} - -async fn resolve_pg_uuid_id( - pool: &PgPool, - sql: &str, - input: &str, - missing: impl FnOnce() -> CliError, -) -> Result { - let uuid = match parse_uuid(input) { - Ok(uuid) => uuid, - Err(_) => return Err(missing()), - }; - let rows = sqlx::query_scalar::<_, String>(sql) - .bind(uuid) - .fetch_all(pool) - .await?; - match rows.as_slice() { - [id] => Ok(id.clone()), - [] => Err(missing()), - [_, _, ..] => unreachable!("exact UUID lookup returns at most one row"), - } -} - -pub struct PgWireBackend { - pool: PgPool, -} - -impl PgWireBackend { - pub async fn connect(database_url: &str) -> Result { - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(database_url) - .await?; - Ok(Self { pool }) - } -} - -#[async_trait] -impl NoteDb for PgWireBackend { - fn user_id(&self) -> &str { - "" - } - - async fn resolve_note_id(&self, prefix: &str) -> Result { - resolve_pg_note_id( - &self.pool, - prefix, - "SELECT id::text FROM notes WHERE id = $1 AND deleted_at IS NULL LIMIT 1", - "SELECT id::text FROM notes WHERE short_id = $1 AND deleted_at IS NULL LIMIT 1", - ) - .await - } - - async fn resolve_archived_note_id(&self, prefix: &str) -> Result { - resolve_pg_note_id( - &self.pool, - prefix, - "SELECT id::text FROM notes WHERE id = $1 AND deleted_at IS NOT NULL LIMIT 1", - "SELECT id::text FROM notes WHERE short_id = $1 AND deleted_at IS NOT NULL LIMIT 1", - ) - .await - } - - async fn find_note(&self, id: &str) -> Result { - sqlx::query_as::<_, NotePgRow>(PG_FIND_NOTE) - .bind(parse_uuid(id)?) - .fetch_optional(&self.pool) - .await? - .map(Note::from) - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn find_archived_note(&self, id: &str) -> Result { - sqlx::query_as::<_, NotePgRow>(PG_FIND_ARCHIVED_NOTE) - .bind(parse_uuid(id)?) - .fetch_optional(&self.pool) - .await? - .map(Note::from) - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn find_note_content(&self, id: &str) -> Result, CliError> { - sqlx::query_scalar::<_, Option>( - "SELECT content FROM notes WHERE id = $1 AND deleted_at IS NULL LIMIT 1", - ) - .bind(parse_uuid(id)?) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn list_notes(&self, filter: &NoteFilter<'_>) -> Result, CliError> { - let project_id = parse_uuid_opt(filter.project_id)?; - let limit = i64::from(filter.limit); - let rows = sqlx::query_as!( - NotePgRow, - r#" - SELECT - id as "id!", - short_id, - user_id as "user_id!", - type as "type!", - status as "status!", - title, - content, - summary, - is_flagged, - project_id, - metadata as "metadata: _", - source as "source: _", - created_at, - updated_at, - deleted_at - FROM notes - WHERE (deleted_at IS NOT NULL) = $1 - AND ($2::text IS NULL OR type = $2) - AND ($3::uuid IS NULL OR project_id = $3) - ORDER BY created_at DESC - LIMIT $4 - "#, - filter.archived, - filter.note_type, - project_id, - limit, - ) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(Note::from).collect()) - } - - async fn search_notes( - &self, - keywords: &[String], - filter: &NoteFilter<'_>, - ) -> Result, CliError> { - if keywords.is_empty() { - return Err(CliError::Other( - "search_notes requires at least one keyword".into(), - )); - } - let project_id = parse_uuid_opt(filter.project_id)?; - let limit = i64::from(filter.limit); - let rows = sqlx::query_as!( - NotePgRow, - r#" - SELECT - id as "id!", - short_id, - user_id as "user_id!", - type as "type!", - status as "status!", - title, - content, - summary, - is_flagged, - project_id, - metadata as "metadata: _", - source as "source: _", - created_at, - updated_at, - deleted_at - FROM notes - WHERE (deleted_at IS NOT NULL) = $1 - AND ($2::text IS NULL OR type = $2) - AND ($3::uuid IS NULL OR project_id = $3) - AND EXISTS ( - SELECT 1 FROM unnest($4::text[]) AS kw(term) - WHERE title ILIKE '%' || kw.term || '%' - OR content ILIKE '%' || kw.term || '%' - OR summary ILIKE '%' || kw.term || '%' - ) - ORDER BY updated_at DESC - LIMIT $5 - "#, - filter.archived, - filter.note_type, - project_id, - keywords, - limit, - ) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(Note::from).collect()) - } - - async fn search_notes_structured( - &self, - search: &NoteSearch, - filter: &NoteFilter<'_>, - ) -> Result, CliError> { - if search.keywords.is_empty() && search.extractions.is_empty() { - return Err(CliError::Other( - "search_notes_structured requires at least one keyword or structured filter".into(), - )); - } - let project_id = parse_uuid_opt(filter.project_id)?; - let limit = i64::from(filter.limit); - let extraction_keys = search - .extractions - .iter() - .map(|filter| filter.key.as_str()) - .collect::>(); - let extraction_values = search - .extractions - .iter() - .map(|filter| filter.value.as_str()) - .collect::>(); - let rows = sqlx::query_as::<_, NotePgRow>( - r#" - SELECT - id, - short_id, - user_id, - type, - status, - title, - content, - summary, - is_flagged, - project_id, - metadata, - source, - created_at, - updated_at, - deleted_at - FROM notes - WHERE (deleted_at IS NOT NULL) = $1 - AND ($2::text IS NULL OR type = $2) - AND ($3::uuid IS NULL OR project_id = $3) - AND ( - cardinality($4::text[]) = 0 OR EXISTS ( - SELECT 1 FROM unnest($4::text[]) AS kw(term) - WHERE title ILIKE '%' || kw.term || '%' - OR content ILIKE '%' || kw.term || '%' - OR summary ILIKE '%' || kw.term || '%' - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM unnest($5::text[], $6::text[]) AS filter(key, value) - WHERE NOT EXISTS ( - SELECT 1 FROM note_extractions extraction - WHERE extraction.note_id = notes.id - AND extraction.key = filter.key - AND extraction.value = filter.value - ) - ) - ORDER BY updated_at DESC - LIMIT $7 - "#, - ) - .bind(filter.archived) - .bind(filter.note_type) - .bind(project_id) - .bind(&search.keywords) - .bind(extraction_keys) - .bind(extraction_values) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(Note::from).collect()) - } - - async fn insert_note(&self, req: &InsertNoteReq<'_>) -> Result { - let metadata: Option = req - .metadata - .map(serde_json::from_str) - .transpose() - .map_err(|e| CliError::Database(format!("invalid metadata JSON: {e}")))?; - let now = parse_iso_utc(req.now)?; - let row = sqlx::query( - "INSERT INTO notes \ - (id, type, status, title, content, metadata, project_id, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id::text, short_id", - ) - .bind(parse_uuid(req.id)?) - .bind(req.note_type) - .bind(req.status) - .bind(req.title) - .bind(req.content) - .bind(metadata) - .bind(parse_uuid_opt(req.project_id)?) - .bind(now) - .bind(now) - .fetch_one(&self.pool) - .await?; - Ok(InsertedNote { - uuid: row.try_get::(0)?, - short_id: row.try_get::, _>(1)?.map(i64::from), - }) - } - - async fn insert_note_with_extractions( - &self, - req: &InsertNoteReq<'_>, - extraction_key: &str, - values: &[String], - ) -> Result { - let metadata: Option = req - .metadata - .map(serde_json::from_str) - .transpose() - .map_err(|e| CliError::Database(format!("invalid metadata JSON: {e}")))?; - let now = parse_iso_utc(req.now)?; - let note_id = parse_uuid(req.id)?; - let mut transaction = self.pool.begin().await?; - let row = sqlx::query( - "INSERT INTO notes \ - (id, type, status, title, content, metadata, project_id, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id::text, short_id", - ) - .bind(note_id) - .bind(req.note_type) - .bind(req.status) - .bind(req.title) - .bind(req.content) - .bind(metadata) - .bind(parse_uuid_opt(req.project_id)?) - .bind(now) - .bind(now) - .fetch_one(&mut *transaction) - .await?; - let mut extraction_ids = Vec::with_capacity(values.len()); - for value in values { - let extraction_id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO note_extractions (id, note_id, user_id, key, value) \ - VALUES ($1, $2, (SELECT user_id FROM notes WHERE id = $2), $3, $4)", - ) - .bind(extraction_id) - .bind(note_id) - .bind(extraction_key) - .bind(value) - .execute(&mut *transaction) - .await?; - extraction_ids.push(extraction_id.to_string()); - } - transaction.commit().await?; - Ok(InsertedNoteWithExtractions { - note: InsertedNote { - uuid: row.try_get::(0)?, - short_id: row.try_get::, _>(1)?.map(i64::from), - }, - extraction_ids, - }) - } - - async fn update_note_content( - &self, - id: &str, - content: &str, - requeue: bool, - ) -> Result<(), CliError> { - let now = Utc::now(); - let result = if requeue { - sqlx::query("UPDATE notes SET content = $1, status = 'ai_queued', updated_at = $2 WHERE id = $3") - .bind(content) - .bind(now) - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await? - } else { - sqlx::query("UPDATE notes SET content = $1, updated_at = $2 WHERE id = $3") - .bind(content) - .bind(now) - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await? - }; - if result.rows_affected() == 0 { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - Ok(()) - } - - async fn set_note_deleted_at( - &self, - id: &str, - deleted_at: Option<&str>, - now: &str, - ) -> Result<(), CliError> { - let result = sqlx::query("UPDATE notes SET deleted_at = $1, updated_at = $2 WHERE id = $3") - .bind(deleted_at.map(parse_iso_utc).transpose()?) - .bind(parse_iso_utc(now)?) - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - Ok(()) - } - - async fn undo_last_delete(&self) -> Result<(), CliError> { - sqlx::query( - "UPDATE notes SET deleted_at = NULL, updated_at = $1 \ - WHERE id = (SELECT id FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 1)", - ) - .bind(Utc::now()) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn find_project_by_name(&self, name: &str) -> Result, CliError> { - Ok(sqlx::query_scalar::<_, String>( - "SELECT id::text FROM projects WHERE name = $1 AND COALESCE(is_archived, false) = false LIMIT 1", - ) - .bind(name) - .fetch_optional(&self.pool) - .await?) - } - - async fn find_project_name_by_id(&self, project_id: &str) -> Result, CliError> { - Ok( - sqlx::query_scalar::<_, String>("SELECT name FROM projects WHERE id = $1 LIMIT 1") - .bind(parse_uuid(project_id)?) - .fetch_optional(&self.pool) - .await?, - ) - } - - async fn list_projects(&self, archived: bool) -> Result, CliError> { - let sql = if archived { - PG_LIST_PROJECTS_ARCHIVED - } else { - PG_LIST_PROJECTS_ACTIVE - }; - let rows = sqlx::query_as::<_, ProjectPgRow>(sql) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(Project::from).collect()) - } - - async fn create_project(&self, name: &str) -> Result { - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO projects (id, name, is_archived, created_at) VALUES ($1, $2, false, $3)", - ) - .bind(id) - .bind(name) - .bind(Utc::now()) - .execute(&self.pool) - .await?; - Ok(id.to_string()) - } - - async fn move_note_to_project( - &self, - note_id: &str, - new_project_id: &str, - old_project_id: Option<&str>, - ) -> Result, CliError> { - let note_uuid = parse_uuid(note_id)?; - let new_project_uuid = parse_uuid(new_project_id)?; - let mut tx = self.pool.begin().await?; - let result = sqlx::query("UPDATE notes SET project_id = $1, updated_at = $2 WHERE id = $3") - .bind(new_project_uuid) - .bind(Utc::now()) - .bind(note_uuid) - .execute(&mut *tx) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::NoteNotFound { - id: note_id.to_string(), - }); - } - let Some(old_pid) = old_project_id else { - tx.commit().await?; - return Ok(None); - }; - let old_uuid = parse_uuid(old_pid)?; - let count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM notes WHERE project_id = $1 AND deleted_at IS NULL", - ) - .bind(old_uuid) - .fetch_one(&mut *tx) - .await?; - if count != 0 { - tx.commit().await?; - return Ok(None); - } - let old_name = sqlx::query_scalar::<_, String>("SELECT name FROM projects WHERE id = $1") - .bind(old_uuid) - .fetch_optional(&mut *tx) - .await?; - sqlx::query("DELETE FROM projects WHERE id = $1") - .bind(old_uuid) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(old_name) - } - - async fn find_project(&self, id: &str) -> Result { - sqlx::query_as::<_, ProjectPgRow>(PG_FIND_PROJECT) - .bind(parse_uuid(id)?) - .fetch_optional(&self.pool) - .await? - .map(Project::from) - .ok_or_else(|| CliError::Other(format!("Project not found: {id}"))) - } - - async fn resolve_project_id(&self, prefix: &str) -> Result { - resolve_pg_uuid_id( - &self.pool, - "SELECT id::text FROM projects WHERE id = $1 LIMIT 1", - prefix, - || CliError::Other(format!("Project not found: {prefix}")), - ) - .await - } - - async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError> { - let update_color = color.is_some(); - if !update_color { - return Ok(()); - } - - let project_id = parse_uuid(id)?; - let color_value = color.flatten(); - let result = sqlx::query!( - r#" - UPDATE projects SET - color = CASE WHEN $2::bool THEN $3::text ELSE color END - WHERE id = $1 - "#, - project_id, - update_color, - color_value, - ) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::Other(format!("Project not found: {id}"))); - } - Ok(()) - } - - async fn delete_project(&self, id: &str) -> Result<(), CliError> { - let result = sqlx::query("UPDATE projects SET is_archived = true WHERE id = $1") - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::Other(format!("Project not found: {id}"))); - } - Ok(()) - } - - async fn update_note_title(&self, id: &str, title: &str) -> Result<(), CliError> { - let result = sqlx::query("UPDATE notes SET title = $1, updated_at = $2 WHERE id = $3") - .bind(title) - .bind(Utc::now()) - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - Ok(()) - } - - async fn update_note_flagged(&self, id: &str, flagged: bool) -> Result<(), CliError> { - let result = sqlx::query("UPDATE notes SET is_flagged = $1, updated_at = $2 WHERE id = $3") - .bind(flagged) - .bind(Utc::now()) - .bind(parse_uuid(id)?) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - Ok(()) - } - - async fn count_notes(&self, filter: &NoteFilter<'_>) -> Result { - let project_id = parse_uuid_opt(filter.project_id)?; - let count = sqlx::query_scalar!( - r#" - SELECT COUNT(*) as "count!" - FROM notes - WHERE (deleted_at IS NOT NULL) = $1 - AND ($2::text IS NULL OR type = $2) - AND ($3::uuid IS NULL OR project_id = $3) - "#, - filter.archived, - filter.note_type, - project_id, - ) - .fetch_one(&self.pool) - .await?; - count - .try_into() - .map_err(|_| CliError::Other(format!("unexpected negative count: {count}"))) - } - - async fn list_note_topics( - &self, - note_ids: &[&str], - ) -> Result>, CliError> { - let extractions = self - .list_note_extractions(note_ids, &[TOPIC_EXTRACTION_KEY]) - .await?; - let mut map = std::collections::HashMap::>::new(); - for (note_id, pairs) in extractions { - map.insert(note_id, pairs.into_iter().map(|(_, value)| value).collect()); - } - Ok(map) - } - async fn list_note_extractions( - &self, - note_ids: &[&str], - extraction_keys: &[&str], - ) -> Result>, CliError> { - if note_ids.is_empty() || extraction_keys.is_empty() { - return Ok(std::collections::HashMap::>::new()); - } - let ids = note_ids - .iter() - .map(|id| parse_uuid(id)) - .collect::, _>>()?; - let rows = sqlx::query( - "SELECT note_id::text, key, value FROM note_extractions WHERE key = ANY($1) AND note_id = ANY($2) ORDER BY key, value", - ) - .bind(extraction_keys) - .bind(&ids) - .fetch_all(&self.pool) - .await?; - let mut map = std::collections::HashMap::>::new(); - for row in rows { - let note_id: String = row.try_get(0)?; - let ext_type: String = row.try_get(1)?; - let value: String = row.try_get(2)?; - map.entry(note_id).or_default().push((ext_type, value)); - } - Ok(map) - } - - async fn list_extraction_values( - &self, - extraction_keys: &[&str], - archived: bool, - ) -> Result, CliError> { - if extraction_keys.is_empty() { - return Ok(Vec::new()); - } - Ok(sqlx::query_scalar::<_, String>( - r#" - SELECT DISTINCT extraction.value - FROM note_extractions extraction - JOIN notes ON notes.id = extraction.note_id - WHERE extraction.key = ANY($1) - AND (notes.deleted_at IS NOT NULL) = $2 - ORDER BY extraction.value - "#, - ) - .bind(extraction_keys) - .bind(archived) - .fetch_all(&self.pool) - .await?) - } - async fn set_note_extractions( - &self, - note_id: &str, - extraction_key: &str, - values: &[String], - ) -> Result<(), CliError> { - let note_uuid = parse_uuid(note_id)?; - // Delete all existing rows for this note + key. - sqlx::query("DELETE FROM note_extractions WHERE note_id = $1 AND key = $2") - .bind(note_uuid) - .bind(extraction_key) - .execute(&self.pool) - .await?; - // Insert new values - for value in values { - // The backend row id is required for sync identity; callers address - // extractions by (note_id, key, value). - sqlx::query( - "INSERT INTO note_extractions (id, note_id, user_id, key, value) VALUES ($1, $2, (SELECT user_id FROM notes WHERE id = $2), $3, $4)", - ) - .bind(Uuid::new_v4()) - .bind(note_uuid) - .bind(extraction_key) - .bind(value) - .execute(&self.pool) - .await?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_uuid_valid() { - let id = "550e8400-e29b-41d4-a716-446655440000"; - let result = parse_uuid(id); - assert!(result.is_ok()); - assert_eq!(result.unwrap().to_string(), id); - } - - #[test] - fn test_parse_uuid_invalid() { - let result = parse_uuid("not-a-uuid"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_uuid_opt_some() { - let id = "550e8400-e29b-41d4-a716-446655440000"; - let result = parse_uuid_opt(Some(id)); - assert!(result.is_ok()); - assert_eq!(result.unwrap().unwrap().to_string(), id); - } - - #[test] - fn test_parse_uuid_opt_none() { - let result = parse_uuid_opt(None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_iso_utc_valid() { - let ts = "2026-04-08T12:00:00Z"; - let result = parse_iso_utc(ts); - assert!(result.is_ok()); - let dt = result.unwrap(); - assert_eq!( - dt.format("%Y-%m-%dT%H:%M:%S").to_string(), - "2026-04-08T12:00:00" - ); - } - - #[test] - fn test_parse_iso_utc_with_offset() { - let ts = "2026-04-08T14:00:00+02:00"; - let result = parse_iso_utc(ts); - assert!(result.is_ok()); - let dt = result.unwrap(); - assert_eq!( - dt.format("%Y-%m-%dT%H:%M:%S").to_string(), - "2026-04-08T12:00:00" - ); - } - - #[test] - fn test_parse_iso_utc_invalid() { - let result = parse_iso_utc("not-a-timestamp"); - assert!(result.is_err()); - } - - #[test] - fn test_note_pg_row_from() { - use chrono::TimeZone; - let pg_row = NotePgRow { - id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), - short_id: Some(42), - user_id: Uuid::nil(), - r#type: "text".into(), - status: "active".into(), - title: Some("Test".into()), - content: None, - summary: None, - is_flagged: Some(true), - project_id: None, - metadata: Some(serde_json::json!({"key": "value"})), - source: None, - created_at: Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).single(), - updated_at: None, - deleted_at: None, - }; - let note: Note = pg_row.into(); - assert_eq!(note.id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(note.short_id, Some(42)); - assert_eq!(note.is_flagged, Some(1)); - assert!(note.created_at.is_some()); - assert!(note.metadata.is_some()); - } - - #[test] - fn test_project_pg_row_from() { - use chrono::TimeZone; - let pg_row = ProjectPgRow { - id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(), - user_id: Uuid::nil(), - name: "My Project".into(), - color: Some("#ff0000".into()), - is_archived: Some(false), - created_at: Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).single(), - }; - let project: Project = pg_row.into(); - assert_eq!(project.id, "550e8400-e29b-41d4-a716-446655440001"); - assert_eq!(project.name, "My Project"); - assert_eq!(project.is_archived, Some(0)); - assert!(project.created_at.is_some()); - } -} diff --git a/flicknote-core/src/services/ports.rs b/flicknote-core/src/services/ports.rs index 3def0ec..eab852a 100644 --- a/flicknote-core/src/services/ports.rs +++ b/flicknote-core/src/services/ports.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use crate::backend::{InsertNoteReq, InsertedNote, NoteDb}; +use crate::backend::{InsertNoteReq, InsertedNote}; use super::error::ServiceError; @@ -46,35 +46,6 @@ pub struct CreatedNote { pub confirmed_extraction_ids: Vec, } -pub struct DirectNoteCreator<'a> { - db: &'a dyn NoteDb, -} - -impl<'a> DirectNoteCreator<'a> { - pub fn new(db: &'a dyn NoteDb) -> Self { - Self { db } - } -} - -#[async_trait] -impl NoteCreator for DirectNoteCreator<'_> { - async fn create(&self, request: CreateNote) -> Result { - let created = self - .db - .insert_note_with_extractions( - &request.as_insert_request(), - crate::TOPIC_EXTRACTION_KEY, - &request.topics, - ) - .await - .map_err(ServiceError::from)?; - Ok(CreatedNote { - inserted: created.note, - confirmed_extraction_ids: created.extraction_ids, - }) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShareResource { Note, @@ -90,76 +61,3 @@ pub trait ShareGateway: Send + Sync { pub trait BrowserOpener { fn open(&self, url: &str) -> Result<(), ServiceError>; } - -#[cfg(all(test, feature = "powersync"))] -mod tests { - use super::*; - use crate::backend::NoteDb; - use crate::services::test_support::make_backend; - - #[tokio::test] - async fn direct_create_rolls_back_note_when_topic_persistence_fails() { - let backend = make_backend().await; - sqlx::query( - "CREATE TRIGGER reject_bad_topic INSTEAD OF INSERT ON note_extractions \ - WHEN NEW.value = 'fail' BEGIN SELECT RAISE(ABORT, 'topic failure'); END", - ) - .execute(&backend.db.pool) - .await - .unwrap(); - let id = uuid::Uuid::new_v4().to_string(); - - let error = DirectNoteCreator::new(&backend) - .create(CreateNote { - id: id.clone(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: chrono::Utc::now().to_rfc3339(), - topics: vec!["fail".to_string()], - attachment_path: None, - }) - .await - .unwrap_err(); - - assert!(error.to_string().contains("topic failure")); - assert!(backend.find_note(&id).await.is_err()); - } - - #[tokio::test] - async fn direct_create_reports_the_committed_extraction_ids() { - let backend = make_backend().await; - let created = DirectNoteCreator::new(&backend) - .create(CreateNote { - id: uuid::Uuid::new_v4().to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: chrono::Utc::now().to_rfc3339(), - topics: vec!["rust".to_string(), "sqlite".to_string()], - attachment_path: None, - }) - .await - .unwrap(); - - let mut stored_ids = sqlx::query_scalar::<_, String>( - "SELECT id FROM note_extractions WHERE note_id = ? ORDER BY id", - ) - .bind(&created.inserted.uuid) - .fetch_all(&backend.db.pool) - .await - .unwrap(); - let mut reported_ids = created.confirmed_extraction_ids; - reported_ids.sort(); - stored_ids.sort(); - - assert_eq!(reported_ids, stored_ids); - assert_eq!(reported_ids.len(), 2); - } -} diff --git a/flicknote-sync/Cargo.toml b/flicknote-sync/Cargo.toml index cc18d27..24eb751 100644 --- a/flicknote-sync/Cargo.toml +++ b/flicknote-sync/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" dist = false [dependencies] -flicknote-core = { path = "../flicknote-core", features = ["storage-pgwire"] } +flicknote-core = { path = "../flicknote-core" } flicknote-auth = { path = "../flicknote-auth" } powersync = { workspace = true } rusqlite = { workspace = true } diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app.rs index 53ea134..a9b34e9 100644 --- a/flicknote-sync/src/app.rs +++ b/flicknote-sync/src/app.rs @@ -4,43 +4,35 @@ use flicknote_core::backend::NoteDb; use flicknote_core::services::dto::NoteAddInput; use flicknote_core::services::error::ServiceError; use flicknote_core::services::note::{NoteService, confirmed_create_followup_error}; -use flicknote_core::services::ports::{CreateNote, DirectNoteCreator, NoteCreator, ShareGateway}; +use flicknote_core::services::ports::{CreateNote, NoteCreator, ShareGateway}; use flicknote_core::services::project::ProjectService; use flicknote_core::services::upload::{self, UploadKind}; -use crate::ipc::{AppRequest, AppResponse, BackendMode, WireError}; +use crate::ipc::{AppRequest, AppResponse, WireError}; pub struct Application { db: Arc, - mode: BackendMode, - creator: Option>, - share_gateway: Option>, + creator: Arc, + share_gateway: Arc, web_url: Option, write_signal: Option>, } impl Application { - pub fn new(db: Arc, mode: BackendMode) -> Self { + pub fn new( + db: Arc, + creator: Arc, + share_gateway: Arc, + ) -> Self { Self { db, - mode, - creator: None, - share_gateway: None, + creator, + share_gateway, web_url: None, write_signal: None, } } - pub fn with_creator(mut self, creator: Arc) -> Self { - self.creator = Some(creator); - self - } - - pub fn with_share_gateway(mut self, gateway: Arc) -> Self { - self.share_gateway = Some(gateway); - self - } - pub fn with_web_url(mut self, web_url: Option) -> Self { self.web_url = web_url; self @@ -51,19 +43,7 @@ impl Application { self } - pub fn mode(&self) -> BackendMode { - self.mode - } - pub async fn handle(&self, request: AppRequest) -> Result { - let required = request.required_capability(); - if !self.mode.supports(required) { - return Err(Self::unsupported( - request.operation_name(), - required, - self.mode, - )); - } let may_write = request.may_write(); let result = self.handle_inner(request).await; if may_write @@ -81,27 +61,11 @@ impl Application { let notes = NoteService::new(self.db.as_ref()); let projects = ProjectService::new(self.db.as_ref()); match request { - AppRequest::NoteAdd(input) => { - if let Some(creator) = self.creator.as_deref() { - return notes - .add(creator, input) - .await - .map(AppResponse::NoteSummary) - .map_err(WireError::from_service); - } - if self.mode == BackendMode::Managed { - return notes - .add(&DirectNoteCreator::new(self.db.as_ref()), input) - .await - .map(AppResponse::NoteSummary) - .map_err(WireError::from_service); - } - Err(Self::unsupported( - "note_add", - crate::ipc::Capability::NoteAdd, - self.mode, - )) - } + AppRequest::NoteAdd(input) => notes + .add(self.creator.as_ref(), input) + .await + .map(AppResponse::NoteSummary) + .map_err(WireError::from_service), AppRequest::NoteAddEditable { document, project } => { let parsed = flicknote_core::services::editable_document::parse_editable_note(&document) @@ -132,20 +96,11 @@ impl Application { topics: parsed.topics, attachment_path: None, }; - let created = if let Some(creator) = self.creator.as_deref() { - creator.create(request).await - } else if self.mode == BackendMode::Managed { - DirectNoteCreator::new(self.db.as_ref()) - .create(request) - .await - } else { - return Err(Self::unsupported( - "note_add_editable", - crate::ipc::Capability::Editor, - self.mode, - )); - } - .map_err(WireError::from_service)?; + let created = self + .creator + .create(request) + .await + .map_err(WireError::from_service)?; notes .get(&created.inserted.uuid, false) .await @@ -176,33 +131,16 @@ impl Application { topics: Vec::new(), created_at, }; - if let Some(creator) = self.creator.as_deref() { - notes.add(creator, input).await - } else if self.mode == BackendMode::Managed { - notes - .add(&DirectNoteCreator::new(self.db.as_ref()), input) - .await - } else { - return Err(Self::unsupported( - "note_upload", - crate::ipc::Capability::Attachment, - self.mode, - )); - } - .map(AppResponse::NoteSummary) - .map_err(WireError::from_service) + notes + .add(self.creator.as_ref(), input) + .await + .map(AppResponse::NoteSummary) + .map_err(WireError::from_service) } UploadKind::Attachment { note_type, metadata, } => { - let creator = self.creator.as_deref().ok_or_else(|| { - Self::unsupported( - "attachment", - crate::ipc::Capability::Attachment, - self.mode, - ) - })?; let project_id = match project.as_deref() { Some(name) => Some( self.db @@ -217,7 +155,8 @@ impl Application { ), None => None, }; - let created = creator + let created = self + .creator .create(CreateNote { id: uuid::Uuid::new_v4().to_string(), note_type: note_type.to_string(), @@ -366,26 +305,16 @@ impl Application { .await .map(AppResponse::NoteArchive) .map_err(WireError::from_service), - AppRequest::NoteShare { id } => { - let gateway = self.share_gateway.as_deref().ok_or_else(|| { - Self::unsupported("note_share", crate::ipc::Capability::Share, self.mode) - })?; - notes - .share(gateway, &id) - .await - .map(AppResponse::Share) - .map_err(WireError::from_service) - } - AppRequest::NoteUnshare { id } => { - let gateway = self.share_gateway.as_deref().ok_or_else(|| { - Self::unsupported("note_unshare", crate::ipc::Capability::Share, self.mode) - })?; - notes - .unshare(gateway, &id) - .await - .map(AppResponse::Unshare) - .map_err(WireError::from_service) - } + AppRequest::NoteShare { id } => notes + .share(self.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Share) + .map_err(WireError::from_service), + AppRequest::NoteUnshare { id } => notes + .unshare(self.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Unshare) + .map_err(WireError::from_service), AppRequest::NoteOpen { id } => { let web_url = self.web_url.as_deref().ok_or_else(|| { WireError::from_service(ServiceError::ConfigMissing("webUrl".to_string())) @@ -448,26 +377,16 @@ impl Application { .await .map(AppResponse::Project) .map_err(WireError::from_service), - AppRequest::ProjectShare { id } => { - let gateway = self.share_gateway.as_deref().ok_or_else(|| { - Self::unsupported("project_share", crate::ipc::Capability::Share, self.mode) - })?; - projects - .share(gateway, &id) - .await - .map(AppResponse::Share) - .map_err(WireError::from_service) - } - AppRequest::ProjectUnshare { id } => { - let gateway = self.share_gateway.as_deref().ok_or_else(|| { - Self::unsupported("project_unshare", crate::ipc::Capability::Share, self.mode) - })?; - projects - .unshare(gateway, &id) - .await - .map(AppResponse::Unshare) - .map_err(WireError::from_service) - } + AppRequest::ProjectShare { id } => projects + .share(self.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Share) + .map_err(WireError::from_service), + AppRequest::ProjectUnshare { id } => projects + .unshare(self.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Unshare) + .map_err(WireError::from_service), AppRequest::ExtractionValues { keys, archived } => { let refs = keys.iter().map(String::as_str).collect::>(); self.db @@ -479,16 +398,6 @@ impl Application { } } - fn unsupported( - operation: &str, - capability: crate::ipc::Capability, - mode: BackendMode, - ) -> WireError { - WireError::from_service(crate::ipc::unsupported_capability( - mode, capability, operation, - )) - } - fn db_error(error: flicknote_core::error::CliError) -> WireError { WireError::from_service(ServiceError::from(error)) } diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs index a59c582..86f13ed 100644 --- a/flicknote-sync/src/ipc.rs +++ b/flicknote-sync/src/ipc.rs @@ -18,125 +18,25 @@ use tokio::net::UnixStream; use crate::app::Application; -pub const PROTOCOL_VERSION: u16 = 1; +pub const PROTOCOL_VERSION: u16 = 2; const IPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const IPC_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const IPC_HEALTH_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const IPC_APP_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum BackendMode { - Local, - Managed, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ClientSurface { - #[default] - Cli, - Mcp, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Capability { - Data, - NoteAdd, - Attachment, - Editor, - Browser, - Mcp, - Share, - LocalSync, -} - -const LOCAL_CAPABILITIES: &[Capability] = &[ - Capability::Data, - Capability::NoteAdd, - Capability::Attachment, - Capability::Editor, - Capability::Browser, - Capability::Mcp, - Capability::Share, - Capability::LocalSync, -]; -const MANAGED_CAPABILITIES: &[Capability] = &[Capability::Data, Capability::NoteAdd]; - -impl BackendMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Managed => "managed", - } - } - - pub const fn capabilities(self) -> &'static [Capability] { - match self { - Self::Local => LOCAL_CAPABILITIES, - Self::Managed => MANAGED_CAPABILITIES, - } - } - - pub fn supports(self, capability: Capability) -> bool { - self.capabilities().contains(&capability) - } -} - -pub fn unsupported_capability( - mode: BackendMode, - capability: Capability, - operation: &str, -) -> ServiceError { - ServiceError::Remote { - code: "unsupported_capability".to_string(), - message: format!( - "{operation} is not available in {} daemon mode", - mode.as_str() - ), - retryable: false, - details: Some(serde_json::json!({ - "operation": operation, - "backend": mode, - "required_capability": capability, - })), - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerInfo { pub protocol: u16, pub version: String, - pub backend: BackendMode, - pub capabilities: Vec, } impl ServerInfo { - pub fn local() -> Self { + pub fn current() -> Self { Self { protocol: PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), - backend: BackendMode::Local, - capabilities: BackendMode::Local.capabilities().to_vec(), } } - - pub fn managed() -> Self { - Self { - protocol: PROTOCOL_VERSION, - version: env!("CARGO_PKG_VERSION").to_string(), - backend: BackendMode::Managed, - capabilities: BackendMode::Managed.capabilities().to_vec(), - } - } - - pub fn require(&self, capability: Capability, operation: &str) -> Result<(), ServiceError> { - if self.capabilities.contains(&capability) { - return Ok(()); - } - Err(unsupported_capability(self.backend, capability, operation)) - } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -269,38 +169,6 @@ impl AppRequest { | Self::ExtractionValues { .. } ) } - - pub fn required_capability(&self) -> Capability { - match self { - Self::NoteAdd(_) => Capability::NoteAdd, - Self::NoteAddEditable { .. } - | Self::NoteLoadEditable { .. } - | Self::NoteSaveEditable { .. } => Capability::Editor, - Self::NoteUpload { .. } => Capability::Attachment, - Self::NoteOpen { .. } => Capability::Browser, - Self::NoteShare { .. } - | Self::NoteUnshare { .. } - | Self::ProjectShare { .. } - | Self::ProjectUnshare { .. } => Capability::Share, - _ => Capability::Data, - } - } - - pub fn operation_name(&self) -> &'static str { - match self { - Self::NoteAdd(_) => "note_add", - Self::NoteAddEditable { .. } => "note_add_editable", - Self::NoteUpload { .. } => "note_upload", - Self::NoteLoadEditable { .. } => "note_load_editable", - Self::NoteSaveEditable { .. } => "note_save_editable", - Self::NoteOpen { .. } => "note_open", - Self::NoteShare { .. } => "note_share", - Self::NoteUnshare { .. } => "note_unshare", - Self::ProjectShare { .. } => "project_share", - Self::ProjectUnshare { .. } => "project_unshare", - _ => "data", - } - } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -436,8 +304,6 @@ pub enum DaemonRequest { }, App { protocol: u16, - #[serde(default)] - surface: ClientSurface, request: Box, }, } @@ -611,22 +477,11 @@ pub async fn send_request( pub struct DaemonClient<'a> { config: &'a Config, - surface: ClientSurface, } impl<'a> DaemonClient<'a> { pub fn new(config: &'a Config) -> Self { - Self { - config, - surface: ClientSurface::Cli, - } - } - - pub fn for_mcp(config: &'a Config) -> Self { - Self { - config, - surface: ClientSurface::Mcp, - } + Self { config } } async fn request(&self, request: DaemonRequest) -> Result { @@ -677,7 +532,6 @@ impl<'a> DaemonClient<'a> { match self .request(DaemonRequest::App { protocol: PROTOCOL_VERSION, - surface: self.surface, request: Box::new(request), }) .await? @@ -799,22 +653,10 @@ async fn serve_app_stream( DaemonRequest::Health { protocol } if protocol == PROTOCOL_VERSION => { DaemonResponse::ServerInfo(info.clone()) } - DaemonRequest::App { - protocol, - surface, - request, - } if protocol == PROTOCOL_VERSION => { - if surface == ClientSurface::Mcp && !info.backend.supports(Capability::Mcp) { - DaemonResponse::AppError(WireError::from_service(unsupported_capability( - info.backend, - Capability::Mcp, - "mcp", - ))) - } else { - match app.handle(*request).await { - Ok(response) => DaemonResponse::App(Box::new(response)), - Err(error) => DaemonResponse::AppError(error), - } + DaemonRequest::App { protocol, request } if protocol == PROTOCOL_VERSION => { + match app.handle(*request).await { + Ok(response) => DaemonResponse::App(Box::new(response)), + Err(error) => DaemonResponse::AppError(error), } } DaemonRequest::Health { protocol } | DaemonRequest::App { protocol, .. } => { @@ -916,6 +758,7 @@ mod tests { #[test] fn versioned_health_and_app_requests_have_stable_contracts() { + assert_eq!(PROTOCOL_VERSION, 2); let health = DaemonRequest::Health { protocol: PROTOCOL_VERSION, }; @@ -929,7 +772,6 @@ mod tests { let request = DaemonRequest::App { protocol: PROTOCOL_VERSION, - surface: ClientSurface::Cli, request: Box::new(AppRequest::NoteList(NoteListInput { note_type: None, project: None, @@ -940,29 +782,22 @@ mod tests { let value = serde_json::to_value(request).unwrap(); assert_eq!(value["type"], "app"); assert_eq!(value["payload"]["protocol"], PROTOCOL_VERSION); - assert_eq!(value["payload"]["surface"], "cli"); + assert!(value["payload"].get("surface").is_none()); assert_eq!(value["payload"]["request"]["type"], "note_list"); } #[test] - fn server_info_reports_backend_mode_and_capabilities() { - let info = ServerInfo::local(); + fn server_info_only_reports_protocol_and_version() { + let info = ServerInfo::current(); assert_eq!(info.protocol, PROTOCOL_VERSION); assert!(!info.version.is_empty()); - assert_eq!(info.backend, BackendMode::Local); - assert!(info.capabilities.contains(&Capability::NoteAdd)); - assert!(info.capabilities.contains(&Capability::Share)); - assert!( - serde_json::to_value(&info).unwrap()["capabilities"] - .as_array() - .unwrap() - .contains(&json!("mcp")) + assert_eq!( + serde_json::to_value(&info).unwrap(), + json!({ + "protocol": PROTOCOL_VERSION, + "version": env!("CARGO_PKG_VERSION"), + }) ); - - let error = ServerInfo::managed() - .require(Capability::Mcp, "mcp") - .unwrap_err(); - assert_eq!(error.code(), "unsupported_capability"); } #[test] @@ -1021,7 +856,6 @@ mod tests { fn mutating_application_requests_do_not_have_an_automatic_response_timeout() { let request = DaemonRequest::App { protocol: PROTOCOL_VERSION, - surface: ClientSurface::Cli, request: Box::new(AppRequest::NoteArchive { id: "note-1".to_string(), }), @@ -1090,6 +924,26 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn protocol_v2_client_rejects_protocol_v1_server_info() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::ServerInfo(ServerInfo { + protocol: 1, + version: "legacy".to_string(), + }), + ) + .await; + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(error.to_string().contains("sync stop")); + server.await.unwrap(); + } + #[tokio::test] async fn application_maps_unknown_envelope_to_protocol_mismatch() { let directory = tempfile::tempdir().unwrap(); @@ -1217,7 +1071,8 @@ mod tests { async fn unexpected_outer_responses_are_classified_by_mutation_safety() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::ServerInfo(ServerInfo::local())).await; + let server = + serve_response(&config, DaemonResponse::ServerInfo(ServerInfo::current())).await; let error = DaemonClient::new(&config) .app(AppRequest::NoteArchive { diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 9eb7a82..7627c6b 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -1800,10 +1800,6 @@ pub async fn run() -> Result<(), Box> { let _pid_guard = check_and_write_pid(&pid_file)?; let (socket_listener, _socket_guard) = bind_socket(&config)?; - if let Ok(database_url) = std::env::var("DATABASE_URL") { - return run_managed(socket_listener, database_url).await; - } - config.validate()?; PowerSyncEnvironment::powersync_auto_extension()?; @@ -1963,14 +1959,12 @@ pub async fn run() -> Result<(), Box> { lock: socket_share_lock, }); let app = Arc::new( - Application::new(backend, ipc::BackendMode::Local) - .with_creator(creator) - .with_share_gateway(gateway) + Application::new(backend, creator, gateway) .with_web_url(config.web_url.clone()) .with_write_signal(trigger_tx), ); let mut socket_handle = tokio::spawn(async move { - if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::local()).await { + if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::current()).await { log::error!("Application socket server failed: {error}"); } }); @@ -2015,22 +2009,6 @@ pub async fn run() -> Result<(), Box> { Ok(()) } -async fn run_managed( - listener: UnixListener, - database_url: String, -) -> Result<(), Box> { - let backend: Arc = - Arc::new(flicknote_core::pgwire::PgWireBackend::connect(&database_url).await?); - let app = Arc::new(Application::new(backend, ipc::BackendMode::Managed)); - log::info!("Managed daemon ready (pid {})", std::process::id()); - tokio::select! { - _ = tokio::signal::ctrl_c() => Ok(()), - result = ipc::serve_app(listener, app, ipc::ServerInfo::managed()) => { - result.map_err(Into::into) - } - } -} - #[cfg(test)] mod tests { use std::io::{Read, Write}; diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 39d989b..2791428 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -4,14 +4,15 @@ use async_trait::async_trait; use flicknote_core::backend::{InsertNoteReq, InsertedNote, NoteDb, SqliteBackend}; use flicknote_core::config::{Config, ConfigPaths}; use flicknote_core::db::Database; -use flicknote_core::services::dto::{ - NoteAddInput, NoteListInput, Patch, ProjectAddInput, ProjectModifyInput, -}; +use flicknote_core::services::dto::{NoteListInput, Patch, ProjectAddInput, ProjectModifyInput}; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::ports::{CreateNote, CreatedNote, NoteCreator}; +use flicknote_core::services::ports::{ + CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, +}; use flicknote_sync::app::Application; use flicknote_sync::ipc::{ - AppRequest, AppResponse, BackendMode, DaemonClient, ServerInfo, serve_app_once, socket_path, + AppRequest, AppResponse, DaemonClient, DaemonRequest, DaemonResponse, ServerInfo, + serve_app_once, socket_path, }; fn test_config(directory: &std::path::Path) -> Config { @@ -38,34 +39,6 @@ fn application_is_safe_to_share_between_daemon_request_tasks() { assert_send_sync::(); } -#[tokio::test] -async fn mcp_surface_is_enforced_on_every_daemon_request() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); - let app = Arc::new(Application::new(backend, BackendMode::Managed)); - let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::managed())); - - let error = DaemonClient::for_mcp(&config) - .call::>(AppRequest::NoteList( - NoteListInput { - note_type: None, - project: None, - archived: false, - limit: 20, - }, - )) - .await - .unwrap_err(); - - assert_eq!(error.code(), "unsupported_capability"); - server.await.unwrap().unwrap(); -} - #[tokio::test] async fn application_signals_every_may_write_request_even_when_it_fails() { let directory = tempfile::tempdir().unwrap(); @@ -75,7 +48,7 @@ async fn application_signals_every_may_write_request_even_when_it_fails() { user_id: "user-1".to_string(), }); let (signal, mut receiver) = tokio::sync::mpsc::channel(4); - let app = Application::new(backend, BackendMode::Local).with_write_signal(signal); + let app = test_app(backend).with_write_signal(signal); app.handle(AppRequest::NoteList(NoteListInput { note_type: None, @@ -130,6 +103,27 @@ impl NoteCreator for DetachedCreator { } } +struct TestShareGateway; + +#[async_trait] +impl ShareGateway for TestShareGateway { + async fn share(&self, _resource: ShareResource, id: &str) -> Result { + Ok(format!("https://share.example/{id}")) + } + + async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { + Ok(()) + } +} + +fn app_with_creator(db: Arc, creator: Arc) -> Application { + Application::new(db, creator, Arc::new(TestShareGateway)) +} + +fn test_app(db: Arc) -> Application { + app_with_creator(db, Arc::new(DetachedCreator)) +} + #[tokio::test] async fn app_preserves_created_identity_when_editor_or_attachment_summary_fails() { let directory = tempfile::tempdir().unwrap(); @@ -140,7 +134,7 @@ async fn app_preserves_created_identity_when_editor_or_attachment_summary_fails( db: Database::open_local(&config).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Application::new(backend, BackendMode::Local).with_creator(Arc::new(DetachedCreator)); + let app = app_with_creator(backend, Arc::new(DetachedCreator)); for request in [ AppRequest::NoteAddEditable { @@ -184,7 +178,7 @@ async fn app_routes_note_list_and_append_through_services() { }) .await .unwrap(); - let app = Application::new(backend.clone(), BackendMode::Local); + let app = test_app(backend.clone()); let listed = app .handle(AppRequest::NoteList(NoteListInput { @@ -238,7 +232,7 @@ async fn app_owns_project_and_catalog_domain_operations() { db: Database::open_local(&config).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Application::new(backend, BackendMode::Local); + let app = test_app(backend); let project = app .handle(AppRequest::ProjectAdd(ProjectAddInput { @@ -274,14 +268,14 @@ async fn versioned_socket_routes_client_requests_through_application() { db: Database::open_local(&config).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Arc::new(Application::new(backend, BackendMode::Local)); + let app = Arc::new(test_app(backend)); let listener = tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)).unwrap(); - let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::local())); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::current())); let client = DaemonClient::new(&config); let info = client.health().await.unwrap(); - assert_eq!(info.backend, BackendMode::Local); + assert_eq!(info.protocol, flicknote_sync::ipc::PROTOCOL_VERSION); server.await.unwrap().unwrap(); std::fs::remove_file(flicknote_sync::ipc::socket_path(&config)).unwrap(); @@ -293,8 +287,8 @@ async fn versioned_socket_routes_client_requests_through_application() { db: Database::open_local(&config2).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Arc::new(Application::new(backend, BackendMode::Local)); - let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::local())); + let app = Arc::new(test_app(backend)); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::current())); let response = client .app(AppRequest::NoteList(NoteListInput { note_type: None, @@ -309,77 +303,44 @@ async fn versioned_socket_routes_client_requests_through_application() { } #[tokio::test] -async fn managed_app_adds_note_and_topics_through_the_backend() { +async fn protocol_v1_app_request_is_rejected_before_application_dispatch() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); let backend = Arc::new(SqliteBackend { db: Database::open_local(&config).await.unwrap(), user_id: "user-1".to_string(), }); - let app = Application::new(backend.clone(), BackendMode::Managed); + let (signal, mut receiver) = tokio::sync::mpsc::channel(1); + let app = Arc::new(test_app(backend).with_write_signal(signal)); + let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::current())); - let response = app - .handle(AppRequest::NoteAdd(NoteAddInput { - content: "# Title\n\nBody".to_string(), - project: None, - interpret_as_url: false, - topics: vec!["rust".to_string()], - created_at: Some("2026-01-02T03:04:05Z".to_string()), - })) + let mut stream = tokio::net::UnixStream::connect(socket_path(&config)) .await .unwrap(); - let AppResponse::NoteSummary(note) = response else { - panic!("unexpected add response") + let request = DaemonRequest::App { + protocol: 1, + request: Box::new(AppRequest::ProjectArchive { + id: "project-1".to_string(), + }), }; - assert_eq!(note.title.as_deref(), Some("Title")); - assert_eq!(note.created_at.as_deref(), Some("2026-01-02T03:04:05Z")); - assert_eq!( - backend - .list_note_topics(&[note.uuid.as_str()]) - .await - .unwrap() - .get(¬e.uuid) - .cloned() - .unwrap_or_default(), - vec!["rust".to_string()] - ); -} - -#[tokio::test] -async fn managed_app_rejects_local_only_workflows() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let upload = directory.path().join("note.md"); - std::fs::write(&upload, "# imported").unwrap(); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); - let app = Application::new(backend, BackendMode::Managed); + stream + .write_all(&serde_json::to_vec(&request).unwrap()) + .await + .unwrap(); + stream.shutdown().await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + let response: DaemonResponse = serde_json::from_slice(&response).unwrap(); - for request in [ - AppRequest::NoteAddEditable { - document: "# editor-created".to_string(), - project: None, - }, - AppRequest::NoteUpload { - path: upload.to_string_lossy().into_owned(), - project: None, - created_at: None, - }, - AppRequest::NoteLoadEditable { - id: "missing".to_string(), - }, - AppRequest::NoteOpen { - id: "missing".to_string(), - }, - AppRequest::NoteShare { - id: "missing".to_string(), - }, - ] { - let error = app.handle(request).await.unwrap_err(); - assert_eq!(error.code, "unsupported_capability"); - } + let DaemonResponse::AppError(error) = response else { + panic!("expected protocol mismatch") + }; + assert_eq!(error.code, "daemon_protocol_mismatch"); + assert!(receiver.try_recv().is_err()); + server.await.unwrap().unwrap(); } #[tokio::test] @@ -396,7 +357,7 @@ async fn local_app_owns_attachment_normalization_and_creator_call() { db: backend.clone(), request: std::sync::Mutex::new(None), }); - let app = Application::new(backend, BackendMode::Local).with_creator(creator.clone()); + let app = app_with_creator(backend, creator.clone()); let response = app .handle(AppRequest::NoteUpload { @@ -426,7 +387,7 @@ async fn app_owns_editable_document_parsing_and_persistence() { db: backend.clone(), request: std::sync::Mutex::new(None), }); - let app = Application::new(backend.clone(), BackendMode::Local).with_creator(creator); + let app = app_with_creator(backend.clone(), creator); let created = app .handle(AppRequest::NoteAddEditable { diff --git a/scripts/sqlx-prepare.sh b/scripts/sqlx-prepare.sh index 724a328..8d59cfd 100755 --- a/scripts/sqlx-prepare.sh +++ b/scripts/sqlx-prepare.sh @@ -5,9 +5,6 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" META_DIR="$ROOT/.sqlx" WORK_DIR="$ROOT/target/sqlx" SQLITE_DB="$WORK_DIR/flicknote-sqlx.sqlite" -SQLITE_META="$WORK_DIR/sqlx-meta-sqlite" -PG_META="$WORK_DIR/sqlx-meta-postgres" -PG_URL="${SQLX_POSTGRES_DATABASE_URL:-postgres://supabase_admin:dev-password@localhost:30432/supabase?search_path=public}" require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then @@ -28,29 +25,7 @@ prepare_sqlite() { --features powersync \ --all-targets - rm -rf "$SQLITE_META" - mkdir -p "$SQLITE_META" - cp "$META_DIR"/*.json "$SQLITE_META"/ -} - -prepare_postgres() { - rm -rf "$META_DIR" - cargo sqlx prepare --workspace -D "$PG_URL" -- \ - -p flicknote-core \ - --no-default-features \ - --features storage-pgwire \ - --all-targets - - rm -rf "$PG_META" - mkdir -p "$PG_META" - cp "$META_DIR"/*.json "$PG_META"/ } mkdir -p "$WORK_DIR" prepare_sqlite -prepare_postgres - -rm -rf "$META_DIR" -mkdir -p "$META_DIR" -cp "$SQLITE_META"/*.json "$META_DIR"/ -cp "$PG_META"/*.json "$META_DIR"/ diff --git a/skills/flicknote.md b/skills/flicknote.md index 15e9212..781c4ee 100644 --- a/skills/flicknote.md +++ b/skills/flicknote.md @@ -70,8 +70,7 @@ Mutating section commands print the updated tree after the change. ## MCP Server `flicknote mcp` serves typed note, source, and project tools over local stdio -and requires a daemon running in local PowerSync mode. Managed daemons reject -MCP startup with `unsupported_capability` before protocol output. +and requires the local PowerSync daemon. Configure an MCP client to run `flicknote` with `args: ["mcp"]`. Content and exact `before`/`after` edits are JSON fields, so MCP callers do not use shell heredocs or edit-mode delimiters. Note tools use numeric short IDs and hide From 9f7deb45b83aaf96cdb7ab8f8b871168a40718b3 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 01:09:15 +0800 Subject: [PATCH 14/16] refactor(sync): split daemon responsibilities --- flicknote-cli/src/main.rs | 613 +--- flicknote-cli/src/main_tests.rs | 609 ++++ flicknote-sync/src/{app.rs => app/mod.rs} | 0 flicknote-sync/src/connector.rs | 44 + flicknote-sync/src/ipc.rs | 1136 ------- flicknote-sync/src/ipc/client.rs | 223 ++ flicknote-sync/src/ipc/mod.rs | 34 + flicknote-sync/src/ipc/protocol.rs | 350 +++ flicknote-sync/src/ipc/server.rs | 104 + flicknote-sync/src/ipc/tests.rs | 439 +++ flicknote-sync/src/lib.rs | 3461 +-------------------- flicknote-sync/src/remote/attachment.rs | 113 + flicknote-sync/src/remote/create.rs | 818 +++++ flicknote-sync/src/remote/create/tests.rs | 701 +++++ flicknote-sync/src/remote/mod.rs | 3 + flicknote-sync/src/remote/share.rs | 245 ++ flicknote-sync/src/remote/share/tests.rs | 106 + flicknote-sync/src/runtime.rs | 318 ++ flicknote-sync/src/storage_maintenance.rs | 81 + flicknote-sync/src/test_support.rs | 240 ++ flicknote-sync/src/upload.rs | 383 +++ flicknote-sync/src/upload/tests.rs | 420 +++ 22 files changed, 5245 insertions(+), 5196 deletions(-) create mode 100644 flicknote-cli/src/main_tests.rs rename flicknote-sync/src/{app.rs => app/mod.rs} (100%) create mode 100644 flicknote-sync/src/connector.rs delete mode 100644 flicknote-sync/src/ipc.rs create mode 100644 flicknote-sync/src/ipc/client.rs create mode 100644 flicknote-sync/src/ipc/mod.rs create mode 100644 flicknote-sync/src/ipc/protocol.rs create mode 100644 flicknote-sync/src/ipc/server.rs create mode 100644 flicknote-sync/src/ipc/tests.rs create mode 100644 flicknote-sync/src/remote/attachment.rs create mode 100644 flicknote-sync/src/remote/create.rs create mode 100644 flicknote-sync/src/remote/create/tests.rs create mode 100644 flicknote-sync/src/remote/mod.rs create mode 100644 flicknote-sync/src/remote/share.rs create mode 100644 flicknote-sync/src/remote/share/tests.rs create mode 100644 flicknote-sync/src/runtime.rs create mode 100644 flicknote-sync/src/storage_maintenance.rs create mode 100644 flicknote-sync/src/test_support.rs create mode 100644 flicknote-sync/src/upload.rs create mode 100644 flicknote-sync/src/upload/tests.rs diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index 729b8b4..8a9bed3 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -166,615 +166,4 @@ async fn dispatch(cli: &Cli, daemon: &DaemonClient<'_>) -> Result<(), CliError> } #[cfg(test)] -mod tests { - use async_trait::async_trait; - use flicknote_core::services::error::ServiceError; - use flicknote_core::services::ports::{ - CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, - }; - - use super::*; - - struct PersistingCreator { - db: std::sync::Arc, - } - - #[async_trait] - impl NoteCreator for PersistingCreator { - async fn create(&self, request: CreateNote) -> Result { - let inserted = self.db.insert_note(&request.as_insert_request()).await?; - Ok(CreatedNote { - inserted, - confirmed_extraction_ids: Vec::new(), - }) - } - } - - struct UnusedShareGateway; - - #[async_trait] - impl ShareGateway for UnusedShareGateway { - async fn share(&self, _resource: ShareResource, _id: &str) -> Result { - Err(ServiceError::Daemon("unexpected share".to_string())) - } - - async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { - Err(ServiceError::Daemon("unexpected unshare".to_string())) - } - } - - async fn call_mcp_tool( - writer: &mut tokio::io::WriteHalf, - reader: &mut tokio::io::BufReader>, - id: u64, - name: &str, - arguments: serde_json::Value, - ) -> serde_json::Value { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - - let request = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": name, "arguments": arguments } - }); - writer - .write_all(format!("{request}\n").as_bytes()) - .await - .unwrap(); - let mut response = String::new(); - reader.read_line(&mut response).await.unwrap(); - serde_json::from_str(&response).unwrap() - } - - fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str) { - match value { - serde_json::Value::String(actual) => assert_ne!(actual, excluded), - serde_json::Value::Array(values) => { - for value in values { - assert_json_does_not_contain_string(value, excluded); - } - } - serde_json::Value::Object(values) => { - for value in values.values() { - assert_json_does_not_contain_string(value, excluded); - } - } - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { - } - } - } - - #[tokio::test(flavor = "current_thread")] - async fn mcp_server_lists_contract_and_calls_note_list() { - use flicknote_core::backend::{NoteDb, SqliteBackend}; - use flicknote_core::db::Database; - use flicknote_sync::app::Application; - use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; - use rmcp::ServiceExt; - use std::rc::Rc; - use std::sync::Arc; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - tokio::task::LocalSet::new() - .run_until(async { - let directory = tempfile::tempdir().unwrap(); - let config = Config { - supabase_url: "https://auth.example.test".to_string(), - supabase_anon_key: "anon-key".to_string(), - powersync_url: String::new(), - api_url: "https://gateway.example.test/api/v1".to_string(), - web_url: Some("https://app.example".to_string()), - paths: flicknote_core::config::ConfigPaths { - config_dir: directory.path().to_path_buf(), - data_dir: directory.path().to_path_buf(), - config_file: directory.path().join("config.json"), - session_file: directory.path().join("session.json"), - db_file: directory.path().join("test.db"), - log_file: directory.path().join("test.log"), - }, - }; - let database = Database::open_local(&config).await.unwrap(); - let backend = Arc::new(SqliteBackend { - db: database, - user_id: "test-user".to_string(), - }); - let project_id = backend.create_project("MCP Project").await.unwrap(); - let note_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&flicknote_core::backend::InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "synced", - title: Some("MCP Note"), - content: Some("## Alpha\n\nOld text.\n\n## Beta\n\nKeep me."), - metadata: None, - project_id: Some(&project_id), - now: "2026-08-05T00:00:00Z", - }) - .await - .unwrap(); - sqlx::query("UPDATE notes SET short_id = 42 WHERE id = ?") - .bind(¬e_id) - .execute(&backend.db.pool) - .await - .unwrap(); - sqlx::query("UPDATE notes SET source = ? WHERE id = ?") - .bind(r#"{"link":{"content":"one\ntwo\nthree"}}"#) - .bind(¬e_id) - .execute(&backend.db.pool) - .await - .unwrap(); - let no_source_note_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&flicknote_core::backend::InsertNoteReq { - id: &no_source_note_id, - note_type: "normal", - status: "synced", - title: Some("No source note"), - content: Some("Editable content"), - metadata: None, - project_id: None, - now: "2026-08-05T00:00:00Z", - }) - .await - .unwrap(); - sqlx::query("UPDATE notes SET short_id = 43 WHERE id = ?") - .bind(&no_source_note_id) - .execute(&backend.db.pool) - .await - .unwrap(); - let alpha_id = flicknote_core::services::markdown::parse_markdown( - "## Alpha\n\nOld text.\n\n## Beta\n\nKeep me.", - ) - .headings[0] - .id - .clone(); - let creator: Arc = Arc::new(PersistingCreator { - db: backend.clone(), - }); - let app = Arc::new( - Application::new( - backend, - creator, - Arc::new(UnusedShareGateway), - ) - .with_web_url(config.web_url.clone()), - ); - let daemon_listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); - let daemon_server = tokio::spawn(serve_app( - daemon_listener, - app, - ServerInfo::current(), - )); - let server = mcp::FlickNoteMcp::new(Rc::new(config)); - let (server_io, client_io) = tokio::io::duplex(8 * 1024); - let server = tokio::task::spawn_local(async move { - server.serve(server_io).await.unwrap().waiting().await - }); - let (client_read, mut client_write) = tokio::io::split(client_io); - let mut client_read = BufReader::new(client_read); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"flicknote-test","version":"0"}}}"#, "\n").as_bytes()) - .await - .unwrap(); - let mut response = String::new(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 1); - assert_eq!(parsed["result"]["serverInfo"]["name"], "flicknote"); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, "\n", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#, "\n").as_bytes()) - .await - .unwrap(); - response.clear(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 2); - let tools = parsed["result"]["tools"].as_array().unwrap(); - let names = tools - .iter() - .map(|tool| tool["name"].as_str().unwrap()) - .collect::>(); - assert_eq!(names, mcp::EXPECTED_TOOLS.into_iter().collect()); - assert!(!names.contains("gateway_web_search")); - assert!(!names.contains("gateway_web_fetch")); - assert!(tools.iter().all(|tool| tool.get("outputSchema").is_some())); - let list_schema = tools - .iter() - .find(|tool| tool["name"] == "note_list") - .unwrap(); - assert_eq!( - list_schema["inputSchema"]["$defs"]["NoteType"]["enum"], - serde_json::json!(["normal", "meeting", "link"]) - ); - let count_schema = tools - .iter() - .find(|tool| tool["name"] == "note_count") - .unwrap(); - assert_eq!( - count_schema["inputSchema"]["$defs"]["NoteType"]["enum"], - serde_json::json!(["normal", "meeting", "link", "file"]) - ); - for tool in tools.iter().filter(|tool| { - tool["name"] - .as_str() - .is_some_and(|name| name.starts_with("note_")) - }) { - let schema = &tool["inputSchema"]; - if schema["properties"].get("id").is_some() { - assert_eq!( - schema["properties"]["id"]["type"], - "integer", - "{} must accept only numeric short IDs", - tool["name"] - ); - } - assert!( - !tool["outputSchema"].to_string().contains("uuid"), - "{} output schema must not expose UUID fields", - tool["name"] - ); - } - let project_get_schema = tools - .iter() - .find(|tool| tool["name"] == "project_get") - .unwrap(); - assert!( - project_get_schema["inputSchema"]["properties"] - .get("project") - .is_some() - ); - assert!( - project_get_schema["inputSchema"]["properties"] - .get("id") - .is_none() - ); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"note_list","arguments":{}}}"#, "\n").as_bytes()) - .await - .unwrap(); - response.clear(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 3); - assert_eq!(parsed["result"]["isError"], false); - assert_eq!(parsed["result"]["structuredContent"].as_array().unwrap().len(), 2); - assert_json_does_not_contain_string( - &parsed["result"]["structuredContent"], - ¬e_id, - ); - - let modified = call_mcp_tool( - &mut client_write, - &mut client_read, - 4, - "note_modify", - serde_json::json!({ - "id": 42, - "before": "Old text.", - "after": "New text.", - "flagged": true - }), - ) - .await; - assert_eq!(modified["result"]["isError"], false); - assert_eq!(modified["result"]["structuredContent"]["note"]["flagged"], true); - assert_json_does_not_contain_string( - &modified["result"]["structuredContent"], - ¬e_id, - ); - - let replaced = call_mcp_tool( - &mut client_write, - &mut client_read, - 5, - "note_replace_section", - serde_json::json!({ - "id": 42, - "section": alpha_id, - "content": "## Alpha revised\n\nReplacement text." - }), - ) - .await; - assert_eq!(replaced["result"]["isError"], false); - - let fetched = call_mcp_tool( - &mut client_write, - &mut client_read, - 6, - "note_get", - serde_json::json!({ "id": 42 }), - ) - .await; - let content = fetched["result"]["structuredContent"]["content"] - .as_str() - .unwrap(); - assert!(content.contains("Replacement text.")); - assert!(content.contains("Keep me.")); - assert!(fetched["result"]["structuredContent"].get("uuid").is_none()); - assert!( - fetched["result"]["structuredContent"] - .get("project_id") - .is_none() - ); - assert_json_does_not_contain_string( - &fetched["result"]["structuredContent"], - ¬e_id, - ); - - let string_id_fetched = call_mcp_tool( - &mut client_write, - &mut client_read, - 17, - "note_get", - serde_json::json!({ "id": "42" }), - ) - .await; - assert_eq!(string_id_fetched["result"]["isError"], false); - assert_eq!(string_id_fetched["result"]["structuredContent"]["id"], 42); - - let uuid_rejected = call_mcp_tool( - &mut client_write, - &mut client_read, - 15, - "note_get", - serde_json::json!({ "id": note_id }), - ) - .await; - assert_eq!(uuid_rejected["result"]["isError"], true); - assert!( - uuid_rejected["result"]["content"][0]["text"] - .as_str() - .unwrap() - .contains("invalid note ID") - ); - - let found = call_mcp_tool( - &mut client_write, - &mut client_read, - 13, - "note_find", - serde_json::json!({ "keywords": ["Replacement"] }), - ) - .await; - assert_eq!( - found["result"]["structuredContent"].as_array().unwrap().len(), - 1 - ); - - let projects = call_mcp_tool( - &mut client_write, - &mut client_read, - 14, - "project_list", - serde_json::json!({}), - ) - .await; - assert_eq!( - projects["result"]["structuredContent"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert!( - projects["result"]["structuredContent"][0] - .get("id") - .is_none() - ); - let project = call_mcp_tool( - &mut client_write, - &mut client_read, - 7, - "project_modify", - serde_json::json!({ - "project": "MCP Project", - "color": "#abcdef" - }), - ) - .await; - assert_eq!( - project["result"]["structuredContent"]["color"], - "#abcdef" - ); - - let source_info = call_mcp_tool( - &mut client_write, - &mut client_read, - 8, - "note_source", - serde_json::json!({ "id": 42, "view": "info" }), - ) - .await; - assert_eq!( - source_info["result"]["structuredContent"], - serde_json::json!({ - "view": "info", - "source_type": "link", - "range_unit": "line", - "count": 3 - }) - ); - - let source_range = call_mcp_tool( - &mut client_write, - &mut client_read, - 9, - "note_source", - serde_json::json!({ - "id": 42, - "view": "rendered", - "range": "2:3" - }), - ) - .await; - assert_eq!( - source_range["result"]["structuredContent"]["content"], - "two\nthree\n" - ); - assert_eq!( - source_range["result"]["structuredContent"]["selected_start"], - 2 - ); - - let no_source = call_mcp_tool( - &mut client_write, - &mut client_read, - 16, - "note_source", - serde_json::json!({ "id": 43, "view": "info" }), - ) - .await; - assert_eq!(no_source["result"]["isError"], true); - assert_eq!( - no_source["result"]["structuredContent"]["code"], - "no_source" - ); - assert_eq!( - no_source["result"]["content"][0]["text"], - "Note has no source data" - ); - - let added = call_mcp_tool( - &mut client_write, - &mut client_read, - 10, - "note_add", - serde_json::json!({ "content": "daemon-backed note" }), - ) - .await; - assert_eq!(added["result"]["isError"], false); - assert_eq!(added["result"]["structuredContent"]["title"], serde_json::Value::Null); - - let archived = call_mcp_tool( - &mut client_write, - &mut client_read, - 11, - "note_archive", - serde_json::json!({ "id": 42 }), - ) - .await; - assert_eq!(archived["result"]["structuredContent"]["archived"], true); - assert_json_does_not_contain_string( - &archived["result"]["structuredContent"], - ¬e_id, - ); - let restored = call_mcp_tool( - &mut client_write, - &mut client_read, - 12, - "note_restore", - serde_json::json!({ "id": 42 }), - ) - .await; - assert_eq!(restored["result"]["structuredContent"]["archived"], false); - - drop(client_write); - drop(client_read); - server.await.unwrap().unwrap(); - daemon_server.abort(); - }) - .await; - } - - #[test] - fn detail_rejects_section_flag() { - assert!(Cli::try_parse_from(["flicknote", "detail", "abc123", "--section", "a1"]).is_err()); - } - - #[test] - fn content_rejects_raw_flag() { - assert!(Cli::try_parse_from(["flicknote", "content", "abc123", "--raw"]).is_err()); - } - - #[test] - fn skill_install_command_parses() { - assert!(Cli::try_parse_from(["flicknote", "skill", "install"]).is_ok()); - } - - #[test] - fn note_share_command_parses() { - assert!(Cli::try_parse_from(["flicknote", "share", "123"]).is_ok()); - } - - #[test] - fn project_share_command_parses() { - assert!( - Cli::try_parse_from([ - "flicknote", - "project", - "share", - "550e8400-e29b-41d4-a716-446655440000", - ]) - .is_ok() - ); - } - - #[test] - fn note_unshare_command_parses() { - assert!(Cli::try_parse_from(["flicknote", "unshare", "123"]).is_ok()); - } - - #[test] - fn project_unshare_command_parses() { - assert!( - Cli::try_parse_from([ - "flicknote", - "project", - "unshare", - "550e8400-e29b-41d4-a716-446655440000", - ]) - .is_ok() - ); - } - - #[test] - fn upload_command_parses() { - assert!(Cli::try_parse_from(["flicknote", "upload", "file.pdf"]).is_ok()); - } - - #[test] - fn metadata_discovery_and_source_commands_parse() { - assert!(Cli::try_parse_from(["flicknote", "topic", "list"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "entity", "list", "--type", "person"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "source", "42"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "source", "42", "12:19"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "source", "42", "--json"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "source", "42", "--info"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", "find", "::topic::AI::person::瓜子"]).is_ok()); - } - - #[test] - fn note_type_filters_accept_meeting_and_reject_voice() { - for command in ["list", "count"] { - assert!(Cli::try_parse_from(["flicknote", command, "--type", "meeting"]).is_ok()); - assert!(Cli::try_parse_from(["flicknote", command, "--type", "voice"]).is_err()); - } - } - - #[test] - fn replace_requires_section() { - assert!(Cli::try_parse_from(["flicknote", "replace", "1"]).is_err()); - assert!(Cli::try_parse_from(["flicknote", "replace", "1", "--section", "a1"]).is_ok()); - } - - #[test] - fn replace_rejects_metadata_flags() { - for flag in ["--project", "--flagged", "--unflagged"] { - let mut argv = vec!["flicknote", "replace", "1", "--section", "a1", flag]; - if flag == "--project" { - argv.push("work"); - } - assert!(Cli::try_parse_from(argv).is_err(), "accepted {flag}"); - } - } - - #[test] - fn mcp_subcommand_parses() { - assert!(Cli::try_parse_from(["flicknote", "mcp"]).is_ok()); - } -} +mod main_tests; diff --git a/flicknote-cli/src/main_tests.rs b/flicknote-cli/src/main_tests.rs new file mode 100644 index 0000000..5567b6c --- /dev/null +++ b/flicknote-cli/src/main_tests.rs @@ -0,0 +1,609 @@ +use async_trait::async_trait; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::ports::{ + CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, +}; + +use super::*; + +struct PersistingCreator { + db: std::sync::Arc, +} + +#[async_trait] +impl NoteCreator for PersistingCreator { + async fn create(&self, request: CreateNote) -> Result { + let inserted = self.db.insert_note(&request.as_insert_request()).await?; + Ok(CreatedNote { + inserted, + confirmed_extraction_ids: Vec::new(), + }) + } +} + +struct UnusedShareGateway; + +#[async_trait] +impl ShareGateway for UnusedShareGateway { + async fn share(&self, _resource: ShareResource, _id: &str) -> Result { + Err(ServiceError::Daemon("unexpected share".to_string())) + } + + async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { + Err(ServiceError::Daemon("unexpected unshare".to_string())) + } +} + +async fn call_mcp_tool( + writer: &mut tokio::io::WriteHalf, + reader: &mut tokio::io::BufReader>, + id: u64, + name: &str, + arguments: serde_json::Value, +) -> serde_json::Value { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }); + writer + .write_all(format!("{request}\n").as_bytes()) + .await + .unwrap(); + let mut response = String::new(); + reader.read_line(&mut response).await.unwrap(); + serde_json::from_str(&response).unwrap() +} + +fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str) { + match value { + serde_json::Value::String(actual) => assert_ne!(actual, excluded), + serde_json::Value::Array(values) => { + for value in values { + assert_json_does_not_contain_string(value, excluded); + } + } + serde_json::Value::Object(values) => { + for value in values.values() { + assert_json_does_not_contain_string(value, excluded); + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } +} + +#[tokio::test(flavor = "current_thread")] +async fn mcp_server_lists_contract_and_calls_note_list() { + use flicknote_core::backend::{NoteDb, SqliteBackend}; + use flicknote_core::db::Database; + use flicknote_sync::app::Application; + use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; + use rmcp::ServiceExt; + use std::rc::Rc; + use std::sync::Arc; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + tokio::task::LocalSet::new() + .run_until(async { + let directory = tempfile::tempdir().unwrap(); + let config = Config { + supabase_url: "https://auth.example.test".to_string(), + supabase_anon_key: "anon-key".to_string(), + powersync_url: String::new(), + api_url: "https://gateway.example.test/api/v1".to_string(), + web_url: Some("https://app.example".to_string()), + paths: flicknote_core::config::ConfigPaths { + config_dir: directory.path().to_path_buf(), + data_dir: directory.path().to_path_buf(), + config_file: directory.path().join("config.json"), + session_file: directory.path().join("session.json"), + db_file: directory.path().join("test.db"), + log_file: directory.path().join("test.log"), + }, + }; + let database = Database::open_local(&config).await.unwrap(); + let backend = Arc::new(SqliteBackend { + db: database, + user_id: "test-user".to_string(), + }); + let project_id = backend.create_project("MCP Project").await.unwrap(); + let note_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&flicknote_core::backend::InsertNoteReq { + id: ¬e_id, + note_type: "normal", + status: "synced", + title: Some("MCP Note"), + content: Some("## Alpha\n\nOld text.\n\n## Beta\n\nKeep me."), + metadata: None, + project_id: Some(&project_id), + now: "2026-08-05T00:00:00Z", + }) + .await + .unwrap(); + sqlx::query("UPDATE notes SET short_id = 42 WHERE id = ?") + .bind(¬e_id) + .execute(&backend.db.pool) + .await + .unwrap(); + sqlx::query("UPDATE notes SET source = ? WHERE id = ?") + .bind(r#"{"link":{"content":"one\ntwo\nthree"}}"#) + .bind(¬e_id) + .execute(&backend.db.pool) + .await + .unwrap(); + let no_source_note_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&flicknote_core::backend::InsertNoteReq { + id: &no_source_note_id, + note_type: "normal", + status: "synced", + title: Some("No source note"), + content: Some("Editable content"), + metadata: None, + project_id: None, + now: "2026-08-05T00:00:00Z", + }) + .await + .unwrap(); + sqlx::query("UPDATE notes SET short_id = 43 WHERE id = ?") + .bind(&no_source_note_id) + .execute(&backend.db.pool) + .await + .unwrap(); + let alpha_id = flicknote_core::services::markdown::parse_markdown( + "## Alpha\n\nOld text.\n\n## Beta\n\nKeep me.", + ) + .headings[0] + .id + .clone(); + let creator: Arc = Arc::new(PersistingCreator { + db: backend.clone(), + }); + let app = Arc::new( + Application::new( + backend, + creator, + Arc::new(UnusedShareGateway), + ) + .with_web_url(config.web_url.clone()), + ); + let daemon_listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + let daemon_server = tokio::spawn(serve_app( + daemon_listener, + app, + ServerInfo::current(), + )); + let server = mcp::FlickNoteMcp::new(Rc::new(config)); + let (server_io, client_io) = tokio::io::duplex(8 * 1024); + let server = tokio::task::spawn_local(async move { + server.serve(server_io).await.unwrap().waiting().await + }); + let (client_read, mut client_write) = tokio::io::split(client_io); + let mut client_read = BufReader::new(client_read); + + client_write + .write_all(concat!(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"flicknote-test","version":"0"}}}"#, "\n").as_bytes()) + .await + .unwrap(); + let mut response = String::new(); + client_read.read_line(&mut response).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed["id"], 1); + assert_eq!(parsed["result"]["serverInfo"]["name"], "flicknote"); + + client_write + .write_all(concat!(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, "\n", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#, "\n").as_bytes()) + .await + .unwrap(); + response.clear(); + client_read.read_line(&mut response).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed["id"], 2); + let tools = parsed["result"]["tools"].as_array().unwrap(); + let names = tools + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect::>(); + assert_eq!(names, mcp::EXPECTED_TOOLS.into_iter().collect()); + assert!(!names.contains("gateway_web_search")); + assert!(!names.contains("gateway_web_fetch")); + assert!(tools.iter().all(|tool| tool.get("outputSchema").is_some())); + let list_schema = tools + .iter() + .find(|tool| tool["name"] == "note_list") + .unwrap(); + assert_eq!( + list_schema["inputSchema"]["$defs"]["NoteType"]["enum"], + serde_json::json!(["normal", "meeting", "link"]) + ); + let count_schema = tools + .iter() + .find(|tool| tool["name"] == "note_count") + .unwrap(); + assert_eq!( + count_schema["inputSchema"]["$defs"]["NoteType"]["enum"], + serde_json::json!(["normal", "meeting", "link", "file"]) + ); + for tool in tools.iter().filter(|tool| { + tool["name"] + .as_str() + .is_some_and(|name| name.starts_with("note_")) + }) { + let schema = &tool["inputSchema"]; + if schema["properties"].get("id").is_some() { + assert_eq!( + schema["properties"]["id"]["type"], + "integer", + "{} must accept only numeric short IDs", + tool["name"] + ); + } + assert!( + !tool["outputSchema"].to_string().contains("uuid"), + "{} output schema must not expose UUID fields", + tool["name"] + ); + } + let project_get_schema = tools + .iter() + .find(|tool| tool["name"] == "project_get") + .unwrap(); + assert!( + project_get_schema["inputSchema"]["properties"] + .get("project") + .is_some() + ); + assert!( + project_get_schema["inputSchema"]["properties"] + .get("id") + .is_none() + ); + + client_write + .write_all(concat!(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"note_list","arguments":{}}}"#, "\n").as_bytes()) + .await + .unwrap(); + response.clear(); + client_read.read_line(&mut response).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed["id"], 3); + assert_eq!(parsed["result"]["isError"], false); + assert_eq!(parsed["result"]["structuredContent"].as_array().unwrap().len(), 2); + assert_json_does_not_contain_string( + &parsed["result"]["structuredContent"], + ¬e_id, + ); + + let modified = call_mcp_tool( + &mut client_write, + &mut client_read, + 4, + "note_modify", + serde_json::json!({ + "id": 42, + "before": "Old text.", + "after": "New text.", + "flagged": true + }), + ) + .await; + assert_eq!(modified["result"]["isError"], false); + assert_eq!(modified["result"]["structuredContent"]["note"]["flagged"], true); + assert_json_does_not_contain_string( + &modified["result"]["structuredContent"], + ¬e_id, + ); + + let replaced = call_mcp_tool( + &mut client_write, + &mut client_read, + 5, + "note_replace_section", + serde_json::json!({ + "id": 42, + "section": alpha_id, + "content": "## Alpha revised\n\nReplacement text." + }), + ) + .await; + assert_eq!(replaced["result"]["isError"], false); + + let fetched = call_mcp_tool( + &mut client_write, + &mut client_read, + 6, + "note_get", + serde_json::json!({ "id": 42 }), + ) + .await; + let content = fetched["result"]["structuredContent"]["content"] + .as_str() + .unwrap(); + assert!(content.contains("Replacement text.")); + assert!(content.contains("Keep me.")); + assert!(fetched["result"]["structuredContent"].get("uuid").is_none()); + assert!( + fetched["result"]["structuredContent"] + .get("project_id") + .is_none() + ); + assert_json_does_not_contain_string( + &fetched["result"]["structuredContent"], + ¬e_id, + ); + + let string_id_fetched = call_mcp_tool( + &mut client_write, + &mut client_read, + 17, + "note_get", + serde_json::json!({ "id": "42" }), + ) + .await; + assert_eq!(string_id_fetched["result"]["isError"], false); + assert_eq!(string_id_fetched["result"]["structuredContent"]["id"], 42); + + let uuid_rejected = call_mcp_tool( + &mut client_write, + &mut client_read, + 15, + "note_get", + serde_json::json!({ "id": note_id }), + ) + .await; + assert_eq!(uuid_rejected["result"]["isError"], true); + assert!( + uuid_rejected["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("invalid note ID") + ); + + let found = call_mcp_tool( + &mut client_write, + &mut client_read, + 13, + "note_find", + serde_json::json!({ "keywords": ["Replacement"] }), + ) + .await; + assert_eq!( + found["result"]["structuredContent"].as_array().unwrap().len(), + 1 + ); + + let projects = call_mcp_tool( + &mut client_write, + &mut client_read, + 14, + "project_list", + serde_json::json!({}), + ) + .await; + assert_eq!( + projects["result"]["structuredContent"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert!( + projects["result"]["structuredContent"][0] + .get("id") + .is_none() + ); + let project = call_mcp_tool( + &mut client_write, + &mut client_read, + 7, + "project_modify", + serde_json::json!({ + "project": "MCP Project", + "color": "#abcdef" + }), + ) + .await; + assert_eq!( + project["result"]["structuredContent"]["color"], + "#abcdef" + ); + + let source_info = call_mcp_tool( + &mut client_write, + &mut client_read, + 8, + "note_source", + serde_json::json!({ "id": 42, "view": "info" }), + ) + .await; + assert_eq!( + source_info["result"]["structuredContent"], + serde_json::json!({ + "view": "info", + "source_type": "link", + "range_unit": "line", + "count": 3 + }) + ); + + let source_range = call_mcp_tool( + &mut client_write, + &mut client_read, + 9, + "note_source", + serde_json::json!({ + "id": 42, + "view": "rendered", + "range": "2:3" + }), + ) + .await; + assert_eq!( + source_range["result"]["structuredContent"]["content"], + "two\nthree\n" + ); + assert_eq!( + source_range["result"]["structuredContent"]["selected_start"], + 2 + ); + + let no_source = call_mcp_tool( + &mut client_write, + &mut client_read, + 16, + "note_source", + serde_json::json!({ "id": 43, "view": "info" }), + ) + .await; + assert_eq!(no_source["result"]["isError"], true); + assert_eq!( + no_source["result"]["structuredContent"]["code"], + "no_source" + ); + assert_eq!( + no_source["result"]["content"][0]["text"], + "Note has no source data" + ); + + let added = call_mcp_tool( + &mut client_write, + &mut client_read, + 10, + "note_add", + serde_json::json!({ "content": "daemon-backed note" }), + ) + .await; + assert_eq!(added["result"]["isError"], false); + assert_eq!(added["result"]["structuredContent"]["title"], serde_json::Value::Null); + + let archived = call_mcp_tool( + &mut client_write, + &mut client_read, + 11, + "note_archive", + serde_json::json!({ "id": 42 }), + ) + .await; + assert_eq!(archived["result"]["structuredContent"]["archived"], true); + assert_json_does_not_contain_string( + &archived["result"]["structuredContent"], + ¬e_id, + ); + let restored = call_mcp_tool( + &mut client_write, + &mut client_read, + 12, + "note_restore", + serde_json::json!({ "id": 42 }), + ) + .await; + assert_eq!(restored["result"]["structuredContent"]["archived"], false); + + drop(client_write); + drop(client_read); + server.await.unwrap().unwrap(); + daemon_server.abort(); + }) + .await; +} + +#[test] +fn detail_rejects_section_flag() { + assert!(Cli::try_parse_from(["flicknote", "detail", "abc123", "--section", "a1"]).is_err()); +} + +#[test] +fn content_rejects_raw_flag() { + assert!(Cli::try_parse_from(["flicknote", "content", "abc123", "--raw"]).is_err()); +} + +#[test] +fn skill_install_command_parses() { + assert!(Cli::try_parse_from(["flicknote", "skill", "install"]).is_ok()); +} + +#[test] +fn note_share_command_parses() { + assert!(Cli::try_parse_from(["flicknote", "share", "123"]).is_ok()); +} + +#[test] +fn project_share_command_parses() { + assert!( + Cli::try_parse_from([ + "flicknote", + "project", + "share", + "550e8400-e29b-41d4-a716-446655440000", + ]) + .is_ok() + ); +} + +#[test] +fn note_unshare_command_parses() { + assert!(Cli::try_parse_from(["flicknote", "unshare", "123"]).is_ok()); +} + +#[test] +fn project_unshare_command_parses() { + assert!( + Cli::try_parse_from([ + "flicknote", + "project", + "unshare", + "550e8400-e29b-41d4-a716-446655440000", + ]) + .is_ok() + ); +} + +#[test] +fn upload_command_parses() { + assert!(Cli::try_parse_from(["flicknote", "upload", "file.pdf"]).is_ok()); +} + +#[test] +fn metadata_discovery_and_source_commands_parse() { + assert!(Cli::try_parse_from(["flicknote", "topic", "list"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "entity", "list", "--type", "person"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "source", "42"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "source", "42", "12:19"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "source", "42", "--json"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "source", "42", "--info"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "find", "::topic::AI::person::瓜子"]).is_ok()); +} + +#[test] +fn note_type_filters_accept_meeting_and_reject_voice() { + for command in ["list", "count"] { + assert!(Cli::try_parse_from(["flicknote", command, "--type", "meeting"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", command, "--type", "voice"]).is_err()); + } +} + +#[test] +fn replace_requires_section() { + assert!(Cli::try_parse_from(["flicknote", "replace", "1"]).is_err()); + assert!(Cli::try_parse_from(["flicknote", "replace", "1", "--section", "a1"]).is_ok()); +} + +#[test] +fn replace_rejects_metadata_flags() { + for flag in ["--project", "--flagged", "--unflagged"] { + let mut argv = vec!["flicknote", "replace", "1", "--section", "a1", flag]; + if flag == "--project" { + argv.push("work"); + } + assert!(Cli::try_parse_from(argv).is_err(), "accepted {flag}"); + } +} + +#[test] +fn mcp_subcommand_parses() { + assert!(Cli::try_parse_from(["flicknote", "mcp"]).is_ok()); +} diff --git a/flicknote-sync/src/app.rs b/flicknote-sync/src/app/mod.rs similarity index 100% rename from flicknote-sync/src/app.rs rename to flicknote-sync/src/app/mod.rs diff --git a/flicknote-sync/src/connector.rs b/flicknote-sync/src/connector.rs new file mode 100644 index 0000000..a0173cb --- /dev/null +++ b/flicknote-sync/src/connector.rs @@ -0,0 +1,44 @@ +use crate::*; + +#[async_trait] +impl BackendConnector for FlickNoteConnector { + async fn fetch_credentials(&self) -> Result { + let session = self + .auth + .get_session() + .await + .map_err(|e| ps_err(format!("Auth error: {e}")))?; + + Ok(PowerSyncCredentials { + endpoint: self.powersync_url.clone(), + token: session.access_token, + }) + } + + async fn upload_data(&self) -> Result<(), PowerSyncError> { + let _guard = self.upload_guard.lock().await; + let token = self.get_token().await?; + // Ignore the bool — checkpoint is only safe to call from the serialized drain path, + // not here (SDK callback fires during active sync alongside the download actor). + run_upload( + &self.db, + &self.http_client, + &token, + &self.supabase_url, + &self.supabase_anon_key, + ) + .await?; + Ok(()) + } +} + +impl FlickNoteConnector { + async fn get_token(&self) -> Result { + let session = self + .auth + .get_session() + .await + .map_err(|e| ps_err(format!("Auth error: {e}")))?; + Ok(session.access_token) + } +} diff --git a/flicknote-sync/src/ipc.rs b/flicknote-sync/src/ipc.rs deleted file mode 100644 index 86f13ed..0000000 --- a/flicknote-sync/src/ipc.rs +++ /dev/null @@ -1,1136 +0,0 @@ -use std::fmt; -use std::path::PathBuf; - -use flicknote_core::config::Config; -use flicknote_core::services::dto::{ - InsertPosition, NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, - NoteListInput, NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, - ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, -}; -use flicknote_core::services::editable_document::EditableSaveResult; -use flicknote_core::services::error::ServiceError; -use flicknote_core::services::source::{SourceResult, SourceView}; -use flicknote_core::types::{Note, Project}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::UnixListener; -use tokio::net::UnixStream; - -use crate::app::Application; - -pub const PROTOCOL_VERSION: u16 = 2; -const IPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); -const IPC_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); -const IPC_HEALTH_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); -const IPC_APP_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerInfo { - pub protocol: u16, - pub version: String, -} - -impl ServerInfo { - pub fn current() -> Self { - Self { - protocol: PROTOCOL_VERSION, - version: env!("CARGO_PKG_VERSION").to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload", rename_all = "snake_case")] -pub enum AppRequest { - NoteAdd(NoteAddInput), - NoteAddEditable { - document: String, - project: Option, - }, - NoteUpload { - path: String, - project: Option, - created_at: Option, - }, - NoteList(NoteListInput), - NoteFind(NoteFindInput), - NoteCount(NoteCountInput), - NoteGet { - id: String, - archived: bool, - }, - NoteLoadEditable { - id: String, - }, - NoteRecord { - id: String, - archived: bool, - }, - NoteGetSection { - id: String, - section: String, - }, - NoteSource { - id: String, - archived: bool, - view: SourceView, - range: Option, - }, - NoteAppend { - id: String, - content: String, - }, - NoteSaveEditable { - id: String, - document: String, - }, - NoteReplaceSection { - id: String, - section: String, - content: String, - }, - NoteRenameSection { - id: String, - section: String, - name: String, - }, - NoteInsert { - id: String, - section: String, - position: InsertPosition, - content: String, - }, - NoteDeleteSection { - id: String, - section: String, - }, - NoteModify(NoteModifyInput), - NoteArchive { - id: String, - }, - NoteRestore { - id: String, - }, - NoteShare { - id: String, - }, - NoteUnshare { - id: String, - }, - NoteOpen { - id: String, - }, - ProjectList { - include_archived: bool, - }, - ProjectRecords { - include_archived: bool, - }, - ProjectGet { - id: String, - }, - ProjectGetByName { - name: String, - }, - ProjectAdd(ProjectAddInput), - ProjectModify(ProjectModifyInput), - ProjectArchive { - id: String, - }, - ProjectShare { - id: String, - }, - ProjectUnshare { - id: String, - }, - ExtractionValues { - keys: Vec, - archived: bool, - }, -} - -impl AppRequest { - pub fn may_write(&self) -> bool { - !matches!( - self, - Self::NoteList(_) - | Self::NoteFind(_) - | Self::NoteCount(_) - | Self::NoteGet { .. } - | Self::NoteLoadEditable { .. } - | Self::NoteRecord { .. } - | Self::NoteGetSection { .. } - | Self::NoteSource { .. } - | Self::NoteOpen { .. } - | Self::ProjectList { .. } - | Self::ProjectRecords { .. } - | Self::ProjectGet { .. } - | Self::ProjectGetByName { .. } - | Self::ExtractionValues { .. } - ) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload", rename_all = "snake_case")] -pub enum AppResponse { - NoteSummary(NoteSummary), - NoteSummaries(Vec), - NoteCount { count: u64 }, - NoteDetail(NoteDetail), - EditableDocument(EditableDocument), - NoteRecord(Note), - NoteSection(NoteSectionResult), - NoteMutation(NoteMutationResult), - EditableSave(EditableSaveResult), - NoteArchive(NoteArchiveResult), - Source(SourceResult), - Share(ShareResult), - Unshare(UnshareResult), - Open(OpenResult), - Projects(Vec), - ProjectRecords(Vec), - Project(ProjectDto), - Id { id: String }, - Values(Vec), - Unit, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EditableDocument { - pub document: String, -} - -pub trait AppResult: Sized { - fn from_response(response: AppResponse) -> Option; -} - -macro_rules! app_result { - ($type:ty, $variant:path) => { - impl AppResult for $type { - fn from_response(response: AppResponse) -> Option { - match response { - $variant(value) => Some(value), - _ => None, - } - } - } - }; -} - -app_result!(NoteSummary, AppResponse::NoteSummary); -app_result!(Vec, AppResponse::NoteSummaries); -app_result!(NoteDetail, AppResponse::NoteDetail); -app_result!(EditableDocument, AppResponse::EditableDocument); -app_result!(Note, AppResponse::NoteRecord); -app_result!(NoteSectionResult, AppResponse::NoteSection); -app_result!(NoteMutationResult, AppResponse::NoteMutation); -app_result!(EditableSaveResult, AppResponse::EditableSave); -app_result!(NoteArchiveResult, AppResponse::NoteArchive); -app_result!(SourceResult, AppResponse::Source); -app_result!(ShareResult, AppResponse::Share); -app_result!(UnshareResult, AppResponse::Unshare); -app_result!(OpenResult, AppResponse::Open); -app_result!(Vec, AppResponse::Projects); -app_result!(Vec, AppResponse::ProjectRecords); -app_result!(ProjectDto, AppResponse::Project); -app_result!(Vec, AppResponse::Values); - -impl AppResult for u64 { - fn from_response(response: AppResponse) -> Option { - match response { - AppResponse::NoteCount { count } => Some(count), - _ => None, - } - } -} - -impl AppResult for String { - fn from_response(response: AppResponse) -> Option { - match response { - AppResponse::Id { id } => Some(id), - _ => None, - } - } -} - -impl AppResult for () { - fn from_response(response: AppResponse) -> Option { - match response { - AppResponse::Unit => Some(()), - _ => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct WireError { - pub code: String, - pub message: String, - pub retryable: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -impl WireError { - pub fn from_service(error: ServiceError) -> Self { - match error { - ServiceError::Remote { - code, - message, - retryable, - details, - } => Self { - code, - message, - retryable, - details, - }, - error => Self { - code: error.code().to_string(), - message: error.to_string(), - retryable: error.retryable(), - details: None, - }, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload", rename_all = "snake_case")] -pub enum DaemonRequest { - Health { - protocol: u16, - }, - App { - protocol: u16, - request: Box, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload", rename_all = "snake_case")] -pub enum DaemonResponse { - ServerInfo(ServerInfo), - App(Box), - AppError(WireError), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "code", rename_all = "snake_case")] -pub enum DaemonError { - Unavailable { - path: String, - message: String, - }, - PartialCreate { - message: String, - note_id: String, - short_id: Option, - confirmed_extraction_ids: Vec, - pending_extraction_ids: Vec, - }, - AmbiguousCreate { - message: String, - note_id: String, - pending_extraction_ids: Vec, - }, - InvalidResponse { - message: String, - }, - IncompleteResponse { - message: String, - }, - MalformedResponse { - message: String, - }, - PostConnectTransport { - message: String, - }, - Other { - message: String, - }, -} - -impl fmt::Display for DaemonError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unavailable { path, message } => { - write!(f, "Sync daemon is not available at {path}: {message}") - } - Self::PartialCreate { message, .. } - | Self::AmbiguousCreate { message, .. } - | Self::InvalidResponse { message } - | Self::IncompleteResponse { message } - | Self::MalformedResponse { message } - | Self::PostConnectTransport { message } - | Self::Other { message } => f.write_str(message), - } - } -} - -impl std::error::Error for DaemonError {} - -pub fn socket_path(config: &Config) -> PathBuf { - config.paths.data_dir.join("sync.sock") -} - -fn unavailable(path: &std::path::Path, stage: &str) -> DaemonError { - DaemonError::Unavailable { - path: path.display().to_string(), - message: format!("timed out while {stage}"), - } -} - -fn request_timeout_error( - request: &DaemonRequest, - path: &std::path::Path, - stage: &str, -) -> DaemonError { - if !is_mutating_app_request(request) { - return unavailable(path, stage); - } - DaemonError::Other { - message: format!( - "Timed out while {stage} from the sync daemon at {}; the application request outcome is unknown. Do not retry it automatically.", - path.display() - ), - } -} - -fn is_mutating_app_request(request: &DaemonRequest) -> bool { - matches!(request, DaemonRequest::App { request, .. } if request.may_write()) -} - -fn response_timeout_for(request: &DaemonRequest) -> Option { - match request { - DaemonRequest::Health { .. } => Some(IPC_HEALTH_RESPONSE_TIMEOUT), - // Once a write request may have reached the daemon, a transport timeout cannot tell - // whether it committed. Keep waiting for the authoritative response until the protocol - // has durable operation IDs and status reconciliation (tracked as FlickNote #1785). - DaemonRequest::App { request, .. } if request.may_write() => None, - DaemonRequest::App { .. } => Some(IPC_APP_RESPONSE_TIMEOUT), - } -} - -pub async fn send_request( - config: &Config, - request: &DaemonRequest, -) -> Result { - let path = socket_path(config); - let request_bytes = serde_json::to_vec(request).map_err(|e| DaemonError::Other { - message: format!("Failed to serialize daemon request: {e}"), - })?; - let mut stream = tokio::time::timeout(IPC_CONNECT_TIMEOUT, UnixStream::connect(&path)) - .await - .map_err(|_| unavailable(&path, "connecting"))? - .map_err(|error| DaemonError::Unavailable { - path: path.display().to_string(), - message: error.to_string(), - })?; - let write_request = async { - stream.write_all(&request_bytes).await?; - stream.shutdown().await - }; - if is_mutating_app_request(request) { - write_request - .await - .map_err(|error| DaemonError::PostConnectTransport { - message: format!("Failed to send daemon request: {error}"), - })?; - } else { - tokio::time::timeout(IPC_WRITE_TIMEOUT, write_request) - .await - .map_err(|_| request_timeout_error(request, &path, "sending a request"))? - .map_err(|error| DaemonError::PostConnectTransport { - message: format!("Failed to send daemon request: {error}"), - })?; - } - let mut buf = Vec::new(); - match response_timeout_for(request) { - Some(response_timeout) => { - tokio::time::timeout(response_timeout, stream.read_to_end(&mut buf)) - .await - .map_err(|_| request_timeout_error(request, &path, "waiting for a response"))? - } - None => stream.read_to_end(&mut buf).await, - } - .map_err(|e| DaemonError::PostConnectTransport { - message: format!("Failed to read daemon response: {e}"), - })?; - serde_json::from_slice(&buf).map_err(|e| { - if e.is_eof() { - return DaemonError::IncompleteResponse { - message: format!("Daemon closed the connection before a complete response: {e}"), - }; - } - match serde_json::from_slice::(&buf) { - Ok(_) => DaemonError::InvalidResponse { - message: format!("Daemon returned an incompatible response: {e}"), - }, - Err(raw_error) => DaemonError::MalformedResponse { - message: format!("Daemon returned a malformed response: {raw_error}"), - }, - } - }) -} - -pub struct DaemonClient<'a> { - config: &'a Config, -} - -impl<'a> DaemonClient<'a> { - pub fn new(config: &'a Config) -> Self { - Self { config } - } - - async fn request(&self, request: DaemonRequest) -> Result { - let is_mutating = is_mutating_app_request(&request); - send_request(self.config, &request) - .await - .map_err(|error| match error { - DaemonError::Unavailable { .. } => ServiceError::DaemonUnavailable(format!( - "{error}. Start it with `flicknote sync start`." - )), - DaemonError::IncompleteResponse { .. } - | DaemonError::MalformedResponse { .. } - | DaemonError::PostConnectTransport { .. } - if !is_mutating => - { - ServiceError::DaemonUnavailable(format!( - "Sync daemon is not ready: {error}. Start it with `flicknote sync start`." - )) - } - DaemonError::IncompleteResponse { message } - | DaemonError::MalformedResponse { message } - | DaemonError::PostConnectTransport { message } - | DaemonError::InvalidResponse { message } - if is_mutating => - { - Self::outcome_unknown(message) - } - DaemonError::InvalidResponse { .. } => Self::protocol_mismatch(), - other => ServiceError::Daemon(other.to_string()), - }) - } - - pub async fn health(&self) -> Result { - match self - .request(DaemonRequest::Health { - protocol: PROTOCOL_VERSION, - }) - .await? - { - DaemonResponse::ServerInfo(info) if info.protocol == PROTOCOL_VERSION => Ok(info), - DaemonResponse::AppError(error) => Err(Self::remote_error(error)), - _ => Err(Self::protocol_mismatch()), - } - } - - pub async fn app(&self, request: AppRequest) -> Result { - let may_write = request.may_write(); - match self - .request(DaemonRequest::App { - protocol: PROTOCOL_VERSION, - request: Box::new(request), - }) - .await? - { - DaemonResponse::App(response) => Ok(*response), - DaemonResponse::AppError(error) => Err(Self::remote_error(error)), - _ if may_write => Err(Self::outcome_unknown( - "The daemon returned an unexpected envelope after a mutating request; the operation outcome is unknown." - .to_string(), - )), - _ => Err(Self::protocol_mismatch()), - } - } - - pub async fn call(&self, request: AppRequest) -> Result { - let may_write = request.may_write(); - let response = self.app(request).await?; - T::from_response(response).ok_or_else(|| { - if may_write { - Self::outcome_unknown( - "The daemon returned an unexpected response after a mutating request; the operation outcome is unknown.".to_string(), - ) - } else { - Self::protocol_mismatch() - } - }) - } - - fn remote_error(error: WireError) -> ServiceError { - ServiceError::Remote { - code: error.code, - message: error.message, - retryable: error.retryable, - details: error.details, - } - } - - fn protocol_mismatch() -> ServiceError { - ServiceError::Remote { - code: "daemon_protocol_mismatch".to_string(), - message: "The running sync daemon uses an incompatible protocol. Restart it with `flicknote sync stop && flicknote sync start`.".to_string(), - retryable: false, - details: None, - } - } - - fn outcome_unknown(message: String) -> ServiceError { - ServiceError::Remote { - code: "daemon_request_outcome_unknown".to_string(), - message, - retryable: false, - details: None, - } - } -} - -pub async fn read_request(stream: &mut UnixStream) -> Result { - let mut buf = Vec::new(); - stream - .read_to_end(&mut buf) - .await - .map_err(|e| DaemonError::Other { - message: format!("Failed to read daemon request: {e}"), - })?; - serde_json::from_slice(&buf).map_err(|e| DaemonError::Other { - message: format!("Failed to parse daemon request: {e}"), - }) -} - -pub async fn write_response( - stream: &mut UnixStream, - response: &DaemonResponse, -) -> Result<(), DaemonError> { - write_json(stream, response).await -} - -pub async fn serve_app_once( - listener: UnixListener, - app: std::sync::Arc, - info: ServerInfo, -) -> Result<(), DaemonError> { - let (mut stream, _) = listener - .accept() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to accept daemon request: {error}"), - })?; - serve_app_stream(&mut stream, &app, &info).await -} - -pub async fn serve_app( - listener: UnixListener, - app: std::sync::Arc, - info: ServerInfo, -) -> Result<(), DaemonError> { - loop { - let (mut stream, _) = listener - .accept() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to accept daemon request: {error}"), - })?; - let app = std::sync::Arc::clone(&app); - let info = info.clone(); - tokio::spawn(async move { - if let Err(error) = serve_app_stream(&mut stream, &app, &info).await { - log::warn!("application IPC request failed: {error}"); - } - }); - } -} - -async fn serve_app_stream( - stream: &mut UnixStream, - app: &Application, - info: &ServerInfo, -) -> Result<(), DaemonError> { - let response = match read_request(stream).await? { - DaemonRequest::Health { protocol } if protocol == PROTOCOL_VERSION => { - DaemonResponse::ServerInfo(info.clone()) - } - DaemonRequest::App { protocol, request } if protocol == PROTOCOL_VERSION => { - match app.handle(*request).await { - Ok(response) => DaemonResponse::App(Box::new(response)), - Err(error) => DaemonResponse::AppError(error), - } - } - DaemonRequest::Health { protocol } | DaemonRequest::App { protocol, .. } => { - DaemonResponse::AppError(WireError { - code: "daemon_protocol_mismatch".to_string(), - message: format!( - "daemon protocol {PROTOCOL_VERSION} does not support client protocol {protocol}" - ), - retryable: false, - details: None, - }) - } - }; - write_response(stream, &response).await -} - -async fn write_json(stream: &mut UnixStream, value: &T) -> Result<(), DaemonError> { - let bytes = serde_json::to_vec(value).map_err(|e| DaemonError::Other { - message: format!("Failed to serialize daemon message: {e}"), - })?; - stream - .write_all(&bytes) - .await - .map_err(|e| DaemonError::Other { - message: format!("Failed to write daemon message: {e}"), - })?; - stream.shutdown().await.map_err(|e| DaemonError::Other { - message: format!("Failed to close daemon message: {e}"), - }) -} - -#[cfg(test)] -mod tests { - use flicknote_core::config::{Config, ConfigPaths}; - use serde_json::json; - use tokio::net::UnixListener; - - use super::*; - - fn test_config(directory: &std::path::Path) -> Config { - Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url: String::new(), - web_url: None, - paths: ConfigPaths { - config_dir: directory.to_path_buf(), - data_dir: directory.to_path_buf(), - config_file: directory.join("config.json"), - session_file: directory.join("session.json"), - db_file: directory.join("flicknote.db"), - log_file: directory.join("sync.log"), - }, - } - } - - async fn serve_response( - config: &Config, - response: DaemonResponse, - ) -> tokio::task::JoinHandle { - let listener = UnixListener::bind(socket_path(config)).unwrap(); - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_request(&mut stream).await.unwrap(); - write_response(&mut stream, &response).await.unwrap(); - request - }) - } - - #[test] - fn socket_path_lives_in_data_dir() { - let suffix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "flicknote-ipc-test-{}-{suffix}", - std::process::id() - )); - let config = Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url: String::new(), - web_url: None, - paths: ConfigPaths { - config_dir: dir.clone(), - data_dir: dir.clone(), - config_file: dir.join("config.json"), - session_file: dir.join("session.json"), - db_file: dir.join("flicknote.db"), - log_file: dir.join("sync.log"), - }, - }; - - assert_eq!(socket_path(&config), dir.join("sync.sock")); - } - - #[test] - fn versioned_health_and_app_requests_have_stable_contracts() { - assert_eq!(PROTOCOL_VERSION, 2); - let health = DaemonRequest::Health { - protocol: PROTOCOL_VERSION, - }; - assert_eq!( - serde_json::to_value(health).unwrap(), - json!({ - "type": "health", - "payload": { "protocol": PROTOCOL_VERSION } - }) - ); - - let request = DaemonRequest::App { - protocol: PROTOCOL_VERSION, - request: Box::new(AppRequest::NoteList(NoteListInput { - note_type: None, - project: None, - archived: false, - limit: 20, - })), - }; - let value = serde_json::to_value(request).unwrap(); - assert_eq!(value["type"], "app"); - assert_eq!(value["payload"]["protocol"], PROTOCOL_VERSION); - assert!(value["payload"].get("surface").is_none()); - assert_eq!(value["payload"]["request"]["type"], "note_list"); - } - - #[test] - fn server_info_only_reports_protocol_and_version() { - let info = ServerInfo::current(); - assert_eq!(info.protocol, PROTOCOL_VERSION); - assert!(!info.version.is_empty()); - assert_eq!( - serde_json::to_value(&info).unwrap(), - json!({ - "protocol": PROTOCOL_VERSION, - "version": env!("CARGO_PKG_VERSION"), - }) - ); - } - - #[test] - fn wire_error_preserves_partial_success_details() { - let details = json!({"created": true, "short_id": 80}); - let wire = WireError::from_service(ServiceError::Remote { - code: "note_create_partial".to_string(), - message: "note created; topics pending".to_string(), - retryable: false, - details: Some(details.clone()), - }); - - assert_eq!(wire.code, "note_create_partial"); - assert_eq!(wire.details, Some(details)); - } - - #[tokio::test] - async fn daemon_client_maps_missing_socket_to_retryable_unavailable() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - - let error = DaemonClient::new(&config).health().await.unwrap_err(); - - assert_eq!(error.code(), "daemon_unavailable"); - assert!(error.retryable()); - assert!(error.to_string().contains("flicknote sync start")); - } - - #[tokio::test] - async fn health_request_has_a_bounded_response_wait() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (_stream, _) = listener.accept().await.unwrap(); - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - }); - - let result = tokio::time::timeout( - std::time::Duration::from_millis(1_200), - send_request( - &config, - &DaemonRequest::Health { - protocol: PROTOCOL_VERSION, - }, - ), - ) - .await; - server.abort(); - - let response = result.expect("IPC must enforce its own response timeout"); - assert!(matches!(response, Err(DaemonError::Unavailable { .. }))); - } - - #[test] - fn mutating_application_requests_do_not_have_an_automatic_response_timeout() { - let request = DaemonRequest::App { - protocol: PROTOCOL_VERSION, - request: Box::new(AppRequest::NoteArchive { - id: "note-1".to_string(), - }), - }; - - assert_eq!(response_timeout_for(&request), None); - } - - #[tokio::test] - async fn daemon_client_preserves_versioned_app_results_and_errors() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 7 })), - ) - .await; - let response = DaemonClient::new(&config) - .app(AppRequest::NoteCount(NoteCountInput { - keywords: Vec::new(), - project: None, - note_type: None, - archived: false, - })) - .await - .unwrap(); - assert!(matches!(response, AppResponse::NoteCount { count: 7 })); - assert!(matches!(server.await.unwrap(), DaemonRequest::App { .. })); - - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::AppError(WireError { - code: "note_not_found".to_string(), - message: "missing".to_string(), - retryable: false, - details: Some(json!({ "id": "42" })), - }), - ) - .await; - let error = DaemonClient::new(&config) - .app(AppRequest::NoteGet { - id: "42".to_string(), - archived: false, - }) - .await - .unwrap_err(); - assert_eq!(error.code(), "note_not_found"); - assert_eq!(error.to_string(), "missing"); - server.await.unwrap(); - } - - #[tokio::test] - async fn health_rejects_unexpected_daemon_responses() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 0 })), - ) - .await; - let error = DaemonClient::new(&config).health().await.unwrap_err(); - assert_eq!(error.code(), "daemon_protocol_mismatch"); - assert!(error.to_string().contains("sync stop")); - server.await.unwrap(); - } - - #[tokio::test] - async fn protocol_v2_client_rejects_protocol_v1_server_info() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = serve_response( - &config, - DaemonResponse::ServerInfo(ServerInfo { - protocol: 1, - version: "legacy".to_string(), - }), - ) - .await; - - let error = DaemonClient::new(&config).health().await.unwrap_err(); - - assert_eq!(error.code(), "daemon_protocol_mismatch"); - assert!(error.to_string().contains("sync stop")); - server.await.unwrap(); - } - - #[tokio::test] - async fn application_maps_unknown_envelope_to_protocol_mismatch() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_request(&mut stream).await.unwrap(); - write_json(&mut stream, &json!({"type":"legacy_result","payload":{}})) - .await - .unwrap(); - }); - - let error = DaemonClient::new(&config) - .app(AppRequest::NoteCount(NoteCountInput { - keywords: Vec::new(), - project: None, - note_type: None, - archived: false, - })) - .await - .unwrap_err(); - - assert_eq!(error.code(), "daemon_protocol_mismatch"); - assert!(!error.retryable()); - server.await.unwrap(); - } - - #[tokio::test] - async fn mutating_application_maps_incomplete_response_to_unknown_outcome() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_request(&mut stream).await.unwrap(); - stream.write_all(br#"{"type":"app""#).await.unwrap(); - stream.shutdown().await.unwrap(); - }); - - let error = DaemonClient::new(&config) - .app(AppRequest::NoteArchive { - id: "note-1".to_string(), - }) - .await - .unwrap_err(); - - assert_eq!(error.code(), "daemon_request_outcome_unknown"); - assert!(!error.retryable()); - server.await.unwrap(); - } - - #[tokio::test] - async fn malformed_transport_responses_are_classified_by_mutation_safety() { - for (request, expected_code, retryable) in [ - ( - AppRequest::NoteArchive { - id: "note-1".to_string(), - }, - "daemon_request_outcome_unknown", - false, - ), - ( - AppRequest::NoteCount(NoteCountInput { - keywords: Vec::new(), - project: None, - note_type: None, - archived: false, - }), - "daemon_unavailable", - true, - ), - ] { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_request(&mut stream).await.unwrap(); - stream.write_all(b"not-json").await.unwrap(); - stream.shutdown().await.unwrap(); - }); - - let error = DaemonClient::new(&config).app(request).await.unwrap_err(); - - assert_eq!(error.code(), expected_code); - assert_eq!(error.retryable(), retryable); - server.await.unwrap(); - } - } - - #[tokio::test] - async fn unexpected_typed_responses_are_classified_by_mutation_safety() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = - serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; - let error = DaemonClient::new(&config) - .call::(AppRequest::NoteCount(NoteCountInput { - keywords: Vec::new(), - project: None, - note_type: None, - archived: false, - })) - .await - .unwrap_err(); - assert_eq!(error.code(), "daemon_protocol_mismatch"); - server.await.unwrap(); - - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = - serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; - let error = DaemonClient::new(&config) - .call::(AppRequest::NoteArchive { - id: "note-1".to_string(), - }) - .await - .unwrap_err(); - assert_eq!(error.code(), "daemon_request_outcome_unknown"); - server.await.unwrap(); - } - - #[tokio::test] - async fn unexpected_outer_responses_are_classified_by_mutation_safety() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let server = - serve_response(&config, DaemonResponse::ServerInfo(ServerInfo::current())).await; - - let error = DaemonClient::new(&config) - .app(AppRequest::NoteArchive { - id: "note-1".to_string(), - }) - .await - .unwrap_err(); - - assert_eq!(error.code(), "daemon_request_outcome_unknown"); - assert!(!error.retryable()); - server.await.unwrap(); - } - - #[tokio::test] - async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_request(&mut stream).await.unwrap(); - let response = json!({ - "type": "error", - "payload": { - "code": "other", - "message": "Failed to parse daemon request: unknown variant `health`" - } - }); - write_json(&mut stream, &response).await.unwrap(); - request - }); - - let error = DaemonClient::new(&config).health().await.unwrap_err(); - - assert_eq!(error.code(), "daemon_protocol_mismatch"); - assert!(!error.retryable()); - assert!(error.to_string().contains("sync stop")); - assert!(matches!( - server.await.unwrap(), - DaemonRequest::Health { .. } - )); - } - - #[tokio::test] - async fn health_maps_empty_startup_response_to_retryable_unavailable() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let listener = UnixListener::bind(socket_path(&config)).unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_request(&mut stream).await.unwrap(); - drop(stream); - }); - - let error = DaemonClient::new(&config).health().await.unwrap_err(); - - assert_eq!(error.code(), "daemon_unavailable"); - assert!(error.retryable()); - server.await.unwrap(); - } -} diff --git a/flicknote-sync/src/ipc/client.rs b/flicknote-sync/src/ipc/client.rs new file mode 100644 index 0000000..99a1230 --- /dev/null +++ b/flicknote-sync/src/ipc/client.rs @@ -0,0 +1,223 @@ +use super::*; + +const IPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_HEALTH_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const IPC_APP_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +pub fn socket_path(config: &Config) -> PathBuf { + config.paths.data_dir.join("sync.sock") +} + +pub(crate) fn unavailable(path: &std::path::Path, stage: &str) -> DaemonError { + DaemonError::Unavailable { + path: path.display().to_string(), + message: format!("timed out while {stage}"), + } +} + +pub(crate) fn request_timeout_error( + request: &DaemonRequest, + path: &std::path::Path, + stage: &str, +) -> DaemonError { + if !is_mutating_app_request(request) { + return unavailable(path, stage); + } + DaemonError::Other { + message: format!( + "Timed out while {stage} from the sync daemon at {}; the application request outcome is unknown. Do not retry it automatically.", + path.display() + ), + } +} + +pub(crate) fn is_mutating_app_request(request: &DaemonRequest) -> bool { + matches!(request, DaemonRequest::App { request, .. } if request.may_write()) +} + +pub(crate) fn response_timeout_for(request: &DaemonRequest) -> Option { + match request { + DaemonRequest::Health { .. } => Some(IPC_HEALTH_RESPONSE_TIMEOUT), + // Once a write request may have reached the daemon, a transport timeout cannot tell + // whether it committed. Keep waiting for the authoritative response until the protocol + // has durable operation IDs and status reconciliation (tracked as FlickNote #1785). + DaemonRequest::App { request, .. } if request.may_write() => None, + DaemonRequest::App { .. } => Some(IPC_APP_RESPONSE_TIMEOUT), + } +} + +pub async fn send_request( + config: &Config, + request: &DaemonRequest, +) -> Result { + let path = socket_path(config); + let request_bytes = serde_json::to_vec(request).map_err(|e| DaemonError::Other { + message: format!("Failed to serialize daemon request: {e}"), + })?; + let mut stream = tokio::time::timeout(IPC_CONNECT_TIMEOUT, UnixStream::connect(&path)) + .await + .map_err(|_| unavailable(&path, "connecting"))? + .map_err(|error| DaemonError::Unavailable { + path: path.display().to_string(), + message: error.to_string(), + })?; + let write_request = async { + stream.write_all(&request_bytes).await?; + stream.shutdown().await + }; + if is_mutating_app_request(request) { + write_request + .await + .map_err(|error| DaemonError::PostConnectTransport { + message: format!("Failed to send daemon request: {error}"), + })?; + } else { + tokio::time::timeout(IPC_WRITE_TIMEOUT, write_request) + .await + .map_err(|_| request_timeout_error(request, &path, "sending a request"))? + .map_err(|error| DaemonError::PostConnectTransport { + message: format!("Failed to send daemon request: {error}"), + })?; + } + let mut buf = Vec::new(); + match response_timeout_for(request) { + Some(response_timeout) => { + tokio::time::timeout(response_timeout, stream.read_to_end(&mut buf)) + .await + .map_err(|_| request_timeout_error(request, &path, "waiting for a response"))? + } + None => stream.read_to_end(&mut buf).await, + } + .map_err(|e| DaemonError::PostConnectTransport { + message: format!("Failed to read daemon response: {e}"), + })?; + serde_json::from_slice(&buf).map_err(|e| { + if e.is_eof() { + return DaemonError::IncompleteResponse { + message: format!("Daemon closed the connection before a complete response: {e}"), + }; + } + match serde_json::from_slice::(&buf) { + Ok(_) => DaemonError::InvalidResponse { + message: format!("Daemon returned an incompatible response: {e}"), + }, + Err(raw_error) => DaemonError::MalformedResponse { + message: format!("Daemon returned a malformed response: {raw_error}"), + }, + } + }) +} + +pub struct DaemonClient<'a> { + config: &'a Config, +} + +impl<'a> DaemonClient<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } + + async fn request(&self, request: DaemonRequest) -> Result { + let is_mutating = is_mutating_app_request(&request); + send_request(self.config, &request) + .await + .map_err(|error| match error { + DaemonError::Unavailable { .. } => ServiceError::DaemonUnavailable(format!( + "{error}. Start it with `flicknote sync start`." + )), + DaemonError::IncompleteResponse { .. } + | DaemonError::MalformedResponse { .. } + | DaemonError::PostConnectTransport { .. } + if !is_mutating => + { + ServiceError::DaemonUnavailable(format!( + "Sync daemon is not ready: {error}. Start it with `flicknote sync start`." + )) + } + DaemonError::IncompleteResponse { message } + | DaemonError::MalformedResponse { message } + | DaemonError::PostConnectTransport { message } + | DaemonError::InvalidResponse { message } + if is_mutating => + { + Self::outcome_unknown(message) + } + DaemonError::InvalidResponse { .. } => Self::protocol_mismatch(), + other => ServiceError::Daemon(other.to_string()), + }) + } + + pub async fn health(&self) -> Result { + match self + .request(DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }) + .await? + { + DaemonResponse::ServerInfo(info) if info.protocol == PROTOCOL_VERSION => Ok(info), + DaemonResponse::AppError(error) => Err(Self::remote_error(error)), + _ => Err(Self::protocol_mismatch()), + } + } + + pub async fn app(&self, request: AppRequest) -> Result { + let may_write = request.may_write(); + match self + .request(DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(request), + }) + .await? + { + DaemonResponse::App(response) => Ok(*response), + DaemonResponse::AppError(error) => Err(Self::remote_error(error)), + _ if may_write => Err(Self::outcome_unknown( + "The daemon returned an unexpected envelope after a mutating request; the operation outcome is unknown." + .to_string(), + )), + _ => Err(Self::protocol_mismatch()), + } + } + + pub async fn call(&self, request: AppRequest) -> Result { + let may_write = request.may_write(); + let response = self.app(request).await?; + T::from_response(response).ok_or_else(|| { + if may_write { + Self::outcome_unknown( + "The daemon returned an unexpected response after a mutating request; the operation outcome is unknown.".to_string(), + ) + } else { + Self::protocol_mismatch() + } + }) + } + + fn remote_error(error: WireError) -> ServiceError { + ServiceError::Remote { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + } + } + + fn protocol_mismatch() -> ServiceError { + ServiceError::Remote { + code: "daemon_protocol_mismatch".to_string(), + message: "The running sync daemon uses an incompatible protocol. Restart it with `flicknote sync stop && flicknote sync start`.".to_string(), + retryable: false, + details: None, + } + } + + fn outcome_unknown(message: String) -> ServiceError { + ServiceError::Remote { + code: "daemon_request_outcome_unknown".to_string(), + message, + retryable: false, + details: None, + } + } +} diff --git a/flicknote-sync/src/ipc/mod.rs b/flicknote-sync/src/ipc/mod.rs new file mode 100644 index 0000000..2d3cae9 --- /dev/null +++ b/flicknote-sync/src/ipc/mod.rs @@ -0,0 +1,34 @@ +use std::fmt; +use std::path::PathBuf; + +use flicknote_core::config::Config; +use flicknote_core::services::dto::{ + InsertPosition, NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, + NoteListInput, NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, + ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, +}; +use flicknote_core::services::editable_document::EditableSaveResult; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::source::{SourceResult, SourceView}; +use flicknote_core::types::{Note, Project}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixListener; +use tokio::net::UnixStream; + +use crate::app::Application; + +mod client; +mod protocol; +mod server; + +#[cfg(test)] +pub(crate) use client::response_timeout_for; +pub use client::{DaemonClient, send_request, socket_path}; +pub use protocol::*; +#[cfg(test)] +pub(crate) use server::write_json; +pub use server::{read_request, serve_app, serve_app_once, write_response}; + +#[cfg(test)] +mod tests; diff --git a/flicknote-sync/src/ipc/protocol.rs b/flicknote-sync/src/ipc/protocol.rs new file mode 100644 index 0000000..9e72003 --- /dev/null +++ b/flicknote-sync/src/ipc/protocol.rs @@ -0,0 +1,350 @@ +use super::*; + +pub const PROTOCOL_VERSION: u16 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub protocol: u16, + pub version: String, +} + +impl ServerInfo { + pub fn current() -> Self { + Self { + protocol: PROTOCOL_VERSION, + version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum AppRequest { + NoteAdd(NoteAddInput), + NoteAddEditable { + document: String, + project: Option, + }, + NoteUpload { + path: String, + project: Option, + created_at: Option, + }, + NoteList(NoteListInput), + NoteFind(NoteFindInput), + NoteCount(NoteCountInput), + NoteGet { + id: String, + archived: bool, + }, + NoteLoadEditable { + id: String, + }, + NoteRecord { + id: String, + archived: bool, + }, + NoteGetSection { + id: String, + section: String, + }, + NoteSource { + id: String, + archived: bool, + view: SourceView, + range: Option, + }, + NoteAppend { + id: String, + content: String, + }, + NoteSaveEditable { + id: String, + document: String, + }, + NoteReplaceSection { + id: String, + section: String, + content: String, + }, + NoteRenameSection { + id: String, + section: String, + name: String, + }, + NoteInsert { + id: String, + section: String, + position: InsertPosition, + content: String, + }, + NoteDeleteSection { + id: String, + section: String, + }, + NoteModify(NoteModifyInput), + NoteArchive { + id: String, + }, + NoteRestore { + id: String, + }, + NoteShare { + id: String, + }, + NoteUnshare { + id: String, + }, + NoteOpen { + id: String, + }, + ProjectList { + include_archived: bool, + }, + ProjectRecords { + include_archived: bool, + }, + ProjectGet { + id: String, + }, + ProjectGetByName { + name: String, + }, + ProjectAdd(ProjectAddInput), + ProjectModify(ProjectModifyInput), + ProjectArchive { + id: String, + }, + ProjectShare { + id: String, + }, + ProjectUnshare { + id: String, + }, + ExtractionValues { + keys: Vec, + archived: bool, + }, +} + +impl AppRequest { + pub fn may_write(&self) -> bool { + !matches!( + self, + Self::NoteList(_) + | Self::NoteFind(_) + | Self::NoteCount(_) + | Self::NoteGet { .. } + | Self::NoteLoadEditable { .. } + | Self::NoteRecord { .. } + | Self::NoteGetSection { .. } + | Self::NoteSource { .. } + | Self::NoteOpen { .. } + | Self::ProjectList { .. } + | Self::ProjectRecords { .. } + | Self::ProjectGet { .. } + | Self::ProjectGetByName { .. } + | Self::ExtractionValues { .. } + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum AppResponse { + NoteSummary(NoteSummary), + NoteSummaries(Vec), + NoteCount { count: u64 }, + NoteDetail(NoteDetail), + EditableDocument(EditableDocument), + NoteRecord(Note), + NoteSection(NoteSectionResult), + NoteMutation(NoteMutationResult), + EditableSave(EditableSaveResult), + NoteArchive(NoteArchiveResult), + Source(SourceResult), + Share(ShareResult), + Unshare(UnshareResult), + Open(OpenResult), + Projects(Vec), + ProjectRecords(Vec), + Project(ProjectDto), + Id { id: String }, + Values(Vec), + Unit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EditableDocument { + pub document: String, +} + +pub trait AppResult: Sized { + fn from_response(response: AppResponse) -> Option; +} + +macro_rules! app_result { + ($type:ty, $variant:path) => { + impl AppResult for $type { + fn from_response(response: AppResponse) -> Option { + match response { + $variant(value) => Some(value), + _ => None, + } + } + } + }; +} + +app_result!(NoteSummary, AppResponse::NoteSummary); +app_result!(Vec, AppResponse::NoteSummaries); +app_result!(NoteDetail, AppResponse::NoteDetail); +app_result!(EditableDocument, AppResponse::EditableDocument); +app_result!(Note, AppResponse::NoteRecord); +app_result!(NoteSectionResult, AppResponse::NoteSection); +app_result!(NoteMutationResult, AppResponse::NoteMutation); +app_result!(EditableSaveResult, AppResponse::EditableSave); +app_result!(NoteArchiveResult, AppResponse::NoteArchive); +app_result!(SourceResult, AppResponse::Source); +app_result!(ShareResult, AppResponse::Share); +app_result!(UnshareResult, AppResponse::Unshare); +app_result!(OpenResult, AppResponse::Open); +app_result!(Vec, AppResponse::Projects); +app_result!(Vec, AppResponse::ProjectRecords); +app_result!(ProjectDto, AppResponse::Project); +app_result!(Vec, AppResponse::Values); + +impl AppResult for u64 { + fn from_response(response: AppResponse) -> Option { + match response { + AppResponse::NoteCount { count } => Some(count), + _ => None, + } + } +} + +impl AppResult for String { + fn from_response(response: AppResponse) -> Option { + match response { + AppResponse::Id { id } => Some(id), + _ => None, + } + } +} + +impl AppResult for () { + fn from_response(response: AppResponse) -> Option { + match response { + AppResponse::Unit => Some(()), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WireError { + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl WireError { + pub fn from_service(error: ServiceError) -> Self { + match error { + ServiceError::Remote { + code, + message, + retryable, + details, + } => Self { + code, + message, + retryable, + details, + }, + error => Self { + code: error.code().to_string(), + message: error.to_string(), + retryable: error.retryable(), + details: None, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum DaemonRequest { + Health { + protocol: u16, + }, + App { + protocol: u16, + request: Box, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum DaemonResponse { + ServerInfo(ServerInfo), + App(Box), + AppError(WireError), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum DaemonError { + Unavailable { + path: String, + message: String, + }, + PartialCreate { + message: String, + note_id: String, + short_id: Option, + confirmed_extraction_ids: Vec, + pending_extraction_ids: Vec, + }, + AmbiguousCreate { + message: String, + note_id: String, + pending_extraction_ids: Vec, + }, + InvalidResponse { + message: String, + }, + IncompleteResponse { + message: String, + }, + MalformedResponse { + message: String, + }, + PostConnectTransport { + message: String, + }, + Other { + message: String, + }, +} + +impl fmt::Display for DaemonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable { path, message } => { + write!(f, "Sync daemon is not available at {path}: {message}") + } + Self::PartialCreate { message, .. } + | Self::AmbiguousCreate { message, .. } + | Self::InvalidResponse { message } + | Self::IncompleteResponse { message } + | Self::MalformedResponse { message } + | Self::PostConnectTransport { message } + | Self::Other { message } => f.write_str(message), + } + } +} + +impl std::error::Error for DaemonError {} diff --git a/flicknote-sync/src/ipc/server.rs b/flicknote-sync/src/ipc/server.rs new file mode 100644 index 0000000..5d5498e --- /dev/null +++ b/flicknote-sync/src/ipc/server.rs @@ -0,0 +1,104 @@ +use super::*; + +pub async fn read_request(stream: &mut UnixStream) -> Result { + let mut buf = Vec::new(); + stream + .read_to_end(&mut buf) + .await + .map_err(|e| DaemonError::Other { + message: format!("Failed to read daemon request: {e}"), + })?; + serde_json::from_slice(&buf).map_err(|e| DaemonError::Other { + message: format!("Failed to parse daemon request: {e}"), + }) +} + +pub async fn write_response( + stream: &mut UnixStream, + response: &DaemonResponse, +) -> Result<(), DaemonError> { + write_json(stream, response).await +} + +pub async fn serve_app_once( + listener: UnixListener, + app: std::sync::Arc, + info: ServerInfo, +) -> Result<(), DaemonError> { + let (mut stream, _) = listener + .accept() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to accept daemon request: {error}"), + })?; + serve_app_stream(&mut stream, &app, &info).await +} + +pub async fn serve_app( + listener: UnixListener, + app: std::sync::Arc, + info: ServerInfo, +) -> Result<(), DaemonError> { + loop { + let (mut stream, _) = listener + .accept() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to accept daemon request: {error}"), + })?; + let app = std::sync::Arc::clone(&app); + let info = info.clone(); + tokio::spawn(async move { + if let Err(error) = serve_app_stream(&mut stream, &app, &info).await { + log::warn!("application IPC request failed: {error}"); + } + }); + } +} + +pub(crate) async fn serve_app_stream( + stream: &mut UnixStream, + app: &Application, + info: &ServerInfo, +) -> Result<(), DaemonError> { + let response = match read_request(stream).await? { + DaemonRequest::Health { protocol } if protocol == PROTOCOL_VERSION => { + DaemonResponse::ServerInfo(info.clone()) + } + DaemonRequest::App { protocol, request } if protocol == PROTOCOL_VERSION => { + match app.handle(*request).await { + Ok(response) => DaemonResponse::App(Box::new(response)), + Err(error) => DaemonResponse::AppError(error), + } + } + DaemonRequest::Health { protocol } | DaemonRequest::App { protocol, .. } => { + DaemonResponse::AppError(WireError { + code: "daemon_protocol_mismatch".to_string(), + message: format!( + "daemon protocol {PROTOCOL_VERSION} does not support client protocol {protocol}" + ), + retryable: false, + details: None, + }) + } + }; + write_response(stream, &response).await +} + +pub(crate) async fn write_json( + stream: &mut UnixStream, + value: &T, +) -> Result<(), DaemonError> { + let bytes = serde_json::to_vec(value).map_err(|e| DaemonError::Other { + message: format!("Failed to serialize daemon message: {e}"), + })?; + stream + .write_all(&bytes) + .await + .map_err(|e| DaemonError::Other { + message: format!("Failed to write daemon message: {e}"), + })?; + stream.shutdown().await.map_err(|e| DaemonError::Other { + message: format!("Failed to close daemon message: {e}"), + }) +} diff --git a/flicknote-sync/src/ipc/tests.rs b/flicknote-sync/src/ipc/tests.rs new file mode 100644 index 0000000..da63fa5 --- /dev/null +++ b/flicknote-sync/src/ipc/tests.rs @@ -0,0 +1,439 @@ +use super::*; +use flicknote_core::config::{Config, ConfigPaths}; +use serde_json::json; +use tokio::net::UnixListener; + +fn test_config(directory: &std::path::Path) -> Config { + Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: String::new(), + web_url: None, + paths: ConfigPaths { + config_dir: directory.to_path_buf(), + data_dir: directory.to_path_buf(), + config_file: directory.join("config.json"), + session_file: directory.join("session.json"), + db_file: directory.join("flicknote.db"), + log_file: directory.join("sync.log"), + }, + } +} + +async fn serve_response( + config: &Config, + response: DaemonResponse, +) -> tokio::task::JoinHandle { + let listener = UnixListener::bind(socket_path(config)).unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await.unwrap(); + write_response(&mut stream, &response).await.unwrap(); + request + }) +} + +#[test] +fn socket_path_lives_in_data_dir() { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "flicknote-ipc-test-{}-{suffix}", + std::process::id() + )); + let config = Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: String::new(), + web_url: None, + paths: ConfigPaths { + config_dir: dir.clone(), + data_dir: dir.clone(), + config_file: dir.join("config.json"), + session_file: dir.join("session.json"), + db_file: dir.join("flicknote.db"), + log_file: dir.join("sync.log"), + }, + }; + + assert_eq!(socket_path(&config), dir.join("sync.sock")); +} + +#[test] +fn versioned_health_and_app_requests_have_stable_contracts() { + assert_eq!(PROTOCOL_VERSION, 2); + let health = DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }; + assert_eq!( + serde_json::to_value(health).unwrap(), + json!({ + "type": "health", + "payload": { "protocol": PROTOCOL_VERSION } + }) + ); + + let request = DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(AppRequest::NoteList(NoteListInput { + note_type: None, + project: None, + archived: false, + limit: 20, + })), + }; + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["type"], "app"); + assert_eq!(value["payload"]["protocol"], PROTOCOL_VERSION); + assert!(value["payload"].get("surface").is_none()); + assert_eq!(value["payload"]["request"]["type"], "note_list"); +} + +#[test] +fn server_info_only_reports_protocol_and_version() { + let info = ServerInfo::current(); + assert_eq!(info.protocol, PROTOCOL_VERSION); + assert!(!info.version.is_empty()); + assert_eq!( + serde_json::to_value(&info).unwrap(), + json!({ + "protocol": PROTOCOL_VERSION, + "version": env!("CARGO_PKG_VERSION"), + }) + ); +} + +#[test] +fn wire_error_preserves_partial_success_details() { + let details = json!({"created": true, "short_id": 80}); + let wire = WireError::from_service(ServiceError::Remote { + code: "note_create_partial".to_string(), + message: "note created; topics pending".to_string(), + retryable: false, + details: Some(details.clone()), + }); + + assert_eq!(wire.code, "note_create_partial"); + assert_eq!(wire.details, Some(details)); +} + +#[tokio::test] +async fn daemon_client_maps_missing_socket_to_retryable_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_unavailable"); + assert!(error.retryable()); + assert!(error.to_string().contains("flicknote sync start")); +} + +#[tokio::test] +async fn health_request_has_a_bounded_response_wait() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(1_200), + send_request( + &config, + &DaemonRequest::Health { + protocol: PROTOCOL_VERSION, + }, + ), + ) + .await; + server.abort(); + + let response = result.expect("IPC must enforce its own response timeout"); + assert!(matches!(response, Err(DaemonError::Unavailable { .. }))); +} + +#[test] +fn mutating_application_requests_do_not_have_an_automatic_response_timeout() { + let request = DaemonRequest::App { + protocol: PROTOCOL_VERSION, + request: Box::new(AppRequest::NoteArchive { + id: "note-1".to_string(), + }), + }; + + assert_eq!(response_timeout_for(&request), None); +} + +#[tokio::test] +async fn daemon_client_preserves_versioned_app_results_and_errors() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 7 })), + ) + .await; + let response = DaemonClient::new(&config) + .app(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap(); + assert!(matches!(response, AppResponse::NoteCount { count: 7 })); + assert!(matches!(server.await.unwrap(), DaemonRequest::App { .. })); + + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::AppError(WireError { + code: "note_not_found".to_string(), + message: "missing".to_string(), + retryable: false, + details: Some(json!({ "id": "42" })), + }), + ) + .await; + let error = DaemonClient::new(&config) + .app(AppRequest::NoteGet { + id: "42".to_string(), + archived: false, + }) + .await + .unwrap_err(); + assert_eq!(error.code(), "note_not_found"); + assert_eq!(error.to_string(), "missing"); + server.await.unwrap(); +} + +#[tokio::test] +async fn health_rejects_unexpected_daemon_responses() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::NoteCount { count: 0 })), + ) + .await; + let error = DaemonClient::new(&config).health().await.unwrap_err(); + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(error.to_string().contains("sync stop")); + server.await.unwrap(); +} + +#[tokio::test] +async fn protocol_v2_client_rejects_protocol_v1_server_info() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response( + &config, + DaemonResponse::ServerInfo(ServerInfo { + protocol: 1, + version: "legacy".to_string(), + }), + ) + .await; + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(error.to_string().contains("sync stop")); + server.await.unwrap(); +} + +#[tokio::test] +async fn application_maps_unknown_envelope_to_protocol_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + write_json(&mut stream, &json!({"type":"legacy_result","payload":{}})) + .await + .unwrap(); + }); + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(!error.retryable()); + server.await.unwrap(); +} + +#[tokio::test] +async fn mutating_application_maps_incomplete_response_to_unknown_outcome() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + stream.write_all(br#"{"type":"app""#).await.unwrap(); + stream.shutdown().await.unwrap(); + }); + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + assert!(!error.retryable()); + server.await.unwrap(); +} + +#[tokio::test] +async fn malformed_transport_responses_are_classified_by_mutation_safety() { + for (request, expected_code, retryable) in [ + ( + AppRequest::NoteArchive { + id: "note-1".to_string(), + }, + "daemon_request_outcome_unknown", + false, + ), + ( + AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + }), + "daemon_unavailable", + true, + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + stream.write_all(b"not-json").await.unwrap(); + stream.shutdown().await.unwrap(); + }); + + let error = DaemonClient::new(&config).app(request).await.unwrap_err(); + + assert_eq!(error.code(), expected_code); + assert_eq!(error.retryable(), retryable); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn unexpected_typed_responses_are_classified_by_mutation_safety() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let error = DaemonClient::new(&config) + .call::(AppRequest::NoteCount(NoteCountInput { + keywords: Vec::new(), + project: None, + note_type: None, + archived: false, + })) + .await + .unwrap_err(); + assert_eq!(error.code(), "daemon_protocol_mismatch"); + server.await.unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let error = DaemonClient::new(&config) + .call::(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + server.await.unwrap(); +} + +#[tokio::test] +async fn unexpected_outer_responses_are_classified_by_mutation_safety() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let server = serve_response(&config, DaemonResponse::ServerInfo(ServerInfo::current())).await; + + let error = DaemonClient::new(&config) + .app(AppRequest::NoteArchive { + id: "note-1".to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(error.code(), "daemon_request_outcome_unknown"); + assert!(!error.retryable()); + server.await.unwrap(); +} + +#[tokio::test] +async fn health_maps_legacy_daemon_error_to_protocol_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await.unwrap(); + let response = json!({ + "type": "error", + "payload": { + "code": "other", + "message": "Failed to parse daemon request: unknown variant `health`" + } + }); + write_json(&mut stream, &response).await.unwrap(); + request + }); + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_protocol_mismatch"); + assert!(!error.retryable()); + assert!(error.to_string().contains("sync stop")); + assert!(matches!( + server.await.unwrap(), + DaemonRequest::Health { .. } + )); +} + +#[tokio::test] +async fn health_maps_empty_startup_response_to_retryable_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let listener = UnixListener::bind(socket_path(&config)).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_request(&mut stream).await.unwrap(); + drop(stream); + }); + + let error = DaemonClient::new(&config).health().await.unwrap_err(); + + assert_eq!(error.code(), "daemon_unavailable"); + assert!(error.retryable()); + server.await.unwrap(); +} diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 7627c6b..486c476 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -23,3456 +23,21 @@ use serde::Deserialize; use tokio::{net::UnixListener, sync::mpsc}; pub mod app; +mod connector; pub mod ipc; +mod remote; +mod runtime; +mod storage_maintenance; +mod upload; + use app::Application; use ipc::DaemonError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShareResource { - Note, - Project, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShareRequest { - resource: ShareResource, - id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CreateNoteRequest { - id: String, - note_type: String, - status: String, - title: Option, - content: Option, - metadata: Option, - project_id: Option, - now: String, - topics: Vec, - attachment_path: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RemoteCreatedNote { - uuid: String, - short_id: i64, - confirmed_extraction_ids: Vec, -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct ExtractionCreateOutcome { - confirmed_ids: Vec, - pending_ids: Vec, - diagnostic: Option, - local_commit_error: Option, -} - -/// Helper to convert arbitrary errors into PowerSyncError. -fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { - std::io::Error::other(msg.to_string()).into() -} - -/// Postgres/PostgREST error codes that will never succeed on retry. -/// Mirrors the iOS PostgresFatalCodes pattern (PowerSyncService.swift). -const FATAL_PG_PREFIXES: &[&str] = &[ - "22", // Class 22 — Data Exception - "23", // Class 23 — Integrity Constraint Violation (FK, unique, not-null) -]; - -const FATAL_PG_CODES: &[&str] = &[ - "42501", // INSUFFICIENT PRIVILEGE (RLS violation) - "42703", // undefined column - "42P01", // undefined table - "PGRST203", // PostgREST: table not found - "PGRST204", // PostgREST: column not found -]; - -/// Check if a Supabase/PostgREST error body contains a non-transient PG error. -/// Returns `Some(code)` if the error is fatal (will never succeed on retry), -/// or `None` if the code is unrecognised, missing, or the body is not JSON. -/// `None` does not mean the error is confirmed transient — it means unknown. -fn extract_fatal_code(body: &str) -> Option { - let parsed: serde_json::Value = serde_json::from_str(body).ok().or_else(|| { - log::debug!("extract_fatal_code: body is not JSON, treating as unknown: {body}"); - None - })?; - let code = parsed.get("code").and_then(|v| v.as_str()).or_else(|| { - log::debug!("extract_fatal_code: no `code` field in body, treating as unknown"); - None - })?; - - for prefix in FATAL_PG_PREFIXES { - if code.starts_with(prefix) { - return Some(code.to_string()); - } - } - if FATAL_PG_CODES.contains(&code) { - return Some(code.to_string()); - } - None -} - -/// Classify an HTTP response as success, fatal (discard), or transient (retry). -enum UploadOutcome { - Success, - Fatal(String), - Transient(String), -} - -async fn classify_response( - resp: reqwest::Response, - op: &str, - table: &str, - id: &str, -) -> UploadOutcome { - let status = resp.status(); - if status.is_success() { - return UploadOutcome::Success; - } - let body = resp - .text() - .await - .unwrap_or_else(|e| format!("")); - if let Some(code) = extract_fatal_code(&body) { - UploadOutcome::Fatal(format!( - "HTTP {status} PG {code}: {op} {table}/{id} — {body}" - )) - } else { - UploadOutcome::Transient(format!("HTTP {status}: {op} {table}/{id} failed: {body}")) - } -} - -struct FlickNoteConnector { - db: PowerSyncDatabase, - auth: Arc, - upload_guard: Arc>, - http_client: reqwest::Client, - powersync_url: String, - supabase_url: String, - supabase_anon_key: String, -} - -/// Un-wrap JSON strings that contain objects/arrays (fixes double-marshal for jsonb columns). -/// PowerSync stores jsonb as text, so crud.data has them as Value::String. -/// Supabase expects Value::Object for jsonb columns. -fn unwrap_json_strings(data: &mut serde_json::Map) { - for (key, value) in data.iter_mut() { - if let serde_json::Value::String(s) = value { - match serde_json::from_str::(s) { - Ok(parsed) if parsed.is_object() || parsed.is_array() => { - *value = parsed; - } - Err(e) if s.starts_with('{') || s.starts_with('[') => { - log::debug!( - "unwrap_json_strings: field `{key}` looks like JSON but failed to parse: {e}" - ); - } - _ => {} - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FlickNoteCrudMarker { - RemoteCommittedInsert, -} - -fn parse_flicknote_crud_marker( - metadata: Option<&str>, -) -> Result, PowerSyncError> { - let Some(metadata) = metadata else { - return Ok(None); - }; - let value: serde_json::Value = serde_json::from_str(metadata) - .map_err(|error| ps_err(format!("invalid CRUD metadata: {error}")))?; - let Some(object) = value.as_object() else { - return Ok(None); - }; - let Some(marker) = object.get("flicknote") else { - return Ok(None); - }; - if object.len() != 1 { - return Err(ps_err( - "invalid FlickNote CRUD metadata: expected exactly one marker field", - )); - } - match marker.as_str() { - Some("remote_committed_insert_v1") => Ok(Some(FlickNoteCrudMarker::RemoteCommittedInsert)), - _ => Err(ps_err(format!( - "unsupported FlickNote CRUD marker: {marker}" - ))), - } -} - -/// Inner upload logic shared by the BackendConnector and application-triggered drain. -/// Caller is responsible for holding `upload_guard` before calling. -/// -/// Returns `true` if at least one CRUD transaction was processed and committed, -/// `false` if ps_crud was empty. Callers may use this to decide whether to -/// run a WAL checkpoint after upload. -/// -/// The token is fetched once per call by the caller. Supabase tokens are typically -/// valid for 1 hour, so any realistic upload batch completes well within the window. -async fn run_upload( - db: &PowerSyncDatabase, - client: &reqwest::Client, - token: &str, - supabase_url: &str, - supabase_anon_key: &str, -) -> Result { - let mut transactions = db.crud_transactions(); - let mut did_upload = false; - - while let Some(mut tx) = transactions.try_next().await? { - let mut fatal_msg: Option = None; - let mut transient_msg: Option = None; - - for mut crud in std::mem::take(&mut tx.crud) { - // The backend retired the keyterm domain. Old offline databases may still - // have queued writes for the removed table or the removed project column. - // Consume those retired fields locally so they cannot block the FIFO or - // cause an otherwise valid project mutation to be discarded by PostgREST. - if crud.table == "keyterms" { - log::info!( - "Discarding queued CRUD for retired keyterms row {}", - crud.id - ); - continue; - } - if crud.table == "projects" - && let Some(data) = crud.data.as_mut() - { - data.remove("keyterm_id"); - } - if parse_flicknote_crud_marker(crud.metadata.as_deref())? - == Some(FlickNoteCrudMarker::RemoteCommittedInsert) - { - let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); - let is_put = matches!(&crud.update_type, UpdateType::Put); - if !allowed_table || !is_put { - let operation = match &crud.update_type { - UpdateType::Put => "PUT", - UpdateType::Patch => "PATCH", - UpdateType::Delete => "DELETE", - }; - return Err(ps_err(format!( - "invalid remote-committed marker on {operation} operation for table {}", - crud.table, - ))); - } - continue; - } - let table = &crud.table; - let id = &crud.id; - - // Single match on crud.update_type — UpdateType is not Copy, - // so we derive both op and resp in one match to avoid use-after-move. - let (op, resp) = match crud.update_type { - UpdateType::Put => { - let mut data = crud.data.unwrap_or_default(); - data.insert("id".into(), serde_json::Value::String(id.clone())); - unwrap_json_strings(&mut data); - let r = client - .post(format!("{supabase_url}/rest/v1/{table}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .header("Prefer", "resolution=merge-duplicates") - .json(&data) - .send() - .await - .map_err(|e| ps_err(format!("Upload PUT failed: {e}")))?; - ("PUT", r) - } - UpdateType::Patch => { - let mut data = crud.data.unwrap_or_default(); - unwrap_json_strings(&mut data); - let r = client - .patch(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .json(&data) - .send() - .await - .map_err(|e| ps_err(format!("Upload PATCH failed: {e}")))?; - ("PATCH", r) - } - UpdateType::Delete => { - // No payload — unwrap_json_strings not needed. - let r = client - .delete(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .send() - .await - .map_err(|e| ps_err(format!("Upload DELETE failed: {e}")))?; - ("DELETE", r) - } - }; - - match classify_response(resp, op, table, id).await { - UploadOutcome::Success => {} - UploadOutcome::Fatal(msg) => { - fatal_msg = Some(msg); - break; // stop processing this transaction's entries - } - UploadOutcome::Transient(msg) => { - transient_msg = Some(msg); - break; // stop processing, will retry - } - } - } - - // Handle outcome AFTER the for loop (tx is not moved inside the loop) - if let Some(msg) = fatal_msg { - log::error!("Non-transient error, discarding transaction: {msg}"); - tx.complete().await.map_err(|e| { - ps_err(format!( - "Failed to discard fatal transaction (original: {msg}): {e}" - )) - })?; // discard entire transaction atomically - did_upload = true; - continue; // next transaction - } - if let Some(msg) = transient_msg { - return Err(ps_err(msg)); // retry on next cycle - } - - // All entries succeeded — complete each transaction individually so - // successfully-uploaded entries are removed from ps_crud before processing - // the next batch. Without this, a mid-batch failure would re-upload all - // prior entries on the next cycle, causing phantom DELETEs (404) and - // duplicate PUTs. - tx.complete().await?; - did_upload = true; - } - - Ok(did_upload) -} - -/// WAL checkpoint mode passed to [`checkpoint_wal_standalone`]. -#[derive(Clone, Copy)] -enum WalCheckpointMode { - /// Checkpoints frames up to the oldest active reader's mark. Never acquires - /// PENDING or EXCLUSIVE locks — returns immediately. Safe at any time alongside - /// active pool connections. Returns `busy=1` when readers constrain the - /// checkpoint to an earlier WAL position (normal during runtime). - Passive, - /// Acquires a PENDING lock while waiting for readers to finish, then resets - /// the WAL to zero length. Use only when no pool connections exist (startup, - /// shutdown) to avoid the PENDING lock blocking pool writers. - Truncate, -} - -impl fmt::Display for WalCheckpointMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Passive => write!(f, "PASSIVE"), - Self::Truncate => write!(f, "TRUNCATE"), - } - } -} - -/// Run a WAL checkpoint using a standalone rusqlite connection. -/// -/// Opens its own connection to the DB file, bypassing PowerSync's writer mutex -/// entirely — competes only at the SQLite file-lock level, not the Rust mutex level. -/// -/// `mode` controls the checkpoint type — see [`WalCheckpointMode`] for semantics. -/// -/// `busy_timeout` is set to 5 000 ms for TRUNCATE so it retries at the SQLite level -/// while pool readers finish their short transactions. It is irrelevant for PASSIVE -/// (which never waits) but harmless to keep set. -/// -/// Reads the `(busy, log, checkpointed)` return tuple from PRAGMA so failures -/// are never silently swallowed. For PASSIVE, `busy=1` when active readers -/// constrain the checkpoint to an earlier WAL position (normal and expected during -/// runtime). For TRUNCATE, `busy=1` means the reset could not complete. -/// -/// This function is **synchronous** (blocking rusqlite I/O). Async callers must -/// wrap it with `tokio::task::spawn_blocking`. -/// -/// `label` identifies the call site in log output (e.g. `"startup"`, `"post-upload"`, -/// `"periodic"`, `"shutdown"`) so production logs are unambiguous. -fn checkpoint_wal_standalone(db_path: &Path, label: &str, mode: WalCheckpointMode) { - let conn = match rusqlite::Connection::open(db_path) { - Ok(c) => c, - Err(e) => { - log::warn!("WAL checkpoint [{label}]: could not open db: {e}"); - return; - } - }; - if let Err(e) = conn.pragma_update(None, "busy_timeout", 5_000i64) { - log::warn!("WAL checkpoint [{label}]: could not set busy_timeout: {e}"); - return; - } - let pragma = format!("PRAGMA wal_checkpoint({})", mode); - match conn.query_row(&pragma, [], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - )) - }) { - Ok((busy, log, checkpointed)) => { - if busy == 0 { - log::info!( - "WAL checkpoint [{label}] ({mode}): {log} pages, {checkpointed} checkpointed" - ); - } else { - log::warn!( - "WAL checkpoint [{label}]: incomplete (busy={busy}, {log} log pages, {checkpointed} checkpointed)" - ); - } - } - Err(e) => log::warn!("WAL checkpoint [{label}]: failed: {e}"), - } - // Connection dropped here — no persistent state -} - -/// Acquire the upload guard, get a fresh token, run_upload, and checkpoint. -/// Shared by the startup drain and application-triggered drain. -/// `context` is used as a log prefix (e.g. "Startup upload", "Upload"). -/// -/// A PASSIVE checkpoint is run after a successful upload to reclaim WAL space -/// freed by crud deletions. PASSIVE never acquires PENDING/EXCLUSIVE locks so it -/// is safe to call alongside active pool connections and the download actor. -/// -/// The checkpoint call uses `spawn_blocking` since `checkpoint_wal_standalone` -/// does blocking I/O (rusqlite open). -#[allow(clippy::too_many_arguments)] -async fn try_upload_and_checkpoint( - db: &PowerSyncDatabase, - client: &reqwest::Client, - auth: &GoTrueClient, - guard: &tokio::sync::Mutex<()>, - supabase_url: &str, - supabase_anon_key: &str, - context: &str, - db_path: &Path, -) -> bool { - let _guard = guard.lock().await; - - let token = match auth.get_session().await { - Ok(s) => s.access_token, - Err(e) => { - log::warn!("{context}: auth error: {e}"); - return false; - } - }; - match run_upload(db, client, &token, supabase_url, supabase_anon_key).await { - Ok(_) => { - // Post-upload PASSIVE checkpoint: reclaim crud deletion frames without - // acquiring any locks that could contend with active pool connections. - let post_path = db_path.to_path_buf(); - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&post_path, "post-upload", WalCheckpointMode::Passive) - }) - .await - { - log::error!("Post-upload WAL checkpoint task panicked: {e}"); - } - true - } - Err(e) => { - log::warn!("{context}: upload failed: {e}"); - false - } - } -} - -async fn retry_with_backoff( - mut attempt: F, - initial_delay: std::time::Duration, - maximum_delay: std::time::Duration, -) where - F: FnMut() -> Fut, - Fut: Future, -{ - let mut delay = initial_delay; - while !attempt().await { - tokio::time::sleep(delay).await; - delay = delay.saturating_mul(2).min(maximum_delay); - } -} - -#[allow(clippy::too_many_arguments)] -async fn retry_upload_until_success( - db: &PowerSyncDatabase, - client: &reqwest::Client, - auth: &GoTrueClient, - guard: &tokio::sync::Mutex<()>, - supabase_url: &str, - supabase_anon_key: &str, - context: &str, - db_path: &Path, -) { - retry_with_backoff( - || { - try_upload_and_checkpoint( - db, - client, - auth, - guard, - supabase_url, - supabase_anon_key, - context, - db_path, - ) - }, - std::time::Duration::from_secs(1), - std::time::Duration::from_secs(30), - ) - .await; -} - -#[async_trait] -impl BackendConnector for FlickNoteConnector { - async fn fetch_credentials(&self) -> Result { - let session = self - .auth - .get_session() - .await - .map_err(|e| ps_err(format!("Auth error: {e}")))?; - - Ok(PowerSyncCredentials { - endpoint: self.powersync_url.clone(), - token: session.access_token, - }) - } - - async fn upload_data(&self) -> Result<(), PowerSyncError> { - let _guard = self.upload_guard.lock().await; - let token = self.get_token().await?; - // Ignore the bool — checkpoint is only safe to call from the serialized drain path, - // not here (SDK callback fires during active sync alongside the download actor). - run_upload( - &self.db, - &self.http_client, - &token, - &self.supabase_url, - &self.supabase_anon_key, - ) - .await?; - Ok(()) - } -} - -impl FlickNoteConnector { - async fn get_token(&self) -> Result { - let session = self - .auth - .get_session() - .await - .map_err(|e| ps_err(format!("Auth error: {e}")))?; - Ok(session.access_token) - } -} - -fn pid_path(config: &Config) -> PathBuf { - PathBuf::from(&config.paths.data_dir).join("sync.pid") -} - -struct PidGuard(PathBuf); - -impl Drop for PidGuard { - fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.0) { - log::warn!("Failed to remove PID file: {}", e); - } - } -} - -struct SocketGuard(PathBuf); - -impl Drop for SocketGuard { - fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.0) { - log::warn!("Failed to remove socket file: {}", e); - } - } -} - -fn bind_socket(config: &Config) -> Result<(UnixListener, SocketGuard), Box> { - let path = ipc::socket_path(config); - if path.exists() { - std::fs::remove_file(&path)?; - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let listener = UnixListener::bind(&path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - Ok((listener, SocketGuard(path))) -} - -/// Check for an existing sync daemon and write our PID file. -/// -/// Note: there is a small TOCTOU window between the `kill(pid, 0)` liveness -/// check and writing the new PID file. Two daemons launched simultaneously -/// could both pass. For a CLI daemon this is acceptable; use `flock` or -/// `O_CREAT|O_EXCL` if stronger guarantees are ever needed. -#[allow(unsafe_code)] -fn check_and_write_pid(path: &Path) -> Result> { - if let Ok(contents) = std::fs::read_to_string(path) - && let Ok(pid) = contents.trim().parse::() - { - let result = unsafe { libc::kill(pid, 0) }; - if result == 0 - || (result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) - { - return Err(format!( - "Sync daemon already running (pid={}). Kill it first or delete {}", - pid, - path.display() - ) - .into()); - } - log::info!("Removing stale PID file (pid={} no longer running)", pid); - } - - std::fs::write(path, std::process::id().to_string()) - .map_err(|e| format!("Failed to write PID file {}: {}", path.display(), e))?; - Ok(PidGuard(path.to_path_buf())) -} - -/// Tear down all async actors, disconnect the database, and run a final TRUNCATE -/// checkpoint. -/// -/// Called from every shutdown path (ctrl-c, task panic, normal exit). The pool -/// is fully gone after `db.disconnect().await`, so TRUNCATE succeeds without -/// contention. Uses `spawn_blocking` to keep the blocking rusqlite I/O off the -/// async executor thread per [`checkpoint_wal_standalone`]'s contract. -async fn shutdown_daemon( - upload_handle: &mut tokio::task::JoinHandle<()>, - checkpoint_handle: &mut tokio::task::JoinHandle<()>, - socket_handle: &mut tokio::task::JoinHandle<()>, - db: &PowerSyncDatabase, - db_path: PathBuf, -) { - upload_handle.abort(); - checkpoint_handle.abort(); - socket_handle.abort(); - db.disconnect().await; - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&db_path, "shutdown", WalCheckpointMode::Truncate) - }) - .await - { - log::error!("Shutdown WAL checkpoint task panicked: {e}"); - } - log::info!("Sync daemon stopped"); -} - -#[derive(Debug, Clone, Deserialize)] -struct RemoteNoteRow { - id: String, - short_id: Option, - user_id: String, - #[serde(rename = "type")] - note_type: String, - status: String, - title: Option, - content: Option, - summary: Option, - #[serde(default)] - is_flagged: bool, - project_id: Option, - metadata: Option, - source: Option, - created_at: Option, - updated_at: Option, - deleted_at: Option, -} - -fn json_column(value: &Option) -> Result, DaemonError> { - value - .as_ref() - .map(serde_json::to_string) - .transpose() - .map_err(|error| DaemonError::Other { - message: format!("Failed to serialize canonical remote JSON: {error}"), - }) -} - -async fn commit_remote_note( - db: &PowerSyncDatabase, - note: &RemoteNoteRow, -) -> Result { - let metadata = json_column(¬e.metadata)?; - let source = json_column(¬e.source)?; - let mut writer = db.writer().await.map_err(|error| DaemonError::Other { - message: format!("Failed to open local PowerSync writer: {error}"), - })?; - let tx = writer - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| DaemonError::Other { - message: format!("Failed to begin local note transaction: {error}"), - })?; - let exists = tx - .query_row( - "SELECT 1 FROM notes WHERE id = ? LIMIT 1", - params![note.id], - |_| Ok(()), - ) - .optional() - .map_err(|error| DaemonError::Other { - message: format!("Failed to check local note {}: {error}", note.id), - })? - .is_some(); - if exists { - tx.commit().map_err(|error| DaemonError::Other { - message: format!("Failed to finish local note transaction: {error}"), - })?; - return Ok(false); - } - - tx.execute( - r#"INSERT INTO notes ( - id, short_id, user_id, type, status, title, content, summary, - is_flagged, project_id, metadata, source, created_at, updated_at, - deleted_at, _metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, - params![ - note.id, - note.short_id, - note.user_id, - note.note_type, - note.status, - note.title, - note.content, - note.summary, - note.is_flagged, - note.project_id, - metadata, - source, - note.created_at, - note.updated_at, - note.deleted_at, - REMOTE_COMMITTED_INSERT_METADATA, - ], - ) - .map_err(|error| DaemonError::Other { - message: format!("Failed to commit remote note {} locally: {error}", note.id), - })?; - tx.commit().map_err(|error| DaemonError::Other { - message: format!("Failed to finish local note transaction: {error}"), - })?; - Ok(true) -} - -#[derive(Debug, Clone, serde::Serialize, Deserialize)] -struct RemoteExtractionRow { - id: String, - note_id: String, - user_id: String, - key: String, - value: String, -} - -async fn commit_remote_extractions( - db: &PowerSyncDatabase, - rows: &[RemoteExtractionRow], -) -> Result { - if rows.is_empty() { - return Ok(0); - } - let mut writer = db.writer().await.map_err(|error| DaemonError::Other { - message: format!("Failed to open local PowerSync writer: {error}"), - })?; - let tx = writer - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| DaemonError::Other { - message: format!("Failed to begin local extraction transaction: {error}"), - })?; - let mut inserted = 0; - for row in rows { - let exists = tx - .query_row( - "SELECT 1 FROM note_extractions WHERE id = ? LIMIT 1", - params![row.id], - |_| Ok(()), - ) - .optional() - .map_err(|error| DaemonError::Other { - message: format!("Failed to check local extraction {}: {error}", row.id), - })? - .is_some(); - if exists { - continue; - } - tx.execute( - r#"INSERT INTO note_extractions ( - id, note_id, user_id, key, value, _metadata - ) VALUES (?, ?, ?, ?, ?, ?)"#, - params![ - row.id, - row.note_id, - row.user_id, - row.key, - row.value, - REMOTE_COMMITTED_INSERT_METADATA, - ], - ) - .map_err(|error| DaemonError::Other { - message: format!( - "Failed to commit remote extraction {} locally: {error}", - row.id - ), - })?; - inserted += 1; - } - tx.commit().map_err(|error| DaemonError::Other { - message: format!("Failed to finish local extraction transaction: {error}"), - })?; - Ok(inserted) -} - -fn attachment_endpoint(base_url: &str, path: &str) -> String { - let versioned_base = base_url - .trim_end_matches('/') - .trim_end_matches("/api/v1") - .trim_end_matches('/'); - let path = path.trim_matches('/'); - format!("{versioned_base}/api/v1/attachments/{path}") -} - -#[derive(Deserialize)] -struct ShareResponse { - url: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ShareApiError { - error_code: Option, - message: Option, -} - -#[derive(Default)] -struct ShareRequestLock { - mutex: tokio::sync::Mutex<()>, -} - -impl ShareRequestLock { - async fn run(&self, operation: impl Future) -> T { - let _guard = self.mutex.lock().await; - operation.await - } -} - -impl ShareResource { - fn path_segment(self) -> &'static str { - match self { - Self::Note => "notes", - Self::Project => "projects", - } - } - - fn missing_error_code(self) -> &'static str { - match self { - Self::Note => "SHARE_NOT_FOUND", - Self::Project => "PROJECT_SHARE_NOT_FOUND", - } - } -} - -fn share_endpoint(api_url: &str, request: &ShareRequest) -> String { - let versioned_base = api_url - .trim_end_matches('/') - .trim_end_matches("/api/v1") - .trim_end_matches('/'); - format!( - "{versioned_base}/api/v1/{}/{}/share", - request.resource.path_segment(), - request.id - ) -} - -fn share_api_error(status: reqwest::StatusCode, body: String) -> DaemonError { - let message = serde_json::from_str::(&body) - .ok() - .and_then(|error| error.message) - .unwrap_or(body); - DaemonError::Other { - message: format!("Share API returned {status}: {message}"), - } -} - -async fn parse_share_url(response: reqwest::Response) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(share_api_error(status, body)); - } - response - .json::() - .await - .map(|share| share.url) - .map_err(|error| DaemonError::Other { - message: format!("Failed to parse share API response: {error}"), - }) -} - -async fn get_or_create_share_with_token( - http: &reqwest::Client, - config: &Config, - access_token: &str, - request: &ShareRequest, -) -> Result { - validate_api_url(config)?; - let endpoint = share_endpoint(&config.api_url, request); - let response = http - .get(&endpoint) - .bearer_auth(access_token) - .send() - .await - .map_err(|error| DaemonError::Other { - message: format!("Share request failed: {error}"), - })?; - - if response.status().is_success() { - return parse_share_url(response).await; - } - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - let is_missing_share = status == reqwest::StatusCode::NOT_FOUND - && serde_json::from_str::(&body) - .ok() - .and_then(|error| error.error_code) - .is_some_and(|code| code == request.resource.missing_error_code()); - if !is_missing_share { - return Err(share_api_error(status, body)); - } - - let response = http - .post(endpoint) - .bearer_auth(access_token) - .json(&serde_json::json!({})) - .send() - .await - .map_err(|error| DaemonError::Other { - message: format!("Share create request failed: {error}"), - })?; - parse_share_url(response).await -} - -async fn revoke_share_with_token( - http: &reqwest::Client, - config: &Config, - access_token: &str, - request: &ShareRequest, -) -> Result<(), DaemonError> { - validate_api_url(config)?; - let response = http - .delete(share_endpoint(&config.api_url, request)) - .bearer_auth(access_token) - .send() - .await - .map_err(|error| DaemonError::Other { - message: format!("Share revoke request failed: {error}"), - })?; - let status = response.status(); - if status.is_success() { - return Ok(()); - } - let body = response.text().await.unwrap_or_default(); - Err(share_api_error(status, body)) -} - -async fn get_or_create_share( - http: &reqwest::Client, - auth: &GoTrueClient, - config: &Config, - request: &ShareRequest, -) -> Result { - let session = auth - .get_session() - .await - .map_err(|error| DaemonError::Other { - message: format!("Auth error: {error}"), - })?; - get_or_create_share_with_token(http, config, &session.access_token, request).await -} - -async fn revoke_share( - http: &reqwest::Client, - auth: &GoTrueClient, - config: &Config, - request: &ShareRequest, -) -> Result<(), DaemonError> { - let session = auth - .get_session() - .await - .map_err(|error| DaemonError::Other { - message: format!("Auth error: {error}"), - })?; - revoke_share_with_token(http, config, &session.access_token, request).await -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UploadUrlResponse { - upload_url: String, - content_type: String, -} - -fn validate_api_url(config: &Config) -> Result<(), DaemonError> { - if config.api_url.is_empty() { - return Err(DaemonError::Other { - message: "apiUrl is not configured — set it in config.json or FLICKNOTE_API_URL" - .to_string(), - }); - } - Ok(()) -} - -async fn upload_attachment( - http: &reqwest::Client, - config: &Config, - access_token: &str, - note_id: &str, - file_path: &Path, -) -> Result<(), DaemonError> { - validate_api_url(config)?; - let filename = file_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| DaemonError::Other { - message: "Invalid filename".to_string(), - })? - .to_string(); - - let resp = http - .post(attachment_endpoint(&config.api_url, "upload-url")) - .bearer_auth(access_token) - .json(&serde_json::json!({ "noteId": note_id, "filename": filename })) - .send() - .await - .map_err(|e| DaemonError::Other { - message: format!("Upload URL request failed: {e}"), - })?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(DaemonError::Other { - message: format!("Upload URL request failed: {body}"), - }); - } - - let upload_resp: UploadUrlResponse = resp.json().await.map_err(|e| DaemonError::Other { - message: format!("Failed to parse upload URL response: {e}"), - })?; - - let file_bytes = std::fs::read(file_path).map_err(|e| DaemonError::Other { - message: format!("Failed to read {}: {e}", file_path.display()), - })?; - let put_resp = http - .put(&upload_resp.upload_url) - .header("Content-Type", &upload_resp.content_type) - .body(file_bytes) - .send() - .await - .map_err(|e| DaemonError::Other { - message: format!("File upload failed: {e}"), - })?; - - if !put_resp.status().is_success() { - let body = put_resp.text().await.unwrap_or_default(); - return Err(DaemonError::Other { - message: format!("File upload to R2 failed: {body}"), - }); - } - - Ok(()) -} - -async fn delete_attachment( - http: &reqwest::Client, - config: &Config, - access_token: &str, - note_id: &str, -) -> Result<(), DaemonError> { - validate_api_url(config)?; - let resp = http - .delete(attachment_endpoint(&config.api_url, note_id)) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| DaemonError::Other { - message: format!("Delete request failed: {e}"), - })?; - - if resp.status().is_success() { - return Ok(()); - } - - let body = resp.text().await.unwrap_or_default(); - Err(DaemonError::Other { - message: format!("Delete failed: {body}"), - }) -} - -async fn create_note_remotely( - db: &PowerSyncDatabase, - http: &reqwest::Client, - auth: &GoTrueClient, - config: &Config, - req: CreateNoteRequest, -) -> Result { - let session = auth.get_session().await.map_err(|e| DaemonError::Other { - message: format!("Auth error: {e}"), - })?; - - create_note_with_token( - db, - http, - config, - &session.access_token, - &session.user.id, - req, - ) - .await -} - -async fn create_note_with_token( - db: &PowerSyncDatabase, - http: &reqwest::Client, - config: &Config, - access_token: &str, - user_id: &str, - req: CreateNoteRequest, -) -> Result { - let extraction_rows = req - .topics - .iter() - .map(|value| RemoteExtractionRow { - id: uuid::Uuid::new_v4().to_string(), - note_id: req.id.clone(), - user_id: user_id.to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: value.clone(), - }) - .collect::>(); - let metadata = match req.metadata.as_deref() { - Some(raw) => { - serde_json::from_str::(raw).map_err(|e| DaemonError::Other { - message: format!("Invalid note metadata JSON: {e}"), - })? - } - None => serde_json::Value::Null, - }; - - let attachment_path = req.attachment_path.as_deref().map(Path::new); - if let Some(path) = attachment_path { - upload_attachment(http, config, access_token, &req.id, path).await?; - } - - let payload = serde_json::json!({ - "id": req.id, - "user_id": user_id, - "type": req.note_type, - "status": req.status, - "title": req.title, - "content": req.content, - "metadata": metadata, - "project_id": req.project_id, - "created_at": req.now, - "updated_at": req.now, - }); - - let send_create = || { - http.post(format!( - "{}/rest/v1/notes?on_conflict=id", - config.supabase_url - )) - .header("apikey", &config.supabase_anon_key) - .bearer_auth(access_token) - .header( - "Prefer", - "resolution=ignore-duplicates,return=representation", - ) - .json(&payload) - .send() - }; - let (resp, initial_ambiguous_error) = match send_create().await { - Ok(resp) if !is_ambiguous_create_status(resp.status()) => (resp, None), - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let initial_error = format!("the first attempt returned {status}: {body}"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - match send_create().await { - Ok(resp) if !is_ambiguous_create_status(resp.status()) => { - (resp, Some(initial_error)) - } - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry returned {status}: {body}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - Err(retry_error) => { - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - } - } - Err(initial_error) => { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - match send_create().await { - Ok(resp) => (resp, Some(initial_error.to_string())), - Err(retry_error) => { - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - } - } - }; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { - return finish_remote_create(db, http, config, access_token, row, &extraction_rows) - .await; - } - if let Some(initial_error) = initial_ambiguous_error { - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID: {initial_error}; the retry returned {status}: {body}. The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - if attachment_path.is_some() - && let Err(e) = delete_attachment(http, config, access_token, &req.id).await - { - log::warn!("Failed to clean up uploaded attachment after note create failure: {e}"); - } - return Err(DaemonError::Other { - message: format!("Remote note create failed ({status}): {body}"), - }); - } - - let row = match resp.json::>().await { - Ok(mut rows) => match rows.pop() { - Some(row) => row, - None => { - reconcile_confirmed_remote_note( - http, - config, - access_token, - &req.id, - &extraction_rows, - format!("Remote note create returned no row for note {}", req.id), - ) - .await? - } - }, - Err(error) => { - reconcile_confirmed_remote_note( - http, - config, - access_token, - &req.id, - &extraction_rows, - format!("Failed to parse remote note create response: {error}"), - ) - .await? - } - }; - finish_remote_create(db, http, config, access_token, row, &extraction_rows).await -} - -fn is_ambiguous_create_status(status: reqwest::StatusCode) -> bool { - status.is_server_error() - || status == reqwest::StatusCode::REQUEST_TIMEOUT - || status == reqwest::StatusCode::TOO_MANY_REQUESTS -} - -fn confirmed_create_error( - message: String, - note_id: String, - short_id: Option, - extraction_rows: &[RemoteExtractionRow], -) -> DaemonError { - DaemonError::PartialCreate { - message, - note_id, - short_id, - confirmed_extraction_ids: Vec::new(), - pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), - } -} - -fn partial_create_error( - message: String, - note_id: String, - short_id: Option, - confirmed_extraction_ids: Vec, - pending_extraction_ids: Vec, -) -> DaemonError { - DaemonError::PartialCreate { - message, - note_id, - short_id, - confirmed_extraction_ids, - pending_extraction_ids, - } -} - -fn ambiguous_create_error( - message: String, - note_id: String, - extraction_rows: &[RemoteExtractionRow], -) -> DaemonError { - DaemonError::AmbiguousCreate { - message, - note_id, - pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), - } -} - -async fn reconcile_confirmed_remote_note( - http: &reqwest::Client, - config: &Config, - access_token: &str, - note_id: &str, - extraction_rows: &[RemoteExtractionRow], - original_error: String, -) -> Result { - match lookup_remote_note(http, config, access_token, note_id).await { - Ok(Some(row)) => Ok(row), - Ok(None) => Err(confirmed_create_error( - format!( - "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}. Do not create it again." - ), - note_id.to_string(), - None, - extraction_rows, - )), - Err(error) => Err(confirmed_create_error( - format!( - "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}; reconciliation failed: {error}. Do not create it again." - ), - note_id.to_string(), - None, - extraction_rows, - )), - } -} - -async fn finish_remote_create( - db: &PowerSyncDatabase, - http: &reqwest::Client, - config: &Config, - access_token: &str, - row: RemoteNoteRow, - extraction_rows: &[RemoteExtractionRow], -) -> Result { - let short_id = match row.short_id { - Some(short_id) => short_id, - None => { - return Err(confirmed_create_error( - format!( - "Note {} was created remotely, but the backend returned no short id. Do not create it again.", - row.id - ), - row.id, - None, - extraction_rows, - )); - } - }; - if let Err(error) = commit_remote_note(db, &row).await { - return Err(confirmed_create_error( - format!( - "Note {short_id} was created remotely, but could not be committed locally: {error}. Do not create it again." - ), - row.id, - Some(short_id), - extraction_rows, - )); - } - let extraction_outcome = - create_extractions_with_token(db, http, config, access_token, extraction_rows).await; - if !extraction_outcome.pending_ids.is_empty() || extraction_outcome.local_commit_error.is_some() - { - let reason = extraction_outcome - .local_commit_error - .as_deref() - .or(extraction_outcome.diagnostic.as_deref()) - .unwrap_or("one or more extraction rows could not be confirmed"); - return Err(partial_create_error( - format!( - "Note {short_id} was created, but its topics were not fully committed: {reason}" - ), - row.id, - Some(short_id), - extraction_outcome.confirmed_ids, - extraction_outcome.pending_ids, - )); - } - Ok(RemoteCreatedNote { - uuid: row.id, - short_id, - confirmed_extraction_ids: extraction_outcome.confirmed_ids, - }) -} - -async fn lookup_remote_note( - http: &reqwest::Client, - config: &Config, - access_token: &str, - id: &str, -) -> Result, DaemonError> { - let response = http - .get(format!( - "{}/rest/v1/notes?id=eq.{id}&select=*", - config.supabase_url - )) - .header("apikey", &config.supabase_anon_key) - .bearer_auth(access_token) - .send() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to reconcile remote note {id}: {error}"), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(DaemonError::Other { - message: format!("Failed to reconcile remote note {id} ({status}): {body}"), - }); - } - let mut rows = response - .json::>() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to parse remote note reconciliation response: {error}"), - })?; - Ok(rows.pop()) -} - -async fn create_extractions_with_token( - db: &PowerSyncDatabase, - http: &reqwest::Client, - config: &Config, - access_token: &str, - requested: &[RemoteExtractionRow], -) -> ExtractionCreateOutcome { - if requested.is_empty() { - return ExtractionCreateOutcome::default(); - } - - let response = http - .post(format!( - "{}/rest/v1/note_extractions?on_conflict=id", - config.supabase_url - )) - .header("apikey", &config.supabase_anon_key) - .bearer_auth(access_token) - .header( - "Prefer", - "resolution=ignore-duplicates,return=representation", - ) - .json(requested) - .send() - .await; - let (mut rows, mut diagnostics) = match response { - Ok(response) if response.status().is_success() => { - match response.json::>().await { - Ok(rows) => (rows, Vec::new()), - Err(error) => ( - Vec::new(), - vec![format!( - "failed to parse remote extraction create response: {error}" - )], - ), - } - } - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - ( - Vec::new(), - vec![format!( - "remote extraction create returned {status}: {body}" - )], - ) - } - Err(error) => ( - Vec::new(), - vec![format!( - "remote extraction create failed in transport: {error}" - )], - ), - }; - let mut confirmed_ids = rows - .iter() - .map(|row| row.id.clone()) - .collect::>(); - for expected in requested { - if confirmed_ids.contains(&expected.id) { - continue; - } - match lookup_remote_extraction(http, config, access_token, &expected.id).await { - Ok(Some(row)) => { - rows.push(row); - confirmed_ids.insert(expected.id.clone()); - } - Ok(None) => {} - Err(error) => diagnostics.push(error.to_string()), - } - } - let confirmed_ids = requested - .iter() - .filter(|row| confirmed_ids.contains(&row.id)) - .map(|row| row.id.clone()) - .collect::>(); - let pending_ids = requested - .iter() - .filter(|row| !confirmed_ids.contains(&row.id)) - .map(|row| row.id.clone()) - .collect::>(); - let local_commit_error = if rows.is_empty() { - None - } else { - commit_remote_extractions(db, &rows) - .await - .err() - .map(|error| error.to_string()) - }; - ExtractionCreateOutcome { - confirmed_ids, - pending_ids, - diagnostic: (!diagnostics.is_empty()).then(|| diagnostics.join("; ")), - local_commit_error, - } -} - -async fn lookup_remote_extraction( - http: &reqwest::Client, - config: &Config, - access_token: &str, - id: &str, -) -> Result, DaemonError> { - let response = http - .get(format!( - "{}/rest/v1/note_extractions?id=eq.{id}&select=*", - config.supabase_url - )) - .header("apikey", &config.supabase_anon_key) - .bearer_auth(access_token) - .send() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to reconcile remote extraction {id}: {error}"), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(DaemonError::Other { - message: format!("Failed to reconcile remote extraction {id} ({status}): {body}"), - }); - } - let mut rows = response - .json::>() - .await - .map_err(|error| DaemonError::Other { - message: format!("Failed to parse extraction reconciliation response: {error}"), - })?; - Ok(rows.pop()) -} - -struct RemoteNoteCreator { - db: PowerSyncDatabase, - auth: Arc, - http: reqwest::Client, - config: Arc, -} - -fn remote_create_service_error( - error: DaemonError, -) -> flicknote_core::services::error::ServiceError { - match error { - DaemonError::PartialCreate { - message, - note_id, - short_id, - confirmed_extraction_ids, - pending_extraction_ids, - } => flicknote_core::services::error::ServiceError::Remote { - code: "note_create_partial".to_string(), - message, - retryable: false, - details: Some(serde_json::json!({ - "created": true, - "note_id": note_id, - "short_id": short_id, - "confirmed_extraction_ids": confirmed_extraction_ids, - "pending_extraction_ids": pending_extraction_ids, - })), - }, - DaemonError::AmbiguousCreate { - message, - note_id, - pending_extraction_ids, - } => flicknote_core::services::error::ServiceError::Remote { - code: "note_create_unknown".to_string(), - message, - retryable: false, - details: Some(serde_json::json!({ - "created": serde_json::Value::Null, - "note_id": note_id, - "short_id": serde_json::Value::Null, - "pending_extraction_ids": pending_extraction_ids, - })), - }, - error => flicknote_core::services::error::ServiceError::Daemon(error.to_string()), - } -} - -#[async_trait] -impl NoteCreator for RemoteNoteCreator { - async fn create( - &self, - request: CreateNote, - ) -> Result< - flicknote_core::services::ports::CreatedNote, - flicknote_core::services::error::ServiceError, - > { - let created = create_note_remotely( - &self.db, - &self.http, - &self.auth, - &self.config, - CreateNoteRequest { - id: request.id, - note_type: request.note_type, - status: request.status, - title: request.title, - content: request.content, - metadata: request.metadata, - project_id: request.project_id, - now: request.now, - topics: request.topics, - attachment_path: request.attachment_path, - }, - ) - .await - .map_err(remote_create_service_error)?; - Ok(flicknote_core::services::ports::CreatedNote { - inserted: flicknote_core::backend::InsertedNote { - uuid: created.uuid, - short_id: Some(created.short_id), - }, - confirmed_extraction_ids: created.confirmed_extraction_ids, - }) - } -} - -struct RemoteShareGateway { - http: reqwest::Client, - auth: Arc, - config: Arc, - lock: Arc, -} - -#[async_trait] -impl ShareGateway for RemoteShareGateway { - async fn share( - &self, - resource: CoreShareResource, - id: &str, - ) -> Result { - let request = ShareRequest { - resource: match resource { - CoreShareResource::Note => ShareResource::Note, - CoreShareResource::Project => ShareResource::Project, - }, - id: id.to_string(), - }; - self.lock - .run(get_or_create_share( - &self.http, - &self.auth, - &self.config, - &request, - )) - .await - .map_err(|error| { - flicknote_core::services::error::ServiceError::Daemon(error.to_string()) - }) - } - - async fn unshare( - &self, - resource: CoreShareResource, - id: &str, - ) -> Result<(), flicknote_core::services::error::ServiceError> { - let request = ShareRequest { - resource: match resource { - CoreShareResource::Note => ShareResource::Note, - CoreShareResource::Project => ShareResource::Project, - }, - id: id.to_string(), - }; - self.lock - .run(revoke_share(&self.http, &self.auth, &self.config, &request)) - .await - .map_err(|error| { - flicknote_core::services::error::ServiceError::Daemon(error.to_string()) - }) - } -} - -pub async fn run() -> Result<(), Box> { - let config = Arc::new(Config::load()?); - - let pid_file = pid_path(&config); - let _pid_guard = check_and_write_pid(&pid_file)?; - let (socket_listener, _socket_guard) = bind_socket(&config)?; - - config.validate()?; - - PowerSyncEnvironment::powersync_auto_extension()?; - - let pool = ConnectionPool::open(&config.paths.db_file)?; - let env = PowerSyncEnvironment::custom( - reqwest::Client::new(), - pool, - PowerSyncEnvironment::tokio_timer(), - ); - - let db = PowerSyncDatabase::new(env, app_schema()); - db.async_tasks().spawn_with_tokio(); - - let auth = Arc::new(GoTrueClient::new( - &config.supabase_url, - &config.supabase_anon_key, - &config.paths.session_file, - )); - - let upload_guard = Arc::new(tokio::sync::Mutex::new(())); - let http_client = reqwest::Client::new(); - let upload_client = http_client.clone(); - - let connector = FlickNoteConnector { - db: db.clone(), - auth: Arc::clone(&auth), - upload_guard: Arc::clone(&upload_guard), - http_client, - powersync_url: config.powersync_url.clone(), - supabase_url: config.supabase_url.clone(), - supabase_anon_key: config.supabase_anon_key.clone(), - }; - - // Reclaim leftover WAL from previous sessions BEFORE connecting sync actors. - // TRUNCATE is safe here because no pool connections exist yet — db.connect() - // hasn't started the download actor. A bloated WAL inherited from a crashed - // session is reset to zero so incremental PASSIVE checkpoints start from a - // clean baseline. - // spawn_blocking keeps blocking rusqlite I/O off the async executor thread. - log::info!("Running startup WAL checkpoint"); - let startup_db_path = config.paths.db_file.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&startup_db_path, "startup", WalCheckpointMode::Truncate) - }) - .await - { - log::error!("Startup WAL checkpoint task panicked: {e}"); - } - - // Finish schema replacement through the application pool before PowerSync - // starts its download/upload actors. Replacing tracking views after connect - // races the actor-held SQLite connections and can fail with SQLITE_BUSY on - // an existing database. - let user_id = flicknote_core::session::get_user_id(&config)?; - let backend: Arc = Arc::new(SqliteBackend { - db: Database::open_local(&config).await?, - user_id, - }); - - log::info!("Sync daemon connecting (pid {})", std::process::id()); - db.connect(SyncOptions::new(connector)).await; - log::info!("Sync daemon connected (pid {})", std::process::id()); - - // Application writes happen in this process. Each may-write request sends a - // best-effort trigger; the startup drain recovers committed writes whose signal - // was lost because of a crash or a full channel. - let (trigger_tx, mut trigger_rx) = mpsc::channel::<()>(16); - - let upload_db = db.clone(); - let upload_supabase_url = config.supabase_url.clone(); - let upload_anon_key = config.supabase_anon_key.clone(); - let upload_guard_clone = Arc::clone(&upload_guard); - let upload_auth_clone = Arc::clone(&auth); - let upload_db_path = config.paths.db_file.clone(); - - let mut upload_handle = tokio::spawn(async move { - // Initial upload on startup recovers committed CRUD left by a crash, - // a lost in-process signal, or a pre-upgrade CLI writer. - retry_upload_until_success( - &upload_db, - &upload_client, - &upload_auth_clone, - &upload_guard_clone, - &upload_supabase_url, - &upload_anon_key, - "Startup upload", - &upload_db_path, - ) - .await; - - loop { - // Block until the application host reports a may-write request. - if trigger_rx.recv().await.is_none() { - break; - } - - // Trailing debounce: collapse burst writes (e.g. bulk import) into a - // single upload attempt. Fire only after 200ms of silence. - loop { - tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => break, - v = trigger_rx.recv() => { - if v.is_none() { return; } // channel closed - // more events arrived — reset the silence window - } - } - } - - retry_upload_until_success( - &upload_db, - &upload_client, - &upload_auth_clone, - &upload_guard_clone, - &upload_supabase_url, - &upload_anon_key, - "Upload", - &upload_db_path, - ) - .await; - } - }); - - // Periodic PASSIVE checkpoint every 30s — independent of upload success or - // download actor state. Makes incremental progress draining the WAL without - // acquiring PENDING/EXCLUSIVE locks, so it never contends with pool writers. - let checkpoint_db_path = config.paths.db_file.clone(); - let mut checkpoint_handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - interval.tick().await; // skip the immediate first tick - loop { - interval.tick().await; - let path = checkpoint_db_path.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&path, "periodic", WalCheckpointMode::Passive) - }) - .await - { - log::error!("Periodic WAL checkpoint task panicked: {e}"); - } - } - }); - - let socket_config = Arc::clone(&config); - let socket_http = reqwest::Client::new(); - let socket_share_lock = Arc::new(ShareRequestLock::default()); - let creator: Arc = Arc::new(RemoteNoteCreator { - db: db.clone(), - auth: Arc::clone(&auth), - http: socket_http.clone(), - config: Arc::clone(&config), - }); - let gateway: Arc = Arc::new(RemoteShareGateway { - http: socket_http.clone(), - auth: Arc::clone(&auth), - config: Arc::clone(&config), - lock: socket_share_lock, - }); - let app = Arc::new( - Application::new(backend, creator, gateway) - .with_web_url(config.web_url.clone()) - .with_write_signal(trigger_tx), - ); - let mut socket_handle = tokio::spawn(async move { - if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::current()).await { - log::error!("Application socket server failed: {error}"); - } - }); - - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - res = &mut upload_handle => { - if let Err(e) = res { - log::error!("Upload task panicked: {e}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(e.into()); - } - } - res = &mut checkpoint_handle => { - match res { - Ok(_) => log::error!("Checkpoint task exited unexpectedly"), - Err(ref e) => log::error!("Checkpoint task panicked: {e}"), - } - let err_msg = format!("Checkpoint task exited: {res:?}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(err_msg.into()); - } - res = &mut socket_handle => { - match res { - Ok(_) => log::error!("Socket task exited unexpectedly"), - Err(ref e) => log::error!("Socket task panicked: {e}"), - } - let err_msg = format!("Socket task exited: {res:?}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(err_msg.into()); - } - } - shutdown_daemon( - &mut upload_handle, - &mut checkpoint_handle, - &mut socket_handle, - &db, - socket_config.paths.db_file.clone(), - ) - .await; - - Ok(()) -} +pub(crate) use remote::attachment::*; +pub(crate) use remote::create::*; +pub(crate) use remote::share::*; +pub use runtime::run; +pub(crate) use storage_maintenance::*; +pub(crate) use upload::*; #[cfg(test)] -mod tests { - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::thread; - - use flicknote_core::config::ConfigPaths; - - use super::*; - - #[tokio::test] - async fn failed_upload_is_retried_without_a_second_write_trigger() { - let attempts = Arc::new(AtomicUsize::new(0)); - let attempt_counter = Arc::clone(&attempts); - - retry_with_backoff( - move || { - let attempt_counter = Arc::clone(&attempt_counter); - async move { attempt_counter.fetch_add(1, Ordering::SeqCst) > 0 } - }, - std::time::Duration::from_millis(1), - std::time::Duration::from_millis(2), - ) - .await; - - assert_eq!(attempts.load(Ordering::SeqCst), 2); - } - - async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { - PowerSyncEnvironment::powersync_auto_extension().unwrap(); - let directory = tempfile::tempdir().unwrap(); - let db = test_powersync_db_at(directory.path().join("test.db"), app_schema()); - db.writer().await.unwrap(); - (directory, db) - } - - fn test_powersync_db_at( - path: impl AsRef, - schema: powersync::schema::Schema, - ) -> PowerSyncDatabase { - PowerSyncEnvironment::powersync_auto_extension().unwrap(); - let pool = ConnectionPool::open(path).unwrap(); - let env = PowerSyncEnvironment::custom( - reqwest::Client::new(), - pool, - PowerSyncEnvironment::tokio_timer(), - ); - PowerSyncDatabase::new(env, schema) - } - - async fn insert_note_with_metadata(db: &PowerSyncDatabase, metadata: &str) { - let writer = db.writer().await.unwrap(); - writer - .execute( - r#"INSERT INTO notes ( - id, short_id, user_id, type, status, title, content, - is_flagged, created_at, updated_at, _metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, - params![ - "note-1", - 42, - "user-1", - "normal", - "ai_queued", - "Title", - "Body", - 0, - "2026-08-09T00:00:00Z", - "2026-08-09T00:00:00Z", - metadata, - ], - ) - .unwrap(); - } - - async fn insert_marked_note(db: &PowerSyncDatabase) { - insert_note_with_metadata(db, REMOTE_COMMITTED_INSERT_METADATA).await; - } - - fn remote_note(id: &str, title: &str) -> RemoteNoteRow { - RemoteNoteRow { - id: id.to_string(), - short_id: Some(42), - user_id: "user-1".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some(title.to_string()), - content: Some("Canonical body".to_string()), - summary: Some("Canonical summary".to_string()), - is_flagged: false, - project_id: Some("project-1".to_string()), - metadata: Some(serde_json::json!({"source": "remote"})), - source: Some(serde_json::json!({"kind": "plain"})), - created_at: Some("2026-08-09T00:00:00Z".to_string()), - updated_at: Some("2026-08-09T00:00:01Z".to_string()), - deleted_at: None, - } - } - - #[tokio::test] - async fn remote_committed_note_is_fully_visible_before_return() { - let (_directory, db) = test_powersync_db().await; - let inserted = commit_remote_note(&db, &remote_note("note-full", "Remote title")) - .await - .unwrap(); - - assert!(inserted); - let reader = db.reader().await.unwrap(); - let row = reader - .query_row( - "SELECT short_id, title, summary, metadata, source FROM notes WHERE id = ?", - params!["note-full"], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - }, - ) - .unwrap(); - assert_eq!(row.0, 42); - assert_eq!(row.1, "Remote title"); - assert_eq!(row.2, "Canonical summary"); - assert_eq!(row.3, r#"{"source":"remote"}"#); - assert_eq!(row.4, r#"{"kind":"plain"}"#); - - let transaction = db.next_crud_transaction().await.unwrap().unwrap(); - assert_eq!( - transaction.crud[0].metadata.as_deref(), - Some(REMOTE_COMMITTED_INSERT_METADATA) - ); - } - - #[tokio::test] - async fn remote_committed_note_does_not_replace_row_downloaded_first() { - let (_directory, db) = test_powersync_db().await; - { - let writer = db.writer().await.unwrap(); - writer - .execute( - "INSERT INTO notes (id, short_id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?, ?)", - params!["note-race", 42, "user-1", "normal", "ready", "Newer title"], - ) - .unwrap(); - writer.execute("DELETE FROM ps_crud", []).unwrap(); - } - - let inserted = commit_remote_note(&db, &remote_note("note-race", "Older title")) - .await - .unwrap(); - - assert!(!inserted); - let reader = db.reader().await.unwrap(); - let title: String = reader - .query_row( - "SELECT title FROM notes WHERE id = ?", - params!["note-race"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "Newer title"); - assert!(db.next_crud_transaction().await.unwrap().is_none()); - } - - #[tokio::test] - async fn remote_committed_insert_records_marker_in_crud() { - let (_directory, db) = test_powersync_db().await; - insert_marked_note(&db).await; - - let transaction = db.next_crud_transaction().await.unwrap().unwrap(); - assert_eq!(transaction.crud.len(), 1); - assert_eq!(transaction.crud[0].table, "notes"); - assert!(matches!( - transaction.crud.first().map(|entry| &entry.update_type), - Some(UpdateType::Put) - )); - assert_eq!( - transaction.crud[0].metadata.as_deref(), - Some(r#"{"flicknote":"remote_committed_insert_v1"}"#) - ); - } - - #[tokio::test] - async fn existing_database_upgrades_to_metadata_tracking_without_losing_rows() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("upgrade.db"); - let mut legacy_schema = app_schema(); - for table in &mut legacy_schema.tables { - if matches!(table.name.as_ref(), "notes" | "note_extractions") { - table.options.track_metadata = false; - } - } - { - let legacy_db = test_powersync_db_at(&path, legacy_schema); - let writer = legacy_db.writer().await.unwrap(); - writer - .execute( - "INSERT INTO notes (id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?)", - params!["existing-note", "user-1", "normal", "ready", "Preserved"], - ) - .unwrap(); - writer.execute("DELETE FROM ps_crud", []).unwrap(); - } - - let upgraded_db = test_powersync_db_at(&path, app_schema()); - { - let writer = upgraded_db.writer().await.unwrap(); - let title: String = writer - .query_row( - "SELECT title FROM notes WHERE id = ?", - params!["existing-note"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "Preserved"); - writer - .execute( - "INSERT INTO notes (id, user_id, type, status, title, _metadata) VALUES (?, ?, ?, ?, ?, ?)", - params![ - "marked-after-upgrade", - "user-1", - "normal", - "ready", - "Marked", - REMOTE_COMMITTED_INSERT_METADATA, - ], - ) - .unwrap(); - } - - let transaction = upgraded_db.next_crud_transaction().await.unwrap().unwrap(); - assert_eq!(transaction.crud.len(), 1); - assert_eq!(transaction.crud[0].id, "marked-after-upgrade"); - assert_eq!( - transaction.crud[0].metadata.as_deref(), - Some(REMOTE_COMMITTED_INSERT_METADATA) - ); - } - - #[tokio::test] - async fn existing_database_retires_keyterm_schema_without_losing_projects() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("keyterm-retirement.db"); - let mut legacy_schema = app_schema(); - let projects = legacy_schema - .tables - .iter_mut() - .find(|table| table.name.as_ref() == "projects") - .unwrap(); - if !projects - .columns - .iter() - .any(|column| column.name.as_ref() == "keyterm_id") - { - projects - .columns - .push(powersync::schema::Column::text("keyterm_id")); - } - if !legacy_schema - .tables - .iter() - .any(|table| table.name.as_ref() == "keyterms") - { - legacy_schema.tables.push(powersync::schema::Table::create( - "keyterms", - vec![ - powersync::schema::Column::text("user_id"), - powersync::schema::Column::text("name"), - powersync::schema::Column::text("description"), - powersync::schema::Column::text("content"), - powersync::schema::Column::text("created_at"), - powersync::schema::Column::text("updated_at"), - ], - |_| {}, - )); - } - - { - let legacy_db = test_powersync_db_at(&path, legacy_schema); - let writer = legacy_db.writer().await.unwrap(); - writer - .execute( - "INSERT INTO keyterms (id, user_id, name) VALUES (?, ?, ?)", - params!["retired-keyterm", "user-1", "Retired"], - ) - .unwrap(); - writer - .execute( - "INSERT INTO projects (id, user_id, name, keyterm_id) VALUES (?, ?, ?, ?)", - params![ - "preserved-project", - "user-1", - "Preserved", - "retired-keyterm" - ], - ) - .unwrap(); - } - - let upgraded_db = test_powersync_db_at(&path, app_schema()); - { - let writer = upgraded_db.writer().await.unwrap(); - let project_name: String = writer - .query_row( - "SELECT name FROM projects WHERE id = ?", - params!["preserved-project"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(project_name, "Preserved"); - let retired_view_count: i64 = writer - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'keyterms'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(retired_view_count, 0); - let retired_column_count: i64 = writer - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name = 'keyterm_id'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(retired_column_count, 0); - } - - let (server_url, server) = spawn_capture_server(1); - assert!( - run_upload( - &upgraded_db, - &reqwest::Client::new(), - "token", - &server_url, - "anon-key", - ) - .await - .unwrap() - ); - assert!(upgraded_db.next_crud_transaction().await.unwrap().is_none()); - let requests = server.join().unwrap(); - assert_eq!(requests.len(), 1); - assert!(requests[0].starts_with("POST /rest/v1/projects ")); - let (_, body) = requests[0].split_once("\r\n\r\n").unwrap(); - let payload: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(payload["name"], "Preserved"); - assert!(payload.get("keyterm_id").is_none()); - } - - #[tokio::test] - async fn remote_committed_put_completes_without_http_request() { - let (_directory, db) = test_powersync_db().await; - insert_marked_note(&db).await; - - let uploaded = run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon-key", - ) - .await - .unwrap(); - - assert!(uploaded); - assert!(db.next_crud_transaction().await.unwrap().is_none()); - } - - #[tokio::test] - async fn remote_committed_marker_is_matched_as_json_not_raw_text() { - let (_directory, db) = test_powersync_db().await; - insert_note_with_metadata(&db, r#"{ "flicknote" : "remote_committed_insert_v1" }"#).await; - - run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon-key", - ) - .await - .unwrap(); - - assert!(db.next_crud_transaction().await.unwrap().is_none()); - } - - #[tokio::test] - async fn remote_committed_marker_rejects_extra_metadata_fields() { - let (_directory, db) = test_powersync_db().await; - insert_note_with_metadata( - &db, - r#"{"flicknote":"remote_committed_insert_v1","other":true}"#, - ) - .await; - - let error = run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon", - ) - .await - .unwrap_err(); - - assert!( - error - .to_string() - .contains("invalid FlickNote CRUD metadata") - ); - assert!(db.crud_transactions().try_next().await.unwrap().is_some()); - } - - #[tokio::test] - async fn unsupported_flicknote_marker_is_rejected_and_retained() { - let (_directory, db) = test_powersync_db().await; - insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v2"}"#).await; - - let error = run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon-key", - ) - .await - .unwrap_err(); - - assert!( - error - .to_string() - .contains("unsupported FlickNote CRUD marker") - ); - assert!(db.next_crud_transaction().await.unwrap().is_some()); - } - - #[tokio::test] - async fn malformed_crud_metadata_is_rejected_and_retained() { - let (_directory, db) = test_powersync_db().await; - insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v1""#).await; - - let error = run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon-key", - ) - .await - .unwrap_err(); - - assert!(error.to_string().contains("invalid CRUD metadata")); - assert!(db.next_crud_transaction().await.unwrap().is_some()); - } - - #[tokio::test] - async fn remote_committed_marker_on_patch_is_rejected_and_retained() { - let (_directory, db) = test_powersync_db().await; - insert_marked_note(&db).await; - db.next_crud_transaction() - .await - .unwrap() - .unwrap() - .complete() - .await - .unwrap(); - { - let writer = db.writer().await.unwrap(); - writer - .execute( - "UPDATE notes SET title = ?, _metadata = ? WHERE id = ?", - params!["Changed", REMOTE_COMMITTED_INSERT_METADATA, "note-1"], - ) - .unwrap(); - } - - let error = run_upload( - &db, - &reqwest::Client::new(), - "token", - "http://127.0.0.1:1", - "anon-key", - ) - .await - .unwrap_err(); - - assert!( - error - .to_string() - .contains("invalid remote-committed marker") - ); - assert!(db.next_crud_transaction().await.unwrap().is_some()); - } - - #[tokio::test] - async fn remote_committed_extractions_are_visible_before_return() { - let (_directory, db) = test_powersync_db().await; - let rows = vec![RemoteExtractionRow { - id: "extraction-1".to_string(), - note_id: "note-1".to_string(), - user_id: "user-1".to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "rust".to_string(), - }]; - - let inserted = commit_remote_extractions(&db, &rows).await.unwrap(); - - assert_eq!(inserted, 1); - let reader = db.reader().await.unwrap(); - let value: String = reader - .query_row( - "SELECT value FROM note_extractions WHERE id = ?", - params!["extraction-1"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(value, "rust"); - let transaction = db.next_crud_transaction().await.unwrap().unwrap(); - assert_eq!( - transaction.crud[0].metadata.as_deref(), - Some(REMOTE_COMMITTED_INSERT_METADATA) - ); - } - - #[tokio::test] - async fn share_request_lock_serializes_operations() { - let lock = Arc::new(ShareRequestLock::default()); - let active = Arc::new(AtomicUsize::new(0)); - let max_active = Arc::new(AtomicUsize::new(0)); - - let operation = || { - let lock = Arc::clone(&lock); - let active = Arc::clone(&active); - let max_active = Arc::clone(&max_active); - async move { - lock.run(async { - let current = active.fetch_add(1, Ordering::SeqCst) + 1; - max_active.fetch_max(current, Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - active.fetch_sub(1, Ordering::SeqCst); - }) - .await; - } - }; - - tokio::join!(operation(), operation()); - - assert_eq!(max_active.load(Ordering::SeqCst), 1); - } - - fn test_config(api_url: String) -> Config { - Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url, - web_url: None, - paths: ConfigPaths { - config_dir: PathBuf::new(), - data_dir: PathBuf::new(), - config_file: PathBuf::new(), - session_file: PathBuf::new(), - db_file: PathBuf::new(), - log_file: PathBuf::new(), - }, - } - } - - fn spawn_server( - responses: Vec<(&'static str, &'static str)>, - ) -> (String, thread::JoinHandle>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let handle = thread::spawn(move || { - let mut requests = Vec::new(); - for (status, body) in responses { - let (mut stream, _) = listener.accept().unwrap(); - let mut buffer = [0_u8; 4096]; - let count = stream.read(&mut buffer).unwrap(); - let request = String::from_utf8_lossy(&buffer[..count]); - requests.push(request.lines().next().unwrap_or_default().to_string()); - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes()).unwrap(); - } - requests - }); - (format!("http://{address}"), handle) - } - - fn read_complete_http_request(stream: &mut std::net::TcpStream) -> String { - stream - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - let mut request = Vec::new(); - loop { - let mut buffer = [0_u8; 4096]; - let count = stream.read(&mut buffer).unwrap(); - if count == 0 { - break; - } - request.extend_from_slice(&buffer[..count]); - let Some(headers_end) = request.windows(4).position(|part| part == b"\r\n\r\n") else { - continue; - }; - let headers = String::from_utf8_lossy(&request[..headers_end]); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().unwrap()) - }) - .unwrap_or(0); - if request.len() >= headers_end + 4 + content_length { - break; - } - } - String::from_utf8(request).unwrap() - } - - fn spawn_capture_server(expected_requests: usize) -> (String, thread::JoinHandle>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let handle = thread::spawn(move || { - let mut requests = Vec::new(); - for _ in 0..expected_requests { - let (mut stream, _) = listener.accept().unwrap(); - requests.push(read_complete_http_request(&mut stream)); - stream - .write_all( - b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .unwrap(); - } - requests - }); - (format!("http://{address}"), handle) - } - - fn spawn_disconnected_response_then_server( - status: &'static str, - body: &'static str, - ) -> (String, thread::JoinHandle>) { - spawn_disconnected_then_retry_responses(vec![(status, body)]) - } - - fn spawn_disconnected_then_retry_responses( - responses: Vec<(&'static str, &'static str)>, - ) -> (String, thread::JoinHandle>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let handle = thread::spawn(move || { - let (mut first, _) = listener.accept().unwrap(); - listener.set_nonblocking(true).unwrap(); - let accept = || { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - match listener.accept() { - Ok(pair) => return Some(pair), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - if std::time::Instant::now() >= deadline { - return None; - } - thread::sleep(std::time::Duration::from_millis(5)); - } - Err(error) => panic!("accept failed: {error}"), - } - } - }; - - let mut requests = Vec::new(); - let mut buffer = [0_u8; 4096]; - let count = first.read(&mut buffer).unwrap(); - requests.push( - String::from_utf8_lossy(&buffer[..count]) - .lines() - .next() - .unwrap_or_default() - .to_string(), - ); - drop(first); - - for (status, body) in responses { - let Some((mut stream, _)) = accept() else { - break; - }; - let count = stream.read(&mut buffer).unwrap(); - requests.push( - String::from_utf8_lossy(&buffer[..count]) - .lines() - .next() - .unwrap_or_default() - .to_string(), - ); - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes()).unwrap(); - } - requests - }); - (format!("http://{address}"), handle) - } - - #[test] - fn partial_remote_create_maps_to_non_retryable_structured_service_error() { - let error = remote_create_service_error(DaemonError::PartialCreate { - message: "note created; topics pending".to_string(), - note_id: "note-partial".to_string(), - short_id: Some(80), - confirmed_extraction_ids: vec!["extraction-confirmed".to_string()], - pending_extraction_ids: vec!["extraction-1".to_string()], - }); - - assert_eq!(error.code(), "note_create_partial"); - assert!(!error.retryable()); - let flicknote_core::services::error::ServiceError::Remote { details, .. } = error else { - panic!("expected remote service error") - }; - let details = details.unwrap(); - assert_eq!(details["short_id"], 80); - assert_eq!( - details["confirmed_extraction_ids"], - serde_json::json!(["extraction-confirmed"]) - ); - } - - #[tokio::test] - async fn remote_create_returns_after_canonical_note_is_committed_locally() { - let body = r#"[{"id":"note-create","short_id":77,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![("201 Created", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { - id: "note-create".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }; - - let created = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - request, - ) - .await - .unwrap(); - - assert_eq!(created.uuid, "note-create"); - assert_eq!(created.short_id, 77); - let reader = db.reader().await.unwrap(); - let title: String = reader - .query_row( - "SELECT title FROM notes WHERE id = ?", - params!["note-create"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "Remote title"); - assert_eq!( - server.join().unwrap(), - ["POST /rest/v1/notes?on_conflict=id HTTP/1.1"] - ); - } - - #[tokio::test] - async fn remote_create_reports_typed_partial_success_after_note_commit() { - let note = r#"[{"id":"note-partial","short_id":80,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![ - ("201 Created", note), - ( - "500 Internal Server Error", - r#"{"message":"topic failure"}"#, - ), - ]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let error = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-partial".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested title".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: vec!["rust".to_string()], - attachment_path: None, - }, - ) - .await - .unwrap_err(); - - let DaemonError::PartialCreate { - note_id, - short_id, - pending_extraction_ids, - .. - } = error - else { - panic!("expected partial create error") - }; - assert_eq!(note_id, "note-partial"); - assert_eq!(short_id, Some(80)); - assert_eq!(pending_extraction_ids.len(), 1); - let reader = db.reader().await.unwrap(); - let count: i64 = reader - .query_row( - "SELECT COUNT(*) FROM notes WHERE id = ?", - params!["note-partial"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 1); - assert_eq!(server.join().unwrap().len(), 2); - } - - #[tokio::test] - async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { - let body = r#"[{"id":"note-retry","short_id":78,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { - id: "note-retry".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }; - - let created = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - request, - ) - .await - .unwrap(); - - assert_eq!(created.short_id, 78); - assert_eq!( - server.join().unwrap(), - [ - "POST /rest/v1/notes?on_conflict=id HTTP/1.1", - "GET /rest/v1/notes?id=eq.note-retry&select=* HTTP/1.1", - ] - ); - } - - #[tokio::test] - async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { - let body = r#"[{"id":"note-malformed","short_id":81,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![("201 Created", "{"), ("200 OK", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let created = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-malformed".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await - .unwrap(); - - assert_eq!(created.short_id, 81); - assert_eq!( - server.join().unwrap(), - [ - "POST /rest/v1/notes?on_conflict=id HTTP/1.1", - "GET /rest/v1/notes?id=eq.note-malformed&select=* HTTP/1.1", - ] - ); - } - - #[tokio::test] - async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() { - let (origin, server) = spawn_server(vec![ - ("201 Created", "{"), - ("503 Service Unavailable", r#"{"message":"try later"}"#), - ]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let error = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-confirmed".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await - .unwrap_err(); - let service_error = remote_create_service_error(error); - - assert_eq!(service_error.code(), "note_create_partial"); - let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error - else { - panic!("expected structured remote error") - }; - let details = details.unwrap(); - assert_eq!(details["created"], true); - assert_eq!(details["note_id"], "note-confirmed"); - assert!(details["short_id"].is_null()); - assert_eq!(server.join().unwrap().len(), 2); - } - - #[tokio::test] - async fn local_commit_failure_after_remote_create_reports_partial_success() { - let note = r#"[{"id":"note-local-failure","short_id":82,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![("201 Created", note)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - db.writer() - .await - .unwrap() - .execute("DROP VIEW notes", []) - .unwrap(); - - let error = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-local-failure".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await - .unwrap_err(); - let service_error = remote_create_service_error(error); - - assert_eq!(service_error.code(), "note_create_partial"); - let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error - else { - panic!("expected structured remote error") - }; - let details = details.unwrap(); - assert_eq!(details["created"], true); - assert_eq!(details["note_id"], "note-local-failure"); - assert_eq!(details["short_id"], 82); - assert_eq!(server.join().unwrap().len(), 1); - } - - #[tokio::test] - async fn remote_create_recovers_lost_response_by_stable_uuid() { - let body = r#"[{"id":"note-lost","short_id":79,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_disconnected_response_then_server("200 OK", body); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { - id: "note-lost".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }; - - let created = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - request, - ) - .await - .unwrap(); - - assert_eq!(created.short_id, 79); - assert_eq!(server.join().unwrap().len(), 2); - } - - #[tokio::test] - async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { - let (origin, server) = spawn_disconnected_response_then_server( - "503 Service Unavailable", - r#"{"message":"try later"}"#, - ); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let error = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-unknown".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await - .unwrap_err(); - let service_error = remote_create_service_error(error); - - assert_eq!(service_error.code(), "note_create_unknown"); - assert!(!service_error.retryable()); - let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error - else { - panic!("expected structured remote error") - }; - let details = details.unwrap(); - assert!(details["created"].is_null()); - assert_eq!(details["note_id"], "note-unknown"); - assert_eq!(server.join().unwrap().len(), 2); - } - - #[tokio::test] - async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() { - let body = r#"[{"id":"note-recovered-after-retry","short_id":83,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_disconnected_then_retry_responses(vec![("201 Created", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let result = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-recovered-after-retry".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await; - let requests = server.join().unwrap(); - - let created = result.unwrap(); - assert_eq!(created.short_id, 83); - assert_eq!(requests.len(), 2); - assert!(requests[0].starts_with("POST /rest/v1/notes")); - assert!(requests[1].starts_with("POST /rest/v1/notes")); - } - - #[tokio::test] - async fn retryable_status_retries_create_with_the_same_stable_uuid() { - let body = r#"[{"id":"note-retryable-status","short_id":84,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; - let (origin, server) = spawn_server(vec![ - ("503 Service Unavailable", r#"{"message":"try later"}"#), - ("201 Created", body), - ]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - - let created = create_note_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - "user-1", - CreateNoteRequest { - id: "note-retryable-status".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some("Requested".to_string()), - content: Some("Body".to_string()), - metadata: None, - project_id: None, - now: "2026-08-09T00:00:00Z".to_string(), - topics: Vec::new(), - attachment_path: None, - }, - ) - .await - .unwrap(); - let requests = server.join().unwrap(); - - assert_eq!(created.short_id, 84); - assert_eq!(requests.len(), 2); - assert!( - requests - .iter() - .all(|request| request.starts_with("POST /rest/v1/notes")) - ); - } - - #[tokio::test] - async fn remote_extraction_create_commits_confirmed_rows_locally() { - let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; - let (origin, server) = spawn_server(vec![("201 Created", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let requested = vec![RemoteExtractionRow { - id: "extraction-create".to_string(), - note_id: "note-create".to_string(), - user_id: "user-1".to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "rust".to_string(), - }]; - - let outcome = create_extractions_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - &requested, - ) - .await; - - assert_eq!(outcome.confirmed_ids, ["extraction-create"]); - assert!(outcome.pending_ids.is_empty()); - let reader = db.reader().await.unwrap(); - let count: i64 = reader - .query_row( - "SELECT COUNT(*) FROM note_extractions WHERE id = ?", - params!["extraction-create"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 1); - assert_eq!( - server.join().unwrap(), - ["POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1"] - ); - } - - #[tokio::test] - async fn remote_extraction_create_recovers_by_stable_uuid() { - let body = r#"[{"id":"extraction-retry","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; - let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let requested = vec![RemoteExtractionRow { - id: "extraction-retry".to_string(), - note_id: "note-create".to_string(), - user_id: "user-1".to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "rust".to_string(), - }]; - - let outcome = create_extractions_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - &requested, - ) - .await; - - assert_eq!(outcome.confirmed_ids, ["extraction-retry"]); - assert!(outcome.pending_ids.is_empty()); - assert_eq!( - server.join().unwrap(), - [ - "POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1", - "GET /rest/v1/note_extractions?id=eq.extraction-retry&select=* HTTP/1.1", - ] - ); - } - - #[tokio::test] - async fn remote_extraction_create_commits_confirmed_subset_and_reports_exact_pending_ids() { - let body = r#"[{"id":"extraction-confirmed","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; - let (origin, server) = spawn_server(vec![("201 Created", body), ("200 OK", "[]")]); - let mut config = test_config(String::new()); - config.supabase_url = origin; - config.supabase_anon_key = "anon-key".to_string(); - let (_directory, db) = test_powersync_db().await; - let requested = vec![ - RemoteExtractionRow { - id: "extraction-confirmed".to_string(), - note_id: "note-create".to_string(), - user_id: "user-1".to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "rust".to_string(), - }, - RemoteExtractionRow { - id: "extraction-pending".to_string(), - note_id: "note-create".to_string(), - user_id: "user-1".to_string(), - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "sqlite".to_string(), - }, - ]; - - let outcome = create_extractions_with_token( - &db, - &reqwest::Client::new(), - &config, - "access-token", - &requested, - ) - .await; - - assert_eq!(outcome.confirmed_ids, ["extraction-confirmed"]); - assert_eq!(outcome.pending_ids, ["extraction-pending"]); - let reader = db.reader().await.unwrap(); - let count: i64 = reader - .query_row( - "SELECT COUNT(*) FROM note_extractions WHERE id = ?", - params!["extraction-confirmed"], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 1); - assert_eq!(server.join().unwrap().len(), 2); - } - - #[tokio::test] - async fn returns_existing_note_share_without_replacing_it() { - let (api_origin, server) = spawn_server(vec![( - "200 OK", - r#"{"token":"existing","url":"https://flicknote.app/s/existing"}"#, - )]); - let config = test_config(format!("{api_origin}/api/v1")); - let request = ShareRequest { - resource: ShareResource::Note, - id: "550e8400-e29b-41d4-a716-446655440000".to_string(), - }; - - let url = get_or_create_share_with_token( - &reqwest::Client::new(), - &config, - "access-token", - &request, - ) - .await - .unwrap(); - - assert_eq!(url, "https://flicknote.app/s/existing"); - assert_eq!( - server.join().unwrap(), - ["GET /api/v1/notes/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1"] - ); - } - - #[tokio::test] - async fn creates_project_share_when_none_exists() { - let (api_url, server) = spawn_server(vec![ - ( - "404 Not Found", - r#"{"_tag":"NotFoundError","message":"No project share link exists for this project","errorCode":"PROJECT_SHARE_NOT_FOUND"}"#, - ), - ( - "200 OK", - r#"{"token":"new-token","url":"https://flicknote.app/p/new-token"}"#, - ), - ]); - let config = test_config(api_url); - let request = ShareRequest { - resource: ShareResource::Project, - id: "550e8400-e29b-41d4-a716-446655440000".to_string(), - }; - - let url = get_or_create_share_with_token( - &reqwest::Client::new(), - &config, - "access-token", - &request, - ) - .await - .unwrap(); - - assert_eq!(url, "https://flicknote.app/p/new-token"); - assert_eq!( - server.join().unwrap(), - [ - "GET /api/v1/projects/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1", - "POST /api/v1/projects/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1", - ] - ); - } - - #[tokio::test] - async fn revokes_existing_note_share() { - let (api_url, server) = spawn_server(vec![("200 OK", r#"{"success":true}"#)]); - let config = test_config(api_url); - let request = ShareRequest { - resource: ShareResource::Note, - id: "550e8400-e29b-41d4-a716-446655440000".to_string(), - }; - - revoke_share_with_token(&reqwest::Client::new(), &config, "access-token", &request) - .await - .unwrap(); - - assert_eq!( - server.join().unwrap(), - ["DELETE /api/v1/notes/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1"] - ); - } - - #[test] - fn test_extract_fatal_code_fk_violation() { - let body = r#"{"code":"23503","details":"Key is not present in table \"projects\".","hint":null,"message":"insert or update on table \"notes\" violates foreign key constraint"}"#; - assert_eq!(extract_fatal_code(body), Some("23503".to_string())); - } - - #[test] - fn test_extract_fatal_code_rls_violation() { - let body = r#"{"code":"42501","message":"new row violates row-level security policy"}"#; - assert_eq!(extract_fatal_code(body), Some("42501".to_string())); - } - - #[test] - fn test_extract_fatal_code_transient() { - let body = r#"{"code":"08006","message":"connection failure"}"#; - assert_eq!(extract_fatal_code(body), None); - } - - #[test] - fn test_extract_fatal_code_not_json() { - assert_eq!(extract_fatal_code("Internal Server Error"), None); - } - - #[test] - fn test_extract_fatal_code_postgrest() { - let body = r#"{"code":"PGRST204","message":"column not found"}"#; - assert_eq!(extract_fatal_code(body), Some("PGRST204".to_string())); - } - - #[test] - fn test_extract_fatal_code_class22_data_exception() { - let body = r#"{"code":"22001","message":"value too long for type character varying(255)"}"#; - assert_eq!(extract_fatal_code(body), Some("22001".to_string())); - } - - #[test] - fn test_extract_fatal_code_missing_code_field() { - // Supabase auth-layer errors omit "code" — should be treated as unknown (transient) - let body = r#"{"error":"invalid_grant","error_description":"Refresh Token Not Found"}"#; - assert_eq!(extract_fatal_code(body), None); - } - - #[test] - fn test_unwrap_json_strings() { - let mut data = serde_json::Map::new(); - data.insert("title".into(), serde_json::Value::String("Hello".into())); - data.insert( - "metadata".into(), - serde_json::Value::String(r#"{"file":{"name":"photo.jpg"}}"#.into()), - ); - data.insert( - "tags".into(), - serde_json::Value::String(r#"["rust","cli"]"#.into()), - ); - // Primitive JSON values ("42", "true") must stay as strings — guard is is_object()||is_array(). - data.insert("count".into(), serde_json::Value::String("42".into())); - data.insert("flag".into(), serde_json::Value::String("true".into())); - data.insert("source".into(), serde_json::Value::Null); - unwrap_json_strings(&mut data); - assert_eq!(data["title"], serde_json::Value::String("Hello".into())); // plain string unchanged - assert!(data["metadata"].is_object()); // JSON object string → Value::Object - assert!(data["tags"].is_array()); // JSON array string → Value::Array - assert_eq!(data["count"], serde_json::Value::String("42".into())); // primitive JSON unchanged - assert_eq!(data["flag"], serde_json::Value::String("true".into())); // primitive JSON unchanged - assert!(data["source"].is_null()); // null unchanged - } -} +mod test_support; diff --git a/flicknote-sync/src/remote/attachment.rs b/flicknote-sync/src/remote/attachment.rs new file mode 100644 index 0000000..b1a5ed7 --- /dev/null +++ b/flicknote-sync/src/remote/attachment.rs @@ -0,0 +1,113 @@ +use crate::*; + +pub(crate) fn attachment_endpoint(base_url: &str, path: &str) -> String { + let versioned_base = base_url + .trim_end_matches('/') + .trim_end_matches("/api/v1") + .trim_end_matches('/'); + let path = path.trim_matches('/'); + format!("{versioned_base}/api/v1/attachments/{path}") +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UploadUrlResponse { + pub(crate) upload_url: String, + pub(crate) content_type: String, +} + +pub(crate) fn validate_api_url(config: &Config) -> Result<(), DaemonError> { + if config.api_url.is_empty() { + return Err(DaemonError::Other { + message: "apiUrl is not configured — set it in config.json or FLICKNOTE_API_URL" + .to_string(), + }); + } + Ok(()) +} + +pub(crate) async fn upload_attachment( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + file_path: &Path, +) -> Result<(), DaemonError> { + validate_api_url(config)?; + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| DaemonError::Other { + message: "Invalid filename".to_string(), + })? + .to_string(); + + let resp = http + .post(attachment_endpoint(&config.api_url, "upload-url")) + .bearer_auth(access_token) + .json(&serde_json::json!({ "noteId": note_id, "filename": filename })) + .send() + .await + .map_err(|e| DaemonError::Other { + message: format!("Upload URL request failed: {e}"), + })?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("Upload URL request failed: {body}"), + }); + } + + let upload_resp: UploadUrlResponse = resp.json().await.map_err(|e| DaemonError::Other { + message: format!("Failed to parse upload URL response: {e}"), + })?; + + let file_bytes = std::fs::read(file_path).map_err(|e| DaemonError::Other { + message: format!("Failed to read {}: {e}", file_path.display()), + })?; + let put_resp = http + .put(&upload_resp.upload_url) + .header("Content-Type", &upload_resp.content_type) + .body(file_bytes) + .send() + .await + .map_err(|e| DaemonError::Other { + message: format!("File upload failed: {e}"), + })?; + + if !put_resp.status().is_success() { + let body = put_resp.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("File upload to R2 failed: {body}"), + }); + } + + Ok(()) +} + +pub(crate) async fn delete_attachment( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, +) -> Result<(), DaemonError> { + validate_api_url(config)?; + let resp = http + .delete(attachment_endpoint(&config.api_url, note_id)) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| DaemonError::Other { + message: format!("Delete request failed: {e}"), + })?; + + if resp.status().is_success() { + return Ok(()); + } + + let body = resp.text().await.unwrap_or_default(); + Err(DaemonError::Other { + message: format!("Delete failed: {body}"), + }) +} diff --git a/flicknote-sync/src/remote/create.rs b/flicknote-sync/src/remote/create.rs new file mode 100644 index 0000000..0f348a5 --- /dev/null +++ b/flicknote-sync/src/remote/create.rs @@ -0,0 +1,818 @@ +use crate::*; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreateNoteRequest { + pub(crate) id: String, + pub(crate) note_type: String, + pub(crate) status: String, + pub(crate) title: Option, + pub(crate) content: Option, + pub(crate) metadata: Option, + pub(crate) project_id: Option, + pub(crate) now: String, + pub(crate) topics: Vec, + pub(crate) attachment_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteCreatedNote { + pub(crate) uuid: String, + pub(crate) short_id: i64, + pub(crate) confirmed_extraction_ids: Vec, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct ExtractionCreateOutcome { + pub(crate) confirmed_ids: Vec, + pub(crate) pending_ids: Vec, + pub(crate) diagnostic: Option, + pub(crate) local_commit_error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct RemoteNoteRow { + pub(crate) id: String, + pub(crate) short_id: Option, + pub(crate) user_id: String, + #[serde(rename = "type")] + pub(crate) note_type: String, + pub(crate) status: String, + pub(crate) title: Option, + pub(crate) content: Option, + pub(crate) summary: Option, + #[serde(default)] + pub(crate) is_flagged: bool, + pub(crate) project_id: Option, + pub(crate) metadata: Option, + pub(crate) source: Option, + pub(crate) created_at: Option, + pub(crate) updated_at: Option, + pub(crate) deleted_at: Option, +} + +pub(crate) fn json_column( + value: &Option, +) -> Result, DaemonError> { + value + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|error| DaemonError::Other { + message: format!("Failed to serialize canonical remote JSON: {error}"), + }) +} + +pub(crate) async fn commit_remote_note( + db: &PowerSyncDatabase, + note: &RemoteNoteRow, +) -> Result { + let metadata = json_column(¬e.metadata)?; + let source = json_column(¬e.source)?; + let mut writer = db.writer().await.map_err(|error| DaemonError::Other { + message: format!("Failed to open local PowerSync writer: {error}"), + })?; + let tx = writer + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| DaemonError::Other { + message: format!("Failed to begin local note transaction: {error}"), + })?; + let exists = tx + .query_row( + "SELECT 1 FROM notes WHERE id = ? LIMIT 1", + params![note.id], + |_| Ok(()), + ) + .optional() + .map_err(|error| DaemonError::Other { + message: format!("Failed to check local note {}: {error}", note.id), + })? + .is_some(); + if exists { + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local note transaction: {error}"), + })?; + return Ok(false); + } + + tx.execute( + r#"INSERT INTO notes ( + id, short_id, user_id, type, status, title, content, summary, + is_flagged, project_id, metadata, source, created_at, updated_at, + deleted_at, _metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + params![ + note.id, + note.short_id, + note.user_id, + note.note_type, + note.status, + note.title, + note.content, + note.summary, + note.is_flagged, + note.project_id, + metadata, + source, + note.created_at, + note.updated_at, + note.deleted_at, + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .map_err(|error| DaemonError::Other { + message: format!("Failed to commit remote note {} locally: {error}", note.id), + })?; + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local note transaction: {error}"), + })?; + Ok(true) +} + +#[derive(Debug, Clone, serde::Serialize, Deserialize)] +pub(crate) struct RemoteExtractionRow { + pub(crate) id: String, + pub(crate) note_id: String, + pub(crate) user_id: String, + pub(crate) key: String, + pub(crate) value: String, +} + +pub(crate) async fn commit_remote_extractions( + db: &PowerSyncDatabase, + rows: &[RemoteExtractionRow], +) -> Result { + if rows.is_empty() { + return Ok(0); + } + let mut writer = db.writer().await.map_err(|error| DaemonError::Other { + message: format!("Failed to open local PowerSync writer: {error}"), + })?; + let tx = writer + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| DaemonError::Other { + message: format!("Failed to begin local extraction transaction: {error}"), + })?; + let mut inserted = 0; + for row in rows { + let exists = tx + .query_row( + "SELECT 1 FROM note_extractions WHERE id = ? LIMIT 1", + params![row.id], + |_| Ok(()), + ) + .optional() + .map_err(|error| DaemonError::Other { + message: format!("Failed to check local extraction {}: {error}", row.id), + })? + .is_some(); + if exists { + continue; + } + tx.execute( + r#"INSERT INTO note_extractions ( + id, note_id, user_id, key, value, _metadata + ) VALUES (?, ?, ?, ?, ?, ?)"#, + params![ + row.id, + row.note_id, + row.user_id, + row.key, + row.value, + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .map_err(|error| DaemonError::Other { + message: format!( + "Failed to commit remote extraction {} locally: {error}", + row.id + ), + })?; + inserted += 1; + } + tx.commit().map_err(|error| DaemonError::Other { + message: format!("Failed to finish local extraction transaction: {error}"), + })?; + Ok(inserted) +} + +pub(crate) async fn create_note_remotely( + db: &PowerSyncDatabase, + http: &reqwest::Client, + auth: &GoTrueClient, + config: &Config, + req: CreateNoteRequest, +) -> Result { + let session = auth.get_session().await.map_err(|e| DaemonError::Other { + message: format!("Auth error: {e}"), + })?; + + create_note_with_token( + db, + http, + config, + &session.access_token, + &session.user.id, + req, + ) + .await +} + +pub(crate) async fn create_note_with_token( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + user_id: &str, + req: CreateNoteRequest, +) -> Result { + let extraction_rows = req + .topics + .iter() + .map(|value| RemoteExtractionRow { + id: uuid::Uuid::new_v4().to_string(), + note_id: req.id.clone(), + user_id: user_id.to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: value.clone(), + }) + .collect::>(); + let metadata = match req.metadata.as_deref() { + Some(raw) => { + serde_json::from_str::(raw).map_err(|e| DaemonError::Other { + message: format!("Invalid note metadata JSON: {e}"), + })? + } + None => serde_json::Value::Null, + }; + + let attachment_path = req.attachment_path.as_deref().map(Path::new); + if let Some(path) = attachment_path { + upload_attachment(http, config, access_token, &req.id, path).await?; + } + + let payload = serde_json::json!({ + "id": req.id, + "user_id": user_id, + "type": req.note_type, + "status": req.status, + "title": req.title, + "content": req.content, + "metadata": metadata, + "project_id": req.project_id, + "created_at": req.now, + "updated_at": req.now, + }); + + let send_create = || { + http.post(format!( + "{}/rest/v1/notes?on_conflict=id", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .header( + "Prefer", + "resolution=ignore-duplicates,return=representation", + ) + .json(&payload) + .send() + }; + let (resp, initial_ambiguous_error) = match send_create().await { + Ok(resp) if !is_ambiguous_create_status(resp.status()) => (resp, None), + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let initial_error = format!("the first attempt returned {status}: {body}"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match send_create().await { + Ok(resp) if !is_ambiguous_create_status(resp.status()) => { + (resp, Some(initial_error)) + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await + { + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; + } + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry returned {status}: {body}). The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + Err(retry_error) => { + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await + { + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; + } + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + } + } + Err(initial_error) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match send_create().await { + Ok(resp) => (resp, Some(initial_error.to_string())), + Err(retry_error) => { + if let Ok(Some(row)) = + lookup_remote_note(http, config, access_token, &req.id).await + { + return finish_remote_create( + db, + http, + config, + access_token, + row, + &extraction_rows, + ) + .await; + } + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + } + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { + return finish_remote_create(db, http, config, access_token, row, &extraction_rows) + .await; + } + if let Some(initial_error) = initial_ambiguous_error { + return Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {} after retrying the same stable UUID: {initial_error}; the retry returned {status}: {body}. The attachment was retained. Do not create it again.", + req.id + ), + req.id, + &extraction_rows, + )); + } + if attachment_path.is_some() + && let Err(e) = delete_attachment(http, config, access_token, &req.id).await + { + log::warn!("Failed to clean up uploaded attachment after note create failure: {e}"); + } + return Err(DaemonError::Other { + message: format!("Remote note create failed ({status}): {body}"), + }); + } + + let row = match resp.json::>().await { + Ok(mut rows) => match rows.pop() { + Some(row) => row, + None => { + reconcile_confirmed_remote_note( + http, + config, + access_token, + &req.id, + &extraction_rows, + format!("Remote note create returned no row for note {}", req.id), + ) + .await? + } + }, + Err(error) => { + reconcile_confirmed_remote_note( + http, + config, + access_token, + &req.id, + &extraction_rows, + format!("Failed to parse remote note create response: {error}"), + ) + .await? + } + }; + finish_remote_create(db, http, config, access_token, row, &extraction_rows).await +} + +pub(crate) fn is_ambiguous_create_status(status: reqwest::StatusCode) -> bool { + status.is_server_error() + || status == reqwest::StatusCode::REQUEST_TIMEOUT + || status == reqwest::StatusCode::TOO_MANY_REQUESTS +} + +pub(crate) fn confirmed_create_error( + message: String, + note_id: String, + short_id: Option, + extraction_rows: &[RemoteExtractionRow], +) -> DaemonError { + DaemonError::PartialCreate { + message, + note_id, + short_id, + confirmed_extraction_ids: Vec::new(), + pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), + } +} + +pub(crate) fn partial_create_error( + message: String, + note_id: String, + short_id: Option, + confirmed_extraction_ids: Vec, + pending_extraction_ids: Vec, +) -> DaemonError { + DaemonError::PartialCreate { + message, + note_id, + short_id, + confirmed_extraction_ids, + pending_extraction_ids, + } +} + +pub(crate) fn ambiguous_create_error( + message: String, + note_id: String, + extraction_rows: &[RemoteExtractionRow], +) -> DaemonError { + DaemonError::AmbiguousCreate { + message, + note_id, + pending_extraction_ids: extraction_rows.iter().map(|row| row.id.clone()).collect(), + } +} + +pub(crate) async fn reconcile_confirmed_remote_note( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + extraction_rows: &[RemoteExtractionRow], + original_error: String, +) -> Result { + match lookup_remote_note(http, config, access_token, note_id).await { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(confirmed_create_error( + format!( + "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}. Do not create it again." + ), + note_id.to_string(), + None, + extraction_rows, + )), + Err(error) => Err(confirmed_create_error( + format!( + "Note {note_id} was accepted remotely, but its canonical row could not be recovered: {original_error}; reconciliation failed: {error}. Do not create it again." + ), + note_id.to_string(), + None, + extraction_rows, + )), + } +} + +pub(crate) async fn finish_remote_create( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + row: RemoteNoteRow, + extraction_rows: &[RemoteExtractionRow], +) -> Result { + let short_id = match row.short_id { + Some(short_id) => short_id, + None => { + return Err(confirmed_create_error( + format!( + "Note {} was created remotely, but the backend returned no short id. Do not create it again.", + row.id + ), + row.id, + None, + extraction_rows, + )); + } + }; + if let Err(error) = commit_remote_note(db, &row).await { + return Err(confirmed_create_error( + format!( + "Note {short_id} was created remotely, but could not be committed locally: {error}. Do not create it again." + ), + row.id, + Some(short_id), + extraction_rows, + )); + } + let extraction_outcome = + create_extractions_with_token(db, http, config, access_token, extraction_rows).await; + if !extraction_outcome.pending_ids.is_empty() || extraction_outcome.local_commit_error.is_some() + { + let reason = extraction_outcome + .local_commit_error + .as_deref() + .or(extraction_outcome.diagnostic.as_deref()) + .unwrap_or("one or more extraction rows could not be confirmed"); + return Err(partial_create_error( + format!( + "Note {short_id} was created, but its topics were not fully committed: {reason}" + ), + row.id, + Some(short_id), + extraction_outcome.confirmed_ids, + extraction_outcome.pending_ids, + )); + } + Ok(RemoteCreatedNote { + uuid: row.id, + short_id, + confirmed_extraction_ids: extraction_outcome.confirmed_ids, + }) +} + +pub(crate) async fn lookup_remote_note( + http: &reqwest::Client, + config: &Config, + access_token: &str, + id: &str, +) -> Result, DaemonError> { + let response = http + .get(format!( + "{}/rest/v1/notes?id=eq.{id}&select=*", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to reconcile remote note {id}: {error}"), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("Failed to reconcile remote note {id} ({status}): {body}"), + }); + } + let mut rows = response + .json::>() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse remote note reconciliation response: {error}"), + })?; + Ok(rows.pop()) +} + +pub(crate) async fn create_extractions_with_token( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + requested: &[RemoteExtractionRow], +) -> ExtractionCreateOutcome { + if requested.is_empty() { + return ExtractionCreateOutcome::default(); + } + + let response = http + .post(format!( + "{}/rest/v1/note_extractions?on_conflict=id", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .header( + "Prefer", + "resolution=ignore-duplicates,return=representation", + ) + .json(requested) + .send() + .await; + let (mut rows, mut diagnostics) = match response { + Ok(response) if response.status().is_success() => { + match response.json::>().await { + Ok(rows) => (rows, Vec::new()), + Err(error) => ( + Vec::new(), + vec![format!( + "failed to parse remote extraction create response: {error}" + )], + ), + } + } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + ( + Vec::new(), + vec![format!( + "remote extraction create returned {status}: {body}" + )], + ) + } + Err(error) => ( + Vec::new(), + vec![format!( + "remote extraction create failed in transport: {error}" + )], + ), + }; + let mut confirmed_ids = rows + .iter() + .map(|row| row.id.clone()) + .collect::>(); + for expected in requested { + if confirmed_ids.contains(&expected.id) { + continue; + } + match lookup_remote_extraction(http, config, access_token, &expected.id).await { + Ok(Some(row)) => { + rows.push(row); + confirmed_ids.insert(expected.id.clone()); + } + Ok(None) => {} + Err(error) => diagnostics.push(error.to_string()), + } + } + let confirmed_ids = requested + .iter() + .filter(|row| confirmed_ids.contains(&row.id)) + .map(|row| row.id.clone()) + .collect::>(); + let pending_ids = requested + .iter() + .filter(|row| !confirmed_ids.contains(&row.id)) + .map(|row| row.id.clone()) + .collect::>(); + let local_commit_error = if rows.is_empty() { + None + } else { + commit_remote_extractions(db, &rows) + .await + .err() + .map(|error| error.to_string()) + }; + ExtractionCreateOutcome { + confirmed_ids, + pending_ids, + diagnostic: (!diagnostics.is_empty()).then(|| diagnostics.join("; ")), + local_commit_error, + } +} + +pub(crate) async fn lookup_remote_extraction( + http: &reqwest::Client, + config: &Config, + access_token: &str, + id: &str, +) -> Result, DaemonError> { + let response = http + .get(format!( + "{}/rest/v1/note_extractions?id=eq.{id}&select=*", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to reconcile remote extraction {id}: {error}"), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(DaemonError::Other { + message: format!("Failed to reconcile remote extraction {id} ({status}): {body}"), + }); + } + let mut rows = response + .json::>() + .await + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse extraction reconciliation response: {error}"), + })?; + Ok(rows.pop()) +} + +pub(crate) struct RemoteNoteCreator { + pub(crate) db: PowerSyncDatabase, + pub(crate) auth: Arc, + pub(crate) http: reqwest::Client, + pub(crate) config: Arc, +} + +pub(crate) fn remote_create_service_error( + error: DaemonError, +) -> flicknote_core::services::error::ServiceError { + match error { + DaemonError::PartialCreate { + message, + note_id, + short_id, + confirmed_extraction_ids, + pending_extraction_ids, + } => flicknote_core::services::error::ServiceError::Remote { + code: "note_create_partial".to_string(), + message, + retryable: false, + details: Some(serde_json::json!({ + "created": true, + "note_id": note_id, + "short_id": short_id, + "confirmed_extraction_ids": confirmed_extraction_ids, + "pending_extraction_ids": pending_extraction_ids, + })), + }, + DaemonError::AmbiguousCreate { + message, + note_id, + pending_extraction_ids, + } => flicknote_core::services::error::ServiceError::Remote { + code: "note_create_unknown".to_string(), + message, + retryable: false, + details: Some(serde_json::json!({ + "created": serde_json::Value::Null, + "note_id": note_id, + "short_id": serde_json::Value::Null, + "pending_extraction_ids": pending_extraction_ids, + })), + }, + error => flicknote_core::services::error::ServiceError::Daemon(error.to_string()), + } +} + +#[async_trait] +impl NoteCreator for RemoteNoteCreator { + async fn create( + &self, + request: CreateNote, + ) -> Result< + flicknote_core::services::ports::CreatedNote, + flicknote_core::services::error::ServiceError, + > { + let created = create_note_remotely( + &self.db, + &self.http, + &self.auth, + &self.config, + CreateNoteRequest { + id: request.id, + note_type: request.note_type, + status: request.status, + title: request.title, + content: request.content, + metadata: request.metadata, + project_id: request.project_id, + now: request.now, + topics: request.topics, + attachment_path: request.attachment_path, + }, + ) + .await + .map_err(remote_create_service_error)?; + Ok(flicknote_core::services::ports::CreatedNote { + inserted: flicknote_core::backend::InsertedNote { + uuid: created.uuid, + short_id: Some(created.short_id), + }, + confirmed_extraction_ids: created.confirmed_extraction_ids, + }) + } +} diff --git a/flicknote-sync/src/remote/create/tests.rs b/flicknote-sync/src/remote/create/tests.rs new file mode 100644 index 0000000..4505822 --- /dev/null +++ b/flicknote-sync/src/remote/create/tests.rs @@ -0,0 +1,701 @@ +use super::*; +use crate::test_support::*; + +#[tokio::test] +async fn remote_committed_note_is_fully_visible_before_return() { + let (_directory, db) = test_powersync_db().await; + let inserted = commit_remote_note(&db, &remote_note("note-full", "Remote title")) + .await + .unwrap(); + + assert!(inserted); + let reader = db.reader().await.unwrap(); + let row = reader + .query_row( + "SELECT short_id, title, summary, metadata, source FROM notes WHERE id = ?", + params!["note-full"], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + ) + .unwrap(); + assert_eq!(row.0, 42); + assert_eq!(row.1, "Remote title"); + assert_eq!(row.2, "Canonical summary"); + assert_eq!(row.3, r#"{"source":"remote"}"#); + assert_eq!(row.4, r#"{"kind":"plain"}"#); + + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); +} + +#[tokio::test] +async fn remote_committed_note_does_not_replace_row_downloaded_first() { + let (_directory, db) = test_powersync_db().await; + { + let writer = db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO notes (id, short_id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?, ?)", + params!["note-race", 42, "user-1", "normal", "ready", "Newer title"], + ) + .unwrap(); + writer.execute("DELETE FROM ps_crud", []).unwrap(); + } + + let inserted = commit_remote_note(&db, &remote_note("note-race", "Older title")) + .await + .unwrap(); + + assert!(!inserted); + let reader = db.reader().await.unwrap(); + let title: String = reader + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["note-race"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Newer title"); + assert!(db.next_crud_transaction().await.unwrap().is_none()); +} + +#[tokio::test] +async fn remote_committed_extractions_are_visible_before_return() { + let (_directory, db) = test_powersync_db().await; + let rows = vec![RemoteExtractionRow { + id: "extraction-1".to_string(), + note_id: "note-1".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let inserted = commit_remote_extractions(&db, &rows).await.unwrap(); + + assert_eq!(inserted, 1); + let reader = db.reader().await.unwrap(); + let value: String = reader + .query_row( + "SELECT value FROM note_extractions WHERE id = ?", + params!["extraction-1"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(value, "rust"); + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); +} + +#[test] +fn partial_remote_create_maps_to_non_retryable_structured_service_error() { + let error = remote_create_service_error(DaemonError::PartialCreate { + message: "note created; topics pending".to_string(), + note_id: "note-partial".to_string(), + short_id: Some(80), + confirmed_extraction_ids: vec!["extraction-confirmed".to_string()], + pending_extraction_ids: vec!["extraction-1".to_string()], + }); + + assert_eq!(error.code(), "note_create_partial"); + assert!(!error.retryable()); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = error else { + panic!("expected remote service error") + }; + let details = details.unwrap(); + assert_eq!(details["short_id"], 80); + assert_eq!( + details["confirmed_extraction_ids"], + serde_json::json!(["extraction-confirmed"]) + ); +} + +#[tokio::test] +async fn remote_create_returns_after_canonical_note_is_committed_locally() { + let body = r#"[{"id":"note-create","short_id":77,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-create".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.uuid, "note-create"); + assert_eq!(created.short_id, 77); + let reader = db.reader().await.unwrap(); + let title: String = reader + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["note-create"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Remote title"); + assert_eq!( + server.join().unwrap(), + ["POST /rest/v1/notes?on_conflict=id HTTP/1.1"] + ); +} + +#[tokio::test] +async fn remote_create_reports_typed_partial_success_after_note_commit() { + let note = r#"[{"id":"note-partial","short_id":80,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![ + ("201 Created", note), + ( + "500 Internal Server Error", + r#"{"message":"topic failure"}"#, + ), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-partial".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested title".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: vec!["rust".to_string()], + attachment_path: None, + }, + ) + .await + .unwrap_err(); + + let DaemonError::PartialCreate { + note_id, + short_id, + pending_extraction_ids, + .. + } = error + else { + panic!("expected partial create error") + }; + assert_eq!(note_id, "note-partial"); + assert_eq!(short_id, Some(80)); + assert_eq!(pending_extraction_ids.len(), 1); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM notes WHERE id = ?", + params!["note-partial"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(server.join().unwrap().len(), 2); +} + +#[tokio::test] +async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { + let body = r#"[{"id":"note-retry","short_id":78,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-retry".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 78); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/notes?on_conflict=id HTTP/1.1", + "GET /rest/v1/notes?id=eq.note-retry&select=* HTTP/1.1", + ] + ); +} + +#[tokio::test] +async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { + let body = r#"[{"id":"note-malformed","short_id":81,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", "{"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-malformed".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 81); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/notes?on_conflict=id HTTP/1.1", + "GET /rest/v1/notes?id=eq.note-malformed&select=* HTTP/1.1", + ] + ); +} + +#[tokio::test] +async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() { + let (origin, server) = spawn_server(vec![ + ("201 Created", "{"), + ("503 Service Unavailable", r#"{"message":"try later"}"#), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-confirmed".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_partial"); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["note_id"], "note-confirmed"); + assert!(details["short_id"].is_null()); + assert_eq!(server.join().unwrap().len(), 2); +} + +#[tokio::test] +async fn local_commit_failure_after_remote_create_reports_partial_success() { + let note = r#"[{"id":"note-local-failure","short_id":82,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![("201 Created", note)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + db.writer() + .await + .unwrap() + .execute("DROP VIEW notes", []) + .unwrap(); + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-local-failure".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_partial"); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert_eq!(details["created"], true); + assert_eq!(details["note_id"], "note-local-failure"); + assert_eq!(details["short_id"], 82); + assert_eq!(server.join().unwrap().len(), 1); +} + +#[tokio::test] +async fn remote_create_recovers_lost_response_by_stable_uuid() { + let body = r#"[{"id":"note-lost","short_id":79,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_disconnected_response_then_server("200 OK", body); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let request = CreateNoteRequest { + id: "note-lost".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + request, + ) + .await + .unwrap(); + + assert_eq!(created.short_id, 79); + assert_eq!(server.join().unwrap().len(), 2); +} + +#[tokio::test] +async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { + let (origin, server) = spawn_disconnected_response_then_server( + "503 Service Unavailable", + r#"{"message":"try later"}"#, + ); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let error = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-unknown".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap_err(); + let service_error = remote_create_service_error(error); + + assert_eq!(service_error.code(), "note_create_unknown"); + assert!(!service_error.retryable()); + let flicknote_core::services::error::ServiceError::Remote { details, .. } = service_error + else { + panic!("expected structured remote error") + }; + let details = details.unwrap(); + assert!(details["created"].is_null()); + assert_eq!(details["note_id"], "note-unknown"); + assert_eq!(server.join().unwrap().len(), 2); +} + +#[tokio::test] +async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() { + let body = r#"[{"id":"note-recovered-after-retry","short_id":83,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_disconnected_then_retry_responses(vec![("201 Created", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let result = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-recovered-after-retry".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await; + let requests = server.join().unwrap(); + + let created = result.unwrap(); + assert_eq!(created.short_id, 83); + assert_eq!(requests.len(), 2); + assert!(requests[0].starts_with("POST /rest/v1/notes")); + assert!(requests[1].starts_with("POST /rest/v1/notes")); +} + +#[tokio::test] +async fn retryable_status_retries_create_with_the_same_stable_uuid() { + let body = r#"[{"id":"note-retryable-status","short_id":84,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; + let (origin, server) = spawn_server(vec![ + ("503 Service Unavailable", r#"{"message":"try later"}"#), + ("201 Created", body), + ]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + + let created = create_note_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + "user-1", + CreateNoteRequest { + id: "note-retryable-status".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some("Requested".to_string()), + content: Some("Body".to_string()), + metadata: None, + project_id: None, + now: "2026-08-09T00:00:00Z".to_string(), + topics: Vec::new(), + attachment_path: None, + }, + ) + .await + .unwrap(); + let requests = server.join().unwrap(); + + assert_eq!(created.short_id, 84); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.starts_with("POST /rest/v1/notes")) + ); +} + +#[tokio::test] +async fn remote_extraction_create_commits_confirmed_rows_locally() { + let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![RemoteExtractionRow { + id: "extraction-create".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let outcome = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await; + + assert_eq!(outcome.confirmed_ids, ["extraction-create"]); + assert!(outcome.pending_ids.is_empty()); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM note_extractions WHERE id = ?", + params!["extraction-create"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + server.join().unwrap(), + ["POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1"] + ); +} + +#[tokio::test] +async fn remote_extraction_create_recovers_by_stable_uuid() { + let body = r#"[{"id":"extraction-retry","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![RemoteExtractionRow { + id: "extraction-retry".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }]; + + let outcome = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await; + + assert_eq!(outcome.confirmed_ids, ["extraction-retry"]); + assert!(outcome.pending_ids.is_empty()); + assert_eq!( + server.join().unwrap(), + [ + "POST /rest/v1/note_extractions?on_conflict=id HTTP/1.1", + "GET /rest/v1/note_extractions?id=eq.extraction-retry&select=* HTTP/1.1", + ] + ); +} + +#[tokio::test] +async fn remote_extraction_create_commits_confirmed_subset_and_reports_exact_pending_ids() { + let body = r#"[{"id":"extraction-confirmed","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; + let (origin, server) = spawn_server(vec![("201 Created", body), ("200 OK", "[]")]); + let mut config = test_config(String::new()); + config.supabase_url = origin; + config.supabase_anon_key = "anon-key".to_string(); + let (_directory, db) = test_powersync_db().await; + let requested = vec![ + RemoteExtractionRow { + id: "extraction-confirmed".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "rust".to_string(), + }, + RemoteExtractionRow { + id: "extraction-pending".to_string(), + note_id: "note-create".to_string(), + user_id: "user-1".to_string(), + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "sqlite".to_string(), + }, + ]; + + let outcome = create_extractions_with_token( + &db, + &reqwest::Client::new(), + &config, + "access-token", + &requested, + ) + .await; + + assert_eq!(outcome.confirmed_ids, ["extraction-confirmed"]); + assert_eq!(outcome.pending_ids, ["extraction-pending"]); + let reader = db.reader().await.unwrap(); + let count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM note_extractions WHERE id = ?", + params!["extraction-confirmed"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(server.join().unwrap().len(), 2); +} diff --git a/flicknote-sync/src/remote/mod.rs b/flicknote-sync/src/remote/mod.rs new file mode 100644 index 0000000..864ad28 --- /dev/null +++ b/flicknote-sync/src/remote/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod attachment; +pub(crate) mod create; +pub(crate) mod share; diff --git a/flicknote-sync/src/remote/share.rs b/flicknote-sync/src/remote/share.rs new file mode 100644 index 0000000..3853423 --- /dev/null +++ b/flicknote-sync/src/remote/share.rs @@ -0,0 +1,245 @@ +use crate::*; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShareResource { + Note, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShareRequest { + pub(crate) resource: ShareResource, + pub(crate) id: String, +} + +#[derive(Deserialize)] +pub(crate) struct ShareResponse { + pub(crate) url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ShareApiError { + pub(crate) error_code: Option, + pub(crate) message: Option, +} + +#[derive(Default)] +pub(crate) struct ShareRequestLock { + pub(crate) mutex: tokio::sync::Mutex<()>, +} + +impl ShareRequestLock { + pub(crate) async fn run(&self, operation: impl Future) -> T { + let _guard = self.mutex.lock().await; + operation.await + } +} + +impl ShareResource { + fn path_segment(self) -> &'static str { + match self { + Self::Note => "notes", + Self::Project => "projects", + } + } + + fn missing_error_code(self) -> &'static str { + match self { + Self::Note => "SHARE_NOT_FOUND", + Self::Project => "PROJECT_SHARE_NOT_FOUND", + } + } +} + +pub(crate) fn share_endpoint(api_url: &str, request: &ShareRequest) -> String { + let versioned_base = api_url + .trim_end_matches('/') + .trim_end_matches("/api/v1") + .trim_end_matches('/'); + format!( + "{versioned_base}/api/v1/{}/{}/share", + request.resource.path_segment(), + request.id + ) +} + +pub(crate) fn share_api_error(status: reqwest::StatusCode, body: String) -> DaemonError { + let message = serde_json::from_str::(&body) + .ok() + .and_then(|error| error.message) + .unwrap_or(body); + DaemonError::Other { + message: format!("Share API returned {status}: {message}"), + } +} + +pub(crate) async fn parse_share_url(response: reqwest::Response) -> Result { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(share_api_error(status, body)); + } + response + .json::() + .await + .map(|share| share.url) + .map_err(|error| DaemonError::Other { + message: format!("Failed to parse share API response: {error}"), + }) +} + +pub(crate) async fn get_or_create_share_with_token( + http: &reqwest::Client, + config: &Config, + access_token: &str, + request: &ShareRequest, +) -> Result { + validate_api_url(config)?; + let endpoint = share_endpoint(&config.api_url, request); + let response = http + .get(&endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Share request failed: {error}"), + })?; + + if response.status().is_success() { + return parse_share_url(response).await; + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let is_missing_share = status == reqwest::StatusCode::NOT_FOUND + && serde_json::from_str::(&body) + .ok() + .and_then(|error| error.error_code) + .is_some_and(|code| code == request.resource.missing_error_code()); + if !is_missing_share { + return Err(share_api_error(status, body)); + } + + let response = http + .post(endpoint) + .bearer_auth(access_token) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Share create request failed: {error}"), + })?; + parse_share_url(response).await +} + +pub(crate) async fn revoke_share_with_token( + http: &reqwest::Client, + config: &Config, + access_token: &str, + request: &ShareRequest, +) -> Result<(), DaemonError> { + validate_api_url(config)?; + let response = http + .delete(share_endpoint(&config.api_url, request)) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| DaemonError::Other { + message: format!("Share revoke request failed: {error}"), + })?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let body = response.text().await.unwrap_or_default(); + Err(share_api_error(status, body)) +} + +pub(crate) async fn get_or_create_share( + http: &reqwest::Client, + auth: &GoTrueClient, + config: &Config, + request: &ShareRequest, +) -> Result { + let session = auth + .get_session() + .await + .map_err(|error| DaemonError::Other { + message: format!("Auth error: {error}"), + })?; + get_or_create_share_with_token(http, config, &session.access_token, request).await +} + +pub(crate) async fn revoke_share( + http: &reqwest::Client, + auth: &GoTrueClient, + config: &Config, + request: &ShareRequest, +) -> Result<(), DaemonError> { + let session = auth + .get_session() + .await + .map_err(|error| DaemonError::Other { + message: format!("Auth error: {error}"), + })?; + revoke_share_with_token(http, config, &session.access_token, request).await +} + +pub(crate) struct RemoteShareGateway { + pub(crate) http: reqwest::Client, + pub(crate) auth: Arc, + pub(crate) config: Arc, + pub(crate) lock: Arc, +} + +#[async_trait] +impl ShareGateway for RemoteShareGateway { + async fn share( + &self, + resource: CoreShareResource, + id: &str, + ) -> Result { + let request = ShareRequest { + resource: match resource { + CoreShareResource::Note => ShareResource::Note, + CoreShareResource::Project => ShareResource::Project, + }, + id: id.to_string(), + }; + self.lock + .run(get_or_create_share( + &self.http, + &self.auth, + &self.config, + &request, + )) + .await + .map_err(|error| { + flicknote_core::services::error::ServiceError::Daemon(error.to_string()) + }) + } + + async fn unshare( + &self, + resource: CoreShareResource, + id: &str, + ) -> Result<(), flicknote_core::services::error::ServiceError> { + let request = ShareRequest { + resource: match resource { + CoreShareResource::Note => ShareResource::Note, + CoreShareResource::Project => ShareResource::Project, + }, + id: id.to_string(), + }; + self.lock + .run(revoke_share(&self.http, &self.auth, &self.config, &request)) + .await + .map_err(|error| { + flicknote_core::services::error::ServiceError::Daemon(error.to_string()) + }) + } +} diff --git a/flicknote-sync/src/remote/share/tests.rs b/flicknote-sync/src/remote/share/tests.rs new file mode 100644 index 0000000..97247b1 --- /dev/null +++ b/flicknote-sync/src/remote/share/tests.rs @@ -0,0 +1,106 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; +use crate::test_support::*; + +#[tokio::test] +async fn share_request_lock_serializes_operations() { + let lock = Arc::new(ShareRequestLock::default()); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + + let operation = || { + let lock = Arc::clone(&lock); + let active = Arc::clone(&active); + let max_active = Arc::clone(&max_active); + async move { + lock.run(async { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + active.fetch_sub(1, Ordering::SeqCst); + }) + .await; + } + }; + + tokio::join!(operation(), operation()); + + assert_eq!(max_active.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn returns_existing_note_share_without_replacing_it() { + let (api_origin, server) = spawn_server(vec![( + "200 OK", + r#"{"token":"existing","url":"https://flicknote.app/s/existing"}"#, + )]); + let config = test_config(format!("{api_origin}/api/v1")); + let request = ShareRequest { + resource: ShareResource::Note, + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + }; + + let url = + get_or_create_share_with_token(&reqwest::Client::new(), &config, "access-token", &request) + .await + .unwrap(); + + assert_eq!(url, "https://flicknote.app/s/existing"); + assert_eq!( + server.join().unwrap(), + ["GET /api/v1/notes/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1"] + ); +} + +#[tokio::test] +async fn creates_project_share_when_none_exists() { + let (api_url, server) = spawn_server(vec![ + ( + "404 Not Found", + r#"{"_tag":"NotFoundError","message":"No project share link exists for this project","errorCode":"PROJECT_SHARE_NOT_FOUND"}"#, + ), + ( + "200 OK", + r#"{"token":"new-token","url":"https://flicknote.app/p/new-token"}"#, + ), + ]); + let config = test_config(api_url); + let request = ShareRequest { + resource: ShareResource::Project, + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + }; + + let url = + get_or_create_share_with_token(&reqwest::Client::new(), &config, "access-token", &request) + .await + .unwrap(); + + assert_eq!(url, "https://flicknote.app/p/new-token"); + assert_eq!( + server.join().unwrap(), + [ + "GET /api/v1/projects/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1", + "POST /api/v1/projects/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1", + ] + ); +} + +#[tokio::test] +async fn revokes_existing_note_share() { + let (api_url, server) = spawn_server(vec![("200 OK", r#"{"success":true}"#)]); + let config = test_config(api_url); + let request = ShareRequest { + resource: ShareResource::Note, + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + }; + + revoke_share_with_token(&reqwest::Client::new(), &config, "access-token", &request) + .await + .unwrap(); + + assert_eq!( + server.join().unwrap(), + ["DELETE /api/v1/notes/550e8400-e29b-41d4-a716-446655440000/share HTTP/1.1"] + ); +} diff --git a/flicknote-sync/src/runtime.rs b/flicknote-sync/src/runtime.rs new file mode 100644 index 0000000..ee28071 --- /dev/null +++ b/flicknote-sync/src/runtime.rs @@ -0,0 +1,318 @@ +use crate::*; + +pub(crate) fn pid_path(config: &Config) -> PathBuf { + PathBuf::from(&config.paths.data_dir).join("sync.pid") +} + +pub(crate) struct PidGuard(PathBuf); + +impl Drop for PidGuard { + fn drop(&mut self) { + if let Err(e) = std::fs::remove_file(&self.0) { + log::warn!("Failed to remove PID file: {}", e); + } + } +} + +pub(crate) struct SocketGuard(PathBuf); + +impl Drop for SocketGuard { + fn drop(&mut self) { + if let Err(e) = std::fs::remove_file(&self.0) { + log::warn!("Failed to remove socket file: {}", e); + } + } +} + +pub(crate) fn bind_socket( + config: &Config, +) -> Result<(UnixListener, SocketGuard), Box> { + let path = ipc::socket_path(config); + if path.exists() { + std::fs::remove_file(&path)?; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let listener = UnixListener::bind(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok((listener, SocketGuard(path))) +} + +/// Check for an existing sync daemon and write our PID file. +/// +/// Note: there is a small TOCTOU window between the `kill(pid, 0)` liveness +/// check and writing the new PID file. Two daemons launched simultaneously +/// could both pass. For a CLI daemon this is acceptable; use `flock` or +/// `O_CREAT|O_EXCL` if stronger guarantees are ever needed. +#[allow(unsafe_code)] +pub(crate) fn check_and_write_pid(path: &Path) -> Result> { + if let Ok(contents) = std::fs::read_to_string(path) + && let Ok(pid) = contents.trim().parse::() + { + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 + || (result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) + { + return Err(format!( + "Sync daemon already running (pid={}). Kill it first or delete {}", + pid, + path.display() + ) + .into()); + } + log::info!("Removing stale PID file (pid={} no longer running)", pid); + } + + std::fs::write(path, std::process::id().to_string()) + .map_err(|e| format!("Failed to write PID file {}: {}", path.display(), e))?; + Ok(PidGuard(path.to_path_buf())) +} + +/// Tear down all async actors, disconnect the database, and run a final TRUNCATE +/// checkpoint. +/// +/// Called from every shutdown path (ctrl-c, task panic, normal exit). The pool +/// is fully gone after `db.disconnect().await`, so TRUNCATE succeeds without +/// contention. Uses `spawn_blocking` to keep the blocking rusqlite I/O off the +/// async executor thread per [`checkpoint_wal_standalone`]'s contract. +pub(crate) async fn shutdown_daemon( + upload_handle: &mut tokio::task::JoinHandle<()>, + checkpoint_handle: &mut tokio::task::JoinHandle<()>, + socket_handle: &mut tokio::task::JoinHandle<()>, + db: &PowerSyncDatabase, + db_path: PathBuf, +) { + upload_handle.abort(); + checkpoint_handle.abort(); + socket_handle.abort(); + db.disconnect().await; + if let Err(e) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&db_path, "shutdown", WalCheckpointMode::Truncate) + }) + .await + { + log::error!("Shutdown WAL checkpoint task panicked: {e}"); + } + log::info!("Sync daemon stopped"); +} + +pub async fn run() -> Result<(), Box> { + let config = Arc::new(Config::load()?); + + let pid_file = pid_path(&config); + let _pid_guard = check_and_write_pid(&pid_file)?; + let (socket_listener, _socket_guard) = bind_socket(&config)?; + + config.validate()?; + + PowerSyncEnvironment::powersync_auto_extension()?; + + let pool = ConnectionPool::open(&config.paths.db_file)?; + let env = PowerSyncEnvironment::custom( + reqwest::Client::new(), + pool, + PowerSyncEnvironment::tokio_timer(), + ); + + let db = PowerSyncDatabase::new(env, app_schema()); + db.async_tasks().spawn_with_tokio(); + + let auth = Arc::new(GoTrueClient::new( + &config.supabase_url, + &config.supabase_anon_key, + &config.paths.session_file, + )); + + let upload_guard = Arc::new(tokio::sync::Mutex::new(())); + let http_client = reqwest::Client::new(); + let upload_client = http_client.clone(); + + let connector = FlickNoteConnector { + db: db.clone(), + auth: Arc::clone(&auth), + upload_guard: Arc::clone(&upload_guard), + http_client, + powersync_url: config.powersync_url.clone(), + supabase_url: config.supabase_url.clone(), + supabase_anon_key: config.supabase_anon_key.clone(), + }; + + // Reclaim leftover WAL from previous sessions BEFORE connecting sync actors. + // TRUNCATE is safe here because no pool connections exist yet — db.connect() + // hasn't started the download actor. A bloated WAL inherited from a crashed + // session is reset to zero so incremental PASSIVE checkpoints start from a + // clean baseline. + // spawn_blocking keeps blocking rusqlite I/O off the async executor thread. + log::info!("Running startup WAL checkpoint"); + let startup_db_path = config.paths.db_file.clone(); + if let Err(e) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&startup_db_path, "startup", WalCheckpointMode::Truncate) + }) + .await + { + log::error!("Startup WAL checkpoint task panicked: {e}"); + } + + // Finish schema replacement through the application pool before PowerSync + // starts its download/upload actors. Replacing tracking views after connect + // races the actor-held SQLite connections and can fail with SQLITE_BUSY on + // an existing database. + let user_id = flicknote_core::session::get_user_id(&config)?; + let backend: Arc = Arc::new(SqliteBackend { + db: Database::open_local(&config).await?, + user_id, + }); + + log::info!("Sync daemon connecting (pid {})", std::process::id()); + db.connect(SyncOptions::new(connector)).await; + log::info!("Sync daemon connected (pid {})", std::process::id()); + + // Application writes happen in this process. Each may-write request sends a + // best-effort trigger; the startup drain recovers committed writes whose signal + // was lost because of a crash or a full channel. + let (trigger_tx, mut trigger_rx) = mpsc::channel::<()>(16); + + let upload_db = db.clone(); + let upload_supabase_url = config.supabase_url.clone(); + let upload_anon_key = config.supabase_anon_key.clone(); + let upload_guard_clone = Arc::clone(&upload_guard); + let upload_auth_clone = Arc::clone(&auth); + let upload_db_path = config.paths.db_file.clone(); + + let mut upload_handle = tokio::spawn(async move { + // Initial upload on startup recovers committed CRUD left by a crash, + // a lost in-process signal, or a pre-upgrade CLI writer. + retry_upload_until_success( + &upload_db, + &upload_client, + &upload_auth_clone, + &upload_guard_clone, + &upload_supabase_url, + &upload_anon_key, + "Startup upload", + &upload_db_path, + ) + .await; + + loop { + // Block until the application host reports a may-write request. + if trigger_rx.recv().await.is_none() { + break; + } + + // Trailing debounce: collapse burst writes (e.g. bulk import) into a + // single upload attempt. Fire only after 200ms of silence. + loop { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => break, + v = trigger_rx.recv() => { + if v.is_none() { return; } // channel closed + // more events arrived — reset the silence window + } + } + } + + retry_upload_until_success( + &upload_db, + &upload_client, + &upload_auth_clone, + &upload_guard_clone, + &upload_supabase_url, + &upload_anon_key, + "Upload", + &upload_db_path, + ) + .await; + } + }); + + // Periodic PASSIVE checkpoint every 30s — independent of upload success or + // download actor state. Makes incremental progress draining the WAL without + // acquiring PENDING/EXCLUSIVE locks, so it never contends with pool writers. + let checkpoint_db_path = config.paths.db_file.clone(); + let mut checkpoint_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + interval.tick().await; // skip the immediate first tick + loop { + interval.tick().await; + let path = checkpoint_db_path.clone(); + if let Err(e) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&path, "periodic", WalCheckpointMode::Passive) + }) + .await + { + log::error!("Periodic WAL checkpoint task panicked: {e}"); + } + } + }); + + let socket_config = Arc::clone(&config); + let socket_http = reqwest::Client::new(); + let socket_share_lock = Arc::new(ShareRequestLock::default()); + let creator: Arc = Arc::new(RemoteNoteCreator { + db: db.clone(), + auth: Arc::clone(&auth), + http: socket_http.clone(), + config: Arc::clone(&config), + }); + let gateway: Arc = Arc::new(RemoteShareGateway { + http: socket_http.clone(), + auth: Arc::clone(&auth), + config: Arc::clone(&config), + lock: socket_share_lock, + }); + let app = Arc::new( + Application::new(backend, creator, gateway) + .with_web_url(config.web_url.clone()) + .with_write_signal(trigger_tx), + ); + let mut socket_handle = tokio::spawn(async move { + if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::current()).await { + log::error!("Application socket server failed: {error}"); + } + }); + + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + res = &mut upload_handle => { + if let Err(e) = res { + log::error!("Upload task panicked: {e}"); + shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; + return Err(e.into()); + } + } + res = &mut checkpoint_handle => { + match res { + Ok(_) => log::error!("Checkpoint task exited unexpectedly"), + Err(ref e) => log::error!("Checkpoint task panicked: {e}"), + } + let err_msg = format!("Checkpoint task exited: {res:?}"); + shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; + return Err(err_msg.into()); + } + res = &mut socket_handle => { + match res { + Ok(_) => log::error!("Socket task exited unexpectedly"), + Err(ref e) => log::error!("Socket task panicked: {e}"), + } + let err_msg = format!("Socket task exited: {res:?}"); + shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; + return Err(err_msg.into()); + } + } + shutdown_daemon( + &mut upload_handle, + &mut checkpoint_handle, + &mut socket_handle, + &db, + socket_config.paths.db_file.clone(), + ) + .await; + + Ok(()) +} diff --git a/flicknote-sync/src/storage_maintenance.rs b/flicknote-sync/src/storage_maintenance.rs new file mode 100644 index 0000000..efd136b --- /dev/null +++ b/flicknote-sync/src/storage_maintenance.rs @@ -0,0 +1,81 @@ +use crate::*; + +/// WAL checkpoint mode passed to [`checkpoint_wal_standalone`]. +#[derive(Clone, Copy)] +pub(crate) enum WalCheckpointMode { + /// Checkpoints frames up to the oldest active reader's mark. Never acquires + /// PENDING or EXCLUSIVE locks — returns immediately. Safe at any time alongside + /// active pool connections. Returns `busy=1` when readers constrain the + /// checkpoint to an earlier WAL position (normal during runtime). + Passive, + /// Acquires a PENDING lock while waiting for readers to finish, then resets + /// the WAL to zero length. Use only when no pool connections exist (startup, + /// shutdown) to avoid the PENDING lock blocking pool writers. + Truncate, +} + +impl fmt::Display for WalCheckpointMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Passive => write!(f, "PASSIVE"), + Self::Truncate => write!(f, "TRUNCATE"), + } + } +} + +/// Run a WAL checkpoint using a standalone rusqlite connection. +/// +/// Opens its own connection to the DB file, bypassing PowerSync's writer mutex +/// entirely — competes only at the SQLite file-lock level, not the Rust mutex level. +/// +/// `mode` controls the checkpoint type — see [`WalCheckpointMode`] for semantics. +/// +/// `busy_timeout` is set to 5 000 ms for TRUNCATE so it retries at the SQLite level +/// while pool readers finish their short transactions. It is irrelevant for PASSIVE +/// (which never waits) but harmless to keep set. +/// +/// Reads the `(busy, log, checkpointed)` return tuple from PRAGMA so failures +/// are never silently swallowed. For PASSIVE, `busy=1` when active readers +/// constrain the checkpoint to an earlier WAL position (normal and expected during +/// runtime). For TRUNCATE, `busy=1` means the reset could not complete. +/// +/// This function is **synchronous** (blocking rusqlite I/O). Async callers must +/// wrap it with `tokio::task::spawn_blocking`. +/// +/// `label` identifies the call site in log output (e.g. `"startup"`, `"post-upload"`, +/// `"periodic"`, `"shutdown"`) so production logs are unambiguous. +pub(crate) fn checkpoint_wal_standalone(db_path: &Path, label: &str, mode: WalCheckpointMode) { + let conn = match rusqlite::Connection::open(db_path) { + Ok(c) => c, + Err(e) => { + log::warn!("WAL checkpoint [{label}]: could not open db: {e}"); + return; + } + }; + if let Err(e) = conn.pragma_update(None, "busy_timeout", 5_000i64) { + log::warn!("WAL checkpoint [{label}]: could not set busy_timeout: {e}"); + return; + } + let pragma = format!("PRAGMA wal_checkpoint({})", mode); + match conn.query_row(&pragma, [], |row| { + Ok(( + row.get::<_, i32>(0)?, + row.get::<_, i32>(1)?, + row.get::<_, i32>(2)?, + )) + }) { + Ok((busy, log, checkpointed)) => { + if busy == 0 { + log::info!( + "WAL checkpoint [{label}] ({mode}): {log} pages, {checkpointed} checkpointed" + ); + } else { + log::warn!( + "WAL checkpoint [{label}]: incomplete (busy={busy}, {log} log pages, {checkpointed} checkpointed)" + ); + } + } + Err(e) => log::warn!("WAL checkpoint [{label}]: failed: {e}"), + } + // Connection dropped here — no persistent state +} diff --git a/flicknote-sync/src/test_support.rs b/flicknote-sync/src/test_support.rs new file mode 100644 index 0000000..744d01e --- /dev/null +++ b/flicknote-sync/src/test_support.rs @@ -0,0 +1,240 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +use flicknote_core::config::ConfigPaths; + +use crate::*; + +pub(crate) async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let db = test_powersync_db_at(directory.path().join("test.db"), app_schema()); + db.writer().await.unwrap(); + (directory, db) +} + +pub(crate) fn test_powersync_db_at( + path: impl AsRef, + schema: powersync::schema::Schema, +) -> PowerSyncDatabase { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::open(path).unwrap(); + let env = PowerSyncEnvironment::custom( + reqwest::Client::new(), + pool, + PowerSyncEnvironment::tokio_timer(), + ); + PowerSyncDatabase::new(env, schema) +} + +pub(crate) async fn insert_note_with_metadata(db: &PowerSyncDatabase, metadata: &str) { + let writer = db.writer().await.unwrap(); + writer + .execute( + r#"INSERT INTO notes ( + id, short_id, user_id, type, status, title, content, + is_flagged, created_at, updated_at, _metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + params![ + "note-1", + 42, + "user-1", + "normal", + "ai_queued", + "Title", + "Body", + 0, + "2026-08-09T00:00:00Z", + "2026-08-09T00:00:00Z", + metadata, + ], + ) + .unwrap(); +} + +pub(crate) async fn insert_marked_note(db: &PowerSyncDatabase) { + insert_note_with_metadata(db, REMOTE_COMMITTED_INSERT_METADATA).await; +} + +pub(crate) fn remote_note(id: &str, title: &str) -> RemoteNoteRow { + RemoteNoteRow { + id: id.to_string(), + short_id: Some(42), + user_id: "user-1".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some(title.to_string()), + content: Some("Canonical body".to_string()), + summary: Some("Canonical summary".to_string()), + is_flagged: false, + project_id: Some("project-1".to_string()), + metadata: Some(serde_json::json!({"source": "remote"})), + source: Some(serde_json::json!({"kind": "plain"})), + created_at: Some("2026-08-09T00:00:00Z".to_string()), + updated_at: Some("2026-08-09T00:00:01Z".to_string()), + deleted_at: None, + } +} + +pub(crate) fn test_config(api_url: String) -> Config { + Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url, + web_url: None, + paths: ConfigPaths { + config_dir: PathBuf::new(), + data_dir: PathBuf::new(), + config_file: PathBuf::new(), + session_file: PathBuf::new(), + db_file: PathBuf::new(), + log_file: PathBuf::new(), + }, + } +} + +pub(crate) fn spawn_server( + responses: Vec<(&'static str, &'static str)>, +) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 4096]; + let count = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..count]); + requests.push(request.lines().next().unwrap_or_default().to_string()); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } + requests + }); + (format!("http://{address}"), handle) +} + +pub(crate) fn read_complete_http_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 4096]; + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + let Some(headers_end) = request.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= headers_end + 4 + content_length { + break; + } + } + String::from_utf8(request).unwrap() +} + +pub(crate) fn spawn_capture_server( + expected_requests: usize, +) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().unwrap(); + requests.push(read_complete_http_request(&mut stream)); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } + requests + }); + (format!("http://{address}"), handle) +} + +pub(crate) fn spawn_disconnected_response_then_server( + status: &'static str, + body: &'static str, +) -> (String, thread::JoinHandle>) { + spawn_disconnected_then_retry_responses(vec![(status, body)]) +} + +pub(crate) fn spawn_disconnected_then_retry_responses( + responses: Vec<(&'static str, &'static str)>, +) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut first, _) = listener.accept().unwrap(); + listener.set_nonblocking(true).unwrap(); + let accept = || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match listener.accept() { + Ok(pair) => return Some(pair), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return None; + } + thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("accept failed: {error}"), + } + } + }; + + let mut requests = Vec::new(); + let mut buffer = [0_u8; 4096]; + let count = first.read(&mut buffer).unwrap(); + requests.push( + String::from_utf8_lossy(&buffer[..count]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + ); + drop(first); + + for (status, body) in responses { + let Some((mut stream, _)) = accept() else { + break; + }; + let count = stream.read(&mut buffer).unwrap(); + requests.push( + String::from_utf8_lossy(&buffer[..count]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + ); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } + requests + }); + (format!("http://{address}"), handle) +} diff --git a/flicknote-sync/src/upload.rs b/flicknote-sync/src/upload.rs new file mode 100644 index 0000000..94009ac --- /dev/null +++ b/flicknote-sync/src/upload.rs @@ -0,0 +1,383 @@ +use crate::*; + +#[cfg(test)] +mod tests; + +/// Helper to convert arbitrary errors into PowerSyncError. +pub(crate) fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { + std::io::Error::other(msg.to_string()).into() +} + +/// Postgres/PostgREST error codes that will never succeed on retry. +/// Mirrors the iOS PostgresFatalCodes pattern (PowerSyncService.swift). +pub(crate) const FATAL_PG_PREFIXES: &[&str] = &[ + "22", // Class 22 — Data Exception + "23", // Class 23 — Integrity Constraint Violation (FK, unique, not-null) +]; + +pub(crate) const FATAL_PG_CODES: &[&str] = &[ + "42501", // INSUFFICIENT PRIVILEGE (RLS violation) + "42703", // undefined column + "42P01", // undefined table + "PGRST203", // PostgREST: table not found + "PGRST204", // PostgREST: column not found +]; + +/// Check if a Supabase/PostgREST error body contains a non-transient PG error. +/// Returns `Some(code)` if the error is fatal (will never succeed on retry), +/// or `None` if the code is unrecognised, missing, or the body is not JSON. +/// `None` does not mean the error is confirmed transient — it means unknown. +pub(crate) fn extract_fatal_code(body: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(body).ok().or_else(|| { + log::debug!("extract_fatal_code: body is not JSON, treating as unknown: {body}"); + None + })?; + let code = parsed.get("code").and_then(|v| v.as_str()).or_else(|| { + log::debug!("extract_fatal_code: no `code` field in body, treating as unknown"); + None + })?; + + for prefix in FATAL_PG_PREFIXES { + if code.starts_with(prefix) { + return Some(code.to_string()); + } + } + if FATAL_PG_CODES.contains(&code) { + return Some(code.to_string()); + } + None +} + +/// Classify an HTTP response as success, fatal (discard), or transient (retry). +pub(crate) enum UploadOutcome { + Success, + Fatal(String), + Transient(String), +} + +pub(crate) async fn classify_response( + resp: reqwest::Response, + op: &str, + table: &str, + id: &str, +) -> UploadOutcome { + let status = resp.status(); + if status.is_success() { + return UploadOutcome::Success; + } + let body = resp + .text() + .await + .unwrap_or_else(|e| format!("")); + if let Some(code) = extract_fatal_code(&body) { + UploadOutcome::Fatal(format!( + "HTTP {status} PG {code}: {op} {table}/{id} — {body}" + )) + } else { + UploadOutcome::Transient(format!("HTTP {status}: {op} {table}/{id} failed: {body}")) + } +} + +pub(crate) struct FlickNoteConnector { + pub(crate) db: PowerSyncDatabase, + pub(crate) auth: Arc, + pub(crate) upload_guard: Arc>, + pub(crate) http_client: reqwest::Client, + pub(crate) powersync_url: String, + pub(crate) supabase_url: String, + pub(crate) supabase_anon_key: String, +} + +/// Un-wrap JSON strings that contain objects/arrays (fixes double-marshal for jsonb columns). +/// PowerSync stores jsonb as text, so crud.data has them as Value::String. +/// Supabase expects Value::Object for jsonb columns. +pub(crate) fn unwrap_json_strings(data: &mut serde_json::Map) { + for (key, value) in data.iter_mut() { + if let serde_json::Value::String(s) = value { + match serde_json::from_str::(s) { + Ok(parsed) if parsed.is_object() || parsed.is_array() => { + *value = parsed; + } + Err(e) if s.starts_with('{') || s.starts_with('[') => { + log::debug!( + "unwrap_json_strings: field `{key}` looks like JSON but failed to parse: {e}" + ); + } + _ => {} + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FlickNoteCrudMarker { + RemoteCommittedInsert, +} + +pub(crate) fn parse_flicknote_crud_marker( + metadata: Option<&str>, +) -> Result, PowerSyncError> { + let Some(metadata) = metadata else { + return Ok(None); + }; + let value: serde_json::Value = serde_json::from_str(metadata) + .map_err(|error| ps_err(format!("invalid CRUD metadata: {error}")))?; + let Some(object) = value.as_object() else { + return Ok(None); + }; + let Some(marker) = object.get("flicknote") else { + return Ok(None); + }; + if object.len() != 1 { + return Err(ps_err( + "invalid FlickNote CRUD metadata: expected exactly one marker field", + )); + } + match marker.as_str() { + Some("remote_committed_insert_v1") => Ok(Some(FlickNoteCrudMarker::RemoteCommittedInsert)), + _ => Err(ps_err(format!( + "unsupported FlickNote CRUD marker: {marker}" + ))), + } +} + +/// Inner upload logic shared by the BackendConnector and application-triggered drain. +/// Caller is responsible for holding `upload_guard` before calling. +/// +/// Returns `true` if at least one CRUD transaction was processed and committed, +/// `false` if ps_crud was empty. Callers may use this to decide whether to +/// run a WAL checkpoint after upload. +/// +/// The token is fetched once per call by the caller. Supabase tokens are typically +/// valid for 1 hour, so any realistic upload batch completes well within the window. +pub(crate) async fn run_upload( + db: &PowerSyncDatabase, + client: &reqwest::Client, + token: &str, + supabase_url: &str, + supabase_anon_key: &str, +) -> Result { + let mut transactions = db.crud_transactions(); + let mut did_upload = false; + + while let Some(mut tx) = transactions.try_next().await? { + let mut fatal_msg: Option = None; + let mut transient_msg: Option = None; + + for mut crud in std::mem::take(&mut tx.crud) { + // The backend retired the keyterm domain. Old offline databases may still + // have queued writes for the removed table or the removed project column. + // Consume those retired fields locally so they cannot block the FIFO or + // cause an otherwise valid project mutation to be discarded by PostgREST. + if crud.table == "keyterms" { + log::info!( + "Discarding queued CRUD for retired keyterms row {}", + crud.id + ); + continue; + } + if crud.table == "projects" + && let Some(data) = crud.data.as_mut() + { + data.remove("keyterm_id"); + } + if parse_flicknote_crud_marker(crud.metadata.as_deref())? + == Some(FlickNoteCrudMarker::RemoteCommittedInsert) + { + let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); + let is_put = matches!(&crud.update_type, UpdateType::Put); + if !allowed_table || !is_put { + let operation = match &crud.update_type { + UpdateType::Put => "PUT", + UpdateType::Patch => "PATCH", + UpdateType::Delete => "DELETE", + }; + return Err(ps_err(format!( + "invalid remote-committed marker on {operation} operation for table {}", + crud.table, + ))); + } + continue; + } + let table = &crud.table; + let id = &crud.id; + + // Single match on crud.update_type — UpdateType is not Copy, + // so we derive both op and resp in one match to avoid use-after-move. + let (op, resp) = match crud.update_type { + UpdateType::Put => { + let mut data = crud.data.unwrap_or_default(); + data.insert("id".into(), serde_json::Value::String(id.clone())); + unwrap_json_strings(&mut data); + let r = client + .post(format!("{supabase_url}/rest/v1/{table}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .header("Prefer", "resolution=merge-duplicates") + .json(&data) + .send() + .await + .map_err(|e| ps_err(format!("Upload PUT failed: {e}")))?; + ("PUT", r) + } + UpdateType::Patch => { + let mut data = crud.data.unwrap_or_default(); + unwrap_json_strings(&mut data); + let r = client + .patch(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .json(&data) + .send() + .await + .map_err(|e| ps_err(format!("Upload PATCH failed: {e}")))?; + ("PATCH", r) + } + UpdateType::Delete => { + // No payload — unwrap_json_strings not needed. + let r = client + .delete(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .map_err(|e| ps_err(format!("Upload DELETE failed: {e}")))?; + ("DELETE", r) + } + }; + + match classify_response(resp, op, table, id).await { + UploadOutcome::Success => {} + UploadOutcome::Fatal(msg) => { + fatal_msg = Some(msg); + break; // stop processing this transaction's entries + } + UploadOutcome::Transient(msg) => { + transient_msg = Some(msg); + break; // stop processing, will retry + } + } + } + + // Handle outcome AFTER the for loop (tx is not moved inside the loop) + if let Some(msg) = fatal_msg { + log::error!("Non-transient error, discarding transaction: {msg}"); + tx.complete().await.map_err(|e| { + ps_err(format!( + "Failed to discard fatal transaction (original: {msg}): {e}" + )) + })?; // discard entire transaction atomically + did_upload = true; + continue; // next transaction + } + if let Some(msg) = transient_msg { + return Err(ps_err(msg)); // retry on next cycle + } + + // All entries succeeded — complete each transaction individually so + // successfully-uploaded entries are removed from ps_crud before processing + // the next batch. Without this, a mid-batch failure would re-upload all + // prior entries on the next cycle, causing phantom DELETEs (404) and + // duplicate PUTs. + tx.complete().await?; + did_upload = true; + } + + Ok(did_upload) +} + +/// Acquire the upload guard, get a fresh token, run_upload, and checkpoint. +/// Shared by the startup drain and application-triggered drain. +/// `context` is used as a log prefix (e.g. "Startup upload", "Upload"). +/// +/// A PASSIVE checkpoint is run after a successful upload to reclaim WAL space +/// freed by crud deletions. PASSIVE never acquires PENDING/EXCLUSIVE locks so it +/// is safe to call alongside active pool connections and the download actor. +/// +/// The checkpoint call uses `spawn_blocking` since `checkpoint_wal_standalone` +/// does blocking I/O (rusqlite open). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn try_upload_and_checkpoint( + db: &PowerSyncDatabase, + client: &reqwest::Client, + auth: &GoTrueClient, + guard: &tokio::sync::Mutex<()>, + supabase_url: &str, + supabase_anon_key: &str, + context: &str, + db_path: &Path, +) -> bool { + let _guard = guard.lock().await; + + let token = match auth.get_session().await { + Ok(s) => s.access_token, + Err(e) => { + log::warn!("{context}: auth error: {e}"); + return false; + } + }; + match run_upload(db, client, &token, supabase_url, supabase_anon_key).await { + Ok(_) => { + // Post-upload PASSIVE checkpoint: reclaim crud deletion frames without + // acquiring any locks that could contend with active pool connections. + let post_path = db_path.to_path_buf(); + if let Err(e) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&post_path, "post-upload", WalCheckpointMode::Passive) + }) + .await + { + log::error!("Post-upload WAL checkpoint task panicked: {e}"); + } + true + } + Err(e) => { + log::warn!("{context}: upload failed: {e}"); + false + } + } +} + +pub(crate) async fn retry_with_backoff( + mut attempt: F, + initial_delay: std::time::Duration, + maximum_delay: std::time::Duration, +) where + F: FnMut() -> Fut, + Fut: Future, +{ + let mut delay = initial_delay; + while !attempt().await { + tokio::time::sleep(delay).await; + delay = delay.saturating_mul(2).min(maximum_delay); + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn retry_upload_until_success( + db: &PowerSyncDatabase, + client: &reqwest::Client, + auth: &GoTrueClient, + guard: &tokio::sync::Mutex<()>, + supabase_url: &str, + supabase_anon_key: &str, + context: &str, + db_path: &Path, +) { + retry_with_backoff( + || { + try_upload_and_checkpoint( + db, + client, + auth, + guard, + supabase_url, + supabase_anon_key, + context, + db_path, + ) + }, + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(30), + ) + .await; +} diff --git a/flicknote-sync/src/upload/tests.rs b/flicknote-sync/src/upload/tests.rs new file mode 100644 index 0000000..adb6b7d --- /dev/null +++ b/flicknote-sync/src/upload/tests.rs @@ -0,0 +1,420 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; +use crate::test_support::*; + +#[tokio::test] +async fn failed_upload_is_retried_without_a_second_write_trigger() { + let attempts = Arc::new(AtomicUsize::new(0)); + let attempt_counter = Arc::clone(&attempts); + + retry_with_backoff( + move || { + let attempt_counter = Arc::clone(&attempt_counter); + async move { attempt_counter.fetch_add(1, Ordering::SeqCst) > 0 } + }, + std::time::Duration::from_millis(1), + std::time::Duration::from_millis(2), + ) + .await; + + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn remote_committed_insert_records_marker_in_crud() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!(transaction.crud.len(), 1); + assert_eq!(transaction.crud[0].table, "notes"); + assert!(matches!( + transaction.crud.first().map(|entry| &entry.update_type), + Some(UpdateType::Put) + )); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(r#"{"flicknote":"remote_committed_insert_v1"}"#) + ); +} + +#[tokio::test] +async fn existing_database_upgrades_to_metadata_tracking_without_losing_rows() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("upgrade.db"); + let mut legacy_schema = app_schema(); + for table in &mut legacy_schema.tables { + if matches!(table.name.as_ref(), "notes" | "note_extractions") { + table.options.track_metadata = false; + } + } + { + let legacy_db = test_powersync_db_at(&path, legacy_schema); + let writer = legacy_db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO notes (id, user_id, type, status, title) VALUES (?, ?, ?, ?, ?)", + params!["existing-note", "user-1", "normal", "ready", "Preserved"], + ) + .unwrap(); + writer.execute("DELETE FROM ps_crud", []).unwrap(); + } + + let upgraded_db = test_powersync_db_at(&path, app_schema()); + { + let writer = upgraded_db.writer().await.unwrap(); + let title: String = writer + .query_row( + "SELECT title FROM notes WHERE id = ?", + params!["existing-note"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title, "Preserved"); + writer + .execute( + "INSERT INTO notes (id, user_id, type, status, title, _metadata) VALUES (?, ?, ?, ?, ?, ?)", + params![ + "marked-after-upgrade", + "user-1", + "normal", + "ready", + "Marked", + REMOTE_COMMITTED_INSERT_METADATA, + ], + ) + .unwrap(); + } + + let transaction = upgraded_db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!(transaction.crud.len(), 1); + assert_eq!(transaction.crud[0].id, "marked-after-upgrade"); + assert_eq!( + transaction.crud[0].metadata.as_deref(), + Some(REMOTE_COMMITTED_INSERT_METADATA) + ); +} + +#[tokio::test] +async fn existing_database_retires_keyterm_schema_without_losing_projects() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("keyterm-retirement.db"); + let mut legacy_schema = app_schema(); + let projects = legacy_schema + .tables + .iter_mut() + .find(|table| table.name.as_ref() == "projects") + .unwrap(); + if !projects + .columns + .iter() + .any(|column| column.name.as_ref() == "keyterm_id") + { + projects + .columns + .push(powersync::schema::Column::text("keyterm_id")); + } + if !legacy_schema + .tables + .iter() + .any(|table| table.name.as_ref() == "keyterms") + { + legacy_schema.tables.push(powersync::schema::Table::create( + "keyterms", + vec![ + powersync::schema::Column::text("user_id"), + powersync::schema::Column::text("name"), + powersync::schema::Column::text("description"), + powersync::schema::Column::text("content"), + powersync::schema::Column::text("created_at"), + powersync::schema::Column::text("updated_at"), + ], + |_| {}, + )); + } + + { + let legacy_db = test_powersync_db_at(&path, legacy_schema); + let writer = legacy_db.writer().await.unwrap(); + writer + .execute( + "INSERT INTO keyterms (id, user_id, name) VALUES (?, ?, ?)", + params!["retired-keyterm", "user-1", "Retired"], + ) + .unwrap(); + writer + .execute( + "INSERT INTO projects (id, user_id, name, keyterm_id) VALUES (?, ?, ?, ?)", + params![ + "preserved-project", + "user-1", + "Preserved", + "retired-keyterm" + ], + ) + .unwrap(); + } + + let upgraded_db = test_powersync_db_at(&path, app_schema()); + { + let writer = upgraded_db.writer().await.unwrap(); + let project_name: String = writer + .query_row( + "SELECT name FROM projects WHERE id = ?", + params!["preserved-project"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(project_name, "Preserved"); + let retired_view_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'keyterms'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_view_count, 0); + let retired_column_count: i64 = writer + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name = 'keyterm_id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retired_column_count, 0); + } + + let (server_url, server) = spawn_capture_server(1); + assert!( + run_upload( + &upgraded_db, + &reqwest::Client::new(), + "token", + &server_url, + "anon-key", + ) + .await + .unwrap() + ); + assert!(upgraded_db.next_crud_transaction().await.unwrap().is_none()); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /rest/v1/projects ")); + let (_, body) = requests[0].split_once("\r\n\r\n").unwrap(); + let payload: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(payload["name"], "Preserved"); + assert!(payload.get("keyterm_id").is_none()); +} + +#[tokio::test] +async fn remote_committed_put_completes_without_http_request() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + + let uploaded = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap(); + + assert!(uploaded); + assert!(db.next_crud_transaction().await.unwrap().is_none()); +} + +#[tokio::test] +async fn remote_committed_marker_is_matched_as_json_not_raw_text() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{ "flicknote" : "remote_committed_insert_v1" }"#).await; + + run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap(); + + assert!(db.next_crud_transaction().await.unwrap().is_none()); +} + +#[tokio::test] +async fn remote_committed_marker_rejects_extra_metadata_fields() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata( + &db, + r#"{"flicknote":"remote_committed_insert_v1","other":true}"#, + ) + .await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("invalid FlickNote CRUD metadata") + ); + assert!(db.crud_transactions().try_next().await.unwrap().is_some()); +} + +#[tokio::test] +async fn unsupported_flicknote_marker_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v2"}"#).await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported FlickNote CRUD marker") + ); + assert!(db.next_crud_transaction().await.unwrap().is_some()); +} + +#[tokio::test] +async fn malformed_crud_metadata_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_note_with_metadata(&db, r#"{"flicknote":"remote_committed_insert_v1""#).await; + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("invalid CRUD metadata")); + assert!(db.next_crud_transaction().await.unwrap().is_some()); +} + +#[tokio::test] +async fn remote_committed_marker_on_patch_is_rejected_and_retained() { + let (_directory, db) = test_powersync_db().await; + insert_marked_note(&db).await; + db.next_crud_transaction() + .await + .unwrap() + .unwrap() + .complete() + .await + .unwrap(); + { + let writer = db.writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET title = ?, _metadata = ? WHERE id = ?", + params!["Changed", REMOTE_COMMITTED_INSERT_METADATA, "note-1"], + ) + .unwrap(); + } + + let error = run_upload( + &db, + &reqwest::Client::new(), + "token", + "http://127.0.0.1:1", + "anon-key", + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("invalid remote-committed marker") + ); + assert!(db.next_crud_transaction().await.unwrap().is_some()); +} + +#[test] +fn test_extract_fatal_code_fk_violation() { + let body = r#"{"code":"23503","details":"Key is not present in table \"projects\".","hint":null,"message":"insert or update on table \"notes\" violates foreign key constraint"}"#; + assert_eq!(extract_fatal_code(body), Some("23503".to_string())); +} + +#[test] +fn test_extract_fatal_code_rls_violation() { + let body = r#"{"code":"42501","message":"new row violates row-level security policy"}"#; + assert_eq!(extract_fatal_code(body), Some("42501".to_string())); +} + +#[test] +fn test_extract_fatal_code_transient() { + let body = r#"{"code":"08006","message":"connection failure"}"#; + assert_eq!(extract_fatal_code(body), None); +} + +#[test] +fn test_extract_fatal_code_not_json() { + assert_eq!(extract_fatal_code("Internal Server Error"), None); +} + +#[test] +fn test_extract_fatal_code_postgrest() { + let body = r#"{"code":"PGRST204","message":"column not found"}"#; + assert_eq!(extract_fatal_code(body), Some("PGRST204".to_string())); +} + +#[test] +fn test_extract_fatal_code_class22_data_exception() { + let body = r#"{"code":"22001","message":"value too long for type character varying(255)"}"#; + assert_eq!(extract_fatal_code(body), Some("22001".to_string())); +} + +#[test] +fn test_extract_fatal_code_missing_code_field() { + // Supabase auth-layer errors omit "code" — should be treated as unknown (transient) + let body = r#"{"error":"invalid_grant","error_description":"Refresh Token Not Found"}"#; + assert_eq!(extract_fatal_code(body), None); +} + +#[test] +fn test_unwrap_json_strings() { + let mut data = serde_json::Map::new(); + data.insert("title".into(), serde_json::Value::String("Hello".into())); + data.insert( + "metadata".into(), + serde_json::Value::String(r#"{"file":{"name":"photo.jpg"}}"#.into()), + ); + data.insert( + "tags".into(), + serde_json::Value::String(r#"["rust","cli"]"#.into()), + ); + // Primitive JSON values ("42", "true") must stay as strings — guard is is_object()||is_array(). + data.insert("count".into(), serde_json::Value::String("42".into())); + data.insert("flag".into(), serde_json::Value::String("true".into())); + data.insert("source".into(), serde_json::Value::Null); + unwrap_json_strings(&mut data); + assert_eq!(data["title"], serde_json::Value::String("Hello".into())); // plain string unchanged + assert!(data["metadata"].is_object()); // JSON object string → Value::Object + assert!(data["tags"].is_array()); // JSON array string → Value::Array + assert_eq!(data["count"], serde_json::Value::String("42".into())); // primitive JSON unchanged + assert_eq!(data["flag"], serde_json::Value::String("true".into())); // primitive JSON unchanged + assert!(data["source"].is_null()); // null unchanged +} From 8583e30cc62d3f968af02da6d620db1658253468 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 01:54:49 +0800 Subject: [PATCH 15/16] refactor(sync): simplify daemon control flow --- flicknote-cli/Cargo.toml | 2 +- flicknote-cli/src/commands/gateway.rs | 4 +- flicknote-cli/src/main.rs | 4 +- flicknote-cli/src/main_tests.rs | 513 +------------------- flicknote-cli/src/main_tests/mcp.rs | 488 +++++++++++++++++++ flicknote-cli/src/mcp/server.rs | 25 +- flicknote-core/src/schema.rs | 214 ++++----- flicknote-core/src/services/ports.rs | 14 +- flicknote-sync/Cargo.toml | 2 +- flicknote-sync/src/app/mod.rs | 366 +------------- flicknote-sync/src/app/note.rs | 332 +++++++++++++ flicknote-sync/src/app/project.rs | 99 ++++ flicknote-sync/src/connector.rs | 5 +- flicknote-sync/src/ipc/protocol.rs | 89 ++-- flicknote-sync/src/ipc/server.rs | 2 +- flicknote-sync/src/ipc/tests.rs | 12 +- flicknote-sync/src/lib.rs | 31 -- flicknote-sync/src/remote/attachment.rs | 29 +- flicknote-sync/src/remote/create.rs | 558 ++++++++++++---------- flicknote-sync/src/remote/create/tests.rs | 54 ++- flicknote-sync/src/remote/mod.rs | 9 +- flicknote-sync/src/remote/share.rs | 71 ++- flicknote-sync/src/runtime.rs | 402 ++++++++-------- flicknote-sync/src/storage_maintenance.rs | 3 +- flicknote-sync/src/test_support.rs | 31 +- flicknote-sync/src/upload.rs | 198 ++++---- flicknote-sync/src/upload/tests.rs | 61 ++- 27 files changed, 1886 insertions(+), 1732 deletions(-) create mode 100644 flicknote-cli/src/main_tests/mcp.rs create mode 100644 flicknote-sync/src/app/note.rs create mode 100644 flicknote-sync/src/app/project.rs diff --git a/flicknote-cli/Cargo.toml b/flicknote-cli/Cargo.toml index dafef7b..b05b20c 100644 --- a/flicknote-cli/Cargo.toml +++ b/flicknote-cli/Cargo.toml @@ -36,7 +36,7 @@ open = "5" tempfile = "3" env_logger = "0.11" log = "0.4" -rmcp = { version = "3.1.0", default-features = false, features = ["server", "transport-io", "local", "macros"] } +rmcp = { version = "3.1.0", default-features = false, features = ["server", "transport-io", "macros"] } schemars = "1" httpdate = "1.0.3" diff --git a/flicknote-cli/src/commands/gateway.rs b/flicknote-cli/src/commands/gateway.rs index 07e261f..39abe7b 100644 --- a/flicknote-cli/src/commands/gateway.rs +++ b/flicknote-cli/src/commands/gateway.rs @@ -71,13 +71,13 @@ async fn request(config: &Config, args: &GatewayRequestArgs) -> Result<(), CliEr }; eprintln!("Gateway response: {}", response.status()); - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); while let Some(chunk) = response .chunk() .await .map_err(|_| CliError::Http("Gateway response interrupted".into()))? { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); stdout.write_all(&chunk)?; stdout.flush()?; } diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index 8a9bed3..4732b14 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -118,9 +118,7 @@ async fn run() -> Result<(), CliError> { let daemon = DaemonClient::new(&config); daemon.health().await?; if matches!(cli.command, Some(Commands::Mcp)) { - return tokio::task::LocalSet::new() - .run_until(mcp::serve(std::rc::Rc::new(config))) - .await; + return mcp::serve(std::sync::Arc::new(config)).await; } dispatch(&cli, &daemon).await } diff --git a/flicknote-cli/src/main_tests.rs b/flicknote-cli/src/main_tests.rs index 5567b6c..ed4c9ea 100644 --- a/flicknote-cli/src/main_tests.rs +++ b/flicknote-cli/src/main_tests.rs @@ -1,515 +1,8 @@ -use async_trait::async_trait; -use flicknote_core::services::error::ServiceError; -use flicknote_core::services::ports::{ - CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, -}; +use clap::Parser; -use super::*; +use super::Cli; -struct PersistingCreator { - db: std::sync::Arc, -} - -#[async_trait] -impl NoteCreator for PersistingCreator { - async fn create(&self, request: CreateNote) -> Result { - let inserted = self.db.insert_note(&request.as_insert_request()).await?; - Ok(CreatedNote { - inserted, - confirmed_extraction_ids: Vec::new(), - }) - } -} - -struct UnusedShareGateway; - -#[async_trait] -impl ShareGateway for UnusedShareGateway { - async fn share(&self, _resource: ShareResource, _id: &str) -> Result { - Err(ServiceError::Daemon("unexpected share".to_string())) - } - - async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { - Err(ServiceError::Daemon("unexpected unshare".to_string())) - } -} - -async fn call_mcp_tool( - writer: &mut tokio::io::WriteHalf, - reader: &mut tokio::io::BufReader>, - id: u64, - name: &str, - arguments: serde_json::Value, -) -> serde_json::Value { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - - let request = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": name, "arguments": arguments } - }); - writer - .write_all(format!("{request}\n").as_bytes()) - .await - .unwrap(); - let mut response = String::new(); - reader.read_line(&mut response).await.unwrap(); - serde_json::from_str(&response).unwrap() -} - -fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str) { - match value { - serde_json::Value::String(actual) => assert_ne!(actual, excluded), - serde_json::Value::Array(values) => { - for value in values { - assert_json_does_not_contain_string(value, excluded); - } - } - serde_json::Value::Object(values) => { - for value in values.values() { - assert_json_does_not_contain_string(value, excluded); - } - } - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} - } -} - -#[tokio::test(flavor = "current_thread")] -async fn mcp_server_lists_contract_and_calls_note_list() { - use flicknote_core::backend::{NoteDb, SqliteBackend}; - use flicknote_core::db::Database; - use flicknote_sync::app::Application; - use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; - use rmcp::ServiceExt; - use std::rc::Rc; - use std::sync::Arc; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - tokio::task::LocalSet::new() - .run_until(async { - let directory = tempfile::tempdir().unwrap(); - let config = Config { - supabase_url: "https://auth.example.test".to_string(), - supabase_anon_key: "anon-key".to_string(), - powersync_url: String::new(), - api_url: "https://gateway.example.test/api/v1".to_string(), - web_url: Some("https://app.example".to_string()), - paths: flicknote_core::config::ConfigPaths { - config_dir: directory.path().to_path_buf(), - data_dir: directory.path().to_path_buf(), - config_file: directory.path().join("config.json"), - session_file: directory.path().join("session.json"), - db_file: directory.path().join("test.db"), - log_file: directory.path().join("test.log"), - }, - }; - let database = Database::open_local(&config).await.unwrap(); - let backend = Arc::new(SqliteBackend { - db: database, - user_id: "test-user".to_string(), - }); - let project_id = backend.create_project("MCP Project").await.unwrap(); - let note_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&flicknote_core::backend::InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "synced", - title: Some("MCP Note"), - content: Some("## Alpha\n\nOld text.\n\n## Beta\n\nKeep me."), - metadata: None, - project_id: Some(&project_id), - now: "2026-08-05T00:00:00Z", - }) - .await - .unwrap(); - sqlx::query("UPDATE notes SET short_id = 42 WHERE id = ?") - .bind(¬e_id) - .execute(&backend.db.pool) - .await - .unwrap(); - sqlx::query("UPDATE notes SET source = ? WHERE id = ?") - .bind(r#"{"link":{"content":"one\ntwo\nthree"}}"#) - .bind(¬e_id) - .execute(&backend.db.pool) - .await - .unwrap(); - let no_source_note_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&flicknote_core::backend::InsertNoteReq { - id: &no_source_note_id, - note_type: "normal", - status: "synced", - title: Some("No source note"), - content: Some("Editable content"), - metadata: None, - project_id: None, - now: "2026-08-05T00:00:00Z", - }) - .await - .unwrap(); - sqlx::query("UPDATE notes SET short_id = 43 WHERE id = ?") - .bind(&no_source_note_id) - .execute(&backend.db.pool) - .await - .unwrap(); - let alpha_id = flicknote_core::services::markdown::parse_markdown( - "## Alpha\n\nOld text.\n\n## Beta\n\nKeep me.", - ) - .headings[0] - .id - .clone(); - let creator: Arc = Arc::new(PersistingCreator { - db: backend.clone(), - }); - let app = Arc::new( - Application::new( - backend, - creator, - Arc::new(UnusedShareGateway), - ) - .with_web_url(config.web_url.clone()), - ); - let daemon_listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); - let daemon_server = tokio::spawn(serve_app( - daemon_listener, - app, - ServerInfo::current(), - )); - let server = mcp::FlickNoteMcp::new(Rc::new(config)); - let (server_io, client_io) = tokio::io::duplex(8 * 1024); - let server = tokio::task::spawn_local(async move { - server.serve(server_io).await.unwrap().waiting().await - }); - let (client_read, mut client_write) = tokio::io::split(client_io); - let mut client_read = BufReader::new(client_read); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"flicknote-test","version":"0"}}}"#, "\n").as_bytes()) - .await - .unwrap(); - let mut response = String::new(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 1); - assert_eq!(parsed["result"]["serverInfo"]["name"], "flicknote"); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, "\n", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#, "\n").as_bytes()) - .await - .unwrap(); - response.clear(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 2); - let tools = parsed["result"]["tools"].as_array().unwrap(); - let names = tools - .iter() - .map(|tool| tool["name"].as_str().unwrap()) - .collect::>(); - assert_eq!(names, mcp::EXPECTED_TOOLS.into_iter().collect()); - assert!(!names.contains("gateway_web_search")); - assert!(!names.contains("gateway_web_fetch")); - assert!(tools.iter().all(|tool| tool.get("outputSchema").is_some())); - let list_schema = tools - .iter() - .find(|tool| tool["name"] == "note_list") - .unwrap(); - assert_eq!( - list_schema["inputSchema"]["$defs"]["NoteType"]["enum"], - serde_json::json!(["normal", "meeting", "link"]) - ); - let count_schema = tools - .iter() - .find(|tool| tool["name"] == "note_count") - .unwrap(); - assert_eq!( - count_schema["inputSchema"]["$defs"]["NoteType"]["enum"], - serde_json::json!(["normal", "meeting", "link", "file"]) - ); - for tool in tools.iter().filter(|tool| { - tool["name"] - .as_str() - .is_some_and(|name| name.starts_with("note_")) - }) { - let schema = &tool["inputSchema"]; - if schema["properties"].get("id").is_some() { - assert_eq!( - schema["properties"]["id"]["type"], - "integer", - "{} must accept only numeric short IDs", - tool["name"] - ); - } - assert!( - !tool["outputSchema"].to_string().contains("uuid"), - "{} output schema must not expose UUID fields", - tool["name"] - ); - } - let project_get_schema = tools - .iter() - .find(|tool| tool["name"] == "project_get") - .unwrap(); - assert!( - project_get_schema["inputSchema"]["properties"] - .get("project") - .is_some() - ); - assert!( - project_get_schema["inputSchema"]["properties"] - .get("id") - .is_none() - ); - - client_write - .write_all(concat!(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"note_list","arguments":{}}}"#, "\n").as_bytes()) - .await - .unwrap(); - response.clear(); - client_read.read_line(&mut response).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed["id"], 3); - assert_eq!(parsed["result"]["isError"], false); - assert_eq!(parsed["result"]["structuredContent"].as_array().unwrap().len(), 2); - assert_json_does_not_contain_string( - &parsed["result"]["structuredContent"], - ¬e_id, - ); - - let modified = call_mcp_tool( - &mut client_write, - &mut client_read, - 4, - "note_modify", - serde_json::json!({ - "id": 42, - "before": "Old text.", - "after": "New text.", - "flagged": true - }), - ) - .await; - assert_eq!(modified["result"]["isError"], false); - assert_eq!(modified["result"]["structuredContent"]["note"]["flagged"], true); - assert_json_does_not_contain_string( - &modified["result"]["structuredContent"], - ¬e_id, - ); - - let replaced = call_mcp_tool( - &mut client_write, - &mut client_read, - 5, - "note_replace_section", - serde_json::json!({ - "id": 42, - "section": alpha_id, - "content": "## Alpha revised\n\nReplacement text." - }), - ) - .await; - assert_eq!(replaced["result"]["isError"], false); - - let fetched = call_mcp_tool( - &mut client_write, - &mut client_read, - 6, - "note_get", - serde_json::json!({ "id": 42 }), - ) - .await; - let content = fetched["result"]["structuredContent"]["content"] - .as_str() - .unwrap(); - assert!(content.contains("Replacement text.")); - assert!(content.contains("Keep me.")); - assert!(fetched["result"]["structuredContent"].get("uuid").is_none()); - assert!( - fetched["result"]["structuredContent"] - .get("project_id") - .is_none() - ); - assert_json_does_not_contain_string( - &fetched["result"]["structuredContent"], - ¬e_id, - ); - - let string_id_fetched = call_mcp_tool( - &mut client_write, - &mut client_read, - 17, - "note_get", - serde_json::json!({ "id": "42" }), - ) - .await; - assert_eq!(string_id_fetched["result"]["isError"], false); - assert_eq!(string_id_fetched["result"]["structuredContent"]["id"], 42); - - let uuid_rejected = call_mcp_tool( - &mut client_write, - &mut client_read, - 15, - "note_get", - serde_json::json!({ "id": note_id }), - ) - .await; - assert_eq!(uuid_rejected["result"]["isError"], true); - assert!( - uuid_rejected["result"]["content"][0]["text"] - .as_str() - .unwrap() - .contains("invalid note ID") - ); - - let found = call_mcp_tool( - &mut client_write, - &mut client_read, - 13, - "note_find", - serde_json::json!({ "keywords": ["Replacement"] }), - ) - .await; - assert_eq!( - found["result"]["structuredContent"].as_array().unwrap().len(), - 1 - ); - - let projects = call_mcp_tool( - &mut client_write, - &mut client_read, - 14, - "project_list", - serde_json::json!({}), - ) - .await; - assert_eq!( - projects["result"]["structuredContent"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert!( - projects["result"]["structuredContent"][0] - .get("id") - .is_none() - ); - let project = call_mcp_tool( - &mut client_write, - &mut client_read, - 7, - "project_modify", - serde_json::json!({ - "project": "MCP Project", - "color": "#abcdef" - }), - ) - .await; - assert_eq!( - project["result"]["structuredContent"]["color"], - "#abcdef" - ); - - let source_info = call_mcp_tool( - &mut client_write, - &mut client_read, - 8, - "note_source", - serde_json::json!({ "id": 42, "view": "info" }), - ) - .await; - assert_eq!( - source_info["result"]["structuredContent"], - serde_json::json!({ - "view": "info", - "source_type": "link", - "range_unit": "line", - "count": 3 - }) - ); - - let source_range = call_mcp_tool( - &mut client_write, - &mut client_read, - 9, - "note_source", - serde_json::json!({ - "id": 42, - "view": "rendered", - "range": "2:3" - }), - ) - .await; - assert_eq!( - source_range["result"]["structuredContent"]["content"], - "two\nthree\n" - ); - assert_eq!( - source_range["result"]["structuredContent"]["selected_start"], - 2 - ); - - let no_source = call_mcp_tool( - &mut client_write, - &mut client_read, - 16, - "note_source", - serde_json::json!({ "id": 43, "view": "info" }), - ) - .await; - assert_eq!(no_source["result"]["isError"], true); - assert_eq!( - no_source["result"]["structuredContent"]["code"], - "no_source" - ); - assert_eq!( - no_source["result"]["content"][0]["text"], - "Note has no source data" - ); - - let added = call_mcp_tool( - &mut client_write, - &mut client_read, - 10, - "note_add", - serde_json::json!({ "content": "daemon-backed note" }), - ) - .await; - assert_eq!(added["result"]["isError"], false); - assert_eq!(added["result"]["structuredContent"]["title"], serde_json::Value::Null); - - let archived = call_mcp_tool( - &mut client_write, - &mut client_read, - 11, - "note_archive", - serde_json::json!({ "id": 42 }), - ) - .await; - assert_eq!(archived["result"]["structuredContent"]["archived"], true); - assert_json_does_not_contain_string( - &archived["result"]["structuredContent"], - ¬e_id, - ); - let restored = call_mcp_tool( - &mut client_write, - &mut client_read, - 12, - "note_restore", - serde_json::json!({ "id": 42 }), - ) - .await; - assert_eq!(restored["result"]["structuredContent"]["archived"], false); - - drop(client_write); - drop(client_read); - server.await.unwrap().unwrap(); - daemon_server.abort(); - }) - .await; -} +mod mcp; #[test] fn detail_rejects_section_flag() { diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs new file mode 100644 index 0000000..89c5b09 --- /dev/null +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -0,0 +1,488 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use async_trait::async_trait; +use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; +use flicknote_core::config::{Config, ConfigPaths}; +use flicknote_core::db::Database; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::ports::{ + CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, +}; +use flicknote_sync::app::Application; +use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; +use rmcp::ServiceExt; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}; + +use crate::mcp; + +struct PersistingCreator { + db: Arc, +} + +#[async_trait] +impl NoteCreator for PersistingCreator { + async fn create(&self, request: CreateNote) -> Result { + let inserted = self.db.insert_note(&request.as_insert_request()).await?; + Ok(CreatedNote { + inserted, + confirmed_extraction_ids: Vec::new(), + }) + } +} + +struct UnusedShareGateway; + +#[async_trait] +impl ShareGateway for UnusedShareGateway { + async fn share(&self, _resource: ShareResource, _id: &str) -> Result { + Err(ServiceError::Daemon("unexpected share".to_string())) + } + + async fn unshare(&self, _resource: ShareResource, _id: &str) -> Result<(), ServiceError> { + Err(ServiceError::Daemon("unexpected unshare".to_string())) + } +} + +struct McpHarness { + _directory: tempfile::TempDir, + writer: WriteHalf, + reader: BufReader>, + next_id: u64, + note_uuid: String, + alpha_id: String, + server: tokio::task::JoinHandle<()>, + daemon: tokio::task::JoinHandle<()>, +} + +impl McpHarness { + async fn start() -> Self { + let directory = tempfile::tempdir().unwrap(); + let config = test_config(directory.path()); + let (backend, note_uuid, alpha_id) = seeded_backend(&config).await; + let creator: Arc = Arc::new(PersistingCreator { + db: backend.clone(), + }); + let app = Arc::new( + Application::new(backend, creator, Arc::new(UnusedShareGateway)) + .with_web_url(config.web_url.clone()), + ); + let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); + let daemon = tokio::spawn(async move { + serve_app(listener, app, ServerInfo::current()) + .await + .unwrap(); + }); + let service = mcp::FlickNoteMcp::new(Arc::new(config)); + let (server_io, client_io) = tokio::io::duplex(8 * 1024); + let server = tokio::spawn(async move { + service + .serve(server_io) + .await + .unwrap() + .waiting() + .await + .unwrap(); + }); + let (reader, mut writer) = tokio::io::split(client_io); + let mut reader = BufReader::new(reader); + initialize_mcp(&mut writer, &mut reader).await; + Self { + _directory: directory, + writer, + reader, + next_id: 2, + note_uuid, + alpha_id, + server, + daemon, + } + } + + async fn request(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value { + let id = self.next_id; + self.next_id += 1; + rpc_request(&mut self.writer, &mut self.reader, id, method, params).await + } + + async fn call(&mut self, name: &str, arguments: serde_json::Value) -> serde_json::Value { + self.request( + "tools/call", + serde_json::json!({ "name": name, "arguments": arguments }), + ) + .await + } + + async fn tools(&mut self) -> Vec { + self.request("tools/list", serde_json::json!({})).await["result"]["tools"] + .as_array() + .unwrap() + .clone() + } +} + +impl Drop for McpHarness { + fn drop(&mut self) { + self.server.abort(); + self.daemon.abort(); + } +} + +fn test_config(directory: &std::path::Path) -> Config { + Config { + supabase_url: "https://auth.example.test".to_string(), + supabase_anon_key: "anon-key".to_string(), + powersync_url: String::new(), + api_url: "https://gateway.example.test/api/v1".to_string(), + web_url: Some("https://app.example".to_string()), + paths: ConfigPaths { + config_dir: directory.to_path_buf(), + data_dir: directory.to_path_buf(), + config_file: directory.join("config.json"), + session_file: directory.join("session.json"), + db_file: directory.join("test.db"), + log_file: directory.join("test.log"), + }, + } +} + +async fn seeded_backend(config: &Config) -> (Arc, String, String) { + let backend = Arc::new(SqliteBackend { + db: Database::open_local(config).await.unwrap(), + user_id: "test-user".to_string(), + }); + let project_id = backend.create_project("MCP Project").await.unwrap(); + let note_uuid = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: ¬e_uuid, + note_type: "normal", + status: "synced", + title: Some("MCP Note"), + content: Some("## Alpha\n\nOld text.\n\n## Beta\n\nKeep me."), + metadata: None, + project_id: Some(&project_id), + now: "2026-08-05T00:00:00Z", + }) + .await + .unwrap(); + sqlx::query("UPDATE notes SET short_id = 42, source = ? WHERE id = ?") + .bind(r#"{"link":{"content":"one\ntwo\nthree"}}"#) + .bind(¬e_uuid) + .execute(&backend.db.pool) + .await + .unwrap(); + let no_source_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &no_source_id, + note_type: "normal", + status: "synced", + title: Some("No source note"), + content: Some("Editable content"), + metadata: None, + project_id: None, + now: "2026-08-05T00:00:00Z", + }) + .await + .unwrap(); + sqlx::query("UPDATE notes SET short_id = 43 WHERE id = ?") + .bind(&no_source_id) + .execute(&backend.db.pool) + .await + .unwrap(); + let alpha_id = flicknote_core::services::markdown::parse_markdown( + "## Alpha\n\nOld text.\n\n## Beta\n\nKeep me.", + ) + .headings[0] + .id + .clone(); + (backend, note_uuid, alpha_id) +} + +async fn initialize_mcp( + writer: &mut WriteHalf, + reader: &mut BufReader>, +) { + let initialized = rpc_request( + writer, + reader, + 1, + "initialize", + serde_json::json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "flicknote-test", "version": "0" } + }), + ) + .await; + assert_eq!(initialized["result"]["serverInfo"]["name"], "flicknote"); + writer + .write_all( + concat!( + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n" + ) + .as_bytes(), + ) + .await + .unwrap(); +} + +async fn rpc_request( + writer: &mut WriteHalf, + reader: &mut BufReader>, + id: u64, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + writer + .write_all(format!("{request}\n").as_bytes()) + .await + .unwrap(); + let mut response = String::new(); + reader.read_line(&mut response).await.unwrap(); + serde_json::from_str(&response).unwrap() +} + +fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str) { + match value { + serde_json::Value::String(actual) => assert_ne!(actual, excluded), + serde_json::Value::Array(values) => { + for value in values { + assert_json_does_not_contain_string(value, excluded); + } + } + serde_json::Value::Object(values) => { + for value in values.values() { + assert_json_does_not_contain_string(value, excluded); + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } +} + +#[tokio::test] +async fn mcp_server_exposes_stable_tool_contract() { + let mut harness = McpHarness::start().await; + let tools = harness.tools().await; + let names = tools + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect::>(); + assert_eq!(names, mcp::EXPECTED_TOOLS.into_iter().collect()); + assert!(tools.iter().all(|tool| tool.get("outputSchema").is_some())); + + let list = tools + .iter() + .find(|tool| tool["name"] == "note_list") + .unwrap(); + assert_eq!( + list["inputSchema"]["$defs"]["NoteType"]["enum"], + serde_json::json!(["normal", "meeting", "link"]) + ); + let count = tools + .iter() + .find(|tool| tool["name"] == "note_count") + .unwrap(); + assert_eq!( + count["inputSchema"]["$defs"]["NoteType"]["enum"], + serde_json::json!(["normal", "meeting", "link", "file"]) + ); + for tool in tools.iter().filter(|tool| { + tool["name"] + .as_str() + .is_some_and(|name| name.starts_with("note_")) + }) { + let schema = &tool["inputSchema"]; + if schema["properties"].get("id").is_some() { + assert_eq!(schema["properties"]["id"]["type"], "integer"); + } + assert!(!tool["outputSchema"].to_string().contains("uuid")); + } + let project_get = tools + .iter() + .find(|tool| tool["name"] == "project_get") + .unwrap(); + assert!( + project_get["inputSchema"]["properties"] + .get("project") + .is_some() + ); + assert!(project_get["inputSchema"]["properties"].get("id").is_none()); +} + +#[tokio::test] +async fn mcp_note_queries_use_short_ids_and_hide_uuid() { + let mut harness = McpHarness::start().await; + let listed = harness.call("note_list", serde_json::json!({})).await; + assert_eq!(listed["result"]["isError"], false); + assert_eq!( + listed["result"]["structuredContent"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_json_does_not_contain_string(&listed["result"]["structuredContent"], &harness.note_uuid); + + let fetched = harness + .call("note_get", serde_json::json!({ "id": 42 })) + .await; + assert!(fetched["result"]["structuredContent"].get("uuid").is_none()); + assert!( + fetched["result"]["structuredContent"] + .get("project_id") + .is_none() + ); + let string_id = harness + .call("note_get", serde_json::json!({ "id": "42" })) + .await; + assert_eq!(string_id["result"]["structuredContent"]["id"], 42); + let uuid = harness.note_uuid.clone(); + let rejected = harness + .call("note_get", serde_json::json!({ "id": uuid })) + .await; + assert_eq!(rejected["result"]["isError"], true); + assert!( + rejected["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("invalid note ID") + ); +} + +#[tokio::test] +async fn mcp_note_mutations_and_lifecycle_route_through_daemon() { + let mut harness = McpHarness::start().await; + let modified = harness + .call( + "note_modify", + serde_json::json!({ + "id": 42, + "before": "Old text.", + "after": "New text.", + "flagged": true + }), + ) + .await; + assert_eq!( + modified["result"]["structuredContent"]["note"]["flagged"], + true + ); + let section = harness.alpha_id.clone(); + harness + .call( + "note_replace_section", + serde_json::json!({ + "id": 42, + "section": section, + "content": "## Alpha revised\n\nReplacement text." + }), + ) + .await; + let fetched = harness + .call("note_get", serde_json::json!({ "id": 42 })) + .await; + let content = fetched["result"]["structuredContent"]["content"] + .as_str() + .unwrap(); + assert!(content.contains("Replacement text.")); + assert!(content.contains("Keep me.")); + let found = harness + .call( + "note_find", + serde_json::json!({ "keywords": ["Replacement"] }), + ) + .await; + assert_eq!( + found["result"]["structuredContent"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let added = harness + .call( + "note_add", + serde_json::json!({ "content": "daemon-backed note" }), + ) + .await; + assert_eq!(added["result"]["isError"], false); + let archived = harness + .call("note_archive", serde_json::json!({ "id": 42 })) + .await; + assert_eq!(archived["result"]["structuredContent"]["archived"], true); + let restored = harness + .call("note_restore", serde_json::json!({ "id": 42 })) + .await; + assert_eq!(restored["result"]["structuredContent"]["archived"], false); +} + +#[tokio::test] +async fn mcp_project_and_source_contracts_are_preserved() { + let mut harness = McpHarness::start().await; + let projects = harness.call("project_list", serde_json::json!({})).await; + assert_eq!( + projects["result"]["structuredContent"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert!( + projects["result"]["structuredContent"][0] + .get("id") + .is_none() + ); + let project = harness + .call( + "project_modify", + serde_json::json!({ "project": "MCP Project", "color": "#abcdef" }), + ) + .await; + assert_eq!(project["result"]["structuredContent"]["color"], "#abcdef"); + + let info = harness + .call( + "note_source", + serde_json::json!({ "id": 42, "view": "info" }), + ) + .await; + assert_eq!( + info["result"]["structuredContent"], + serde_json::json!({ + "view": "info", + "source_type": "link", + "range_unit": "line", + "count": 3 + }) + ); + let range = harness + .call( + "note_source", + serde_json::json!({ "id": 42, "view": "rendered", "range": "2:3" }), + ) + .await; + assert_eq!( + range["result"]["structuredContent"]["content"], + "two\nthree\n" + ); + let no_source = harness + .call( + "note_source", + serde_json::json!({ "id": 43, "view": "info" }), + ) + .await; + assert_eq!( + no_source["result"]["structuredContent"]["code"], + "no_source" + ); +} diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index 60bdab0..39149f6 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::sync::Arc; use flicknote_core::config::Config; use flicknote_core::error::CliError; @@ -62,12 +62,12 @@ struct CountResult { #[derive(Clone)] pub(crate) struct FlickNoteMcp { - config: Rc, + config: Arc, tool_router: ToolRouter, } impl FlickNoteMcp { - pub(crate) fn new(config: Rc) -> Self { + pub(crate) fn new(config: Arc) -> Self { Self { config, tool_router: Self::tool_router(), @@ -595,7 +595,7 @@ impl ServerHandler for FlickNoteMcp { } } -pub(crate) async fn serve(config: Rc) -> Result<(), CliError> { +pub(crate) async fn serve(config: Arc) -> Result<(), CliError> { FlickNoteMcp::new(config) .serve(rmcp::transport::stdio()) .await @@ -608,8 +608,25 @@ pub(crate) async fn serve(config: Rc) -> Result<(), CliError> { #[cfg(test)] mod tests { + use std::sync::Arc; + + use flicknote_core::config::Config; + use super::FlickNoteMcp; + fn assert_send(_: T) {} + fn assert_send_sync() {} + + #[allow(dead_code)] + fn assert_serve_future_is_send(config: Arc) { + assert_send(super::serve(config)); + } + + #[test] + fn mcp_service_is_send_and_sync() { + assert_send_sync::(); + } + #[test] fn explicit_project_wins_then_falls_back_to_non_empty_environment_value() { assert_eq!( diff --git a/flicknote-core/src/schema.rs b/flicknote-core/src/schema.rs index cf94df6..fa26f5c 100644 --- a/flicknote-core/src/schema.rs +++ b/flicknote-core/src/schema.rs @@ -1,9 +1,21 @@ use powersync::schema::{Column, Index, IndexedColumn, Schema, Table}; pub fn app_schema() -> Schema { - let mut schema = Schema::default(); + Schema { + tables: vec![ + notes_table(), + projects_table(), + note_extractions_table(), + taskchampion_tasks_table(), + taskchampion_operations_table(), + settings_table(), + ], + ..Schema::default() + } +} - schema.tables.push(Table::create( +fn notes_table() -> Table { + Table::create( "notes", vec![ Column::integer("short_id"), @@ -21,77 +33,26 @@ pub fn app_schema() -> Schema { Column::text("updated_at"), Column::text("deleted_at"), ], - |t| { - t.options.track_metadata = true; - t.indexes = vec![ - Index { - name: "notes_user_short_id_idx".into(), - columns: vec![ - IndexedColumn { - name: "user_id".into(), - ascending: true, - type_name: "TEXT".into(), - }, - IndexedColumn { - name: "short_id".into(), - ascending: true, - type_name: "INTEGER".into(), - }, - ], - }, - Index { - name: "type".into(), - columns: vec![IndexedColumn { - name: "type".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "project".into(), - columns: vec![IndexedColumn { - name: "project_id".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "status".into(), - columns: vec![IndexedColumn { - name: "status".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "created".into(), - columns: vec![IndexedColumn { - name: "created_at".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "notes_deleted_at_idx".into(), - columns: vec![IndexedColumn { - name: "deleted_at".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "notes_updated_at_idx".into(), - columns: vec![IndexedColumn { - name: "updated_at".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, + |table| { + table.options.track_metadata = true; + table.indexes = vec![ + compound_index( + "notes_user_short_id_idx", + &[("user_id", "TEXT"), ("short_id", "INTEGER")], + ), + index("type", "type", "TEXT"), + index("project", "project_id", "TEXT"), + index("status", "status", "TEXT"), + index("created", "created_at", "TEXT"), + index("notes_deleted_at_idx", "deleted_at", "TEXT"), + index("notes_updated_at_idx", "updated_at", "TEXT"), ]; }, - )); + ) +} - schema.tables.push(Table::create( +fn projects_table() -> Table { + Table::create( "projects", vec![ Column::text("user_id"), @@ -101,9 +62,11 @@ pub fn app_schema() -> Schema { Column::text("created_at"), ], |_| {}, - )); + ) +} - schema.tables.push(Table::create( +fn note_extractions_table() -> Table { + Table::create( "note_extractions", vec![ Column::text("note_id"), @@ -111,30 +74,18 @@ pub fn app_schema() -> Schema { Column::text("key"), Column::text("value"), ], - |t| { - t.options.track_metadata = true; - t.indexes = vec![ - Index { - name: "note_extractions_note_id_idx".into(), - columns: vec![IndexedColumn { - name: "note_id".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "note_extractions_key_idx".into(), - columns: vec![IndexedColumn { - name: "key".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, + |table| { + table.options.track_metadata = true; + table.indexes = vec![ + index("note_extractions_note_id_idx", "note_id", "TEXT"), + index("note_extractions_key_idx", "key", "TEXT"), ]; }, - )); + ) +} - schema.tables.push(Table::create( +fn taskchampion_tasks_table() -> Table { + Table::create( "tc_tasks", vec![ Column::integer("short_id"), @@ -154,56 +105,35 @@ pub fn app_schema() -> Schema { Column::text("note_id"), Column::text("project_id"), ], - |t| { - t.indexes = vec![ - Index { - name: "tc_tasks_user_short_id_idx".into(), - columns: vec![ - IndexedColumn { - name: "user_id".into(), - ascending: true, - type_name: "TEXT".into(), - }, - IndexedColumn { - name: "short_id".into(), - ascending: true, - type_name: "INTEGER".into(), - }, - ], - }, - Index { - name: "tc_tasks_status".into(), - columns: vec![IndexedColumn { - name: "status".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, - Index { - name: "tc_tasks_parent".into(), - columns: vec![IndexedColumn { - name: "parent_id".into(), - ascending: true, - type_name: "TEXT".into(), - }], - }, + |table| { + table.indexes = vec![ + compound_index( + "tc_tasks_user_short_id_idx", + &[("user_id", "TEXT"), ("short_id", "INTEGER")], + ), + index("tc_tasks_status", "status", "TEXT"), + index("tc_tasks_parent", "parent_id", "TEXT"), ]; }, - )); + ) +} - schema.tables.push(Table::create( +fn taskchampion_operations_table() -> Table { + Table::create( "tc_operations", vec![ Column::text("user_id"), Column::text("data"), Column::text("created_at"), ], - |t| { - t.options.local_only = true; + |table| { + table.options.local_only = true; }, - )); + ) +} - schema.tables.push(Table::create( +fn settings_table() -> Table { + Table::create( "settings", vec![ Column::text("language"), @@ -214,9 +144,25 @@ pub fn app_schema() -> Schema { Column::text("tc_config"), ], |_| {}, - )); + ) +} - schema +fn index(name: &str, column: &str, type_name: &str) -> Index { + compound_index(name, &[(column, type_name)]) +} + +fn compound_index(name: &str, columns: &[(&str, &str)]) -> Index { + Index { + name: name.to_string().into(), + columns: columns + .iter() + .map(|(name, type_name)| IndexedColumn { + name: (*name).to_string().into(), + ascending: true, + type_name: (*type_name).to_string().into(), + }) + .collect(), + } } #[cfg(test)] diff --git a/flicknote-core/src/services/ports.rs b/flicknote-core/src/services/ports.rs index eab852a..da18f56 100644 --- a/flicknote-core/src/services/ports.rs +++ b/flicknote-core/src/services/ports.rs @@ -58,6 +58,18 @@ pub trait ShareGateway: Send + Sync { async fn unshare(&self, resource: ShareResource, id: &str) -> Result<(), ServiceError>; } -pub trait BrowserOpener { +pub trait BrowserOpener: Send + Sync { fn open(&self, url: &str) -> Result<(), ServiceError>; } + +#[cfg(test)] +mod tests { + use super::BrowserOpener; + + fn assert_send_sync() {} + + #[test] + fn browser_opener_port_is_send_and_sync() { + assert_send_sync::(); + } +} diff --git a/flicknote-sync/Cargo.toml b/flicknote-sync/Cargo.toml index 24eb751..d87a154 100644 --- a/flicknote-sync/Cargo.toml +++ b/flicknote-sync/Cargo.toml @@ -16,7 +16,7 @@ flicknote-auth = { path = "../flicknote-auth" } powersync = { workspace = true } rusqlite = { workspace = true } async-trait = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "net", "macros", "signal", "sync", "io-util", "time"] } +tokio = { workspace = true, features = ["rt-multi-thread", "net", "macros", "signal", "sync", "io-util", "time", "fs"] } reqwest = { version = "0.13.2", default-features = false, features = ["json", "charset", "http2", "rustls", "stream"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/flicknote-sync/src/app/mod.rs b/flicknote-sync/src/app/mod.rs index a9b34e9..75ebac2 100644 --- a/flicknote-sync/src/app/mod.rs +++ b/flicknote-sync/src/app/mod.rs @@ -1,14 +1,13 @@ use std::sync::Arc; use flicknote_core::backend::NoteDb; -use flicknote_core::services::dto::NoteAddInput; use flicknote_core::services::error::ServiceError; -use flicknote_core::services::note::{NoteService, confirmed_create_followup_error}; -use flicknote_core::services::ports::{CreateNote, NoteCreator, ShareGateway}; -use flicknote_core::services::project::ProjectService; -use flicknote_core::services::upload::{self, UploadKind}; +use flicknote_core::services::ports::{NoteCreator, ShareGateway}; -use crate::ipc::{AppRequest, AppResponse, WireError}; +use crate::ipc::{AppRequest, AppRequestKind, AppResponse, WireError}; + +mod note; +mod project; pub struct Application { db: Arc, @@ -58,346 +57,27 @@ impl Application { } async fn handle_inner(&self, request: AppRequest) -> Result { - let notes = NoteService::new(self.db.as_ref()); - let projects = ProjectService::new(self.db.as_ref()); - match request { - AppRequest::NoteAdd(input) => notes - .add(self.creator.as_ref(), input) - .await - .map(AppResponse::NoteSummary) - .map_err(WireError::from_service), - AppRequest::NoteAddEditable { document, project } => { - let parsed = - flicknote_core::services::editable_document::parse_editable_note(&document) - .map_err(Self::db_error)?; - let project_id = match project.as_deref() { - Some(name) => Some( - self.db - .find_project_by_name(name) - .await - .map_err(Self::db_error)? - .ok_or_else(|| { - WireError::from_service(ServiceError::ProjectNotFound( - name.to_string(), - )) - })?, - ), - None => None, - }; - let request = CreateNote { - id: uuid::Uuid::new_v4().to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some(parsed.title), - content: Some(parsed.stored_content), - metadata: None, - project_id, - now: chrono::Utc::now().to_rfc3339(), - topics: parsed.topics, - attachment_path: None, - }; - let created = self - .creator - .create(request) - .await - .map_err(WireError::from_service)?; - notes - .get(&created.inserted.uuid, false) - .await - .map(|detail| AppResponse::NoteSummary(detail.note)) - .map_err(|error| { - WireError::from_service(confirmed_create_followup_error(&created, &error)) - }) - } - AppRequest::NoteUpload { - path, - project, - created_at, - } => { - let path = std::path::PathBuf::from(path); - match upload::classify(&path).map_err(Self::db_error)? { - UploadKind::Text => { - let content = std::fs::read_to_string(&path) - .map_err(|error| WireError::from_service(ServiceError::Io(error)))?; - if content.trim().is_empty() { - return Err(WireError::from_service(ServiceError::InvalidArgument( - "content must not be empty".to_string(), - ))); - } - let input = NoteAddInput { - content: content.trim_end().to_string(), - project, - interpret_as_url: false, - topics: Vec::new(), - created_at, - }; - notes - .add(self.creator.as_ref(), input) - .await - .map(AppResponse::NoteSummary) - .map_err(WireError::from_service) - } - UploadKind::Attachment { - note_type, - metadata, - } => { - let project_id = match project.as_deref() { - Some(name) => Some( - self.db - .find_project_by_name(name) - .await - .map_err(Self::db_error)? - .ok_or_else(|| { - WireError::from_service(ServiceError::ProjectNotFound( - name.to_string(), - )) - })?, - ), - None => None, - }; - let created = self - .creator - .create(CreateNote { - id: uuid::Uuid::new_v4().to_string(), - note_type: note_type.to_string(), - status: "source_queued".to_string(), - title: None, - content: None, - metadata: Some(metadata), - project_id, - now: created_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), - topics: Vec::new(), - attachment_path: Some(path.to_string_lossy().into_owned()), - }) - .await - .map_err(WireError::from_service)?; - notes - .get(&created.inserted.uuid, false) - .await - .map(|detail| AppResponse::NoteSummary(detail.note)) - .map_err(|error| { - WireError::from_service(confirmed_create_followup_error( - &created, &error, - )) - }) - } - } - } - AppRequest::NoteList(input) => notes - .list(input) - .await - .map(AppResponse::NoteSummaries) - .map_err(WireError::from_service), - AppRequest::NoteAppend { id, content } => notes - .append(&id, &content) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteSaveEditable { id, document } => { - let id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; - flicknote_core::services::editable_document::save_editable_note( - self.db.as_ref(), - &id, - &document, - ) - .await - .map(AppResponse::EditableSave) - .map_err(Self::db_error) - } - AppRequest::NoteFind(input) => notes - .find(input) - .await - .map(AppResponse::NoteSummaries) - .map_err(WireError::from_service), - AppRequest::NoteCount(input) => notes - .count(input) - .await - .map(|count| AppResponse::NoteCount { count }) - .map_err(WireError::from_service), - AppRequest::NoteGet { id, archived } => notes - .get(&id, archived) - .await - .map(AppResponse::NoteDetail) - .map_err(WireError::from_service), - AppRequest::NoteLoadEditable { id } => { - let id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; - flicknote_core::services::editable_document::load_editable_note( - self.db.as_ref(), - &id, - ) - .await - .map(|document| { - AppResponse::EditableDocument(crate::ipc::EditableDocument { document }) - }) - .map_err(Self::db_error) - } - AppRequest::NoteRecord { id, archived } => { - let id = if archived { - self.db.resolve_archived_note_id(&id).await - } else { - self.db.resolve_note_id(&id).await - } - .map_err(Self::db_error)?; - let note = if archived { - self.db.find_archived_note(&id).await - } else { - self.db.find_note(&id).await - } - .map_err(Self::db_error)?; - Ok(AppResponse::NoteRecord(note)) - } - AppRequest::NoteGetSection { id, section } => notes - .get_section(&id, §ion) - .await - .map(AppResponse::NoteSection) - .map_err(WireError::from_service), - AppRequest::NoteSource { - id, - archived, - view, - range, - } => notes - .source(&id, archived, view, range.as_deref()) - .await - .map(AppResponse::Source) - .map_err(WireError::from_service), - AppRequest::NoteReplaceSection { - id, - section, - content, - } => notes - .replace_section(&id, §ion, &content) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteRenameSection { id, section, name } => notes - .rename_section(&id, §ion, &name) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteInsert { - id, - section, - position, - content, - } => notes - .insert(&id, §ion, position, &content) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteDeleteSection { id, section } => notes - .delete_section(&id, §ion) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteModify(input) => notes - .modify(input) - .await - .map(AppResponse::NoteMutation) - .map_err(WireError::from_service), - AppRequest::NoteArchive { id } => notes - .archive(&id) - .await - .map(AppResponse::NoteArchive) - .map_err(WireError::from_service), - AppRequest::NoteRestore { id } => notes - .restore(&id) - .await - .map(AppResponse::NoteArchive) - .map_err(WireError::from_service), - AppRequest::NoteShare { id } => notes - .share(self.share_gateway.as_ref(), &id) - .await - .map(AppResponse::Share) - .map_err(WireError::from_service), - AppRequest::NoteUnshare { id } => notes - .unshare(self.share_gateway.as_ref(), &id) - .await - .map(AppResponse::Unshare) - .map_err(WireError::from_service), - AppRequest::NoteOpen { id } => { - let web_url = self.web_url.as_deref().ok_or_else(|| { - WireError::from_service(ServiceError::ConfigMissing("webUrl".to_string())) - })?; - let full_id = self.db.resolve_note_id(&id).await.map_err(Self::db_error)?; - let note = self.db.find_note(&full_id).await.map_err(Self::db_error)?; - let url_id = note.short_id.map_or(full_id, |value| value.to_string()); - Ok(AppResponse::Open( - flicknote_core::services::dto::OpenResult { - url: format!("{}/notes/{url_id}", web_url.trim_end_matches('/')), - opened: false, - }, - )) - } - AppRequest::ProjectList { include_archived } => projects - .list(include_archived) - .await - .map(AppResponse::Projects) - .map_err(WireError::from_service), - AppRequest::ProjectRecords { include_archived } => { - let mut records = self.db.list_projects(false).await.map_err(Self::db_error)?; - if include_archived { - records.extend(self.db.list_projects(true).await.map_err(Self::db_error)?); - records.sort_by(|left, right| right.created_at.cmp(&left.created_at)); - } - Ok(AppResponse::ProjectRecords(records)) - } - AppRequest::ProjectGet { id } => projects - .get(&id) - .await - .map(AppResponse::Project) - .map_err(WireError::from_service), - AppRequest::ProjectGetByName { name } => { - let id = self - .db - .find_project_by_name(&name) - .await - .map_err(Self::db_error)? - .ok_or_else(|| { - WireError::from_service(ServiceError::ProjectNotFound(name.clone())) - })?; - projects - .get(&id) - .await - .map(AppResponse::Project) - .map_err(WireError::from_service) - } - AppRequest::ProjectAdd(input) => projects - .add(input) - .await - .map(AppResponse::Project) - .map_err(WireError::from_service), - AppRequest::ProjectModify(input) => projects - .modify(input) - .await - .map(AppResponse::Project) - .map_err(WireError::from_service), - AppRequest::ProjectArchive { id } => projects - .archive(&id) - .await - .map(AppResponse::Project) - .map_err(WireError::from_service), - AppRequest::ProjectShare { id } => projects - .share(self.share_gateway.as_ref(), &id) - .await - .map(AppResponse::Share) - .map_err(WireError::from_service), - AppRequest::ProjectUnshare { id } => projects - .unshare(self.share_gateway.as_ref(), &id) - .await - .map(AppResponse::Unshare) - .map_err(WireError::from_service), - AppRequest::ExtractionValues { keys, archived } => { - let refs = keys.iter().map(String::as_str).collect::>(); - self.db - .list_extraction_values(&refs, archived) - .await - .map(AppResponse::Values) - .map_err(Self::db_error) - } + match request.kind() { + AppRequestKind::NoteRead => note::handle_read(self, request).await, + AppRequestKind::NoteWrite => note::handle_write(self, request).await, + AppRequestKind::ProjectRead => project::handle_read(self, request).await, + AppRequestKind::ProjectWrite => project::handle_write(self, request).await, + AppRequestKind::ExtractionRead => self.handle_extraction(request).await, } } + async fn handle_extraction(&self, request: AppRequest) -> Result { + let AppRequest::ExtractionValues { keys, archived } = request else { + unreachable!("request kind guarantees an extraction request") + }; + let refs = keys.iter().map(String::as_str).collect::>(); + self.db + .list_extraction_values(&refs, archived) + .await + .map(AppResponse::Values) + .map_err(Self::db_error) + } + fn db_error(error: flicknote_core::error::CliError) -> WireError { WireError::from_service(ServiceError::from(error)) } diff --git a/flicknote-sync/src/app/note.rs b/flicknote-sync/src/app/note.rs new file mode 100644 index 0000000..5f10bcf --- /dev/null +++ b/flicknote-sync/src/app/note.rs @@ -0,0 +1,332 @@ +use std::path::PathBuf; + +use flicknote_core::services::dto::NoteAddInput; +use flicknote_core::services::editable_document; +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::note::{NoteService, confirmed_create_followup_error}; +use flicknote_core::services::ports::{CreateNote, CreatedNote}; +use flicknote_core::services::upload::{self, UploadKind}; + +use super::Application; +use crate::ipc::{AppRequest, AppResponse, EditableDocument, WireError}; + +pub(super) async fn handle_read( + app: &Application, + request: AppRequest, +) -> Result { + let notes = NoteService::new(app.db.as_ref()); + match request { + AppRequest::NoteList(input) => { + service_result(notes.list(input).await, AppResponse::NoteSummaries) + } + AppRequest::NoteFind(input) => { + service_result(notes.find(input).await, AppResponse::NoteSummaries) + } + AppRequest::NoteCount(input) => notes + .count(input) + .await + .map(|count| AppResponse::NoteCount { count }) + .map_err(WireError::from_service), + AppRequest::NoteGet { id, archived } => { + service_result(notes.get(&id, archived).await, AppResponse::NoteDetail) + } + AppRequest::NoteLoadEditable { id } => load_editable(app, &id).await, + AppRequest::NoteRecord { id, archived } => note_record(app, &id, archived).await, + AppRequest::NoteGetSection { id, section } => service_result( + notes.get_section(&id, §ion).await, + AppResponse::NoteSection, + ), + AppRequest::NoteSource { + id, + archived, + view, + range, + } => service_result( + notes.source(&id, archived, view, range.as_deref()).await, + AppResponse::Source, + ), + AppRequest::NoteOpen { id } => open_note(app, &id).await, + _ => unreachable!("request kind guarantees a read-only note request"), + } +} + +pub(super) async fn handle_write( + app: &Application, + request: AppRequest, +) -> Result { + let notes = NoteService::new(app.db.as_ref()); + match request { + AppRequest::NoteAdd(input) => service_result( + notes.add(app.creator.as_ref(), input).await, + AppResponse::NoteSummary, + ), + AppRequest::NoteAddEditable { document, project } => { + add_editable(app, &document, project.as_deref()).await + } + AppRequest::NoteUpload { + path, + project, + created_at, + } => upload_note(app, PathBuf::from(path), project, created_at).await, + AppRequest::NoteAppend { id, content } => { + service_result(notes.append(&id, &content).await, AppResponse::NoteMutation) + } + AppRequest::NoteSaveEditable { id, document } => save_editable(app, &id, &document).await, + AppRequest::NoteReplaceSection { + id, + section, + content, + } => service_result( + notes.replace_section(&id, §ion, &content).await, + AppResponse::NoteMutation, + ), + AppRequest::NoteRenameSection { id, section, name } => service_result( + notes.rename_section(&id, §ion, &name).await, + AppResponse::NoteMutation, + ), + AppRequest::NoteInsert { + id, + section, + position, + content, + } => service_result( + notes.insert(&id, §ion, position, &content).await, + AppResponse::NoteMutation, + ), + AppRequest::NoteDeleteSection { id, section } => service_result( + notes.delete_section(&id, §ion).await, + AppResponse::NoteMutation, + ), + AppRequest::NoteModify(input) => { + service_result(notes.modify(input).await, AppResponse::NoteMutation) + } + AppRequest::NoteArchive { id } => { + service_result(notes.archive(&id).await, AppResponse::NoteArchive) + } + AppRequest::NoteRestore { id } => { + service_result(notes.restore(&id).await, AppResponse::NoteArchive) + } + AppRequest::NoteShare { id } => service_result( + notes.share(app.share_gateway.as_ref(), &id).await, + AppResponse::Share, + ), + AppRequest::NoteUnshare { id } => service_result( + notes.unshare(app.share_gateway.as_ref(), &id).await, + AppResponse::Unshare, + ), + _ => unreachable!("request kind guarantees a mutating note request"), + } +} + +fn service_result( + result: Result, + response: impl FnOnce(T) -> AppResponse, +) -> Result { + result.map(response).map_err(WireError::from_service) +} + +async fn load_editable(app: &Application, id: &str) -> Result { + let id = app + .db + .resolve_note_id(id) + .await + .map_err(Application::db_error)?; + editable_document::load_editable_note(app.db.as_ref(), &id) + .await + .map(|document| AppResponse::EditableDocument(EditableDocument { document })) + .map_err(Application::db_error) +} + +async fn note_record( + app: &Application, + id: &str, + archived: bool, +) -> Result { + let id = if archived { + app.db.resolve_archived_note_id(id).await + } else { + app.db.resolve_note_id(id).await + } + .map_err(Application::db_error)?; + let note = if archived { + app.db.find_archived_note(&id).await + } else { + app.db.find_note(&id).await + } + .map_err(Application::db_error)?; + Ok(AppResponse::NoteRecord(note)) +} + +async fn open_note(app: &Application, id: &str) -> Result { + let web_url = app.web_url.as_deref().ok_or_else(|| { + WireError::from_service(ServiceError::ConfigMissing("webUrl".to_string())) + })?; + let full_id = app + .db + .resolve_note_id(id) + .await + .map_err(Application::db_error)?; + let note = app + .db + .find_note(&full_id) + .await + .map_err(Application::db_error)?; + let url_id = note.short_id.map_or(full_id, |value| value.to_string()); + Ok(AppResponse::Open( + flicknote_core::services::dto::OpenResult { + url: format!("{}/notes/{url_id}", web_url.trim_end_matches('/')), + opened: false, + }, + )) +} + +async fn add_editable( + app: &Application, + document: &str, + project: Option<&str>, +) -> Result { + let parsed = editable_document::parse_editable_note(document).map_err(Application::db_error)?; + let created = app + .creator + .create(CreateNote { + id: uuid::Uuid::new_v4().to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some(parsed.title), + content: Some(parsed.stored_content), + metadata: None, + project_id: resolve_project_id(app, project).await?, + now: chrono::Utc::now().to_rfc3339(), + topics: parsed.topics, + attachment_path: None, + }) + .await + .map_err(WireError::from_service)?; + confirmed_summary(app, created).await +} + +async fn upload_note( + app: &Application, + path: PathBuf, + project: Option, + created_at: Option, +) -> Result { + match upload::classify(&path).map_err(Application::db_error)? { + UploadKind::Text => upload_text(app, &path, project, created_at).await, + UploadKind::Attachment { + note_type, + metadata, + } => { + upload_attachment( + app, + path, + project.as_deref(), + created_at, + note_type, + metadata, + ) + .await + } + } +} + +async fn upload_text( + app: &Application, + path: &PathBuf, + project: Option, + created_at: Option, +) -> Result { + let content = tokio::fs::read_to_string(path) + .await + .map_err(|error| WireError::from_service(ServiceError::Io(error)))?; + if content.trim().is_empty() { + return Err(WireError::from_service(ServiceError::InvalidArgument( + "content must not be empty".to_string(), + ))); + } + let notes = NoteService::new(app.db.as_ref()); + service_result( + notes + .add( + app.creator.as_ref(), + NoteAddInput { + content: content.trim_end().to_string(), + project, + interpret_as_url: false, + topics: Vec::new(), + created_at, + }, + ) + .await, + AppResponse::NoteSummary, + ) +} + +async fn upload_attachment( + app: &Application, + path: PathBuf, + project: Option<&str>, + created_at: Option, + note_type: &'static str, + metadata: String, +) -> Result { + let created = app + .creator + .create(CreateNote { + id: uuid::Uuid::new_v4().to_string(), + note_type: note_type.to_string(), + status: "source_queued".to_string(), + title: None, + content: None, + metadata: Some(metadata), + project_id: resolve_project_id(app, project).await?, + now: created_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + topics: Vec::new(), + attachment_path: Some(path.to_string_lossy().into_owned()), + }) + .await + .map_err(WireError::from_service)?; + confirmed_summary(app, created).await +} + +async fn resolve_project_id( + app: &Application, + project: Option<&str>, +) -> Result, WireError> { + let Some(name) = project else { + return Ok(None); + }; + app.db + .find_project_by_name(name) + .await + .map_err(Application::db_error)? + .map(Some) + .ok_or_else(|| WireError::from_service(ServiceError::ProjectNotFound(name.to_string()))) +} + +async fn confirmed_summary( + app: &Application, + created: CreatedNote, +) -> Result { + NoteService::new(app.db.as_ref()) + .get(&created.inserted.uuid, false) + .await + .map(|detail| AppResponse::NoteSummary(detail.note)) + .map_err(|error| WireError::from_service(confirmed_create_followup_error(&created, &error))) +} + +async fn save_editable( + app: &Application, + id: &str, + document: &str, +) -> Result { + let id = app + .db + .resolve_note_id(id) + .await + .map_err(Application::db_error)?; + editable_document::save_editable_note(app.db.as_ref(), &id, document) + .await + .map(AppResponse::EditableSave) + .map_err(Application::db_error) +} diff --git a/flicknote-sync/src/app/project.rs b/flicknote-sync/src/app/project.rs new file mode 100644 index 0000000..d7bc024 --- /dev/null +++ b/flicknote-sync/src/app/project.rs @@ -0,0 +1,99 @@ +use flicknote_core::services::error::ServiceError; +use flicknote_core::services::project::ProjectService; + +use super::Application; +use crate::ipc::{AppRequest, AppResponse, WireError}; + +pub(super) async fn handle_read( + app: &Application, + request: AppRequest, +) -> Result { + let projects = ProjectService::new(app.db.as_ref()); + match request { + AppRequest::ProjectList { include_archived } => projects + .list(include_archived) + .await + .map(AppResponse::Projects) + .map_err(WireError::from_service), + AppRequest::ProjectRecords { include_archived } => { + project_records(app, include_archived).await + } + AppRequest::ProjectGet { id } => projects + .get(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectGetByName { name } => project_by_name(app, &name).await, + _ => unreachable!("request kind guarantees a read-only project request"), + } +} + +pub(super) async fn handle_write( + app: &Application, + request: AppRequest, +) -> Result { + let projects = ProjectService::new(app.db.as_ref()); + match request { + AppRequest::ProjectAdd(input) => projects + .add(input) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectModify(input) => projects + .modify(input) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectArchive { id } => projects + .archive(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service), + AppRequest::ProjectShare { id } => projects + .share(app.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Share) + .map_err(WireError::from_service), + AppRequest::ProjectUnshare { id } => projects + .unshare(app.share_gateway.as_ref(), &id) + .await + .map(AppResponse::Unshare) + .map_err(WireError::from_service), + _ => unreachable!("request kind guarantees a mutating project request"), + } +} + +async fn project_records( + app: &Application, + include_archived: bool, +) -> Result { + let mut records = app + .db + .list_projects(false) + .await + .map_err(Application::db_error)?; + if include_archived { + records.extend( + app.db + .list_projects(true) + .await + .map_err(Application::db_error)?, + ); + records.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + } + Ok(AppResponse::ProjectRecords(records)) +} + +async fn project_by_name(app: &Application, name: &str) -> Result { + let id = app + .db + .find_project_by_name(name) + .await + .map_err(Application::db_error)? + .ok_or_else(|| WireError::from_service(ServiceError::ProjectNotFound(name.to_string())))?; + ProjectService::new(app.db.as_ref()) + .get(&id) + .await + .map(AppResponse::Project) + .map_err(WireError::from_service) +} diff --git a/flicknote-sync/src/connector.rs b/flicknote-sync/src/connector.rs index a0173cb..7a7e8c4 100644 --- a/flicknote-sync/src/connector.rs +++ b/flicknote-sync/src/connector.rs @@ -1,4 +1,7 @@ -use crate::*; +use async_trait::async_trait; +use powersync::{BackendConnector, PowerSyncCredentials, error::PowerSyncError}; + +use crate::upload::{FlickNoteConnector, ps_err, run_upload}; #[async_trait] impl BackendConnector for FlickNoteConnector { diff --git a/flicknote-sync/src/ipc/protocol.rs b/flicknote-sync/src/ipc/protocol.rs index 9e72003..4fed718 100644 --- a/flicknote-sync/src/ipc/protocol.rs +++ b/flicknote-sync/src/ipc/protocol.rs @@ -128,27 +128,61 @@ pub enum AppRequest { } impl AppRequest { - pub fn may_write(&self) -> bool { - !matches!( - self, + pub(crate) fn kind(&self) -> AppRequestKind { + match self { Self::NoteList(_) - | Self::NoteFind(_) - | Self::NoteCount(_) - | Self::NoteGet { .. } - | Self::NoteLoadEditable { .. } - | Self::NoteRecord { .. } - | Self::NoteGetSection { .. } - | Self::NoteSource { .. } - | Self::NoteOpen { .. } - | Self::ProjectList { .. } - | Self::ProjectRecords { .. } - | Self::ProjectGet { .. } - | Self::ProjectGetByName { .. } - | Self::ExtractionValues { .. } + | Self::NoteFind(_) + | Self::NoteCount(_) + | Self::NoteGet { .. } + | Self::NoteLoadEditable { .. } + | Self::NoteRecord { .. } + | Self::NoteGetSection { .. } + | Self::NoteSource { .. } + | Self::NoteOpen { .. } => AppRequestKind::NoteRead, + Self::NoteAdd(_) + | Self::NoteAddEditable { .. } + | Self::NoteUpload { .. } + | Self::NoteAppend { .. } + | Self::NoteSaveEditable { .. } + | Self::NoteReplaceSection { .. } + | Self::NoteRenameSection { .. } + | Self::NoteInsert { .. } + | Self::NoteDeleteSection { .. } + | Self::NoteModify(_) + | Self::NoteArchive { .. } + | Self::NoteRestore { .. } + | Self::NoteShare { .. } + | Self::NoteUnshare { .. } => AppRequestKind::NoteWrite, + Self::ProjectList { .. } + | Self::ProjectRecords { .. } + | Self::ProjectGet { .. } + | Self::ProjectGetByName { .. } => AppRequestKind::ProjectRead, + Self::ProjectAdd(_) + | Self::ProjectModify(_) + | Self::ProjectArchive { .. } + | Self::ProjectShare { .. } + | Self::ProjectUnshare { .. } => AppRequestKind::ProjectWrite, + Self::ExtractionValues { .. } => AppRequestKind::ExtractionRead, + } + } + + pub fn may_write(&self) -> bool { + matches!( + self.kind(), + AppRequestKind::NoteWrite | AppRequestKind::ProjectWrite ) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppRequestKind { + NoteRead, + NoteWrite, + ProjectRead, + ProjectWrite, + ExtractionRead, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum AppResponse { @@ -169,9 +203,7 @@ pub enum AppResponse { Projects(Vec), ProjectRecords(Vec), Project(ProjectDto), - Id { id: String }, Values(Vec), - Unit, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -223,24 +255,6 @@ impl AppResult for u64 { } } -impl AppResult for String { - fn from_response(response: AppResponse) -> Option { - match response { - AppResponse::Id { id } => Some(id), - _ => None, - } - } -} - -impl AppResult for () { - fn from_response(response: AppResponse) -> Option { - match response { - AppResponse::Unit => Some(()), - _ => None, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WireError { pub code: String, @@ -294,8 +308,7 @@ pub enum DaemonResponse { AppError(WireError), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "code", rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum DaemonError { Unavailable { path: String, diff --git a/flicknote-sync/src/ipc/server.rs b/flicknote-sync/src/ipc/server.rs index 5d5498e..6844f2b 100644 --- a/flicknote-sync/src/ipc/server.rs +++ b/flicknote-sync/src/ipc/server.rs @@ -85,7 +85,7 @@ pub(crate) async fn serve_app_stream( write_response(stream, &response).await } -pub(crate) async fn write_json( +pub(crate) async fn write_json( stream: &mut UnixStream, value: &T, ) -> Result<(), DaemonError> { diff --git a/flicknote-sync/src/ipc/tests.rs b/flicknote-sync/src/ipc/tests.rs index da63fa5..7da9ffc 100644 --- a/flicknote-sync/src/ipc/tests.rs +++ b/flicknote-sync/src/ipc/tests.rs @@ -346,7 +346,11 @@ async fn malformed_transport_responses_are_classified_by_mutation_safety() { async fn unexpected_typed_responses_are_classified_by_mutation_safety() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::Values(Vec::new()))), + ) + .await; let error = DaemonClient::new(&config) .call::(AppRequest::NoteCount(NoteCountInput { keywords: Vec::new(), @@ -361,7 +365,11 @@ async fn unexpected_typed_responses_are_classified_by_mutation_safety() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let server = serve_response(&config, DaemonResponse::App(Box::new(AppResponse::Unit))).await; + let server = serve_response( + &config, + DaemonResponse::App(Box::new(AppResponse::Values(Vec::new()))), + ) + .await; let error = DaemonClient::new(&config) .call::(AppRequest::NoteArchive { id: "note-1".to_string(), diff --git a/flicknote-sync/src/lib.rs b/flicknote-sync/src/lib.rs index 486c476..67e044c 100644 --- a/flicknote-sync/src/lib.rs +++ b/flicknote-sync/src/lib.rs @@ -1,27 +1,3 @@ -use std::fmt; -use std::future::Future; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use async_trait::async_trait; -use flicknote_auth::client::GoTrueClient; -use flicknote_core::{ - REMOTE_COMMITTED_INSERT_METADATA, TOPIC_EXTRACTION_KEY, - backend::{NoteDb, SqliteBackend}, - config::Config, - db::Database, - schema::app_schema, - services::ports::{CreateNote, NoteCreator, ShareGateway, ShareResource as CoreShareResource}, -}; -use futures_lite::StreamExt; -use powersync::{ - BackendConnector, ConnectionPool, PowerSyncCredentials, PowerSyncDatabase, SyncOptions, - UpdateType, env::PowerSyncEnvironment, error::PowerSyncError, -}; -use rusqlite::{OptionalExtension, params}; -use serde::Deserialize; -use tokio::{net::UnixListener, sync::mpsc}; - pub mod app; mod connector; pub mod ipc; @@ -30,14 +6,7 @@ mod runtime; mod storage_maintenance; mod upload; -use app::Application; -use ipc::DaemonError; -pub(crate) use remote::attachment::*; -pub(crate) use remote::create::*; -pub(crate) use remote::share::*; pub use runtime::run; -pub(crate) use storage_maintenance::*; -pub(crate) use upload::*; #[cfg(test)] mod test_support; diff --git a/flicknote-sync/src/remote/attachment.rs b/flicknote-sync/src/remote/attachment.rs index b1a5ed7..715fbf6 100644 --- a/flicknote-sync/src/remote/attachment.rs +++ b/flicknote-sync/src/remote/attachment.rs @@ -1,6 +1,11 @@ -use crate::*; +use std::path::Path; -pub(crate) fn attachment_endpoint(base_url: &str, path: &str) -> String { +use flicknote_core::config::Config; +use serde::Deserialize; + +use crate::ipc::DaemonError; + +fn attachment_endpoint(base_url: &str, path: &str) -> String { let versioned_base = base_url .trim_end_matches('/') .trim_end_matches("/api/v1") @@ -11,12 +16,12 @@ pub(crate) fn attachment_endpoint(base_url: &str, path: &str) -> String { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct UploadUrlResponse { - pub(crate) upload_url: String, - pub(crate) content_type: String, +struct UploadUrlResponse { + upload_url: String, + content_type: String, } -pub(crate) fn validate_api_url(config: &Config) -> Result<(), DaemonError> { +pub(super) fn validate_api_url(config: &Config) -> Result<(), DaemonError> { if config.api_url.is_empty() { return Err(DaemonError::Other { message: "apiUrl is not configured — set it in config.json or FLICKNOTE_API_URL" @@ -26,7 +31,7 @@ pub(crate) fn validate_api_url(config: &Config) -> Result<(), DaemonError> { Ok(()) } -pub(crate) async fn upload_attachment( +pub(super) async fn upload_attachment( http: &reqwest::Client, config: &Config, access_token: &str, @@ -63,9 +68,11 @@ pub(crate) async fn upload_attachment( message: format!("Failed to parse upload URL response: {e}"), })?; - let file_bytes = std::fs::read(file_path).map_err(|e| DaemonError::Other { - message: format!("Failed to read {}: {e}", file_path.display()), - })?; + let file_bytes = tokio::fs::read(file_path) + .await + .map_err(|e| DaemonError::Other { + message: format!("Failed to read {}: {e}", file_path.display()), + })?; let put_resp = http .put(&upload_resp.upload_url) .header("Content-Type", &upload_resp.content_type) @@ -86,7 +93,7 @@ pub(crate) async fn upload_attachment( Ok(()) } -pub(crate) async fn delete_attachment( +pub(super) async fn delete_attachment( http: &reqwest::Client, config: &Config, access_token: &str, diff --git a/flicknote-sync/src/remote/create.rs b/flicknote-sync/src/remote/create.rs index 0f348a5..0b4b325 100644 --- a/flicknote-sync/src/remote/create.rs +++ b/flicknote-sync/src/remote/create.rs @@ -1,61 +1,54 @@ -use crate::*; +use std::path::Path; +use std::sync::Arc; -#[cfg(test)] -mod tests; +use async_trait::async_trait; +use flicknote_auth::client::GoTrueClient; +use flicknote_core::{ + REMOTE_COMMITTED_INSERT_METADATA, TOPIC_EXTRACTION_KEY, + backend::InsertedNote, + config::Config, + services::ports::{CreateNote, CreatedNote, NoteCreator}, +}; +use powersync::PowerSyncDatabase; +use rusqlite::{OptionalExtension, params}; +use serde::Deserialize; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CreateNoteRequest { - pub(crate) id: String, - pub(crate) note_type: String, - pub(crate) status: String, - pub(crate) title: Option, - pub(crate) content: Option, - pub(crate) metadata: Option, - pub(crate) project_id: Option, - pub(crate) now: String, - pub(crate) topics: Vec, - pub(crate) attachment_path: Option, -} +use crate::ipc::DaemonError; +use crate::remote::attachment::{delete_attachment, upload_attachment}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RemoteCreatedNote { - pub(crate) uuid: String, - pub(crate) short_id: i64, - pub(crate) confirmed_extraction_ids: Vec, -} +#[cfg(test)] +mod tests; #[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct ExtractionCreateOutcome { - pub(crate) confirmed_ids: Vec, - pub(crate) pending_ids: Vec, - pub(crate) diagnostic: Option, - pub(crate) local_commit_error: Option, +struct ExtractionCreateOutcome { + confirmed_ids: Vec, + pending_ids: Vec, + diagnostic: Option, + local_commit_error: Option, } #[derive(Debug, Clone, Deserialize)] -pub(crate) struct RemoteNoteRow { - pub(crate) id: String, - pub(crate) short_id: Option, - pub(crate) user_id: String, +struct RemoteNoteRow { + id: String, + short_id: Option, + user_id: String, #[serde(rename = "type")] - pub(crate) note_type: String, - pub(crate) status: String, - pub(crate) title: Option, - pub(crate) content: Option, - pub(crate) summary: Option, + note_type: String, + status: String, + title: Option, + content: Option, + summary: Option, #[serde(default)] - pub(crate) is_flagged: bool, - pub(crate) project_id: Option, - pub(crate) metadata: Option, - pub(crate) source: Option, - pub(crate) created_at: Option, - pub(crate) updated_at: Option, - pub(crate) deleted_at: Option, + is_flagged: bool, + project_id: Option, + metadata: Option, + source: Option, + created_at: Option, + updated_at: Option, + deleted_at: Option, } -pub(crate) fn json_column( - value: &Option, -) -> Result, DaemonError> { +fn json_column(value: &Option) -> Result, DaemonError> { value .as_ref() .map(serde_json::to_string) @@ -65,7 +58,7 @@ pub(crate) fn json_column( }) } -pub(crate) async fn commit_remote_note( +async fn commit_remote_note( db: &PowerSyncDatabase, note: &RemoteNoteRow, ) -> Result { @@ -132,15 +125,15 @@ pub(crate) async fn commit_remote_note( } #[derive(Debug, Clone, serde::Serialize, Deserialize)] -pub(crate) struct RemoteExtractionRow { - pub(crate) id: String, - pub(crate) note_id: String, - pub(crate) user_id: String, - pub(crate) key: String, - pub(crate) value: String, +struct RemoteExtractionRow { + id: String, + note_id: String, + user_id: String, + key: String, + value: String, } -pub(crate) async fn commit_remote_extractions( +async fn commit_remote_extractions( db: &PowerSyncDatabase, rows: &[RemoteExtractionRow], ) -> Result { @@ -198,13 +191,13 @@ pub(crate) async fn commit_remote_extractions( Ok(inserted) } -pub(crate) async fn create_note_remotely( +async fn create_note_remotely( db: &PowerSyncDatabase, http: &reqwest::Client, auth: &GoTrueClient, config: &Config, - req: CreateNoteRequest, -) -> Result { + req: CreateNote, +) -> Result { let session = auth.get_session().await.map_err(|e| DaemonError::Other { message: format!("Auth error: {e}"), })?; @@ -220,208 +213,261 @@ pub(crate) async fn create_note_remotely( .await } -pub(crate) async fn create_note_with_token( - db: &PowerSyncDatabase, - http: &reqwest::Client, - config: &Config, - access_token: &str, - user_id: &str, - req: CreateNoteRequest, -) -> Result { - let extraction_rows = req +enum NoteCreateAttempt { + Response(NoteCreateResponse), + Recovered(RemoteNoteRow), +} + +struct NoteCreateResponse { + response: reqwest::Response, + initial_error: Option, +} + +fn extraction_rows(request: &CreateNote, user_id: &str) -> Vec { + request .topics .iter() .map(|value| RemoteExtractionRow { id: uuid::Uuid::new_v4().to_string(), - note_id: req.id.clone(), + note_id: request.id.clone(), user_id: user_id.to_string(), key: TOPIC_EXTRACTION_KEY.to_string(), value: value.clone(), }) - .collect::>(); - let metadata = match req.metadata.as_deref() { - Some(raw) => { - serde_json::from_str::(raw).map_err(|e| DaemonError::Other { - message: format!("Invalid note metadata JSON: {e}"), - })? - } - None => serde_json::Value::Null, - }; - - let attachment_path = req.attachment_path.as_deref().map(Path::new); - if let Some(path) = attachment_path { - upload_attachment(http, config, access_token, &req.id, path).await?; - } + .collect() +} - let payload = serde_json::json!({ - "id": req.id, +fn note_payload(request: &CreateNote, user_id: &str) -> Result { + let metadata = request + .metadata + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|error| DaemonError::Other { + message: format!("Invalid note metadata JSON: {error}"), + })? + .unwrap_or(serde_json::Value::Null); + Ok(serde_json::json!({ + "id": request.id, "user_id": user_id, - "type": req.note_type, - "status": req.status, - "title": req.title, - "content": req.content, + "type": request.note_type, + "status": request.status, + "title": request.title, + "content": request.content, "metadata": metadata, - "project_id": req.project_id, - "created_at": req.now, - "updated_at": req.now, - }); - - let send_create = || { - http.post(format!( - "{}/rest/v1/notes?on_conflict=id", - config.supabase_url - )) - .header("apikey", &config.supabase_anon_key) - .bearer_auth(access_token) - .header( - "Prefer", - "resolution=ignore-duplicates,return=representation", - ) - .json(&payload) + "project_id": request.project_id, + "created_at": request.now, + "updated_at": request.now, + })) +} + +fn note_create_request( + http: &reqwest::Client, + config: &Config, + access_token: &str, + payload: &serde_json::Value, +) -> reqwest::RequestBuilder { + http.post(format!( + "{}/rest/v1/notes?on_conflict=id", + config.supabase_url + )) + .header("apikey", &config.supabase_anon_key) + .bearer_auth(access_token) + .header( + "Prefer", + "resolution=ignore-duplicates,return=representation", + ) + .json(payload) +} + +async fn recover_ambiguous_create( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + extraction_rows: &[RemoteExtractionRow], + initial_error: &str, + retry_error: &str, +) -> Result { + if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, note_id).await { + return Ok(NoteCreateAttempt::Recovered(row)); + } + Err(ambiguous_create_error( + format!( + "Remote note create outcome is unknown for note {note_id} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again." + ), + note_id.to_string(), + extraction_rows, + )) +} + +async fn send_note_create( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + extraction_rows: &[RemoteExtractionRow], + payload: &serde_json::Value, +) -> Result { + let first = note_create_request(http, config, access_token, payload) .send() - }; - let (resp, initial_ambiguous_error) = match send_create().await { - Ok(resp) if !is_ambiguous_create_status(resp.status()) => (resp, None), - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let initial_error = format!("the first attempt returned {status}: {body}"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - match send_create().await { - Ok(resp) if !is_ambiguous_create_status(resp.status()) => { - (resp, Some(initial_error)) - } - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry returned {status}: {body}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - Err(retry_error) => { - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - } + .await; + let (initial_error, initial_was_status) = match first { + Ok(response) if !is_ambiguous_create_status(response.status()) => { + return Ok(NoteCreateAttempt::Response(NoteCreateResponse { + response, + initial_error: None, + })); } - Err(initial_error) => { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - match send_create().await { - Ok(resp) => (resp, Some(initial_error.to_string())), - Err(retry_error) => { - if let Ok(Some(row)) = - lookup_remote_note(http, config, access_token, &req.id).await - { - return finish_remote_create( - db, - http, - config, - access_token, - row, - &extraction_rows, - ) - .await; - } - return Err(ambiguous_create_error( - format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID ({initial_error}; retry: {retry_error}). The attachment was retained. Do not create it again.", - req.id - ), - req.id, - &extraction_rows, - )); - } - } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + (format!("the first attempt returned {status}: {body}"), true) } + Err(error) => (error.to_string(), false), }; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, &req.id).await { - return finish_remote_create(db, http, config, access_token, row, &extraction_rows) - .await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match note_create_request(http, config, access_token, payload) + .send() + .await + { + Ok(response) if !initial_was_status || !is_ambiguous_create_status(response.status()) => { + Ok(NoteCreateAttempt::Response(NoteCreateResponse { + response, + initial_error: Some(initial_error), + })) + } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + recover_ambiguous_create( + http, + config, + access_token, + note_id, + extraction_rows, + &initial_error, + &format!("returned {status}: {body}"), + ) + .await + } + Err(error) => { + recover_ambiguous_create( + http, + config, + access_token, + note_id, + extraction_rows, + &initial_error, + &error.to_string(), + ) + .await + } + } +} + +async fn canonical_note_row( + http: &reqwest::Client, + config: &Config, + access_token: &str, + note_id: &str, + extraction_rows: &[RemoteExtractionRow], + attachment_uploaded: bool, + create_response: NoteCreateResponse, +) -> Result { + let NoteCreateResponse { + response, + initial_error, + } = create_response; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if let Ok(Some(row)) = lookup_remote_note(http, config, access_token, note_id).await { + return Ok(row); } - if let Some(initial_error) = initial_ambiguous_error { + if let Some(initial_error) = initial_error { return Err(ambiguous_create_error( format!( - "Remote note create outcome is unknown for note {} after retrying the same stable UUID: {initial_error}; the retry returned {status}: {body}. The attachment was retained. Do not create it again.", - req.id + "Remote note create outcome is unknown for note {note_id} after retrying the same stable UUID: {initial_error}; the retry returned {status}: {body}. The attachment was retained. Do not create it again." ), - req.id, - &extraction_rows, + note_id.to_string(), + extraction_rows, )); } - if attachment_path.is_some() - && let Err(e) = delete_attachment(http, config, access_token, &req.id).await + if attachment_uploaded + && let Err(error) = delete_attachment(http, config, access_token, note_id).await { - log::warn!("Failed to clean up uploaded attachment after note create failure: {e}"); + log::warn!("Failed to clean up uploaded attachment after note create failure: {error}"); } return Err(DaemonError::Other { message: format!("Remote note create failed ({status}): {body}"), }); } - let row = match resp.json::>().await { + match response.json::>().await { Ok(mut rows) => match rows.pop() { - Some(row) => row, + Some(row) => Ok(row), None => { reconcile_confirmed_remote_note( http, config, access_token, - &req.id, - &extraction_rows, - format!("Remote note create returned no row for note {}", req.id), + note_id, + extraction_rows, + format!("Remote note create returned no row for note {note_id}"), ) - .await? + .await } }, Err(error) => { reconcile_confirmed_remote_note( + http, + config, + access_token, + note_id, + extraction_rows, + format!("Failed to parse remote note create response: {error}"), + ) + .await + } + } +} + +async fn create_note_with_token( + db: &PowerSyncDatabase, + http: &reqwest::Client, + config: &Config, + access_token: &str, + user_id: &str, + req: CreateNote, +) -> Result { + let extraction_rows = extraction_rows(&req, user_id); + let payload = note_payload(&req, user_id)?; + let attachment_path = req.attachment_path.as_deref().map(Path::new); + if let Some(path) = attachment_path { + upload_attachment(http, config, access_token, &req.id, path).await?; + } + let row = match send_note_create( + http, + config, + access_token, + &req.id, + &extraction_rows, + &payload, + ) + .await? + { + NoteCreateAttempt::Recovered(row) => row, + NoteCreateAttempt::Response(response) => { + canonical_note_row( http, config, access_token, &req.id, &extraction_rows, - format!("Failed to parse remote note create response: {error}"), + attachment_path.is_some(), + response, ) .await? } @@ -429,13 +475,13 @@ pub(crate) async fn create_note_with_token( finish_remote_create(db, http, config, access_token, row, &extraction_rows).await } -pub(crate) fn is_ambiguous_create_status(status: reqwest::StatusCode) -> bool { +fn is_ambiguous_create_status(status: reqwest::StatusCode) -> bool { status.is_server_error() || status == reqwest::StatusCode::REQUEST_TIMEOUT || status == reqwest::StatusCode::TOO_MANY_REQUESTS } -pub(crate) fn confirmed_create_error( +fn confirmed_create_error( message: String, note_id: String, short_id: Option, @@ -450,7 +496,7 @@ pub(crate) fn confirmed_create_error( } } -pub(crate) fn partial_create_error( +fn partial_create_error( message: String, note_id: String, short_id: Option, @@ -466,7 +512,7 @@ pub(crate) fn partial_create_error( } } -pub(crate) fn ambiguous_create_error( +fn ambiguous_create_error( message: String, note_id: String, extraction_rows: &[RemoteExtractionRow], @@ -478,7 +524,7 @@ pub(crate) fn ambiguous_create_error( } } -pub(crate) async fn reconcile_confirmed_remote_note( +async fn reconcile_confirmed_remote_note( http: &reqwest::Client, config: &Config, access_token: &str, @@ -507,14 +553,14 @@ pub(crate) async fn reconcile_confirmed_remote_note( } } -pub(crate) async fn finish_remote_create( +async fn finish_remote_create( db: &PowerSyncDatabase, http: &reqwest::Client, config: &Config, access_token: &str, row: RemoteNoteRow, extraction_rows: &[RemoteExtractionRow], -) -> Result { +) -> Result { let short_id = match row.short_id { Some(short_id) => short_id, None => { @@ -558,14 +604,16 @@ pub(crate) async fn finish_remote_create( extraction_outcome.pending_ids, )); } - Ok(RemoteCreatedNote { - uuid: row.id, - short_id, + Ok(CreatedNote { + inserted: InsertedNote { + uuid: row.id, + short_id: Some(short_id), + }, confirmed_extraction_ids: extraction_outcome.confirmed_ids, }) } -pub(crate) async fn lookup_remote_note( +async fn lookup_remote_note( http: &reqwest::Client, config: &Config, access_token: &str, @@ -599,7 +647,7 @@ pub(crate) async fn lookup_remote_note( Ok(rows.pop()) } -pub(crate) async fn create_extractions_with_token( +async fn create_extractions_with_token( db: &PowerSyncDatabase, http: &reqwest::Client, config: &Config, @@ -696,7 +744,7 @@ pub(crate) async fn create_extractions_with_token( } } -pub(crate) async fn lookup_remote_extraction( +async fn lookup_remote_extraction( http: &reqwest::Client, config: &Config, access_token: &str, @@ -731,13 +779,29 @@ pub(crate) async fn lookup_remote_extraction( } pub(crate) struct RemoteNoteCreator { - pub(crate) db: PowerSyncDatabase, - pub(crate) auth: Arc, - pub(crate) http: reqwest::Client, - pub(crate) config: Arc, + db: PowerSyncDatabase, + auth: Arc, + http: reqwest::Client, + config: Arc, } -pub(crate) fn remote_create_service_error( +impl RemoteNoteCreator { + pub(crate) fn new( + db: PowerSyncDatabase, + auth: Arc, + http: reqwest::Client, + config: Arc, + ) -> Self { + Self { + db, + auth, + http, + config, + } + } +} + +fn remote_create_service_error( error: DaemonError, ) -> flicknote_core::services::error::ServiceError { match error { @@ -787,32 +851,8 @@ impl NoteCreator for RemoteNoteCreator { flicknote_core::services::ports::CreatedNote, flicknote_core::services::error::ServiceError, > { - let created = create_note_remotely( - &self.db, - &self.http, - &self.auth, - &self.config, - CreateNoteRequest { - id: request.id, - note_type: request.note_type, - status: request.status, - title: request.title, - content: request.content, - metadata: request.metadata, - project_id: request.project_id, - now: request.now, - topics: request.topics, - attachment_path: request.attachment_path, - }, - ) - .await - .map_err(remote_create_service_error)?; - Ok(flicknote_core::services::ports::CreatedNote { - inserted: flicknote_core::backend::InsertedNote { - uuid: created.uuid, - short_id: Some(created.short_id), - }, - confirmed_extraction_ids: created.confirmed_extraction_ids, - }) + create_note_remotely(&self.db, &self.http, &self.auth, &self.config, request) + .await + .map_err(remote_create_service_error) } } diff --git a/flicknote-sync/src/remote/create/tests.rs b/flicknote-sync/src/remote/create/tests.rs index 4505822..95e74b8 100644 --- a/flicknote-sync/src/remote/create/tests.rs +++ b/flicknote-sync/src/remote/create/tests.rs @@ -1,6 +1,26 @@ use super::*; use crate::test_support::*; +fn remote_note(id: &str, title: &str) -> RemoteNoteRow { + RemoteNoteRow { + id: id.to_string(), + short_id: Some(42), + user_id: "user-1".to_string(), + note_type: "normal".to_string(), + status: "ai_queued".to_string(), + title: Some(title.to_string()), + content: Some("Canonical body".to_string()), + summary: Some("Canonical summary".to_string()), + is_flagged: false, + project_id: Some("project-1".to_string()), + metadata: Some(serde_json::json!({"source": "remote"})), + source: Some(serde_json::json!({"kind": "plain"})), + created_at: Some("2026-08-09T00:00:00Z".to_string()), + updated_at: Some("2026-08-09T00:00:01Z".to_string()), + deleted_at: None, + } +} + #[tokio::test] async fn remote_committed_note_is_fully_visible_before_return() { let (_directory, db) = test_powersync_db().await; @@ -130,7 +150,7 @@ async fn remote_create_returns_after_canonical_note_is_committed_locally() { config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { + let request = CreateNote { id: "note-create".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -154,8 +174,8 @@ async fn remote_create_returns_after_canonical_note_is_committed_locally() { .await .unwrap(); - assert_eq!(created.uuid, "note-create"); - assert_eq!(created.short_id, 77); + assert_eq!(created.inserted.uuid, "note-create"); + assert_eq!(created.inserted.short_id, Some(77)); let reader = db.reader().await.unwrap(); let title: String = reader .query_row( @@ -192,7 +212,7 @@ async fn remote_create_reports_typed_partial_success_after_note_commit() { &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-partial".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -240,7 +260,7 @@ async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { + let request = CreateNote { id: "note-retry".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -264,7 +284,7 @@ async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { .await .unwrap(); - assert_eq!(created.short_id, 78); + assert_eq!(created.inserted.short_id, Some(78)); assert_eq!( server.join().unwrap(), [ @@ -289,7 +309,7 @@ async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-malformed".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -305,7 +325,7 @@ async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { .await .unwrap(); - assert_eq!(created.short_id, 81); + assert_eq!(created.inserted.short_id, Some(81)); assert_eq!( server.join().unwrap(), [ @@ -332,7 +352,7 @@ async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-confirmed".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -381,7 +401,7 @@ async fn local_commit_failure_after_remote_create_reports_partial_success() { &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-local-failure".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -418,7 +438,7 @@ async fn remote_create_recovers_lost_response_by_stable_uuid() { config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; - let request = CreateNoteRequest { + let request = CreateNote { id: "note-lost".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -442,7 +462,7 @@ async fn remote_create_recovers_lost_response_by_stable_uuid() { .await .unwrap(); - assert_eq!(created.short_id, 79); + assert_eq!(created.inserted.short_id, Some(79)); assert_eq!(server.join().unwrap().len(), 2); } @@ -463,7 +483,7 @@ async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-unknown".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -507,7 +527,7 @@ async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-recovered-after-retry".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -524,7 +544,7 @@ async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() let requests = server.join().unwrap(); let created = result.unwrap(); - assert_eq!(created.short_id, 83); + assert_eq!(created.inserted.short_id, Some(83)); assert_eq!(requests.len(), 2); assert!(requests[0].starts_with("POST /rest/v1/notes")); assert!(requests[1].starts_with("POST /rest/v1/notes")); @@ -548,7 +568,7 @@ async fn retryable_status_retries_create_with_the_same_stable_uuid() { &config, "access-token", "user-1", - CreateNoteRequest { + CreateNote { id: "note-retryable-status".to_string(), note_type: "normal".to_string(), status: "ai_queued".to_string(), @@ -565,7 +585,7 @@ async fn retryable_status_retries_create_with_the_same_stable_uuid() { .unwrap(); let requests = server.join().unwrap(); - assert_eq!(created.short_id, 84); + assert_eq!(created.inserted.short_id, Some(84)); assert_eq!(requests.len(), 2); assert!( requests diff --git a/flicknote-sync/src/remote/mod.rs b/flicknote-sync/src/remote/mod.rs index 864ad28..50e427b 100644 --- a/flicknote-sync/src/remote/mod.rs +++ b/flicknote-sync/src/remote/mod.rs @@ -1,3 +1,6 @@ -pub(crate) mod attachment; -pub(crate) mod create; -pub(crate) mod share; +mod attachment; +mod create; +mod share; + +pub(crate) use create::RemoteNoteCreator; +pub(crate) use share::RemoteShareGateway; diff --git a/flicknote-sync/src/remote/share.rs b/flicknote-sync/src/remote/share.rs index 3853423..2dea477 100644 --- a/flicknote-sync/src/remote/share.rs +++ b/flicknote-sync/src/remote/share.rs @@ -1,39 +1,51 @@ -use crate::*; +use std::future::Future; +use std::sync::Arc; + +use async_trait::async_trait; +use flicknote_auth::client::GoTrueClient; +use flicknote_core::{ + config::Config, + services::ports::{ShareGateway, ShareResource as CoreShareResource}, +}; +use serde::Deserialize; + +use crate::ipc::DaemonError; +use crate::remote::attachment::validate_api_url; #[cfg(test)] mod tests; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ShareResource { +enum ShareResource { Note, Project, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ShareRequest { - pub(crate) resource: ShareResource, - pub(crate) id: String, +struct ShareRequest { + resource: ShareResource, + id: String, } #[derive(Deserialize)] -pub(crate) struct ShareResponse { - pub(crate) url: String, +struct ShareResponse { + url: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ShareApiError { - pub(crate) error_code: Option, - pub(crate) message: Option, +struct ShareApiError { + error_code: Option, + message: Option, } #[derive(Default)] -pub(crate) struct ShareRequestLock { - pub(crate) mutex: tokio::sync::Mutex<()>, +struct ShareRequestLock { + mutex: tokio::sync::Mutex<()>, } impl ShareRequestLock { - pub(crate) async fn run(&self, operation: impl Future) -> T { + async fn run(&self, operation: impl Future) -> T { let _guard = self.mutex.lock().await; operation.await } @@ -55,7 +67,7 @@ impl ShareResource { } } -pub(crate) fn share_endpoint(api_url: &str, request: &ShareRequest) -> String { +fn share_endpoint(api_url: &str, request: &ShareRequest) -> String { let versioned_base = api_url .trim_end_matches('/') .trim_end_matches("/api/v1") @@ -67,7 +79,7 @@ pub(crate) fn share_endpoint(api_url: &str, request: &ShareRequest) -> String { ) } -pub(crate) fn share_api_error(status: reqwest::StatusCode, body: String) -> DaemonError { +fn share_api_error(status: reqwest::StatusCode, body: String) -> DaemonError { let message = serde_json::from_str::(&body) .ok() .and_then(|error| error.message) @@ -77,7 +89,7 @@ pub(crate) fn share_api_error(status: reqwest::StatusCode, body: String) -> Daem } } -pub(crate) async fn parse_share_url(response: reqwest::Response) -> Result { +async fn parse_share_url(response: reqwest::Response) -> Result { let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); @@ -92,7 +104,7 @@ pub(crate) async fn parse_share_url(response: reqwest::Response) -> Result, - pub(crate) config: Arc, - pub(crate) lock: Arc, + http: reqwest::Client, + auth: Arc, + config: Arc, + lock: ShareRequestLock, +} + +impl RemoteShareGateway { + pub(crate) fn new(http: reqwest::Client, auth: Arc, config: Arc) -> Self { + Self { + http, + auth, + config, + lock: ShareRequestLock::default(), + } + } } #[async_trait] diff --git a/flicknote-sync/src/runtime.rs b/flicknote-sync/src/runtime.rs index ee28071..c07fbb8 100644 --- a/flicknote-sync/src/runtime.rs +++ b/flicknote-sync/src/runtime.rs @@ -1,32 +1,48 @@ -use crate::*; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -pub(crate) fn pid_path(config: &Config) -> PathBuf { +use flicknote_auth::client::GoTrueClient; +use flicknote_core::{ + backend::{NoteDb, SqliteBackend}, + config::Config, + db::Database, + schema::app_schema, + services::ports::{NoteCreator, ShareGateway}, +}; +use powersync::{ConnectionPool, PowerSyncDatabase, SyncOptions, env::PowerSyncEnvironment}; +use tokio::{net::UnixListener, sync::mpsc}; + +use crate::app::Application; +use crate::ipc; +use crate::remote::{RemoteNoteCreator, RemoteShareGateway}; +use crate::storage_maintenance::{WalCheckpointMode, checkpoint_wal_standalone}; +use crate::upload::{FlickNoteConnector, retry_upload_until_success}; + +fn pid_path(config: &Config) -> PathBuf { PathBuf::from(&config.paths.data_dir).join("sync.pid") } -pub(crate) struct PidGuard(PathBuf); +struct PidGuard(PathBuf); impl Drop for PidGuard { fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.0) { - log::warn!("Failed to remove PID file: {}", e); + if let Err(error) = std::fs::remove_file(&self.0) { + log::warn!("Failed to remove PID file: {error}"); } } } -pub(crate) struct SocketGuard(PathBuf); +struct SocketGuard(PathBuf); impl Drop for SocketGuard { fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.0) { - log::warn!("Failed to remove socket file: {}", e); + if let Err(error) = std::fs::remove_file(&self.0) { + log::warn!("Failed to remove socket file: {error}"); } } } -pub(crate) fn bind_socket( - config: &Config, -) -> Result<(UnixListener, SocketGuard), Box> { +fn bind_socket(config: &Config) -> Result<(UnixListener, SocketGuard), Box> { let path = ipc::socket_path(config); if path.exists() { std::fs::remove_file(&path)?; @@ -45,12 +61,10 @@ pub(crate) fn bind_socket( /// Check for an existing sync daemon and write our PID file. /// -/// Note: there is a small TOCTOU window between the `kill(pid, 0)` liveness -/// check and writing the new PID file. Two daemons launched simultaneously -/// could both pass. For a CLI daemon this is acceptable; use `flock` or -/// `O_CREAT|O_EXCL` if stronger guarantees are ever needed. +/// Note: there is a small TOCTOU window between the liveness check and writing +/// the new PID file. Two daemons launched simultaneously could both pass. #[allow(unsafe_code)] -pub(crate) fn check_and_write_pid(path: &Path) -> Result> { +fn check_and_write_pid(path: &Path) -> Result> { if let Ok(contents) = std::fs::read_to_string(path) && let Ok(pid) = contents.trim().parse::() { @@ -59,260 +73,252 @@ pub(crate) fn check_and_write_pid(path: &Path) -> Result, - checkpoint_handle: &mut tokio::task::JoinHandle<()>, - socket_handle: &mut tokio::task::JoinHandle<()>, - db: &PowerSyncDatabase, - db_path: PathBuf, -) { - upload_handle.abort(); - checkpoint_handle.abort(); - socket_handle.abort(); - db.disconnect().await; - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&db_path, "shutdown", WalCheckpointMode::Truncate) - }) - .await - { - log::error!("Shutdown WAL checkpoint task panicked: {e}"); - } - log::info!("Sync daemon stopped"); +struct ActorHandles { + upload: tokio::task::JoinHandle<()>, + checkpoint: tokio::task::JoinHandle<()>, + socket: tokio::task::JoinHandle<()>, } pub async fn run() -> Result<(), Box> { let config = Arc::new(Config::load()?); - - let pid_file = pid_path(&config); - let _pid_guard = check_and_write_pid(&pid_file)?; + let _pid_guard = check_and_write_pid(&pid_path(&config))?; let (socket_listener, _socket_guard) = bind_socket(&config)?; - config.validate()?; - PowerSyncEnvironment::powersync_auto_extension()?; + let db = open_powersync_database(&config)?; + let auth = Arc::new(GoTrueClient::new( + &config.supabase_url, + &config.supabase_anon_key, + &config.paths.session_file, + )); + let upload_guard = Arc::new(tokio::sync::Mutex::new(())); + let connector = build_connector(&db, &auth, &upload_guard, &config); + + startup_checkpoint(config.paths.db_file.clone()).await; + let backend = open_local_backend(&config).await?; + log::info!("Sync daemon connecting (pid {})", std::process::id()); + let (trigger_tx, trigger_rx) = mpsc::channel::<()>(16); + let upload_worker = upload_worker(&connector, trigger_rx, config.paths.db_file.clone()); + db.connect(SyncOptions::new(connector)).await; + log::info!("Sync daemon connected (pid {})", std::process::id()); + + let mut actors = ActorHandles { + upload: tokio::spawn(upload_worker), + checkpoint: spawn_checkpoint_worker(config.paths.db_file.clone()), + socket: spawn_socket_server(socket_listener, backend, &db, &auth, &config, trigger_tx), + }; + let result = wait_for_shutdown(&mut actors).await; + shutdown_daemon(&mut actors, &db, config.paths.db_file.clone()).await; + result.map_err(Into::into) +} + +fn open_powersync_database( + config: &Config, +) -> Result> { + PowerSyncEnvironment::powersync_auto_extension()?; let pool = ConnectionPool::open(&config.paths.db_file)?; - let env = PowerSyncEnvironment::custom( + let environment = PowerSyncEnvironment::custom( reqwest::Client::new(), pool, PowerSyncEnvironment::tokio_timer(), ); - - let db = PowerSyncDatabase::new(env, app_schema()); + let db = PowerSyncDatabase::new(environment, app_schema()); db.async_tasks().spawn_with_tokio(); + Ok(db) +} - let auth = Arc::new(GoTrueClient::new( - &config.supabase_url, - &config.supabase_anon_key, - &config.paths.session_file, - )); - - let upload_guard = Arc::new(tokio::sync::Mutex::new(())); - let http_client = reqwest::Client::new(); - let upload_client = http_client.clone(); - - let connector = FlickNoteConnector { +fn build_connector( + db: &PowerSyncDatabase, + auth: &Arc, + upload_guard: &Arc>, + config: &Config, +) -> FlickNoteConnector { + FlickNoteConnector { db: db.clone(), - auth: Arc::clone(&auth), - upload_guard: Arc::clone(&upload_guard), - http_client, + auth: Arc::clone(auth), + upload_guard: Arc::clone(upload_guard), + http_client: reqwest::Client::new(), powersync_url: config.powersync_url.clone(), supabase_url: config.supabase_url.clone(), supabase_anon_key: config.supabase_anon_key.clone(), - }; + } +} - // Reclaim leftover WAL from previous sessions BEFORE connecting sync actors. - // TRUNCATE is safe here because no pool connections exist yet — db.connect() - // hasn't started the download actor. A bloated WAL inherited from a crashed - // session is reset to zero so incremental PASSIVE checkpoints start from a - // clean baseline. - // spawn_blocking keeps blocking rusqlite I/O off the async executor thread. +async fn startup_checkpoint(db_path: PathBuf) { log::info!("Running startup WAL checkpoint"); - let startup_db_path = config.paths.db_file.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&startup_db_path, "startup", WalCheckpointMode::Truncate) + if let Err(error) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&db_path, "startup", WalCheckpointMode::Truncate) }) .await { - log::error!("Startup WAL checkpoint task panicked: {e}"); + log::error!("Startup WAL checkpoint task panicked: {error}"); } +} - // Finish schema replacement through the application pool before PowerSync - // starts its download/upload actors. Replacing tracking views after connect - // races the actor-held SQLite connections and can fail with SQLITE_BUSY on - // an existing database. - let user_id = flicknote_core::session::get_user_id(&config)?; - let backend: Arc = Arc::new(SqliteBackend { - db: Database::open_local(&config).await?, +async fn open_local_backend( + config: &Config, +) -> Result, Box> { + let user_id = flicknote_core::session::get_user_id(config)?; + Ok(Arc::new(SqliteBackend { + db: Database::open_local(config).await?, user_id, - }); - - log::info!("Sync daemon connecting (pid {})", std::process::id()); - db.connect(SyncOptions::new(connector)).await; - log::info!("Sync daemon connected (pid {})", std::process::id()); - - // Application writes happen in this process. Each may-write request sends a - // best-effort trigger; the startup drain recovers committed writes whose signal - // was lost because of a crash or a full channel. - let (trigger_tx, mut trigger_rx) = mpsc::channel::<()>(16); - - let upload_db = db.clone(); - let upload_supabase_url = config.supabase_url.clone(); - let upload_anon_key = config.supabase_anon_key.clone(); - let upload_guard_clone = Arc::clone(&upload_guard); - let upload_auth_clone = Arc::clone(&auth); - let upload_db_path = config.paths.db_file.clone(); + })) +} - let mut upload_handle = tokio::spawn(async move { - // Initial upload on startup recovers committed CRUD left by a crash, - // a lost in-process signal, or a pre-upgrade CLI writer. +fn upload_worker( + connector: &FlickNoteConnector, + mut trigger_rx: mpsc::Receiver<()>, + db_path: PathBuf, +) -> impl std::future::Future + Send + 'static { + let db = connector.db.clone(); + let client = connector.http_client.clone(); + let auth = Arc::clone(&connector.auth); + let guard = Arc::clone(&connector.upload_guard); + let supabase_url = connector.supabase_url.clone(); + let anon_key = connector.supabase_anon_key.clone(); + async move { retry_upload_until_success( - &upload_db, - &upload_client, - &upload_auth_clone, - &upload_guard_clone, - &upload_supabase_url, - &upload_anon_key, + &db, + &client, + &auth, + &guard, + &supabase_url, + &anon_key, "Startup upload", - &upload_db_path, + &db_path, ) .await; - loop { - // Block until the application host reports a may-write request. - if trigger_rx.recv().await.is_none() { - break; - } - - // Trailing debounce: collapse burst writes (e.g. bulk import) into a - // single upload attempt. Fire only after 200ms of silence. - loop { - tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => break, - v = trigger_rx.recv() => { - if v.is_none() { return; } // channel closed - // more events arrived — reset the silence window - } - } - } - + while wait_for_upload_trigger(&mut trigger_rx).await { retry_upload_until_success( - &upload_db, - &upload_client, - &upload_auth_clone, - &upload_guard_clone, - &upload_supabase_url, - &upload_anon_key, + &db, + &client, + &auth, + &guard, + &supabase_url, + &anon_key, "Upload", - &upload_db_path, + &db_path, ) .await; } - }); + } +} - // Periodic PASSIVE checkpoint every 30s — independent of upload success or - // download actor state. Makes incremental progress draining the WAL without - // acquiring PENDING/EXCLUSIVE locks, so it never contends with pool writers. - let checkpoint_db_path = config.paths.db_file.clone(); - let mut checkpoint_handle = tokio::spawn(async move { +async fn wait_for_upload_trigger(trigger_rx: &mut mpsc::Receiver<()>) -> bool { + if trigger_rx.recv().await.is_none() { + return false; + } + loop { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => return true, + value = trigger_rx.recv() => { + if value.is_none() { + return false; + } + } + } + } +} + +fn spawn_checkpoint_worker(db_path: PathBuf) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - interval.tick().await; // skip the immediate first tick + interval.tick().await; loop { interval.tick().await; - let path = checkpoint_db_path.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { + let path = db_path.clone(); + if let Err(error) = tokio::task::spawn_blocking(move || { checkpoint_wal_standalone(&path, "periodic", WalCheckpointMode::Passive) }) .await { - log::error!("Periodic WAL checkpoint task panicked: {e}"); + log::error!("Periodic WAL checkpoint task panicked: {error}"); } } - }); + }) +} - let socket_config = Arc::clone(&config); - let socket_http = reqwest::Client::new(); - let socket_share_lock = Arc::new(ShareRequestLock::default()); - let creator: Arc = Arc::new(RemoteNoteCreator { - db: db.clone(), - auth: Arc::clone(&auth), - http: socket_http.clone(), - config: Arc::clone(&config), - }); - let gateway: Arc = Arc::new(RemoteShareGateway { - http: socket_http.clone(), - auth: Arc::clone(&auth), - config: Arc::clone(&config), - lock: socket_share_lock, - }); +fn spawn_socket_server( + listener: UnixListener, + backend: Arc, + db: &PowerSyncDatabase, + auth: &Arc, + config: &Arc, + trigger_tx: mpsc::Sender<()>, +) -> tokio::task::JoinHandle<()> { + let http = reqwest::Client::new(); + let creator: Arc = Arc::new(RemoteNoteCreator::new( + db.clone(), + Arc::clone(auth), + http.clone(), + Arc::clone(config), + )); + let gateway: Arc = Arc::new(RemoteShareGateway::new( + http, + Arc::clone(auth), + Arc::clone(config), + )); let app = Arc::new( Application::new(backend, creator, gateway) .with_web_url(config.web_url.clone()) .with_write_signal(trigger_tx), ); - let mut socket_handle = tokio::spawn(async move { - if let Err(error) = ipc::serve_app(socket_listener, app, ipc::ServerInfo::current()).await { + tokio::spawn(async move { + if let Err(error) = ipc::serve_app(listener, app, ipc::ServerInfo::current()).await { log::error!("Application socket server failed: {error}"); } - }); + }) +} +async fn wait_for_shutdown(actors: &mut ActorHandles) -> Result<(), String> { tokio::select! { - _ = tokio::signal::ctrl_c() => {} - res = &mut upload_handle => { - if let Err(e) = res { - log::error!("Upload task panicked: {e}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(e.into()); - } - } - res = &mut checkpoint_handle => { - match res { - Ok(_) => log::error!("Checkpoint task exited unexpectedly"), - Err(ref e) => log::error!("Checkpoint task panicked: {e}"), + _ = tokio::signal::ctrl_c() => Ok(()), + result = &mut actors.upload => result.map_err(|error| error.to_string()), + result = &mut actors.checkpoint => { + if let Err(error) = &result { + log::error!("Checkpoint task panicked: {error}"); + } else { + log::error!("Checkpoint task exited unexpectedly"); } - let err_msg = format!("Checkpoint task exited: {res:?}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(err_msg.into()); + Err(format!("Checkpoint task exited: {result:?}")) } - res = &mut socket_handle => { - match res { - Ok(_) => log::error!("Socket task exited unexpectedly"), - Err(ref e) => log::error!("Socket task panicked: {e}"), + result = &mut actors.socket => { + if let Err(error) = &result { + log::error!("Socket task panicked: {error}"); + } else { + log::error!("Socket task exited unexpectedly"); } - let err_msg = format!("Socket task exited: {res:?}"); - shutdown_daemon(&mut upload_handle, &mut checkpoint_handle, &mut socket_handle, &db, socket_config.paths.db_file.clone()).await; - return Err(err_msg.into()); + Err(format!("Socket task exited: {result:?}")) } } - shutdown_daemon( - &mut upload_handle, - &mut checkpoint_handle, - &mut socket_handle, - &db, - socket_config.paths.db_file.clone(), - ) - .await; +} - Ok(()) +async fn shutdown_daemon(actors: &mut ActorHandles, db: &PowerSyncDatabase, db_path: PathBuf) { + actors.upload.abort(); + actors.checkpoint.abort(); + actors.socket.abort(); + db.disconnect().await; + if let Err(error) = tokio::task::spawn_blocking(move || { + checkpoint_wal_standalone(&db_path, "shutdown", WalCheckpointMode::Truncate) + }) + .await + { + log::error!("Shutdown WAL checkpoint task panicked: {error}"); + } + log::info!("Sync daemon stopped"); } diff --git a/flicknote-sync/src/storage_maintenance.rs b/flicknote-sync/src/storage_maintenance.rs index efd136b..305d7b6 100644 --- a/flicknote-sync/src/storage_maintenance.rs +++ b/flicknote-sync/src/storage_maintenance.rs @@ -1,4 +1,5 @@ -use crate::*; +use std::fmt; +use std::path::Path; /// WAL checkpoint mode passed to [`checkpoint_wal_standalone`]. #[derive(Clone, Copy)] diff --git a/flicknote-sync/src/test_support.rs b/flicknote-sync/src/test_support.rs index 744d01e..0ca9dbf 100644 --- a/flicknote-sync/src/test_support.rs +++ b/flicknote-sync/src/test_support.rs @@ -1,10 +1,15 @@ use std::io::{Read, Write}; use std::net::TcpListener; +use std::path::PathBuf; use std::thread; -use flicknote_core::config::ConfigPaths; - -use crate::*; +use flicknote_core::{ + REMOTE_COMMITTED_INSERT_METADATA, + config::{Config, ConfigPaths}, + schema::app_schema, +}; +use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; +use rusqlite::params; pub(crate) async fn test_powersync_db() -> (tempfile::TempDir, PowerSyncDatabase) { PowerSyncEnvironment::powersync_auto_extension().unwrap(); @@ -57,26 +62,6 @@ pub(crate) async fn insert_marked_note(db: &PowerSyncDatabase) { insert_note_with_metadata(db, REMOTE_COMMITTED_INSERT_METADATA).await; } -pub(crate) fn remote_note(id: &str, title: &str) -> RemoteNoteRow { - RemoteNoteRow { - id: id.to_string(), - short_id: Some(42), - user_id: "user-1".to_string(), - note_type: "normal".to_string(), - status: "ai_queued".to_string(), - title: Some(title.to_string()), - content: Some("Canonical body".to_string()), - summary: Some("Canonical summary".to_string()), - is_flagged: false, - project_id: Some("project-1".to_string()), - metadata: Some(serde_json::json!({"source": "remote"})), - source: Some(serde_json::json!({"kind": "plain"})), - created_at: Some("2026-08-09T00:00:00Z".to_string()), - updated_at: Some("2026-08-09T00:00:01Z".to_string()), - deleted_at: None, - } -} - pub(crate) fn test_config(api_url: String) -> Config { Config { supabase_url: String::new(), diff --git a/flicknote-sync/src/upload.rs b/flicknote-sync/src/upload.rs index 94009ac..5f39445 100644 --- a/flicknote-sync/src/upload.rs +++ b/flicknote-sync/src/upload.rs @@ -1,4 +1,12 @@ -use crate::*; +use std::future::Future; +use std::path::Path; +use std::sync::Arc; + +use flicknote_auth::client::GoTrueClient; +use futures_lite::StreamExt; +use powersync::{CrudEntry, PowerSyncDatabase, UpdateType, error::PowerSyncError}; + +use crate::storage_maintenance::{WalCheckpointMode, checkpoint_wal_standalone}; #[cfg(test)] mod tests; @@ -10,12 +18,12 @@ pub(crate) fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { /// Postgres/PostgREST error codes that will never succeed on retry. /// Mirrors the iOS PostgresFatalCodes pattern (PowerSyncService.swift). -pub(crate) const FATAL_PG_PREFIXES: &[&str] = &[ +const FATAL_PG_PREFIXES: &[&str] = &[ "22", // Class 22 — Data Exception "23", // Class 23 — Integrity Constraint Violation (FK, unique, not-null) ]; -pub(crate) const FATAL_PG_CODES: &[&str] = &[ +const FATAL_PG_CODES: &[&str] = &[ "42501", // INSUFFICIENT PRIVILEGE (RLS violation) "42703", // undefined column "42P01", // undefined table @@ -27,7 +35,7 @@ pub(crate) const FATAL_PG_CODES: &[&str] = &[ /// Returns `Some(code)` if the error is fatal (will never succeed on retry), /// or `None` if the code is unrecognised, missing, or the body is not JSON. /// `None` does not mean the error is confirmed transient — it means unknown. -pub(crate) fn extract_fatal_code(body: &str) -> Option { +fn extract_fatal_code(body: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(body).ok().or_else(|| { log::debug!("extract_fatal_code: body is not JSON, treating as unknown: {body}"); None @@ -49,13 +57,13 @@ pub(crate) fn extract_fatal_code(body: &str) -> Option { } /// Classify an HTTP response as success, fatal (discard), or transient (retry). -pub(crate) enum UploadOutcome { +enum UploadOutcome { Success, Fatal(String), Transient(String), } -pub(crate) async fn classify_response( +async fn classify_response( resp: reqwest::Response, op: &str, table: &str, @@ -91,7 +99,7 @@ pub(crate) struct FlickNoteConnector { /// Un-wrap JSON strings that contain objects/arrays (fixes double-marshal for jsonb columns). /// PowerSync stores jsonb as text, so crud.data has them as Value::String. /// Supabase expects Value::Object for jsonb columns. -pub(crate) fn unwrap_json_strings(data: &mut serde_json::Map) { +fn unwrap_json_strings(data: &mut serde_json::Map) { for (key, value) in data.iter_mut() { if let serde_json::Value::String(s) = value { match serde_json::from_str::(s) { @@ -110,11 +118,11 @@ pub(crate) fn unwrap_json_strings(data: &mut serde_json::Map, ) -> Result, PowerSyncError> { let Some(metadata) = metadata else { @@ -141,6 +149,92 @@ pub(crate) fn parse_flicknote_crud_marker( } } +fn prepare_crud(crud: &mut CrudEntry) -> Result { + if crud.table == "keyterms" { + log::info!( + "Discarding queued CRUD for retired keyterms row {}", + crud.id + ); + return Ok(true); + } + if crud.table == "projects" + && let Some(data) = crud.data.as_mut() + { + data.remove("keyterm_id"); + } + if parse_flicknote_crud_marker(crud.metadata.as_deref())? + != Some(FlickNoteCrudMarker::RemoteCommittedInsert) + { + return Ok(false); + } + + let operation = match &crud.update_type { + UpdateType::Put => "PUT", + UpdateType::Patch => "PATCH", + UpdateType::Delete => "DELETE", + }; + let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); + if !allowed_table || !matches!(&crud.update_type, UpdateType::Put) { + return Err(ps_err(format!( + "invalid remote-committed marker on {operation} operation for table {}", + crud.table, + ))); + } + Ok(true) +} + +async fn upload_crud( + client: &reqwest::Client, + token: &str, + supabase_url: &str, + supabase_anon_key: &str, + crud: CrudEntry, +) -> Result { + let table = crud.table; + let id = crud.id; + let (operation, response) = match crud.update_type { + UpdateType::Put => { + let mut data = crud.data.unwrap_or_default(); + data.insert("id".into(), serde_json::Value::String(id.clone())); + unwrap_json_strings(&mut data); + let response = client + .post(format!("{supabase_url}/rest/v1/{table}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .header("Prefer", "resolution=merge-duplicates") + .json(&data) + .send() + .await + .map_err(|error| ps_err(format!("Upload PUT failed: {error}")))?; + ("PUT", response) + } + UpdateType::Patch => { + let mut data = crud.data.unwrap_or_default(); + unwrap_json_strings(&mut data); + let response = client + .patch(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .json(&data) + .send() + .await + .map_err(|error| ps_err(format!("Upload PATCH failed: {error}")))?; + ("PATCH", response) + } + UpdateType::Delete => { + let response = client + .delete(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) + .header("apikey", supabase_anon_key) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .map_err(|error| ps_err(format!("Upload DELETE failed: {error}")))?; + ("DELETE", response) + } + }; + Ok(classify_response(response, operation, &table, &id).await) +} + /// Inner upload logic shared by the BackendConnector and application-triggered drain. /// Caller is responsible for holding `upload_guard` before calling. /// @@ -165,88 +259,10 @@ pub(crate) async fn run_upload( let mut transient_msg: Option = None; for mut crud in std::mem::take(&mut tx.crud) { - // The backend retired the keyterm domain. Old offline databases may still - // have queued writes for the removed table or the removed project column. - // Consume those retired fields locally so they cannot block the FIFO or - // cause an otherwise valid project mutation to be discarded by PostgREST. - if crud.table == "keyterms" { - log::info!( - "Discarding queued CRUD for retired keyterms row {}", - crud.id - ); + if prepare_crud(&mut crud)? { continue; } - if crud.table == "projects" - && let Some(data) = crud.data.as_mut() - { - data.remove("keyterm_id"); - } - if parse_flicknote_crud_marker(crud.metadata.as_deref())? - == Some(FlickNoteCrudMarker::RemoteCommittedInsert) - { - let allowed_table = matches!(crud.table.as_str(), "notes" | "note_extractions"); - let is_put = matches!(&crud.update_type, UpdateType::Put); - if !allowed_table || !is_put { - let operation = match &crud.update_type { - UpdateType::Put => "PUT", - UpdateType::Patch => "PATCH", - UpdateType::Delete => "DELETE", - }; - return Err(ps_err(format!( - "invalid remote-committed marker on {operation} operation for table {}", - crud.table, - ))); - } - continue; - } - let table = &crud.table; - let id = &crud.id; - - // Single match on crud.update_type — UpdateType is not Copy, - // so we derive both op and resp in one match to avoid use-after-move. - let (op, resp) = match crud.update_type { - UpdateType::Put => { - let mut data = crud.data.unwrap_or_default(); - data.insert("id".into(), serde_json::Value::String(id.clone())); - unwrap_json_strings(&mut data); - let r = client - .post(format!("{supabase_url}/rest/v1/{table}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .header("Prefer", "resolution=merge-duplicates") - .json(&data) - .send() - .await - .map_err(|e| ps_err(format!("Upload PUT failed: {e}")))?; - ("PUT", r) - } - UpdateType::Patch => { - let mut data = crud.data.unwrap_or_default(); - unwrap_json_strings(&mut data); - let r = client - .patch(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .json(&data) - .send() - .await - .map_err(|e| ps_err(format!("Upload PATCH failed: {e}")))?; - ("PATCH", r) - } - UpdateType::Delete => { - // No payload — unwrap_json_strings not needed. - let r = client - .delete(format!("{supabase_url}/rest/v1/{table}?id=eq.{id}")) - .header("apikey", supabase_anon_key) - .header("Authorization", format!("Bearer {token}")) - .send() - .await - .map_err(|e| ps_err(format!("Upload DELETE failed: {e}")))?; - ("DELETE", r) - } - }; - - match classify_response(resp, op, table, id).await { + match upload_crud(client, token, supabase_url, supabase_anon_key, crud).await? { UploadOutcome::Success => {} UploadOutcome::Fatal(msg) => { fatal_msg = Some(msg); @@ -297,7 +313,7 @@ pub(crate) async fn run_upload( /// The checkpoint call uses `spawn_blocking` since `checkpoint_wal_standalone` /// does blocking I/O (rusqlite open). #[allow(clippy::too_many_arguments)] -pub(crate) async fn try_upload_and_checkpoint( +async fn try_upload_and_checkpoint( db: &PowerSyncDatabase, client: &reqwest::Client, auth: &GoTrueClient, @@ -337,7 +353,7 @@ pub(crate) async fn try_upload_and_checkpoint( } } -pub(crate) async fn retry_with_backoff( +async fn retry_with_backoff( mut attempt: F, initial_delay: std::time::Duration, maximum_delay: std::time::Duration, diff --git a/flicknote-sync/src/upload/tests.rs b/flicknote-sync/src/upload/tests.rs index adb6b7d..2c3d282 100644 --- a/flicknote-sync/src/upload/tests.rs +++ b/flicknote-sync/src/upload/tests.rs @@ -1,5 +1,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; +use flicknote_core::{REMOTE_COMMITTED_INSERT_METADATA, schema::app_schema}; +use rusqlite::params; + use super::*; use crate::test_support::*; @@ -96,46 +99,38 @@ async fn existing_database_upgrades_to_metadata_tracking_without_losing_rows() { ); } -#[tokio::test] -async fn existing_database_retires_keyterm_schema_without_losing_projects() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("keyterm-retirement.db"); - let mut legacy_schema = app_schema(); - let projects = legacy_schema +fn schema_with_retired_keyterms() -> powersync::schema::Schema { + let mut schema = app_schema(); + let projects = schema .tables .iter_mut() .find(|table| table.name.as_ref() == "projects") .unwrap(); - if !projects + projects .columns - .iter() - .any(|column| column.name.as_ref() == "keyterm_id") - { - projects - .columns - .push(powersync::schema::Column::text("keyterm_id")); - } - if !legacy_schema - .tables - .iter() - .any(|table| table.name.as_ref() == "keyterms") - { - legacy_schema.tables.push(powersync::schema::Table::create( - "keyterms", - vec![ - powersync::schema::Column::text("user_id"), - powersync::schema::Column::text("name"), - powersync::schema::Column::text("description"), - powersync::schema::Column::text("content"), - powersync::schema::Column::text("created_at"), - powersync::schema::Column::text("updated_at"), - ], - |_| {}, - )); - } + .push(powersync::schema::Column::text("keyterm_id")); + schema.tables.push(powersync::schema::Table::create( + "keyterms", + vec![ + powersync::schema::Column::text("user_id"), + powersync::schema::Column::text("name"), + powersync::schema::Column::text("description"), + powersync::schema::Column::text("content"), + powersync::schema::Column::text("created_at"), + powersync::schema::Column::text("updated_at"), + ], + |_| {}, + )); + schema +} + +#[tokio::test] +async fn existing_database_retires_keyterm_schema_without_losing_projects() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("keyterm-retirement.db"); { - let legacy_db = test_powersync_db_at(&path, legacy_schema); + let legacy_db = test_powersync_db_at(&path, schema_with_retired_keyterms()); let writer = legacy_db.writer().await.unwrap(); writer .execute( From 06472ccb4e4097c54376b65584cbe3b3d817b8a0 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 02:06:24 +0800 Subject: [PATCH 16/16] chore(ci): enforce workspace rust lint gates --- .github/workflows/ci.yaml | 8 ++++---- .github/workflows/pr.yaml | 8 ++++---- AGENTS.md | 9 +++++---- Cargo.toml | 6 ++++++ README.md | 2 +- justfile | 4 ++-- lefthook.yml | 6 +++--- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1f0dbc4..4cd335f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,10 +32,10 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Run tests - run: cargo test + run: cargo test --workspace --all-features - name: Build - run: cargo build + run: cargo build --workspace lint: runs-on: ubuntu-latest @@ -53,7 +53,7 @@ jobs: components: rustfmt, clippy - name: Check formatting - run: cargo fmt --check -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync + run: cargo fmt --all --check - name: Lint with clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 035b03d..0703ce1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -37,13 +37,13 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Format code - run: cargo fmt -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync + run: cargo fmt --all --check - name: Lint with clippy - run: cargo clippy -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test + run: cargo test --workspace --all-features - name: Test release workflow run: bash scripts/test-release.sh @@ -52,7 +52,7 @@ jobs: run: cargo deny check - name: Build - run: cargo build + run: cargo build --workspace osv-scan: uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.5 diff --git a/AGENTS.md b/AGENTS.md index 1914e11..05e7a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,8 @@ Rust workspace with 4 crates: ```bash cargo build # build all crates cargo test # run all tests -cargo clippy # lint -cargo fmt --check # format check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all --check # format check ``` Or use the justfile: `just build`, `just test`, `just check`, `just install` @@ -48,8 +48,8 @@ columns. This repo uses lefthook for git hooks. Install once with `lefthook install` (or `just setup`). -- **pre-commit** runs `cargo fmt --check` — validates formatting (does NOT auto-fix). If it fails, run `cargo fmt` then re-commit. -- **pre-push** runs the SQLx offline check, clippy, and cargo deny. Requires `cargo install cargo-deny`. +- **pre-commit** runs `cargo fmt --all --check` — validates formatting (does NOT auto-fix). If it fails, run `cargo fmt --all` then re-commit. +- **pre-push** runs the workspace/all-target/all-feature SQLx offline check, clippy with warnings denied, and cargo deny. Requires `cargo install cargo-deny`. Manual usage: @@ -72,6 +72,7 @@ lefthook run pre-push # run pre-push hooks - Rust 2024 edition, resolver 3 - Guard clauses over deep nesting +- Workspace Clippy keeps `too_many_lines`, `cognitive_complexity`, `large_futures`, and `future_not_send` enabled; CI denies all warnings across every target and feature - `thiserror` for error types - Config via XDG dirs (`~/.config/flicknote/`) or env vars - Data stored at `~/.local/share/flicknote/` diff --git a/Cargo.toml b/Cargo.toml index 6b06598..eb9abd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,12 @@ unsafe_code = "warn" unreachable_pub = "warn" [workspace.lints.clippy] +# Keep async and control-flow complexity visible across every crate and target. +too_many_lines = "warn" +cognitive_complexity = "warn" +large_futures = "warn" +future_not_send = "warn" + # Error swallowing prevention let_underscore_must_use = "warn" let_underscore_untyped = "warn" diff --git a/README.md b/README.md index 8091e7f..77d94da 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ flicknote delete # Manage sync daemon flicknote sync start -# Reports the running daemon version and selected backend mode +# Reports the running daemon and protocol version flicknote sync status flicknote sync stop diff --git a/justfile b/justfile index 32ad3fd..bdc7c65 100644 --- a/justfile +++ b/justfile @@ -22,11 +22,11 @@ check: fmt clippy test # Check Rust formatting. fmt: - cargo fmt -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync --check + cargo fmt --all --check # Run Clippy with warnings denied. clippy: - cargo clippy -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync --all-targets -- -D warnings + cargo clippy --workspace --all-targets --all-features -- -D warnings # Refresh SQLx offline metadata. sqlx-prepare: diff --git a/lefthook.yml b/lefthook.yml index 9e9f8ef..ee3ed3e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,7 +2,7 @@ pre-commit: parallel: true commands: cargo-fmt: - run: cargo fmt --check -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync + run: cargo fmt --all --check sqlx-metadata: run: | if git diff --cached -U0 -- ':(glob)**/*.rs' | grep -Eq '^\+.*sqlx::(query!|query_as!|query_scalar!)'; then @@ -18,8 +18,8 @@ pre-push: parallel: true commands: cargo-sqlx-offline-check: - run: SQLX_OFFLINE=true cargo check --all-targets --all-features + run: SQLX_OFFLINE=true cargo check --workspace --all-targets --all-features cargo-clippy: - run: cargo clippy -p flicknote-auth -p flicknote-cli -p flicknote-core -p flicknote-sync --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings cargo-deny: run: cargo deny check