experiments in a post-browser web
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'`.