mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -3,7 +3,12 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { encodeCursor, decodeCursor, type SyncCursor } from "../cursor";
|
||||
import {
|
||||
encodeCursor,
|
||||
decodeCursor,
|
||||
decodeCursorIssuedAt,
|
||||
type SyncCursor,
|
||||
} from "../cursor";
|
||||
|
||||
describe("sync cursor codec", () => {
|
||||
it("round-trips a per-domain keyset map through encode/decode", () => {
|
||||
@@ -112,3 +117,20 @@ describe("sync cursor codec", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor issue time (sync reset)", () => {
|
||||
const cursor: SyncCursor = {
|
||||
measurements: { updatedAtMs: 1_000, id: "clx0m" },
|
||||
};
|
||||
|
||||
it("round-trips the issue time beside the watermarks", () => {
|
||||
const token = encodeCursor(cursor, 1_790_000_000_000);
|
||||
expect(decodeCursorIssuedAt(token)).toBe(1_790_000_000_000);
|
||||
expect(decodeCursor(token)).toEqual(cursor);
|
||||
});
|
||||
|
||||
it("reads no issue time from a token that carries none", () => {
|
||||
expect(decodeCursorIssuedAt(encodeCursor(cursor))).toBeNull();
|
||||
expect(decodeCursorIssuedAt("not a token")).toBeNull();
|
||||
});
|
||||
});
|
||||
+31
-3
@@ -63,14 +63,27 @@ const CURSOR_VERSION = 2;
|
||||
* `cycleDays`/`cycles` domains; they decode as absent (fresh scan). */
|
||||
const SUPPORTED_CURSOR_VERSIONS: readonly number[] = [1, 2];
|
||||
|
||||
/** Encode a per-domain keyset map into an opaque token. */
|
||||
export function encodeCursor(cursor: SyncCursor): string {
|
||||
/**
|
||||
* Encode a per-domain keyset map into an opaque token.
|
||||
*
|
||||
* `issuedAtMs` is when the server handed the token out. The watermarks cannot
|
||||
* say that: a restore writes rows back with their original `updatedAt`, so a
|
||||
* cursor drained entirely after a restore can still carry watermarks older
|
||||
* than it. The issue time is what `/api/sync/changes` compares against the
|
||||
* account's `syncResetAt`. Additive inside the v2 envelope: a token without
|
||||
* it decodes as before and counts as issued before any reset.
|
||||
*/
|
||||
export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
|
||||
const d: Record<string, { u: number; i: string }> = {};
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
const wm = cursor[domain];
|
||||
if (wm) d[domain] = { u: wm.updatedAtMs, i: wm.id };
|
||||
}
|
||||
const json = JSON.stringify({ v: CURSOR_VERSION, d });
|
||||
const json = JSON.stringify(
|
||||
issuedAtMs === undefined
|
||||
? { v: CURSOR_VERSION, d }
|
||||
: { v: CURSOR_VERSION, t: issuedAtMs, d },
|
||||
);
|
||||
return Buffer.from(json, "utf-8").toString("base64url");
|
||||
}
|
||||
|
||||
@@ -117,3 +130,18 @@ export function decodeCursor(token: string): SyncCursor | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a token was issued (see {@link encodeCursor}), or null for a token
|
||||
* that does not say: one from before the field existed, or one that does not
|
||||
* parse.
|
||||
*/
|
||||
export function decodeCursorIssuedAt(token: string): number | null {
|
||||
try {
|
||||
const json = Buffer.from(token, "base64url").toString("utf-8");
|
||||
const t = (JSON.parse(json) as { t?: unknown } | null)?.t;
|
||||
return typeof t === "number" && Number.isFinite(t) ? t : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Expire every delta cursor an account's paired clients hold.
|
||||
*
|
||||
* Called when the account's record is replaced wholesale: a backup restore
|
||||
* (inside its transaction and again right after the commit) and the
|
||||
* person's delete-all-data. `/api/sync/changes` answers `cursorExpired` to
|
||||
* any cursor issued before the stamp, and the client re-initialises. See
|
||||
* `User.syncResetAt` in the schema for why an incremental catch-up from such
|
||||
* a cursor would miss rows.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export async function stampSyncReset(
|
||||
db: Pick<PrismaClient | Prisma.TransactionClient, "user">,
|
||||
userId: string,
|
||||
at: Date = new Date(),
|
||||
): Promise<void> {
|
||||
await db.user.update({ where: { id: userId }, data: { syncResetAt: at } });
|
||||
}
|
||||
Reference in new issue
Block a user