mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(backups): tell an empty ledger apart from an empty bucket
On the release that adds the ledger the table is empty, so on a host that has been uploading nightly for months every account read Never, under a sentence saying no copy had ever reached the bucket. That sentence is untrue on exactly the morning an operator opens the new card for the first time, and it is a claim the host cannot make: what is empty is the record, not the bucket. The run now records every account it walks, not only the ones an object came of. A walk that produced nothing moves the attempt instant and leaves the success columns alone, so the two cases separate: no row at all is no record yet, a row without a success is never. Never now means what the word says — a run looked at this account and has never put anything there. The card names the last check on the never row, so the state is a fact with a timestamp rather than an assertion. Runbook and all seven bundles carry the same two sentences. The integration file proves both arms against real Postgres, including that a failed run leaves the previous good copy on the row.
This commit is contained in:
1 parent
cfc19a211c
commit
25f0310596
21 files changed
+340
-80
No files matched your search
@@ -63,6 +63,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
id: "u1",
|
||||
username: "account-one",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 3 * HOUR),
|
||||
lastSuccessAt: new Date(now - 3 * HOUR),
|
||||
// The column is BigInt — an object may be 80 GB — and the wire type
|
||||
// is a plain number.
|
||||
@@ -73,6 +74,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
id: "u2",
|
||||
username: "account-two",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 31 * HOUR),
|
||||
lastSuccessAt: new Date(now - 31 * HOUR),
|
||||
sizeBytes: BigInt(8192),
|
||||
},
|
||||
@@ -81,11 +83,24 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
id: "u3",
|
||||
username: "account-three",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 100 * HOUR),
|
||||
lastSuccessAt: new Date(now - 100 * HOUR),
|
||||
sizeBytes: BigInt(128),
|
||||
},
|
||||
},
|
||||
{ id: "u4", username: "account-four", offhostBackupState: null },
|
||||
{
|
||||
id: "u4",
|
||||
username: "account-four",
|
||||
// Walked by a run, nothing landed.
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 2 * HOUR),
|
||||
lastSuccessAt: null,
|
||||
sizeBytes: null,
|
||||
},
|
||||
},
|
||||
// No run has recorded this one at all — the state of every account on
|
||||
// the morning the ledger ships.
|
||||
{ id: "u5", username: "account-five", offhostBackupState: null },
|
||||
]);
|
||||
|
||||
const body = await read();
|
||||
@@ -99,12 +114,17 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
["account-two", "due"],
|
||||
["account-three", "stale"],
|
||||
["account-four", "never"],
|
||||
["account-five", "unknown"],
|
||||
]);
|
||||
expect(body.offhost.rows[0]?.sizeBytes).toBe(4096);
|
||||
expect(typeof body.offhost.rows[0]?.sizeBytes).toBe("number");
|
||||
expect(body.offhost.rows[0]?.ageHours).toBe(3);
|
||||
expect(body.offhost.rows[3]?.lastSuccessAt).toBeNull();
|
||||
expect(body.offhost.rows[3]?.sizeBytes).toBeNull();
|
||||
expect(body.offhost.rows[3]?.lastAttemptAt).not.toBeNull();
|
||||
// `unknown` is the honest one: the ledger has no history for this
|
||||
// account, which is not the same claim as "the bucket is empty".
|
||||
expect(body.offhost.rows[4]?.lastAttemptAt).toBeNull();
|
||||
});
|
||||
|
||||
it("says so and reads no account when off-host backup is not configured", async () => {
|
||||
@@ -128,6 +148,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
id: "u1",
|
||||
username: "account-one",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(),
|
||||
lastSuccessAt: new Date(),
|
||||
sizeBytes: BigInt(1),
|
||||
},
|
||||
@@ -139,6 +160,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
|
||||
"ageHours",
|
||||
"freshness",
|
||||
"lastAttemptAt",
|
||||
"lastSuccessAt",
|
||||
"sizeBytes",
|
||||
"userId",
|
||||
|
||||
@@ -95,7 +95,11 @@ export const GET = apiHandler(async () => {
|
||||
id: true,
|
||||
username: true,
|
||||
offhostBackupState: {
|
||||
select: { lastSuccessAt: true, sizeBytes: true },
|
||||
select: {
|
||||
lastAttemptAt: true,
|
||||
lastSuccessAt: true,
|
||||
sizeBytes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { username: "asc" },
|
||||
@@ -105,17 +109,22 @@ export const GET = apiHandler(async () => {
|
||||
const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => {
|
||||
const state = account.offhostBackupState;
|
||||
const verdict = classifyOffhostBackup({
|
||||
lastAttemptAt: state?.lastAttemptAt ?? null,
|
||||
lastSuccessAt: state?.lastSuccessAt ?? null,
|
||||
now,
|
||||
});
|
||||
return {
|
||||
userId: account.id,
|
||||
username: account.username,
|
||||
lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null,
|
||||
lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null,
|
||||
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
|
||||
// BigInt column -> number on the wire. The value is bytes of one
|
||||
// object, capped by the uploader at 80 GB, so it is nowhere near
|
||||
// Number.MAX_SAFE_INTEGER and JSON has no BigInt.
|
||||
sizeBytes: state ? Number(state.sizeBytes) : null,
|
||||
sizeBytes:
|
||||
state?.sizeBytes === null || state?.sizeBytes === undefined
|
||||
? null
|
||||
: Number(state.sizeBytes),
|
||||
ageHours: verdict.ageHours,
|
||||
freshness: verdict.freshness,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user