Public: anyone can read this; the crew can change it.
The last two writes outside a transaction: claimBoard and createBoard #37
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/30-the-last-two-writes-outside-a-transactio → main 1 commit fork
2 files
changed +73
−17
src/db.test.ts +50
| @@ -141,3 +141,53 @@ test("a write from another process holding the write lock does not fail moveCard | ||
| 141 | 141 | const cards = db.listCards(boardId).filter((c) => c.column_id === columnId); |
| 142 | 142 | expect(cards[0].id).toBe(mover.id); |
| 143 | 143 | }, 15000); |
| 144 | ||
| 145 | // Same shape as the moveCard case above, but for claimBoard: its | |
| 146 | // read-then-write (check owner_id is null, then set it) must hold the | |
| 147 | // write lock across both steps, or two claimers racing each other could | |
| 148 | // both see null and both return "claimed". | |
| 149 | test("a write from another process holding the write lock does not fail claimBoard, it waits", async () => { | |
| 150 | const board = db.createBoard("Unclaimed"); | |
| 151 | ||
| 152 | const dbPath = db.db.filename; | |
| 153 | const markerPath = join(dataDir, "claim.marker"); | |
| 154 | rmSync(markerPath, { force: true }); | |
| 155 | ||
| 156 | const script = ` | |
| 157 | import { Database } from "bun:sqlite"; | |
| 158 | import { writeFileSync } from "node:fs"; | |
| 159 | const other = new Database(process.env.DB_PATH); | |
| 160 | other.exec("PRAGMA journal_mode = WAL"); | |
| 161 | other.exec("PRAGMA busy_timeout = 5000"); | |
| 162 | other.exec("BEGIN IMMEDIATE"); | |
| 163 | other.query("UPDATE board SET name = ?2 WHERE id = ?1").run(process.env.BOARD_ID, "touched-by-other-process"); | |
| 164 | writeFileSync(process.env.MARKER_PATH, "locked"); | |
| 165 | Bun.sleepSync(300); | |
| 166 | other.exec("COMMIT"); | |
| 167 | `; | |
| 168 | const child = Bun.spawn({ | |
| 169 | cmd: [process.execPath, "-e", script], | |
| 170 | env: { ...process.env, DB_PATH: dbPath, BOARD_ID: board.id, MARKER_PATH: markerPath }, | |
| 171 | stderr: "pipe", | |
| 172 | }); | |
| 173 | ||
| 174 | const deadline = Date.now() + 8000; | |
| 175 | while (!existsSync(markerPath) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); | |
| 176 | if (!existsSync(markerPath)) console.error(await new Response(child.stderr).text()); | |
| 177 | expect(existsSync(markerPath)).toBe(true); | |
| 178 | ||
| 179 | const result = db.claimBoard(board.id, { id: "owner-1", name: "Owner" }); | |
| 180 | ||
| 181 | const exitCode = await child.exited; | |
| 182 | expect(exitCode).toBe(0); | |
| 183 | expect(result).toBe("claimed"); | |
| 184 | expect(db.getBoard(board.id)!.owner_id).toBe("owner-1"); | |
| 185 | expect(db.getBoard(board.id)!.name).toBe("touched-by-other-process"); | |
| 186 | }, 15000); | |
| 187 | ||
| 188 | test("every board returned by createBoard has exactly its three default columns", () => { | |
| 189 | for (let i = 0; i < 5; i++) { | |
| 190 | const board = db.createBoard(`Board ${i}`); | |
| 191 | expect(db.listColumns(board.id).map((c) => c.name)).toEqual(["To do", "Doing", "Done"]); | |
| 192 | } | |
| 193 | }); |
src/db.ts +23 −17
| @@ -109,16 +109,18 @@ function logAction(boardId: string, payload: Payload): void { | ||
| 109 | 109 | const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at"; |
| 110 | 110 | |
| 111 | 111 | export function createBoard(name: string, owner?: { id: string; name: string }): Board { |
| 112 | const id = randomId(8); | |
| 113 | db.query(`INSERT INTO board (id, name, owner_id, owner_name) VALUES (?1, ?2, ?3, ?4)`).run( | |
| 114 | id, | |
| 115 | name, | |
| 116 | owner?.id ?? null, | |
| 117 | owner?.name ?? null, | |
| 118 | ); | |
| 119 | const defaults = ["To do", "Doing", "Done"]; | |
| 120 | defaults.forEach((columnName, i) => createColumn(id, columnName, i)); | |
| 121 | return getBoard(id)!; | |
| 112 | return db.transaction((): Board => { | |
| 113 | const id = randomId(8); | |
| 114 | db.query(`INSERT INTO board (id, name, owner_id, owner_name) VALUES (?1, ?2, ?3, ?4)`).run( | |
| 115 | id, | |
| 116 | name, | |
| 117 | owner?.id ?? null, | |
| 118 | owner?.name ?? null, | |
| 119 | ); | |
| 120 | const defaults = ["To do", "Doing", "Done"]; | |
| 121 | defaults.forEach((columnName, i) => createColumn(id, columnName, i)); | |
| 122 | return getBoard(id)!; | |
| 123 | }).immediate(); | |
| 122 | 124 | } |
| 123 | 125 | |
| 124 | 126 | export function listBoards(): Board[] { |
| @@ -142,16 +144,20 @@ export type ClaimResult = "claimed" | "already_yours" | "owned_by_other" | "not_ | ||
| 142 | 144 | * in their list from then on. It never changes hands after that. |
| 143 | 145 | */ |
| 144 | 146 | export function claimBoard(boardId: string, owner: { id: string; name: string }): ClaimResult { |
| 145 | const board = getBoard(boardId); | |
| 146 | if (!board) return "not_found"; | |
| 147 | if (board.owner_id === owner.id) return "already_yours"; | |
| 148 | if (board.owner_id !== null) return "owned_by_other"; | |
| 149 | db.query("UPDATE board SET owner_id = ?2, owner_name = ?3 WHERE id = ?1").run(boardId, owner.id, owner.name); | |
| 150 | return "claimed"; | |
| 147 | return db.transaction((): ClaimResult => { | |
| 148 | const board = getBoard(boardId); | |
| 149 | if (!board) return "not_found"; | |
| 150 | if (board.owner_id === owner.id) return "already_yours"; | |
| 151 | if (board.owner_id !== null) return "owned_by_other"; | |
| 152 | db.query("UPDATE board SET owner_id = ?2, owner_name = ?3 WHERE id = ?1").run(boardId, owner.id, owner.name); | |
| 153 | return "claimed"; | |
| 154 | }).immediate(); | |
| 151 | 155 | } |
| 152 | 156 | |
| 153 | 157 | export function renameBoard(boardId: string, name: string): void { |
| 154 | db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(boardId, name); | |
| 158 | db.transaction((): void => { | |
| 159 | db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(boardId, name); | |
| 160 | }).immediate(); | |
| 155 | 161 | } |
| 156 | 162 | |
| 157 | 163 | export function listColumns(boardId: string): Column[] { |
new above
offline