mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
419ff04765
commit
eda87fd4a7
14 files changed
+375
-13
No files matched your search
@@ -113,7 +113,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Every account row survives; the personal data carried on its own
|
||||
// columns does not. Same classification contract as the model list.
|
||||
await tx.user.updateMany({ data: USER_RESET });
|
||||
// Paired clients drop their delta cursors and start over, instead of
|
||||
// catching up from a position whose rows are gone (`syncResetAt`).
|
||||
await tx.user.updateMany({
|
||||
data: { ...USER_RESET, syncResetAt: new Date() },
|
||||
});
|
||||
|
||||
return perModel;
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
wipeDelegateKey,
|
||||
WIPE_MODELS,
|
||||
} from "@/lib/data-wipe/wipe-plan";
|
||||
import { stampSyncReset } from "@/lib/sync/reset";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -104,6 +105,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// The account row survives; the personal data carried on its own columns
|
||||
// does not. Same classification contract as the model list.
|
||||
await tx.user.update({ where: { id: userId }, data: USER_RESET });
|
||||
// Paired clients drop their delta cursors and start over, instead of
|
||||
// catching up from a position whose rows are gone.
|
||||
await stampSyncReset(tx, userId);
|
||||
|
||||
// The nightly off-host copies of the record are part of "all your
|
||||
// data". The request commits with the wipe; the purge job deletes
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
* - `cursorExpired: true` when ANY domain watermark predates the
|
||||
* tombstone-retention horizon — the client drops its cursor and does
|
||||
* a clean initial sync (a deletion older than retention may have
|
||||
* been pruned, so an incremental delta could silently miss it).
|
||||
* been pruned, so an incremental delta could silently miss it) — or
|
||||
* when the cursor was issued before the account's `syncResetAt` (a
|
||||
* backup restore or a delete-all-data since, see the schema note).
|
||||
* - `syncVersion` is echoed per row so the client can keep its mirror's
|
||||
* version monotonic.
|
||||
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
|
||||
@@ -64,6 +66,7 @@ import { contextForWire, type MoodContextWire } from "@/lib/mood/context";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import {
|
||||
decodeCursor,
|
||||
decodeCursorIssuedAt,
|
||||
encodeCursor,
|
||||
SYNC_DOMAINS,
|
||||
type DomainWatermark,
|
||||
@@ -271,14 +274,39 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// A stale cursor (ANY domain watermark older than retention) forces a
|
||||
// clean re-init: a deletion older than retention may already be pruned.
|
||||
const expired = SYNC_DOMAINS.some((domain) => {
|
||||
const pastRetention = SYNC_DOMAINS.some((domain) => {
|
||||
const wm = cursor[domain];
|
||||
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
|
||||
});
|
||||
// A cursor issued before the account's record was last replaced (a backup
|
||||
// restore, or the person deleting all their data) cannot be caught up
|
||||
// either: the restore wrote rows back with their original `updatedAt`,
|
||||
// behind the cursor's watermarks, and removed rows without tombstones. The
|
||||
// client re-initialises instead. Compared by the time the cursor was
|
||||
// issued, not by its watermarks, which a restore leaves meaningless; a
|
||||
// cursor from before the issue time was recorded counts as issued before
|
||||
// any reset.
|
||||
let beforeReset = false;
|
||||
if (parsed.data.cursor) {
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { syncResetAt: true },
|
||||
});
|
||||
if (account?.syncResetAt) {
|
||||
const issuedAt = decodeCursorIssuedAt(parsed.data.cursor);
|
||||
beforeReset =
|
||||
issuedAt === null || issuedAt < account.syncResetAt.getTime();
|
||||
}
|
||||
}
|
||||
const expired = pastRetention || beforeReset;
|
||||
if (expired) {
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
meta: { cursor_expired: true, returned: 0 },
|
||||
meta: {
|
||||
cursor_expired: true,
|
||||
cursor_expired_reason: beforeReset ? "sync_reset" : "retention",
|
||||
returned: 0,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
serverNow: serverNow.toISOString(),
|
||||
@@ -552,7 +580,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
return apiSuccess({
|
||||
serverNow: serverNow.toISOString(),
|
||||
cursor: encodeCursor(nextCursor),
|
||||
cursor: encodeCursor(nextCursor, serverNow.getTime()),
|
||||
hasMore,
|
||||
cursorExpired: false,
|
||||
changes: {
|
||||
|
||||
Reference in new issue
Block a user