mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* A restore or a delete-all-data expires every delta cursor issued before it.
|
||||
*
|
||||
* A restore writes rows back with their original `updatedAt` and removes rows
|
||||
* without leaving tombstones. A paired client that kept its cursor would, on
|
||||
* its next incremental pull, miss both: the restored rows sit behind its
|
||||
* watermarks and the removed ones never show up as deletions. So the restore
|
||||
* stamps `User.syncResetAt`, and `/api/sync/changes` answers `cursorExpired`
|
||||
* to any cursor issued before it. A cursor the feed hands out after the
|
||||
* reset keeps working, although it may carry watermarks older than the reset
|
||||
* (the restored rows'), which is why the comparison is on the cursor's issue
|
||||
* time.
|
||||
*
|
||||
* Mutation checks: drop the `beforeReset` arm in the route and the first two
|
||||
* tests go red on `cursorExpired`; compare the reset against the cursor's
|
||||
* watermarks instead of its issue time and the cursor issued after the
|
||||
* restore expires too.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
interface Page {
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
cursorExpired: boolean;
|
||||
changes: {
|
||||
measurements: {
|
||||
upserts: Array<{ id: string }>;
|
||||
tombstones: Array<{ id: string }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function pull(cursor?: string): Promise<Page> {
|
||||
const { GET } = await import("@/app/api/sync/changes/route");
|
||||
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/sync/changes${query}`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: Page }).data;
|
||||
}
|
||||
|
||||
/** Drain the feed from `cursor` to the end; the last page's cursor. */
|
||||
async function drain(
|
||||
cursor?: string,
|
||||
): Promise<{ cursor: string; ids: string[] }> {
|
||||
const ids: string[] = [];
|
||||
let next = cursor;
|
||||
for (;;) {
|
||||
const page = await pull(next);
|
||||
expect(page.cursorExpired).toBe(false);
|
||||
ids.push(...page.changes.measurements.upserts.map((m) => m.id));
|
||||
next = page.cursor!;
|
||||
if (!page.hasMore) return { cursor: next, ids };
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAccount() {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "sync-reset",
|
||||
email: "sync-reset@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
const earlier = Date.now() - 3 * 86_400_000;
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `m-${i}`,
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + i,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(earlier + i * 60_000),
|
||||
// Old row times, as a restored row carries.
|
||||
createdAt: new Date(earlier),
|
||||
updatedAt: new Date(earlier + i),
|
||||
})),
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async function restoreBackup(backupId: string) {
|
||||
const { POST } = await import("./restore-job-driver");
|
||||
const res = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backupId}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backupId }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
describe("sync reset after a restore or a wipe", () => {
|
||||
it("expires a cursor issued before a restore, and not one issued after it", async () => {
|
||||
const user = await seedAccount();
|
||||
const prisma = getPrismaClient();
|
||||
const { id: backupId } = await storeBackupBlob(
|
||||
prisma,
|
||||
{ userId: user.id, type: "WEEKLY_AUTO" },
|
||||
(write) =>
|
||||
streamFullBackupJson(prisma, user.id, write, {
|
||||
purpose: "disaster-recovery",
|
||||
}),
|
||||
);
|
||||
|
||||
// A client caught up, then a reading arrived and was synced, and then
|
||||
// the account was restored to the backup, which removes that reading
|
||||
// without a tombstone.
|
||||
const before = await drain();
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
id: "m-after-backup",
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 90,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
},
|
||||
});
|
||||
const caughtUp = await drain(before.cursor);
|
||||
expect(caughtUp.ids).toEqual(["m-after-backup"]);
|
||||
|
||||
await restoreBackup(backupId);
|
||||
expect(
|
||||
await prisma.measurement.findUnique({ where: { id: "m-after-backup" } }),
|
||||
).toBeNull();
|
||||
|
||||
// The old cursor would have reported nothing: the removal left no
|
||||
// tombstone and the restored rows sit behind its watermarks.
|
||||
const stale = await pull(caughtUp.cursor);
|
||||
expect(stale.cursorExpired).toBe(true);
|
||||
expect(stale.changes.measurements.upserts).toEqual([]);
|
||||
|
||||
// Re-initialised: the whole restored record, and the new cursor holds
|
||||
// although every restored row is older than the reset.
|
||||
const fresh = await drain();
|
||||
expect(fresh.ids.sort()).toEqual(["m-0", "m-1", "m-2", "m-3", "m-4"]);
|
||||
const next = await pull(fresh.cursor);
|
||||
expect(next.cursorExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("expires a cursor issued before the person deleted all their data", async () => {
|
||||
await seedAccount();
|
||||
const { cursor } = await drain();
|
||||
|
||||
const { DELETE } = await import("@/app/api/settings/data/route");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/settings/data", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect((await pull(cursor)).cursorExpired).toBe(true);
|
||||
const fresh = await drain();
|
||||
expect(fresh.ids).toEqual([]);
|
||||
expect((await pull(fresh.cursor)).cursorExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves an account that was never restored alone", async () => {
|
||||
await seedAccount();
|
||||
const { cursor } = await drain();
|
||||
expect((await pull(cursor)).cursorExpired).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user