experiments in a post-browser web
peek docs sync-coverage-gaps.md
22 kB

Sync coverage gaps — what a server restore would not bring back #

Research note. Surveys which parts of the datastore reach the sync server and which are local-only, on the question: if a laptop is lost, what does restoring from the server produce?

Nothing here is scheduled work. Each gap names what settles it.

Sources: packages/schema/v1.json (the canonical schema, sync flag per column), apps/desktop/main/sync.ts (pushSingleItem(), mergeServerItem(), pullFromServer(), pushToServer(), syncTagsToItem(), pushTagsToServer(), pullTagsFromServer(), mergeServerTag(), pushEventsToServer(), pullEventsFromServer(), mergeServerEvent()), apps/desktop/main/rules.ts and rule-engine.ts, and apps/server/db.js (initializeSchema(), saveItem(), saveTags(), getTagsSince()).

Short answer #

A restore returns items with all their fields — title, domain, favicon, visit history, frecency, starred/archived state, deletion tombstones — their tags, tag frecency, tag metadata, and now item events (the observations behind series and feed items). It does not return user-created rules, because none exist yet: there is no rule editor and no code path that creates a source: 'user' rule.

The remaining gaps are narrower than they once were: tag rename and tag deletion don't propagate, event deletion doesn't propagate, and a push with no tags field is a latent (not live) hazard. See below for each.

1. Item fields — all now sync #

title, domain, favicon, visitCount, lastVisitAt, and frecencyScore are "sync": true on items in packages/schema/v1.json, carried by sync.ts pushSingleItem() / mergeServerItem(), and stored by apps/server/db.js saveItem(). So are mimeType, starred, archived, and deletedAt.

createdAt is client-authoritative: pushSingleItem() sends the item's original local createdAt, and apps/server/db.js saveItem() honors it only on INSERT, so an item's original creation date survives a restore. updatedAt stays server-stamped on every write instead — mergeServerItem()'s conflict resolution (last-write-wins) compares on it, so it can't also carry client-supplied values without breaking that comparison.

2. item_events now syncs, stamped per event #

packages/schema/v1.json marks every item_events column "sync": true and lists the table in validation.required_sync_columns. sync.ts pushEventsToServer() / pullEventsFromServer() / mergeServerEvent() carry it, and apps/server/db.js stores it. series and feed items — whose entire payload lives in item_events rows rather than in the item itself — restore with their observations intact.

Push records delivery per event, in item_events.pushedAt (0 = never delivered), rather than against any time watermark. The reason a watermark cannot serve here: an event's itemId on the wire is the server's item id, resolved from the local item's syncId — so an event whose parent item has no syncId yet (not pushed, or still mid-push) has to be skipped for this run. Any watermark still advances at the end of that run (items tolerate this, since they track their own per-row syncedAt), which would push it past the skipped events' createdAt and strand them permanently.

A dedicated event watermark, held below the oldest skipped event, was the previous answer and had its own failure: an item the server permanently rejects — or one of a type that never syncs at all — never earns a syncId, so it pinned that watermark indefinitely and every run re-pushed the whole event tail behind it (24,000 events per run, measured against production). With a per-row stamp, an event whose parent cannot sync strands exactly itself and is picked up whenever that parent does sync.

pushedAt is declared "sync": false in packages/schema/v1.json — local bookkeeping, never sent on the wire, no column on the server — so it needs no datastore version bump. datastore.ts migrateEventPushedAtColumn() adds the column and stamps everything at or below the retired watermark as already delivered; only the tail behind it re-pushes, once, and the server ignores ids it already holds.

Deletion does not propagate: a locally hard-deleted event reappears on the next pull if the server still has it. There is no deletedAt (or any tombstone) column for item_events in any backend's schema.

Settled by: a tombstone column for item_events, plus push/pull handling for it — unscheduled work, and the one piece of §2 still open.

3. Tag metadata now syncs; tag identity and deletion are the remaining gaps #

slug, color, parentId, description, and metadata on tags are "sync": true as of DATASTORE_VERSION 3. sync.ts pushTagsToServer() / pullTagsFromServer() / mergeServerTag() carry them, keyed by the tag's name — the same identity apps/server/db.js getOrCreateTagWithConn() already used for tag rows an item push derives. parentId travels on the wire under that same field name but holds the parent's name, not any id — the server column of the same name stores that name too, not a row id — resolved back to a local tag id on pull in a second pass so a parent arriving after its child in the same pull still links correctly; an incoming cycle of parentId (name) links is broken deterministically (newest updatedAt loses its parent link, ties going to the lower id) so every device that pulls the same cycle breaks the same link.

Two gaps remain, accepted rather than fixed:

  • Renaming a tag on one device creates a second tag rather than renaming the first. Identity on the wire is the name; a syncId would give the same row two sources of truth to reconcile, and the push/pull code was built to avoid that rather than build rename support.
  • Tag deletion does not propagate. There is no tombstone column on tags — a tag deleted on one device still exists, and can still be pulled back, on every other device.

Settled by: a real tag identity beyond name (needed for rename to sync) and a deletedAt column plus tombstone push/pull (needed for delete to propagate) — both separate, unscheduled work.

4. Rules are entirely local — deferred, not rejected #

The rules table is "sync": false in every column. A survey of the code finds zero rows with source: 'user', and no path that produces one: there is a tile:rules:register IPC handler (api.rules.register), but it always writes source: 'runtime', never 'user', and there is no rule editor UI. The only rules that exist are source: 'manifest' (4 of them, across entities and tag-actions), reconciled from feature manifests by rules.ts syncManifestRules() on every load — so manifest rules regenerate themselves and should not sync.

Rule sync isn't rejected, just blocked on a rule editor that doesn't exist yet. Whoever builds one has to satisfy two constraints first for sync to be possible:

  • User-rule ids must be globally unique across devices. The id is the only viable sync identity for a user rule. Manifest rules can't use plain ids for this because rules.ts computeManifestRuleId() derives theirs from a content hash of the rule's fields — a user rule minted the same way would collide by design the moment two devices declare similar-looking rules.
  • Rule deletion needs a tombstone. rules.ts deleteRule() is a hard DELETE FROM rules WHERE id = ?, and there is no deletedAt (or equivalent) column on rules in any backend's schema. Syncing rules as-is would resurrect a deleted rule on every other device that still has it.

Separately, and true regardless of whether rules ever sync: rule-engine.ts fireRule() resolves a rule's scriptId by publishing to scripts:execute:<scriptId> — a plain pubsub topic. If no script on that device subscribes to that topic (because the script doesn't exist there), the publish has no listener, the rule silently never fires, and nothing logs it. A synced rule naming a script the receiving device lacks would hit exactly this.

Settled by: building a rule editor (giving source: 'user' a real origin), then deciding whether user-authored rules sync — filtered to source = 'user', once the id-uniqueness and tombstone constraints above are met.

5. The two copies of the canonical schema are no longer divergent #

apps/server/schema.json is generated from packages/schema/v1.json by packages/schema/codegen.js generateServerSchema(), and the generated file is checked in. Generation is necessary because deploys upload apps/server/ alone (see docs/server-backup-and-deploy.md), so server code can never import from packages/schema at runtime — it needs its own copy of whatever it reads.

The generated file is a narrow projection, not a mirror: SERVER_TABLES in codegen.js lists only the tables the server physically creates (items, tags, item_tags, item_events — rules is desktop-only and deliberately dropped), and for each it carries only validation.required_sync_columns, because that's the only part apps/server/db.js validateSchema() reads at boot. Freshness is covered by packages/schema/check-freshness.js, which regenerates and diffs against the checked-in copy.

6. The server's items table columns — resolved #

apps/server/db.js initializeSchema() creates items with title, domain, favicon, frecencyScore, visitCount, lastVisitAt, and mimeType alongside the original columns. This was downstream of §1: once those fields started syncing, the server needed columns to receive them. See docs/mcp-remote-backend-design.md §3.1.

Every other item column apps/server/db.js saveItem() writes goes through COALESCE(?, existingColumn), so a client that omits a field on a re-push (e.g. an older client that doesn't carry title/domain/starred/etc.) leaves the stored value untouched. tags is the one exception: saveItem() unconditionally runs DELETE FROM item_tags WHERE itemId = ? and rebuilds the links from the pushed tags array, and POST /items defaults an absent tags field to []. A push with no tags field at all therefore clears the item's tags, where the same omission on any other field is a no-op.

This is latent, not live: every current client (desktop, mobile, extension) always sends a full tags array on every item push. Pinned by apps/server/test.js "a push with no tags field wipes the item's existing tags (latent hazard, not desirable)".

Settled by: giving tags the same COALESCE-style omitted-means-unchanged treatment as every other column — unscheduled work, since no current client hits it.

8. What the sync test suites can and cannot catch #

A green suite only means something if it is known what that suite is able to fail on. Six suites carry sync coverage; this is what each one exercises for real and what is invisible to it by construction. The occasion for writing it down: sync-e2e.test.js was 2 passed / 11 failed for two months (2026-06-24 to 2026-08-18) while every other suite stayed green, because the only suite able to see that class of fault was the broken one.

All six pass as of 2026-09-01: 41, 17, 9, 8, 17 and 7 cases respectively.

A worktree needs yarn workspace @peek/desktop build before the two suites that import apps/desktop/dist/main/ can run at all — without it they die on ERR_MODULE_NOT_FOUND rather than reporting a failure against the code.

apps/desktop/main/sync.test.ts — 41 cases #

Runs as runner 1 of the 6-runner desktop gate (yarn test:desktop:electron), and standalone via yarn test:unit. Exercises the real sync.ts push and pull functions against the real server app and a real node:sqlite datastore, and is the only suite covering all six stages deliberately rather than incidentally (describe('IPC entry points — tile:sync:push / tile:sync:pull must run all three stages')). It also owns the paged-pull cases (describe('Paged pulls — the client follows the server cursor')), which answer the item pull from a stub rather than the real app: what is under test there is the client's loop — how many requests it makes and when it stops.

Blind by construction to any disagreement between two separately-configured HTTP clients: setup() stubs globalThis.fetch and routes it into the server's Hono app.request() in-process, so there is no socket, no header serialization round trip, and no second fetch implementation. That is exactly the shape of the sync-e2e.test.js fault, which is why this suite could not see it.

Also blind to drift in the real IPC handlers: replayTileSyncPush() and replayTileSyncPull() re-implement the body of tile-ipc.ts's handlers, because tile-ipc.ts cannot be imported under ELECTRON_RUN_AS_NODE. If the registered handler's stage order changes, the replay still passes.

Covers all three recent changes: pushedAt per-event stamping, the parked-item columns, and the batched push including its 404 fallback.

packages/integration-tests/sync-e2e.test.js — 17 cases #

yarn test:sync:e2e. The only suite where a real desktop client and a real server process talk over a real socket: it spawns apps/server/index.js on port 3458 and drives the compiled sync.ts against it. This is the suite that can see a client/server disagreement, and the reason step 1 of restoring the foundation was repairing it rather than deleting it.

One limit remains:

  • Authentication is bypassed. The server is spawned with E2E_TEST=true, which short-circuits deviceMiddleware and multiUserMiddleware in apps/server/auth.js, so a credential-handling mismatch on the wire is invisible here. sync-auth-e2e.test.js below is the suite that covers it.

Tag and event stages are no longer incidental. testTagStagesOverSocket() and testEventStagesOverSocket() call the real pushTagsToServer() / pullTagsFromServer() / pushEventsToServer() / pullEventsFromServer() and confirm what crossed the wire by requesting /tags/since and /events/since directly, in both directions. testTagsetSync() still covers only the tags array on /items, which is a different thing from the tag-metadata routes. Two cases used to print a pass on every path and never inject a failure. They were replaced with assertions of the behaviour that actually holds now: testDeletionTombstonePropagates() proves a soft delete reaches the server as a deletedAt tombstone and drops out of the default listing, and testRejectedItemIsParked() proves a permanently rejected row records syncError/syncErrorAt, is not re-attempted while unchanged, and pushes once edited. That makes the parked-item mechanism the one recent change proven over a real socket rather than only in-process.

pushedAt and the /items/batch 404 fallback are now proven here too. testEventPushedAtStamping() asserts a delivered event's pushedAt moves off zero while an event whose parent item has no syncId stays at zero — the stranding invariant §2 describes. The 404 fallback cannot be reached against the spawned server, which always has the route, so testBatchFallbackWhenRouteMissing() puts a node:http proxy in front that answers POST /items/batch with a 404 and forwards everything else. The proxy counts what it sees, and the test asserts on those counts rather than on the items alone: asserting only that the items arrive would pass just as well if the batch route had never been attempted.

packages/integration-tests/sync-integration.test.js — 9 cases #

yarn test:sync. Real HTTP to a real spawned server on port 3457, with requests built by hand in serverRequest(). No desktop code is loaded at all, so this is a server-route suite: it validates POST /items and GET /items/since/:timestamp and nothing else. No /tags or /events route is requested. Nothing about how the real client builds a request or reads a response is under test.

packages/integration-tests/sync-three-way.test.js — 8 cases #

yarn test:sync:three-way. Real HTTP to a real spawned server on port 3459. Both "mobile" and "desktop" are the same hand-rolled clientRequest() helper with different profile query params — neither the desktop sync.ts nor the Rust mobile client is involved. What it actually proves is server-side profile resolution and isolation, including that an unregistered UUID gets its own profile rather than falling into default. Only /items is touched; no tag or event stages, no batching, no sync-state bookkeeping.

packages/integration-tests/sync-version-compat.test.js — 17 cases #

yarn test:version-compat. Two mechanisms in one file:

  • Header negotiation (test1_bothV1 through test8_bothNoHeaders, testHealthCheck, testIncrementalSyncVersionCheck) — real HTTP to a real server on port 3460, but the client side is versionedRequest() setting X-Peek-Datastore-Version and friends by hand, not serverFetch(). It proves the server's reading of those headers, not the desktop's writing of them.
  • Version-bump behaviour (test9_initWritesVersion through test13_legacyEncodedVersionFallback, testServerDBVersion) — these now drive the real implementations. checkAndWriteDatastoreVersion() is module-private in datastore.ts and returns void, so it is exercised through its real entry point, initDatabase(), with the outcome read from the feature_settings row and from isSyncDisabledDueToVersion(); tamperStoredVersion() writes the stored version straight into sqlite between inits to stand in for a database left by another binary. testServerDBVersion() drives the real db.js initializeSchema() instead of hand-building a settings table. test13_legacyEncodedVersionFallback covers the bare-numeral encoding that JSON.parse rejects and the implementation's parseInt fallback recovers — a branch the previous copy did not distinguish.

Only /items is requested; no tag or event stages.

packages/integration-tests/sync-auth-e2e.test.js — 7 cases #

yarn test:sync:auth. Spawns the real server on port 3461 in multi-user mode with no E2E_TEST, so multiUserMiddleware and users.resolveDevice() actually run. Credentials are minted at runtime through POST /admin/devices behind ADMIN_TOKEN. It covers the 401 paths (absent header, well-formed but unminted token), a minted key succeeding, a revoked device's key failing afterwards — the individual-revocation guarantee — the public health check, and that device keys and ADMIN_TOKEN cannot authenticate each other's routes.

The case that closes the gap is testRealDesktopClientAuthenticates(): it configures the compiled sync.js with a genuinely minted key, pushes, and confirms the item lands, then reconfigures with an unminted key and asserts the push fails rather than silently reporting success. That puts the client's own header construction under test, not just the server's reading of a hand-built header.

Nothing runs any of this automatically #

There is no CI, no .github/ workflows, no husky config, and no installed git hook (core.hooksPath unset, .git/hooks holds only the stock samples). Suites 2 through 5 are standalone yarn scripts that no aggregate script calls. Suite 1 rides the desktop gate, which itself requires explicit human approval per apps/desktop/CLAUDE.md before each run. That is why a June breakage surfaced in August.

Decision: the five non-gate suites are grouped under yarn test:sync:all and run before landing any sync change, rather than being wired into a pre-commit hook. Each spawns a server process and together they take a few minutes, which is too slow to run on every commit; the desktop gate stays the only place suite 1 runs.

They share fixed ports — 3457, 3458, 3459, 3460, 3461 — and each suite creates a fixed-name profile on its own server. So two runs of the same suite at once collide: waitForServer() polls the port and treats any 200 as its own server, and the second run then fails on an already-existing profile. Run the chain serially.

The gaps this leaves #

The four gaps this section previously named are closed, each by the suite noted above: tag and event stages now run over a real socket, real authentication is exercised end to end by sync-auth-e2e.test.js, pushedAt and the POST /items/batch 404 fallback are proven over the wire, and the version-bump logic drives the real implementation rather than a copy.

What remains:

  • Nothing runs any of it automatically. The aggregate yarn test:sync:all is still something a person has to remember, which is the condition that let a June breakage surface in August.
  • Two suites depend on apps/desktop/dist/ existing. A worktree that has not run yarn workspace @peek/desktop build gets a module-resolution crash from them, which is a louder failure than a silent pass but still not a result about the code.
  • The header-negotiation half of sync-version-compat.test.js still builds its requests by hand in versionedRequest(), so it proves the server's reading of the version headers, not the desktop's writing of them.
  • The mobile client is not covered by any of this. sync_e2e_mobile is a separate cargo suite, and nothing exercises the Rust client against a spawned server in the same way sync-e2e.test.js covers the desktop one.

Verifying any of this #

grep -n '"sync": false' packages/schema/v1.json
grep -rn "source: 'user'" apps/desktop/main --include='*.ts' --exclude='*.test.ts'   # returns nothing
yarn schema:check

The first is the local-only inventory — now just rules and the two accepted tag gaps. The second is the §4 finding: no code path produces a user-authored rule. The third regenerates apps/server/schema.json from packages/schema/v1.json and diffs against the checked-in copy — a clean exit is §5 holding.

Not covered by this note #

  • Whether the server's backup path (apps/server/backup.js) changes any of the above. It backs up what the server holds; it cannot restore what never arrived.
  • Blob and image storage coverage.
  • Whether any of these gaps have already caused loss in practice — no audit of the live server's data was run.