From d503ac319987253a9150730990c9b1046f767773 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:52:55 +0000 Subject: [PATCH 1/2] feat: sync foundation - types, schema, API stubs, client state, hashing - Define core sync types (VaultId, FileId, DeviceId, BlobHash, Operation, OpType, OperationPayload, FileEntry) in api-sync/src/types.rs - Define API request/response types (PushOpsRequest, PushOpsResponse, PullOpsResponse, RejectedOp, vault/device types) - Add operation route stubs (push_ops, pull_ops) in api-sync/src/routes/ops.rs - Add blob route stubs (upload, check, download) in api-sync/src/routes/blobs.rs - Add vault route stubs (create, list, register_device) in api-sync/src/routes/vaults.rs - Write Postgres migration for sync_vaults, sync_devices, sync_files, sync_operations, sync_blobs tables - Wire api-sync into apps/api: add dependency, mount router at /sync with auth middleware - Add sync state SQLite tables (sync_file_registry, sync_pending_ops, sync_cursor) to db2 plugin - Add sync_device_id helper to settings plugin (generates and persists UUID) - Add SHA-256 content hashing utility (hash.rs) and content_hash Tauri command to fs-sync plugin Co-Authored-By: yujonglee --- Cargo.lock | 5 + apps/api/Cargo.toml | 1 + apps/api/src/main.rs | 13 ++ crates/api-sync/Cargo.toml | 3 + crates/api-sync/src/lib.rs | 1 + crates/api-sync/src/routes/blobs.rs | 27 +++ crates/api-sync/src/routes/mod.rs | 19 +- crates/api-sync/src/routes/ops.rs | 34 ++++ crates/api-sync/src/routes/vaults.rs | 36 ++++ crates/api-sync/src/types.rs | 164 ++++++++++++++++++ plugins/db2/src/ext.rs | 47 +++++ plugins/fs-sync/Cargo.toml | 1 + plugins/fs-sync/src/commands.rs | 8 + plugins/fs-sync/src/hash.rs | 13 ++ plugins/fs-sync/src/lib.rs | 2 + plugins/settings/Cargo.toml | 1 + plugins/settings/src/ext.rs | 16 ++ .../20260224000000_create_sync_tables.sql | 53 ++++++ 18 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 crates/api-sync/src/routes/blobs.rs create mode 100644 crates/api-sync/src/routes/ops.rs create mode 100644 crates/api-sync/src/routes/vaults.rs create mode 100644 crates/api-sync/src/types.rs create mode 100644 plugins/fs-sync/src/hash.rs create mode 100644 supabase/migrations/20260224000000_create_sync_tables.sql diff --git a/Cargo.lock b/Cargo.lock index 44d439b7c8..483a4d8817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,6 +479,7 @@ dependencies = [ "api-research", "api-subscription", "api-support", + "api-sync", "axum 0.8.8", "dotenvy", "envy", @@ -679,6 +680,7 @@ name = "api-sync" version = "0.1.0" dependencies = [ "axum 0.8.8", + "chrono", "reqwest 0.13.2", "sentry", "serde", @@ -688,6 +690,7 @@ dependencies = [ "tokio", "tracing", "utoipa", + "uuid", ] [[package]] @@ -17602,6 +17605,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "specta", "specta-typescript", "tauri", @@ -18198,6 +18202,7 @@ dependencies = [ "tauri-specta", "thiserror 2.0.18", "tokio", + "uuid", ] [[package]] diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 0919688aa5..614306788c 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -12,6 +12,7 @@ hypr-api-nango = { workspace = true } hypr-api-research = { workspace = true } hypr-api-subscription = { workspace = true } hypr-api-support = { workspace = true } +hypr-api-sync = { workspace = true } hypr-llm-proxy = { workspace = true } hypr-transcribe-proxy = { workspace = true } owhisper-client = { workspace = true } diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 1ab6f3e375..a0d89fdcc2 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -166,6 +166,18 @@ async fn app() -> Router { auth::optional_auth, )); + let sync_config = + hypr_api_sync::SyncConfig::new(&env.supabase.supabase_url, &env.supabase.supabase_anon_key); + let sync_state = hypr_api_sync::AppState::new(sync_config); + let auth_state_sync = AuthState::new(&env.supabase.supabase_url); + let sync_routes = Router::new() + .nest("/sync", hypr_api_sync::router(sync_state)) + .route_layer(middleware::from_fn(auth::sentry_and_analytics)) + .route_layer(middleware::from_fn_with_state( + auth_state_sync, + auth::require_auth, + )); + Router::new() .route("/health", axum::routing::get(version)) .route("/openapi.json", axum::routing::get(openapi_json)) @@ -174,6 +186,7 @@ async fn app() -> Router { .merge(pro_routes) .merge(integration_routes) .merge(auth_routes) + .merge(sync_routes) .layer( CorsLayer::new() .allow_origin(cors::Any) diff --git a/crates/api-sync/Cargo.toml b/crates/api-sync/Cargo.toml index 921bbf1155..083334a48b 100644 --- a/crates/api-sync/Cargo.toml +++ b/crates/api-sync/Cargo.toml @@ -17,3 +17,6 @@ tracing = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } + +chrono = { workspace = true, features = ["serde"] } +uuid = { workspace = true, features = ["serde", "v4"] } diff --git a/crates/api-sync/src/lib.rs b/crates/api-sync/src/lib.rs index bfe4393fcc..61005d2b9e 100644 --- a/crates/api-sync/src/lib.rs +++ b/crates/api-sync/src/lib.rs @@ -2,6 +2,7 @@ mod config; mod error; mod routes; mod state; +pub mod types; pub use config::SyncConfig; pub use error::{Result, SyncError}; diff --git a/crates/api-sync/src/routes/blobs.rs b/crates/api-sync/src/routes/blobs.rs new file mode 100644 index 0000000000..f3038e9226 --- /dev/null +++ b/crates/api-sync/src/routes/blobs.rs @@ -0,0 +1,27 @@ +use axum::body::Bytes; +use axum::extract::Path; +use axum::http::StatusCode; +use uuid::Uuid; + +use crate::error::Result; + +/// POST /vaults/:vault_id/blobs -- upload blob +pub async fn upload_blob(Path(_vault_id): Path, _body: Bytes) -> Result { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} + +/// HEAD /vaults/:vault_id/blobs/:hash -- check blob existence +pub async fn check_blob(Path((_vault_id, _hash)): Path<(Uuid, String)>) -> Result { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} + +/// GET /vaults/:vault_id/blobs/:hash -- download blob +pub async fn download_blob(Path((_vault_id, _hash)): Path<(Uuid, String)>) -> Result { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} diff --git a/crates/api-sync/src/routes/mod.rs b/crates/api-sync/src/routes/mod.rs index 26447a54f2..2984cfb801 100644 --- a/crates/api-sync/src/routes/mod.rs +++ b/crates/api-sync/src/routes/mod.rs @@ -1,4 +1,9 @@ +mod blobs; +mod ops; +mod vaults; + use axum::Router; +use axum::routing::{get, head, post}; use utoipa::OpenApi; use crate::state::AppState; @@ -16,5 +21,17 @@ pub fn openapi() -> utoipa::openapi::OpenApi { } pub fn router(state: AppState) -> Router { - Router::new().with_state(state) + let vault_routes = Router::new() + .route("/", post(vaults::create_vault)) + .route("/", get(vaults::list_vaults)) + .route("/{vault_id}/devices", post(vaults::register_device)) + .route("/{vault_id}/ops", post(ops::push_ops)) + .route("/{vault_id}/ops", get(ops::pull_ops)) + .route("/{vault_id}/blobs", post(blobs::upload_blob)) + .route("/{vault_id}/blobs/{hash}", head(blobs::check_blob)) + .route("/{vault_id}/blobs/{hash}", get(blobs::download_blob)); + + Router::new() + .nest("/vaults", vault_routes) + .with_state(state) } diff --git a/crates/api-sync/src/routes/ops.rs b/crates/api-sync/src/routes/ops.rs new file mode 100644 index 0000000000..c09895b66d --- /dev/null +++ b/crates/api-sync/src/routes/ops.rs @@ -0,0 +1,34 @@ +use axum::Json; +use axum::extract::{Path, Query}; +use axum::http::StatusCode; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::Result; +use crate::types::{PullOpsResponse, PushOpsRequest, PushOpsResponse}; + +#[derive(Deserialize)] +pub struct PullOpsQuery { + pub cursor: Option, + pub limit: Option, +} + +/// POST /vaults/:vault_id/ops -- push operations +pub async fn push_ops( + Path(_vault_id): Path, + Json(_body): Json, +) -> Result<(StatusCode, Json)> { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} + +/// GET /vaults/:vault_id/ops?cursor=N&limit=100 -- pull operations +pub async fn pull_ops( + Path(_vault_id): Path, + Query(_query): Query, +) -> Result> { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} diff --git a/crates/api-sync/src/routes/vaults.rs b/crates/api-sync/src/routes/vaults.rs new file mode 100644 index 0000000000..df576b526b --- /dev/null +++ b/crates/api-sync/src/routes/vaults.rs @@ -0,0 +1,36 @@ +use axum::Json; +use axum::extract::Path; +use axum::http::StatusCode; +use uuid::Uuid; + +use crate::error::Result; +use crate::types::{ + CreateVaultRequest, CreateVaultResponse, ListVaultsResponse, RegisterDeviceRequest, + RegisterDeviceResponse, +}; + +/// POST /vaults -- create vault +pub async fn create_vault( + Json(_body): Json, +) -> Result<(StatusCode, Json)> { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} + +/// GET /vaults -- list user's vaults +pub async fn list_vaults() -> Result> { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} + +/// POST /vaults/:vault_id/devices -- register device +pub async fn register_device( + Path(_vault_id): Path, + Json(_body): Json, +) -> Result<(StatusCode, Json)> { + Err(crate::error::SyncError::Internal( + "not implemented".to_string(), + )) +} diff --git a/crates/api-sync/src/types.rs b/crates/api-sync/src/types.rs new file mode 100644 index 0000000000..eed593f4f3 --- /dev/null +++ b/crates/api-sync/src/types.rs @@ -0,0 +1,164 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// --- Newtypes --- + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VaultId(pub Uuid); + +impl fmt::Display for VaultId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FileId(pub Uuid); + +impl fmt::Display for FileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DeviceId(pub Uuid); + +impl fmt::Display for DeviceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BlobHash(pub String); + +impl fmt::Display for BlobHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// --- OpType --- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OpType { + Create, + Modify, + Move, + Delete, +} + +// --- OperationPayload --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OperationPayload { + /// Small content (< 256KB) -- stored inline in Postgres + Inline { content: Vec }, + /// Large content -- stored in S3, referenced by hash + BlobRef { hash: BlobHash, size_bytes: u64 }, + /// Move operation -- new path + MoveTo { new_path: String }, + /// Delete -- tombstone, no content + Tombstone, +} + +// --- Operation --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Operation { + pub id: Uuid, + pub vault_id: VaultId, + pub file_id: FileId, + pub author_user_id: Uuid, + pub author_device_id: DeviceId, + pub base_version: i64, + pub op_type: OpType, + pub payload: OperationPayload, + pub created_at: DateTime, + /// Monotonic ordering for cursor-based pull + pub seq: i64, +} + +// --- FileEntry --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub id: FileId, + pub vault_id: VaultId, + pub path: String, + pub version: i64, + pub content_hash: Option, + pub is_deleted: bool, +} + +// --- Request/Response types --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushOperation { + pub file_id: FileId, + pub base_version: i64, + pub op_type: OpType, + pub payload: OperationPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushOpsRequest { + pub ops: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushOpsResponse { + pub accepted: Vec, + pub rejected: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RejectedOp { + pub file_id: FileId, + pub reason: String, + pub current_version: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullOpsResponse { + pub ops: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateVaultRequest { + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateVaultResponse { + pub vault_id: VaultId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultInfo { + pub id: VaultId, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListVaultsResponse { + pub vaults: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterDeviceRequest { + pub device_id: DeviceId, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterDeviceResponse { + pub device_id: DeviceId, +} diff --git a/plugins/db2/src/ext.rs b/plugins/db2/src/ext.rs index 12b1bbf562..8e5d129489 100644 --- a/plugins/db2/src/ext.rs +++ b/plugins/db2/src/ext.rs @@ -32,6 +32,53 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Database2<'a, R, M> { Ok(()) } + pub async fn sync_init(&self) -> Result<(), crate::Error> { + let state = self.manager.state::(); + let guard = state.lock().await; + + if let Some(db) = &guard.local_db { + let conn = db.conn()?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS sync_file_registry ( + file_id TEXT PRIMARY KEY, + vault_path TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + last_synced_at TEXT, + UNIQUE(vault_path) + )", + (), + ) + .await?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS sync_pending_ops ( + op_id TEXT PRIMARY KEY, + file_id TEXT NOT NULL, + op_type TEXT NOT NULL, + payload_json TEXT, + base_version INTEGER NOT NULL, + created_at TEXT NOT NULL + )", + (), + ) + .await?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS sync_cursor ( + vault_id TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL DEFAULT 0, + last_synced_at TEXT + )", + (), + ) + .await?; + } + + Ok(()) + } + pub async fn init_cloud(&self, connection_str: &str) -> Result<(), crate::Error> { let (client, connection) = tokio_postgres::connect(connection_str, tokio_postgres::NoTls).await?; diff --git a/plugins/fs-sync/Cargo.toml b/plugins/fs-sync/Cargo.toml index e3a48f7319..57774020e5 100644 --- a/plugins/fs-sync/Cargo.toml +++ b/plugins/fs-sync/Cargo.toml @@ -40,6 +40,7 @@ rayon = { workspace = true } rodio = { workspace = true, features = ["symphonia-all"] } chrono = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/plugins/fs-sync/src/commands.rs b/plugins/fs-sync/src/commands.rs index 8171115575..8f98aa09bb 100644 --- a/plugins/fs-sync/src/commands.rs +++ b/plugins/fs-sync/src/commands.rs @@ -9,6 +9,7 @@ use tauri_plugin_settings::SettingsPluginExt; use crate::FsSyncPluginExt; use crate::frontmatter::ParsedDocument; +use crate::hash; use crate::session::find_session_dir; use crate::session_content::load_session_content as load_session_content_from_fs; use crate::types::{CleanupTarget, ListFoldersResult, ScanResult, SessionContentData}; @@ -363,3 +364,10 @@ pub(crate) async fn attachment_remove( .map_err(|e| e.to_string()) }) } + +#[tauri::command] +#[specta::specta] +pub(crate) async fn content_hash(file_path: String) -> Result { + let path = PathBuf::from(&file_path); + spawn_blocking!({ hash::hash_file(&path).map_err(|e| e.to_string()) }) +} diff --git a/plugins/fs-sync/src/hash.rs b/plugins/fs-sync/src/hash.rs new file mode 100644 index 0000000000..2d87813cec --- /dev/null +++ b/plugins/fs-sync/src/hash.rs @@ -0,0 +1,13 @@ +use sha2::{Digest, Sha256}; +use std::path::Path; + +pub fn hash_file(path: &Path) -> std::io::Result { + let content = std::fs::read(path)?; + let digest = Sha256::digest(&content); + Ok(format!("sha256:{:x}", digest)) +} + +pub fn hash_bytes(data: &[u8]) -> String { + let digest = Sha256::digest(data); + format!("sha256:{:x}", digest) +} diff --git a/plugins/fs-sync/src/lib.rs b/plugins/fs-sync/src/lib.rs index ac4bfc2b8a..01d163aaa5 100644 --- a/plugins/fs-sync/src/lib.rs +++ b/plugins/fs-sync/src/lib.rs @@ -8,6 +8,7 @@ mod error; mod ext; mod folder; mod frontmatter; +pub mod hash; mod json; mod path; mod scan; @@ -51,6 +52,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::attachment_save::, commands::attachment_list::, commands::attachment_remove::, + commands::content_hash, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } diff --git a/plugins/settings/Cargo.toml b/plugins/settings/Cargo.toml index b19f0c50aa..cd356e832d 100644 --- a/plugins/settings/Cargo.toml +++ b/plugins/settings/Cargo.toml @@ -27,3 +27,4 @@ specta = { workspace = true, features = ["derive", "serde_json"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } +uuid = { workspace = true, features = ["v4"] } diff --git a/plugins/settings/src/ext.rs b/plugins/settings/src/ext.rs index 2cd5781622..e0e6e8de46 100644 --- a/plugins/settings/src/ext.rs +++ b/plugins/settings/src/ext.rs @@ -50,6 +50,22 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Settings<'a, R, M> { state.load().await } + /// Returns the sync device ID, generating and persisting one if it doesn't exist yet. + pub async fn sync_device_id(&self) -> crate::Result { + let settings = self.load().await?; + + if let Some(id_str) = settings.get("sync_device_id").and_then(|v| v.as_str()) { + if let Ok(id) = uuid::Uuid::parse_str(id_str) { + return Ok(id); + } + } + + let id = uuid::Uuid::new_v4(); + self.save(serde_json::json!({ "sync_device_id": id.to_string() })) + .await?; + Ok(id) + } + pub async fn save(&self, settings: serde_json::Value) -> crate::Result<()> { let state = self.manager.state::(); state.save(settings).await diff --git a/supabase/migrations/20260224000000_create_sync_tables.sql b/supabase/migrations/20260224000000_create_sync_tables.sql new file mode 100644 index 0000000000..3a61147596 --- /dev/null +++ b/supabase/migrations/20260224000000_create_sync_tables.sql @@ -0,0 +1,53 @@ +-- sync_vaults: one per user (or team, later) +CREATE TABLE sync_vaults ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- sync_devices: registered devices per vault +CREATE TABLE sync_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_id UUID NOT NULL REFERENCES sync_vaults(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id), + name TEXT NOT NULL, + registered_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- sync_files: file registry (server-side state) +CREATE TABLE sync_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_id UUID NOT NULL REFERENCES sync_vaults(id) ON DELETE CASCADE, + path TEXT NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + content_hash TEXT, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (vault_id, path) +); + +-- sync_operations: append-only operation log +CREATE TABLE sync_operations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_id UUID NOT NULL REFERENCES sync_vaults(id) ON DELETE CASCADE, + file_id UUID NOT NULL REFERENCES sync_files(id), + author_user_id UUID NOT NULL REFERENCES auth.users(id), + author_device_id UUID NOT NULL REFERENCES sync_devices(id), + base_version BIGINT NOT NULL, + op_type TEXT NOT NULL CHECK (op_type IN ('create', 'modify', 'move', 'delete')), + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + seq BIGSERIAL NOT NULL +); +CREATE INDEX idx_sync_operations_vault_seq ON sync_operations (vault_id, seq); + +-- sync_blobs: metadata for S3-stored blobs +CREATE TABLE sync_blobs ( + hash TEXT NOT NULL, + vault_id UUID NOT NULL REFERENCES sync_vaults(id) ON DELETE CASCADE, + size_bytes BIGINT NOT NULL, + storage_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (vault_id, hash) +); From 5e3daa5b1e5e68cd43181324133801de6e4f5b3e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:03:09 +0000 Subject: [PATCH 2/2] fix: convert libsql::Error through hypr_db_core::Error in db2 sync_init Co-Authored-By: yujonglee --- plugins/db2/src/ext.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/db2/src/ext.rs b/plugins/db2/src/ext.rs index 8e5d129489..7face03780 100644 --- a/plugins/db2/src/ext.rs +++ b/plugins/db2/src/ext.rs @@ -50,7 +50,8 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Database2<'a, R, M> { )", (), ) - .await?; + .await + .map_err(|e| hypr_db_core::Error::from(e))?; conn.execute( "CREATE TABLE IF NOT EXISTS sync_pending_ops ( @@ -63,7 +64,8 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Database2<'a, R, M> { )", (), ) - .await?; + .await + .map_err(|e| hypr_db_core::Error::from(e))?; conn.execute( "CREATE TABLE IF NOT EXISTS sync_cursor ( @@ -73,7 +75,8 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Database2<'a, R, M> { )", (), ) - .await?; + .await + .map_err(|e| hypr_db_core::Error::from(e))?; } Ok(())