Skip to content

feat: sync foundation - types, schema, API stubs, client state, hashing#4213

Open
devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1771944285-sync-foundation
Open

feat: sync foundation - types, schema, API stubs, client state, hashing#4213
devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1771944285-sync-foundation

Conversation

@devin-ai-integration
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot commented Feb 24, 2026

Summary

Phase 1 of sync: establishes the data model on both server and client sides without any filesystem layout changes or actual sync execution.

Server-side (api-sync crate):

  • Core sync types in types.rs: newtypes (VaultId, FileId, DeviceId, BlobHash), OpType enum, Operation struct with cursor-based seq, OperationPayload enum (inline vs blob-ref), FileEntry, and all request/response types
  • Route stubs for operations (push/pull), blobs (upload/check/download), and vaults (create/list/register_device) — all return errors for now
  • Routes wired into apps/api at /sync behind require_auth middleware
  • Postgres migration creating 5 tables: sync_vaults, sync_devices, sync_files, sync_operations (with BIGSERIAL seq + index), sync_blobs

Client-side:

  • SQLite sync state tables (sync_file_registry, sync_pending_ops, sync_cursor) added to db2 plugin via sync_init() method
  • sync_device_id helper on settings plugin — generates and persists a per-installation UUID
  • SHA-256 content hashing utility in fs-sync plugin, exposed as content_hash Tauri command

Updates since last revision

  • Fixed compilation error on linux-aarch64: libsql::Error needed to be converted through hypr_db_core::Error (which has From<libsql::Error>) before the db2 Error type could accept it via ?. Added .map_err(|e| hypr_db_core::Error::from(e)) to each conn.execute() call in sync_init().

Review & Testing Checklist for Human

  • db2::sync_init() is never called on startup: The method exists but is not invoked from any initialization path. The spec says it should run alongside init_local(). Confirm whether this should be wired in now or deferred to a later phase.
  • OperationPayload::Inline { content: Vec<u8> } serialization: Vec<u8> serializes to a JSON array of numbers (not base64). Verify this is the intended wire/storage format, or if a base64-encoded string wrapper is needed before phase 2 implementation begins.
  • Postgres migration schema review: No RLS policies (intentionally deferred). Verify FK relationships and the CHECK constraint on op_type match your intended data model. Particularly confirm sync_operations.file_id referencing sync_files.id — this means a sync_files row must exist before any operation can be inserted (even create ops).
  • TypeScript bindings not regenerated: The content_hash command was added to fs-sync but plugins/fs-sync/js/bindings.gen.ts was not regenerated. CI passed (suggesting no drift check), but the binding won't be available to the frontend until cargo test is run in plugins/fs-sync.

Notes

  • All route handlers return SyncError::Internal("not implemented") which maps to HTTP 500. The spec mentioned 501 Not Implemented — minor semantic difference.
  • No tests were added for the new types, hashing, or device ID generation.
  • hash_file() reads entire file into memory — acceptable for foundation but may need streaming for large files later.

Link to Devin run: https://app.devin.ai/sessions/5aae7f7847cd44cc922525fa97320555
Requested by: @yujonglee

- 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 <yujonglee.dev@gmail.com>
@netlify
Copy link

netlify bot commented Feb 24, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit 5e3daa5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/699dbdb25a763f0008fd5b48

@netlify
Copy link

netlify bot commented Feb 24, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit 5e3daa5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/699dbdb24a0e220008e76b1d

@devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

🐛 1 issue in files not directly in the diff

🐛 content_hash command missing from permissions default.toml (plugins/fs-sync/permissions/default.toml:27)

The content_hash command was added to the specta builder in plugins/fs-sync/src/lib.rs:55 but was not added to plugins/fs-sync/permissions/default.toml. All other commands registered in this builder have a corresponding "allow-<command>" entry in default.toml.

Root Cause and Impact

Tauri's permission system requires each command to be explicitly allowed in the plugin's default.toml (or equivalent capability config). The existing permissions file at plugins/fs-sync/permissions/default.toml:3-27 lists every other command but is missing "allow-content-hash". The autogenerated permission file (plugins/fs-sync/permissions/autogenerated/commands/content_hash.toml) also doesn't exist yet — the codegen step was not run.

Per plugins/AGENTS.md: "After updating commands in plugins/<NAME>/src/lib.rs, run codegen, update plugins/<NAME>/permissions/default.toml and apps/desktop/src-tauri/capabilities/default.json."

Impact: Any frontend call to content_hash will be rejected by Tauri's permission system at runtime, making the command completely unusable.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +35 to +83
pub async fn sync_init(&self) -> Result<(), crate::Error> {
let state = self.manager.state::<crate::ManagedState>();
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
.map_err(|e| hypr_db_core::Error::from(e))?;

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
.map_err(|e| hypr_db_core::Error::from(e))?;

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
.map_err(|e| hypr_db_core::Error::from(e))?;
}

Ok(())
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 sync_init silently succeeds when local_db is None

The sync_init method at plugins/db2/src/ext.rs:35-83 returns Ok(()) when local_db is None, silently pretending the sync tables were created when in fact nothing happened.

Root Cause and Impact

The method uses if let Some(db) = &guard.local_db { ... } and falls through to Ok(()) if the database hasn't been initialized yet. Compare with init_local() at plugins/db2/src/ext.rs:7 which always sets local_db before returning Ok. If sync_init is called before init_local (an easy ordering mistake since they're separate methods), the caller receives Ok(()) and proceeds assuming the sync_file_registry, sync_pending_ops, and sync_cursor tables exist. Any subsequent query against these tables will fail at runtime.

Impact: Silent initialization failure that would cause confusing downstream errors when sync operations try to use the non-existent tables.

Suggested change
pub async fn sync_init(&self) -> Result<(), crate::Error> {
let state = self.manager.state::<crate::ManagedState>();
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
.map_err(|e| hypr_db_core::Error::from(e))?;
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
.map_err(|e| hypr_db_core::Error::from(e))?;
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
.map_err(|e| hypr_db_core::Error::from(e))?;
}
Ok(())
}
pub async fn sync_init(&self) -> Result<(), crate::Error> {
let state = self.manager.state::<crate::ManagedState>();
let guard = state.lock().await;
let db = guard.local_db.as_ref().ok_or_else(|| {
crate::Error::from(hypr_db_core::Error::NotInitialized)
})?;
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
.map_err(|e| hypr_db_core::Error::from(e))?;
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
.map_err(|e| hypr_db_core::Error::from(e))?;
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
.map_err(|e| hypr_db_core::Error::from(e))?;
Ok(())
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +54 to +67
pub async fn sync_device_id(&self) -> crate::Result<uuid::Uuid> {
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)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 sync_device_id has TOCTOU race between load and save

The sync_device_id method at plugins/settings/src/ext.rs:54-67 performs a non-atomic check-then-act: it calls self.load() (which acquires and releases a read lock), then conditionally calls self.save() (which acquires a separate write lock).

Race Condition Details

The sequence is:

  1. load() acquires read lock, reads settings, releases lock
  2. Check if sync_device_id exists
  3. If not, generate new UUID
  4. save() acquires write lock, merges and writes

If two calls to sync_device_id execute concurrently (e.g., during app startup from multiple async tasks), both can observe the absence of sync_device_id at step 2, each generates a different UUID, and both proceed to save. The second save overwrites the first. The first caller returns UUID-A, but UUID-B is what's persisted. On the next app restart, the device will load UUID-B, causing a device identity mismatch with any server-side registration that used UUID-A.

Impact: Potential device identity inconsistency for sync. Low probability in practice since this is typically called once at startup, but the consequence (device ID mismatch) could cause sync failures that are hard to debug.

Prompt for agents
In plugins/settings/src/ext.rs, the sync_device_id method at lines 54-67 needs to perform the read-check-write atomically. The underlying State (plugins/settings/src/state.rs) has a RwLock. Consider either: (1) adding a dedicated method on State that holds the write lock for the entire check-and-generate operation, or (2) restructuring sync_device_id to use save() which already does read-merge-write under the write lock, then re-read to confirm the value. The simplest approach would be to always call save with a conditional merge that only sets sync_device_id if it doesn't already exist.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +370 to +373
pub(crate) async fn content_hash(file_path: String) -> Result<String, String> {
let path = PathBuf::from(&file_path);
spawn_blocking!({ hash::hash_file(&path).map_err(|e| e.to_string()) })
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 content_hash command accepts absolute paths without base-dir validation

The content_hash command at plugins/fs-sync/src/commands.rs:370-372 accepts a raw file_path string and passes it directly to hash::hash_file without restricting it to the vault base directory. The plugin's own plugins/fs-sync/src/AGENTS.md states: "Every path we expose through commands should be relative to app.path2().base()."

However, this is a pre-existing pattern — read_document_batch at plugins/fs-sync/src/commands.rs:119 also takes a raw dir_path without validation. The content_hash command follows this same convention. Since this is a Tauri desktop app (not a web server), the attack surface is limited to the local frontend. Still, it would be worth aligning with the documented convention by resolving the path relative to the vault base.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +169 to +171
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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 SyncConfig uses supabase_anon_key but the API typically needs service role key

At apps/api/src/main.rs:170, SyncConfig is constructed with supabase_anon_key. Looking at crates/api-sync/src/config.rs:11-17, the config stores supabase_anon_key. However, the server-side sync operations (creating vaults, managing files) will likely need the supabase_service_role_key for admin-level database operations, similar to how NangoConfig at apps/api/src/main.rs:78 uses supabase_service_role_key. This may need to be changed when the stub implementations are filled in.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant