# SyncKit Client SDK -- Integration Patterns How GoingsOn, Balanced Breakfast, and audiofiles consume the SyncKit client SDK. Use this guide to add sync to a new app. ## Common Architecture All three apps follow the same pattern: ``` App (SQLite) ├── Syncable tables with INSERT/UPDATE/DELETE triggers ├── sync_changelog table (captures local mutations) ├── sync_state table (key-value: device_id, cursor, flags) ├── Sync service module (push/pull logic) └── Scheduler (background timer, exponential backoff) ``` ### Trigger-Based Changelog Each syncable table has SQL triggers that write to `sync_changelog`: - Triggers fire on INSERT, UPDATE, DELETE - When `applying_remote = '1'` in sync_state, triggers are suppressed (prevents echo-back) - Only whitelisted columns are included in the JSON payload - Row IDs are UUIDs (GO, BB) or content hashes (AF) ### Push/Pull Cycle 1. **Push**: Read unpushed changelog entries (batch 500) → encrypt via SDK → send to MNW → mark as pushed 2. **Pull**: Fetch remote changes from MNW → decrypt via SDK → apply locally with triggers suppressed → update cursor 3. **Cleanup**: Delete pushed entries older than 7 days ### FK-Safe Ordering Both push and pull use foreign-key-safe ordering: - **Upserts**: Parents first (projects before tasks, VFS before nodes) - **Deletes**: Children first (tasks before projects, nodes before VFS) --- ## Per-App Integration ### GoingsOn (13 tables) **Tables synced:** ``` projects → milestones, tasks, events tasks → annotations, subtasks contacts → contact_emails, contact_phones, contact_social_handles, contact_custom_fields email_accounts (16 config columns only — credentials excluded) ``` **Special handling:** - Email account passwords and OAuth tokens are excluded from the column whitelist — never leave the device - Tasks with `source_email_id` referencing unsynced emails: FK enforcement relaxed during remote apply **Location:** `src-tauri/src/sync_service.rs` (1814 lines, 43 tests) ### Balanced Breakfast (5 tables) **Tables synced:** ``` feeds → feed_tags, query_feeds user_config feed_items (partial: is_read + is_starred only) ``` **Special handling:** - `feed_items`: Uses UPDATE (not INSERT OR REPLACE) — only syncs user read/star state, never full content - `feed_items` deletes are ignored — content re-fetches from source feeds - Changelog retention cap: MAX_CHANGELOG_ENTRIES = 10,000 (prevents unbounded growth) **Location:** `src-tauri/src/sync_service.rs` (1062 lines, 30 tests) ### audiofiles (9 tables) **Tables synced:** ``` vfs → vfs_nodes samples → audio_analysis, tags, collection_members collections → collection_members smart_folders user_config ``` **Special handling — blob sync:** 1. VFS entries have a `sync_files` flag controlling blob sync 2. Samples marked `cloud_only = 1` if blob doesn't exist locally 3. After push/pull, upload pending blobs (local files in sync-enabled VFS) 4. Download missing blobs (cloud_only samples where file is needed) **Location:** `crates/audiofiles-sync/src/service.rs` (1438 lines, 48 tests) --- ## SDK Public API ### Authentication ```rust use synckit_client::SyncKitClient; // Create client let client = SyncKitClient::new(SyncKitConfig { server_url: "https://makenot.work".into(), api_key: "your-app-api-key".into(), }); // OAuth2 PKCE flow let auth_url = client.build_authorize_url(port, state, code_challenge); // ... user completes browser flow ... let (user_id, app_id) = client.authenticate_with_code(code, code_verifier, port, sdk_key).await?; // Session management client.is_token_expired() -> bool client.session_info() -> Option client.clear_session() -> Result<()> ``` ### Encryption Setup ```rust // First device: generate new master key client.setup_encryption_new(password).await?; // Subsequent devices: decrypt existing key from server client.setup_encryption_existing(password).await?; // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager) client.try_load_key_from_keychain().await? -> bool // Check if server has encrypted key (determines new vs existing flow) client.has_server_key().await? -> bool client.has_master_key() -> bool ``` ### Device Management ```rust client.register_device(hostname, platform).await? -> Device client.list_devices().await? -> Vec ``` ### Push/Pull ```rust use synckit_client::{ChangeEntry, ChangeOp}; // Push encrypted changes to server client.push(device_id, changes: Vec).await?; // Pull decrypted changes from server let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?; ``` `ChangeEntry` fields: - `table`: Table name (string) - `op`: `ChangeOp::Insert`, `Update`, or `Delete` - `row_id`: Primary key (string) - `timestamp`: When the change was made - `data`: `Option` (None for deletes) ### Blob Operations (audiofiles only) ```rust // Upload let resp = client.blob_upload_url(hash, size).await?; client.blob_upload(resp.url, data).await?; client.blob_confirm(hash, size).await?; // Download let url = client.blob_download_url(hash).await?; let data = client.blob_download(url).await?; ``` --- ## First-Run Sequence ### First Device 1. User clicks "Connect to Sync" 2. App builds auth URL with PKCE challenge → opens browser 3. User logs in on MNW, approves scopes 4. Browser redirects to `localhost:PORT` with authorization code 5. App exchanges code for JWT via SDK 6. App detects no server key → shows "Set Encryption Password" dialog 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key 8. App registers device → stores device_id in sync_state 9. App creates initial snapshot (INSERT all existing rows to changelog) 10. First sync cycle runs ### Same Device, Later Run 1. App calls `try_load_key_from_keychain()` → restores session + key 2. If success, ready to sync 3. If keychain empty, re-run OAuth flow ### Additional Device 1. OAuth flow as above 2. App detects server has key → shows "Enter Encryption Password" dialog 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain 4. Register new device, create initial snapshot, sync --- ## Adding Sync to a New App ### 1. Database Schema Add these tables: ```sql CREATE TABLE sync_changelog ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' row_id TEXT NOT NULL, timestamp INTEGER NOT NULL, data TEXT, -- JSON, NULL for DELETE pushed INTEGER DEFAULT 0 ); CREATE TABLE sync_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); -- Seed defaults INSERT INTO sync_state VALUES ('pull_cursor', '0'); INSERT INTO sync_state VALUES ('applying_remote', '0'); INSERT INTO sync_state VALUES ('initial_snapshot_done', '0'); INSERT INTO sync_state VALUES ('auto_sync_enabled', '1'); INSERT INTO sync_state VALUES ('sync_interval_minutes', '15'); ``` ### 2. Triggers For each syncable table, add three triggers: ```sql CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table BEGIN SELECT CASE WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data) VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'), json_object('col1', NEW.col1, 'col2', NEW.col2))) END; END; -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL) ``` ### 3. Core Module Implement these functions: | Function | Purpose | |----------|---------| | `get_sync_state(key)` | Read from sync_state | | `set_sync_state(key, value)` | Write to sync_state | | `ensure_device_registered(client)` | Cache device_id after first registration | | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog | | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed | | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor | | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order | | `apply_delete(table, row_id)` | DELETE with FK order | | `cleanup_changelog()` | Prune pushed entries older than 7 days | ### 4. Scheduler - Check every 60 seconds if sync is due - On first run: `create_initial_snapshot()` if needed - Exponential backoff on failure (2^N minutes, capped at 15) - Emit status events for UI updates ### 5. Commands / UI Expose these to the frontend: - `sync_status()` — configured, authenticated, encryption, device, pending changes - `sync_start_auth()` — returns auth URL + state + verifier - `sync_complete_auth(code, state, verifier)` — exchanges for JWT - `sync_setup_encryption_new/existing(password)` — calls SDK method - `sync_now()` — triggers immediate cycle - `sync_disconnect()` — clears credentials and session --- ## Key Files | What | Where | |------|-------| | SDK source | `MNW/shared/synckit-client/src/` | | SDK auth | `MNW/shared/synckit-client/src/client/auth.rs` | | SDK push/pull | `MNW/shared/synckit-client/src/client/sync.rs` | | SDK encryption | `MNW/shared/synckit-client/src/crypto.rs` | | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` | | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` | | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` | | Server endpoints | `MNW/server/src/routes/synckit.rs` | | Server DB | `MNW/server/src/db/synckit.rs` |