Merge pull request #1064 from MBombeck/fix/backup-summary-manifest

Answer the restore preview from counts kept with the backup
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:25:26 +02:00
commit a5d19bfe98
19 files changed
+1605 -101

No files matched your search

@@ -1,11 +1,14 @@
/**
* v1.37.20 — restore preview: the summary read decrypts and validates the
* stored file through the SAME helpers the restore uses, and only ever
* reads. Watched red: written against the route before its annotate landed;
* the not-found and decrypt-failure arms were asserted first.
* Restore preview. A copy written with a preview is answered from it without
* opening the copy (#1031); a copy from before previews is read whole once,
* through the same helpers the restore uses, and what that read found is kept
* on the row. The verdicts (schema version, keys) are taken again on every
* read. Only a file that fails the schema is a 422 with that message; any
* other failure is the server's, not the file's.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
@@ -23,9 +26,15 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findUniqueMock = vi.fn();
const updateManyMock = vi.fn();
const chunkCountMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
dataBackup: {
findUnique: (...a: unknown[]) => findUniqueMock(...a),
updateMany: (...a: unknown[]) => updateManyMock(...a),
},
dataBackupChunk: { count: (...a: unknown[]) => chunkCountMock(...a) },
},
}));
@@ -34,16 +43,32 @@ const decryptMock = vi.fn();
// decrypts anything, so a stub of this module has to answer that too.
vi.mock("@/lib/crypto", () => ({
decrypt: (...a: unknown[]) => decryptMock(...a),
decryptBytes: vi.fn(),
getConfiguredKeyIds: () => ["v1"],
isStreamCiphertext: () => false,
decryptStream: vi.fn(),
}));
const openSpy = vi.fn();
vi.mock("@/lib/export/stored-backup", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/export/stored-backup")>();
return {
...actual,
openStoredBackup: (...a: Parameters<typeof actual.openStoredBackup>) => {
openSpy(...a);
return actual.openStoredBackup(...a);
},
};
});
const parseMock = vi.fn();
const summarizeMock = vi.fn();
const compatibleMock = vi.fn((version: string) => version !== "99");
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
// The measurements are read as a stream and counted one by one.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
@@ -52,52 +77,201 @@ vi.mock("@/lib/validations/backup", () => ({
}));
import { GET } from "../route";
import { storedCopyIdentity } from "@/lib/export/backup-preview";
const request = new NextRequest(
"http://localhost/api/admin/backups/b1/summary",
);
const params = { params: Promise.resolve({ id: "b1" }) };
const params = () => ({ params: Promise.resolve({ id: "b1" }) });
const ROW = {
id: "b1",
userId: "u1",
data: "ciphertext",
chunkCount: null,
chunkStreamId: null,
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
};
function storedPreview(overrides: Record<string, unknown> = {}) {
return {
version: 1,
copy: storedCopyIdentity(ROW),
summary: {
schemaVersion: "2",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 1_800_000,
moodEntries: 3,
},
keys: [],
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
findUniqueMock.mockResolvedValue({
id: "b1",
userId: "u1",
data: "ciphertext",
chunkCount: null,
chunkStreamId: null,
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
findUniqueMock.mockResolvedValue({ ...ROW, preview: null });
updateManyMock.mockResolvedValue({ count: 1 });
// Twelve measurements in the file: the preview counts them from the stream.
decryptMock.mockReturnValue(
JSON.stringify({
measurements: Array.from({ length: 12 }, (_, i) => ({ id: `m${i}` })),
}),
);
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 0, moodEntries: 3 });
parseMock.mockReturnValue({ schemaVersion: "2" });
summarizeMock.mockReturnValue({
schemaVersion: "2",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 0,
moodEntries: 3,
});
});
describe("GET /api/admin/backups/[id]/summary", () => {
it("answers the file's counts without writing anything", async () => {
const res = await GET(request, params);
describe("GET /api/admin/backups/[id]/summary, from the stored preview", () => {
it("answers from the preview without opening the copy", async () => {
findUniqueMock.mockResolvedValue({ ...ROW, preview: storedPreview() });
const res = await GET(request, params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
expect(body.data.summary).toMatchObject({
measurements: 1_800_000,
moodEntries: 3,
});
expect(body.data.owner).toBe("self-hoster");
expect(openSpy).not.toHaveBeenCalled();
expect(decryptMock).not.toHaveBeenCalled();
expect(updateManyMock).not.toHaveBeenCalled();
});
it("takes the schema verdict again against this release", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({
summary: {
schemaVersion: "99",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 1,
},
}),
});
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(openSpy).not.toHaveBeenCalled();
});
it("takes the key verdict again against this server's keys", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({
keys: [
{
keyId: "v7",
count: 4,
sections: ["moodEntries"],
sample: { value: "v7.AAAA", form: "string" },
},
// Only in the instance settings, which the restore leaves out.
{
keyId: "v8",
count: 1,
sections: ["appSettings"],
sample: null,
},
],
}),
});
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.key.missing", keyIds: ["v7"] },
});
expect(openSpy).not.toHaveBeenCalled();
});
it("still counts the pieces, and refuses a copy that lost one", async () => {
const chunked = {
...ROW,
data: null,
chunkCount: 3,
chunkStreamId: "s1",
};
findUniqueMock.mockResolvedValue({
...chunked,
preview: storedPreview({ copy: storedCopyIdentity(chunked) }),
});
chunkCountMock.mockResolvedValue(3);
expect((await GET(request, params())).status).toBe(200);
chunkCountMock.mockResolvedValue(2);
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.payload.undecryptable" },
});
expect(openSpy).not.toHaveBeenCalled();
});
it("ignores a preview that describes another copy", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({ copy: "chunks:other:3" }),
});
const res = await GET(request, params());
expect(res.status).toBe(200);
expect((await res.json()).data.summary.measurements).toBe(12);
expect(openSpy).toHaveBeenCalledTimes(1);
});
});
describe("GET /api/admin/backups/[id]/summary, a copy without a preview", () => {
it("reads the copy once and keeps what it found on the row", async () => {
const res = await GET(request, params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toMatchObject({
measurements: 12,
moodEntries: 3,
});
expect(openSpy).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(updateManyMock).toHaveBeenCalledTimes(1));
const [call] = updateManyMock.mock.calls[0] as [
{ where: Record<string, unknown>; data: { preview: { copy: string } } },
];
// Only onto the copy it was read from.
expect(call.where).toEqual({
id: "b1",
chunkStreamId: null,
chunkCount: null,
});
expect(call.data.preview.copy).toBe(storedCopyIdentity(ROW));
});
it("shares one read between requests for the same copy", async () => {
const [a, b] = await Promise.all([
GET(request, params()),
GET(request, params()),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(openSpy).toHaveBeenCalledTimes(1);
});
it("404s an unknown backup id", async () => {
findUniqueMock.mockResolvedValue(null);
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
await expect(GET(request, params())).rejects.toMatchObject({
status: 404,
});
});
it("refuses a file it cannot decrypt with the documented 422", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
const res = await GET(request, params());
// Bad stored input — a key dropped from `ENCRYPTION_KEYS`, or bytes that
// are not the ones written — and not a fault in this process, so it does
// not answer 500 and does not reach the error reporter as one.
@@ -105,13 +279,42 @@ describe("GET /api/admin/backups/[id]/summary", () => {
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.payload.undecryptable" },
});
expect(updateManyMock).not.toHaveBeenCalled();
});
it("422s a file that fails schema validation", async () => {
parseMock.mockImplementation(() => {
throw new Error("nope");
throw new ZodError([]);
});
const res = await GET(request, params);
const res = await GET(request, params());
expect(res.status).toBe(422);
expect((await res.json()).error).toBe(
"Backup payload failed schema validation",
);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("422s a file that is not JSON", async () => {
decryptMock.mockReturnValue("{ not json");
const res = await GET(request, params());
expect(res.status).toBe(422);
expect((await res.json()).error).toBe(
"Backup payload failed schema validation",
);
});
it("does not call a server failure a schema failure", async () => {
parseMock.mockImplementation(() => {
throw new Error("out of memory");
});
// Thrown to the handler, which answers 500 and reports it.
await expect(GET(request, params())).rejects.toThrow("out of memory");
expect(updateManyMock).not.toHaveBeenCalled();
});
it("still answers when keeping the preview fails", async () => {
updateManyMock.mockRejectedValue(new Error("db gone"));
const res = await GET(request, params());
expect(res.status).toBe(200);
});
});
+200 -46
View File
@@ -4,33 +4,57 @@
*
* The restore dialog used to ask for the typed confirmation on nothing but
* a filename and a date; the counts only appeared in the audit row after
* the rows were already replaced. This read decrypts and schema-validates
* the stored file exactly the way the restore itself does (same decrypt,
* same `parseBackupPayload`, same compatibility gate) and answers with the
* `summarizeBackup` counts — so what the operator confirms is what the
* restore will write, derived from the same code path. Read-only; nothing
* is touched.
* the rows were already replaced. This answers with the `summarizeBackup`
* counts of the stored copy, and refuses the way the restore would refuse:
* a schema version this release cannot restore, or a key the copy's inner
* ciphertext needs and this server does not have.
*
* The counts are worked out while the copy is written and kept on its row
* (`DataBackup.preview`, `src/lib/export/backup-preview.ts`), so this answers
* without opening the copy. Reading the copy here took two minutes for an
* account of 1.8 million readings on slow hardware, long after the dialog had
* given up (#1031). Both verdicts are taken again on every read, from what
* the preview keeps, because the release and the server's keys can both
* change after the copy was written. The copy's pieces are still counted
* against the row (no piece is read), so a copy that lost one since is
* refused here, as the restore and the download refuse it.
*
* A copy written before previews existed has none. That one is read whole,
* once: the preview worked out from it is stored on the row, so the next
* request answers at once, and concurrent requests for the same copy share
* the one read.
*/
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
checkStoredBackupShape,
isStoredBackupReadError,
openStoredBackup,
storedBackupRefusal,
STORED_BACKUP_SELECT,
type StoredBackupRef,
} from "@/lib/export/stored-backup";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
BackupKeyIdCollector,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import { BackupJsonError } from "@/lib/export/backup-json-scan";
import {
buildBackupPreview,
storedCopyIdentity,
storedPreviewFor,
type BackupPreview,
} from "@/lib/export/backup-preview";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
StreamedBackupInvalidError,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
@@ -43,6 +67,114 @@ export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
/** Why a copy could not be summarised, as the route answers it. */
type Refusal = { status: number; message: string; code?: string };
type ReadOutcome = { preview: BackupPreview } | { refusal: Refusal };
/**
* Reads of a copy without a preview that are running now, by copy. A second
* request for the same copy (the dialog opened again while the first read is
* still going) waits for the first instead of reading it all again.
*/
const inFlight = new Map<string, Promise<ReadOutcome>>();
/** Whether a failure is the file itself not being a backup this reads. */
function isPayloadInvalid(err: unknown): boolean {
return (
err instanceof BackupJsonError ||
err instanceof StreamedBackupInvalidError ||
err instanceof ZodError
);
}
/**
* Read a copy whole and work out its preview: the way every preview was
* taken before previews were stored. Throws for anything that is neither a
* read refusal nor a file that fails the schema, which the handler answers
* as a server fault.
*/
async function readPreview(
backup: StoredBackupRef,
copy: string,
): Promise<ReadOutcome> {
const refused = (err: unknown): ReadOutcome => {
const refusal = storedBackupRefusal(err);
return {
refusal: {
status: refusal.status,
message: refusal.message,
code: refusal.code,
},
};
};
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source;
try {
source = await openStoredBackup(prisma, backup);
} catch (err) {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
// is the first place an operator meets it.
return refused(err);
}
try {
const streamed = await readStreamedBackup(source);
const payload = parseBackupPayload(streamed.raw);
const summary = {
...summarizeBackup(payload),
measurements: streamed.measurementCount,
};
return { preview: buildBackupPreview(copy, summary, streamed.keys) };
} catch (err) {
if (isStoredBackupReadError(err)) return refused(err);
// Only a file that is not a backup this release reads is a 422. Anything
// else is this server failing, not the file, and is answered as such.
if (isPayloadInvalid(err)) {
return {
refusal: {
status: 422,
message: "Backup payload failed schema validation",
},
};
}
throw err;
}
}
/**
* Keep a preview worked out by reading the copy, so the next request answers
* without reading it. Written only while the row still holds the copy it was
* read from; a failure here costs the next request the same read, nothing
* more, so it is noted and not raised.
*/
async function keepPreview(
backup: StoredBackupRef,
preview: BackupPreview,
): Promise<void> {
try {
await prisma.dataBackup.updateMany({
where: {
id: backup.id,
chunkStreamId: backup.chunkStreamId,
chunkCount: backup.chunkCount,
},
data: { preview: preview as unknown as Prisma.InputJsonValue },
});
} catch (err) {
annotate({
action: { name: "admin.backups.summary.keep_failed" },
meta: {
backupId: backup.id,
error: err instanceof Error ? err.name : "unknown",
},
});
}
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user: admin } = await requireAdmin();
@@ -53,6 +185,7 @@ export const GET = apiHandler(
where: { id },
select: {
...STORED_BACKUP_SELECT,
preview: true,
createdAt: true,
user: { select: { id: true, username: true } },
},
@@ -61,41 +194,58 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source: BackupSource;
try {
source = await openStoredBackup(prisma, backup);
} catch (err) {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
// is the first place an operator meets it.
const refusal = storedBackupRefusal(err);
return apiError(refusal.message, refusal.status, {
errorCode: refusal.code,
});
}
let payload;
let measurementCount: number;
let streamed: StreamedBackup;
try {
streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch (err) {
if (isStoredBackupReadError(err)) {
let preview = storedPreviewFor(backup.preview, backup);
const fromStore = preview !== null;
if (preview) {
// The counts were kept; the copy's pieces are still counted, so a copy
// that lost one since is refused here as the restore would refuse it.
// Tampering inside a piece is found by the restore's own open, before
// it deletes anything.
try {
await checkStoredBackupShape(prisma, backup);
} catch (err) {
if (!isStoredBackupReadError(err)) throw err;
const refusal = storedBackupRefusal(err);
return apiError(refusal.message, refusal.status, {
errorCode: refusal.code,
return refusalResponse({
status: refusal.status,
message: refusal.message,
code: refusal.code,
});
}
return apiError("Backup payload failed schema validation", 422);
}
if (!preview) {
const copy = storedCopyIdentity(backup);
if (copy === null) {
// A row holding both forms, or neither: the open refuses it, with
// the reason.
const outcome = await readPreview(backup, "");
if ("refusal" in outcome) return refusalResponse(outcome.refusal);
throw new Error("A stored backup with no nameable copy was read");
}
const flight = `${backup.id}:${copy}`;
let pending = inFlight.get(flight);
if (!pending) {
pending = readPreview(backup, copy).finally(() => {
inFlight.delete(flight);
});
inFlight.set(flight, pending);
// Kept whatever the verdicts below say: they are taken again on
// every read, and the counts do not change.
void pending.then(
(outcome) =>
"preview" in outcome ? keepPreview(backup, outcome.preview) : null,
() => null,
);
}
const outcome = await pending;
if ("refusal" in outcome) return refusalResponse(outcome.refusal);
preview = outcome.preview;
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
const { summary } = preview;
if (!isCompatibleSchemaVersion(summary.schemaVersion)) {
return apiError(
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
`Backup schema version ${summary.schemaVersion} is not restorable by this release`,
422,
);
}
@@ -105,9 +255,10 @@ export const GET = apiHandler(
// does not have. The instance settings are left out here, as the restore
// leaves them out unless asked; the restore itself checks them when they
// are asked for.
const keyVerdict = assessBackupKeys(streamed.keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyVerdict = assessBackupKeys(
BackupKeyIdCollector.fromStored(preview.keys),
{ ignoreSections: new Set(["appSettings"]) },
);
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
return apiError(keyProblem, 422, {
@@ -116,14 +267,9 @@ export const GET = apiHandler(
});
}
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
};
annotate({
action: { name: "admin.backups.summary" },
meta: { backupId: id, ownerId: backup.userId },
meta: { backupId: id, ownerId: backup.userId, fromStore },
});
return apiSuccess({
@@ -133,3 +279,11 @@ export const GET = apiHandler(
});
},
);
function refusalResponse(refusal: Refusal) {
return apiError(
refusal.message,
refusal.status,
refusal.code ? { errorCode: refusal.code } : undefined,
);
}
@@ -0,0 +1,136 @@
/**
* The restore dialog's contents box asks once and says honestly why it has
* nothing to show.
*
* A copy from an earlier version is read whole on the server, which took two
* minutes for a large record; the dialog stopped waiting, retried, and the
* server read the whole copy a second time (#1031). Pinned here: the preview
* query is never retried and carries its own timeout, and a timeout reads as
* "still being read", not as "could not be read".
*
* Mutation check: drop `retry: false` and the first case goes red; classify a
* timeout as unavailable and the second goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
const mocks = vi.hoisted(() => ({
queries: [] as Array<Record<string, unknown>>,
apiGet: vi.fn(async () => ({ summary: {} })),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/backups",
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/api-fetch")>();
return { ...actual, apiGet: mocks.apiGet };
});
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: Record<string, unknown>) => {
mocks.queries.push(options);
return {
data: {
rows: [
{
id: "b1",
userId: "u1",
username: "self-hoster",
type: "WEEKLY_AUTO",
sizeBytes: 1024,
createdAt: "2026-09-01T03:00:00.000Z",
},
],
retentionDays: 30,
schedule: {
lastSuccessAt: "2026-09-01T03:00:00.000Z",
lastSuccessAgeDays: 2,
staleAfterDays: 10,
stale: false,
lastRun: null,
lastRunFailed: false,
},
},
isLoading: false,
isError: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
variables: undefined,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "ADMIN" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { BackupsSection, restorePreviewFailure } from "../backups-section";
describe("restore preview query", () => {
it("is never retried and waits a bounded time", async () => {
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BackupsSection />
</I18nProvider>,
);
const key = JSON.stringify(queryKeys.adminBackupSummary("b1"));
const preview = mocks.queries.find(
(options) => JSON.stringify(options.queryKey) === key,
);
expect(preview).toBeDefined();
expect(preview!.retry).toBe(false);
await (preview!.queryFn as () => Promise<unknown>)();
const [path, init] = mocks.apiGet.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe("/api/admin/backups/b1/summary");
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
describe("restorePreviewFailure", () => {
it("reads a timeout as a copy still being read", () => {
const timeout = new DOMException("signal timed out", "TimeoutError");
expect(restorePreviewFailure(timeout)).toBe("stillReading");
});
it("names a missing key", () => {
expect(
restorePreviewFailure(
new ApiError("key", 422, {
errorCode: "backup.key.missing",
keyIds: ["v1"],
}),
),
).toBe("keyMissing");
});
it("calls everything else unavailable", () => {
expect(restorePreviewFailure(new ApiError("boom", 500))).toBe(
"unavailable",
);
expect(restorePreviewFailure(new TypeError("offline"))).toBe("unavailable");
});
});
+50 -6
View File
@@ -90,6 +90,39 @@ import {
* Mirrors the wipe dialog's pattern but adds the typed gate because the
* blast radius is bigger (re-creates rows, not just deletes).
*/
/**
* How long the restore dialog waits for the contents of a copy. A copy's
* counts are kept with it and come back at once; this only runs out on a copy
* from an earlier version that the server is still reading whole, and stays
* under the minute a reverse proxy commonly allows a request.
*/
const PREVIEW_TIMEOUT_MS = 55_000;
/**
* What the restore dialog says when the contents did not come back: the key
* the copy needs and this server lacks, a copy from an earlier version still
* being read when the dialog stopped waiting, or anything else.
*/
export function restorePreviewFailure(
error: unknown,
): "keyMissing" | "stillReading" | "unavailable" {
if (
error instanceof ApiError &&
error.meta?.errorCode === "backup.key.missing"
) {
return "keyMissing";
}
// `AbortSignal.timeout` rejects the fetch with a DOMException of this name.
if (
typeof error === "object" &&
error !== null &&
(error as { name?: unknown }).name === "TimeoutError"
) {
return "stillReading";
}
return "unavailable";
}
/**
* v1.37.20 — the sections a restore preview names, in render order. Each
* entry sums one or more `BackupSummary` counters under one human label;
@@ -151,18 +184,25 @@ function RestoreRowDialog({
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
// v1.37.20 — restore preview: fetch what the file contains the moment the
// dialog opens, so the typed confirmation is an informed one. Derived from
// the same decrypt + schema path the restore itself runs. A failed preview
// never blocks the restore — it states its own absence instead.
// dialog opens, so the typed confirmation is an informed one. A failed
// preview never blocks the restore — it states its own absence instead.
//
// Never retried. The server answers from counts kept with the copy, at
// once; only a copy from an earlier version is read whole, and that read
// goes on (and is kept) after this request gives up. A retry would only
// start the same read a second time (#1031).
const preview = useQuery({
queryKey: queryKeys.adminBackupSummary(row.id),
queryFn: () =>
apiGet<{ summary: Record<string, unknown> }>(
`/api/admin/backups/${row.id}/summary`,
{ signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) },
),
enabled: open,
staleTime: 60_000,
retry: false,
});
const previewFailure = restorePreviewFailure(preview.error);
const numericSummary: Record<string, number> = {};
if (preview.data?.summary) {
for (const [key, value] of Object.entries(preview.data.summary)) {
@@ -239,17 +279,21 @@ function RestoreRowDialog({
</p>
)}
{preview.isError &&
(preview.error instanceof ApiError &&
preview.error.meta?.errorCode === "backup.key.missing" ? (
(previewFailure === "keyMissing" &&
preview.error instanceof ApiError ? (
// The one preview failure the restore would repeat: say which
// key, so the operator can put it back before trying.
<p role="alert" className="text-destructive">
{t("admin.section.backups.previewKeyMissing", {
keys: Array.isArray(preview.error.meta.keyIds)
keys: Array.isArray(preview.error.meta?.keyIds)
? preview.error.meta.keyIds.map(String).join(", ")
: "",
})}
</p>
) : previewFailure === "stillReading" ? (
<p className="text-muted-foreground">
{t("admin.section.backups.previewStillReading")}
</p>
) : (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
@@ -0,0 +1,222 @@
/**
* The preview worked out while a copy is written has to say what the preview
* route used to derive by reading the stored copy: the same counts, the same
* key uses. Held here against that derivation over the same file, fed in
* pieces that split values, strings and multi-byte characters.
*/
import { afterEach, describe, expect, it } from "vitest";
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
import {
BackupKeyIdCollector,
assessBackupKeys,
} from "@/lib/export/backup-key-ids";
import {
buildBackupPreview,
createBackupPreviewScanner,
storedCopyIdentity,
storedPreviewFor,
} from "@/lib/export/backup-preview";
import { backupPayloadSchema, summarizeBackup } from "@/lib/validations/backup";
const ts = "2026-07-19T07:00:00.000Z";
/** A bytes-column value holding the string codec under key id `v3`. */
const sealedV3 = Buffer.from(`v3.${"QUJD".repeat(20)}`, "utf8").toString(
"base64",
);
const FILE = {
schemaVersion: "2",
exportedAt: ts,
userId: "u1",
measurements: [1, 2, 3].map((i) => ({
id: `m${i}`,
type: "WEIGHT",
value: 70 + i,
unit: "kg",
measuredAt: ts,
source: "MANUAL",
notes: "Grüße, ☕",
...(i === 2 ? { notesEncrypted: sealedV3 } : {}),
})),
intakeEvents: [
{ medication: "Example", scheduledFor: ts, source: "WEB" },
{ medication: "Example", scheduledFor: ts, source: "WEB" },
],
moodEntries: [
{
date: "2026-05-08",
mood: "GUT",
score: 4,
source: "MOODLOG",
loggedAt: ts,
},
],
labResults: [{ analyte: "HbA1c", unit: "%", takenAt: ts, source: "MANUAL" }],
illnessEpisodes: [
{
id: "ep-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: ts,
dayLogs: [
{ date: "2026-04-01", symptoms: [] },
{ date: "2026-04-02", symptoms: [] },
],
},
],
};
async function scan(pieces: Array<string | Uint8Array>) {
const scanner = createBackupPreviewScanner();
for (const piece of pieces) await scanner.feed(piece);
return scanner.finish();
}
function split(text: string, size: number): Uint8Array[] {
const bytes = Buffer.from(text, "utf8");
const out: Uint8Array[] = [];
for (let at = 0; at < bytes.length; at += size) {
out.push(bytes.subarray(at, at + size));
}
return out;
}
describe("createBackupPreviewScanner", () => {
const json = JSON.stringify(FILE);
// What the preview route derived from a stored copy.
const expected = summarizeBackup(backupPayloadSchema.parse(FILE));
const expectedKeys = new BackupKeyIdCollector();
expectedKeys.visit(FILE);
it.each([1, 7, 64, 100_000])(
"counts what the full read counts, in pieces of %i bytes",
async (size) => {
const result = await scan(split(json, size));
expect(result).not.toBeNull();
expect(result!.summary).toEqual(expected);
expect(result!.summary.measurements).toBe(3);
expect(result!.summary.intakeEvents).toBe(2);
expect(result!.summary.moodEntries).toBe(1);
expect(result!.summary.illnessDayLogs).toBe(2);
expect(result!.keys.toStored()).toEqual(expectedKeys.toStored());
expect(result!.keys.keyIds()).toEqual(["v3"]);
},
);
it("takes text pieces the way the weekly writer hands them over", async () => {
const result = await scan([json.slice(0, 50), json.slice(50)]);
expect(result!.summary).toEqual(expected);
});
it("answers null for a file that is not a backup, and never holds the writer", async () => {
expect(await scan(["{ not json", "more", "and more"])).toBeNull();
expect(await scan([JSON.stringify({ schemaVersion: 2 })])).toBeNull();
expect(await scan([json.slice(0, 40)])).toBeNull();
});
});
describe("storedPreviewFor", () => {
const chunked = { data: null, chunkCount: 4, chunkStreamId: "s1" };
const preview = () =>
buildBackupPreview(
storedCopyIdentity(chunked)!,
summarizeBackup(backupPayloadSchema.parse(FILE)),
new BackupKeyIdCollector(),
);
it("reads a preview of the copy the row holds", () => {
const stored = JSON.parse(JSON.stringify(preview()));
expect(storedPreviewFor(stored, chunked)?.summary.measurements).toBe(3);
});
it("ignores a preview of another copy", () => {
expect(
storedPreviewFor(preview(), { ...chunked, chunkStreamId: "s2" }),
).toBeNull();
expect(
storedPreviewFor(preview(), { ...chunked, chunkCount: 5 }),
).toBeNull();
});
it("describes nothing on a row holding both forms, or none", () => {
expect(storedPreviewFor(preview(), { ...chunked, data: "x" })).toBeNull();
expect(
storedPreviewFor(preview(), {
data: null,
chunkCount: null,
chunkStreamId: null,
}),
).toBeNull();
});
it("ignores a malformed preview", () => {
expect(storedPreviewFor({ version: 1 }, chunked)).toBeNull();
expect(storedPreviewFor(null, chunked)).toBeNull();
});
it("names a single stored value by its length and head", () => {
const a = storedCopyIdentity({
data: "v1.aaaa",
chunkCount: null,
chunkStreamId: null,
});
const b = storedCopyIdentity({
data: "v1.aaab",
chunkCount: null,
chunkStreamId: null,
});
expect(a).toMatch(/^value:[0-9a-f]{64}$/);
expect(a).not.toBe(b);
});
});
describe("BackupKeyIdCollector.toStored / fromStored", () => {
const saved = {
key: process.env.ENCRYPTION_KEY,
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
function useKeys(keys: Record<string, string>, active: string) {
delete process.env.ENCRYPTION_KEY;
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
_resetCryptoCacheForTests();
}
afterEach(() => {
for (const [name, value] of [
["ENCRYPTION_KEY", saved.key],
["ENCRYPTION_KEYS", saved.keys],
["ENCRYPTION_ACTIVE_KEY_ID", saved.active],
] as const) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
_resetCryptoCacheForTests();
});
it("keeps what the verdict needs, so it can be taken again later", () => {
// Written under `old` and `other` on the host that made the copy.
useKeys({ old: "11".repeat(32), other: "22".repeat(32) }, "old");
const collector = new BackupKeyIdCollector();
collector.add("moodEntries", encrypt("a note"));
process.env.ENCRYPTION_ACTIVE_KEY_ID = "other";
_resetCryptoCacheForTests();
collector.add("labResults", encrypt("another note"));
collector.add("appSettings", encrypt("a setting"));
const stored = JSON.parse(JSON.stringify(collector.toStored()));
const options = { ignoreSections: new Set(["appSettings"]) };
// Later, on a host that dropped `old` and holds different material
// under `other`.
useKeys({ other: "33".repeat(32) }, "other");
const back = BackupKeyIdCollector.fromStored(stored);
const verdict = assessBackupKeys(back, options);
expect(verdict.missing).toEqual(["old"]);
expect(verdict.unreadable).toEqual(["other"]);
expect(verdict).toEqual(assessBackupKeys(collector, options));
expect(back.toStored()).toEqual(collector.toStored());
});
});
+37
View File
@@ -185,6 +185,43 @@ export class BackupKeyIdCollector {
entries(): ReadonlyMap<string, KeyUse> {
return this.uses;
}
/**
* What was collected, as plain data, for a copy's stored preview
* (`backup-preview.ts`): enough to hold it against this host's keys again
* later without reading the copy.
*/
toStored(): StoredKeyUse[] {
return [...this.uses.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([keyId, use]) => ({
keyId,
count: use.count,
sections: [...use.sections].sort(),
sample: use.sample ? { ...use.sample } : null,
}));
}
/** The collector {@link toStored} was taken from. */
static fromStored(stored: readonly StoredKeyUse[]): BackupKeyIdCollector {
const collector = new BackupKeyIdCollector();
for (const entry of stored) {
collector.uses.set(entry.keyId, {
count: entry.count,
sections: new Set(entry.sections),
sample: entry.sample ? { ...entry.sample } : null,
});
}
return collector;
}
}
/** One key id a copy needs, as {@link BackupKeyIdCollector.toStored} keeps it. */
export interface StoredKeyUse {
keyId: string;
count: number;
sections: string[];
sample: { value: string; form: InnerCiphertextForm } | null;
}
export interface BackupKeyAssessment {
+223
View File
@@ -0,0 +1,223 @@
/**
* What the restore preview shows for a stored backup, worked out while the
* copy is written and kept on its row (`DataBackup.preview`).
*
* Why. The preview used to read the whole stored copy inside the request that
* asked for it: open every piece, parse every measurement, validate the rest.
* For an account of 1.8 million readings on slow hardware that took two
* minutes, the dialog gave up long before, and the operator was told the
* contents could not be read (#1031). The copy passes through this process
* once anyway, on its way into the pieces, so the counts are taken there.
*
* What is kept. The `summarizeBackup` counts of the copy, its schema version,
* and the inner key ids with the sections they sit in and one sample value
* each. The verdicts are not kept: whether this release can restore the
* schema version, and whether this server holds the keys the copy needs, can
* both change after the copy was written, so the route takes them again from
* these inputs on every read.
*
* Which copy. A preview names the copy it describes (`copy`), and a reader
* ignores one that names another: a copy replaced by a writer that does not
* know about previews (an older release, after a downgrade) must not be
* described by the numbers of the one it replaced.
*/
import { createHash } from "node:crypto";
import { z } from "zod/v4";
import {
BackupKeyIdCollector,
type StoredKeyUse,
} from "@/lib/export/backup-key-ids";
import { scanBackupJson } from "@/lib/export/backup-json-scan";
import type { StoredBackupRef } from "@/lib/export/stored-backup";
import {
backupPayloadSchema,
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup";
export interface BackupPreview {
version: 1;
/** The copy this describes (`storedCopyIdentity`). */
copy: string;
summary: BackupSummary;
keys: StoredKeyUse[];
}
const storedPreviewSchema = z.object({
version: z.literal(1),
copy: z.string().min(1),
summary: z
.object({
schemaVersion: z.string(),
userId: z.string(),
exportedAt: z.string(),
measurements: z.number(),
})
.catchall(z.union([z.string(), z.number()])),
keys: z.array(
z.object({
keyId: z.string(),
count: z.number().int().nonnegative(),
sections: z.array(z.string()),
sample: z
.object({
value: z.string(),
form: z.enum(["string", "bytes-string", "binary"]),
})
.nullable(),
}),
),
});
/**
* How a single stored value is told apart from another: its length and its
* first characters, which carry the key id and the random nonce of the
* envelope. Hashed, so the preview does not repeat the envelope's head.
*/
export function singleValueIdentity(length: number, head: string): string {
return `value:${createHash("sha256").update(`${length}:${head}`).digest("hex")}`;
}
/** How many leading characters of a single stored value the identity reads. */
export const SINGLE_VALUE_IDENTITY_HEAD = 128;
/**
* The name of the copy a row holds now, or null for a row nothing may
* describe: one holding both forms, or neither.
*/
export function storedCopyIdentity(
backup: Pick<StoredBackupRef, "data" | "chunkCount" | "chunkStreamId">,
): string | null {
if (backup.data != null && backup.chunkStreamId != null) return null;
if (backup.chunkStreamId != null) {
return `chunks:${backup.chunkStreamId}:${backup.chunkCount ?? ""}`;
}
if (backup.data != null) {
return singleValueIdentity(
backup.data.length,
backup.data.slice(0, SINGLE_VALUE_IDENTITY_HEAD),
);
}
return null;
}
export function buildBackupPreview(
copy: string,
summary: BackupSummary,
keys: BackupKeyIdCollector,
): BackupPreview {
return { version: 1, copy, summary, keys: keys.toStored() };
}
/**
* The stored preview of a row, when it describes the copy the row holds now;
* null when there is none, it is malformed, or it describes another copy.
*/
export function storedPreviewFor(
stored: unknown,
backup: Pick<StoredBackupRef, "data" | "chunkCount" | "chunkStreamId">,
): BackupPreview | null {
const copy = storedCopyIdentity(backup);
if (copy === null || stored == null) return null;
const parsed = storedPreviewSchema.safeParse(stored);
if (!parsed.success || parsed.data.copy !== copy) return null;
return parsed.data as unknown as BackupPreview;
}
/**
* The sections that grow with a record. Their elements are counted and their
* key ids read as they pass, and never kept, so the scan holds no more of the
* copy than the writer itself does (`full-backup-stream.ts`).
*/
const STREAMED = new Set(["measurements", "intakeEvents", "moodEntries"]);
export interface BackupPreviewScanner {
/**
* Hand over the next piece of the JSON. Resolves once the scan has taken
* it, so a writer never runs more than a piece ahead of the scan.
*/
feed(piece: string | Uint8Array): Promise<void>;
/**
* The counts and key uses, once every piece has been fed; null when the
* JSON was not a backup the schema accepts. Never rejects: a copy is
* stored whether or not its preview could be worked out.
*/
finish(): Promise<{
summary: BackupSummary;
keys: BackupKeyIdCollector;
} | null>;
}
/**
* Work out a preview from a copy's JSON as it is written. The counts are the
* ones the preview route derives from the stored copy (`summarizeBackup` over
* the parsed file), the three bulk sections counted rather than parsed.
*/
export function createBackupPreviewScanner(): BackupPreviewScanner {
const queue: Array<{ chunk: Uint8Array; taken: () => void }> = [];
let ended = false;
let stopped = false;
let wake: (() => void) | null = null;
const signal = () => {
const w = wake;
wake = null;
w?.();
};
async function* pieces(): AsyncGenerator<Uint8Array> {
for (;;) {
const next = queue.shift();
if (next) {
next.taken();
yield next.chunk;
continue;
}
if (ended) return;
await new Promise<void>((resolve) => {
wake = resolve;
});
}
}
const keys = new BackupKeyIdCollector();
const result = scanBackupJson(pieces(), {
streamKeys: STREAMED,
onElement: (key, element) => keys.visit(element, key),
})
.then(({ document, streamedCounts }) => {
keys.visit(document);
const parsed = backupPayloadSchema.safeParse(document);
if (!parsed.success) return null;
const summary: BackupSummary = {
...summarizeBackup(parsed.data),
measurements: streamedCounts.measurements ?? 0,
intakeEvents: streamedCounts.intakeEvents ?? 0,
moodEntries: streamedCounts.moodEntries ?? 0,
};
return { summary, keys };
})
.catch(() => null)
.finally(() => {
// A scan that stopped early takes nothing more: release the writer.
stopped = true;
for (const waiting of queue.splice(0)) waiting.taken();
});
return {
feed(piece) {
if (stopped) return Promise.resolve();
const chunk =
typeof piece === "string" ? Buffer.from(piece, "utf8") : piece;
return new Promise<void>((taken) => {
queue.push({ chunk, taken });
signal();
});
},
finish() {
ended = true;
signal();
return result;
},
};
}
+66 -5
View File
@@ -23,7 +23,7 @@
* this module exists to remove. A model create is planned once for its shape
* and keeps nothing.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import { isStreamCiphertext, openStreamDecryptor } from "@/lib/crypto";
import {
@@ -35,6 +35,14 @@ import {
type PackBackupOptions,
} from "@/lib/export/backup-blob";
import { createBackupKeyIdTextScanner } from "@/lib/export/backup-key-ids";
import {
buildBackupPreview,
createBackupPreviewScanner,
SINGLE_VALUE_IDENTITY_HEAD,
singleValueIdentity,
storedCopyIdentity,
type BackupPreview,
} from "@/lib/export/backup-preview";
import {
BackupIntegrityError,
newChunkStreamId,
@@ -118,6 +126,9 @@ export async function storeBackupBlob(
// what is inside them, so this is what tells an operator how long a
// retired key is still needed (`GET /api/admin/encryption/status`).
const keyScanner = createBackupKeyIdTextScanner();
// And what the restore preview shows, from the same pass, so the
// preview never has to read the copy back (`backup-preview.ts`).
const previewScanner = createBackupPreviewScanner();
const { chunks, bytes } = await packBackupChunks(
async (sealed, seq) => {
await tx.dataBackupChunk.create({
@@ -129,10 +140,12 @@ export async function storeBackupBlob(
(write) =>
producer(async (chunk) => {
keyScanner.feed(chunk);
await previewScanner.feed(chunk);
await write(chunk);
}),
options,
);
const scanned = await previewScanner.finish();
// Again at the end: a restore may have been queued while this ran. The
// window left is the few milliseconds to the commit, and a restore
@@ -147,6 +160,18 @@ export async function storeBackupBlob(
chunkStreamId: streamId,
innerKeyIds: keyScanner.keyIds(),
innerKeyIdsRecorded: true,
// Prisma's JSON null clears a preview left by the previous copy.
preview: scanned
? (buildBackupPreview(
storedCopyIdentity({
data: null,
chunkCount: chunks,
chunkStreamId: streamId,
})!,
scanned.summary,
scanned.keys,
) as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
createdAt: new Date(),
},
});
@@ -156,6 +181,17 @@ export async function storeBackupBlob(
);
}
/** A single value's stored preview, when it describes that value. */
function previewOfSingleValue(
stored: unknown,
len: number,
head: string,
): BackupPreview | null {
if (!stored || typeof stored !== "object") return null;
const preview = stored as BackupPreview;
return preview.copy === singleValueIdentity(len, head) ? preview : null;
}
/**
* The first characters of a single stored value, which carry its key id, or
* null when the row no longer holds one. For the key-rotation scan, which must
@@ -208,10 +244,16 @@ export async function convertSingleValueBackup(
`SET LOCAL idle_in_transaction_session_timeout = '${STORE_IDLE_TIMEOUT}'`,
);
const [row] = await tx.$queryRaw<
Array<{ head: string; len: number; stream_id: string | null }>
Array<{
head: string;
len: number;
stream_id: string | null;
preview: unknown;
}>
>`
SELECT left(data, 128) AS head, length(data)::int AS len,
chunk_stream_id AS stream_id
SELECT left(data, ${SINGLE_VALUE_IDENTITY_HEAD}::int) AS head,
length(data)::int AS len,
chunk_stream_id AS stream_id, preview
FROM data_backups
WHERE id = ${id} AND data IS NOT NULL
FOR UPDATE
@@ -272,9 +314,28 @@ export async function convertSingleValueBackup(
// limit on writing a new one.
{ maxBytes: Number.MAX_SAFE_INTEGER },
);
// The content is the same copy in another form, so a preview of it
// still holds; it is moved over to the new form's name.
const preview = previewOfSingleValue(row.preview, row.len, row.head);
await tx.dataBackup.update({
where: { id },
data: { data: null, chunkCount: chunks, chunkStreamId: streamId },
data: {
data: null,
chunkCount: chunks,
chunkStreamId: streamId,
...(preview
? {
preview: {
...preview,
copy: storedCopyIdentity({
data: null,
chunkCount: chunks,
chunkStreamId: streamId,
})!,
} as unknown as Prisma.InputJsonValue,
}
: {}),
},
});
return "converted";
},
+66 -18
View File
@@ -141,6 +141,71 @@ async function* openedChunks(
}
}
/**
* Refuse a chunked copy whose row does not list as many pieces as are stored:
* one missing, or one this code never wrote. A count, without reading a
* piece; `openStoredBackup` goes on to authenticate every piece.
*/
async function checkStoredBackupPieceCount(
prisma: ChunkReader,
id: string,
streamId: string,
count: number,
): Promise<void> {
if (count < 1) {
throw new BackupIntegrityError("The stored copy lists no pieces.");
}
// Pieces beyond the count would be ignored by the reads, but they are not
// something this code ever writes, so their presence is refused too.
const stored = await prisma.dataBackupChunk.count({
where: { backupId: id },
});
if (stored !== count) {
const now = await prisma.dataBackup.findUnique({
where: { id },
select: { chunkStreamId: true },
});
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
throw new BackupIntegrityError(
`The stored copy lists ${count} pieces but ${stored} are stored.`,
);
}
}
/**
* The checks on a stored copy that need no piece read: both forms, a count
* without a stream id or the reverse, no content, and the pieces a chunked
* copy lists against those stored. Throws what `openStoredBackup` would
* throw for the same row. For a reader that answers from what was kept about
* the copy (the restore preview) and must still not describe a copy the
* restore would refuse on sight.
*/
export async function checkStoredBackupShape(
prisma: ChunkReader,
backup: StoredBackupRef,
): Promise<void> {
if (backup.data != null && backup.chunkStreamId != null) {
throw new BackupFormsConflictError();
}
if ((backup.chunkStreamId == null) !== (backup.chunkCount == null)) {
throw new BackupIntegrityError(
"The stored copy lists pieces without saying how many, or the reverse.",
);
}
if (backup.chunkStreamId == null || backup.chunkCount == null) {
if (backup.data == null) {
throw new BackupIntegrityError("The stored copy has no content.");
}
return;
}
await checkStoredBackupPieceCount(
prisma,
backup.id,
backup.chunkStreamId,
backup.chunkCount,
);
}
/**
* Open a stored copy as a source of its JSON bytes, openable as many times as
* the caller needs. Rejects when the copy cannot be read whole: a key that is
@@ -166,24 +231,7 @@ export async function openStoredBackup(
return openBackupBlob(backup.data);
}
const { id, chunkStreamId: streamId, chunkCount: count } = backup;
if (count < 1) {
throw new BackupIntegrityError("The stored copy lists no pieces.");
}
// Pieces beyond the count would be ignored by the reads below, but they are
// not something this code ever writes, so their presence is refused too.
const stored = await prisma.dataBackupChunk.count({
where: { backupId: id },
});
if (stored !== count) {
const now = await prisma.dataBackup.findUnique({
where: { id },
select: { chunkStreamId: true },
});
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
throw new BackupIntegrityError(
`The stored copy lists ${count} pieces but ${stored} are stored.`,
);
}
await checkStoredBackupPieceCount(prisma, id, streamId, count);
// The whole copy, checked once before anyone reads a byte of it.
for await (const piece of openedChunks(prisma, id, streamId, count)) {
void piece;