experiments in a post-browser web
at main 246 lines 12 kB view raw view rendered
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