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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2145-the-restore-drill-counts-users-pulls-and → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2145-the-restore-drill-counts-users-pulls-and was merged into main as c7a01f2.

Review

Sign in to review this change.
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;

NOT NULL DEFAULT 0 makes an old drill claim zero users, pulls and apps. Use nullable columns and omit the unknown counts when rendering.

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 {
3535 readonly boot?: typeof bootRestored | undefined;
3636 }
3737
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
3846 export interface DrillResult {
3947 readonly at: string;
4048 readonly snapshotId: string | null;
4149 readonly ms: number;
42 readonly counts: { readonly repositories: number; readonly issues: number };
50 readonly counts: DrillCounts;
4351 /** The side-booted forge's word; "unreached" when an earlier step failed. */
4452 readonly health: "ok" | "failed" | "unreached";
4553 readonly verdict: "green" | "red";
@@ -64,7 +72,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
6472 const at = clock().toISOString();
6573 const timeoutMs = deps.timeoutMs ?? DRILL_TIMEOUT_MS;
6674 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 };
6876 let snapshotId: string | null = null;
6977 let step: DrillStep = "fetch";
7078 let scratch: string | undefined;
@@ -99,6 +107,9 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
99107 db.query<{ n: number }, []>(sql).get()?.n ?? 0;
100108 counts.repositories = n("SELECT count(*) AS n FROM repo");
101109 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");
102113
103114 step = "unseal";
104115 const secret = db
src/snapshot/drills.ts +15 −3
@@ -13,6 +13,9 @@ interface Row {
1313 ms: number;
1414 repositories: number;
1515 issues: number;
16 users: number;
17 pulls: number;
18 apps: number;
1619 verdict: "green" | "red";
1720 failed_step: DrillStep | null;
1821 reason: string | null;
@@ -20,14 +23,17 @@ interface Row {
2023
2124 export function recordDrill(db: Database, result: DrillResult): void {
2225 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2528 ).run(
2629 result.at,
2730 result.snapshotId,
2831 result.ms,
2932 result.counts.repositories,
3033 result.counts.issues,
34 result.counts.users,
35 result.counts.pulls,
36 result.counts.apps,
3137 result.verdict,
3238 result.failedStep,
3339 result.reason,
@@ -45,7 +51,13 @@ export function lastDrill(db: Database): DrillResult | undefined {
4551 at: row.at,
4652 snapshotId: row.snapshot_id,
4753 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 },
4961 // Not stored: green means health read ok; a red one failed at it or never got there.
5062 health:
5163 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 {
116116 `- health: ${r.health}`,
117117 `- repositories: ${r.counts.repositories}`,
118118 `- issues: ${r.counts.issues}`,
119 `- users: ${r.counts.users}`,
120 `- pulls: ${r.counts.pulls}`,
121 `- apps: ${r.counts.apps}`,
119122 ...(pass || !r.reason ? [] : [`- why: ${r.reason}`]),
120123 ].join("\n");
121124 }
src/web/pages/backups.ts +1 −1
@@ -98,7 +98,7 @@ function drillLine(ctx: Ctx): Html {
9898 ? html`<p class="backups-drill">
9999 ${icon("check")} Last drill ${time(d.at)}: snapshot
100100 <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.

102102 </p>`
103103 : html`<p class="backups-error backups-drill">
104104 ${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", () => {
6262
6363 test("a red restore drill says the snapshot store's words", () => {
6464 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 },
6666 health: "unreached", verdict: "red", failedStep: "fetch", reason: "no bucket",
6767 });
6868 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 => ({
2323 at: new Date(at).toISOString(),
2424 snapshotId: "snap-1",
2525 ms: 5,
26 counts: { repositories: 1, issues: 1 },
26 counts: { repositories: 1, issues: 1, users: 0, pulls: 0, apps: 0 },
2727 health: red ? "failed" : "ok",
2828 verdict: red ? "red" : "green",
2929 failedStep: red ? "unseal" : null,
test/drill-verdict.test.ts +2 −2
@@ -134,7 +134,7 @@ describe("the drill's verdict", () => {
134134 at: "2026-09-22T09:00:00Z",
135135 snapshotId: null,
136136 ms: 5,
137 counts: { repositories: 0, issues: 0 },
137 counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
138138 health: "unreached",
139139 verdict: "red",
140140 failedStep: "fetch",
@@ -221,7 +221,7 @@ describe("telling the recovery issue", () => {
221221 at: "2026-10-18T09:00:00Z",
222222 snapshotId: "s",
223223 ms: 1,
224 counts: { repositories: 0, issues: 0 },
224 counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
225225 health: "ok",
226226 verdict: "green",
227227 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", () => {
6262 at: new Date().toISOString(),
6363 snapshotId: "snap-stub-1",
6464 ms: 1234,
65 counts: { repositories: 7, issues: 42 },
65 counts: { repositories: 7, issues: 42, users: 0, pulls: 0, apps: 0 },
6666 health: verdict === "green" ? "ok" : "unreached",
6767 verdict,
6868 failedStep: verdict === "green" ? null : "unseal",
@@ -97,7 +97,7 @@ describe("the drill on the Backups page", () => {
9797 expect(ran).toBe(1);
9898 const html = await page(cookie);
9999 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");
101101 expect(html).toContain("1234 ms");
102102 expect(app.db.query("SELECT count(*) AS n FROM drill").get()).toEqual({ n: 1 });
103103 });
test/snapshot-drill.test.ts +1 −1
@@ -98,7 +98,7 @@ describe("the restore drill", () => {
9898 expect(r.verdict).toBe("green");
9999 expect(r.failedStep).toBeNull();
100100 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 });
102102 expect(r.ms).toBeGreaterThanOrEqual(0);
103103 expect(scratchLeft()).toEqual([]);
104104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs