fix(sync): expire delta cursors issued before a restore or a data wipe

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:46:38 +02:00
1 parent 419ff04765
commit eda87fd4a7
14 files changed
+375 -13

No files matched your search

@@ -759,7 +759,10 @@ describe("backup round trip — export, wire schema, restore", () => {
});
const userUpdate = written.find(
(w) => w.model === "user" && w.op === "update",
(w) =>
w.model === "user" &&
w.op === "update" &&
"insightsCachedText" in w.data,
);
expect(
userUpdate,
@@ -798,8 +801,13 @@ describe("backup round trip — export, wire schema, restore", () => {
// whether the restore over-invalidates on a genuine no-op.
const { written: writtenUnchanged } = await roundTrip(liveSeed, payload);
// The restore also stamps the account's sync reset; only an update
// that touches the cached briefing is what this asks about.
const userUpdate = writtenUnchanged.find(
(w) => w.model === "user" && w.op === "update",
(w) =>
w.model === "user" &&
w.op === "update" &&
"insightsCachedText" in w.data,
);
expect(
userUpdate,