Public: anyone can read this; the crew can change it.
The restore drill counts users, pulls and apps as well as repositories and issues, and a failed count turns it red #2155
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2145-the-restore-drill-counts-users-pulls-and → main 1 commit fork 2 threads · 2 unresolved
11 files
changed +152
−13
src/core/migrations/20261016140000-drill-counts.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // The restore drill counts users, pulls and apps too (#2145). Drills that ran | |
| 2 | // before this keep 0, which the Backups line shows as it was recorded. | |
| 3 | export default ` | |
| 4 | ALTER TABLE drill ADD COLUMN users INTEGER NOT NULL DEFAULT 0; | |
| 5 | ALTER TABLE drill ADD COLUMN pulls INTEGER NOT NULL DEFAULT 0; | |
| 6 | ALTER TABLE drill ADD COLUMN apps INTEGER NOT NULL DEFAULT 0; | |
| 7 | `; |
src/snapshot/drill.ts +13 −2
| @@ -35,11 +35,19 @@ export interface DrillDeps { | ||
| 35 | 35 | readonly boot?: typeof bootRestored | undefined; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | export interface DrillCounts { | |
| 39 | readonly repositories: number; | |
| 40 | readonly issues: number; | |
| 41 | readonly users: number; | |
| 42 | readonly pulls: number; | |
| 43 | readonly apps: number; | |
| 44 | } | |
| 45 | ||
| 38 | 46 | export interface DrillResult { |
| 39 | 47 | readonly at: string; |
| 40 | 48 | readonly snapshotId: string | null; |
| 41 | 49 | readonly ms: number; |
| 42 | readonly counts: { readonly repositories: number; readonly issues: number }; | |
| 50 | readonly counts: DrillCounts; | |
| 43 | 51 | /** The side-booted forge's word; "unreached" when an earlier step failed. */ |
| 44 | 52 | readonly health: "ok" | "failed" | "unreached"; |
| 45 | 53 | readonly verdict: "green" | "red"; |
| @@ -64,7 +72,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 64 | 72 | const at = clock().toISOString(); |
| 65 | 73 | const timeoutMs = deps.timeoutMs ?? DRILL_TIMEOUT_MS; |
| 66 | 74 | const restore = deps.restore ?? restoreSnapshot; |
| 67 | const counts = { repositories: 0, issues: 0 }; | |
| 75 | const counts = { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }; | |
| 68 | 76 | let snapshotId: string | null = null; |
| 69 | 77 | let step: DrillStep = "fetch"; |
| 70 | 78 | let scratch: string | undefined; |
| @@ -99,6 +107,9 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 99 | 107 | db.query<{ n: number }, []>(sql).get()?.n ?? 0; |
| 100 | 108 | counts.repositories = n("SELECT count(*) AS n FROM repo"); |
| 101 | 109 | counts.issues = n("SELECT count(*) AS n FROM issue"); |
| 110 | counts.users = n("SELECT count(*) AS n FROM user"); | |
| 111 | counts.pulls = n("SELECT count(*) AS n FROM pull"); | |
| 112 | counts.apps = n("SELECT count(*) AS n FROM app"); | |
| 102 | 113 | |
| 103 | 114 | step = "unseal"; |
| 104 | 115 | const secret = db |
src/snapshot/drills.ts +15 −3
| @@ -13,6 +13,9 @@ interface Row { | ||
| 13 | 13 | ms: number; |
| 14 | 14 | repositories: number; |
| 15 | 15 | issues: number; |
| 16 | users: number; | |
| 17 | pulls: number; | |
| 18 | apps: number; | |
| 16 | 19 | verdict: "green" | "red"; |
| 17 | 20 | failed_step: DrillStep | null; |
| 18 | 21 | reason: string | null; |
| @@ -20,14 +23,17 @@ interface Row { | ||
| 20 | 23 | |
| 21 | 24 | export function recordDrill(db: Database, result: DrillResult): void { |
| 22 | 25 | db.query( |
| 23 | `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, verdict, failed_step, reason) | |
| 24 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 26 | `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, users, pulls, apps, verdict, failed_step, reason) | |
| 27 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 25 | 28 | ).run( |
| 26 | 29 | result.at, |
| 27 | 30 | result.snapshotId, |
| 28 | 31 | result.ms, |
| 29 | 32 | result.counts.repositories, |
| 30 | 33 | result.counts.issues, |
| 34 | result.counts.users, | |
| 35 | result.counts.pulls, | |
| 36 | result.counts.apps, | |
| 31 | 37 | result.verdict, |
| 32 | 38 | result.failedStep, |
| 33 | 39 | result.reason, |
| @@ -45,7 +51,13 @@ export function lastDrill(db: Database): DrillResult | undefined { | ||
| 45 | 51 | at: row.at, |
| 46 | 52 | snapshotId: row.snapshot_id, |
| 47 | 53 | ms: row.ms, |
| 48 | counts: { repositories: row.repositories, issues: row.issues }, | |
| 54 | counts: { | |
| 55 | repositories: row.repositories, | |
| 56 | issues: row.issues, | |
| 57 | users: row.users, | |
| 58 | pulls: row.pulls, | |
| 59 | apps: row.apps, | |
| 60 | }, | |
| 49 | 61 | // Not stored: green means health read ok; a red one failed at it or never got there. |
| 50 | 62 | health: |
| 51 | 63 | row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached", |
src/snapshot/verdict.ts +3
| @@ -116,6 +116,9 @@ export function verdictComment(r: DrillResult): string { | ||
| 116 | 116 | `- health: ${r.health}`, |
| 117 | 117 | `- repositories: ${r.counts.repositories}`, |
| 118 | 118 | `- issues: ${r.counts.issues}`, |
| 119 | `- users: ${r.counts.users}`, | |
| 120 | `- pulls: ${r.counts.pulls}`, | |
| 121 | `- apps: ${r.counts.apps}`, | |
| 119 | 122 | ...(pass || !r.reason ? [] : [`- why: ${r.reason}`]), |
| 120 | 123 | ].join("\n"); |
| 121 | 124 | } |
src/web/pages/backups.ts +1 −1
| @@ -98,7 +98,7 @@ function drillLine(ctx: Ctx): Html { | ||
| 98 | 98 | ? html`<p class="backups-drill"> |
| 99 | 99 | ${icon("check")} Last drill ${time(d.at)}: snapshot |
| 100 | 100 | <code>${d.snapshotId ?? ""}</code> restored green in ${d.ms} ms — |
| 101 | ${d.counts.repositories} repositories, ${d.counts.issues} issues. | |
| 101 | ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps. | |
This line now runs very long, and with the five counts it wraps awkwardly on a 360px phone. Consider splitting it over several lines of the template. |
||
| 102 | 102 | </p>` |
| 103 | 103 | : html`<p class="backups-error backups-drill"> |
| 104 | 104 | ${icon("alert")} Last drill ${time(d.at)}: snapshot |
test/domains.test.ts +1 −1
| @@ -62,7 +62,7 @@ test("a nearly full disk says the state's words", () => { | ||
| 62 | 62 | |
| 63 | 63 | test("a red restore drill says the snapshot store's words", () => { |
| 64 | 64 | recordVerdict(dataDir, { |
| 65 | at: new Date().toISOString(), snapshotId: null, ms: 1, counts: { repositories: 0, issues: 0 }, | |
| 65 | at: new Date().toISOString(), snapshotId: null, ms: 1, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 66 | 66 | health: "unreached", verdict: "red", failedStep: "fetch", reason: "no bucket", |
| 67 | 67 | }); |
| 68 | 68 | for (const s of said("snapshots")) expect(read()).toContain(s); |
test/drill-board-line.test.ts +1 −1
| @@ -23,7 +23,7 @@ const drill = (at: number, red: boolean): DrillResult => ({ | ||
| 23 | 23 | at: new Date(at).toISOString(), |
| 24 | 24 | snapshotId: "snap-1", |
| 25 | 25 | ms: 5, |
| 26 | counts: { repositories: 1, issues: 1 }, | |
| 26 | counts: { repositories: 1, issues: 1, users: 0, pulls: 0, apps: 0 }, | |
| 27 | 27 | health: red ? "failed" : "ok", |
| 28 | 28 | verdict: red ? "red" : "green", |
| 29 | 29 | failedStep: red ? "unseal" : null, |
test/drill-verdict.test.ts +2 −2
| @@ -134,7 +134,7 @@ describe("the drill's verdict", () => { | ||
| 134 | 134 | at: "2026-09-22T09:00:00Z", |
| 135 | 135 | snapshotId: null, |
| 136 | 136 | ms: 5, |
| 137 | counts: { repositories: 0, issues: 0 }, | |
| 137 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 138 | 138 | health: "unreached", |
| 139 | 139 | verdict: "red", |
| 140 | 140 | failedStep: "fetch", |
| @@ -221,7 +221,7 @@ describe("telling the recovery issue", () => { | ||
| 221 | 221 | at: "2026-10-18T09:00:00Z", |
| 222 | 222 | snapshotId: "s", |
| 223 | 223 | ms: 1, |
| 224 | counts: { repositories: 0, issues: 0 }, | |
| 224 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 225 | 225 | health: "ok", |
| 226 | 226 | verdict: "green", |
| 227 | 227 | failedStep: null, |
test/snapshot-drill-counts.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * The restore drill counts users, pulls and apps as well as repositories and | |
| 3 | * issues (#2145), and a count that cannot be read is red at `count`. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtempSync } from "node:fs"; | |
| 7 | import { rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { enableApp } from "../src/actions/apps.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createIssue } from "../src/core/issues.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { runDrill } from "../src/snapshot/drill.ts"; | |
| 16 | import { lastDrill, recordDrill } from "../src/snapshot/drills.ts"; | |
| 17 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; | |
| 18 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 19 | ||
| 20 | describe("the restore drill's counts", () => { | |
| 21 | let root: string; | |
| 22 | let app: App; | |
| 23 | const snap = (name: string, at: string) => | |
| 24 | takeSnapshot( | |
| 25 | { | |
| 26 | db: app.db, | |
| 27 | dataDir: app.config.dataDir, | |
| 28 | reposRoot: app.reposRoot, | |
| 29 | version: "t", | |
| 30 | }, | |
| 31 | dirStore(join(root, name)), | |
| 32 | new Date(at), | |
| 33 | ); | |
| 34 | const drill = (name: string) => | |
| 35 | runDrill({ | |
| 36 | store: dirStore(join(root, name)), | |
| 37 | dataDir: join(root, "drill-data"), | |
| 38 | boot: async () => ({ ok: true, reason: "" }), | |
| 39 | }); | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | root = mkdtempSync(join(tmpdir(), "forge-drill-counts-")); | |
| 43 | app = createApp({ dataDir: join(root, "live"), port: 0 }); | |
| 44 | const users = []; | |
| 45 | for (const name of ["alice", "bob"]) { | |
| 46 | const u = await createUser(app.db, { | |
| 47 | trusted: true, | |
| 48 | username: name, | |
| 49 | email: `${name}@forge.local`, | |
| 50 | password: "a-good-passphrase", | |
| 51 | adminIfFirst: name === "alice", | |
| 52 | }); | |
| 53 | if (!u.ok) throw new Error(u.error); | |
| 54 | users.push(u.value); | |
| 55 | } | |
| 56 | const repos = []; | |
| 57 | for (const name of ["one", "two", "three"]) { | |
| 58 | const r = await createRepo(app, users[0]!, { name, visibility: "private" }); | |
| 59 | if (!r.ok) throw new Error(String(r.error)); | |
| 60 | repos.push(r.value); | |
| 61 | } | |
| 62 | const issue = createIssue(app.db, { | |
| 63 | repoId: repos[0]!.id, | |
| 64 | authorId: users[0]!.id, | |
| 65 | title: "a pull", | |
| 66 | kind: "pull", | |
| 67 | }); | |
| 68 | if (!issue.ok) throw new Error(issue.error); | |
| 69 | app.db | |
| 70 | .query( | |
| 71 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) | |
| 72 | VALUES (?1, 'main', 'topic', 'a', 'b')`, | |
| 73 | ) | |
| 74 | .run(issue.value.id); | |
| 75 | const enabled = enableApp(app, users[0]!, repos[0]!); | |
| 76 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 77 | await snap("good", "2026-09-22T08:00:00Z"); | |
| 78 | ||
| 79 | app.db.exec("PRAGMA foreign_keys = OFF; DROP TABLE pull;"); | |
| 80 | await snap("nopulls", "2026-09-23T08:00:00Z"); | |
| 81 | }); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | app.close(); | |
| 85 | await rm(root, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("2 users, 3 repositories, 1 pull and 1 app are counted exactly", async () => { | |
| 89 | const r = await drill("good"); | |
| 90 | expect(r.verdict).toBe("green"); | |
| 91 | expect(r.counts).toMatchObject({ users: 2, repositories: 3, pulls: 1, apps: 1 }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a restored database without the pulls table is red at count, not a note on green", async () => { | |
| 95 | const r = await drill("nopulls"); | |
| 96 | expect(r.verdict).toBe("red"); | |
| 97 | expect(r.failedStep).toBe("count"); | |
| 98 | expect(r.reason).toContain("pull"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a drill row keeps all five counts", async () => { | |
| 102 | const r = await drill("good"); | |
| 103 | recordDrill(app.db, r); | |
| 104 | expect(lastDrill(app.db)?.counts).toEqual(r.counts); | |
| 105 | }); | |
| 106 | }); |
test/snapshot-drill-page.test.ts +2 −2
| @@ -62,7 +62,7 @@ describe("the drill on the Backups page", () => { | ||
| 62 | 62 | at: new Date().toISOString(), |
| 63 | 63 | snapshotId: "snap-stub-1", |
| 64 | 64 | ms: 1234, |
| 65 | counts: { repositories: 7, issues: 42 }, | |
| 65 | counts: { repositories: 7, issues: 42, users: 0, pulls: 0, apps: 0 }, | |
| 66 | 66 | health: verdict === "green" ? "ok" : "unreached", |
| 67 | 67 | verdict, |
| 68 | 68 | failedStep: verdict === "green" ? null : "unseal", |
| @@ -97,7 +97,7 @@ describe("the drill on the Backups page", () => { | ||
| 97 | 97 | expect(ran).toBe(1); |
| 98 | 98 | const html = await page(cookie); |
| 99 | 99 | expect(html).toContain("snap-stub-1"); |
| 100 | expect(html).toContain("7 repositories, 42 issues"); | |
| 100 | expect(html).toContain("0 users, 7 repositories, 42 issues, 0 pulls, 0 apps"); | |
| 101 | 101 | expect(html).toContain("1234 ms"); |
| 102 | 102 | expect(app.db.query("SELECT count(*) AS n FROM drill").get()).toEqual({ n: 1 }); |
| 103 | 103 | }); |
test/snapshot-drill.test.ts +1 −1
| @@ -98,7 +98,7 @@ describe("the restore drill", () => { | ||
| 98 | 98 | expect(r.verdict).toBe("green"); |
| 99 | 99 | expect(r.failedStep).toBeNull(); |
| 100 | 100 | expect(r.snapshotId).toBe("2026-09-22T08-00-00Z"); |
| 101 | expect(r.counts).toEqual({ repositories: 1, issues: 2 }); | |
| 101 | expect(r.counts).toEqual({ repositories: 1, issues: 2, users: 1, pulls: 0, apps: 1 }); | |
| 102 | 102 | expect(r.ms).toBeGreaterThanOrEqual(0); |
| 103 | 103 | expect(scratchLeft()).toEqual([]); |
| 104 | 104 | }); |
new above
offline
NOT NULL DEFAULT 0 makes an old drill claim zero users, pulls and apps. Use nullable columns and omit the unknown counts when rendering.