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; }), /**