Public: anyone can read this; the crew can change it.
A proposal to the parent forge reaches an open chain page live, through any door #2436
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2230-a-proposal-to-the-parent-forge-reaches-a → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +49
−5
src/actions/upstream.ts +23 −3
| @@ -271,18 +271,32 @@ export async function proposeToParentForge( | ||
| 271 | 271 | return err({ kind: "refused", reason: "the parent's answer was not a pull" }); |
| 272 | 272 | |
| 273 | 273 | const now = Date.now(); |
| 274 | app.db | |
| 274 | const inserted = app.db | |
| 275 | 275 | .query( |
| 276 | 276 | `INSERT INTO upstream_proposal (repo_id, branch, number, title, source, state, reachable, checked_at, created_at) |
| 277 | 277 | VALUES (?1, ?2, ?3, ?4, ?5, 'open', 1, ?6, ?6) ON CONFLICT (repo_id, branch) DO NOTHING`, |
| 278 | 278 | ) |
| 279 | 279 | .run(repo.id, branch, made.number, title.slice(0, 200), source, now); |
| 280 | if (inserted.changes > 0) announce(app, repo, actor, made.number, "open"); | |
| 280 | 281 | const row = app.db |
| 281 | 282 | .query<Row, [string, string]>("SELECT * FROM upstream_proposal WHERE repo_id = ?1 AND branch = ?2") |
| 282 | 283 | .get(repo.id, branch)!; |
| 283 | 284 | return ok({ ...asProposal(cred, row), opened: true }); |
| 284 | 285 | } |
| 285 | 286 | |
| 287 | /** | |
| 288 | * Tell an open chain page its Proposals card changed. Tied to the repository, | |
| 289 | * so the stream hands it only to those who can already see the forge. | |
| 290 | */ | |
| 291 | function announce(app: App, repo: Repo, actor: User | undefined, number: number, state: ForgeProposalState): void { | |
| 292 | app.bus.append({ | |
| 293 | kind: "forge.proposal", | |
| 294 | repoId: repo.id, | |
| 295 | ...(actor === undefined ? {} : { actorId: actor.id }), | |
| 296 | payload: { repo: `${repo.ownerName}/${repo.name}`, number, state }, | |
| 297 | }); | |
| 298 | } | |
| 299 | ||
| 286 | 300 | /** Ask the parent how one pull stands; undefined when it did not say. */ |
| 287 | 301 | async function readState(cred: ParentCredential, number: number): Promise<ForgeProposalState | undefined> { |
| 288 | 302 | try { |
| @@ -323,9 +337,15 @@ export async function forgeProposals( | ||
| 323 | 337 | if (!refresh || i >= REFRESHED || r.state !== "open" || now - r.checked_at < STATE_TTL_MS) return r; |
| 324 | 338 | const state = await readState(cred, r.number); |
| 325 | 339 | const next = { ...r, state: state ?? r.state, reachable: state === undefined ? 0 : 1, checked_at: now }; |
| 340 | // The state is compared in the write itself, so two readers racing to | |
| 341 | // record the same change publish it once between them. | |
| 342 | const moved = app.db | |
| 343 | .query("UPDATE upstream_proposal SET state = ?1 WHERE id = ?2 AND state != ?1") | |
| 344 | .run(next.state, r.id); | |
| 326 | 345 | app.db |
| 327 | .query("UPDATE upstream_proposal SET state = ?1, reachable = ?2, checked_at = ?3 WHERE id = ?4") | |
| 328 | .run(next.state, next.reachable, now, r.id); | |
| 346 | .query("UPDATE upstream_proposal SET reachable = ?1, checked_at = ?2 WHERE id = ?3") | |
| 347 | .run(next.reachable, now, r.id); | |
| 348 | if (moved.changes > 0) announce(app, repo, undefined, r.number, next.state); | |
| 329 | 349 | return next; |
| 330 | 350 | }), |
| 331 | 351 | ); |
src/web/pages/chain.ts +1 −1
| @@ -18,7 +18,7 @@ import { flashOrJson, page, redirect } from "../layout.ts"; | ||
| 18 | 18 | import { btn, card } from "../ui.ts"; |
| 19 | 19 | |
| 20 | 20 | /** The events that change what the chain page says. */ |
| 21 | export const CHAIN_LIVE = "forge.severed"; | |
| 21 | export const CHAIN_LIVE = "forge.severed,forge.proposal"; | |
| 22 | 22 | |
| 23 | 23 | const MODE_WORDS = { "tell-me": "recorded: tell me (nothing tells it yet)", "take-updates": "recorded: take updates (nothing acts on it yet)" } as const; |
| 24 | 24 |
test/following.test.ts +1 −1
| @@ -1082,7 +1082,7 @@ describe("an open chain page hears a sever", () => { | ||
| 1082 | 1082 | test("the page carries a live region for forge.severed, scoped to its repository", async () => { |
| 1083 | 1083 | const page = await chainPage(); |
| 1084 | 1084 | expect(page).toContain('id="live-chain"'); |
| 1085 | expect(page).toContain('data-live="forge.severed"'); | |
| 1085 | expect(page).toContain('data-live="forge.severed,forge.proposal"'); | |
| 1086 | 1086 | expect(page).toContain('data-live-repo="kid/forge"'); |
| 1087 | 1087 | expect(page).not.toContain("follows nothing since"); |
| 1088 | 1088 | }); |
test/upstream-forge.test.ts +24
| @@ -256,11 +256,35 @@ describe("a child forge proposes to its parent forge", () => { | ||
| 256 | 256 | await land("termed", "Add termed"); |
| 257 | 257 | const toasts: string[] = []; |
| 258 | 258 | const ctx = { toast: (m: string) => toasts.push(m), pop() {}, push() {}, theme: {} } as any; |
| 259 | const heard: any[] = []; | |
| 260 | const off = child.bus.subscribe((e) => e.kind === "forge.proposal" && heard.push(e)); | |
| 259 | 261 | const screen = await chainScreen(child, kid, ctx); |
| 260 | 262 | expect(screen.keys.map((k: any) => k.keys?.[0] ?? k[0])).toContain("p"); |
| 261 | 263 | screen.key({ key: "p" } as any); |
| 262 | 264 | await Bun.sleep(1500); |
| 265 | off(); | |
| 263 | 266 | expect(toasts.join("\n")).toContain("Proposed to the parent forge as #"); |
| 267 | // One event, tied to the forge's repository so only its readers hear it. | |
| 268 | expect(heard).toHaveLength(1); | |
| 269 | expect(heard[0].repoId).toBe(forge.id); | |
| 270 | expect(heard[0].payload).toMatchObject({ repo: "kid/forge", state: "open" }); | |
| 271 | expect(toasts.join("\n")).toContain(`#${heard[0].payload.number}`); | |
| 272 | }); | |
| 273 | ||
| 274 | test("a state the parent changes is published once, and not again while it holds", async () => { | |
| 275 | const open = (await forgeProposals(child, forge, Date.now() + 20 * STATE_TTL_MS)).find((p) => p.state === "open")!; | |
| 276 | const heard: any[] = []; | |
| 277 | const off = child.bus.subscribe((e) => e.kind === "forge.proposal" && heard.push(e)); | |
| 278 | // Asked again with the parent unchanged: nothing to say. | |
| 279 | await forgeProposals(child, forge, Date.now() + 40 * STATE_TTL_MS); | |
| 280 | expect(heard).toHaveLength(0); | |
| 281 | parent.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = ?2").run(lib.id, open.number); | |
| 282 | await forgeProposals(child, forge, Date.now() + 60 * STATE_TTL_MS); | |
| 283 | await forgeProposals(child, forge, Date.now() + 80 * STATE_TTL_MS); | |
| 284 | off(); | |
| 285 | expect(heard).toHaveLength(1); | |
| 286 | expect(heard[0].payload).toMatchObject({ number: open.number, state: "closed" }); | |
| 287 | expect(heard[0].repoId).toBe(forge.id); | |
| 264 | 288 | }); |
| 265 | 289 | |
| 266 | 290 | test("the page button: a writer's form POST proposes; a cross-origin one is refused", async () => { |
new above
offline
Nit: the two UPDATEs are not atomic. A crash between them leaves state recorded but checked_at stale, which only means one extra read, so harmless. A single UPDATE with RETURNING of the old state would be tidier.