mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from getDatabaseBackupHealth, and the dump test reaches the off-host key id where it moved.
This commit is contained in:
commit
e88dcd2c53
99 files changed
+6106
-492
No files matched your search
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"registeredQueueCount": 122,
|
||||
"registeredQueueCount": 123,
|
||||
"registeredQueues": [
|
||||
"achievement-unlock-sweep",
|
||||
"apple-health-import",
|
||||
@@ -23,6 +23,7 @@
|
||||
"data-backup",
|
||||
"data-backup-offhost",
|
||||
"data-restore-drill",
|
||||
"database-backup",
|
||||
"dawarich-sync",
|
||||
"dense-intraday-hourly-rebuild",
|
||||
"dense-intraday-retention",
|
||||
@@ -455,6 +456,21 @@
|
||||
"retry_delay_max": null,
|
||||
"retry_limit": 2
|
||||
},
|
||||
{
|
||||
"dead_letter": null,
|
||||
"deletion_seconds": 604800,
|
||||
"expire_seconds": 900,
|
||||
"heartbeat_seconds": null,
|
||||
"name": "database-backup",
|
||||
"notify": false,
|
||||
"partition": false,
|
||||
"policy": "standard",
|
||||
"retention_seconds": 1209600,
|
||||
"retry_backoff": false,
|
||||
"retry_delay": 0,
|
||||
"retry_delay_max": null,
|
||||
"retry_limit": 2
|
||||
},
|
||||
{
|
||||
"dead_letter": null,
|
||||
"deletion_seconds": 604800,
|
||||
@@ -2137,6 +2153,21 @@
|
||||
},
|
||||
"timezone": "Europe/Berlin"
|
||||
},
|
||||
{
|
||||
"cron": "30 1 * * *",
|
||||
"data": {},
|
||||
"key": "",
|
||||
"kind": "cron",
|
||||
"name": "database-backup",
|
||||
"options": {
|
||||
"expireInSeconds": 14400,
|
||||
"heartbeatSeconds": 300,
|
||||
"retryDelay": 900,
|
||||
"retryLimit": 1,
|
||||
"tz": "Europe/Berlin"
|
||||
},
|
||||
"timezone": "Europe/Berlin"
|
||||
},
|
||||
{
|
||||
"cron": "20 3 * * *",
|
||||
"data": {},
|
||||
@@ -3022,6 +3053,13 @@
|
||||
"pollingIntervalSeconds": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"queue": "database-backup",
|
||||
"options": {
|
||||
"localConcurrency": 1,
|
||||
"pollingIntervalSeconds": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"queue": "dawarich-sync",
|
||||
"options": {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The steps of a long Coach answer survive a reload against a real Postgres.
|
||||
*
|
||||
* The turn records up to `MAX_TURN_STEPS` steps on `metricSource.steps`; the
|
||||
* read path restores them from `coach_messages.metric_source_json`. Both ends
|
||||
* read the one constant, so a full turn's worth comes back whole and an
|
||||
* over-long stored blob is cut at that cap, not before it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
appendMessage,
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
function step(n: number): CoachStep {
|
||||
return {
|
||||
id: `s${n}`,
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Read the last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: n,
|
||||
};
|
||||
}
|
||||
|
||||
const RECORDED = Array.from({ length: MAX_TURN_STEPS }, (_, i) => step(i + 1));
|
||||
|
||||
async function seedConversation(): Promise<{
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
}> {
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: { username: "steps-owner", role: "USER" },
|
||||
});
|
||||
const convo = await createConversation({ userId: user.id, title: "long" });
|
||||
return { userId: user.id, conversationId: convo.id };
|
||||
}
|
||||
|
||||
describe("Coach steps across a reload", () => {
|
||||
it("restores every step of a full turn", async () => {
|
||||
const { userId, conversationId } = await seedConversation();
|
||||
const reply = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: "A long answer.",
|
||||
metricSource: { windows: [], metrics: [], steps: RECORDED },
|
||||
});
|
||||
|
||||
const reloaded = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
const message = reloaded?.messages.find((m) => m.id === reply.id);
|
||||
expect(message?.metricSource?.steps).toHaveLength(MAX_TURN_STEPS);
|
||||
expect(message?.metricSource?.steps).toEqual(RECORDED);
|
||||
});
|
||||
|
||||
it("cuts an over-long stored blob at the cap, not before it", async () => {
|
||||
const { userId, conversationId } = await seedConversation();
|
||||
const reply = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: "An over-long blob.",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
await getPrismaClient().coachMessage.update({
|
||||
where: { id: reply.id },
|
||||
data: {
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [...RECORDED, step(MAX_TURN_STEPS + 1)],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const reloaded = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
const message = reloaded?.messages.find((m) => m.id === reply.id);
|
||||
expect(message?.metricSource?.steps).toEqual(RECORDED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* The nightly whole-database dump against a real Postgres: the job writes a
|
||||
* dump and its manifest, records the run, copies both off-host in the
|
||||
* envelope the restore opens, keeps only what retention allows; and the
|
||||
* restore puts the dump into an empty database and refuses one with tables.
|
||||
*
|
||||
* `pg_dump` and `pg_restore` are the test container's own. A workstation may
|
||||
* have none, or one of another major than the server (which `pg_dump`
|
||||
* refuses), so the test puts two small forwarders on PATH that run the tool
|
||||
* inside the database container with `docker exec`. Everything else, the
|
||||
* snapshot, the counts, the manifest, the ledger and the restore's checks,
|
||||
* runs exactly as in the image.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getDatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import { runDatabaseBackup } from "@/lib/database-backup/run";
|
||||
import {
|
||||
connect,
|
||||
countRows,
|
||||
decryptOffhostFile,
|
||||
offhostObjectKey,
|
||||
readManifestFile,
|
||||
} from "@/lib/database-backup/dump-core.mjs";
|
||||
|
||||
import { main as restoreMain } from "../../scripts/db-restore.mjs";
|
||||
import { shippedMigrations } from "../../scripts/pending-migrations.mjs";
|
||||
|
||||
const OFFHOST_KEY = "e".repeat(64);
|
||||
const work = mkdtempSync(join(tmpdir(), "hl-db-backup-"));
|
||||
const backups = join(work, "backups");
|
||||
const shims = join(work, "bin");
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
/** The id of the container that serves DATABASE_URL. */
|
||||
function containerId(): string {
|
||||
const port = new URL(process.env.DATABASE_URL ?? "").port;
|
||||
const rows = execFileSync(
|
||||
"docker",
|
||||
["ps", "--format", "{{.ID}} {{.Ports}}"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
).split("\n");
|
||||
const row = rows.find((r) => r.includes(`:${port}->5432/tcp`));
|
||||
if (!row) throw new Error(`no container publishes port ${port}`);
|
||||
return row.split(" ")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* `pg_dump` / `pg_restore` that run inside the database container. The
|
||||
* connection comes from the PG* variables the core sets; host and port become
|
||||
* the container's own. A file argument that exists on this machine is sent on
|
||||
* standard input, which both tools read when no file is named.
|
||||
*/
|
||||
function writeShims(container: string): void {
|
||||
for (const tool of ["pg_dump", "pg_restore"]) {
|
||||
const path = join(shims, tool);
|
||||
writeFileSync(
|
||||
path,
|
||||
`#!/usr/bin/env node
|
||||
const { spawn } = require("node:child_process");
|
||||
const { createReadStream, existsSync } = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
let input = null;
|
||||
if (args.length && !args[args.length - 1].startsWith("-") && existsSync(args[args.length - 1])) {
|
||||
input = args.pop();
|
||||
}
|
||||
const env = [];
|
||||
for (const name of ["PGUSER", "PGPASSWORD", "PGDATABASE"]) {
|
||||
if (process.env[name] !== undefined) env.push("-e", name + "=" + process.env[name]);
|
||||
}
|
||||
env.push("-e", "PGHOST=127.0.0.1", "-e", "PGPORT=5432");
|
||||
const child = spawn("docker", ["exec", "-i", ...env, ${JSON.stringify(container)}, ${JSON.stringify(tool)}, ...args], {
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
(input ? createReadStream(input) : process.stdin).pipe(child.stdin);
|
||||
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
||||
`,
|
||||
);
|
||||
chmodSync(path, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
/** A bucket in memory, with the one call the job's uploader uses. */
|
||||
function memoryBucket() {
|
||||
const objects = new Map<string, Buffer>();
|
||||
return {
|
||||
objects,
|
||||
putStream: async (key: string, body: Readable) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of body) chunks.push(c as Buffer);
|
||||
objects.set(key, Buffer.concat(chunks));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function backupEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
||||
return { ...process.env, DB_BACKUP_DIR: backups, ...extra };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const { mkdirSync } = await import("node:fs");
|
||||
mkdirSync(shims, { recursive: true });
|
||||
mkdirSync(backups, { recursive: true });
|
||||
writeShims(containerId());
|
||||
process.env.PATH = `${shims}:${originalPath}`;
|
||||
for (const name of ["dump-a", "dump-b", "dump-c"]) {
|
||||
await prisma.user.create({
|
||||
data: { username: `${name}-${Date.now()}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.PATH = originalPath;
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the nightly database dump", () => {
|
||||
let dumpFile = "";
|
||||
|
||||
it("writes a checked dump and manifest, records the run and copies both off-host", async () => {
|
||||
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.invalid");
|
||||
vi.stubEnv("BACKUP_S3_BUCKET", "b");
|
||||
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a");
|
||||
vi.stubEnv("BACKUP_S3_SECRET_KEY", "s");
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_KEY", OFFHOST_KEY);
|
||||
const bucket = memoryBucket();
|
||||
const now = new Date("2026-10-20T01:30:00Z");
|
||||
|
||||
const report = await runDatabaseBackup(prisma, {
|
||||
now,
|
||||
env: backupEnv(),
|
||||
s3: bucket as never,
|
||||
});
|
||||
expect(report).toMatchObject({
|
||||
status: "succeeded",
|
||||
local: "written",
|
||||
offhost: "uploaded",
|
||||
error: null,
|
||||
fileName: "healthlog-20261020T013000Z-daily.dump",
|
||||
});
|
||||
dumpFile = join(backups, report.fileName ?? "");
|
||||
|
||||
// Both files, under their final names, and nothing half-written.
|
||||
expect(readdirSync(backups).sort()).toEqual([
|
||||
"healthlog-20261020T013000Z-daily.dump",
|
||||
"healthlog-20261020T013000Z-daily.manifest.json",
|
||||
]);
|
||||
expect(readFileSync(dumpFile).subarray(0, 5).toString("latin1")).toBe(
|
||||
"PGDMP",
|
||||
);
|
||||
|
||||
const manifest = await readManifestFile(
|
||||
join(backups, "healthlog-20261020T013000Z-daily.manifest.json"),
|
||||
);
|
||||
expect(manifest.kind).toBe("daily");
|
||||
expect(manifest.dump.bytes).toBe(report.dumpBytes);
|
||||
expect(manifest.latestMigration).toBe(
|
||||
shippedMigrations(join(process.cwd(), "prisma", "migrations")).at(-1),
|
||||
);
|
||||
expect(manifest.postgres.serverVersionNum).toBeGreaterThanOrEqual(160000);
|
||||
expect(manifest.rowCounts["public.users"]).toBe(await prisma.user.count());
|
||||
expect(Object.keys(manifest.rowCounts).length).toBeGreaterThan(100);
|
||||
expect(manifest.keys.encryption.keys.length).toBeGreaterThan(0);
|
||||
expect(manifest.keys.backupEncryptionKey).toMatch(/^[0-9a-f]{12}$/);
|
||||
|
||||
// The ledger row and what the admin page reads from it.
|
||||
const runs = await prisma.databaseBackupRun.findMany({
|
||||
where: { startedAt: now },
|
||||
});
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({
|
||||
status: "succeeded",
|
||||
fileName: "healthlog-20261020T013000Z-daily.dump",
|
||||
localResult: "written",
|
||||
offhostResult: "uploaded",
|
||||
error: null,
|
||||
});
|
||||
expect(Number(runs[0].dumpBytes)).toBe(manifest.dump.bytes);
|
||||
const health = await getDatabaseBackupHealth(
|
||||
new Date("2026-10-20T02:00:00Z"),
|
||||
backupEnv(),
|
||||
);
|
||||
expect(health).toMatchObject({
|
||||
enabled: true,
|
||||
localDirWritable: true,
|
||||
offhostConfigured: true,
|
||||
lastGood: {
|
||||
fileName: "healthlog-20261020T013000Z-daily.dump",
|
||||
sizeBytes: manifest.dump.bytes,
|
||||
local: true,
|
||||
offhost: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The off-host pair opens, with the restore's own reader, to the very
|
||||
// bytes on disk.
|
||||
expect([...bucket.objects.keys()].sort()).toEqual([
|
||||
"database/healthlog-20261020T013000Z-daily.dump.enc",
|
||||
"database/healthlog-20261020T013000Z-daily.manifest.json.enc",
|
||||
]);
|
||||
const opened = join(work, "opened");
|
||||
const { mkdirSync } = await import("node:fs");
|
||||
mkdirSync(opened);
|
||||
for (const name of [
|
||||
"healthlog-20261020T013000Z-daily.dump",
|
||||
"healthlog-20261020T013000Z-daily.manifest.json",
|
||||
]) {
|
||||
writeFileSync(
|
||||
join(opened, `${name}.enc`),
|
||||
bucket.objects.get(offhostObjectKey(`${name}.enc`)) as Buffer,
|
||||
);
|
||||
await decryptOffhostFile({
|
||||
inPath: join(opened, `${name}.enc`),
|
||||
outPath: join(opened, name),
|
||||
objectKey: offhostObjectKey(`${name}.enc`),
|
||||
keys: [Buffer.from(OFFHOST_KEY, "hex")],
|
||||
});
|
||||
expect(
|
||||
readFileSync(join(opened, name)).equals(
|
||||
readFileSync(join(backups, name)),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("keeps DB_BACKUP_KEEP nightly dumps and removes the oldest", async () => {
|
||||
for (const day of ["21", "22", "23"]) {
|
||||
const report = await runDatabaseBackup(prisma, {
|
||||
now: new Date(`2026-10-${day}T01:30:00Z`),
|
||||
env: backupEnv({ DB_BACKUP_KEEP: "2" }),
|
||||
});
|
||||
expect(report.status).toBe("succeeded");
|
||||
expect(report.offhost).toBe("not_configured");
|
||||
}
|
||||
expect(readdirSync(backups).filter((n) => n.endsWith(".dump"))).toEqual([
|
||||
"healthlog-20261022T013000Z-daily.dump",
|
||||
"healthlog-20261023T013000Z-daily.dump",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a failed dump, leaves nothing that looks good, and the page says so", async () => {
|
||||
const before = readdirSync(backups).sort();
|
||||
const url = new URL(process.env.DATABASE_URL ?? "");
|
||||
url.password = "not-the-password";
|
||||
const now = new Date("2026-10-24T01:30:00Z");
|
||||
const report = await runDatabaseBackup(prisma, {
|
||||
now,
|
||||
env: backupEnv({ DATABASE_URL: url.toString() }),
|
||||
});
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.local).toBe("failed");
|
||||
expect(report.error).toMatch(/password authentication failed/);
|
||||
expect(readdirSync(backups).sort()).toEqual(before);
|
||||
const health = await getDatabaseBackupHealth(
|
||||
new Date("2026-10-24T02:00:00Z"),
|
||||
backupEnv(),
|
||||
);
|
||||
expect(health.lastFailure?.error).toMatch(/password authentication/);
|
||||
expect(health.lastGood?.fileName).toBe(
|
||||
"healthlog-20261023T013000Z-daily.dump",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips, and says why, with nowhere to put a dump", async () => {
|
||||
const report = await runDatabaseBackup(prisma, {
|
||||
now: new Date("2026-10-24T03:00:00Z"),
|
||||
env: backupEnv({ DB_BACKUP_DIR: "/proc/healthlog-cannot-write-here" }),
|
||||
});
|
||||
expect(report).toMatchObject({
|
||||
status: "skipped",
|
||||
local: "no_directory",
|
||||
offhost: "not_configured",
|
||||
});
|
||||
expect(report.error).toMatch(/no writable backup directory/);
|
||||
const off = await runDatabaseBackup(prisma, {
|
||||
now: new Date("2026-10-24T04:00:00Z"),
|
||||
env: backupEnv({ DB_BACKUP_ENABLED: "false" }),
|
||||
});
|
||||
expect(off).toMatchObject({
|
||||
status: "skipped",
|
||||
local: "skipped",
|
||||
offhost: "skipped",
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoring it", () => {
|
||||
const target = `restore_${process.pid}_${Date.now()}`;
|
||||
let targetUrl = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const admin = await connect(process.env.DATABASE_URL ?? "");
|
||||
await admin.query(`CREATE DATABASE ${target}`);
|
||||
await admin.end();
|
||||
const url = new URL(process.env.DATABASE_URL ?? "");
|
||||
url.pathname = `/${target}`;
|
||||
targetUrl = url.toString();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const admin = await connect(process.env.DATABASE_URL ?? "");
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${target} WITH (FORCE)`);
|
||||
await admin.end();
|
||||
});
|
||||
|
||||
it("refuses the database the dump came from, which has tables", async () => {
|
||||
const errors: string[] = [];
|
||||
const spy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation((l: string) => void errors.push(String(l)));
|
||||
const code = await restoreMain(["latest"], backupEnv());
|
||||
spy.mockRestore();
|
||||
expect(code).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/only into an empty database/);
|
||||
});
|
||||
|
||||
it("restores the newest dump into an empty database with every row count", async () => {
|
||||
const lines: string[] = [];
|
||||
const spy = vi
|
||||
.spyOn(console, "log")
|
||||
.mockImplementation((l: string) => void lines.push(String(l)));
|
||||
const code = await restoreMain(
|
||||
["latest"],
|
||||
backupEnv({ DATABASE_URL: targetUrl }),
|
||||
);
|
||||
spy.mockRestore();
|
||||
expect(code, lines.join("\n")).toBe(0);
|
||||
expect(lines.join("\n")).toMatch(
|
||||
/all \d+ tables hold the row counts the manifest names/,
|
||||
);
|
||||
|
||||
const manifest = await readManifestFile(
|
||||
join(backups, "healthlog-20261023T013000Z-daily.manifest.json"),
|
||||
);
|
||||
const client = await connect(targetUrl);
|
||||
try {
|
||||
expect(await countRows(client)).toEqual(manifest.rowCounts);
|
||||
const { rows } = await client.query(
|
||||
"SELECT count(*)::int AS n FROM users WHERE username LIKE 'dump-%'",
|
||||
);
|
||||
expect(rows[0].n).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
// Restoring again into the now full database is refused.
|
||||
const again = await restoreMain(
|
||||
["latest"],
|
||||
backupEnv({ DATABASE_URL: targetUrl }),
|
||||
);
|
||||
expect(again).toBe(1);
|
||||
expect(existsSync(dumpFile)).toBe(false); // the first dump aged out
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dump before migrating", () => {
|
||||
const preDir = join(work, "pre");
|
||||
const appDir = join(work, "app");
|
||||
const failing = join(work, "failing-bin");
|
||||
let cwd = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const { cpSync, mkdirSync } = await import("node:fs");
|
||||
// An image that ships one migration the database has not applied.
|
||||
mkdirSync(join(appDir, "prisma"), { recursive: true });
|
||||
cpSync(
|
||||
join(process.cwd(), "prisma", "migrations"),
|
||||
join(appDir, "prisma", "migrations"),
|
||||
{ recursive: true },
|
||||
);
|
||||
mkdirSync(join(appDir, "prisma", "migrations", "9999_not_applied_yet"));
|
||||
writeFileSync(
|
||||
join(
|
||||
appDir,
|
||||
"prisma",
|
||||
"migrations",
|
||||
"9999_not_applied_yet",
|
||||
"migration.sql",
|
||||
),
|
||||
"",
|
||||
);
|
||||
mkdirSync(preDir, { recursive: true });
|
||||
mkdirSync(failing, { recursive: true });
|
||||
writeFileSync(
|
||||
join(failing, "pg_dump"),
|
||||
"#!/bin/sh\necho 'pg_dump: error: could not write to output file: No space left on device' >&2\nexit 1\n",
|
||||
);
|
||||
chmodSync(join(failing, "pg_dump"), 0o755);
|
||||
cwd = process.cwd();
|
||||
process.chdir(appDir);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(cwd);
|
||||
process.env.PATH = `${shims}:${originalPath}`;
|
||||
});
|
||||
|
||||
async function preMigration(env: Record<string, string>) {
|
||||
const { preMigrationDump } = await import("../../scripts/db-dump.mjs");
|
||||
const errors: string[] = [];
|
||||
const spyErr = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation((l: string) => void errors.push(String(l)));
|
||||
const spyLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const code = await preMigrationDump({ ...process.env, ...env });
|
||||
return { code, errors: errors.join("\n") };
|
||||
} finally {
|
||||
spyErr.mockRestore();
|
||||
spyLog.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
it("writes a checked dump with its manifest, and keeps the last two", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) await new Promise((r) => setTimeout(r, 1100));
|
||||
const { code } = await preMigration({ DB_BACKUP_DIR: preDir });
|
||||
expect(code).toBe(0);
|
||||
}
|
||||
const names = readdirSync(preDir).sort();
|
||||
expect(names).toHaveLength(4);
|
||||
expect(names.filter((n) => n.endsWith("-pre-migration.dump"))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
const manifest = await readManifestFile(
|
||||
join(preDir, names.find((n) => n.endsWith(".manifest.json")) ?? ""),
|
||||
);
|
||||
expect(manifest.kind).toBe("pre-migration");
|
||||
expect(manifest.rowCounts["public.users"]).toBe(await prisma.user.count());
|
||||
});
|
||||
|
||||
it("does nothing when switched off", async () => {
|
||||
const before = readdirSync(preDir);
|
||||
const { code } = await preMigration({
|
||||
DB_BACKUP_DIR: preDir,
|
||||
DB_BACKUP_BEFORE_MIGRATE: "false",
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(readdirSync(preDir)).toEqual(before);
|
||||
});
|
||||
|
||||
it("warns and lets the boot go on without a writable directory", async () => {
|
||||
const { code, errors } = await preMigration({
|
||||
DB_BACKUP_DIR: "/proc/healthlog-cannot-write-here",
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toMatch(
|
||||
/is not writable, so no dump is taken before the 1 pending migration/,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops the boot when the dump fails on a writable directory, and leaves no dump behind", async () => {
|
||||
// A second of its own, so the name cannot meet the last dump's.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const before = readdirSync(preDir).sort();
|
||||
process.env.PATH = `${failing}:${originalPath}`;
|
||||
const { code, errors } = await preMigration({ DB_BACKUP_DIR: preDir });
|
||||
process.env.PATH = `${shims}:${originalPath}`;
|
||||
expect(code).toBe(1);
|
||||
expect(errors).toMatch(
|
||||
/the database dump before migrating failed: .*No space left/,
|
||||
);
|
||||
expect(readdirSync(preDir).sort()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("two dumps in the same second", () => {
|
||||
it("refuses the second and leaves the first whole", async () => {
|
||||
const { createDatabaseDump } =
|
||||
await import("@/lib/database-backup/dump-core.mjs");
|
||||
const dir = join(work, "same-second");
|
||||
const { mkdirSync } = await import("node:fs");
|
||||
mkdirSync(dir);
|
||||
const now = new Date("2026-10-26T01:30:00Z");
|
||||
const options = {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
dir,
|
||||
kind: "daily" as const,
|
||||
now,
|
||||
};
|
||||
await createDatabaseDump(options);
|
||||
const before = readdirSync(dir)
|
||||
.sort()
|
||||
.map((n) => [n, readFileSync(join(dir, n)).length]);
|
||||
await expect(createDatabaseDump(options)).rejects.toThrow(/already exists/);
|
||||
expect(
|
||||
readdirSync(dir)
|
||||
.sort()
|
||||
.map((n) => [n, readFileSync(join(dir, n)).length]),
|
||||
).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -313,6 +313,9 @@ describe("GET /api/fhir/metadata", () => {
|
||||
z.looseObject({
|
||||
type: z.string(),
|
||||
interaction: z.array(z.object({ code: z.string() })),
|
||||
searchParam: z.array(
|
||||
z.object({ name: z.string(), type: z.string() }),
|
||||
),
|
||||
operation: z.array(z.object({ name: z.string() })).optional(),
|
||||
}),
|
||||
),
|
||||
@@ -330,6 +333,21 @@ describe("GET /api/fhir/metadata", () => {
|
||||
expect(patient).toHaveLength(1);
|
||||
expect(patient[0].operation?.map((o) => o.name)).toEqual(["everything"]);
|
||||
|
||||
// Paging on every search; the Observation search alone honours the
|
||||
// time-series range and the code filter, and says so.
|
||||
for (const resource of resources) {
|
||||
expect(resource.searchParam, resource.type).toEqual([
|
||||
{ name: "_count", type: "number" },
|
||||
{ name: "_offset", type: "number" },
|
||||
...(resource.type === "Observation"
|
||||
? [
|
||||
{ name: "date", type: "date" },
|
||||
{ name: "code", type: "token" },
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
const root = path.join(process.cwd(), "src/app/api/fhir");
|
||||
for (const resource of resources) {
|
||||
for (const { code } of resource.interaction) {
|
||||
|
||||
Reference in new issue
Block a user