Skip to main content

Sync audit — 2026-09-27

Read-only review of API, contracts, migrations, web client and mobile integration in this checkout. Runtime code, schemas and generated clients were not changed. This is a dated finding record; sync remains the current protocol reference. Production data and deployment were not inspected.

Current flow​

Web edit → Dexie row + outbox (one transaction)
→ push → per-item user lock → patch/delete + optional loser snapshot
← acknowledgement (status, identity, version; no canonical row)
→ paged pull → rows + cursor (one local transaction per page)
→ unresolved-conflict list

Mobile → local Room calendars; generated SDK has no sync-engine integration

Observed strengths: writes and pulls share a per-user advisory lock; database triggers allocate versions after that lock; pull uses bounded per-entity watermarks and includes tombstones. Local mutation/outbox writes and pull-page/cursor writes are transactional. These mechanisms are useful, but do not establish end-to-end losslessness or retry idempotency.

Findings​

P1 means risk of losing work, breaking account isolation or blocking ordinary sync. P2 means incorrect behavior or misleading acknowledgements. Reproduced below means an isolated in-memory execution, not a live PostgreSQL or browser concurrency test. The confirmed winner policy is server-arrival-order overwrite with delete precedence (a stale push still applies its fields and banks the prior row), not client-timestamp LWW; see sync.

PriorityFinding and consequenceEvidence
P1Temporary refresh failure deletes offline work. Any refresh network exception or non-OK response emits logout; the auth subscriber deletes IndexedDB, including outbox. A 503 after access expiry can erase unsent edits.apiClient:75–89, AuthContext:75–82, wipeDb. Static trace.
P1Reminder edits reject the whole batch. updateReminder queues only changed value/type/isEnabled; the schema requires exactly one parent UID on every upsert. Unrelated writes in the same request fail too.local:51–60, schema:100, route:50. Schema rejection reproduced.
P1Logout does not invalidate in-flight sync. stop removes scheduling, but an old pull can write into the shared DB after wipe/reopen or account switch. This can repopulate a new session with the old account's data.stop:242, pull:136, logout:99. Race inferred from control flow.
P1Task and task-reminder restore report false success. No TASK restore branch exists; REMINDER restoration joins only through events. Both can mark a conflict restored without changing its entity, hiding the recovery entry.restore:743–817. No-op paths reproduced.
P1Replayed updates overwrite intervening edits. A(base 10) commits 11, its response is lost, B commits 12, replay A(base 10) writes 13 over B. Losing B is banked, but automatic retry is not harmless.stale write:374–382, incorrect idempotence description:114. A/B/replay reproduced.
P2Pull replaces pending local state. bulkPut ignores the outbox, including edits made while push is in flight and local tombstones. The mutation may remain queued, so this alone is not proof of permanent loss; the UI and subsequent edits nevertheless use reverted data.pull:137–143. Static trace.
P2Outbox has no batch cap. 1,001 mutations of one entity exceed the schema limit, retry together and eventually become failed. The route returns 400 before its intended 413 branch.batch:19–35, limits:105, route:50–58. Static trace.
P2Leadership does not cover every trigger. Followers can kick sync without owning the lock; every start resets shared in_flight entries. A cancelled queued leadership request can later install listeners. Duplicate pushes can therefore reach the non-idempotent server path.kick/start:207–239, lock callback:24. Race inferred.
P2Same-client edits conflict with each other. Successive offline mutations retain the same baseVersion; acknowledgement updates the row but never rebases queued successors. Today they overwrite with artificial conflicts; naive stale rejection would instead lose later edits.event enqueue:57–66, ack:56–60. Static trace.
P2Nullable task relations cannot be cleared. null calendarUid/eventUid/parentTaskUid is accepted but skipped by truthiness checks; success increments version without detaching the relationship.resolveTaskFks:234–249. parentTaskUid:null reproduced.
P2Restore is incomplete and non-atomic. null snapshot fields become undefined, so restoration cannot clear recurrence/location/etc. Conflict lookup, entity restore and resolution marking are separate operations; concurrent decisions or failure between writes can leave inconsistent results.lookup:726, mapping:754, commit/mark:815–817. Null omission reproduced; transaction race inferred.
P2Invalid relationship changes may be silently ignored. Event/reminder update ignores unresolved parent UIDs; reminder parent-type changes do not clear the previous FK and can fail XOR.event:377–380, reminder:437–444. Static trace.
P2Resolved conflicts linger on other devices. Refresh only inserts unresolved entries and never removes cached entries resolved elsewhere.refreshConflicts:169–179. Static trace.

Further discrepancies: bootstrap is always push-first; null-base delete is handled before delete semantics; winnerVersion names the previous row on stale writes; conflict does not always mean applied; missing-row deletion has no version. These are documented precisely in sync. Generated OpenAPI/Kotlin comments still inherit misleading descriptions from contracts; those code artifacts were left unchanged under the read-only scope.

Can canonical rows replace LWW and conflictId?​

Yes as a redesigned protocol, not as a field removal. conflictId is the actual wire spelling. Three decisions are independent:

DecisionEffect
Return canonical entity with each resultClient learns accepted fields, normalization, tombstone and remapped UID without waiting for pull
Reject stale writes instead of applying themReplaces arrival-order LWW with optimistic concurrency; local intent must survive rejection
Remove conflict storage and endpointsRemoves server-side loser recovery and cross-device conflict history

Recommended direction for a future implementation: retain version/baseVersion and signed pull cursors; apply a mutation only against its expected version; return applied, stale or error plus the authoritative entity or explicit missing-row result. These names are illustrative, not the current contract. Read that DTO inside the locked item transaction. Keep submitted identity distinct from remapped identity, and correlate results by request index or stable mutation ID.

On stale rejection, preserve the local mutation for rebase, merge or user choice. Blindly saving the returned row and deleting the mutation is a server-wins policy that discards offline work. Compact or sequentially rebase same-record outbox mutations; apply acknowledgement and canonical state atomically while retaining newer pending edits. A full canonical response still cannot replace pull: other clients and side effects such as default-calendar demotion remain unseen.

Use a stable mutation identity with server deduplication if transport retries must return the original outcome. Returning a row cannot recover a lost acknowledgement by itself. Define create collisions, unknown deletes, tombstones and explicit resurrection; do not silently recreate deleted records during rebase.

Make the response additive first, update contracts/web and regenerate Kotlin, then migrate the conflict policy and consumers deliberately. Drain, retain or export existing conflict records before removing their UI/endpoints/storage. This audit implements none of those runtime changes.

Verification and remaining uncertainty​

  • Existing web sync suite: 24 tests, 6 files passed (npm test -- --run src/lib/sync in apps/calendar-web).
  • In-memory executions of the actual service with mocked Prisma reproduced replay overwrite, no-op restores, null detachment and null-base delete behavior; these are control-flow checks, not database integration coverage.
  • No API integration tests ran: their setup migrates and clears a test database. No production access or database mutations were performed.
  • Needed regression coverage: refresh outage with pending work; logout during pull; cross-tab ownership; edit during pull; repeated/lost acknowledgements; oversized/mixed-invalid batches; all entity restore/null/reparent paths; pagination under concurrent writes.
  • Pull locking appears sound for the inspected owner-scoped paths. Live lock behavior, crash recovery and deployed-client compatibility were not validated. Passing existing tests does not negate the findings.