From c0e0598f54a73181c9b24538943c0498afa644a2 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:46:02 +0000 Subject: [PATCH] feat(service): a board save answers the transaction that moved its live 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 (11af552b), which merged without it. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/web/tests/tasks-app.test.ts | 63 ++++++----- .../src/models/__tests__/board-store.test.ts | 39 +++++++ packages/database/src/models/board-store.ts | 103 ++++++++++++++---- packages/features/src/board-wire.ts | 16 ++- packages/features/src/contract/tasks.ts | 9 +- packages/features/src/task-client.ts | 17 ++- packages/host/src/compose-tasks.test.ts | 2 +- packages/host/src/compose-tasks.ts | 6 +- .../service/src/tasks/tasks-procedures.ts | 4 +- 9 files changed, 190 insertions(+), 69 deletions(-) diff --git a/apps/web/tests/tasks-app.test.ts b/apps/web/tests/tasks-app.test.ts index 13fac11858..3bcbfb3bd4 100644 --- a/apps/web/tests/tasks-app.test.ts +++ b/apps/web/tests/tasks-app.test.ts @@ -17,7 +17,7 @@ import { openWorkspace, } from "@luke/database/testing"; import { BOARD_ELEMENT_TYPE } from "@luke/features/board-vocabulary"; -import { boardAnswerSchema } from "@luke/features/board-wire"; +import { boardAnswerSchema, boardSaveAnswerSchema } from "@luke/features/board-wire"; import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; import { PAGE_SHOWER } from "@luke/features/page-wire"; import { DEFAULT_PLAN_TEMPLATE } from "@luke/features/plan-document-template"; @@ -1566,34 +1566,41 @@ it.layer(getTestDB())("the plan routes", (it) => { }), ); - it.effect("the board one member saves is read by the rest of the workspace", () => - Effect.gen(function* () { - const { owner, other, ask } = yield* openAccounts(); - const started = readAnswer( - taskAnswerSchema, - HOSTED_HTTP_STATUS.CREATED, - yield* ask(request(TASKS, owner, { method: "POST", body: RELAY })), - ).plan; - yield* joinWorkspace(started.workspaceId, other); - - const saved = yield* ask( - request(BOARD(started.id), other, { - method: "PUT", - - body: { elements: [NOTE], appliedDrawing: 0 }, - }), - ); + it.effect( + "the board one member saves is read by the rest of the workspace, the save answering its transaction where the board's live row moved and none where it did not", + () => + Effect.gen(function* () { + const { owner, other, ask } = yield* openAccounts(); + const started = readAnswer( + taskAnswerSchema, + HOSTED_HTTP_STATUS.CREATED, + yield* ask(request(TASKS, owner, { method: "POST", body: RELAY })), + ).plan; + yield* joinWorkspace(started.workspaceId, other); + const save = () => + Effect.map( + ask( + request(BOARD(started.id), other, { + method: "PUT", + body: { elements: [NOTE], appliedDrawing: 0 }, + }), + ), + (answer) => readAnswer(boardSaveAnswerSchema, HOSTED_HTTP_STATUS.OK, answer), + ); - assert.equal(saved.status, HOSTED_HTTP_STATUS.OK); - assert.deepEqual( - readAnswer( - boardAnswerSchema, - HOSTED_HTTP_STATUS.OK, - yield* ask(request(BOARD(started.id), owner)), - ).board.elements, - [NOTE], - ); - }), + const saved = yield* save(); + assert.deepEqual(saved.board.elements, [NOTE]); + assert.match(saved.txid ?? "", /^\d+$/, "the board's first save adds its live row"); + assert.equal((yield* save()).txid, undefined, "the same scene again moves nothing"); + assert.deepEqual( + readAnswer( + boardAnswerSchema, + HOSTED_HTTP_STATUS.OK, + yield* ask(request(BOARD(started.id), owner)), + ).board.elements, + [NOTE], + ); + }), ); it.effect( diff --git a/packages/database/src/models/__tests__/board-store.test.ts b/packages/database/src/models/__tests__/board-store.test.ts index f0a892b6a2..3582d61505 100644 --- a/packages/database/src/models/__tests__/board-store.test.ts +++ b/packages/database/src/models/__tests__/board-store.test.ts @@ -18,6 +18,9 @@ import { createTask } from "../task-store.js"; * outside the workspace reads none, nor does a member who does not reach * the repository of a task on its owner's own GitHub token. * + * A scene's write answers the id of the transaction that wrote it where it + * moved that row, and none where the row stands as it stood. + * * Synthetic accounts, drawings, and a stand-in for the image throughout. */ @@ -93,4 +96,40 @@ it.layer(getTestDB())("a board's live statement", (it) => { assert.deepEqual(yield* liveRows(teammate, task.id), []); }), ); + + it.effect( + "a scene's write answers its committed transaction where it moves the live row, and none where it leaves the row as it stood", + () => + Effect.gen(function* () { + const owner = yield* openAccount; + const workspace = yield* openWorkspace(owner); + const task = yield* createTask(owner, { organizationId: workspace, name: "Invitations" }); + const sql = yield* SqlClient.SqlClient; + const statusOf = (txid: string) => + Effect.map( + sql<{ readonly status: string }>`select pg_xact_status(${txid}::xid8) as status`, + ([row]) => row?.status, + ); + + const first = Option.getOrThrow(yield* writeScene(owner, task.id, SCENE, 0)); + assert.ok(first.txid !== undefined, "the board's first write adds its live row"); + assert.match(first.txid, /^\d+$/); + assert.equal(yield* statusOf(first.txid), "committed"); + + const moved = [ + { id: "api", type: BOARD_ELEMENT_TYPE.RECTANGLE, x: 40, y: 0, width: 200, height: 80 }, + ]; + const unmoved = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 0)); + assert.deepEqual(unmoved, { board: { elements: moved, appliedDrawing: 0 } }); + + const same = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 0)); + assert.equal(same.txid, undefined, "a save that changes nothing answers none"); + + yield* writeDrawing(owner, task.id, BOX); + const applied = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 1, IMAGE)); + assert.ok(applied.txid !== undefined, "holding the drawing and its image moves the row"); + assert.ok(BigInt(applied.txid) > BigInt(first.txid)); + assert.equal(yield* statusOf(applied.txid), "committed"); + }), + ); }); diff --git a/packages/database/src/models/board-store.ts b/packages/database/src/models/board-store.ts index a3543f6204..36499d1b71 100644 --- a/packages/database/src/models/board-store.ts +++ b/packages/database/src/models/board-store.ts @@ -52,6 +52,16 @@ const BoardRowSchema = Schema.Struct({ type BoardRow = typeof BoardRowSchema.Type; +/** A scene's row as its write returns it: the board, whether it holds an image, and the id of the transaction that wrote it. */ +const SavedSceneRowSchema = Schema.Struct({ + ...BoardRowSchema.fields, + imaged: Schema.Boolean, + txid: Schema.String, +}); + +/** The id of the transaction a write runs in, read in the write's own `RETURNING`, as text so its 64 bits stay exact. */ +const TXID = sql`pg_current_xact_id()::text`; + const BOARD_COLUMNS = { elements: taskBoard.elements, appliedDrawing: taskBoard.appliedDrawing, @@ -74,17 +84,37 @@ const findBoard = SqlSchema.findOneOption({ ), }); -/** The task's id and whether it is archived, under its row's lock until the transaction ends; nothing where the account reaches no such task. */ +/** Where the board's live row (`boardLiveStatement`) stands: the drawing the scene holds and whether it holds its image, each null before the board was first written. */ +const LiveMarkSchema = Schema.Struct({ + appliedDrawing: Schema.NullOr(Schema.Int), + imaged: Schema.NullOr(Schema.Boolean), +}); + +type LiveMark = typeof LiveMarkSchema.Type; + +/** + * The task's id, whether it is archived, and where its board's live row + * stands, under the task row's lock until the transaction ends; nothing + * where the account reaches no such task. + */ const lockTask = SqlSchema.findOneOption({ Request: TaskKeySchema, - Result: Schema.Struct({ id: Schema.String, archived: Schema.Boolean }), + Result: Schema.Struct({ id: Schema.String, archived: Schema.Boolean, ...LiveMarkSchema.fields }), execute: ({ userId, taskId }) => Database.use((db) => db - .select({ id: task.id, archived: sql`${task.archivedAt} is not null` }) + .select({ + id: task.id, + archived: sql`${task.archivedAt} is not null`, + appliedDrawing: taskBoard.appliedDrawing, + imaged: sql< + boolean | null + >`case when ${taskBoard.taskId} is null then null else ${taskBoard.image} is not null end`, + }) .from(task) + .leftJoin(taskBoard, eq(taskBoard.taskId, task.id)) .where(reachableTask(userId, taskId)) - .for("update"), + .for("update", { of: task }), ), }); @@ -97,7 +127,7 @@ const lockTask = SqlSchema.findOneOption({ function upsertScene(elements: readonly BoardElement[], image: string | undefined) { return SqlSchema.findOne({ Request: Schema.Struct({ taskId: Schema.String, appliedDrawing: Schema.Int, now: Schema.Date }), - Result: BoardRowSchema, + Result: SavedSceneRowSchema, execute: ({ taskId, appliedDrawing, now }) => { const scene = { elements, appliedDrawing, updatedAt: now }; // Note that a save moving to a new drawing with no image drops the old one, which shows a drawing gone. @@ -110,7 +140,11 @@ function upsertScene(elements: readonly BoardElement[], image: string | undefine target: taskBoard.taskId, set: { ...scene, image: image ?? kept }, }) - .returning(BOARD_COLUMNS), + .returning({ + ...BOARD_COLUMNS, + imaged: sql`${taskBoard.image} is not null`, + txid: TXID, + }), ); }, }); @@ -171,18 +205,22 @@ function boardOf(row: BoardRow): Board { * Runs one write under the task row's lock, handed whether the task is * archived as the lock read it; nothing where the account reaches no such task. */ -function underTaskLock( +function underTaskLock( userId: string, taskId: string, - write: (now: Date, archived: boolean) => Effect.Effect, + write: ( + now: Date, + locked: { readonly archived: boolean; readonly mark: LiveMark }, + ) => Effect.Effect, ) { return Effect.flatMap(SqlClient.SqlClient, (client) => client.withTransaction( Effect.gen(function* () { const locked = yield* lockTask({ userId, taskId }); - if (Option.isNone(locked)) return Option.none(); + if (Option.isNone(locked)) return Option.none(); const now = yield* DateTime.nowAsDate; - return Option.some(boardOf(yield* write(now, locked.value.archived))); + const { archived, appliedDrawing, imaged } = locked.value; + return Option.some(yield* write(now, { archived, mark: { appliedDrawing, imaged } })); }), ), ); @@ -211,9 +249,15 @@ export function readBoardImage( /** * The Mac's scene, written whole with the number of Luke's drawing it holds - * and any image of it; the board as written. An archived task takes no scene - * of the developer's, read under the same lock, while a drawing of Luke's on - * a call that stood when it was archived still lands. + * and any image of it; the board as written and, where the write moved the + * board's live row (`boardLiveStatement`), the id of the transaction that + * wrote it, which a desktop following the board waits on before it + * confirms the save, 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, answers none, since no follower would ever see its + * transaction. An archived task takes no scene of the developer's, read + * under the same lock, while a drawing of Luke's on a call that stood when + * it was archived still lands. */ export function writeScene( userId: string, @@ -221,14 +265,26 @@ export function writeScene( elements: readonly BoardElement[], appliedDrawing: number, image?: string, -): Effect.Effect, TaskStoreFailure | TaskArchived, DatabaseServices> { - return underTaskLock(userId, taskId, (now, archived) => - archived - ? Effect.fail(new TaskArchived()) - : upsertScene( - elements, - image, - )({ taskId, appliedDrawing, now }).pipe(Effect.catchTag("NoSuchElementError", Effect.die)), +): Effect.Effect< + Option.Option<{ readonly board: Board; readonly txid?: string }>, + TaskStoreFailure | TaskArchived, + DatabaseServices +> { + return underTaskLock(userId, taskId, (now, { archived, mark }) => + Effect.andThen( + archived ? Effect.fail(new TaskArchived()) : Effect.void, + upsertScene( + elements, + image, + )({ taskId, appliedDrawing, now }).pipe( + Effect.catchTag("NoSuchElementError", Effect.die), + Effect.map((row) => { + const board = boardOf(row); + const moved = mark.appliedDrawing !== row.appliedDrawing || mark.imaged !== row.imaged; + return moved ? { board, txid: row.txid } : { board }; + }), + ), + ), ); } @@ -240,7 +296,10 @@ export function writeDrawing( ): TaskStoreEffect> { // An upsert that returned no row is the database breaking its own contract, not an outcome. return underTaskLock(userId, taskId, (now) => - upsertDrawing(drawing)({ taskId, now }).pipe(Effect.catchTag("NoSuchElementError", Effect.die)), + upsertDrawing(drawing)({ taskId, now }).pipe( + Effect.catchTag("NoSuchElementError", Effect.die), + Effect.map(boardOf), + ), ); } diff --git a/packages/features/src/board-wire.ts b/packages/features/src/board-wire.ts index 3e752a25b8..eeb6ae2866 100644 --- a/packages/features/src/board-wire.ts +++ b/packages/features/src/board-wire.ts @@ -235,9 +235,23 @@ export const boardSchema = EffectSchema.Struct({ export type Board = typeof boardSchema.Type; -/** A board read (GET), and what a scene's write answers (PUT). */ +/** A board read (GET). */ export const boardAnswerSchema = EffectSchema.Struct({ board: boardSchema }); +/** + * What a scene's write answers (PUT): the board as written, and, where the + * write moved the board's live row (`boardLiveStatement`), the id of the + * transaction that wrote it as a decimal string, which a desktop following + * the board over Neon Realtime waits to see before it confirms the save. A + * save the live row does not show answers none, so there is nothing to wait for. + */ +export const boardSaveAnswerSchema = EffectSchema.Struct({ + board: boardSchema, + txid: EffectSchema.optionalKey(EffectSchema.String.check(EffectSchema.isPattern(/^\d+$/))), +}); + +export type BoardSaveAnswer = typeof boardSaveAnswerSchema.Type; + /** * The Mac's write (PUT): the whole scene, and the number of Luke's drawing it * holds; with the scene drawn as a PNG, base64, when this write is the first diff --git a/packages/features/src/contract/tasks.ts b/packages/features/src/contract/tasks.ts index 6faf4b0a00..22a4740084 100644 --- a/packages/features/src/contract/tasks.ts +++ b/packages/features/src/contract/tasks.ts @@ -12,7 +12,12 @@ import { HTTP_METHOD } from "@luke/wire"; import { openapi } from "@orpc/openapi"; import { Schema as EffectSchema } from "effect"; -import { BOARD_BOUNDS, boardAnswerSchema, boardSaveRequestSchema } from "../board-wire.js"; +import { + BOARD_BOUNDS, + boardAnswerSchema, + boardSaveAnswerSchema, + boardSaveRequestSchema, +} from "../board-wire.js"; import { MAXIMUM_START_BODY_BYTES } from "../coding-agent-wire.js"; import { planEditAnswerSchema, planEditRequestSchema } from "../plan-edit.js"; import { HOSTED_SERVICE_PATH } from "../service-paths.js"; @@ -132,7 +137,7 @@ export const tasksContract = { .meta(bodyCap(BOARD_BODY_BYTES)) .meta(openapi({ method: HTTP_METHOD.PUT, path: `${PLAN}/board`, inputStructure: "detailed" })) .input(exactBodyInput(EffectSchema.Struct(taskParams), boardSaveRequestSchema)) - .output(EffectSchema.flip(boardAnswerSchema)), + .output(EffectSchema.flip(boardSaveAnswerSchema)), /** The developer's edit of the document. */ edit: hostedProcedure .meta(bodyCap(EDIT_BODY_BYTES)) diff --git a/packages/features/src/task-client.ts b/packages/features/src/task-client.ts index ec2f7a698d..98f086011d 100644 --- a/packages/features/src/task-client.ts +++ b/packages/features/src/task-client.ts @@ -13,7 +13,7 @@ import { type CallCredential, callAnswered, } from "./account-call.js"; -import type { Board, BoardElement } from "./board-wire.js"; +import type { Board, BoardElement, BoardSaveAnswer } from "./board-wire.js"; import { startRefusedAs } from "./coding-agent-client.js"; import type { CodingAgentAgentAnswer } from "./coding-agent-view.js"; import { CODING_AGENT_ATTACHMENT_TYPE } from "./coding-agent-wire.js"; @@ -833,21 +833,18 @@ export class HostedTaskClient { ); } - /** The board's scene written whole, with the number of Luke's drawing it holds and, when it is the first to hold it, the scene's image; the board as written, or nothing where the service did not answer. */ + /** The board's scene written whole, with the number of Luke's drawing it holds and, when it is the first to hold it, the scene's image; the board as written and, where the write moved the board's live row, the id of its transaction, or nothing where the service did not answer. */ saveBoard( taskId: string, elements: readonly BoardElement[], appliedDrawing: number, image?: string, - ): Effect.Effect { + ): Effect.Effect { return orNothing( - Effect.map( - askHosted(this.#hosted.plans.saveBoard, { - params: { id: taskId }, - body: { elements, appliedDrawing, ...(image === undefined ? undefined : { image }) }, - }), - (answer) => answer.board, - ), + askHosted(this.#hosted.plans.saveBoard, { + params: { id: taskId }, + body: { elements, appliedDrawing, ...(image === undefined ? undefined : { image }) }, + }), ); } diff --git a/packages/host/src/compose-tasks.test.ts b/packages/host/src/compose-tasks.test.ts index 983c038fe0..dd436bf139 100644 --- a/packages/host/src/compose-tasks.test.ts +++ b/packages/host/src/compose-tasks.test.ts @@ -985,7 +985,7 @@ function fakeService(tasks: Task[]): FakeService { service.images[taskId] = image; const board = { ...service.boards[taskId], elements, appliedDrawing }; service.boards[taskId] = board; - return board; + return { board }; }), listLive: () => Effect.sync(() => { diff --git a/packages/host/src/compose-tasks.ts b/packages/host/src/compose-tasks.ts index dd624fd213..bc07de3a63 100644 --- a/packages/host/src/compose-tasks.ts +++ b/packages/host/src/compose-tasks.ts @@ -2356,9 +2356,9 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct if (!gate() || view.activeTaskId !== taskId) return { saved: false }; return yield* serial( Effect.gen(function* () { - const board = yield* served(client.saveBoard(taskId, elements, appliedDrawing, image)); - if (board === undefined) return { saved: false }; - if (view.activeTaskId === taskId) write({ board }); + const saved = yield* served(client.saveBoard(taskId, elements, appliedDrawing, image)); + if (saved === undefined) return { saved: false }; + if (view.activeTaskId === taskId) write({ board: saved.board }); return { saved: true }; }), ); diff --git a/packages/service/src/tasks/tasks-procedures.ts b/packages/service/src/tasks/tasks-procedures.ts index 9501dd223f..f649e9038e 100644 --- a/packages/service/src/tasks/tasks-procedures.ts +++ b/packages/service/src/tasks/tasks-procedures.ts @@ -661,7 +661,7 @@ export function tasksProcedures(seams: TasksProcedureSeams) { return { board: board.value }; }), - /** The Mac's scene written whole, the last write winning. */ + /** The Mac's scene written whole, the last write winning; with the id of its transaction where the board's live row shows it. */ saveBoard: plans.saveBoard.effect(function* ({ input, context }) { const taskId = yield* taskIdOf(input.params.id); const save = input.body; @@ -669,7 +669,7 @@ export function tasksProcedures(seams: TasksProcedureSeams) { writeScene(context.userId, taskId, save.elements, save.appliedDrawing, save.image), ); if (Option.isNone(written)) return yield* Effect.fail(HOSTED_REFUSAL.NOT_FOUND); - return { board: written.value }; + return written.value; }), /**