Public: anyone can read this; the crew can change it.
The kept front page is keyed on a one-row counter the database bumps when the shelf's data changes #6339
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6325-the-kept-front-page-is-keyed-on-a-one-ro → main 2 commits fork
7 files
changed +151
−18
src/actions/front.ts +15 −8
| @@ -45,6 +45,7 @@ import { keepLook } from "../looks/service.ts"; | ||
| 45 | 45 | import { canRead, getRepo, getReposByIds, type Repo } from "../core/repos.ts"; |
| 46 | 46 | import { listActiveRuns } from "../core/runs.ts"; |
| 47 | 47 | import { getUsersByIds, isActive, type User } from "../core/users.ts"; |
| 48 | import { timePhase } from "../core/route_timing.ts"; | |
| 48 | 49 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 49 | 50 | import { limitValue } from "../settings/limits.ts"; |
| 50 | 51 | |
| @@ -163,18 +164,24 @@ const PAGE_MS = 3_000; | ||
| 163 | 164 | */ |
| 164 | 165 | const built = new WeakMap<App, { key: string; at: number; page: FrontPage }>(); |
| 165 | 166 | |
| 166 | function dbKey(app: App): string { | |
| 167 | /** | |
| 168 | * The shelf's data version: one row that triggers bump when a column the | |
| 169 | * shelf renders changes (`front_epoch`). Its size never depends on how many | |
| 170 | * apps there are, and a session, a heartbeat or a push does not move it. | |
| 171 | */ | |
| 172 | export function frontKey(app: App): string { | |
| 167 | 173 | const row = app.db |
| 168 | .query<{ c: number; v: number }, []>( | |
| 169 | "SELECT total_changes() AS c, (SELECT data_version FROM pragma_data_version) AS v", | |
| 170 | ) | |
| 174 | .query<{ n: number }, []>("SELECT n FROM front_epoch") | |
| 171 | 175 | .get(); |
| 172 | return `${row?.c}:${row?.v}`; | |
| 176 | return String(row?.n); | |
| 173 | 177 | } |
| 174 | 178 | |
| 175 | /** The front page as a stranger is shown it. */ | |
| 176 | export function frontPage(app: App, now = Date.now()): FrontPage { | |
| 177 | const key = dbKey(app); | |
| 179 | /** The front page as a stranger is shown it; `request` is told what the key cost. */ | |
| 180 | export function frontPage(app: App, now = Date.now(), request?: Request): FrontPage { | |
| 181 | const key = | |
| 182 | request === undefined | |
| 183 | ? frontKey(app) | |
| 184 | : timePhase(request, "key", () => frontKey(app)); | |
| 178 | 185 | const kept = built.get(app); |
| 179 | 186 | const wall = performance.now(); |
| 180 | 187 | if (kept !== undefined && kept.key === key && wall - kept.at < PAGE_MS) |
src/core/migrations/20261026120000-front-epoch.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | // The kept front page asks one row whether the shelf's data changed, instead | |
| 2 | // of reading every app and look (#6325). `front_epoch.n` is bumped by | |
| 3 | // triggers on only the columns the shelf renders: never `repo.issue_seq` or | |
| 4 | // `repo.last_fetch_at`, which move on every push, and on `look` only a | |
| 5 | // production thumb. Additive. | |
| 6 | export default ` | |
| 7 | CREATE TABLE front_epoch (n INTEGER NOT NULL); | |
| 8 | INSERT INTO front_epoch (n) VALUES (0); | |
| 9 | ||
| 10 | CREATE TRIGGER front_epoch_app_insert AFTER INSERT ON app | |
| 11 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 12 | ||
| 13 | CREATE TRIGGER front_epoch_app_delete AFTER DELETE ON app | |
| 14 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 15 | ||
| 16 | CREATE TRIGGER front_epoch_app_update AFTER UPDATE OF repo_id, slug, access ON app | |
| 17 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 18 | ||
| 19 | CREATE TRIGGER front_epoch_repo_insert AFTER INSERT ON repo | |
| 20 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 21 | ||
| 22 | CREATE TRIGGER front_epoch_repo_delete AFTER DELETE ON repo | |
| 23 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 24 | ||
| 25 | CREATE TRIGGER front_epoch_repo_update AFTER UPDATE OF owner_id, name, visibility, description, archived_at, sealed_for ON repo | |
| 26 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 27 | ||
| 28 | CREATE TRIGGER front_epoch_front_pick_insert AFTER INSERT ON front_pick | |
| 29 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 30 | ||
| 31 | CREATE TRIGGER front_epoch_front_pick_delete AFTER DELETE ON front_pick | |
| 32 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 33 | ||
| 34 | CREATE TRIGGER front_epoch_front_pick_update AFTER UPDATE OF place, position ON front_pick | |
| 35 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 36 | ||
| 37 | CREATE TRIGGER front_epoch_front_setting_insert AFTER INSERT ON front_setting | |
| 38 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 39 | ||
| 40 | CREATE TRIGGER front_epoch_front_setting_delete AFTER DELETE ON front_setting | |
| 41 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 42 | ||
| 43 | CREATE TRIGGER front_epoch_front_setting_update AFTER UPDATE OF value ON front_setting | |
| 44 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 45 | ||
| 46 | CREATE TRIGGER front_epoch_user_insert AFTER INSERT ON user | |
| 47 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 48 | ||
| 49 | CREATE TRIGGER front_epoch_user_delete AFTER DELETE ON user | |
| 50 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 51 | ||
| 52 | CREATE TRIGGER front_epoch_user_update AFTER UPDATE OF username, suspended_at, removed_at ON user | |
| 53 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 54 | ||
| 55 | CREATE TRIGGER front_epoch_look_insert AFTER INSERT ON look | |
| 56 | WHEN NEW.env = 'production' AND NEW.kind = 'thumb' | |
| 57 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 58 | ||
| 59 | CREATE TRIGGER front_epoch_look_delete AFTER DELETE ON look | |
| 60 | WHEN OLD.env = 'production' AND OLD.kind = 'thumb' | |
| 61 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 62 | ||
| 63 | CREATE TRIGGER front_epoch_look_update AFTER UPDATE OF app_id, env, commit_oid, kind, path, error, taken_as ON look | |
| 64 | WHEN NEW.env = 'production' AND NEW.kind = 'thumb' OR OLD.env = 'production' AND OLD.kind = 'thumb' | |
| 65 | BEGIN UPDATE front_epoch SET n = n + 1; END; | |
| 66 | `; |
src/core/repos.ts +5 −4
| @@ -1152,13 +1152,14 @@ export function moveArchived( | ||
| 1152 | 1152 | archived: boolean, |
| 1153 | 1153 | at: string = new Date().toISOString(), |
| 1154 | 1154 | ): boolean { |
| 1155 | // RETURNING counts the repository, not the trigger's write to `front_epoch`. | |
| 1155 | 1156 | const changes = archived |
| 1156 | 1157 | ? db |
| 1157 | .query("UPDATE repo SET archived_at = ?2 WHERE id = ?1 AND archived_at IS NULL") | |
| 1158 | .run(repoId, at).changes | |
| 1158 | .query("UPDATE repo SET archived_at = ?2 WHERE id = ?1 AND archived_at IS NULL RETURNING id") | |
| 1159 | .all(repoId, at).length | |
| 1159 | 1160 | : db |
| 1160 | .query("UPDATE repo SET archived_at = NULL WHERE id = ?1 AND archived_at IS NOT NULL") | |
| 1161 | .run(repoId).changes; | |
| 1161 | .query("UPDATE repo SET archived_at = NULL WHERE id = ?1 AND archived_at IS NOT NULL RETURNING id") | |
| 1162 | .all(repoId).length; | |
| 1162 | 1163 | if (changes === 1) forgetListings(db); |
| 1163 | 1164 | return changes === 1; |
| 1164 | 1165 | } |
src/core/users.ts +3 −2
| @@ -218,8 +218,9 @@ interface CreateUserFields { | ||
| 218 | 218 | * other trace is removed; anything else stays. |
| 219 | 219 | */ |
| 220 | 220 | export function discardFreshUser(db: Database, userId: string): void { |
| 221 | const removed = db.query("DELETE FROM user WHERE id = ?1 AND password_hash = ''").run(userId).changes; | |
| 222 | if (removed === 1) reopenInvite(db, userId); | |
| 221 | // RETURNING counts the account, not the trigger's write to `front_epoch`. | |
| 222 | const removed = db.query("DELETE FROM user WHERE id = ?1 AND password_hash = '' RETURNING id").all(userId); | |
| 223 | if (removed.length === 1) reopenInvite(db, userId); | |
| 223 | 224 | } |
| 224 | 225 | |
| 225 | 226 | /** |
src/web/studio/home.ts +1 −1
| @@ -504,7 +504,7 @@ function frontControls(ctx: Ctx, front: FrontPage): Html { | ||
| 504 | 504 | export function studioDoor(ctx: Ctx): Response { |
| 505 | 505 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 506 | 506 | let tiles: Html[] = []; |
| 507 | const front = timePhase(ctx.request, "shelf", () => frontPage(ctx.app)); | |
| 507 | const front = timePhase(ctx.request, "shelf", () => frontPage(ctx.app, Date.now(), ctx.request)); | |
| 508 | 508 | timePhase(ctx.request, "tiles", () => { |
| 509 | 509 | tiles = front.shelf.map(tile); |
| 510 | 510 | }); |
test/front-speed.test.ts +59 −1
| @@ -5,7 +5,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | import { forgetFrontScores, frontPage } from "../src/actions/front.ts"; | |
| 8 | import { forgetFrontScores, frontKey, frontPage } from "../src/actions/front.ts"; | |
| 9 | 9 | import { createRepo } from "../src/actions/repos.ts"; |
| 10 | 10 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 11 | 11 | import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts"; |
| @@ -103,3 +103,61 @@ test("making one app private builds a new page without it on the next request", | ||
| 103 | 103 | expect(after.total).toBe(APPS - 1); |
| 104 | 104 | expect([after.featured, ...after.shelf].some((e) => e?.repo.id === gone.id)).toBe(false); |
| 105 | 105 | }); |
| 106 | ||
| 107 | test("a session insert and an issue_seq bump leave the kept page as it was (#6325)", () => { | |
| 108 | const first = frontPage(app); | |
| 109 | const owner = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id; | |
| 110 | app.db | |
| 111 | .query("INSERT INTO session (token_hash, user_id, expires_at) VALUES ('h6325', ?1, '2099-01-01T00:00:00Z')") | |
| 112 | .run(owner); | |
| 113 | app.db.query("UPDATE repo SET issue_seq = issue_seq + 1, last_fetch_at = 'now'").run(); | |
| 114 | expect(frontPage(app)).toBe(first); | |
| 115 | }); | |
| 116 | ||
| 117 | test("archiving a repo, making an app private and retaking a thumb each give a new page (#6325)", () => { | |
| 118 | const ids = (p: ReturnType<typeof frontPage>) => | |
| 119 | [p.featured, ...p.shelf].flatMap((e) => (e === undefined ? [] : [e.repo.id])); | |
| 120 | let page = frontPage(app); | |
| 121 | const [a, b] = ids(page); | |
| 122 | app.db.query("UPDATE repo SET archived_at = '2026-10-26T00:00:00Z' WHERE id = ?1").run(a!); | |
| 123 | let next = frontPage(app); | |
| 124 | expect(next).not.toBe(page); | |
| 125 | expect(frontPage(app)).toBe(next); | |
| 126 | page = next; | |
| 127 | app.db.query("UPDATE app SET access = 'private' WHERE repo_id = ?1").run(b!); | |
| 128 | next = frontPage(app); | |
| 129 | expect(next).not.toBe(page); | |
| 130 | expect(frontPage(app)).toBe(next); | |
| 131 | page = next; | |
| 132 | const appId = app.db.query<{ id: string }, [string]>("SELECT id FROM app WHERE repo_id = ?1").get(ids(page)[0]!)!.id; | |
| 133 | recordLook(app.db, { appId, env: "production", commitOid: "b".repeat(40), kind: "thumb", path: `looks/${appId}/thumb.png` }); | |
| 134 | next = frontPage(app); | |
| 135 | expect(next).not.toBe(page); | |
| 136 | // A phone look, or a preview's thumb, is not the shelf's concern. | |
| 137 | recordLook(app.db, { appId, env: "production", commitOid: "c".repeat(40), kind: "phone", path: "x.png" }); | |
| 138 | recordLook(app.db, { appId, env: "preview", commitOid: "c".repeat(40), kind: "thumb", path: "x.png" }); | |
| 139 | expect(frontPage(app)).toBe(next); | |
| 140 | }); | |
| 141 | ||
| 142 | test("with 2,000 more apps the key keeps its size and reads no app or look column (#6325)", () => { | |
| 143 | const owner = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id; | |
| 144 | const small = frontKey(app); | |
| 145 | app.db.transaction(() => { | |
| 146 | for (let n = 0; n < 2000; n++) { | |
| 147 | app.db.query("INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, 'public')").run(`bulk${n}`, owner, `bulk${n}`); | |
| 148 | app.db.query("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'public')").run(`bulkapp${n}`, `bulk${n}`, `bulk${n}`); | |
| 149 | } | |
| 150 | })(); | |
| 151 | const big = frontKey(app); | |
| 152 | expect(big).not.toBe(small); | |
| 153 | expect(big).toMatch(/^\d{1,9}$/); | |
| 154 | const page = frontPage(app); | |
| 155 | const asked: string[] = []; | |
| 156 | const spy = spyOn(app.db, "query").mockImplementation(((sql: string) => { | |
| 157 | asked.push(sql); | |
| 158 | return (Object.getPrototypeOf(app.db).query as (s: string) => unknown).call(app.db, sql); | |
| 159 | }) as never); | |
| 160 | expect(frontPage(app)).toBe(page); | |
| 161 | spy.mockRestore(); | |
| 162 | expect(asked).toEqual(["SELECT n FROM front_epoch"]); | |
| 163 | }); |
test/front-timing.test.ts +2 −2
| @@ -33,13 +33,13 @@ async function read(): Promise<string[]> { | ||
| 33 | 33 | return (res.headers.get("server-timing") ?? "").split(", "); |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | test("GET / signed out names session, shelf, tiles, render in order and covers the handler's share of app", async () => { | |
| 36 | test("GET / signed out names session, key, shelf, tiles, render in order and covers the handler's share of app", async () => { | |
| 37 | 37 | for (let i = 0; i < 5; i++) await read(); // warm |
| 38 | 38 | const shares: number[] = []; |
| 39 | 39 | for (let i = 0; i < 15; i++) { |
| 40 | 40 | const parts = await read(); |
| 41 | 41 | const names = parts.map((p) => p.split(";")[0]); |
| 42 | expect(names.filter((n) => n !== "app" && n !== "db")).toEqual(["session", "shelf", "tiles", "render"]); | |
| 42 | expect(names.filter((n) => n !== "app" && n !== "db")).toEqual(["session", "key", "shelf", "tiles", "render"]); | |
| 43 | 43 | const total = dur(parts.find((p) => p.startsWith("app;"))!); |
| 44 | 44 | const named = parts.filter((p) => /^(session|shelf|tiles|render);/.test(p)).reduce((s, p) => s + dur(p), 0); |
| 45 | 45 | shares.push(total === 0 ? 1 : named / total); |
new above
offline