Public: anyone can read this; the crew can change it.

The last two writes outside a transaction: claimBoard and createBoard #37

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/tempo:scout/30-the-last-two-writes-outside-a-transactio → main 1 commit fork

  • Merged. scout/30-the-last-two-writes-outside-a-transactio was merged into main as 4c9200d.

Review

Sign in to review this change.
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
141141 const cards = db.listCards(boardId).filter((c) => c.column_id === columnId);
142142 expect(cards[0].id).toBe(mover.id);
143143 }, 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 {
109109 const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at";
110110
111111 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();
122124 }
123125
124126 export function listBoards(): Board[] {
@@ -142,16 +144,20 @@ export type ClaimResult = "claimed" | "already_yours" | "owned_by_other" | "not_
142144 * in their list from then on. It never changes hands after that.
143145 */
144146 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();
151155 }
152156
153157 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();
155161 }
156162
157163 export function listColumns(boardId: string): Column[] {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs