experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(mobile): add device ID and _sync metadata to mobile backend

Generate raw UUID device IDs (no prefix) on mobile, stored in settings
table. Inject _sync metadata (createdBy/createdAt/modifiedBy/modifiedAt)
on all save and update commands. Sync pull preserves server metadata
as-is via separate codepath. Also update desktop device.ts to generate
raw UUIDs and add shared SyncMetadata type.

+209 -40
+2 -2
backend/electron/device.ts
··· 42 42 // Table might not exist yet, will be created by datastore 43 43 } 44 44 45 - // Generate new device ID 46 - deviceId = `desktop-${crypto.randomUUID()}`; 45 + // Generate new device ID (raw UUID, no prefix) 46 + deviceId = crypto.randomUUID(); 47 47 DEBUG && console.log('[device] Generated new device ID:', deviceId); 48 48 49 49 // Save to database
+151 -33
backend/tauri-mobile/src-tauri/src/lib.rs
··· 5 5 use std::os::raw::c_char; 6 6 use std::path::PathBuf; 7 7 use std::fs; 8 - use std::sync::RwLock; 8 + use std::sync::{OnceLock, RwLock}; 9 9 use reqwest; 10 10 use regex::Regex; 11 11 use tauri::Manager; ··· 1066 1066 use std::sync::Once; 1067 1067 1068 1068 static DB_INIT: Once = Once::new(); 1069 + static DEVICE_ID: OnceLock<String> = OnceLock::new(); 1069 1070 1070 1071 /// Schema version for tracking compatibility between Rust main app and Swift Share Extension. 1071 1072 /// Increment this when making schema changes that both codepaths must understand. ··· 1759 1760 } 1760 1761 } 1761 1762 1763 + // Initialize device ID (generate and store if missing) 1764 + let device_id: String = conn 1765 + .query_row("SELECT value FROM settings WHERE key = 'device_id'", [], |row| row.get(0)) 1766 + .unwrap_or_default(); 1767 + 1768 + if device_id.is_empty() { 1769 + let new_id = uuid::Uuid::new_v4().to_string(); 1770 + let _ = conn.execute( 1771 + "INSERT OR REPLACE INTO settings (key, value) VALUES ('device_id', ?1)", 1772 + params![&new_id], 1773 + ); 1774 + println!("[Rust] Generated new device ID: {}", new_id); 1775 + let _ = DEVICE_ID.set(new_id); 1776 + } else { 1777 + println!("[Rust] Loaded existing device ID: {}", device_id); 1778 + let _ = DEVICE_ID.set(device_id); 1779 + } 1780 + 1762 1781 println!("[Rust] Database initialized successfully (schema version {})", SCHEMA_VERSION); 1763 1782 }); 1764 1783 1765 1784 init_result 1766 1785 } 1767 1786 1787 + /// Get the device ID (raw UUID, no prefix). Cached in OnceLock after first DB read. 1788 + fn get_device_id() -> &'static str { 1789 + DEVICE_ID.get().map(|s| s.as_str()).unwrap_or("unknown") 1790 + } 1791 + 1792 + /// Inject _sync metadata for a new item (save/create). 1793 + /// Sets createdBy, createdAt, modifiedBy, modifiedAt. 1794 + fn inject_sync_metadata_for_save(metadata: Option<serde_json::Value>) -> serde_json::Value { 1795 + let mut meta = match metadata { 1796 + Some(v) if v.is_object() => v, 1797 + _ => serde_json::json!({}), 1798 + }; 1799 + let device_id = get_device_id(); 1800 + let timestamp = chrono::Utc::now().timestamp_millis(); 1801 + meta.as_object_mut().unwrap().insert("_sync".to_string(), serde_json::json!({ 1802 + "createdBy": device_id, 1803 + "createdAt": timestamp, 1804 + "modifiedBy": device_id, 1805 + "modifiedAt": timestamp, 1806 + })); 1807 + meta 1808 + } 1809 + 1810 + /// Inject _sync metadata for an update to an existing item. 1811 + /// Preserves createdBy/createdAt, updates modifiedBy/modifiedAt. 1812 + fn inject_sync_metadata_for_update(existing_metadata_str: Option<String>) -> String { 1813 + let mut meta: serde_json::Value = existing_metadata_str 1814 + .as_deref() 1815 + .and_then(|s| serde_json::from_str(s).ok()) 1816 + .unwrap_or_else(|| serde_json::json!({})); 1817 + 1818 + let device_id = get_device_id(); 1819 + let timestamp = chrono::Utc::now().timestamp_millis(); 1820 + 1821 + let obj = meta.as_object_mut().unwrap(); 1822 + 1823 + let existing_sync = obj.get("_sync").cloned(); 1824 + let mut sync = existing_sync 1825 + .and_then(|v| v.as_object().cloned()) 1826 + .unwrap_or_default(); 1827 + 1828 + // Backfill createdBy if missing (items predating this feature) 1829 + if !sync.contains_key("createdBy") { 1830 + sync.insert("createdBy".to_string(), serde_json::json!(device_id)); 1831 + sync.insert("createdAt".to_string(), serde_json::json!(timestamp)); 1832 + } 1833 + 1834 + sync.insert("modifiedBy".to_string(), serde_json::json!(device_id)); 1835 + sync.insert("modifiedAt".to_string(), serde_json::json!(timestamp)); 1836 + 1837 + obj.insert("_sync".to_string(), serde_json::Value::Object(sync)); 1838 + 1839 + serde_json::to_string(&meta).unwrap_or_default() 1840 + } 1841 + 1842 + #[tauri::command] 1843 + async fn get_device_id_command() -> Result<String, String> { 1844 + ensure_database_initialized()?; 1845 + Ok(get_device_id().to_string()) 1846 + } 1847 + 1768 1848 fn get_connection() -> Result<Connection, String> { 1769 1849 // Ensure tables exist (only runs once) 1770 1850 ensure_database_initialized()?; ··· 2249 2329 let conn = get_connection()?; 2250 2330 let now = Utc::now().to_rfc3339(); 2251 2331 let id = uuid::Uuid::new_v4().to_string(); 2252 - let metadata_json = metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()); 2253 2332 2254 2333 // Check if URL already exists (as a page type) 2255 2334 // Check both normalized and without trailing slash to match legacy data ··· 2263 2342 .ok(); 2264 2343 2265 2344 let item_id = if let Some(existing) = existing_id { 2266 - // Update existing item (normalize URL + update metadata if provided) 2267 - if metadata_json.is_some() { 2268 - conn.execute( 2269 - "UPDATE items SET url = ?, updated_at = ?, metadata = ? WHERE id = ?", 2270 - params![&url, &now, &metadata_json, &existing], 2271 - ) 2272 - .map_err(|e| format!("Failed to update item: {}", e))?; 2345 + // Update existing item (normalize URL + update metadata) 2346 + // Read existing metadata, merge user-provided fields, then inject sync update 2347 + let existing_meta_str: Option<String> = conn 2348 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&existing], |row| row.get(0)) 2349 + .ok(); 2350 + // Merge user-provided metadata fields into existing metadata 2351 + let merged_str = if let Some(new_meta) = &metadata { 2352 + let mut existing: serde_json::Value = existing_meta_str 2353 + .as_deref() 2354 + .and_then(|s| serde_json::from_str(s).ok()) 2355 + .unwrap_or_else(|| serde_json::json!({})); 2356 + if let (Some(dst), Some(src)) = (existing.as_object_mut(), new_meta.as_object()) { 2357 + for (k, v) in src { 2358 + if k != "_sync" { dst.insert(k.clone(), v.clone()); } 2359 + } 2360 + } 2361 + Some(serde_json::to_string(&existing).unwrap_or_default()) 2273 2362 } else { 2274 - conn.execute( 2275 - "UPDATE items SET url = ?, updated_at = ? WHERE id = ?", 2276 - params![&url, &now, &existing], 2277 - ) 2278 - .map_err(|e| format!("Failed to update item: {}", e))?; 2279 - } 2363 + existing_meta_str 2364 + }; 2365 + let updated_meta = inject_sync_metadata_for_update(merged_str); 2366 + conn.execute( 2367 + "UPDATE items SET url = ?, updated_at = ?, metadata = ? WHERE id = ?", 2368 + params![&url, &now, &updated_meta, &existing], 2369 + ) 2370 + .map_err(|e| format!("Failed to update item: {}", e))?; 2280 2371 2281 2372 // Remove old tag associations 2282 2373 conn.execute("DELETE FROM item_tags WHERE item_id = ?", params![&existing]) ··· 2286 2377 } else { 2287 2378 // Insert new url item 2288 2379 // IMPORTANT: Use 'url' type to match Swift's ItemType.page.rawValue = "url" 2380 + let meta_with_sync = inject_sync_metadata_for_save(metadata); 2381 + let metadata_json = serde_json::to_string(&meta_with_sync).unwrap_or_default(); 2289 2382 conn.execute( 2290 2383 "INSERT INTO items (id, type, url, metadata, created_at, updated_at) VALUES (?, 'url', ?, ?, ?, ?)", 2291 2384 params![&id, &url, &metadata_json, &now, &now], ··· 2655 2748 return Err("Page not found".to_string()); 2656 2749 } 2657 2750 2658 - // Update URL value and timestamp 2751 + // Update URL value, timestamp, and sync metadata 2752 + let existing_meta: Option<String> = conn 2753 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&id], |row| row.get(0)) 2754 + .ok(); 2755 + let updated_meta = inject_sync_metadata_for_update(existing_meta); 2659 2756 conn.execute( 2660 - "UPDATE items SET url = ?, updated_at = ? WHERE id = ?", 2661 - params![&url, &now, &id], 2757 + "UPDATE items SET url = ?, updated_at = ?, metadata = ? WHERE id = ?", 2758 + params![&url, &now, &updated_meta, &id], 2662 2759 ) 2663 2760 .map_err(|e| format!("Failed to update item: {}", e))?; 2664 2761 ··· 2803 2900 let tags_to_add: Vec<&String> = new_tags_set.difference(&existing_tags).collect(); 2804 2901 let tags_to_remove: Vec<&String> = existing_tags.difference(&new_tags_set).collect(); 2805 2902 2806 - // Update item's updated_at timestamp 2903 + // Update item's updated_at timestamp and sync metadata 2904 + let existing_meta: Option<String> = conn 2905 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&id], |row| row.get(0)) 2906 + .ok(); 2907 + let updated_meta = inject_sync_metadata_for_update(existing_meta); 2807 2908 conn.execute( 2808 - "UPDATE items SET updated_at = ? WHERE id = ?", 2809 - params![&now, &id], 2909 + "UPDATE items SET updated_at = ?, metadata = ? WHERE id = ?", 2910 + params![&now, &updated_meta, &id], 2810 2911 ) 2811 2912 .map_err(|e| format!("Failed to update item: {}", e))?; 2812 2913 ··· 2900 3001 let conn = get_connection()?; 2901 3002 let now = Utc::now().to_rfc3339(); 2902 3003 let id = uuid::Uuid::new_v4().to_string(); 2903 - let metadata_json = metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()); 3004 + let meta_with_sync = inject_sync_metadata_for_save(metadata); 3005 + let metadata_json: Option<String> = Some(serde_json::to_string(&meta_with_sync).unwrap_or_default()); 2904 3006 2905 3007 // Parse hashtags from content and merge with provided tags 2906 3008 let mut all_tags = parse_hashtags(&content); ··· 2990 3092 let conn = get_connection()?; 2991 3093 let now = Utc::now().to_rfc3339(); 2992 3094 let id = uuid::Uuid::new_v4().to_string(); 2993 - let metadata_json = metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()); 3095 + let meta_with_sync = inject_sync_metadata_for_save(metadata); 3096 + let metadata_json: Option<String> = Some(serde_json::to_string(&meta_with_sync).unwrap_or_default()); 2994 3097 2995 3098 // Insert tagset item 2996 3099 conn.execute( ··· 3172 3275 return Err("Text not found".to_string()); 3173 3276 } 3174 3277 3175 - // Update content and timestamp 3278 + // Update content, timestamp, and sync metadata 3279 + let existing_meta: Option<String> = conn 3280 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&id], |row| row.get(0)) 3281 + .ok(); 3282 + let updated_meta = inject_sync_metadata_for_update(existing_meta); 3176 3283 conn.execute( 3177 - "UPDATE items SET content = ?, updated_at = ? WHERE id = ?", 3178 - params![&content, &now, &id], 3284 + "UPDATE items SET content = ?, updated_at = ?, metadata = ? WHERE id = ?", 3285 + params![&content, &now, &updated_meta, &id], 3179 3286 ) 3180 3287 .map_err(|e| format!("Failed to update text: {}", e))?; 3181 3288 ··· 3301 3408 return Err("Tagset not found".to_string()); 3302 3409 } 3303 3410 3304 - // Update timestamp 3411 + // Update timestamp and sync metadata 3412 + let existing_meta: Option<String> = conn 3413 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&id], |row| row.get(0)) 3414 + .ok(); 3415 + let updated_meta = inject_sync_metadata_for_update(existing_meta); 3305 3416 conn.execute( 3306 - "UPDATE items SET updated_at = ? WHERE id = ?", 3307 - params![&now, &id], 3417 + "UPDATE items SET updated_at = ?, metadata = ? WHERE id = ?", 3418 + params![&now, &updated_meta, &id], 3308 3419 ) 3309 3420 .map_err(|e| format!("Failed to update tagset: {}", e))?; 3310 3421 ··· 3418 3529 let now = Utc::now().to_rfc3339(); 3419 3530 let item_id = uuid::Uuid::new_v4().to_string(); 3420 3531 let blob_id = uuid::Uuid::new_v4().to_string(); 3421 - let metadata_json = metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()); 3532 + let meta_with_sync = inject_sync_metadata_for_save(metadata); 3533 + let metadata_json: Option<String> = Some(serde_json::to_string(&meta_with_sync).unwrap_or_default()); 3422 3534 3423 3535 // Decode base64 image data 3424 3536 use base64::{Engine as _, engine::general_purpose::STANDARD}; ··· 3621 3733 return Err("Image not found".to_string()); 3622 3734 } 3623 3735 3624 - // Update timestamp 3736 + // Update timestamp and sync metadata 3737 + let existing_meta: Option<String> = conn 3738 + .query_row("SELECT metadata FROM items WHERE id = ?", params![&id], |row| row.get(0)) 3739 + .ok(); 3740 + let updated_meta = inject_sync_metadata_for_update(existing_meta); 3625 3741 conn.execute( 3626 - "UPDATE items SET updated_at = ? WHERE id = ?", 3627 - params![&now, &id], 3742 + "UPDATE items SET updated_at = ?, metadata = ? WHERE id = ?", 3743 + params![&now, &updated_meta, &id], 3628 3744 ) 3629 3745 .map_err(|e| format!("Failed to update image: {}", e))?; 3630 3746 ··· 4857 4973 // Tag commands 4858 4974 get_tags_by_frecency, 4859 4975 get_tags_by_frecency_for_url, 4976 + // Device 4977 + get_device_id_command, 4860 4978 // Settings and sync 4861 4979 is_dark_mode, 4862 4980 get_webhook_url,
+8
backend/tauri-mobile/src/App.tsx
··· 13 13 selectedText?: string; 14 14 sourceApp?: string; 15 15 sharedAt?: string; 16 + _sync?: SyncMetadata; 16 17 } 17 18 18 19 interface SavedUrl { ··· 54 55 frequency: number; 55 56 lastUsed: string; 56 57 frecencyScore: number; 58 + } 59 + 60 + interface SyncMetadata { 61 + createdBy?: string; 62 + createdAt?: number; 63 + modifiedBy?: string; 64 + modifiedAt?: number; 57 65 } 58 66 59 67 interface BidirectionalSyncResult {
+9
backend/types/index.ts
··· 115 115 favicon: string; 116 116 } 117 117 118 + // Sync metadata stored inside item metadata._sync 119 + // Used by both desktop and mobile to track which device created/modified items 120 + export interface SyncMetadata { 121 + createdBy?: string; 122 + createdAt?: number; 123 + modifiedBy?: string; 124 + modifiedAt?: number; 125 + } 126 + 118 127 // Visit record for item_visits table (local-only, not synced) 119 128 export interface ItemVisit { 120 129 id: string;
+39 -5
docs/sync-source-refactor.md
··· 136 136 137 137 Classifications are already preserved in tags (`from:*`) and metadata JSON. 138 138 139 - **Datastore** — remove syncSource gating on device metadata: 140 - - `backend/electron/datastore.ts:2322-2324` — always call `addDeviceMetadata()`, not just when `!options.syncSource` 139 + **Datastore** — replace syncSource gating on device metadata with architectural separation (match mobile pattern): 140 + - `backend/electron/datastore.ts:2322-2324` — currently uses `if (!options.syncSource)` to gate `addDeviceMetadata()`. This guard goes away with syncSource. 141 141 - `backend/electron/datastore.ts:2404-2406` — same for updates 142 + - **Fix:** Sync pull in `sync.ts` should write server metadata directly to DB (separate codepath) instead of routing through the shared `addItem()`/`updateItem()`. This matches how mobile works — `merge_server_item()` is architecturally separate from user-facing `save_*`/`update_*` commands, so server `_sync` metadata is preserved naturally without any guard. 142 143 143 144 ### 3. Device ID prefix removal 144 145 ··· 146 147 147 148 **Change:** Generate plain UUIDs going forward. Platform/type info belongs in the devices table. 148 149 150 + **Already done (mobile device ID PR):** 151 + - `backend/electron/device.ts:46` — now generates `crypto.randomUUID()` (no `desktop-` prefix) 152 + - `backend/tauri-mobile/src-tauri/src/lib.rs` — mobile generates raw UUID, stores in `settings` table, injects `_sync` metadata on all save/update commands 153 + - `backend/types/index.ts` — shared `SyncMetadata` interface added 154 + - `backend/tauri-mobile/src/App.tsx` — `SyncMetadata` interface and `_sync` field on `ItemMetadata` 155 + 156 + **Still needed:** 157 + 149 158 **Migration:** On startup, detect prefixed device IDs and strip the prefix: 150 159 151 160 ``` ··· 161 170 **One exception:** `backend/extension/environment.js:29` checks `parsed.startsWith('extension-')` for the extension device ID. This needs updating to handle both old prefixed and new plain UUID formats during the transition period. 162 171 163 172 **Files to update:** 164 - - `backend/electron/device.ts:46` — `crypto.randomUUID()` (remove `desktop-` prefix) 165 - - `backend/extension/environment.js` — update `startsWith('extension-')` check 166 - - Add migration in `backend/electron/datastore.ts` (startup) 173 + - `backend/extension/environment.js` — update `startsWith('extension-')` check, generate raw UUID 174 + - Add migration in `backend/electron/datastore.ts` (startup) — strip prefix from stored device ID + update existing item metadata 167 175 - Add migration in `backend/extension/` (startup) 168 176 169 177 ### 4. New `devices` table ··· 231 239 - **Browser imports will start syncing**: This is intentional but may surprise users who had large history imports. Consider a one-time notification or a toggle. 232 240 - **No breaking API changes**: Server doesn't use syncSource for anything — it stores and returns it, that's all 233 241 - **Device ID migration is local-only**: Each client migrates its own stored ID on startup. No server coordination needed. 242 + 243 + ## Testing Notes 244 + 245 + **CRITICAL:** All Node usage must use Electron's Node. No exceptions — `better-sqlite3` is compiled for Electron's Node version and will crash with system Node (`NODE_MODULE_VERSION` mismatch). 246 + 247 + ```bash 248 + # WRONG — uses system Node, will crash on better-sqlite3 249 + yarn test:sync:e2e 250 + 251 + # RIGHT — uses Electron's bundled Node 252 + ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron backend/tests/sync-e2e.test.js 253 + ``` 254 + 255 + **Current test status (as of mobile device ID PR):** 256 + 257 + | Test | Command | Status | Notes | 258 + |------|---------|--------|-------| 259 + | Sync integration | `yarn test:sync` | Passes (9/9) | Server-only, no better-sqlite3 dependency | 260 + | Sync e2e | `ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron backend/tests/sync-e2e.test.js` | Fails (pre-existing) | `item_events` table missing from test DB — schema validation rejects fresh DB without migration | 261 + | Sync three-way | `yarn test:sync:three-way` | Not tested | Likely same `item_events` issue | 262 + 263 + **Pre-existing issues to fix before these tests can validate the refactor:** 264 + 265 + 1. **`item_events` table migration** — `sync-e2e.test.js` creates a fresh DB but the desktop datastore's `validateSyncSchema()` requires all tables from `schema/v1.json` including `item_events`. Either the test setup needs to run migrations, or `initDatabase()` needs to create missing tables. 266 + 2. **`test:sync:e2e` package.json script** — should use `ELECTRON_RUN_AS_NODE=1` instead of bare `node`. All sync test scripts that touch the desktop datastore need this. 267 + 3. **Server deps** — `backend/server/node_modules` may not be installed in jj workspaces. Run `cd backend/server && npm install` if tests fail with `Cannot find module 'hono'`.