A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.
`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
Response drift was structurally undetectable. `openapi:check` proves the
YAML matches the registry and the route-coverage guard proves the verb is
listed; both pass when the registered schema disagrees with what
`apiSuccess()` sends, and the coverage guard says so in its own doc
comment. That is how `/api/sync/changes` shipped five domains against a
three-domain contract and how the error envelope went years without
declaring the field the multi-issue 422 was built for.
Two bodies, the two that were broken: a full `/api/sync/changes` page
with live and soft-deleted rows in all five domains, its `cursorExpired`
short-circuit (a second shape under the same schema), and a 422 carrying
more than one issue.
`safeParse` alone would not have caught either finding, because Zod
strips undeclared keys instead of refusing them — a schema short of the
wire parses cleanly. So the parsed value is compared back to the raw
body; a strip means the contract is missing something the server sends.
Removing `cycleDays` / `cycles` from the sync schema, or `details` from
the envelope, fails all three cases.
`GET /api/sync/changes` returns five domains — measurements, mood,
intakes, cycleDays, cycles — and has since the cycle module landed. The
registered schema declared three, closed the object and required only
those three, so `cycleDays` and `cycles` did not exist in the contract:
full day-log and cycle payloads, both upserts and tombstones, computed
and serialised on every single pull, invisible to any client generated
from the spec. The body was also invalid against its own schema, since
the object was closed against the two keys it always sends.
The day-log and cycle DTOs were already published for the cycle routes,
so the two upsert arrays reuse them and only the tombstone shapes are
new. The module's own description and the operation summary were stale
in the same direction and now name all five.
This is the two-ended change the contributor notes describe: the writer
shipped, the contract half did not, and no gate noticed because every
gate proves the other end.
No wire change.
- /api/sync/changes carries a1 to a5 and the day context under the same keys
the write path accepts, so a client reads back exactly what it can post
- BulkMoodEntry accepts both, all optional; a1 stays server-derived from the
five-point label, so a build that sends only a label still writes a complete
row and needs no change to stay correct
- the batch parses each entry's context on its own: one row carrying a value
the vocabulary does not name is skipped and the rest of the batch lands
- docs/api/openapi.yaml regenerated alongside
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.
Each write moves to the place that causes it.
The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.
The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.
The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.