Public: anyone can read this; the crew can change it.
Two acts cannot take one turn: the ledger writes inside a transaction that re-checks, and a race proves it #82
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/46-two-acts-cannot-take-one-turn-the-ledger → main 3 commits fork 1 thread · 1 unresolved
6 files
changed +297
−36
AGENTS.md +6 −1
| @@ -54,7 +54,12 @@ whether it binds, what a seat may do and what the settlement is out — so an | ||
| 54 | 54 | action that plays a turn is a transaction, not a rules engine. Rules of that |
| 55 | 55 | kind go in a pure module an action calls; both speak the same named |
| 56 | 56 | failures, from `src/result.ts`, and `src/example.ts` is the worked table of |
| 57 | `RULES.md` §10 that both are tested against. | |
| 57 | `RULES.md` §10 that both are tested against. A ledger write is a | |
| 58 | transaction that re-checks inside itself: it opens immediate, re-reads the | |
| 59 | acts, asks the rules again about the ledger as it actually is, and only | |
| 60 | then inserts — read, decide and write are never three statements, the row | |
| 61 | carries the turn it took (`act.ordinal`, unique per table) so a door that | |
| 62 | forgets is refused by SQLite, and `emit` is never called with one open. | |
| 58 | 63 | |
| 59 | 64 | Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`, |
| 60 | 65 | verified by signature, never by a cookie the app invents. An action that |
README.md +7
| @@ -129,6 +129,13 @@ and its acceptances are the `accept` acts after it. A new offer therefore | ||
| 129 | 129 | clears every acceptance by existing, and there is no pointer or tally that |
| 130 | 130 | can fall out of step with the record. |
| 131 | 131 | |
| 132 | Because the turn is a count of that ledger, two doors of one seat could | |
| 133 | read the same count at the same instant and both believe they hold the | |
| 134 | turn. So the append is one immediate transaction that re-reads the acts and | |
| 135 | asks the rules again inside itself, and the row it writes carries the turn | |
| 136 | it took, unique per table: the second writer of a turn is refused, told | |
| 137 | plainly that it is not their turn, and nothing it sent is half written. | |
| 138 | ||
| 132 | 139 | A table today is opened, sat at, left, started and played. Scoring the |
| 133 | 140 | accord that reaches quorum, the reveal and the clock that passes for a seat |
| 134 | 141 | that never played are still to come; until then a table that binds stops |
src/actions.test.ts +147 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import type { Database } from "bun:sqlite"; |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { |
| @@ -24,7 +24,8 @@ import { a1, a2, CHARTER, charges } from "./example.ts"; | ||
| 24 | 24 | import { subscribe } from "./live.ts"; |
| 25 | 25 | import type { Who } from "./who.ts"; |
| 26 | 26 | |
| 27 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); | |
| 27 | const dir = mkdtempSync(join(tmpdir(), "app-actions-")); | |
| 28 | const db = openDb(dir); | |
| 28 | 29 | const anon: Who = { role: "anon" }; |
| 29 | 30 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 30 | 31 | const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| @@ -700,6 +701,150 @@ test("a citation is the seat's choice among what §6 allows, and nothing else", | ||
| 700 | 701 | }); |
| 701 | 702 | }); |
| 702 | 703 | |
| 704 | /** | |
| 705 | * One door, in its own process, taking one turn per tick of a clock every | |
| 706 | * other door is watching. `bun:sqlite` is synchronous, so two promises are | |
| 707 | * not a race and two handles in one process are not either: only two | |
| 708 | * processes on one file can read the same ledger at the same instant. | |
| 709 | * | |
| 710 | * It reports what came back, in the action's own words, and never what it | |
| 711 | * decided itself. | |
| 712 | */ | |
| 713 | const RACER = ` | |
| 714 | import { openDb } from ${JSON.stringify(join(import.meta.dir, "db.ts"))}; | |
| 715 | import { passTurn } from ${JSON.stringify(join(import.meta.dir, "actions.ts"))}; | |
| 716 | ||
| 717 | const [dir, table, id, name, from, rounds, spacing] = process.argv.slice(2); | |
| 718 | const db = openDb(dir); | |
| 719 | const who = { role: "user", id, name }; | |
| 720 | const said = []; | |
| 721 | for (let round = 0; round < Number(rounds); round++) { | |
| 722 | const tick = Number(from) + round * Number(spacing); | |
| 723 | await Bun.sleep(Math.max(0, tick - Date.now())); | |
| 724 | try { | |
| 725 | const act = passTurn(db, who, { id: Number(table) }); | |
| 726 | said.push(act.ok ? "ok" : act.error + ": " + act.message); | |
| 727 | } catch (error) { | |
| 728 | said.push("threw: " + (error instanceof Error ? error.message : error)); | |
| 729 | } | |
| 730 | } | |
| 731 | console.log(JSON.stringify(said)); | |
| 732 | `; | |
| 733 | ||
| 734 | test( | |
| 735 | "two acts cannot take one turn: eight doors on one file, and one act a turn", | |
| 736 | async () => { | |
| 737 | const id = table10(); | |
| 738 | const seats = order(id); | |
| 739 | // One attempt per door per round, and a door's ration (`allow`, a burst | |
| 740 | // of 20 to a process) is spent on attempts whether they land or not: a | |
| 741 | // round count past that would come back `rate_limited` and read like a | |
| 742 | // race that never happened. More contention belongs in more doors. | |
| 743 | const ROUNDS = 16; | |
| 744 | const SPACING_MS = 20; | |
| 745 | const script = join(dir, "racer.ts"); | |
| 746 | writeFileSync(script, RACER); | |
| 747 | ||
| 748 | // Two processes per seat — one person at two doors at once, which is the | |
| 749 | // only way two writes can both believe they hold the same turn — all | |
| 750 | // firing on the same tick, sixteen times over. | |
| 751 | const doors = [alice, alice, bob, bob, carol, carol, dave, dave]; | |
| 752 | const from = Date.now() + 700; | |
| 753 | const outcomes = await Promise.all( | |
| 754 | doors.map(async (player) => { | |
| 755 | if (player.role === "anon") throw new Error("a racer holds a seat"); | |
| 756 | const child = Bun.spawn( | |
| 757 | [ | |
| 758 | process.execPath, | |
| 759 | "run", | |
| 760 | script, | |
| 761 | dir, | |
| 762 | String(id), | |
| 763 | player.id, | |
| 764 | player.name, | |
| 765 | String(from), | |
| 766 | String(ROUNDS), | |
| 767 | String(SPACING_MS), | |
| 768 | ], | |
| 769 | { stdout: "pipe", stderr: "pipe" }, | |
| 770 | ); | |
| 771 | const [out, err] = await Promise.all([ | |
| 772 | new Response(child.stdout).text(), | |
| 773 | new Response(child.stderr).text(), | |
| 774 | ]); | |
| 775 | await child.exited; | |
| 776 | if (child.exitCode !== 0) | |
| 777 | throw new Error(`the ${player.name} door died: ${err}`); | |
| 778 | return JSON.parse(out) as string[]; | |
| 779 | }), | |
| 780 | ); | |
| 781 | ||
| 782 | // Every attempt was answered, and the loser of a race is told the one | |
| 783 | // thing there is to tell it: the turn was not theirs. No door invents | |
| 784 | // wording, nothing throws, and nothing comes back unanswered. | |
| 785 | const said = outcomes.flat(); | |
| 786 | expect(said.length).toBe(doors.length * ROUNDS); | |
| 787 | expect(new Set(said)).toEqual( | |
| 788 | new Set(["ok", "conflict: It is not your turn."]), | |
| 789 | ); | |
| 790 | const landed = said.filter((one) => one === "ok").length; | |
| 791 | ||
| 792 | // The ledger agrees, act for act: every write that said it landed is a | |
| 793 | // row, every row took the turn it was told it held, and the turn each | |
| 794 | // took is the seat the arithmetic says had it. | |
| 795 | const rows = db | |
| 796 | .query< | |
| 797 | { ordinal: number | null; seat_id: number }, | |
| 798 | [number] | |
| 799 | >("SELECT ordinal, seat_id FROM act WHERE game_id = ?1 ORDER BY id") | |
| 800 | .all(id); | |
| 801 | expect(rows.length).toBe(landed); | |
| 802 | expect(landed).toBeGreaterThan(3); | |
| 803 | expect(rows.map((row) => row.ordinal)).toEqual(rows.map((_, turn) => turn)); | |
| 804 | expect(rows.map((row) => row.seat_id)).toEqual( | |
| 805 | rows.map((_, turn) => seats[turn % seats.length]!), | |
| 806 | ); | |
| 807 | const after = at(id, anon); | |
| 808 | expect(after.acts.length).toBe(rows.length); | |
| 809 | expect(after.turn).toBe(seats[rows.length % seats.length]!); | |
| 810 | }, | |
| 811 | 20_000, | |
| 812 | ); | |
| 813 | ||
| 814 | test("a turn already taken is a turn the schema refuses", () => { | |
| 815 | const id = table10(); | |
| 816 | expect(passTurn(db, alice, { id }).ok).toBe(true); | |
| 817 | const taken = db | |
| 818 | .query<{ seat_id: number }, [number]>( | |
| 819 | "SELECT seat_id FROM act WHERE game_id = ?1", | |
| 820 | ) | |
| 821 | .get(id)!; | |
| 822 | // The guard under the transaction: a door that one day reads, decides and | |
| 823 | // writes without one still cannot put two acts on turn 0. | |
| 824 | expect(() => | |
| 825 | db | |
| 826 | .query( | |
| 827 | "INSERT INTO act (game_id, seat_id, kind, ordinal) VALUES (?1, ?2, 'pass', 0)", | |
| 828 | ) | |
| 829 | .run(id, taken.seat_id), | |
| 830 | ).toThrow(); | |
| 831 | // It is the turn that is unique, and only for a turn: the acts a table | |
| 832 | // makes for itself take none and are not counted. | |
| 833 | expect(() => | |
| 834 | db | |
| 835 | .query("INSERT INTO act (game_id, kind, ordinal) VALUES (?1, 'deal', NULL)") | |
| 836 | .run(id), | |
| 837 | ).not.toThrow(); | |
| 838 | expect(() => | |
| 839 | db | |
| 840 | .query("INSERT INTO act (game_id, kind, ordinal) VALUES (?1, 'deal', NULL)") | |
| 841 | .run(id), | |
| 842 | ).not.toThrow(); | |
| 843 | // And nothing reads it: the turn is still the count of the acts that are | |
| 844 | // turns, which the two seatless rows are not. | |
| 845 | expect(at(id, anon).turn).toBe(order(id)[1]!); | |
| 846 | }); | |
| 847 | ||
| 703 | 848 | test("the schema keeps the promises the actions rest on", () => { |
| 704 | 849 | const table = open(alice, { seats_max: 4 }); |
| 705 | 850 | const seats = (db as Database) |
src/actions.ts +102 −29
| @@ -323,12 +323,8 @@ export function leaveTable( | ||
| 323 | 323 | if (game === undefined) return fail("not_found", "No such table."); |
| 324 | 324 | if (game.status !== "open") |
| 325 | 325 | return fail("conflict", "A seat is fixed once the table starts."); |
| 326 | const seat = db | |
| 327 | .query<SeatRow, [number, string]>( | |
| 328 | "SELECT * FROM seat WHERE game_id = ?1 AND player_id = ?2", | |
| 329 | ) | |
| 330 | .get(game.id, who.id); | |
| 331 | if (seat === null) | |
| 326 | const seat = seatOf(db, game.id, who.id); | |
| 327 | if (seat === undefined) | |
| 332 | 328 | return fail("not_found", "You do not hold a seat at this table."); |
| 333 | 329 | db.query("DELETE FROM seat WHERE id = ?1").run(seat.id); |
| 334 | 330 | const state = view(db, game, who); |
| @@ -504,22 +500,19 @@ interface Taking { | ||
| 504 | 500 | * seat of your own, a rate you could have taken by hand, and a charge to |
| 505 | 501 | * play it with. What happens next is `src/round.ts`'s to say, and `may` |
| 506 | 502 | * carries its answer and its words. |
| 503 | * | |
| 504 | * What it answers is true of the instant it read, and the instant is gone by | |
| 505 | * the time the caller has shaped an accord: it is the door's judgement, not | |
| 506 | * the ledger's. The judgement that decides is `judged`, again, inside the | |
| 507 | * transaction that writes. | |
| 507 | 508 | */ |
| 508 | 509 | function taking(db: Database, who: Who, id: unknown): Result<Taking> { |
| 509 | 510 | if (who.role === "anon") return fail("signin_required", "Sign in to play."); |
| 510 | 511 | const game = find(db, id); |
| 511 | 512 | if (game === undefined) return fail("not_found", "No such table."); |
| 512 | if (game.status !== "playing") | |
| 513 | return fail( | |
| 514 | "conflict", | |
| 515 | game.status === "open" | |
| 516 | ? "This table has not started." | |
| 517 | : game.status === "settled" | |
| 518 | ? "This table has settled." | |
| 519 | : "This table was impounded.", | |
| 520 | ); | |
| 521 | const seats = seatRows(db, game.id); | |
| 522 | const seat = seats.find((one) => one.player_id === who.id); | |
| 513 | const stopped = notPlaying(game); | |
| 514 | if (stopped !== null) return fail("conflict", stopped); | |
| 515 | const seat = seatOf(db, game.id, who.id); | |
| 523 | 516 | if (seat === undefined) |
| 524 | 517 | return fail("forbidden", "You hold no seat at this table."); |
| 525 | 518 | // Rationed per seat per table, here rather than at one door: the terminal |
| @@ -527,11 +520,23 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> { | ||
| 527 | 520 | // agent on its turn should not be able to spend it faster than it thought. |
| 528 | 521 | if (!allow(`act:${game.id}:${who.id}`)) |
| 529 | 522 | return fail("rate_limited", "That was too fast. Take your turn again in a moment."); |
| 523 | return judged(db, game, seat); | |
| 524 | } | |
| 525 | ||
| 526 | /** | |
| 527 | * What the rules say about this seat, from the ledger as it stands at this | |
| 528 | * instant: the acts re-read, the seat's own charge, and §5 and §6's answer. | |
| 529 | * | |
| 530 | * It reads and decides and writes nothing, so it is safe to ask twice — and | |
| 531 | * it is asked twice: once at the door, and once more inside the write. | |
| 532 | */ | |
| 533 | function judged(db: Database, game: GameRow, seat: SeatRow): Result<Taking> { | |
| 530 | 534 | const held = parse(seat.charge_json) as Charge | null; |
| 531 | 535 | if (held === null || !Array.isArray(held.aims)) |
| 532 | 536 | return fail("conflict", "This table has not been dealt."); |
| 533 | 537 | // The seat's own charge, under its own ordinal: §6 reads nobody else's. |
| 534 | 538 | const charge: Charge = { seat: seat.ordinal, aims: held.aims }; |
| 539 | const seats = seatRows(db, game.id); | |
| 535 | 540 | const round = roundOf(game, seats, actRows(db, game.id)); |
| 536 | 541 | return ok({ |
| 537 | 542 | game, |
| @@ -541,7 +546,40 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> { | ||
| 541 | 546 | }); |
| 542 | 547 | } |
| 543 | 548 | |
| 544 | /** One act, one row, and the two things every open page is told about it. */ | |
| 549 | /** Why this table cannot be played right now, in its own words, or null. */ | |
| 550 | const notPlaying = (game: GameRow): string | null => | |
| 551 | game.status === "playing" | |
| 552 | ? null | |
| 553 | : game.status === "open" | |
| 554 | ? "This table has not started." | |
| 555 | : game.status === "settled" | |
| 556 | ? "This table has settled." | |
| 557 | : "This table was impounded."; | |
| 558 | ||
| 559 | /** | |
| 560 | * One act, one row — appended in the one transaction that may. | |
| 561 | * | |
| 562 | * The turn is the count of acts (`src/round.ts`), so a decision made outside | |
| 563 | * a write lock is a decision about a ledger that can grow under it: two | |
| 564 | * doors of one seat, reading the same count, would both believe they hold | |
| 565 | * the turn. So read, decide and write are one statement here. The | |
| 566 | * transaction is *immediate* — the write lock is taken before the first | |
| 567 | * read, not upgraded after it — the acts are re-read inside it, and §5 and | |
| 568 | * §6 are asked again about the ledger as it actually is. The loser of the | |
| 569 | * race finds the turn moved on and gets `round.ts`'s own sentence for it; | |
| 570 | * nothing it sent is silently dropped, and nothing it sent is written. | |
| 571 | * | |
| 572 | * The row carries the turn it took (`act.ordinal`), which the schema holds | |
| 573 | * unique per table: the same refusal again, in SQLite's hand, for a door | |
| 574 | * that one day forgets this one. Nothing reads it back. | |
| 575 | * | |
| 576 | * Nothing that is not the write happens in here — the state a door answers | |
| 577 | * with is read after the commit, and `emit` is never called with a | |
| 578 | * transaction open. A write that follows from an act (the settlement an | |
| 579 | * acceptance reaches the quorum with, when there is one) belongs in here | |
| 580 | * too, after this row: one act lands, so it happens once and is announced | |
| 581 | * once. | |
| 582 | */ | |
| 545 | 583 | function written( |
| 546 | 584 | db: Database, |
| 547 | 585 | game: GameRow, |
| @@ -551,17 +589,40 @@ function written( | ||
| 551 | 589 | accord: Accord | null, |
| 552 | 590 | cite: Cite | null, |
| 553 | 591 | ): Result<TableView> { |
| 554 | db.query<ActRow, [number, number, string, string | null, string | null]>( | |
| 555 | `INSERT INTO act (game_id, seat_id, kind, accord_json, cite_json) | |
| 556 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 557 | ).run( | |
| 558 | game.id, | |
| 559 | seat.id, | |
| 560 | kind, | |
| 561 | accord === null ? null : JSON.stringify(accord), | |
| 562 | cite === null ? null : JSON.stringify(cite), | |
| 563 | ); | |
| 564 | const state = view(db, game, who); | |
| 592 | const append = db.transaction((): Result<GameRow> => { | |
| 593 | const fresh = find(db, game.id); | |
| 594 | if (fresh === undefined) return fail("not_found", "No such table."); | |
| 595 | const stopped = notPlaying(fresh); | |
| 596 | if (stopped !== null) return fail("conflict", stopped); | |
| 597 | const still = seatOf(db, fresh.id, who.id); | |
| 598 | if (still === undefined) | |
| 599 | return fail("forbidden", "You hold no seat at this table."); | |
| 600 | const again = judged(db, fresh, still); | |
| 601 | if (!again.ok) return again; | |
| 602 | const { may, round } = again.value; | |
| 603 | if (!may[kind].may) return fail("conflict", may[kind].refusal!); | |
| 604 | db.query< | |
| 605 | ActRow, | |
| 606 | [number, number, string, string | null, string | null, number] | |
| 607 | >( | |
| 608 | `INSERT INTO act (game_id, seat_id, kind, accord_json, cite_json, ordinal) | |
| 609 | VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, | |
| 610 | ).run( | |
| 611 | fresh.id, | |
| 612 | still.id, | |
| 613 | kind, | |
| 614 | accord === null ? null : JSON.stringify(accord), | |
| 615 | cite === null ? null : JSON.stringify(cite), | |
| 616 | round.acts.length, | |
| 617 | ); | |
| 618 | return ok(fresh); | |
| 619 | }); | |
| 620 | const appended = append.immediate(); | |
| 621 | if (!appended.ok) return appended; | |
| 622 | // The table as it was when the row went in, read back after the commit: | |
| 623 | // a door answers with the state its own write made, not with the row it | |
| 624 | // was holding before it. | |
| 625 | const state = view(db, appended.value, who); | |
| 565 | 626 | // Both events name the table and the seat and say nothing else: what the |
| 566 | 627 | // act was, and whose turn it is now, are the viewer's to refetch — and the |
| 567 | 628 | // turn is nobody's once an accord binds, which is not an address at all. |
| @@ -748,6 +809,18 @@ const actRows = (db: Database, gameId: number): ActRow[] => | ||
| 748 | 809 | .query<ActRow, [number]>("SELECT * FROM act WHERE game_id = ?1 ORDER BY id") |
| 749 | 810 | .all(gameId); |
| 750 | 811 | |
| 812 | /** The seat one person holds at one table, or nothing. */ | |
| 813 | const seatOf = ( | |
| 814 | db: Database, | |
| 815 | gameId: number, | |
| 816 | playerId: string, | |
| 817 | ): SeatRow | undefined => | |
| 818 | db | |
| 819 | .query<SeatRow, [number, string]>( | |
| 820 | "SELECT * FROM seat WHERE game_id = ?1 AND player_id = ?2", | |
| 821 | ) | |
| 822 | .get(gameId, playerId) ?? undefined; | |
| 823 | ||
| 751 | 824 | const seatRows = (db: Database, gameId: number): SeatRow[] => |
| 752 | 825 | db |
| 753 | 826 | .query<SeatRow, [number]>( |
src/db.test.ts +7 −1
| @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { check, migrate, migrations, openDb } from "./db.ts"; | |
| 5 | import { BUSY_TIMEOUT_MS, check, migrate, migrations, openDb } from "./db.ts"; | |
| 6 | 6 | |
| 7 | 7 | test("migrations apply once, in order, and the file checks out", () => { |
| 8 | 8 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); |
| @@ -20,4 +20,10 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 20 | 20 | db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get() |
| 21 | 21 | ?.foreign_keys, |
| 22 | 22 | ).toBe(1); |
| 23 | // A second writer waits for the writer ahead of it rather than failing: | |
| 24 | // the terminal is another process on this file, and a ledger write takes | |
| 25 | // the lock before it reads. | |
| 26 | expect( | |
| 27 | db.query<{ timeout: number }, []>("PRAGMA busy_timeout").get()?.timeout, | |
| 28 | ).toBe(BUSY_TIMEOUT_MS); | |
| 23 | 29 | }); |
src/db.ts +28 −3
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The database: one SQLite file under FORGE_APP_DATA, so production and |
| 3 | 3 | * every preview each have their own. WAL so readers never wait on the |
| 4 | * writer (the terminal is a second process on the same file), foreign keys | |
| 5 | * on, and a forward-only list of migrations applied once, in a | |
| 4 | * writer (the terminal is a second process on the same file), a busy | |
| 5 | * timeout so a writer waits for the writer ahead of it instead of failing, | |
| 6 | * foreign keys on, and a forward-only list of migrations applied once, in a | |
| 6 | 7 | * transaction, when the file is opened. |
| 7 | 8 | * |
| 8 | 9 | * bun run src/db.ts --check the schema version and an integrity check |
| @@ -91,14 +92,38 @@ export const migrations: string[] = [ | ||
| 91 | 92 | // verified against the accepting seat's charge before the row was written, |
| 92 | 93 | // and public forever after. Null on an offer and on a pass. |
| 93 | 94 | `ALTER TABLE act ADD COLUMN cite_json TEXT`, |
| 95 | ||
| 96 | // The turn this act took: the count of turns before it, which is exactly | |
| 97 | // the number `src/round.ts` divides by the seat count to say whose turn it | |
| 98 | // is. Two acts cannot take one turn, and now that is a rule SQLite keeps | |
| 99 | // rather than a rule the code remembers — the second writer of a turn gets | |
| 100 | // an error even if some future door forgets the transaction. It is a guard | |
| 101 | // and not a second source of truth: nothing reads it to decide anything, | |
| 102 | // and the turn is still derived from the acts. Null on an act the table | |
| 103 | // made for itself (a deal, an impound), which is not a turn. | |
| 104 | `ALTER TABLE act ADD COLUMN ordinal INTEGER`, | |
| 105 | `UPDATE act SET ordinal = ( | |
| 106 | SELECT count(*) FROM act AS before | |
| 107 | WHERE before.game_id = act.game_id AND before.id < act.id | |
| 108 | AND before.seat_id IS NOT NULL AND before.kind IN ('offer', 'accept', 'pass') | |
| 109 | ) WHERE seat_id IS NOT NULL AND kind IN ('offer', 'accept', 'pass')`, | |
| 110 | `CREATE UNIQUE INDEX act_turn ON act (game_id, ordinal) WHERE ordinal IS NOT NULL`, | |
| 94 | 111 | ]; |
| 95 | 112 | |
| 113 | /** | |
| 114 | * How long a connection waits for the writer ahead of it before it gives up | |
| 115 | * (`SQLITE_BUSY`). A ledger write is one short immediate transaction, so a | |
| 116 | * wait here is microseconds of queueing; five seconds is the outside edge of | |
| 117 | * a stalled machine, not a budget anything spends. | |
| 118 | */ | |
| 119 | export const BUSY_TIMEOUT_MS = 5_000; | |
| 120 | ||
| 96 | 121 | export function openDb(dir = dataDir): Database { |
| 97 | 122 | mkdirSync(dir, { recursive: true }); |
| 98 | 123 | const db = new Database(join(dir, "app.db")); |
| 99 | 124 | db.exec("PRAGMA journal_mode = WAL"); |
| 100 | 125 | db.exec("PRAGMA foreign_keys = ON"); |
| 101 | db.exec("PRAGMA busy_timeout = 5000"); | |
| 126 | db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); | |
| 102 | 127 | migrate(db); |
| 103 | 128 | return db; |
| 104 | 129 | } |
new above
offline
Approved. I read every changed line, ran the suite at the head, tried the preview and checked the upgrade path against a database built at the previous version.
What I verified rather than took on trust
append.immediate()downgraded to a plain deferred transaction the race test fails; with the transaction removed andordinalwritten as NULL it fails on the ledger itself (two acts on one turn), and "a turn already taken is a turn the schema refuses" fails with it. So the test would catch a regression even if the schema guard were gone, which is what makes it worth its second of the suite.main's code, played a table to three turns with a seatlessdealrow in the ledger, then opened it with this branch. The backfill gives 0,1,2 to the turns and NULL to thedeal, the derived turn is unchanged, and the next act lands on ordinal 3. A game mid-ledger survives the upgrade. The backfill also cannot collide on a database that already holds the bug, since it counts rows before each act.bun testis green at the head (123 pass, 2.1s). The branch is behindmain(based on 948abc0, before #73), so I built the merge withmainas well: it merges clean and is green there too (133 pass).varynaming both forge headers). Writes are gated at every door I could reach as a stranger:POST /api/tablesandPOST /api/tables/1/passaresignin_required, anx-forge-identityheader I wrote myself buys nothing, andtools/call open_tableover/mcprefuses the same way in the same words. Nothing in the screenshots moved, which is right — this pull changes no pixel.round.tsis untouched and nothing readsordinalback, soordinalstays a guard and not a second source of truth, as #46 asked.One thing left, filed rather than blocking: #85.
acceptOfferstill picks the aim it will swear from the outermay.citeandwritten()re-checks onlymay.accept.maybefore inserting that citation verbatim.may.citedepends on the accord that stands and on what the seat has already sworn, so if a full lap of turns lands in the gap between the two reads, the re-check passes and the row swears an aim chosen against a different offer — a §6 oath the rules would not have allowed. It needs four to six acts inside a sub-millisecond window, so it is an edge and not a live bug, but it is the one place where read, decide and write are still three statements, and it sits in the function this pull just made the right home for it. #85 also notes the same shape injoinTable(two joins racing the last place can seat five at a four-seat table) andstartTable(a host at two doors can deal twice) — neither is the ledger, so #46 rightly did not cover them.Two small notes for whoever next touches these files, not worth a round:
BUSY_TIMEOUT_MSis exported but has only one reader, insrc/db.tsitself; and the race test's 16 rounds sit under the rate limiter's burst of 20 per process, so raisingROUNDSpast 20 would fail the test asrate_limitedrather than as a race. The unticked boxes on #46 — settlement once, the lapsed pass — depend on code that does not exist yet (#10, #11/#77), andwritten()'s comment puts them in the right place for when it does; that is the honest call.