experiments in a post-browser web
at main 270 lines 14 kB view raw view rendered
1# Sync Source Refactor: Replace syncSource with Device IDs 2 3Research doc — Feb 2026 4 5## Problem 6 7`syncSource` is overloaded with two jobs: 8 91. **Origin tracking** — where did this item come from? 102. **Sync gating** — should this item be pushed to the server? 11 12This causes several issues: 13 14- Items imported from browser history/tabs/bookmarks are silently excluded from sync (by convention, not by design) 15- Items pulled from the server lose their original source (overwritten with `'server'`) 16- The empty-string convention (`syncSource = ''` means "eligible for push") is implicit and fragile 17- Desktop navigation-tracked URLs sync with `syncSource = ''`, same as manual saves — making them indistinguishable on other devices 18 19The fix: **everything syncs**, using the deterministic timestamp-based algorithm we already have. Device IDs (already partially implemented) become the source-of-truth for item origin. 20 21## Current State 22 23### syncSource values in the wild 24 25| Value | Set by | Meaning | Syncs? | 26|-------|--------|---------|--------| 27| `''` (empty) | Default on item creation | Locally created, never synced | Yes (first push) | 28| `'server'` | Sync pull/push | Came from or was pushed to server | Only if modified after last sync | 29| `'history'` | Browser extension | Imported from browser history | Yes (empty string check passes*) | 30| `'tab'` | Browser extension | Imported from open tabs | Yes* | 31| `'bookmark'` | Browser extension | Imported from bookmarks | Yes* | 32 33*Note: Extension imports have non-empty syncSource but `syncedAt = 0`, so they match the push query on incremental sync (`syncSource = '' OR (syncedAt > 0 AND updatedAt > syncedAt)`) — the first condition fails, the second fails too since `syncedAt = 0`. On full sync, only `syncSource = ''` items are pushed. So **extension imports are effectively blocked from syncing**, which was the intent but is encoded implicitly. 34 35### Where syncSource is read/written 36 37**Sync algorithm (push filtering):** 38- `backend/electron/sync.ts:515-525` — WHERE clause filters by `syncSource = ''` 39- `sync/sync.js:102-107` — Same filter in unified sync engine 40- `backend/tauri/src-tauri/src/sync.rs:634-665` — Same filter in Tauri desktop 41 42**Set on push (marks as synced):** 43- `backend/electron/sync.ts:619-620``syncSource = 'server'` after push 44- `sync/sync.js:162-166` — Same 45- `backend/tauri/src-tauri/src/sync.rs:799-800` — Same 46 47**Set on pull (marks origin as server):** 48- `backend/electron/sync.ts:393-394``syncSource = 'server'` on new items from server 49- `sync/sync.js:399-400` — Same 50- `backend/tauri/src-tauri/src/sync.rs:521-522` — Same 51 52**Reset on server change:** 53- `backend/electron/sync.ts:667-668` — Resets all to `syncSource = ''` 54- `sync/sync.js:277-282` — Same 55 56**Browser extension (sets import source):** 57- `backend/extension/history.js:119``syncSource = 'history'` 58- `backend/extension/tabs.js:138``syncSource = 'tab'` 59- `backend/extension/bookmarks.js:84``syncSource = 'bookmark'` 60 61**Datastore (gates device metadata):** 62- `backend/electron/datastore.ts:2322-2324` — Skips `addDeviceMetadata()` if `syncSource` is present 63- `backend/electron/datastore.ts:2404-2406` — Same for updates 64 65**Sync status UI:** 66- `backend/electron/sync.ts:773-775` — Counts pending items using syncSource filter 67- Extension stats pages count items by syncSource value 68 69### Device ID (already partially implemented) 70 71`backend/electron/device.ts` generates device IDs and adds them to item metadata: 72 73``` 74metadata._sync = { 75 createdBy: "desktop-550e8400-e29b-41d4-a716-446655440000", 76 createdAt: 1767023561234, 77 modifiedBy: "desktop-550e8400-e29b-41d4-a716-446655440000", 78 modifiedAt: 1767023561234 79} 80``` 81 82This metadata **survives sync** (it's inside the JSON blob), unlike `syncSource` which gets overwritten. 83 84### Browser import classifications 85 86Already stored redundantly — **removing syncSource loses nothing**: 87 88- **Tags**: `from:history`, `from:tab`, `from:bookmark` — auto-applied on import 89- **Metadata JSON**: Rich browser-specific data (visit counts, tab properties, bookmark dates) 90 91## Changes 92 93### 1. Remove syncSource from sync algorithm ✅ DONE (desktop, server, unified sync module) 94 95**Push query** — replace syncSource filter with timestamp-based: 96 97Before: 98```sql 99WHERE (deletedAt = 0 AND (syncSource = '' OR (syncedAt > 0 AND updatedAt > syncedAt))) 100``` 101 102After: 103```sql 104WHERE (deletedAt = 0 AND (syncedAt = 0 OR updatedAt > syncedAt)) 105``` 106 107This means: push items that have never been synced (`syncedAt = 0`) or that were modified since last sync. No reference to syncSource. 108 109**After push** — only update `syncId` and `syncedAt`, stop setting `syncSource`: 110 111Before: 112```sql 113UPDATE items SET syncId = ?, syncSource = 'server', syncedAt = ? WHERE id = ? 114``` 115 116After: 117```sql 118UPDATE items SET syncId = ?, syncedAt = ? WHERE id = ? 119``` 120 121**On pull** — stop setting `syncSource = 'server'` on new items. Device metadata (`_sync.createdBy`) already tracks origin. 122 123**On server change** — reset `syncedAt = 0` to force full re-sync. No need to touch syncSource. 124 125**Files updated:** 126-`backend/electron/sync.ts` — syncSource removed from push filter, post-push update, server-change reset 127-`sync/sync.js` — syncSource replaced with syncedAt-based filtering, fromISOString() added for server timestamp conversion 128-`sync/adapters/better-sqlite3.js` — syncSource removed from schema, migration, INSERT 129-`sync/data.js` — syncSource removed from addItem and saveItem 130-`backend/tauri/src-tauri/src/sync.rs` — still has sync_source (Tauri desktop) 131-`backend/tauri-mobile/src-tauri/src/lib.rs` — still has sync_source (iOS mobile) 132 133### 2. Stop setting syncSource on item creation 134 135**Browser extension** — remove `syncSource` param from `addItem()` calls: 136- `backend/extension/history.js:119` — remove `syncSource: 'history'` 137- `backend/extension/tabs.js:138` — remove `syncSource: 'tab'` 138- `backend/extension/bookmarks.js:84` — remove `syncSource: 'bookmark'` 139 140Classifications are already preserved in tags (`from:*`) and metadata JSON. 141 142**Datastore** — replace syncSource gating on device metadata with architectural separation (match mobile pattern): 143- `backend/electron/datastore.ts:2322-2324` — currently uses `if (!options.syncSource)` to gate `addDeviceMetadata()`. This guard goes away with syncSource. 144- `backend/electron/datastore.ts:2404-2406` — same for updates 145- **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. 146 147### 3. Device ID prefix removal 148 149Device IDs currently use platform prefixes: `"desktop-{uuid}"` (Electron), `"extension-{uuid}"` (browser extension). These prefixes are **not used in any conditional logic** — verified by codebase-wide search. No code checks `startsWith('desktop')` or parses the prefix. 150 151**Change:** Generate plain UUIDs going forward. Platform/type info belongs in the devices table. 152 153**Already done (mobile device ID PR):** 154- `backend/electron/device.ts:46` — now generates `crypto.randomUUID()` (no `desktop-` prefix) 155- `backend/tauri-mobile/src-tauri/src/lib.rs` — mobile generates raw UUID, stores in `settings` table, injects `_sync` metadata on all save/update commands 156- `backend/types/index.ts` — shared `SyncMetadata` interface added 157- `backend/tauri-mobile/src/App.tsx``SyncMetadata` interface and `_sync` field on `ItemMetadata` 158 159**Still needed:** 160 161**Migration:** On startup, detect prefixed device IDs and strip the prefix: 162 163``` 164"desktop-550e8400-e29b-41d4-a716-446655440000" → "550e8400-e29b-41d4-a716-446655440000" 165``` 166 167This requires updating: 1681. The stored device ID in `extension_settings` 1692. All `metadata._sync.createdBy` and `metadata._sync.modifiedBy` references in existing items 170 171**Detection logic:** Check if device ID matches `/{prefix}-[0-9a-f]{8}-/` pattern. Known prefixes: `desktop-`, `extension-`. Strip prefix, keep UUID. 172 173**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. 174 175**Files to update:** 176- `backend/extension/environment.js` — update `startsWith('extension-')` check, generate raw UUID 177- Add migration in `backend/electron/datastore.ts` (startup) — strip prefix from stored device ID + update existing item metadata 178- Add migration in `backend/extension/` (startup) 179 180### 4. New `devices` table 181 182Replace the ad-hoc `extension_settings` K/V storage with a proper table. 183 184```sql 185CREATE TABLE IF NOT EXISTS devices ( 186 id TEXT PRIMARY KEY, -- Plain UUID 187 name TEXT DEFAULT '', -- User-friendly: hostname, "iPhone", etc. 188 platform TEXT NOT NULL, -- 'electron', 'tauri', 'tauri-mobile', 'extension', 'server' 189 metadata TEXT DEFAULT '{}', -- JSON: OS, arch, app version, backend type, capabilities 190 createdAt INTEGER NOT NULL, -- First registration 191 lastSeenAt INTEGER NOT NULL -- Last activity (updated on sync, app launch, etc.) 192); 193``` 194 195**All clients register on startup.** Server nodes also register (even though they don't originate items today, they may in the future — e.g., server-side feed fetching). 196 197**The `metadata._sync.createdBy` / `modifiedBy` fields** reference `devices.id` (plain UUID). 198 199**Synced table:** The devices table itself should sync across all nodes so every client knows about every device. This enables UI like "saved from MacBook" or "last synced from iPhone". 200 201**Schema location:** Add to `schema/v1.json` (or v2 if we version it). 202 203### 5. Mobile filtering (separate task) 204 205Once syncSource cleanup is done and device IDs are reliable, mobile can filter the default list view: 206 207- Items with `metadata._sync.createdBy` matching a device where `platform = 'electron'` AND tagged `from:history` or created by navigation tracking → hide from default list, show in search 208- This is a frontend-only change on mobile (no schema changes needed) 209 210## Migration & Backward Compatibility 211 212### syncSource column 213 214**Keep the column** in the schema — removing it would break older clients. Just stop reading/writing it in new code. It becomes a no-op legacy field. 215 216### Existing items 217 218No data migration needed for sync to work. The new push query (`syncedAt = 0 OR updatedAt > syncedAt`) handles all existing items correctly: 219- Items with `syncSource = ''` and `syncedAt = 0` → pushed (same as before) 220- Items with `syncSource = 'server'` and `syncedAt > 0` → only pushed if modified (same as before) 221- Items with `syncSource = 'history'` and `syncedAt = 0` → now pushed (new — these were previously blocked) 222 223The last point means browser imports will start syncing after this change. This is the desired behavior ("everything syncs"). 224 225### Device ID migration 226 227Run on startup before any sync operations: 228 2291. Read current device ID from `extension_settings` 2302. If it matches `{prefix}-{uuid}` pattern, strip the prefix 2313. Update `extension_settings` with the plain UUID 2324. Scan items table: `UPDATE items SET metadata = replace(metadata, old_device_id, new_device_id) WHERE metadata LIKE '%' || old_device_id || '%'` 2335. Register in new `devices` table 234 235### Rollback safety 236 237If needed, the old sync algorithm still works — it just won't push browser imports (same as today). The syncSource column is preserved, so reverting the push query is a one-line change. 238 239## Prod Impact 240 241- **Low risk**: The sync algorithm change is additive (more items sync, none stop syncing) 242- **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. 243- **No breaking API changes**: Server doesn't use syncSource for anything — it stores and returns it, that's all 244- **Device ID migration is local-only**: Each client migrates its own stored ID on startup. No server coordination needed. 245 246## Testing Notes 247 248**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). 249 250```bash 251# WRONG — uses system Node, will crash on better-sqlite3 252yarn test:sync:e2e 253 254# RIGHT — uses Electron's bundled Node 255ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron backend/tests/sync-e2e.test.js 256``` 257 258**Current test status (as of mobile device ID PR):** 259 260| Test | Command | Status | Notes | 261|------|---------|--------|-------| 262| Sync integration | `yarn test:sync` | Passes (9/9) | Server-only, no better-sqlite3 dependency | 263| 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 | 264| Sync three-way | `yarn test:sync:three-way` | Not tested | Likely same `item_events` issue | 265 266**Pre-existing issues to fix before these tests can validate the refactor:** 267 2681. **`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. 2692. **`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. 2703. **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'`.