mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|