mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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,
|
||||
|
||||
@@ -103,6 +103,7 @@ import { restoreEcgData } from "@/lib/export/ecg-backup";
|
||||
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import { stampSyncReset } from "@/lib/sync/reset";
|
||||
import {
|
||||
classifyRestoreFailure,
|
||||
RESTORE_FAILURE_CAUSE_MESSAGES,
|
||||
@@ -2412,6 +2413,11 @@ export async function restoreBackup(
|
||||
foreignBefore,
|
||||
await countForeignReferences(tx, ownerId, tenantEdges),
|
||||
);
|
||||
// Paired clients' delta cursors are no longer valid (see
|
||||
// `User.syncResetAt`). Stamped as the last write, so it commits with
|
||||
// the data, and stamped again right after the commit below to cover
|
||||
// a cursor issued while this transaction was still running.
|
||||
await stampSyncReset(tx, ownerId);
|
||||
progress.sectionsDone = RESTORE_SECTION_STEPS.length;
|
||||
report("sections", true);
|
||||
return {
|
||||
@@ -2479,6 +2485,22 @@ export async function restoreBackup(
|
||||
);
|
||||
}
|
||||
|
||||
// A pull that started before the commit read the pre-restore rows and may
|
||||
// have been issued a cursor after the stamp inside the transaction. The
|
||||
// second stamp expires that one too; it can only expire more cursors, so a
|
||||
// failure here costs a client at most one extra incremental page.
|
||||
try {
|
||||
await stampSyncReset(prisma, ownerId);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
restore_sync_reset_failed: true,
|
||||
restore_sync_reset_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { cleared, skipped, expiredTombstonesSkipped } = outcome;
|
||||
const measurementsRestored =
|
||||
streamed.measurementCount - expiredTombstonesSkipped;
|
||||
|
||||
Reference in new issue
Block a user