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

Every change writes down what it was: a label in the action row, and an undo that returns it #124

Merged opened this

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

Merged

scout/tempo:scout/118-every-change-writes-down-what-it-was-a-l → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/118-every-change-writes-down-what-it-was-a-l was merged into main as d6cc1c7.

Review

Sign in to review this change.
5 files changed +124 −28
src/db.test.ts +76 −6
@@ -71,7 +71,7 @@ test("undo of a renormalising move restores every renormalised card, not just th
7171 const afterMove = db.listCards(boardId).filter((c) => c.column_id === columnId);
7272 expect(afterMove.length).toBe(before.length + 1);
7373
74 expect(db.undo(boardId)).toBe(true);
74 expect(db.undo(boardId)).toBe('moved "mover"');
7575 const afterUndo = db.listCards(boardId).filter((c) => c.column_id === columnId && c.id !== mover.id);
7676 expect(afterUndo.map((c) => [c.id, c.position])).toEqual(before.map((c) => [c.id, c.position]));
7777 expect(db.getCard(boardId, mover.id)!.position).toBe(moverBefore.position);
@@ -91,8 +91,8 @@ test("ties are ordered consistently by position then id", () => {
9191 expect(second.id).toBe(expectedSecond);
9292 });
9393
94 test("undo returns false with nothing to undo", () => {
95 expect(db.undo("no-such-board")).toBe(false);
94 test("undo returns null with nothing to undo", () => {
95 expect(db.undo("no-such-board")).toBeNull();
9696 });
9797
9898 test("listBoardsByOwner is a person's own boards only, never unowned ones or another person's", () => {
@@ -290,11 +290,11 @@ test("undoing a rename restores the old name and leaves a card moved before it u
290290 db.renameBoard(boardId, "New name");
291291 expect(db.getBoard(boardId)!.name).toBe("New name");
292292
293 expect(db.undo(boardId)).toBe(true);
293 expect(db.undo(boardId)).toBe("renamed the board");
294294 expect(db.getBoard(boardId)!.name).toBe("Board");
295295 expect(db.getCard(boardId, card.id)!.column_id).toBe(columns[1].id);
296296
297 expect(db.undo(boardId)).toBe(true);
297 expect(db.undo(boardId)).toBe('moved "Card"');
298298 expect(db.getCard(boardId, card.id)!.column_id).toBe(columnId);
299299 });
300300
@@ -307,6 +307,76 @@ test("an action row logged before restoreBoardName existed still undoes cleanly"
307307 const card = assertCard(db.createCard(boardId, columnId, "Card"));
308308 db.db.query("INSERT INTO action (board_id, payload) VALUES (?1, ?2)").run(boardId, JSON.stringify({ deleteCardIds: [card.id] }));
309309
310 expect(db.undo(boardId)).toBe(true);
310 expect(db.undo(boardId)).toBe("a change");
311311 expect(db.getCard(boardId, card.id)).toBeNull();
312312 });
313
314 test("a legacy action row with no label undoes cleanly and reports a change", () => {
315 const { boardId, columnId } = setup();
316 const card = assertCard(db.createCard(boardId, columnId, "Card"));
317 db.db.query("DELETE FROM action WHERE board_id = ?1").run(boardId);
318 db.db.query("INSERT INTO action (board_id, payload) VALUES (?1, ?2)").run(boardId, JSON.stringify({ deleteCardIds: [card.id] }));
319
320 expect(db.peekUndo(boardId)).toBe("a change");
321 expect(db.undo(boardId)).toBe("a change");
322 expect(db.getCard(boardId, card.id)).toBeNull();
323 });
324
325 test("a label round-trips through the action row for each kind of mutation", () => {
326 const { boardId, columnId } = setup();
327
328 const card = assertCard(db.createCard(boardId, columnId, "Ship it"));
329 expect(db.peekUndo(boardId)).toBe("added a card");
330 expect(db.undo(boardId)).toBe("added a card");
331
332 const card2 = assertCard(db.createCard(boardId, columnId, "Ship it"));
333 db.editCard(boardId, card2.id, "Ship it now");
334 expect(db.undo(boardId)).toBe('edited "Ship it"');
335
336 db.moveCard(boardId, card2.id, columnId, 0);
337 expect(db.undo(boardId)).toBe('moved "Ship it"');
338
339 db.deleteCard(boardId, card2.id);
340 expect(db.undo(boardId)).toBe('deleted "Ship it"');
341
342 const column = db.createColumn(boardId, "Doing 2");
343 if (typeof column === "string") throw new Error("expected a Column");
344 expect(db.undo(boardId)).toBe("added a column");
345
346 const column2 = db.createColumn(boardId, "Doing 2");
347 if (typeof column2 === "string") throw new Error("expected a Column");
348 db.renameColumn(boardId, column2.id, "Renamed");
349 expect(db.undo(boardId)).toBe('renamed the column "Doing 2"');
350
351 db.moveColumn(boardId, column2.id, "left");
352 expect(db.undo(boardId)).toBe('moved the column "Doing 2"');
353
354 db.deleteColumn(boardId, column2.id);
355 expect(db.undo(boardId)).toBe('deleted the column "Doing 2"');
356
357 db.renameBoard(boardId, "Renamed board");
358 expect(db.undo(boardId)).toBe("renamed the board");
359 });
360
361 test("a title longer than 40 characters is elided once in the label itself", () => {
362 const { boardId, columnId } = setup();
363 const longTitle = "x".repeat(60);
364 const card = assertCard(db.createCard(boardId, columnId, longTitle));
365 db.deleteCard(boardId, card.id);
366 const label = db.peekUndo(boardId)!;
367 expect(label).toBe(`deleted "${"x".repeat(39)}…"`);
368 });
369
370 test("peekUndo names the last change without consuming it", () => {
371 expect(db.peekUndo("no-such-board")).toBeNull();
372
373 const { boardId, columnId } = setup();
374 expect(db.peekUndo(boardId)).toBe("added a column"); // the board's own three default columns
375
376 const card = assertCard(db.createCard(boardId, columnId, "Card"));
377 expect(db.peekUndo(boardId)).toBe("added a card");
378 expect(db.peekUndo(boardId)).toBe("added a card"); // reading twice does not consume it
379
380 expect(db.undo(boardId)).toBe("added a card");
381 expect(db.peekUndo(boardId)).toBe("added a column");
382 });
src/db.ts +42 −16
@@ -91,6 +91,14 @@ function randomId(length: number): string {
9191 }
9292
9393 type Payload = {
94 // A short human sentence for what this row's write did — "moved
95 // \"Ship it\"", "deleted the column \"Doing\"" — built once, here, from
96 // the data the write already holds. A card or column title inside it is
97 // someone else's text: nothing in this file renders it, so every caller
98 // (the page's `escape()`, the terminal's control-character strip) must
99 // escape it before drawing. Optional only because a row written before
100 // this field existed has none; undo() falls back to "a change" for those.
101 label?: string;
94102 restoreColumns?: Column[];
95103 restoreCards?: Card[];
96104 deleteColumnIds?: string[];
@@ -100,12 +108,20 @@ type Payload = {
100108 restoreBoardName?: { id: string; name: string };
101109 };
102110
111 // A title dropped into a label is elided once, here, rather than by every
112 // renderer — a label is about to sit in an aria-label and a terminal line,
113 // neither of which wants to wrap a long card title.
114 const LABEL_TITLE_MAX = 40;
115 function elide(title: string): string {
116 return title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TITLE_MAX - 1)}…` : title;
117 }
118
103119 // Runs inside the same transaction as the write that logged the action, so
104120 // undo never sees a row this trim already removed. Keeping only the
105121 // newest MAX_UNDO_DEPTH means undoing past that horizon takes the
106122 // existing "nothing to undo" path (undo() just finds no row).
107 function logAction(boardId: string, payload: Payload): void {
108 db.query("INSERT INTO action (board_id, payload) VALUES (?1, ?2)").run(boardId, JSON.stringify(payload));
123 function logAction(boardId: string, label: string, payload: Omit<Payload, "label">): void {
124 db.query("INSERT INTO action (board_id, payload) VALUES (?1, ?2)").run(boardId, JSON.stringify({ label, ...payload }));
109125 db.query(
110126 `DELETE FROM action WHERE board_id = ?1 AND id NOT IN (
111127 SELECT id FROM action WHERE board_id = ?1 ORDER BY id DESC LIMIT ?2
@@ -196,7 +212,7 @@ export function renameBoard(boardId: string, name: string): boolean {
196212 const before = getBoard(boardId);
197213 if (!before) return false;
198214 db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(boardId, name);
199 logAction(boardId, { restoreBoardName: { id: boardId, name: before.name } });
215 logAction(boardId, "renamed the board", { restoreBoardName: { id: boardId, name: before.name } });
200216 bumpRevision(boardId);
201217 bumpRevision(BOARDS_SCOPE);
202218 return true;
@@ -244,7 +260,7 @@ export function createColumn(boardId: string, name: string, explicitPosition?: n
244260 name,
245261 position,
246262 );
247 logAction(boardId, { deleteColumnIds: [id] });
263 logAction(boardId, "added a column", { deleteColumnIds: [id] });
248264 bumpRevision(boardId);
249265 return { id, board_id: boardId, name, position };
250266 }).immediate();
@@ -261,7 +277,7 @@ export function renameColumn(boardId: string, columnId: string, name: string): b
261277 const before = getColumn(boardId, columnId);
262278 if (!before) return false;
263279 db.query("UPDATE board_column SET name = ?2 WHERE id = ?1").run(columnId, name);
264 logAction(boardId, { restoreColumns: [before] });
280 logAction(boardId, `renamed the column "${elide(before.name)}"`, { restoreColumns: [before] });
265281 bumpRevision(boardId);
266282 return true;
267283 }).immediate();
@@ -277,7 +293,7 @@ export function moveColumn(boardId: string, columnId: string, direction: "left"
277293 const before = [columns[i], columns[j]];
278294 db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[i].id, columns[j].position);
279295 db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[j].id, columns[i].position);
280 logAction(boardId, { restoreColumns: before });
296 logAction(boardId, `moved the column "${elide(columns[i].name)}"`, { restoreColumns: before });
281297 bumpRevision(boardId);
282298 return true;
283299 }).immediate();
@@ -289,7 +305,7 @@ export function deleteColumn(boardId: string, columnId: string): boolean {
289305 if (!before) return false;
290306 const cards = listCards(boardId).filter((c) => c.column_id === columnId);
291307 db.query("DELETE FROM board_column WHERE id = ?1").run(columnId);
292 logAction(boardId, { restoreColumns: [before], restoreCards: cards });
308 logAction(boardId, `deleted the column "${elide(before.name)}"`, { restoreColumns: [before], restoreCards: cards });
293309 bumpRevision(boardId);
294310 return true;
295311 }).immediate();
@@ -309,7 +325,7 @@ export function createCard(boardId: string, columnId: string, title: string): Ca
309325 title,
310326 position,
311327 );
312 logAction(boardId, { deleteCardIds: [id] });
328 logAction(boardId, "added a card", { deleteCardIds: [id] });
313329 bumpRevision(boardId);
314330 return getCard(boardId, id)!;
315331 }).immediate();
@@ -329,7 +345,7 @@ export function editCard(boardId: string, cardId: string, title: string): boolea
329345 const before = getCard(boardId, cardId);
330346 if (!before) return false;
331347 db.query("UPDATE card SET title = ?2, updated_at = datetime('now') WHERE id = ?1").run(cardId, title);
332 logAction(boardId, { restoreCards: [before] });
348 logAction(boardId, `edited "${elide(before.title)}"`, { restoreCards: [before] });
333349 bumpRevision(boardId);
334350 return true;
335351 }).immediate();
@@ -359,7 +375,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde
359375 columnId,
360376 position,
361377 );
362 logAction(boardId, { restoreCards: [before] });
378 logAction(boardId, `moved "${elide(before.title)}"`, { restoreCards: [before] });
363379 bumpRevision(boardId);
364380 return true;
365381 }
@@ -377,7 +393,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde
377393 columnId,
378394 at + 1,
379395 );
380 logAction(boardId, { restoreCards });
396 logAction(boardId, `moved "${elide(before.title)}"`, { restoreCards });
381397 bumpRevision(boardId);
382398 return true;
383399 }).immediate();
@@ -388,18 +404,28 @@ export function deleteCard(boardId: string, cardId: string): boolean {
388404 const before = getCard(boardId, cardId);
389405 if (!before) return false;
390406 db.query("DELETE FROM card WHERE id = ?1").run(cardId);
391 logAction(boardId, { restoreCards: [before] });
407 logAction(boardId, `deleted "${elide(before.title)}"`, { restoreCards: [before] });
392408 bumpRevision(boardId);
393409 return true;
394410 }).immediate();
395411 }
396412
397 export function undo(boardId: string): boolean {
398 return db.transaction((): boolean => {
413 /** The label of what Undo would reverse — one indexed read, nothing changed. */
414 export function peekUndo(boardId: string): string | null {

Approved — anchoring the verdict here, on peekUndo, because this whole pull is one function's worth of new surface.

I read every changed line against #118 and the direction, ran the suite at this head, and used the preview as a person would.

What I checked

  • Scope: exactly the database half #118 asked for — five files, no page markup, no terminal drawing, no new dependency. undo() went boolean → string | null, peekUndo() is the single indexed SELECT ... ORDER BY id DESC LIMIT 1 the issue specified, and the label rides in the existing payload JSON, so there is no schema change and no migration. Nothing reaches past that.
  • Security: no new surface. Labels hold card and column titles, but the payload already stored those titles raw in restoreCards/restoreColumns, and nothing in this change renders a label — the comment on Payload.label names the escaping contract for the callers that will (escape() on the page, the control-character strip in the terminal), which is the right place for it. Titles reaching a label have already been through cleanText, so no control byte can ride in from either door. Every query stays parameterised, and logAction's JSON.stringify({ label, ...payload }) cannot be shadowed because the parameter is typed Omit<Payload, "label">. On the preview the floor is intact on / — CSP, nosniff, referrer-policy, vary: x-forge-identity, HSTS. The undo route still spends a writeBucket token keyed on callerKey(who, ip) — the verified forge id, or the socket for a guest, never a header — before it touches the database, and its 404 / {ok:false} path is untouched.
  • Both doors: src/keys.ts's u key needed no change, because a label is never the empty string; the BoardStore type was updated alongside it. routes.ts converts with an explicit !== null at its one call site. Page and terminal behave exactly as before, which is right for a change that adds no capability yet — the README's undo section is still accurate, so nothing to add there either.
  • Tests: bun test is green at 8e54245 — 201 pass, 0 fail, 1336 assertions, run here, not taken on trust. The new tests fail on main by construction (they assert label strings where undo() returned true), and they cover all four things the issue listed: a label round-trip per mutation kind, peekUndo naming without consuming, a legacy row with no label undoing to "a change", and elision at 40. The edits to db.test.ts/limits.test.ts tighten rather than weaken — toBe(true) became the exact label.
  • Preview: created a board, added a card, undid it, then undid past that — the card came back, and so did a default column. POST /b/:id/undo still answers {"ok":true} to a JSON caller, and a card titled Ship it <script>alert(1)</script> came back escaped in both the card and its edit input. / answers in about 130 ms over the network. The two pictures match main, which is what a database-only change should look like.

Nits for whoever picks up #43 — none blocking, none worth another round here

  1. renameColumn logs "renamed a column" and editCard logs "edited a card", while moveColumn and deleteColumn name the thing they touched. For a rename the old name is the useful one (renamed the column "Doing") and it is already in hand as before.name; same for editCard with before.title. The row that names itself least is the one a person is most likely to be undoing by mistake.
  2. src/keys.ts tests the result for truthiness while src/routes.ts tests !== null. Both are correct today only because no label is empty; the two call sites now disagree about what "nothing to undo" means. Settle on !== null in both when #43 touches the undo path.
  3. peekUndo has no production caller until #43 renders it. That is what the issue asked for and it is tested, so it is landing ahead of its consumer rather than dead code — but it should acquire that caller in the next pull rather than sit.
  4. elide slices by UTF-16 code unit, so a title of 60 emoji can be cut mid-surrogate. cleanText in src/text.ts already slices the same way, so this follows the pattern the repo has rather than inventing one, and JSON.stringify escapes the lone surrogate rather than corrupting the row. Cosmetic at worst.
415 const row = db
416 .query<{ payload: string }, [string]>("SELECT payload FROM action WHERE board_id = ?1 ORDER BY id DESC LIMIT 1")
417 .get(boardId);
418 if (!row) return null;
419 return (JSON.parse(row.payload) as Payload).label ?? "a change";
420 }
421
422 /** Reverses the last change and returns its label, or null when there was nothing to undo. */
423 export function undo(boardId: string): string | null {
424 return db.transaction((): string | null => {
399425 const row = db
400426 .query<{ id: number; payload: string }, [string]>("SELECT id, payload FROM action WHERE board_id = ?1 ORDER BY id DESC LIMIT 1")
401427 .get(boardId);
402 if (!row) return false;
428 if (!row) return null;
403429 const payload = JSON.parse(row.payload) as Payload;
404430 const restoreColumn = db.query(
405431 "INSERT OR REPLACE INTO board_column (id, board_id, name, position) VALUES (?1, ?2, ?3, ?4)",
@@ -417,6 +443,6 @@ export function undo(boardId: string): boolean {
417443 }
418444 db.query("DELETE FROM action WHERE id = ?1").run(row.id);
419445 bumpRevision(boardId);
420 return true;
446 return payload.label ?? "a change";
421447 }).immediate();
422448 }
src/keys.ts +2 −2
@@ -30,7 +30,7 @@ export type BoardStore = {
3030 editCard(boardId: string, cardId: string, title: string): boolean;
3131 deleteCard(boardId: string, cardId: string): boolean;
3232 moveCard(boardId: string, cardId: string, columnId: string, index: number): boolean;
33 undo(boardId: string): boolean;
33 undo(boardId: string): string | null;
3434 boardRevision(boardId: string): number;
3535 boardsRevision(): number;
3636 };
@@ -305,7 +305,7 @@ function handleBoardKey(state: State, key: string, board: BoardStore): Outcome {
305305 } else if (key === "f") {
306306 next.prompt = { kind: "filter", label: "Filter (empty clears)", value: state.filter };
307307 } else if (key === "u") {
308 next.message = board.undo(state.boardId!) ? "" : "nothing to undo";
308 next.message = board.undo(state.boardId!) !== null ? "" : "nothing to undo";
309309 }
310310 return { state: clampCursors(next, board), quit: false };
311311 }
src/limits.test.ts +3 −3
@@ -37,12 +37,12 @@ test("the undo log keeps only the newest N actions per board, oldest dropped, ne
3737 expect(rowCount).toBe(MAX_UNDO_DEPTH);
3838
3939 // The newest change is still reachable...
40 expect(db.undo(board.id)).toBe(true);
40 expect(db.undo(board.id)).toBe(`renamed the column "name-${total - 2}"`);
4141 expect(db.getColumn(board.id, column.id)!.name).toBe(`name-${total - 2}`);
4242
4343 // ...but the log holds exactly N actions — one more undo than that runs out.
44 for (let i = 1; i < MAX_UNDO_DEPTH; i++) expect(db.undo(board.id)).toBe(true);
45 expect(db.undo(board.id)).toBe(false);
44 for (let i = 1; i < MAX_UNDO_DEPTH; i++) expect(db.undo(board.id)).not.toBeNull();
45 expect(db.undo(board.id)).toBeNull();
4646 });
4747
4848 test("a token bucket refuses once its tokens run out", () => {
src/routes.ts +1 −1
@@ -317,7 +317,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s
317317
318318 if (method === "POST" && parts.length === 3 && parts[2] === "undo") {
319319 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
320 return donePublish(request, who, boardId, db.undo(boardId), "undo");
320 return donePublish(request, who, boardId, db.undo(boardId) !== null, "undo");
321321 }
322322
323323 if (method === "POST" && parts.length === 3 && parts[2] === "columns") {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs