Skip to content

feat(service): a board save answers the transaction that moved its live row - #4432

Merged
luke-agent[bot] merged 1 commit into
mainfrom
luke/board-save-txid
Oct 21, 2026
Merged

luke-agent[bot] merged 1 commit into
mainfrom
luke/board-save-txid

Conversation

@charleslpan

@charleslpan charleslpan commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Service half of the board-save area of the txid rollout: saving a task's whiteboard returns the txid of the write that changed its row, and a save that changes nothing returns none. The host half (waiting on it through the board follow with confirmWrite, #4423) follows in its own PR.

Ported from the txid half of #3757 (11af552b, from that PR's earlier history; its branch luke/neon-realtime-task-record is gone). #3757 merged without it.

What changes

  • writeScene (packages/database/src/models/board-store.ts) returns pg_current_xact_id()::text in the upsert's own RETURNING, as Neon's mutations guide says. It opens no extra transaction: the write already runs under the task row's lock, and that lock read now also reads where the board's live row stood before.
  • The save answers txid only where the write moved the board's live row (boardLiveStatement: a new board, a changed appliedDrawing, or a change in whether it holds an image).
    • This differs from the old branch. There the live row carried an md5 of the elements. Today's row is a mark without the elements, so a save that moves only elements, or changes nothing, never shows on the follow. awaitTxId would then sit out its whole 10 s timeout, so these saves answer no txid.
  • Wire: boardSaveAnswerSchema = { board, txid? } (a decimal string) is the saveBoard output. HostedTaskClient.saveBoard answers it whole. The host only adapts to the new shape here (saved.board) and doesn't wait yet.

Tests

  • board-store.test.ts: the first save answers a committed txid. An elements-only save and an unchanged save answer none. A save that takes the drawing and its image answers a newer committed txid.
  • tasks-app.test.ts (web, over HTTP): the first PUT /board answers a txid, and the same scene again answers none.

Checks run: typecheck for database, features, service, host and web; vitest on the files above plus compose-tasks.test.ts, board-look.test.ts and the luke-mcp app test; biome; ./scripts/oxlint.sh.

Made with Luke

View guided diff Turn on auto-fix


Devin Review

…ve row

The scene's write returns `pg_current_xact_id()::text` from its own
RETURNING, and the save answers it as `txid` where the write moved the
board's live row (a new board, or the drawing the scene holds or its
image changed), as Neon's guide to optimistic mutations has it. A save
that leaves that row as it stood, such as one moving only the scene's
elements or one changing nothing, answers none, since no follower of the
board would ever see its transaction. The row's prior mark is read under
the task lock the write already takes.

Ported from the txid half of #3757 (11af552), which merged without it.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
luke-web Ready Ready Preview Oct 21, 2026 8:49pm UTC

Request Review

@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 645e13d Oct 21, 2026
11 of 12 checks passed
@luke-agent
luke-agent Bot deleted the luke/board-save-txid branch October 21, 2026 20:59

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Devin Review

Comment on lines +283 to +284
const moved = mark.appliedDrawing !== row.appliedDrawing || mark.imaged !== row.imaged;
return moved ? { board, txid: row.txid } : { board };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent board saves return phantom confirmations

When writeScene waits behind another save, mark can reflect the board before that save commits. An unchanged live row then returns a txid, leaving its follower waiting for a change it cannot see.

Learn more

lockTask joins task_board in the same SELECT FOR UPDATE OF task that waits for the task lock. Under PostgreSQL READ COMMITTED, the waiting statement can retain an earlier snapshot for the unlocked joined board row, even after it obtains the task lock. The upsert runs afterward and observes the committed board, so comparing its result with the earlier mark can incorrectly claim that its transaction changed the live query.

Example: Save A inserts the first board row with appliedDrawing: 0; save B starts its lock query while A holds the task lock. B sees a null joined board mark, then A commits. B writes the same appliedDrawing: 0 with no image and returns a txid, although the live row stays unchanged.

Recommended fix: Acquire the task lock in one statement, then read the board mark in a separate statement under that lock before upsertScene. This gives the comparison a snapshot taken after any preceding save commits. Add a PostgreSQL concurrency test that overlaps two saves; PGlite serializes access to its connection and cannot reproduce this ordering.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch was successfully deployed

1 active deployment
Preview — c0e0598f Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant