Files
HealthLog/src/lib/export/backup-preview.ts
T
Marc-André Bombeck a2fceb5585 fix(backups): answer the restore preview from counts kept with the copy
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.

The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.

The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.

A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.

Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.

The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.

Refs #1031
2026-09-30 22:51:02 +02:00

224 lines
7.2 KiB
TypeScript

/**
* 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;
},
};
}