experiments in a post-browser web
1# Peek Sync
2
3Cross-platform data synchronization between mobile, desktop, and server.
4
5## Architecture
6
7```
8┌─────────────┐ ┌─────────────┐ ┌─────────────┐
9│ Mobile │────▶│ Server │◀────│ Desktop │
10│ (Tauri) │ │ (Hono) │ │ (Electron) │
11└─────────────┘ └─────────────┘ └─────────────┘
12 │ │ │
13 ▼ ▼ ▼
14 SQLite SQLite SQLite
15```
16
17The server acts as the source of truth. All clients push and pull data via the server's REST API.
18
19## Item Types
20
21All platforms use the same unified types:
22
23| Type | Description | Content Field |
24|------|-------------|---------------|
25| `url` | Saved URLs/bookmarks | The URL string |
26| `text` | Text content/notes | The text content |
27| `tagset` | Tag-only items | null |
28| `image` | Binary images | Base64 data or filename |
29
30## Sync Protocol
31
32### Pull (Server → Client)
33
341. Fetch: `GET /items` (full) or `GET /items/since/:timestamp` (incremental)
35 - All pull requests include `includeDeleted=true` so tombstones propagate
362. For each server item:
37 - If `deleted_at` is set: mark local item as soft-deleted (tombstone)
38 - Find local item by `syncId` matching server `id`
39 - If not found: insert new item
40 - If found and server is newer: update local
41 - If local is newer: skip (will push later)
42
43### Push (Client → Server)
44
451. Query items where `syncSource = ''` OR `updatedAt > lastSyncTime`
46 - Includes soft-deleted items (those with `deleted_at` set) so tombstones propagate
472. For each item: `POST /items` with type, content, tags
48 - If item has `deleted_at`: include it in the push payload so the server records the tombstone
493. On success: update local `syncId` and `syncSource`
50
51### Conflict Resolution
52
53**Last-write-wins** based on `updatedAt` timestamp.
54
55## API Endpoints
56
57### Server
58
59All endpoints accept a `?profile={uuid}` parameter (defaults to `default`):
60
61```
62GET /items?profile=<uuid>&includeDeleted=true # All items (with tombstones)
63GET /items/since/:timestamp?profile=<uuid> # Items modified after timestamp (always includes tombstones)
64GET /items/:id?profile=<uuid> # Single item
65POST /items?profile=<uuid> # Create/update item (supports deleted_at for tombstones)
66PATCH /items/:id/tags?profile=<uuid> # Update tags
67DELETE /items/:id?profile=<uuid> # Hard delete item
68```
69
70The `includeDeleted=true` parameter on `GET /items` returns soft-deleted items (those with `deleted_at` set). The `/items/since/:timestamp` endpoint always includes tombstones so incremental sync propagates deletions.
71
72The `profile` parameter is a server-side profile UUID. The server uses the UUID directly as the folder name on disk. Legacy slugs are resolved to UUIDs for backward compatibility.
73
74**Profile Management:**
75```
76GET /profiles # List user's profiles
77POST /profiles # Create profile
78DELETE /profiles/:id # Delete profile
79```
80
81### Desktop IPC
82
83```javascript
84await window.app.sync.getConfig() // Get server URL, API key
85await window.app.sync.setConfig(cfg) // Save config
86await window.app.sync.pull() // Pull from server
87await window.app.sync.push() // Push to server
88await window.app.sync.full() // Full bidirectional sync
89```
90
91## Configuration
92
93### Desktop
94
95Sync configuration is **per-profile** and stored in `profiles.db`:
96- `api_key` - API key (authenticates with server user)
97- `server_profile_slug` (mapped to `serverProfileId` in code) - Server profile UUID to sync to
98- `last_sync_at` - Last sync timestamp
99- `sync_enabled` - Enable sync for this profile
100
101**Server URL** is environment-based (`SYNC_SERVER_URL` env var or default).
102
103Each desktop profile can independently sync to different server profiles under the same server user account.
104
105**Example:**
106```
107Desktop Profile API Key Server Profile
108────────────── ────────── ──────────────
109Work alice's key work
110Personal alice's key personal
111```
112
113### Mobile
114
115Sync configuration is stored in `profiles.json` in the App Group container:
116- `sync.server_url` - Server URL (top-level, used for requests)
117- `sync.api_key` - API key (top-level, used for auth)
118- `profiles[].server_profile_id` - Server profile UUID for each local profile
119- `profiles[].server_url` / `profiles[].api_key` - Per-profile sync config
120
121The mobile sends `?profile=<server_profile_id>` on sync requests. If `server_profile_id` is not set, falls back to the local profile UUID.
122
123## Delete Propagation
124
125Deletions propagate across platforms via **tombstones** (soft-deletes with a `deleted_at` timestamp).
126
127### Flow
128
1291. **Delete locally**: Item gets `deleted_at` timestamp instead of being hard-deleted
1302. **Push tombstone**: Next sync pushes the item with `deleted_at` to the server
1313. **Server stores tombstone**: Server records `deleted_at` on the item
1324. **Pull tombstone**: Other clients pull items with `includeDeleted=true`, see `deleted_at`, and soft-delete locally
133
134### Implementation by platform
135
136| Platform | Push tombstones | Pull tombstones | Query param |
137|----------|----------------|-----------------|-------------|
138| JS engine (`sync/sync.js`) | `getItemsForPush()` includes deleted | `applyPulledItems()` checks `deleted_at` | `includeDeleted=true` |
139| Electron (`backend/electron/sync.ts`) | Push query includes `deleted_at IS NOT NULL` | Pull applies `deleted_at` to local items | `&includeDeleted=true` |
140| Tauri desktop (`backend/tauri/src-tauri/src/sync.rs`) | Push includes soft-deleted items | Pull handles `deleted_at` field | `&includeDeleted=true` |
141| Tauri mobile (`backend/tauri-mobile/src-tauri/src/lib.rs`) | Push includes soft-deleted items | Pull handles `deleted_at` field | `&includeDeleted=true` |
142| Server (`backend/server/db.js`) | N/A | `getItems()` respects `includeDeleted` param; `getItemsSince()` always includes tombstones | N/A |
143
144### Tombstone lifecycle
145
146- Tombstones are permanent — they are never hard-deleted by sync
147- The `deleted_at` field is a Unix millisecond timestamp
148- Items with `deleted_at` are excluded from normal UI queries but included in sync queries
149
150## Server-Change Detection
151
152When a user changes sync servers (or configures sync for the first time after having synced previously), per-item sync markers (`syncSource`, `syncedAt`, `syncId`) from the old server would prevent items from being pushed to the new server. Both desktop and mobile detect this:
153
1541. After every pull or full sync, the current server URL and profile ID are saved to a `settings` table (`lastSyncServerUrl`, `lastSyncProfileId`).
1552. Before `syncAll()`, the stored values are compared to the current config.
1563. If they differ (server changed), all per-item sync markers are reset — making every item eligible for push.
1574. If no stored values exist (first-time sync), no reset occurs — items pulled in a prior pull-only sync retain their markers. This prevents duplicates when `pullFromServer()` runs before `syncAll()` has stored the config.
158
159This ensures no data loss when switching servers and no duplicates on first sync.
160
161**Files:**
162- `sync/sync.js`: `resetSyncStateIfServerChanged()`, `saveSyncServerConfig()` (unified engine)
163- `backend/electron/sync.ts`: `resetSyncStateIfServerChanged()`, `saveSyncServerConfig()` (desktop)
164- `backend/tauri-mobile/src-tauri/src/lib.rs`: `reset_sync_state_if_server_changed()`, `save_sync_server_config()` (mobile)
165
166## Deduplication
167
168### Sync Path (syncId-based only)
169
170When items arrive via sync (`POST /items` with `sync_id`), dedup uses **syncId matching only**:
171- If an item with the same `sync_id` exists: update it
172- If no match: create a new item (even if content is identical)
173
174This means the same URL saved independently on two devices creates two separate server items (each with its own sync_id). This is intentional — sync_id is the canonical identity.
175
176### Non-Sync Path (content-based)
177
178When items arrive without a `sync_id` (e.g., browser extension, API calls):
179- URL items: dedup by `type + content`
180- Tagset items: dedup by matching tag set
181- Text items: dedup by `type + content`
182
183### fromISOString
184
185The server returns integer timestamps (Unix ms). The `fromISOString()` helper in both `sync/sync.js` and `backend/electron/sync.ts` handles both integer and ISO string inputs.
186
187## E2E Full Sync Test
188
189`scripts/e2e-full-sync-test.sh` (`yarn test:e2e:full-sync`) runs a clean-room three-way sync test:
190
191**Phase 1 — Three-way sync (6 items):**
1921. Starts a fresh temp server with 2 seeded items
1932. Configures a desktop profile and seeds 2 items, pushes to server
1943. Creates a fresh iOS simulator database with 2 items
1954. Polls the server waiting for iOS to push (manual step: build in Xcode, sync in simulator)
1965. Triggers desktop re-sync to pull iOS items
1976. Verifies all three platforms have 6 items
198
199**Phase 2 — Sync dedup testing (10 items):**
2007. Re-pushes an existing item with its own sync_id → verifies dedup (still 6)
2018. Seeds identical cross-device URL into desktop + iOS (no sync_id)
2029. Seeds identical cross-device tagset into desktop + iOS (no sync_id)
20310. Desktop pushes new items to server
20411. iOS syncs (manual step)
20512. Verifies 10 items on all platforms (no content dedup in sync path)
20613. Re-sync stability check: 0 pulled, 0 pushed (no growth)
207
208**Phase 3 — One-time dedup cleanup:**
20914. Seeds 3 duplicate items into each platform's database
21015. Restarts server → dedup migration runs, removes duplicates
21116. Restarts desktop → dedup migration runs
21217. Force-quit/relaunch iOS (manual step) → dedup migration runs
21318. Verifies all platforms removed duplicates and set `dedup_cleanup_v1` flag
21419. Idempotency check: restart server again, verify flag prevents re-run
215
216**Phase 4 — Delete propagation (tombstone sync):**
21720. Deletes a desktop item → desktop sync pushes tombstone to server
21821. iOS syncs (manual step) → pulls tombstone, soft-deletes locally
21922. Deletes an iOS item (via direct DB write after app termination)
22023. iOS relaunches and syncs (manual step) → pushes tombstone to server
22124. Desktop sync pulls iOS tombstone
22225. Verifies tombstone counts on all platforms
223
224All data is temporary and cleaned up on exit (including iOS simulator backup/restore).
225
226## Known Limitations
227
228### ~~Deletes Not Synced~~ (FIXED)
229
230Deletions now propagate via tombstones. See [Delete Propagation](#delete-propagation) above.
231
232### Push Failures Not Retried (HIGH)
233
234Failed push operations are logged but not retried. After sync, `lastSyncTime` advances, so failed items won't be picked up on next sync.
235
236**Workaround:** Manually trigger sync if network issues occurred.
237
238## Files
239
240- `sync/sync.js` - Unified cross-platform sync engine (JS, used by mobile/web)
241- `backend/electron/sync.ts` - Desktop sync implementation (Electron-specific)
242- `backend/server/db.js` - Server database with sync support
243- `backend/tests/sync-e2e.test.js` - Server + desktop headless e2e sync tests
244- `backend/tauri-mobile/src-tauri/src/lib.rs` - Mobile sync (pull/push/sync_all)
245- `scripts/e2e-full-sync-test.sh` - Full three-way e2e test (server + desktop + iOS simulator)
246- `notes/sync-edge-cases.md` - Detailed edge case documentation