Public: anyone can read this; the crew can change it.
Feature flags, part 3: each request's variant is counted, and Settings → Flags shows the A/B read-out for 7 and 30 days with one button to make the winner the default #2159
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1829-feature-flags-part-3-each-request-s-vari → main 5 commits fork 7 threads · 7 unresolved
README.md +2
| @@ -1167,3 +1167,5 @@ stays empty — the preview has no install step, so the files are committed unde | ||
| 1167 | 1167 | |
| 1168 | 1168 | Not done: the SVG route is unchanged and still the card's source of truth; the |
| 1169 | 1169 | rasterizer runs on the main thread (about 100 ms once per distinct card). |
| 1170 | ||
| 1171 | Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people. | |
src/actions/flag_winner.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * Make a flag's winner the default (#1829): the flag reads the winner at once | |
| 3 | * for everyone (per-user, role and percentage rules cleared), and an issue on | |
| 4 | * the forge's repository asks the crew to remove the losing path and the flag. | |
| 5 | * Only the variant the read-out offers (`winnerOffer`: enough reads, no tie, | |
| 6 | * not already the value) is accepted, whichever door asks. The issue is opened first, so a refusal leaves the flag as it was. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { flagRegistry, mayManageFlags, winnerOffer } from "../core/flags.ts"; | |
| 11 | import type { Issue } from "../core/issues.ts"; | |
| 12 | import { getRepo } from "../core/repos.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { openIssue } from "./issues.ts"; | |
| 15 | ||
| 16 | export function makeWinnerDefault( | |
| 17 | app: App, | |
| 18 | actor: User, | |
| 19 | name: string, | |
| 20 | winner: "on" | "off", | |
| 21 | ): { ok: true; issue: Issue } | { ok: false; message: string } { | |
outdated
Gate on the offer here, not just on admin: compute winnerOffer(registry.uses(app.db,name,30), registry.value(name)) and return {ok:false,message:"That variant is not the winner yet."} unless it equals |
||
| 22 | if (!mayManageFlags(actor)) return { ok: false, message: "Flags are set by a forge admin." }; | |
| 23 | const registry = flagRegistry(); | |
| 24 | const decl = (registry.decls as Record<string, { why: string; issue: number }>)[name]; | |
| 25 | if (!Object.hasOwn(registry.decls, name) || decl === undefined) | |
| 26 | return { ok: false, message: "There is no such flag." }; | |
The |
||
| 27 | if (winnerOffer(registry.uses(app.db, name, 30), registry.value(name as never)) !== winner) | |
| 28 | return { ok: false, message: "That variant is not the winner yet." }; | |
| 29 | const [owner, repoName] = (app.config.forgeRepo ?? "").split("/"); | |
| 30 | const repo = owner && repoName ? getRepo(app.db, owner, repoName) : undefined; | |
| 31 | if (!repo) return { ok: false, message: "The forge names no repository to open the issue on." }; | |
| 32 | const loser = winner === "on" ? "off" : "on"; | |
| 33 | const issue = openIssue(app, actor, repo, { | |
| 34 | title: `Remove flag ${name}: keep the ${winner} path`, | |
| 35 | body: | |
| 36 | `The A/B read-out for \`${name}\` ended with **${winner}** the winner, and it is now the flag's value for everyone.\n\n` + | |
| 37 | `Remove the ${loser} path and the \`${name}\` flag (declared in \`src/core/flags.ts\`; owner #${decl.issue}). ` + | |
outdated
Good ordering (issue first). The TUI needs to call this too so both doors can do it. |
||
| 38 | `Flag: ${decl.why}`, | |
| 39 | }); | |
| 40 | if (!issue.ok) return { ok: false, message: "The issue could not be opened." }; | |
| 41 | registry.set(app.db, name, { forge: winner === "on" }, actor.id); | |
| 42 | return { ok: true, issue: issue.value }; | |
| 43 | } |
src/core/flags.ts +61 −1
| @@ -41,6 +41,24 @@ export type FlagValue = { | ||
| 41 | 41 | percent?: number; |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | /** How many times a flag answered each way. */ | |
| 45 | export type FlagUses = { on: number; off: number }; | |
| 46 | ||
| 47 | /** Fewer reads than this in 30 days are too few to call a winner. */ | |
| 48 | export const MIN_WINNER_READS = 50; | |
| 49 | ||
| 50 | /** The variant to offer as the default: the 30-day leader, unless it is too few reads, a tie, or already the forge's value. */ | |
| 51 | export function winnerOffer(uses: FlagUses, current: FlagValue | undefined): "on" | "off" | undefined { | |
| 52 | if (uses.on + uses.off < MIN_WINNER_READS || uses.on === uses.off) return undefined; | |
| 53 | const lead = uses.on > uses.off ? "on" : "off"; | |
| 54 | return current?.forge === (lead === "on") ? undefined : lead; | |
| 55 | } | |
| 56 | ||
| 57 | /** The read-out line both doors show. */ | |
| 58 | export function usesLine(week: FlagUses, month: FlagUses): string { | |
| 59 | return `Uses, 7 days: on ${week.on} · off ${week.off} · 30 days: on ${month.on} · off ${month.off}`; | |
| 60 | } | |
| 61 | ||
| 44 | 62 | const KEY_PREFIX = "flag."; |
| 45 | 63 | |
| 46 | 64 | /** FNV-1a with a final avalanche, so near ids do not land in near buckets. */ |
| @@ -169,6 +187,9 @@ export function unreadFlags( | ||
| 169 | 187 | /** A flag registry over `decls`; the forge's own is `flag`/`loadFlags` below. */ |
| 170 | 188 | export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) { |
| 171 | 189 | let values = new Map<string, FlagValue>(); |
| 190 | // Reads waiting to be written: `day\tflag\tvariant` → count. Counting is a | |
| 191 | // map bump; `flush` writes the lot in one transaction, so a read costs no query. | |
| 192 | let pending = new Map<string, number>(); | |
| 172 | 193 | return { |
| 173 | 194 | /** Read every stored value once. A failure leaves the defaults. */ |
| 174 | 195 | load(db: Database): void { |
| @@ -230,7 +251,43 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) { | ||
| 230 | 251 | return true; |
| 231 | 252 | }, |
| 232 | 253 | flag(name: keyof D & string, viewer?: Viewer): boolean { |
| 233 | return evaluate(name, decls[name]!.default, values.get(name), viewer); | |
| 254 | const on = evaluate(name, decls[name]!.default, values.get(name), viewer); | |
| 255 | const key = `${new Date().toISOString().slice(0, 10)}\t${name}\t${on ? 1 : 0}`; | |
| 256 | pending.set(key, (pending.get(key) ?? 0) + 1); | |
| 257 | return on; | |
| 258 | }, | |
| 259 | /** Write the counted reads. A failure keeps them for the next try. */ | |
| 260 | flush(db: Database): void { | |
| 261 | if (pending.size === 0) return; | |
| 262 | const taken = pending; | |
| 263 | pending = new Map(); | |
| 264 | try { | |
| 265 | const up = db.query( | |
| 266 | `INSERT INTO flag_use (day, flag, variant, n) VALUES (?1, ?2, ?3, ?4) | |
| 267 | ON CONFLICT (day, flag, variant) DO UPDATE SET n = n + excluded.n`, | |
| 268 | ); | |
| 269 | db.transaction(() => { | |
| 270 | for (const [key, n] of taken) { | |
| 271 | const [day, flag, variant] = key.split("\t"); | |
| 272 | up.run(day!, flag!, Number(variant), n); | |
| 273 | } | |
| 274 | })(); | |
| 275 | } catch { | |
| 276 | for (const [key, n] of taken) pending.set(key, (pending.get(key) ?? 0) + n); | |
| 277 | } | |
| 278 | }, | |
| 279 | /** Reads per variant over the last `days` UTC days, today included. */ | |
| 280 | uses(db: Database, name: string, days: number, now = new Date()): FlagUses { | |
| 281 | this.flush(db); | |
| 282 | const since = new Date(now.getTime() - (days - 1) * 86_400_000).toISOString().slice(0, 10); | |
| 283 | const rows = db | |
| 284 | .query<{ variant: number; n: number }, [string, string]>( | |
| 285 | "SELECT variant, SUM(n) AS n FROM flag_use WHERE flag = ?1 AND day >= ?2 GROUP BY variant", | |
| 286 | ) | |
| 287 | .all(name, since); | |
| 288 | const out: FlagUses = { on: 0, off: 0 }; | |
| 289 | for (const r of rows) out[r.variant === 1 ? "on" : "off"] = r.n; | |
| 290 | return out; | |
| 234 | 291 | }, |
| 235 | 292 | }; |
| 236 | 293 | } |
| @@ -243,6 +300,9 @@ export const loadFlags = forgeFlags.load; | ||
| 243 | 300 | /** Whether `name` is on for `viewer` (none: signed out). */ |
| 244 | 301 | export const flag = forgeFlags.flag; |
| 245 | 302 | |
| 303 | /** Write the counted flag reads; the boot calls it on a timer and at exit. */ | |
| 304 | export const flushFlagUses = forgeFlags.flush; | |
| 305 | ||
| 246 | 306 | /** The forge's registry, for the settings page. */ |
| 247 | 307 | export const forgeRegistry = forgeFlags; |
| 248 | 308 |
src/core/migrations/20261016140000-flag-use.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | // Flag reads counted by the day (#1829): one row per UTC day, flag and the | |
| 2 | // variant it answered (1 on, 0 off). Written in batches from memory. | |
| 3 | export default ` | |
| 4 | CREATE TABLE flag_use ( | |
| 5 | day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL, | |
| 6 | n INTEGER NOT NULL DEFAULT 0, | |
| 7 | PRIMARY KEY (day, flag, variant) | |
| 8 | ) WITHOUT ROWID; | |
| 9 | `; |
src/main.ts +4 −1
| @@ -11,7 +11,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts"; | ||
| 11 | 11 | import { releaseEnv } from "./release/env.ts"; |
| 12 | 12 | import { createApp } from "./app.ts"; |
| 13 | 13 | import { backfillCarried } from "./core/carried.ts"; |
| 14 | import { loadFlags } from "./core/flags.ts"; | |
| 14 | import { flushFlagUses, loadFlags } from "./core/flags.ts"; | |
| 15 | 15 | import { reconcile } from "./core/reconcile.ts"; |
| 16 | 16 | import { syncAllPillars } from "./actions/pillars.ts"; |
| 17 | 17 | import { bootHostedChildren } from "./spawn/host.ts"; |
| @@ -241,6 +241,7 @@ const app = createApp({ | ||
| 241 | 241 | }); |
| 242 | 242 | |
| 243 | 243 | loadFlags(app.db); |
| 244 | const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000); | |
| 244 | 245 | await reconcile(app); |
| 245 | 246 | // Issues linked under a pillar's umbrella before pillars had labels. |
| 246 | 247 | const labelled = syncAllPillars(app); |
| @@ -565,6 +566,8 @@ function close(): void { | ||
| 565 | 566 | app.log.info("forge shutting down"); |
| 566 | 567 | ssh?.stop(); |
| 567 | 568 | servers.stop(); |
| 569 | clearInterval(flagTimer); | |
| 570 | flushFlagUses(app.db); | |
| 568 | 571 | app.close(); |
| 569 | 572 | process.exit(0); |
| 570 | 573 | } |
src/tui/screens/flags.ts +27 −4
| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | * why it exists, its default, the owner issue and its live value, as |
| 4 | 4 | * Settings → Flags shows them. Enter sets one from a line like |
| 5 | 5 | * `forge=on users=u1,u2 roles=admin percent=30` (checked by `checkInput`, the |
| 6 | * page's own check); `x` clears it. A change goes through the registry's | |
| 7 | * `set`, so it is live at once, as on the page. The gate is the page's: | |
| 6 | * page's own check); `x` clears it; `w` makes the 30-day winner the default | |
| 7 | * and opens the removal issue (`makeWinnerDefault`). A change goes through | |
| 8 | * the registry's `set`, so it is live at once, as on the page. The gate is the page's: | |
| 8 | 9 | * an admin who is not an agent; anyone else sees nothing and sets nothing. |
| 9 | 10 | */ |
| 10 | 11 | |
| @@ -12,10 +13,13 @@ import { | ||
| 12 | 13 | checkInput, |
| 13 | 14 | flagRegistry, |
| 14 | 15 | mayManageFlags, |
| 16 | usesLine, | |
| 17 | winnerOffer, | |
| 15 | 18 | type FlagDecl, |
| 16 | 19 | type FlagInput, |
| 17 | 20 | type FlagValue, |
| 18 | 21 | } from "../../core/flags.ts"; |
| 22 | import { makeWinnerDefault } from "../../actions/flag_winner.ts"; | |
| 19 | 23 | import type { Ctx, Screen } from "../app/app.ts"; |
| 20 | 24 | import { binding } from "../app/keys.ts"; |
| 21 | 25 | import { style } from "../term/style.ts"; |
| @@ -65,13 +69,30 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen { | ||
| 65 | 69 | ctx.invalidate(); |
| 66 | 70 | }; |
| 67 | 71 | |
| 72 | /** The read-out of the flag under the cursor. */ | |
| 73 | const usesOf = (name: string): string => | |
| 74 | `${name} ${usesLine(flagRegistry().uses(f.db, name, 7), flagRegistry().uses(f.db, name, 30))}`; | |
| 75 | ||
| 76 | const offer = (name: string) => | |
| 77 | winnerOffer(flagRegistry().uses(f.db, name, 30), flagRegistry().value(name as never)); | |
| 78 | ||
| 79 | const winner = (name: string): void => { | |
| 80 | const lead = offer(name); | |
| 81 | if (!allowed || f.user === undefined || lead === undefined) return; | |
| 82 | const made = makeWinnerDefault(f.app, f.user, name, lead); | |
| 83 | note = made.ok | |
| 84 | ? `${name}: ${lead} is the default; issue #${made.issue.number} asks the crew to remove the flag.` | |
| 85 | : made.message; | |
| 86 | ctx.invalidate(); | |
| 87 | }; | |
| 88 | ||
| 68 | 89 | const rows = (): ListRow[] => |
| 69 | 90 | names().map((name) => { |
| 70 | 91 | const decl = (flagRegistry().decls as Record<string, FlagDecl>)[name]!; |
| 71 | 92 | return { |
| 72 | 93 | title: name, |
| 73 | 94 | secondary: show(flagRegistry().value(name as never)), |
| 74 | meta: `${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`, | |
| 95 | meta: `${offer(name) === undefined ? "" : `w: make ${offer(name)} the default `}${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`, | |
rows() runs offer(name) twice per flag, and each call flushes and runs a 30-day query. rows() runs on every render, so this is up to 2N queries per frame. Compute the offer once per row, and cache it for a few seconds. |
||
| 75 | 96 | }; |
| 76 | 97 | }); |
| 77 | 98 | |
| @@ -81,6 +102,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen { | ||
| 81 | 102 | binding(["j", "k"], "move cursor"), |
| 82 | 103 | binding(["enter"], "set"), |
| 83 | 104 | binding(["x"], "clear"), |
| 105 | binding(["w"], "make the winner the default"), | |
| 84 | 106 | ], |
| 85 | 107 | render(area, s) { |
| 86 | 108 | const c = ctx.theme.c; |
| @@ -92,7 +114,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen { | ||
| 92 | 114 | ? "Flags are set by a forge admin." |
| 93 | 115 | : names().length === 0 |
| 94 | 116 | ? "No flag is declared yet." |
| 95 | : `${names().length} flag${names().length === 1 ? "" : "s"}`; | |
| 117 | : usesOf(names()[state.index] ?? names()[0]!); | |
| 96 | 118 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 97 | 119 | drawList(s, list!, c, state, rows()); |
| 98 | 120 | }, |
| @@ -103,6 +125,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen { | ||
| 103 | 125 | const name = all[state.index]; |
| 104 | 126 | if (name === undefined) return false; |
| 105 | 127 | if (e.key === "x") save(name, undefined); |
| 128 | else if (e.key === "w") winner(name); | |
| 106 | 129 | else if (e.key === "enter") |
| 107 | 130 | ctx.push( |
| 108 | 131 | promptScreen(ctx, screen, { |
src/web/pages/flags.ts +21 −3
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * Anyone else is told 403, and an agent is never an admin here. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { checkInput, flagRegistry, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts"; | |
| 9 | import { checkInput, flagRegistry, usesLine, winnerOffer, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts"; | |
| 10 | import { makeWinnerDefault } from "../../actions/flag_winner.ts"; | |
| 10 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 12 | import { settingsLayout, signInFirst } from "../chrome.ts"; |
| 12 | 13 | import { field, readForm } from "../form.ts"; |
| @@ -66,14 +67,17 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 66 | 67 | const rows = names.map((name) => { |
| 67 | 68 | const decl = (registry.decls as Record<string, FlagDecl>)[name]!; |
| 68 | 69 | const v = registry.value(name as never); |
| 70 | const week = registry.uses(ctx.app.db, name, 7); | |
| 71 | const month = registry.uses(ctx.app.db, name, 30); | |
| 72 | const lead = winnerOffer(month, v); | |
| 69 | 73 | return row({ |
| 70 | 74 | lead: icon("activity"), |
| 71 | 75 | title: html`<span class="mono">${name}</span>`, |
| 72 | 76 | meta: html`${decl.why}<br />Default |
| 73 | 77 | <span class="mono">${decl.default ? "on" : "off"}</span> · owner |
| 74 | 78 | ${issueLink(repo, decl.issue)} · |
| 75 | live: ${summary(v)}`, | |
| 76 | trail: html`<form class="row-actions" method="post" action="/settings/flags"> | |
| 79 | live: ${summary(v)}<br /><span class="mono">${usesLine(week, month)}</span>`, | |
| 80 | trail: html`<form class="row-actions flag-form" method="post" action="/settings/flags"> | |
| 77 | 81 | <input type="hidden" name="name" value="${name}" /> |
| 78 | 82 | <select class="input" name="forge" aria-label="${name} for the forge"> |
| 79 | 83 | <option value="" ${v?.forge === undefined ? "selected" : ""}>forge: default</option> |
| @@ -85,6 +89,9 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 85 | 89 | <input class="input mono" name="percent" placeholder="%" size="3" value="${v?.percent === undefined ? "" : String(v.percent)}" aria-label="${name} percent" /> |
| 86 | 90 | ${btn("Save", { size: "sm", variant: "primary", label: `Save ${name}` })} |
| 87 | 91 | ${btn("Clear", { size: "sm", name: "clear", value: "1", label: `Clear ${name}` })} |
| 92 | ${lead === undefined | |
| 93 | ? "" | |
| 94 | : btn(`Make ${lead} the default`, { size: "sm", name: "winner", value: lead, label: `Make ${lead} the default for ${name}` })} | |
| 88 | 95 | </form>`, |
outdated
Show this only when |
||
| 89 | 96 | }); |
| 90 | 97 | }); |
| @@ -119,6 +126,17 @@ export async function flagsSet(ctx: Ctx): Promise<Response> { | ||
| 119 | 126 | const form = await readForm(ctx, "admin"); |
| 120 | 127 | if (form instanceof Response) return form; |
| 121 | 128 | const name = field(form, "name"); |
| 129 | const winner = field(form, "winner"); | |
| 130 | if (winner === "on" || winner === "off") { | |
| 131 | const made = makeWinnerDefault(ctx.app, ctx.auth.user!, name, winner); | |
| 132 | return flashTo( | |
| 133 | ctx, | |
| 134 | "/settings/flags", | |
| 135 | made.ok | |
| 136 | ? { message: `${name}: ${winner} is the default; issue #${made.issue.number} asks the crew to remove the flag.` } | |
| 137 | : { tone: "danger", message: made.message }, | |
| 138 | ); | |
| 139 | } | |
| 122 | 140 | const checked = |
| 123 | 141 | field(form, "clear") === "1" |
| 124 | 142 | ? ({ ok: true, value: undefined } as const) |
src/web/style/content.ts +8
| @@ -362,6 +362,14 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 362 | 362 | .row:has(.headroom) { flex-wrap: wrap; } |
| 363 | 363 | .row:has(.headroom) .row-trail { flex-basis: 100%; flex-wrap: wrap; } |
| 364 | 364 | } |
| 365 | /* Settings → Flags: a flag's controls wrap under its text on a phone, never sideways. */ | |
| 366 | .flag-form { flex-wrap: wrap; justify-content: flex-end; max-width: 100%; } | |
| 367 | @media (max-width: 640px) { | |
| 368 | .row:has(.flag-form) { flex-wrap: wrap; } | |
| 369 | .row:has(.flag-form) .row-trail { flex-basis: 100%; min-width: 0; } | |
| 370 | .flag-form { justify-content: flex-start; } | |
| 371 | .flag-form .input { min-width: 0; flex: 1 1 8rem; } | |
| 372 | } | |
| 365 | 373 | .backups-now { margin-top: 12px; } |
| 366 | 374 | /* The fleet graph: a selection's spend and runs, hour by hour and day by day. |
| 367 | 375 | Series colours are Okabe–Ito (told apart with common colour-vision |
test/flags-settings.test.ts +96 −1
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | /** Settings → Flags (#1970): admin-only, validated, live at once, kept. */ |
| 2 | 2 | |
| 3 | import { Database } from "bun:sqlite"; | |
| 3 | 4 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 4 | 5 | import { mkdtemp, rm } from "node:fs/promises"; |
| 5 | 6 | import { tmpdir } from "node:os"; |
| 6 | 7 | import { join } from "node:path"; |
| 7 | 8 | import { createApp, type App } from "../src/app.ts"; |
| 8 | import { checkInput, makeFlags } from "../src/core/flags.ts"; | |
| 9 | import { checkInput, makeFlags, winnerOffer } from "../src/core/flags.ts"; | |
| 10 | import { createRepo } from "../src/core/repos.ts"; | |
| 9 | 11 | import { createUser } from "../src/core/users.ts"; |
| 10 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 13 | import { useFlagRegistry } from "../src/web/pages/flags.ts"; |
| @@ -106,3 +108,96 @@ describe("checkInput", () => { | ||
| 106 | 108 | expect(checkInput({ ...base, ...bad }).ok).toBe(false); |
| 107 | 109 | }); |
| 108 | 110 | }); |
| 111 | ||
| 112 | describe("flag read-out (#1829)", () => { | |
| 113 | test("counts per variant match the table, for 7 and 30 days", () => { | |
| 114 | const r = makeFlags({ alpha: decl }); | |
| 115 | const db = new Database(":memory:"); | |
| 116 | db.exec("CREATE TABLE flag_use (day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL, n INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, flag, variant)) WITHOUT ROWID"); | |
| 117 | const day = (ago: number) => new Date(Date.now() - ago * 86_400_000).toISOString().slice(0, 10); | |
| 118 | const put = db.query("INSERT INTO flag_use VALUES (?1, 'alpha', ?2, ?3)"); | |
| 119 | put.run(day(10), 1, 100); | |
| 120 | put.run(day(40), 0, 900); | |
| 121 | put.run(day(2), 0, 5); | |
| 122 | expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 5 }); | |
| 123 | for (let i = 0; i < 3; i++) r.flag("alpha"); | |
| 124 | expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 8 }); | |
| 125 | expect(r.uses(db, "alpha", 30)).toEqual({ on: 100, off: 8 }); | |
| 126 | expect(db.query("SELECT SUM(n) AS n FROM flag_use WHERE flag = 'alpha' AND day >= ?1").get(day(29))).toEqual({ n: 108 }); | |
| 127 | expect(r.uses(db, "beta", 30)).toEqual({ on: 0, off: 0 }); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a read runs no query; flush writes the batch", () => { | |
| 131 | const r = makeFlags({ alpha: decl }); | |
| 132 | const db = new Database(":memory:"); // no table: a query would throw | |
| 133 | for (let i = 0; i < 4; i++) expect(r.flag("alpha")).toBe(false); | |
| 134 | r.flush(db); // fails quietly, keeps the counts | |
| 135 | db.exec("CREATE TABLE flag_use (day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL, n INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, flag, variant)) WITHOUT ROWID"); | |
| 136 | expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 4 }); | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | describe("make the winner the default (#1829)", () => { | |
| 141 | let dataDir: string; | |
| 142 | let app: App; | |
| 143 | let servers: Servers; | |
| 144 | const reg2 = makeFlags({ beta: decl }); | |
| 145 | beforeAll(async () => { | |
| 146 | dataDir = await mkdtemp(join(tmpdir(), "forge-flagwin-")); | |
| 147 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" }); | |
| 148 | servers = await serve(app); | |
| 149 | const u = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 150 | if (!u.ok) throw new Error(u.error); | |
| 151 | const made = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "forge", visibility: "public" }); | |
| 152 | if (!made.ok) throw new Error(String(made.error)); | |
| 153 | useFlagRegistry(reg2); | |
| 154 | }); | |
| 155 | afterAll(async () => { | |
| 156 | useFlagRegistry(undefined); | |
| 157 | servers.stop(); | |
| 158 | app.close(); | |
| 159 | await rm(dataDir, { recursive: true, force: true }); | |
| 160 | }); | |
| 161 | ||
| 162 | test("opens the removal issue and the flag reads the winner", async () => { | |
| 163 | const res0 = await fetch(`${servers.url}/signin`, { | |
| 164 | method: "POST", redirect: "manual", | |
| 165 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 166 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 167 | }); | |
| 168 | const cookie = (res0.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 169 | for (let i = 0; i < 60; i++) reg2.flag("beta"); // off x60: enough reads to call a winner | |
| 170 | const post = (body: Record<string, string>) => | |
| 171 | fetch(`${servers.url}/settings/flags`, { | |
| 172 | method: "POST", redirect: "manual", | |
| 173 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 174 | body: new URLSearchParams(body), | |
| 175 | }); | |
| 176 | reg2.set(app.db, "beta", { users: ["u1"] }, "x"); | |
| 177 | const page = await (await fetch(`${servers.url}/settings/flags`, { headers: { cookie } })).text(); | |
| 178 | expect(page).toContain("Uses, 7 days"); | |
| 179 | expect(page).toContain("Uses, 7 days: on 0 · off 60"); | |
| 180 | expect(page).toContain("Make off the default"); | |
| 181 | expect(winnerOffer({ on: 3, off: 2 }, undefined)).toBeUndefined(); // too few reads | |
| 182 | expect(winnerOffer({ on: 5, off: 60 }, { forge: false })).toBeUndefined(); // already the value | |
| 183 | expect(winnerOffer({ on: 30, off: 30 }, undefined)).toBeUndefined(); // a tie | |
| 184 | expect(winnerOffer({ on: 5, off: 60 }, { forge: true })).toBe("off"); | |
| 185 | ||
| 186 | // The unoffered variant (and a flag with too few reads) is refused: no issue, no change. | |
| 187 | expect((await post({ name: "beta", winner: "on" })).status).toBe(303); | |
| 188 | expect(reg2.value("beta")).toEqual({ users: ["u1"] }); | |
| 189 | expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]); | |
| 190 | ||
| 191 | expect((await post({ name: "beta", winner: "off" })).status).toBe(303); | |
| 192 | expect(reg2.value("beta")).toEqual({ forge: false }); | |
| 193 | expect(reg2.flag("beta")).toBe(false); | |
outdated
This posts winner=on while the page offered off, and expects success. Post the offered variant, and add a refusal case for the other one. |
||
| 194 | const issues = app.db.query<{ title: string; body: string }, []>("SELECT title, body FROM issue").all(); | |
| 195 | expect(issues.length).toBe(1); | |
| 196 | expect(issues[0]!.title).toContain("Remove flag beta"); | |
| 197 | expect(issues[0]!.body).toContain("#1970"); | |
| 198 | expect(issues[0]!.title).toContain("keep the off path"); | |
| 199 | ||
| 200 | await post({ name: "nope", winner: "on" }); | |
| 201 | expect(app.db.query("SELECT 1 FROM issue").all().length).toBe(1); | |
| 202 | }); | |
| 203 | }); |
test/tui-flags.test.ts +23
| @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 11 | 11 | import { makeFlags, useFlagRegistry } from "../src/core/flags.ts"; |
| 12 | import { createRepo } from "../src/core/repos.ts"; | |
| 12 | 13 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | 14 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 14 | 15 | import { flagsScreen, parseLine } from "../src/tui/screens/flags.ts"; |
| @@ -100,6 +101,26 @@ describe("flags in the terminal", () => { | ||
| 100 | 101 | h.stop(); |
| 101 | 102 | }); |
| 102 | 103 | |
| 104 | test("the read-out shows, and w makes the winner the default and opens the issue", async () => { | |
| 105 | const owner = getUserByName(forge.db, "alice")!; | |
| 106 | const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.id, name: "forge", visibility: "public" }); | |
| 107 | if (!made.ok) throw new Error(String(made.error)); | |
| 108 | forge.config.forgeRepo = "alice/forge"; | |
| 109 | for (let i = 0; i < 60; i++) reg.flag("alpha"); | |
| 110 | const h = boot("alice"); | |
| 111 | await open(h); | |
| 112 | expect(h.text()).toContain("Uses, 7 days: on 1 · off 60"); | |
| 113 | expect(h.text()).toContain("w: make off the default"); | |
| 114 | await h.press("w"); | |
| 115 | expect(reg.value("alpha")).toEqual({ forge: false }); | |
| 116 | expect(h.text()).toContain("off is the default; issue #1"); | |
| 117 | expect(forge.db.query("SELECT title FROM issue").all()).toEqual([{ title: "Remove flag alpha: keep the off path" }]); | |
| 118 | await h.press("w"); // already the value: no second issue | |
| 119 | expect(forge.db.query("SELECT 1 FROM issue").all().length).toBe(1); | |
| 120 | await h.press("x"); | |
| 121 | h.stop(); | |
| 122 | }); | |
| 123 | ||
| 103 | 124 | test("a bad line is refused and nothing changes", async () => { |
| 104 | 125 | const h = boot("alice"); |
| 105 | 126 | await open(h); |
| @@ -132,8 +153,10 @@ describe("flags in the terminal", () => { | ||
| 132 | 153 | const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never); |
| 133 | 154 | key("enter"); |
| 134 | 155 | key("x"); |
| 156 | key("w"); | |
| 135 | 157 | expect(pushed).toEqual([]); |
| 136 | 158 | expect(reg.value("alpha")).toBeUndefined(); |
| 159 | expect(forge.db.query("SELECT 1 FROM issue WHERE author_id = ?1").all(user.id)).toEqual([]); | |
| 137 | 160 | expect(forge.db.query("SELECT key FROM forge_setting_change WHERE key = 'flag.alpha' AND actor_id = ?1").all(user.id).length).toBe(0); |
| 138 | 161 | } |
| 139 | 162 | }); |
This paragraph sits at the end of the card-rasterizer section. Give it its own heading, for example "Flag read-out", so the next reader can find it.