From a7b1aa701c8ee02daca675d3e9d276c6e1471402 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 13:31:50 -0700 Subject: [PATCH 1/5] docs: add problem guides for races, rooms, and offline state Developers search for a problem, not for "virtual actors". Three guides start from a failure they can reproduce, show the simplest fix without an actor, and then show a tested Solid Objects version with its limits: - Prevent race conditions in Node.js: a lost update across an await, a mutex in each process that still oversells, the conditional UPDATE fix, and an EventSeats actor whose holds, payment confirmations, and expiry reminders race. - Keep turn-based room state through restarts: process memory loses the room and its timer, a version column fixes the write, and a TurnRoom actor keeps turn order, a durable timer, and authorized reconnect. - Offline-first state in the browser with SQLite WASM: a page-memory outbox loses edits on reload, and a module worker queues writes with transmit that the server applies once, in order. Every factual claim was checked against the source and the cited primary sources. Every code block is an example file that a test runs; the race-conditions test holds seats from two real processes. The documentation check now requires each guide to embed its current example files and rejects an em dash in a guide. Vitest maps solid-objects to src so examples that import the package name run before a build; the guide tests type-check under tsconfig.examples.json, which already maps it. --- README.md | 10 + docs/agents.md | 5 + docs/guides/offline-first.md | 275 +++++++++++++ docs/guides/persistent-rooms.md | 381 ++++++++++++++++++ docs/guides/race-conditions.md | 358 ++++++++++++++++ .../guides/offline-first/inspection-worker.js | 59 +++ .../guides/offline-first/memory-outbox.js | 16 + examples/guides/offline-first/page.js | 30 ++ .../guides/offline-first/server-inspection.ts | 17 + examples/guides/offline-first/sync-route.ts | 30 ++ .../guides/persistent-rooms/memory-room.ts | 44 ++ examples/guides/persistent-rooms/reconnect.ts | 18 + .../guides/persistent-rooms/room-runtime.ts | 49 +++ examples/guides/persistent-rooms/sql-room.ts | 69 ++++ examples/guides/persistent-rooms/turn-room.ts | 85 ++++ .../guides/race-conditions/atomic-hold.ts | 28 ++ .../guides/race-conditions/event-runtime.ts | 48 +++ .../guides/race-conditions/event-seats.ts | 73 ++++ .../guides/race-conditions/lost-update.ts | 37 ++ .../guides/race-conditions/seat-inventory.ts | 36 ++ scripts/check-documentation.mjs | 86 ++++ test/browser-server.mjs | 70 ++++ test/browser/offline-first.browser.ts | 99 +++++ test/fixtures/event-hold-process.ts | 24 ++ test/guide-offline-first.test.ts | 116 ++++++ test/guide-persistent-rooms.test.ts | 233 +++++++++++ test/guide-race-conditions.test.ts | 279 +++++++++++++ test/support/register-source-hooks.mjs | 3 + test/support/source-hooks.mjs | 20 + tsconfig.examples.json | 10 +- tsconfig.json | 7 +- vitest.config.ts | 10 + 32 files changed, 2622 insertions(+), 3 deletions(-) create mode 100644 docs/guides/offline-first.md create mode 100644 docs/guides/persistent-rooms.md create mode 100644 docs/guides/race-conditions.md create mode 100644 examples/guides/offline-first/inspection-worker.js create mode 100644 examples/guides/offline-first/memory-outbox.js create mode 100644 examples/guides/offline-first/page.js create mode 100644 examples/guides/offline-first/server-inspection.ts create mode 100644 examples/guides/offline-first/sync-route.ts create mode 100644 examples/guides/persistent-rooms/memory-room.ts create mode 100644 examples/guides/persistent-rooms/reconnect.ts create mode 100644 examples/guides/persistent-rooms/room-runtime.ts create mode 100644 examples/guides/persistent-rooms/sql-room.ts create mode 100644 examples/guides/persistent-rooms/turn-room.ts create mode 100644 examples/guides/race-conditions/atomic-hold.ts create mode 100644 examples/guides/race-conditions/event-runtime.ts create mode 100644 examples/guides/race-conditions/event-seats.ts create mode 100644 examples/guides/race-conditions/lost-update.ts create mode 100644 examples/guides/race-conditions/seat-inventory.ts create mode 100644 test/browser/offline-first.browser.ts create mode 100644 test/fixtures/event-hold-process.ts create mode 100644 test/guide-offline-first.test.ts create mode 100644 test/guide-persistent-rooms.test.ts create mode 100644 test/guide-race-conditions.test.ts create mode 100644 test/support/register-source-hooks.mjs create mode 100644 test/support/source-hooks.mjs diff --git a/README.md b/README.md index 203640b..67b61fb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ subscriptions. Start with the [Cloudflare example](examples/cloudflare/README.md - [Solid Objects in the browser](#solid-objects-in-the-browser) - [When a transaction is better](#when-a-transaction-is-better) - [Guarantees and boundaries](#guarantees-and-boundaries) +- [Guides](#guides) - [Read more](#read-more) - [Status and license](#status-and-license) @@ -258,6 +259,15 @@ or jobs. A plain counter is not a reason to install this package. There is no exactly-once delivery. Read the [correctness contract](docs/correctness.md) before using important data. +## Guides + +Each guide reproduces a failure, shows the simplest fix without an actor, and +then shows a tested Solid Objects version with its limits: + +- [Prevent race conditions in Node.js](docs/guides/race-conditions.md) +- [Keep turn-based room state through restarts](docs/guides/persistent-rooms.md) +- [Offline-first state in the browser with SQLite WASM](docs/guides/offline-first.md) + ## Read more - [Five-minute Node guide](https://solidobjects.dev/5min/node) diff --git a/docs/agents.md b/docs/agents.md index 3768a30..1f31c66 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -31,6 +31,11 @@ Select a simpler tool in these cases: The full list is in [Choosing Solid Objects](fit.md). The category guide is [Virtual actors in TypeScript and Node.js](virtual-actors.md). +Problem guides start from a failure and show the fix without an actor first: +[race conditions](guides/race-conditions.md), +[turn-based rooms](guides/persistent-rooms.md), and +[offline-first browser state](guides/offline-first.md). + ## 2. Package identity | Item | Value | diff --git a/docs/guides/offline-first.md b/docs/guides/offline-first.md new file mode 100644 index 0000000..a743c84 --- /dev/null +++ b/docs/guides/offline-first.md @@ -0,0 +1,275 @@ +# Offline-first state in the browser with SQLite WASM + +The Solid Objects browser runtime keeps each identity's state in SQLite WASM in the origin private file system (OPFS). Tabs share that state. The runtime queues each write for the server in the same transaction as the local change. The queue sends writes in order when the network returns, and the server applies each write once. The runtime does not replicate server changes to the browser or merge concurrent edits like a CRDT. Nothing runs while the browser is closed. + +## The failure: an outbox in page memory + +An inspector records findings on a tablet without a network. Each inspection has an identity, and each finding has an ID and a note. The tablet must keep each finding until the server receives it. + +```javascript +let pending = [] + +export function queueFinding(finding) { + pending = [...pending, finding] +} + +export function pendingFindings() { + return pending.length +} + +export async function flushFindings({ send }) { + while (pending.length > 0) { + await send(pending[0]) + pending = pending.slice(1) + } +} +``` + +This example keeps the outbox in an array in page memory. The `queueFinding` function adds a finding, and `flushFindings` sends each finding before it removes it. A reload or a tab close loses every queued edit. The browser test queues two findings, reloads the page, and finds no queued finding. + +A page can close after `flushFindings` sends an edit but before it removes that edit. If the app retries that edit, it sends the edit again. The server needs a key to ignore the repeated edit. A queue must preserve both the edit and its key across attempts. + +## The native fix + +A durable outbox in IndexedDB preserves queued edits across reloads. The client gives each edit an ID and keeps that ID with the edit. A server table records each ID that the server applies. The server ignores an ID that the table already contains. + +With several tabs, one tab at a time must flush the outbox. The Web Locks API can coordinate this work across tabs of the same origin. A tab holds a lock while it sends queued edits. Other tabs wait for the same lock. [MDN describes this use of Web Locks.](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) + +The Background Synchronization API can retry a send from a service worker after the network returns. It needs a service worker and a secure context. MDN marks it "Limited availability" because it does not work in some widely used browsers. [MDN lists these requirements and limits.](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API) + +This is a good design when the app needs only a queue. Solid Objects becomes relevant when each identity also has local state, rules, and an order that the server must replay. + +## One actor per inspection, in a module worker + +```javascript +import { NonRetryableError } from "solid-objects/core" +import { Actor, configure, registerTransmit, sharedSqliteWasm } from "solid-objects/browser/host" + +class Inspection extends Actor { + static actorType = "Inspection" + + findings = [] + + record({ findingId, note }) { + if (this.findings.some((finding) => finding.findingId === findingId)) { + return this.findings.length + } + this.findings = [...this.findings, { findingId, note }] + this.transmit().record({ findingId, note }) + return this.findings.length + } +} + +const allowInspections = ({ actorType }) => actorType === "Inspection" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "inspections.db" }), + authorizeMessage: allowInspections, + authorizeQuery: allowInspections, + maxAttempts: 1_000, + retryDelayMilliseconds: (attempt) => Math.min(2 ** (attempt - 1), 30) * 1_000, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) + +registerTransmit({ + runtime, + deliver: async (envelope) => { + const response = await fetch("/inspections/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }) + if (response.status === 422) { + throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`) + } + if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`) + }, +}) +runtime.register(Inspection) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await Inspection.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} +``` + +The runtime runs in a module worker and imports its APIs from `solid-objects/browser/host`. Each inspection has one `Inspection` actor identity. Its `findings` field holds the local state. + +The `sharedSqliteWasm` adapter stores that state in OPFS and shares one database between tabs. One tab holds the database. Other tabs send their SQL to that tab. Another tab takes over when the holder tab closes. + +The `record` operation checks the `findingId` before it changes the state. If the finding already exists, the operation returns the count without another change. Otherwise, it adds the finding and calls `this.transmit().record(...)`. + +The runtime commits the local change and the write for the server in the same transaction. Thus, a local commit cannot omit its queued write for the server. A retryable failure rolls back the local change and the queued write together. + +The `registerTransmit` function gives each queued write to `deliver`. This callback sends the envelope to `/inspections/sync`. The envelope identifies the effect, actor, operation, and arguments. The callback decides whether delivery succeeds or fails. + +A network failure throws an `Error`. The effect tries again later with the delay from `retryDelayMilliseconds`. This example increases the delay after each attempt, up to 30 seconds. An HTTP response other than success or 422 also causes an `Error`. + +An HTTP 422 response causes `deliver` to throw `NonRetryableError`, from `solid-objects/core`. The runtime moves that effect to dead letters and does not retry it. A normal return marks the write as delivered. The runtime does not inspect the HTTP response itself. + +The example sets `maxAttempts` to 1,000 because a long offline period can use many attempts. The default is 5. A high value extends the retry period, but it does not provide unlimited retries. + +The short lease settings let another tab take over quickly after a tab closes. The example uses 750 milliseconds for `processAliveThresholdMilliseconds` and `leaseDurationMilliseconds`. It uses 250 milliseconds for `leaseRenewalIntervalMilliseconds`. + +The policy permits messages and queries only for the `Inspection` actor type. A browser policy limits what your own page can call. It is not a security boundary because the user controls the page. + +```javascript +const worker = new Worker(new URL("./inspection-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 +let workerFailure + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + +export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} +``` + +The page starts a module worker and sends messages through `callActor`. The page holds no actor reference. A request ID connects each response to its call. + +If the worker fails to load, the page rejects every call that awaits a response. The page also rejects each later call with the stored error. The map in the page tracks call responses; the database holds the actor state and outbox. + +## The server applies each write once + +```typescript +import { Actor } from "solid-objects" + +type Finding = { findingId: string; note: string } + +export class Inspection extends Actor { + static override readonly actorType = "Inspection" + + findings: Finding[] = [] + + record({ findingId, note }: Finding): number { + if (this.findings.some((finding) => finding.findingId === findingId)) { + return this.findings.length + } + this.findings = [...this.findings, { findingId, note }] + return this.findings.length + } +} +``` + +The server defines an actor with the same actor type, `Inspection`. Its `record` operation adds a finding only if its `findingId` does not already exist. The server operation does not transmit the write again. + +```typescript +import { + IdempotencyConflict, + InvalidPayload, + receiveTransmitEnvelope, + type SolidObjectsRuntime, + type TransmitEnvelope, +} from "solid-objects" + +export async function handleInspectionSync({ + request, + runtime, + canWrite, +}: { + request: Request + runtime: SolidObjectsRuntime + canWrite: (options: { request: Request; envelope: TransmitEnvelope }) => Promise +}): Promise { + const envelope = (await request.json()) as TransmitEnvelope + if (!(await canWrite({ request, envelope }))) return new Response("Forbidden", { status: 403 }) + + try { + await receiveTransmitEnvelope({ runtime, envelope }) + return Response.json({}) + } catch (error) { + if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) { + return new Response(null, { status: 422 }) + } + throw error + } +} +``` + +The `receiveTransmitEnvelope` function enqueues the write with the effect ID as the idempotency key. This key identifies repeated delivery of the same write. A repeated envelope therefore applies once, even if the browser sends it more than once. + +Delivery through `receiveTransmitEnvelope` skips `authorizeMessage`. The route must authenticate the device before it calls this function. It must also check that the device can write the actor in the envelope. + +The example gives those checks to `canWrite`. If that callback denies the request, the route answers 403 and does not enqueue the write. The browser policy cannot replace these server checks. + +The route answers 422 for `InvalidPayload` and `IdempotencyConflict`. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply. + +A Rails server can receive the same envelopes with `SolidObjects::Transmission.receive`. That method also skips message authorization. Authenticate the device before the call. Check its permission to write the actor. + +## Several tabs + +Each tab starts its own worker. The workers share the database through `sharedSqliteWasm`. Writes from two tabs to one inspection apply in the local order. + +The browser test records two findings in one tab and one finding in a second tab while offline. The server has no findings before the network returns. After the network returns, the server receives all three findings and applies each once, in order. + +Run one effect worker for each local runtime to preserve the order of writes for each actor. [The transmit section of the public API](../api.md#solid-objectstransmit) states this requirement. This order belongs to each actor identity; it does not define one order across all inspections. + +## What this does not do + +- It does not replicate server changes to the browser. Fetch the server state. Alternatively, subscribe with the WebSocket client in `solid-objects/browser`. +- It does not merge concurrent edits from different devices. The server applies writes in the order it receives them for each identity. The application defines the conflict rules. CRDT libraries such as [Yjs](https://yjs.dev) and [Automerge](https://automerge.org) support automatic merges of concurrent edits. +- Nothing runs while the browser is closed. Reminders and the outbox run only while a tab runs the worker. Queued writes wait in OPFS and send when a tab opens again. + +## Limits + +- CI tests the browser runtime in Chromium only. Safari 16.4 and Firefox 111 added the OPFS API that persistent storage needs. Test each engine that you support. +- Persistent storage needs a secure context and a dedicated worker. An embedded WebView can lack OPFS even when the device browser supports it. Test the WebView itself. +- The browser can clear the storage of an origin. Call `navigator.storage.persist()` to ask the browser to keep it. +- All authorization callbacks deny by default, also in the browser. The example explicitly permits messages and queries for `Inspection`. +- Delivery is at least once. The server must stay idempotent: a repeated write must not cause another state change. + +[Step 12 of the agent guide](../agents.md#12-use-the-browser-runtime) explains browser setup and policies. [Correctness and delivery semantics](../correctness.md) defines the guarantees and limits. [Virtual actors in TypeScript and Node.js](../virtual-actors.md) explains the actor model. + +## What the tests prove + +The [browser tests](../../test/browser/offline-first.browser.ts) check these cases: + +- Two tabs record three findings while offline. After the network returns, the server state contains each finding once, in the same order. +- A finding in the local actor state survives a page reload. +- Two findings in the outbox in page memory disappear after a page reload. + +The [route tests](../../test/guide-offline-first.test.ts) check these cases: + +- A request without device authorization receives 403 and adds no finding. +- An authorized request receives 200 and adds the expected finding. +- A repeated envelope receives 200 and leaves one finding. +- An envelope with an empty operation receives 422. + +These tests do not check every browser engine or every interruption during delivery. They also do not test `IdempotencyConflict` or the browser response to 422. + +## Sources + +- MDN, [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API), checked October 9, 2026. +- MDN, [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API), checked October 9, 2026. +- [Yjs](https://yjs.dev), and [Automerge](https://automerge.org), checked October 9, 2026. diff --git a/docs/guides/persistent-rooms.md b/docs/guides/persistent-rooms.md new file mode 100644 index 0000000..5637a01 --- /dev/null +++ b/docs/guides/persistent-rooms.md @@ -0,0 +1,381 @@ +# Keep turn-based room state through restarts in Node.js + +A room in process memory loses its state and its turn timer when the process restarts. Store the room in SQL. Arm the turn timer from durable data. One actor per room gives you ordered moves, a durable turn timer, and the current state after a restart. This guide uses `solid-objects` for turn-based games, lobbies, and shared boards in Node.js. + +## The failure: state and timers in process memory + +```typescript +type MemoryRoom = { players: string[]; turnNumber: number; started: boolean } + +export function createMemoryRooms({ turnMilliseconds }: { turnMilliseconds: number }) { + const rooms = new Map() + const timers = new Map>() + + function armTurnTimer(roomId: string): void { + clearTimeout(timers.get(roomId)) + timers.set( + roomId, + setTimeout(() => skipTurn(roomId), turnMilliseconds), + ) + } + + function skipTurn(roomId: string): void { + const room = rooms.get(roomId) + if (!room) return + rooms.set(roomId, { ...room, turnNumber: room.turnNumber + 1 }) + armTurnTimer(roomId) + } + + return { + join({ roomId, playerId }: { roomId: string; playerId: string }): void { + const room = rooms.get(roomId) ?? { players: [], turnNumber: 0, started: false } + rooms.set(roomId, { ...room, players: [...room.players, playerId] }) + }, + start({ roomId }: { roomId: string }): void { + const room = rooms.get(roomId) + if (!room) return + rooms.set(roomId, { ...room, started: true }) + armTurnTimer(roomId) + }, + room({ roomId }: { roomId: string }): MemoryRoom | undefined { + return rooms.get(roomId) + }, + pendingTimers(): number { + return timers.size + }, + close(): void { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + }, + } +} +``` + +The `Map` holds the room state in one process. The `setTimeout` holds the turn timer in that process. Neither survives a process restart. A deploy or a crash loses both. A second process has its own memory and cannot read the first process's room or timer. + +The test models a restart with two instances of `createMemoryRooms`. It starts a game with two players and checks that one timer exists. It then calls `close` and creates a new instance. The new instance has no room and no timer. The test uses new instances in the same process. + +## The native fix: a row with a version and a deadline + +```typescript +import { DatabaseSync } from "node:sqlite" + +export function openRoomStore({ path }: { path: string }): DatabaseSync { + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + state TEXT NOT NULL, + version INTEGER NOT NULL, + turn_deadline INTEGER + ) + `) + return database +} + +export function saveRoom({ + database, + roomId, + state, + expectedVersion, + turnDeadline, +}: { + database: DatabaseSync + roomId: string + state: unknown + expectedVersion: number + turnDeadline: number | null +}): { saved: boolean } { + if (expectedVersion === 0) { + const inserted = database + .prepare( + "INSERT OR IGNORE INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, ?)", + ) + .run(roomId, JSON.stringify(state), turnDeadline) + return { saved: inserted.changes === 1 } + } + const updated = database + .prepare( + "UPDATE rooms SET state = ?, version = version + 1, turn_deadline = ? WHERE room_id = ? AND version = ?", + ) + .run(JSON.stringify(state), turnDeadline, roomId, expectedVersion) + return { saved: updated.changes === 1 } +} + +export function loadRoom({ + database, + roomId, +}: { + database: DatabaseSync + roomId: string +}): { state: unknown; version: number } | undefined { + const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as + { state: string; version: number } | undefined + if (!row) return undefined + return { state: JSON.parse(row.state), version: row.version } +} + +export function dueTurnDeadlines({ + database, + now, +}: { + database: DatabaseSync + now: number +}): string[] { + const rows = database + .prepare("SELECT room_id FROM rooms WHERE turn_deadline <= ? ORDER BY turn_deadline") + .all(now) as { room_id: string }[] + return rows.map((row) => row.room_id) +} +``` + +The SQL design stores each room as JSON in a row with a `version` column and a turn deadline. `loadRoom` returns the state and its version. The caller passes that version to `saveRoom` as `expectedVersion`. + +`saveRoom` updates a row only when its version still matches. A successful update also increases the version. A write from an old version returns `{ saved: false }`. The caller then reloads the room and tries again. An old copy of the state cannot replace a newer copy. + +The row also stores `turn_deadline`. Call `dueTurnDeadlines` at startup and on an interval. It finds rooms whose deadline equals or precedes `now`. The deadline survives because SQL stores it with the state. The query finds due rooms; it does not apply their timeouts. + +The application still owns these parts: + +- A retry loop for version conflicts. +- A sweep process that checks deadlines. +- A rule that permits only one process to apply each timeout. + +The test closes the database and opens the same database again. The room survives. A write with the current version succeeds, and a write with the old version fails. The sweep then finds the due room. + +## One actor per room + +```typescript +import { Actor } from "solid-objects" + +export const TURN_MILLISECONDS = 60_000 + +const MAXIMUM_PLAYERS = 4 + +type Move = { playerId: string; move: string; turnNumber: number } + +export class TurnRoom extends Actor { + static override readonly actorType = "TurnRoom" + + players: string[] = [] + started = false + turnNumber = 0 + moves: Move[] = [] + skippedTurns: number[] = [] + revision = 0 + + get currentPlayer(): string | null { + if (!this.started) return null + return this.players[this.turnNumber % this.players.length] ?? null + } + + join({ playerId }: { playerId: string }): { players: string[] } { + if (this.players.includes(playerId)) return { players: this.players } + if (this.started) this.reject("game_started", { message: "The game already started" }) + if (this.players.length === MAXIMUM_PLAYERS) { + this.reject("room_full", { message: "The room is full" }) + } + this.players = [...this.players, playerId] + this.revision += 1 + return { players: this.players } + } + + start({ playerId }: { playerId: string }): { turnNumber: number } { + if (this.started) return { turnNumber: this.turnNumber } + if (!this.players.includes(playerId)) { + this.reject("not_in_room", { message: "Only a player in the room can start the game" }) + } + if (this.players.length < 2) { + this.reject("not_enough_players", { message: "The game needs two players" }) + } + this.started = true + this.revision += 1 + this.#armTurnTimer() + return { turnNumber: this.turnNumber } + } + + play({ playerId, move, turnNumber }: { playerId: string; move: string; turnNumber: number }): { + turnNumber: number + } { + if (!this.started) this.reject("game_not_started", { message: "The game has not started" }) + if (turnNumber !== this.turnNumber) { + this.reject("stale_turn", { + message: "That turn already ended", + details: { turnNumber: this.turnNumber }, + }) + } + if (playerId !== this.currentPlayer) { + this.reject("not_your_turn", { message: "It is not your turn" }) + } + this.moves = [...this.moves, { playerId, move, turnNumber }] + this.#advanceTurn() + return { turnNumber: this.turnNumber } + } + + turnTimeout({ turnNumber }: { turnNumber: number }): { skipped: boolean } { + if (turnNumber !== this.turnNumber) return { skipped: false } + this.skippedTurns = [...this.skippedTurns, turnNumber] + this.#advanceTurn() + return { skipped: true } + } + + #advanceTurn(): void { + this.turnNumber += 1 + this.revision += 1 + this.#armTurnTimer() + } + + #armTurnTimer(): void { + this.schedule({ at: new Date(Date.now() + TURN_MILLISECONDS) }).turnTimeout({ + turnNumber: this.turnNumber, + }) + } +} +``` + +Each room has one `TurnRoom` actor identity. Moves for that room run one at a time, in order, across processes. The actor stores the players, moves, turn number, skipped turns, and revision. Other rooms have separate identities. + +`play` takes the `turnNumber` that the client saw. The actor compares that value with its current turn number before it accepts the move. Two tabs can submit different moves for the same turn. The first accepted move advances the turn. The second submission then rejects with `stale_turn`. + +The actor also checks the player. A move from the wrong player rejects with `not_your_turn`. The getter `currentPlayer` identifies the player for the current turn. Before the game starts, it returns `null`. A getter is a read-only query. + +Each turn arms `schedule({ at }).turnTimeout({ turnNumber })`. The reminder stores the turn number that it applies to. A reminder with the same operation and no key moves the same alarm. It does not add a second alarm. Thus, a move sets a new deadline for the next turn. + +`turnTimeout` compares its argument with the current turn number. It does nothing when that turn already ended. A late or repeated timer cannot skip a later turn. A valid timeout records the skipped turn and advances to the next turn. + +`revision` increases on every change to the room. A client compares revisions to detect missed changes. A repeated `join` for the same player returns the players without a change. It does not increase `revision`. + +## Authorize players + +```typescript +import { createRuntime, type SolidObjectsRuntime } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" +import { TurnRoom } from "./turn-room.js" + +export type RoomCaller = { playerId: string } + +const PLAYER_OPERATIONS = new Set(["join", "start", "play"]) + +export function authorizeRoomMessage({ + actorType, + operation, + arguments: argumentsValue, + authorizationContext, +}: { + actorType: string + operation: string + arguments: Record + authorizationContext: unknown +}): boolean { + if (actorType !== TurnRoom.actorType) return false + if (!PLAYER_OPERATIONS.has(operation)) return false + if (!isRoomCaller(authorizationContext)) return false + return argumentsValue.playerId === authorizationContext.playerId +} + +export function authorizeRoomQuery({ + actorType, + authorizationContext, +}: { + actorType: string + authorizationContext: unknown +}): boolean { + return actorType === TurnRoom.actorType && isRoomCaller(authorizationContext) +} + +export function roomRuntime({ path }: { path: string }): SolidObjectsRuntime { + const runtime = createRuntime({ + database: sqlite({ path }), + authorizeMessage: authorizeRoomMessage, + authorizeQuery: authorizeRoomQuery, + }) + runtime.register(TurnRoom) + return runtime +} + +function isRoomCaller(value: unknown): value is RoomCaller { + if (typeof value !== "object" || value === null) return false + return "playerId" in value && typeof value.playerId === "string" +} +``` + +All authorization callbacks deny by default. The application supplies a policy for messages and queries. The message policy permits only `join`, `start`, and `play` for `TurnRoom`. + +The policy binds the `playerId` argument to the authenticated player in `authorizationContext`. One player cannot submit a move for another player. The application authenticates the WebSocket or HTTP request and passes the player as `authorizationContext`. A player identifier from an untrusted request does not establish that identity. + +No caller can run `turnTimeout` directly through this policy. Only the runtime runs it from the reminder. The query policy checks the actor type and the player context. The reconnect function below also checks room membership before it returns the room. + +## Reconnect: send the current room + +```typescript +import type { ActorSnapshot, SolidObjectsRuntime } from "solid-objects" +import { TurnRoom } from "./turn-room.js" + +export async function resumeRoom({ + runtime, + roomId, + playerId, + lastSeenRevision, +}: { + runtime: SolidObjectsRuntime + roomId: string + playerId: string + lastSeenRevision: number +}): Promise<{ room: ActorSnapshot; changed: boolean } | null> { + const room = await runtime.ref(TurnRoom, roomId).snapshot({ authorizationContext: { playerId } }) + if (!room.players.includes(playerId)) return null + return { room, changed: room.revision !== lastSeenRevision } +} +``` + +After a reconnect or a server restart, the server reads the room with the player's authorization and sends it. `resumeRoom` reads a snapshot with `authorizationContext`. It returns `null` to a player who is not in the room. Keep this membership check on the path that sends room state to the client. + +For a room member, `resumeRoom` returns the room and `changed`. It compares `revision` with `lastSeenRevision` to calculate `changed`. The client compares the current revision with the last revision it saw. A difference shows that the client missed a change. + +Socket.IO connection state recovery restores the socket id, rooms, and `data` when recovery succeeds. The server must enable this feature. It also sends events that the client missed during a temporary disconnection. The documentation says recovery “will not always be successful”, so the application must still synchronize client and server state. [Socket.IO documentation](https://socket.io/docs/v4/connection-state-recovery). + +Adapter support also matters: + +- The in-memory adapter supports recovery. +- The Redis adapter does not support recovery. +- The Redis Streams adapter supports recovery. +- The MongoDB adapter supports recovery. + +The [Socket.IO adapter table](https://socket.io/docs/v4/connection-state-recovery#compatibility-with-existing-adapters) lists this support. Socket.IO recovery and this pattern work together. Socket.IO restores the connection, and the actor restores the authorized room state. + +## Run the runtime process + +The turn timer is a reminder. In normal operation, reminders run only while a process calls `runtime.run(signal)`. The `roomRuntime` function registers `TurnRoom` before the runtime starts. The process must know the actor class to execute its operations. + +Run the background roles as described in [step 7 of the agent guide](../agents.md#7-run-the-background-roles). When no process runs, the due reminder waits in SQL. It runs when a process starts. The runtime needs a process to execute each timeout. + +In tests, `runtime.testing.runDueReminders({ now })` finds due reminders at the supplied time. Then `runtime.testing.drain({ roles: ["actors"] })` runs the actor work. These calls test due timers without a wait for the real deadline. + +## What the tests prove + +The [tests for persistent rooms](../../test/guide-persistent-rooms.test.ts) check these cases in this order: + +1. A new instance of the memory design has no room and no timer after a simulated restart. +2. The SQL room survives a database reopen. A write from an old version fails, and the sweep finds the due room. +3. Two submissions for one turn produce one accepted move and one `stale_turn` rejection. +4. A move from the wrong player rejects with `not_your_turn`. +5. A caller cannot submit a move for another player. +6. After a runtime restart, a due reminder skips turn zero and makes the second player current. +7. After a move, a sweep before the next deadline leaves the turn unchanged and records no skipped turn. +8. A caller cannot invoke `turnTimeout` directly. +9. After a restart, a room member receives the current room, its move, and `changed: true`. +10. A player outside the room receives `null` from `resumeRoom`. + +The seventh test does not inject a late or repeated timer. The turn number check in `turnTimeout` handles that case. + +## Limits + +Delivery is at least once. Write each operation so that it can run again. One room runs one write at a time. This fits turn-based play, not a high-frequency game loop or fast real-time physics. + +The application owns the WebSocket server, authentication, and the display of the room. There are no transactions across two rooms. The package requires Node.js 24.4 or newer and ESM only. The package is pre-1.0. + +See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md). + +## Sources + +Socket.IO, [Connection state recovery](https://socket.io/docs/v4/connection-state-recovery), checked October 9, 2026. diff --git a/docs/guides/race-conditions.md b/docs/guides/race-conditions.md new file mode 100644 index 0000000..007a93a --- /dev/null +++ b/docs/guides/race-conditions.md @@ -0,0 +1,358 @@ +# Prevent race conditions in Node.js + +A race condition in Node.js usually involves a read-modify-write sequence that spans an `await`. Fix most races in the database with one conditional statement, a constraint, or a transaction. Most applications do not need an actor library for this. Use one actor per contested identity when commands also come from jobs and reminders and must survive restarts. + +## The failure: a lost update across an await + +Node.js runs one callback at a time, but every `await` lets another request run. Two requests can read the same value before either request writes. The sequence from the first read to the final write does not run as one unit. + +The example stores the available seat count in `seats` and each hold in `holds`. The test gives an event one available seat. + +```typescript +import { DatabaseSync } from "node:sqlite" + +export function openSeatInventory({ + path, + eventId, + available, +}: { + path: string + eventId: string + available: number +}): DatabaseSync { + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE IF NOT EXISTS seats (event_id TEXT PRIMARY KEY, available INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS holds (event_id TEXT NOT NULL, buyer TEXT NOT NULL); + `) + database + .prepare("INSERT OR IGNORE INTO seats (event_id, available) VALUES (?, ?)") + .run(eventId, available) + return database +} + +export function seatCounts({ database, eventId }: { database: DatabaseSync; eventId: string }): { + available: number + holds: number +} { + const seats = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as { + available: number + } + const holds = database + .prepare("SELECT COUNT(*) AS count FROM holds WHERE event_id = ?") + .get(eventId) as { + count: number + } + return { available: seats.available, holds: holds.count } +} +``` + +The operation reads the count, then pauses at `await checkBuyer()`. Another request reads the same count during that pause. Each request subtracts one from its own copy of the count. Each request writes zero and inserts a hold. + +```typescript +import type { DatabaseSync } from "node:sqlite" + +export async function holdSeatWithRace({ + database, + eventId, + buyer, + checkBuyer, +}: { + database: DatabaseSync + eventId: string + buyer: string + checkBuyer: () => Promise +}): Promise<{ held: boolean }> { + const row = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as { + available: number + } + if (row.available === 0) return { held: false } + + await checkBuyer() + + database + .prepare("UPDATE seats SET available = ? WHERE event_id = ?") + .run(row.available - 1, eventId) + database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer) + return { held: true } +} + +export function createLocalMutex(): { run(task: () => Promise): Promise } { + let tail: Promise = Promise.resolve() + return { + run(task: () => Promise): Promise { + const result = tail.then(task) + tail = result.catch(() => undefined) + return result + }, + } +} +``` + +The test starts with one seat left. Two concurrent calls to `holdSeatWithRace` both return `held: true`, and the table records two holds. The available count still reads zero. The count alone therefore does not reveal the extra hold. + +## Why a mutex in one process does not help + +A mutex makes calls wait for their turn. `createLocalMutex` serializes calls that share that mutex inside one process. It can protect a sequence that spans an `await` within that process. + +Production can run several processes or containers. Each process has its own mutex, so a mutex in one process does not stop a call in another process. + +The mutex test models two processes with two database connections and two mutexes. Both calls take the last seat. The test uses separate connections and mutexes within one test process. The actor test below uses two actual processes. + +## Start with the database + +The database can enforce a rule at the point where data changes. This often solves the whole problem. Choose the smallest database operation that enforces the rule. + +| Problem | Start with | When an actor becomes relevant | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Duplicate records | A unique constraint | A larger lifecycle also needs ordered, durable work. | +| Concurrent decrement of a counter or stock | One conditional `UPDATE` in a transaction | The change forms part of holds, expiry, retries, and later commands. | +| Two people edit an old form | A revision column that the `UPDATE` checks | The record also receives commands from jobs and reminders. | +| Several rows change in one request | One transaction with row locks (`SELECT ... FOR UPDATE` in PostgreSQL) | Work must continue after the transaction and survive failures. | +| Commands for one identity arrive from requests, jobs, and reminders | An explicit coordination design | This is the main case for an actor. | + +The database fix keeps the availability check and the decrement in one conditional `UPDATE`. The statement changes the count only when `available > 0`. The operation inserts a hold only when the statement changes a row. + +```typescript +import type { DatabaseSync } from "node:sqlite" + +export function holdSeatAtomically({ + database, + eventId, + buyer, +}: { + database: DatabaseSync + eventId: string + buyer: string +}): { held: boolean } { + database.exec("BEGIN IMMEDIATE") + try { + const result = database + .prepare("UPDATE seats SET available = available - 1 WHERE event_id = ? AND available > 0") + .run(eventId) + if (result.changes === 0) { + database.exec("ROLLBACK") + return { held: false } + } + database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer) + database.exec("COMMIT") + return { held: true } + } catch (error) { + database.exec("ROLLBACK") + throw error + } +} +``` + +In SQLite, `BEGIN IMMEDIATE` starts the write transaction at once, before the first write statement. See the [SQLite transaction documentation](https://www.sqlite.org/lang_transaction.html). The transaction makes the conditional `UPDATE` and the insert commit together. If the operation fails, it rolls back the transaction. If the statement changes no row, the operation returns `held: false`. + +In PostgreSQL or MySQL, the conditional `UPDATE ... WHERE available > 0` gives the same guarantee for the decrement in one statement. Keep the separate hold insert in the same transaction. + +The test uses two connections to try the last seat. One hold succeeds, one fails, and the database records one hold with zero seats available. This test calls the synchronous operations in sequence across the two connections. + +## When one statement is not enough + +A seat hold has a lifecycle beyond the first request. A request holds a seat, a payment job confirms it, and a timer releases it after 10 minutes. These three sources can race. The process can restart before the timer fires. + +The database fix protects the initial decrement. The application still needs rules for later commands and work that survives a restart. Solid Objects becomes relevant when one identity owns that state and needs ordered, durable work from all three sources. + +## One actor owns the seats for one event + +`EventSeats` keeps the capacity, holds, and sales for one event. It calculates availability from that state. + +```typescript +import { Actor } from "solid-objects" + +export const HOLD_MILLISECONDS = 10 * 60 * 1000 + +export type EventCaller = { userId: string; role: "buyer" | "organizer" | "system" } + +type Hold = { buyer: string; expiresAt: number } + +export class EventSeats extends Actor { + static override readonly actorType = "EventSeats" + + capacity = 0 + revision = 0 + holds: Record = {} + sold: Record = {} + + get available(): number { + return this.capacity - Object.keys(this.holds).length - Object.keys(this.sold).length + } + + setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): { + revision: number + } { + if (expectedRevision !== this.revision) { + this.reject("stale_revision", { + message: "The event changed after you loaded it", + details: { revision: this.revision }, + }) + } + if (capacity < this.capacity - this.available) { + this.reject("capacity_below_sold", { message: "Holds and sales already use more seats" }) + } + this.capacity = capacity + this.revision += 1 + return { revision: this.revision } + } + + hold({ holdId, buyer }: { holdId: string; buyer: string }): { held: boolean; available: number } { + if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) { + return { held: true, available: this.available } + } + if (this.available === 0) return { held: false, available: 0 } + + const expiresAt = Date.now() + HOLD_MILLISECONDS + this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } } + this.schedule({ at: new Date(expiresAt), key: holdId }).expire({ holdId }) + return { held: true, available: this.available } + } + + confirm({ holdId }: { holdId: string }): { confirmed: boolean } { + if (Object.hasOwn(this.sold, holdId)) return { confirmed: true } + const hold = this.holds[holdId] + if (!hold) { + this.reject("hold_expired", { message: "The hold expired before the payment arrived" }) + } + + this.holds = withoutKey(this.holds, holdId) + this.sold = { ...this.sold, [holdId]: hold.buyer } + this.unschedule("expire", { key: holdId }) + return { confirmed: true } + } + + expire({ holdId }: { holdId: string }): { expired: boolean } { + if (!Object.hasOwn(this.holds, holdId)) return { expired: false } + + this.holds = withoutKey(this.holds, holdId) + return { expired: true } + } +} + +function withoutKey(record: Record, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key)) +} +``` + +The actor identity is the event because the event owns the shared availability. One actor per hold still lets two holds take the same seat. Both holds need the same owner for the count. + +Calls for one event run one at a time, in order, across processes. Different events can run at the same time. The boundary follows the event, regardless of which process receives a request. + +`hold` checks `holdId` in both `holds` and `sold` before it takes another seat. A repeat for a recorded hold or sale returns `held: true` without another decrement. The repeated call in the test returns the same answer. The `available` getter reports the current count. + +`schedule({ at, key: holdId }).expire({ holdId })` sets one reminder per hold. The reminder is durable and runs after a restart when the runtime resumes. Its due time is 10 minutes after the hold starts. + +`confirm` moves the hold into `sold`. It cancels the reminder with `unschedule("expire", { key: holdId })`. Cancellation cannot recall a reminder that already became a message. `expire` does nothing when the hold is gone, so a late or repeated reminder is harmless. + +`confirm` rejects an expired hold with the code `hold_expired`. Expiry changes the state when `expire` runs. The passage of 10 minutes alone does not remove the hold. + +## Order alone does not stop a stale write + +An old form can overwrite newer data even when calls run in order. The calls run in order, but the second call still carries old data. The actor needs a rule that detects this conflict. + +`setCapacity` takes `expectedRevision` and compares it with `revision`. A mismatch rejects the change with `stale_revision`. An accepted capacity change increases the revision. + +The test accepts a change from 10 seats to 12 seats. An old form then requests eight seats with the previous revision. The actor rejects that request and keeps the capacity at 12. + +Keep revision checks or domain operations inside the actor. Order controls when a command runs. The operation still decides whether its arguments permit a valid change. + +## Authorize every caller + +All authorization callbacks deny by default. The example supplies policies for messages and queries. + +```typescript +import { createRuntime, type SolidObjectsRuntime } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" +import { EventSeats, type EventCaller } from "./event-seats.js" + +const SYSTEM_OPERATIONS = new Set(["confirm", "expire"]) + +export function authorizeEventMessage({ + actorType, + operation, + authorizationContext, +}: { + actorType: string + operation: string + authorizationContext: unknown +}): boolean { + if (actorType !== EventSeats.actorType) return false + if (!isEventCaller(authorizationContext)) return false + if (operation === "setCapacity") return authorizationContext.role === "organizer" + if (SYSTEM_OPERATIONS.has(operation)) return authorizationContext.role === "system" + return true +} + +export function authorizeEventQuery({ + actorType, + authorizationContext, +}: { + actorType: string + authorizationContext: unknown +}): boolean { + return actorType === EventSeats.actorType && isEventCaller(authorizationContext) +} + +export function eventRuntime({ path }: { path: string }): SolidObjectsRuntime { + const runtime = createRuntime({ + database: sqlite({ path }), + authorizeMessage: authorizeEventMessage, + authorizeQuery: authorizeEventQuery, + }) + runtime.register(EventSeats) + return runtime +} + +function isEventCaller(value: unknown): value is EventCaller { + if (typeof value !== "object" || value === null) return false + if (!("userId" in value) || typeof value.userId !== "string") return false + if (!("role" in value)) return false + return value.role === "buyer" || value.role === "organizer" || value.role === "system" +} +``` + +The policy limits access to the `EventSeats` actor type and checks the caller context. Only an organizer can change capacity. Calls to `confirm` or `expire` require the `system` role, which the example assigns to the payment job. A reminder runs inside the runtime and needs no caller policy. + +The example checks the role but does not bind a caller to a particular event. Bind the actor type and identity to the authenticated user or tenant in a production policy. An actor identity does not grant permission. + +Pass the caller to `snapshot()` as `snapshot({ authorizationContext })`. For operation calls, pass the caller through `.with({ authorizationContext })`. A snapshot and an operation use different entry points for the context. + +## Run the runtime process + +Reminders, effects, and messages to other actors run only while a process calls `runtime.run(signal)`. Register each actor class before `run`. The example registers `EventSeats` when it creates the runtime. + +When no process runs, committed work waits in the database. It runs later when a runtime process resumes. See [step 7 of the agent guide](../agents.md#7-run-the-background-roles). + +In tests, call `runtime.testing.runDueReminders({ now })` to put due reminders in the queue. Then call `runtime.testing.drain({ roles: ["actors"] })` to run that work. These calls test expiry without a wait for the real deadline. + +## What the tests prove + +The [test file](../../test/guide-race-conditions.test.ts) checks these outcomes: + +- Two processes attempt eight holds at the same time against a capacity of three. Only three holds succeed. +- A repeated hold applies once and leaves the same available count. +- A hold expires after a runtime restart when its reminder is due. +- A late expiry after a confirmation changes nothing. +- A confirmation after expiry rejects with `hold_expired`. +- A capacity change from a stale form rejects with `stale_revision`. +- A buyer cannot change capacity. + +The assertions check both results and stored state. They show the behavior of these examples and their conflict cases. + +## Limits and costs + +Delivery is at least once. Write each operation so that it can run again. The runtime can retry work after a failure. + +An external call, such as a payment or an email, belongs in an effect. Use the stable effect ID `context.id` as the provider idempotency key. This key lets the provider identify a repeat of the same effect. See [step 8 of the agent guide](../agents.md#8-make-external-effects-idempotent). + +There are no transactions across two actor identities. One identity runs one write at a time. An event with many writes therefore has a throughput limit. + +Each operation adds a message row. The runtime keeps completed messages for 30 days by default through `messageRetentionMilliseconds`. See [Configuration](../configuration.md#retention-and-cleanup) for retention and cleanup controls. + +The package requires Node.js 24.4 or newer and supports ESM only. The package is pre-1.0. See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md). + +## Sources + +[SQLite transaction documentation, BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html), checked October 9, 2026. diff --git a/examples/guides/offline-first/inspection-worker.js b/examples/guides/offline-first/inspection-worker.js new file mode 100644 index 0000000..a8d1cde --- /dev/null +++ b/examples/guides/offline-first/inspection-worker.js @@ -0,0 +1,59 @@ +import { NonRetryableError } from "solid-objects/core" +import { Actor, configure, registerTransmit, sharedSqliteWasm } from "solid-objects/browser/host" + +class Inspection extends Actor { + static actorType = "Inspection" + + findings = [] + + record({ findingId, note }) { + if (this.findings.some((finding) => finding.findingId === findingId)) { + return this.findings.length + } + this.findings = [...this.findings, { findingId, note }] + this.transmit().record({ findingId, note }) + return this.findings.length + } +} + +const allowInspections = ({ actorType }) => actorType === "Inspection" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "inspections.db" }), + authorizeMessage: allowInspections, + authorizeQuery: allowInspections, + maxAttempts: 1_000, + retryDelayMilliseconds: (attempt) => Math.min(2 ** (attempt - 1), 30) * 1_000, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) + +registerTransmit({ + runtime, + deliver: async (envelope) => { + const response = await fetch("/inspections/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }) + if (response.status === 422) { + throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`) + } + if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`) + }, +}) +runtime.register(Inspection) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await Inspection.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} diff --git a/examples/guides/offline-first/memory-outbox.js b/examples/guides/offline-first/memory-outbox.js new file mode 100644 index 0000000..d31c3c3 --- /dev/null +++ b/examples/guides/offline-first/memory-outbox.js @@ -0,0 +1,16 @@ +let pending = [] + +export function queueFinding(finding) { + pending = [...pending, finding] +} + +export function pendingFindings() { + return pending.length +} + +export async function flushFindings({ send }) { + while (pending.length > 0) { + await send(pending[0]) + pending = pending.slice(1) + } +} diff --git a/examples/guides/offline-first/page.js b/examples/guides/offline-first/page.js new file mode 100644 index 0000000..5214fb3 --- /dev/null +++ b/examples/guides/offline-first/page.js @@ -0,0 +1,30 @@ +const worker = new Worker(new URL("./inspection-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 +let workerFailure + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + +export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} diff --git a/examples/guides/offline-first/server-inspection.ts b/examples/guides/offline-first/server-inspection.ts new file mode 100644 index 0000000..bcf47aa --- /dev/null +++ b/examples/guides/offline-first/server-inspection.ts @@ -0,0 +1,17 @@ +import { Actor } from "solid-objects" + +type Finding = { findingId: string; note: string } + +export class Inspection extends Actor { + static override readonly actorType = "Inspection" + + findings: Finding[] = [] + + record({ findingId, note }: Finding): number { + if (this.findings.some((finding) => finding.findingId === findingId)) { + return this.findings.length + } + this.findings = [...this.findings, { findingId, note }] + return this.findings.length + } +} diff --git a/examples/guides/offline-first/sync-route.ts b/examples/guides/offline-first/sync-route.ts new file mode 100644 index 0000000..a2cb7e2 --- /dev/null +++ b/examples/guides/offline-first/sync-route.ts @@ -0,0 +1,30 @@ +import { + IdempotencyConflict, + InvalidPayload, + receiveTransmitEnvelope, + type SolidObjectsRuntime, + type TransmitEnvelope, +} from "solid-objects" + +export async function handleInspectionSync({ + request, + runtime, + canWrite, +}: { + request: Request + runtime: SolidObjectsRuntime + canWrite: (options: { request: Request; envelope: TransmitEnvelope }) => Promise +}): Promise { + const envelope = (await request.json()) as TransmitEnvelope + if (!(await canWrite({ request, envelope }))) return new Response("Forbidden", { status: 403 }) + + try { + await receiveTransmitEnvelope({ runtime, envelope }) + return Response.json({}) + } catch (error) { + if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) { + return new Response(null, { status: 422 }) + } + throw error + } +} diff --git a/examples/guides/persistent-rooms/memory-room.ts b/examples/guides/persistent-rooms/memory-room.ts new file mode 100644 index 0000000..aea607e --- /dev/null +++ b/examples/guides/persistent-rooms/memory-room.ts @@ -0,0 +1,44 @@ +type MemoryRoom = { players: string[]; turnNumber: number; started: boolean } + +export function createMemoryRooms({ turnMilliseconds }: { turnMilliseconds: number }) { + const rooms = new Map() + const timers = new Map>() + + function armTurnTimer(roomId: string): void { + clearTimeout(timers.get(roomId)) + timers.set( + roomId, + setTimeout(() => skipTurn(roomId), turnMilliseconds), + ) + } + + function skipTurn(roomId: string): void { + const room = rooms.get(roomId) + if (!room) return + rooms.set(roomId, { ...room, turnNumber: room.turnNumber + 1 }) + armTurnTimer(roomId) + } + + return { + join({ roomId, playerId }: { roomId: string; playerId: string }): void { + const room = rooms.get(roomId) ?? { players: [], turnNumber: 0, started: false } + rooms.set(roomId, { ...room, players: [...room.players, playerId] }) + }, + start({ roomId }: { roomId: string }): void { + const room = rooms.get(roomId) + if (!room) return + rooms.set(roomId, { ...room, started: true }) + armTurnTimer(roomId) + }, + room({ roomId }: { roomId: string }): MemoryRoom | undefined { + return rooms.get(roomId) + }, + pendingTimers(): number { + return timers.size + }, + close(): void { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + }, + } +} diff --git a/examples/guides/persistent-rooms/reconnect.ts b/examples/guides/persistent-rooms/reconnect.ts new file mode 100644 index 0000000..685b358 --- /dev/null +++ b/examples/guides/persistent-rooms/reconnect.ts @@ -0,0 +1,18 @@ +import type { ActorSnapshot, SolidObjectsRuntime } from "solid-objects" +import { TurnRoom } from "./turn-room.js" + +export async function resumeRoom({ + runtime, + roomId, + playerId, + lastSeenRevision, +}: { + runtime: SolidObjectsRuntime + roomId: string + playerId: string + lastSeenRevision: number +}): Promise<{ room: ActorSnapshot; changed: boolean } | null> { + const room = await runtime.ref(TurnRoom, roomId).snapshot({ authorizationContext: { playerId } }) + if (!room.players.includes(playerId)) return null + return { room, changed: room.revision !== lastSeenRevision } +} diff --git a/examples/guides/persistent-rooms/room-runtime.ts b/examples/guides/persistent-rooms/room-runtime.ts new file mode 100644 index 0000000..facde4f --- /dev/null +++ b/examples/guides/persistent-rooms/room-runtime.ts @@ -0,0 +1,49 @@ +import { createRuntime, type SolidObjectsRuntime } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" +import { TurnRoom } from "./turn-room.js" + +export type RoomCaller = { playerId: string } + +const PLAYER_OPERATIONS = new Set(["join", "start", "play"]) + +export function authorizeRoomMessage({ + actorType, + operation, + arguments: argumentsValue, + authorizationContext, +}: { + actorType: string + operation: string + arguments: Record + authorizationContext: unknown +}): boolean { + if (actorType !== TurnRoom.actorType) return false + if (!PLAYER_OPERATIONS.has(operation)) return false + if (!isRoomCaller(authorizationContext)) return false + return argumentsValue.playerId === authorizationContext.playerId +} + +export function authorizeRoomQuery({ + actorType, + authorizationContext, +}: { + actorType: string + authorizationContext: unknown +}): boolean { + return actorType === TurnRoom.actorType && isRoomCaller(authorizationContext) +} + +export function roomRuntime({ path }: { path: string }): SolidObjectsRuntime { + const runtime = createRuntime({ + database: sqlite({ path }), + authorizeMessage: authorizeRoomMessage, + authorizeQuery: authorizeRoomQuery, + }) + runtime.register(TurnRoom) + return runtime +} + +function isRoomCaller(value: unknown): value is RoomCaller { + if (typeof value !== "object" || value === null) return false + return "playerId" in value && typeof value.playerId === "string" +} diff --git a/examples/guides/persistent-rooms/sql-room.ts b/examples/guides/persistent-rooms/sql-room.ts new file mode 100644 index 0000000..3d1240f --- /dev/null +++ b/examples/guides/persistent-rooms/sql-room.ts @@ -0,0 +1,69 @@ +import { DatabaseSync } from "node:sqlite" + +export function openRoomStore({ path }: { path: string }): DatabaseSync { + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + state TEXT NOT NULL, + version INTEGER NOT NULL, + turn_deadline INTEGER + ) + `) + return database +} + +export function saveRoom({ + database, + roomId, + state, + expectedVersion, + turnDeadline, +}: { + database: DatabaseSync + roomId: string + state: unknown + expectedVersion: number + turnDeadline: number | null +}): { saved: boolean } { + if (expectedVersion === 0) { + const inserted = database + .prepare( + "INSERT OR IGNORE INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, ?)", + ) + .run(roomId, JSON.stringify(state), turnDeadline) + return { saved: inserted.changes === 1 } + } + const updated = database + .prepare( + "UPDATE rooms SET state = ?, version = version + 1, turn_deadline = ? WHERE room_id = ? AND version = ?", + ) + .run(JSON.stringify(state), turnDeadline, roomId, expectedVersion) + return { saved: updated.changes === 1 } +} + +export function loadRoom({ + database, + roomId, +}: { + database: DatabaseSync + roomId: string +}): { state: unknown; version: number } | undefined { + const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as + { state: string; version: number } | undefined + if (!row) return undefined + return { state: JSON.parse(row.state), version: row.version } +} + +export function dueTurnDeadlines({ + database, + now, +}: { + database: DatabaseSync + now: number +}): string[] { + const rows = database + .prepare("SELECT room_id FROM rooms WHERE turn_deadline <= ? ORDER BY turn_deadline") + .all(now) as { room_id: string }[] + return rows.map((row) => row.room_id) +} diff --git a/examples/guides/persistent-rooms/turn-room.ts b/examples/guides/persistent-rooms/turn-room.ts new file mode 100644 index 0000000..0c495cb --- /dev/null +++ b/examples/guides/persistent-rooms/turn-room.ts @@ -0,0 +1,85 @@ +import { Actor } from "solid-objects" + +export const TURN_MILLISECONDS = 60_000 + +const MAXIMUM_PLAYERS = 4 + +type Move = { playerId: string; move: string; turnNumber: number } + +export class TurnRoom extends Actor { + static override readonly actorType = "TurnRoom" + + players: string[] = [] + started = false + turnNumber = 0 + moves: Move[] = [] + skippedTurns: number[] = [] + revision = 0 + + get currentPlayer(): string | null { + if (!this.started) return null + return this.players[this.turnNumber % this.players.length] ?? null + } + + join({ playerId }: { playerId: string }): { players: string[] } { + if (this.players.includes(playerId)) return { players: this.players } + if (this.started) this.reject("game_started", { message: "The game already started" }) + if (this.players.length === MAXIMUM_PLAYERS) { + this.reject("room_full", { message: "The room is full" }) + } + this.players = [...this.players, playerId] + this.revision += 1 + return { players: this.players } + } + + start({ playerId }: { playerId: string }): { turnNumber: number } { + if (this.started) return { turnNumber: this.turnNumber } + if (!this.players.includes(playerId)) { + this.reject("not_in_room", { message: "Only a player in the room can start the game" }) + } + if (this.players.length < 2) { + this.reject("not_enough_players", { message: "The game needs two players" }) + } + this.started = true + this.revision += 1 + this.#armTurnTimer() + return { turnNumber: this.turnNumber } + } + + play({ playerId, move, turnNumber }: { playerId: string; move: string; turnNumber: number }): { + turnNumber: number + } { + if (!this.started) this.reject("game_not_started", { message: "The game has not started" }) + if (turnNumber !== this.turnNumber) { + this.reject("stale_turn", { + message: "That turn already ended", + details: { turnNumber: this.turnNumber }, + }) + } + if (playerId !== this.currentPlayer) { + this.reject("not_your_turn", { message: "It is not your turn" }) + } + this.moves = [...this.moves, { playerId, move, turnNumber }] + this.#advanceTurn() + return { turnNumber: this.turnNumber } + } + + turnTimeout({ turnNumber }: { turnNumber: number }): { skipped: boolean } { + if (turnNumber !== this.turnNumber) return { skipped: false } + this.skippedTurns = [...this.skippedTurns, turnNumber] + this.#advanceTurn() + return { skipped: true } + } + + #advanceTurn(): void { + this.turnNumber += 1 + this.revision += 1 + this.#armTurnTimer() + } + + #armTurnTimer(): void { + this.schedule({ at: new Date(Date.now() + TURN_MILLISECONDS) }).turnTimeout({ + turnNumber: this.turnNumber, + }) + } +} diff --git a/examples/guides/race-conditions/atomic-hold.ts b/examples/guides/race-conditions/atomic-hold.ts new file mode 100644 index 0000000..bed0417 --- /dev/null +++ b/examples/guides/race-conditions/atomic-hold.ts @@ -0,0 +1,28 @@ +import type { DatabaseSync } from "node:sqlite" + +export function holdSeatAtomically({ + database, + eventId, + buyer, +}: { + database: DatabaseSync + eventId: string + buyer: string +}): { held: boolean } { + database.exec("BEGIN IMMEDIATE") + try { + const result = database + .prepare("UPDATE seats SET available = available - 1 WHERE event_id = ? AND available > 0") + .run(eventId) + if (result.changes === 0) { + database.exec("ROLLBACK") + return { held: false } + } + database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer) + database.exec("COMMIT") + return { held: true } + } catch (error) { + database.exec("ROLLBACK") + throw error + } +} diff --git a/examples/guides/race-conditions/event-runtime.ts b/examples/guides/race-conditions/event-runtime.ts new file mode 100644 index 0000000..2177ec1 --- /dev/null +++ b/examples/guides/race-conditions/event-runtime.ts @@ -0,0 +1,48 @@ +import { createRuntime, type SolidObjectsRuntime } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" +import { EventSeats, type EventCaller } from "./event-seats.js" + +const SYSTEM_OPERATIONS = new Set(["confirm", "expire"]) + +export function authorizeEventMessage({ + actorType, + operation, + authorizationContext, +}: { + actorType: string + operation: string + authorizationContext: unknown +}): boolean { + if (actorType !== EventSeats.actorType) return false + if (!isEventCaller(authorizationContext)) return false + if (operation === "setCapacity") return authorizationContext.role === "organizer" + if (SYSTEM_OPERATIONS.has(operation)) return authorizationContext.role === "system" + return true +} + +export function authorizeEventQuery({ + actorType, + authorizationContext, +}: { + actorType: string + authorizationContext: unknown +}): boolean { + return actorType === EventSeats.actorType && isEventCaller(authorizationContext) +} + +export function eventRuntime({ path }: { path: string }): SolidObjectsRuntime { + const runtime = createRuntime({ + database: sqlite({ path }), + authorizeMessage: authorizeEventMessage, + authorizeQuery: authorizeEventQuery, + }) + runtime.register(EventSeats) + return runtime +} + +function isEventCaller(value: unknown): value is EventCaller { + if (typeof value !== "object" || value === null) return false + if (!("userId" in value) || typeof value.userId !== "string") return false + if (!("role" in value)) return false + return value.role === "buyer" || value.role === "organizer" || value.role === "system" +} diff --git a/examples/guides/race-conditions/event-seats.ts b/examples/guides/race-conditions/event-seats.ts new file mode 100644 index 0000000..1f2af3b --- /dev/null +++ b/examples/guides/race-conditions/event-seats.ts @@ -0,0 +1,73 @@ +import { Actor } from "solid-objects" + +export const HOLD_MILLISECONDS = 10 * 60 * 1000 + +export type EventCaller = { userId: string; role: "buyer" | "organizer" | "system" } + +type Hold = { buyer: string; expiresAt: number } + +export class EventSeats extends Actor { + static override readonly actorType = "EventSeats" + + capacity = 0 + revision = 0 + holds: Record = {} + sold: Record = {} + + get available(): number { + return this.capacity - Object.keys(this.holds).length - Object.keys(this.sold).length + } + + setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): { + revision: number + } { + if (expectedRevision !== this.revision) { + this.reject("stale_revision", { + message: "The event changed after you loaded it", + details: { revision: this.revision }, + }) + } + if (capacity < this.capacity - this.available) { + this.reject("capacity_below_sold", { message: "Holds and sales already use more seats" }) + } + this.capacity = capacity + this.revision += 1 + return { revision: this.revision } + } + + hold({ holdId, buyer }: { holdId: string; buyer: string }): { held: boolean; available: number } { + if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) { + return { held: true, available: this.available } + } + if (this.available === 0) return { held: false, available: 0 } + + const expiresAt = Date.now() + HOLD_MILLISECONDS + this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } } + this.schedule({ at: new Date(expiresAt), key: holdId }).expire({ holdId }) + return { held: true, available: this.available } + } + + confirm({ holdId }: { holdId: string }): { confirmed: boolean } { + if (Object.hasOwn(this.sold, holdId)) return { confirmed: true } + const hold = this.holds[holdId] + if (!hold) { + this.reject("hold_expired", { message: "The hold expired before the payment arrived" }) + } + + this.holds = withoutKey(this.holds, holdId) + this.sold = { ...this.sold, [holdId]: hold.buyer } + this.unschedule("expire", { key: holdId }) + return { confirmed: true } + } + + expire({ holdId }: { holdId: string }): { expired: boolean } { + if (!Object.hasOwn(this.holds, holdId)) return { expired: false } + + this.holds = withoutKey(this.holds, holdId) + return { expired: true } + } +} + +function withoutKey(record: Record, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key)) +} diff --git a/examples/guides/race-conditions/lost-update.ts b/examples/guides/race-conditions/lost-update.ts new file mode 100644 index 0000000..fce1376 --- /dev/null +++ b/examples/guides/race-conditions/lost-update.ts @@ -0,0 +1,37 @@ +import type { DatabaseSync } from "node:sqlite" + +export async function holdSeatWithRace({ + database, + eventId, + buyer, + checkBuyer, +}: { + database: DatabaseSync + eventId: string + buyer: string + checkBuyer: () => Promise +}): Promise<{ held: boolean }> { + const row = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as { + available: number + } + if (row.available === 0) return { held: false } + + await checkBuyer() + + database + .prepare("UPDATE seats SET available = ? WHERE event_id = ?") + .run(row.available - 1, eventId) + database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer) + return { held: true } +} + +export function createLocalMutex(): { run(task: () => Promise): Promise } { + let tail: Promise = Promise.resolve() + return { + run(task: () => Promise): Promise { + const result = tail.then(task) + tail = result.catch(() => undefined) + return result + }, + } +} diff --git a/examples/guides/race-conditions/seat-inventory.ts b/examples/guides/race-conditions/seat-inventory.ts new file mode 100644 index 0000000..237780e --- /dev/null +++ b/examples/guides/race-conditions/seat-inventory.ts @@ -0,0 +1,36 @@ +import { DatabaseSync } from "node:sqlite" + +export function openSeatInventory({ + path, + eventId, + available, +}: { + path: string + eventId: string + available: number +}): DatabaseSync { + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE IF NOT EXISTS seats (event_id TEXT PRIMARY KEY, available INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS holds (event_id TEXT NOT NULL, buyer TEXT NOT NULL); + `) + database + .prepare("INSERT OR IGNORE INTO seats (event_id, available) VALUES (?, ?)") + .run(eventId, available) + return database +} + +export function seatCounts({ database, eventId }: { database: DatabaseSync; eventId: string }): { + available: number + holds: number +} { + const seats = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as { + available: number + } + const holds = database + .prepare("SELECT COUNT(*) AS count FROM holds WHERE event_id = ?") + .get(eventId) as { + count: number + } + return { available: seats.available, holds: holds.count } +} diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index 8ad036a..a034da5 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -10,6 +10,10 @@ const documentationPaths = [ .filter((name) => name.endsWith(".md")) .sort() .map((name) => `docs/${name}`), + ...(await readdir(resolve(repositoryRoot, "docs/guides"))) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => `docs/guides/${name}`), ] for (const documentationPath of documentationPaths) { @@ -52,6 +56,81 @@ const embeddedExamples = [ example: "examples/browser/page.js", language: "javascript", }, + { + documentation: "docs/guides/race-conditions.md", + example: "examples/guides/race-conditions/seat-inventory.ts", + language: "typescript", + }, + { + documentation: "docs/guides/race-conditions.md", + example: "examples/guides/race-conditions/lost-update.ts", + language: "typescript", + }, + { + documentation: "docs/guides/race-conditions.md", + example: "examples/guides/race-conditions/atomic-hold.ts", + language: "typescript", + }, + { + documentation: "docs/guides/race-conditions.md", + example: "examples/guides/race-conditions/event-seats.ts", + language: "typescript", + }, + { + documentation: "docs/guides/race-conditions.md", + example: "examples/guides/race-conditions/event-runtime.ts", + language: "typescript", + }, + { + documentation: "docs/guides/persistent-rooms.md", + example: "examples/guides/persistent-rooms/memory-room.ts", + language: "typescript", + }, + { + documentation: "docs/guides/persistent-rooms.md", + example: "examples/guides/persistent-rooms/sql-room.ts", + language: "typescript", + }, + { + documentation: "docs/guides/persistent-rooms.md", + example: "examples/guides/persistent-rooms/turn-room.ts", + language: "typescript", + }, + { + documentation: "docs/guides/persistent-rooms.md", + example: "examples/guides/persistent-rooms/room-runtime.ts", + language: "typescript", + }, + { + documentation: "docs/guides/persistent-rooms.md", + example: "examples/guides/persistent-rooms/reconnect.ts", + language: "typescript", + }, + { + documentation: "docs/guides/offline-first.md", + example: "examples/guides/offline-first/memory-outbox.js", + language: "javascript", + }, + { + documentation: "docs/guides/offline-first.md", + example: "examples/guides/offline-first/inspection-worker.js", + language: "javascript", + }, + { + documentation: "docs/guides/offline-first.md", + example: "examples/guides/offline-first/page.js", + language: "javascript", + }, + { + documentation: "docs/guides/offline-first.md", + example: "examples/guides/offline-first/server-inspection.ts", + language: "typescript", + }, + { + documentation: "docs/guides/offline-first.md", + example: "examples/guides/offline-first/sync-route.ts", + language: "typescript", + }, ] for (const { documentation, example, language } of embeddedExamples) { const documentationSource = await readFile(resolve(repositoryRoot, documentation), "utf8") @@ -61,6 +140,13 @@ for (const { documentation, example, language } of embeddedExamples) { } } +for (const documentationPath of documentationPaths.filter((path) => + path.startsWith("docs/guides/"), +)) { + const source = await readFile(resolve(repositoryRoot, documentationPath), "utf8") + if (source.includes("\u2014")) throw new Error(`${documentationPath} contains an em dash`) +} + const publicApi = await readFile(resolve(repositoryRoot, "docs/api.md"), "utf8") const configurationReference = await readFile( resolve(repositoryRoot, "docs/configuration.md"), diff --git a/test/browser-server.mjs b/test/browser-server.mjs index f19d1ec..b1a4d0d 100644 --- a/test/browser-server.mjs +++ b/test/browser-server.mjs @@ -10,6 +10,7 @@ const sqliteWasmRoot = resolve(import.meta.dirname, "../node_modules/@sqlite.org const signalPolyfillRoot = resolve(import.meta.dirname, "../node_modules/signal-polyfill/dist") const browserFixtureRoot = resolve(import.meta.dirname, "browser") const browserExampleRoot = resolve(import.meta.dirname, "../examples/browser") +const offlineGuideRoot = resolve(import.meta.dirname, "../examples/guides/offline-first") const contentTypes = { ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", @@ -30,6 +31,17 @@ class TransmitCounter extends Actor { return this.count } } +class ServerInspection extends Actor { + static actorType = "Inspection" + findings = [] + record({ findingId, note }) { + if (this.findings.some((finding) => finding.findingId === findingId)) + return this.findings.length + this.findings = [...this.findings, { findingId, note }] + return this.findings.length + } +} +let inspectionsOnline = true const runtime = configure({ database: sqlite({ path: ":memory:" }), authorizeMessage: () => true, @@ -38,6 +50,7 @@ const runtime = configure({ }) runtime.register(DashboardBrowserActor) runtime.register(TransmitCounter) +runtime.register(ServerInspection) await runtime.install() await DashboardBrowserActor.ref("browser-room").increment() const sessionValues = new Map() @@ -100,6 +113,27 @@ const server = createServer(async (request, response) => { } return } + if (pathname === "/inspections/network" && request.method === "POST") { + inspectionsOnline = + new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("online") === "true" + response.writeHead(200, { "content-type": "application/json" }) + response.end("{}") + return + } + if (pathname === "/inspections/sync" && request.method === "POST") { + await handleInspectionSync({ request, response }) + return + } + if (pathname === "/inspections/state") { + const actorId = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("actorId") + const snapshot = await runtime + .ref(ServerInspection, actorId ?? "missing") + .snapshot() + .catch(() => ({ findings: [] })) + response.writeHead(200, { "content-type": "application/json" }) + response.end(JSON.stringify(snapshot)) + return + } if (pathname === "/sync-state") { const actorId = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("actorId") const snapshot = await runtime @@ -122,6 +156,20 @@ const server = createServer(async (request, response) => { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return } + const isOfflineGuide = pathname.startsWith("/examples/guides/offline-first/") + const offlineGuidePath = resolve( + offlineGuideRoot, + `.${pathname.slice("/examples/guides/offline-first".length)}`, + ) + if (isOfflineGuide && !offlineGuidePath.startsWith(`${offlineGuideRoot}/`)) { + response.writeHead(404) + response.end() + return + } + if (isOfflineGuide) { + await serveFile({ response, path: offlineGuidePath }) + return + } const isExample = pathname.startsWith("/examples/browser/") const examplePath = resolve(browserExampleRoot, `.${pathname.slice("/examples/browser".length)}`) if (isExample && !examplePath.startsWith(`${browserExampleRoot}/`)) { @@ -165,6 +213,27 @@ const server = createServer(async (request, response) => { await serveFile({ response, path }) }) +async function handleInspectionSync({ request, response }) { + if (!inspectionsOnline) { + request.socket.destroy() + return + } + if (!(request.headers.cookie ?? "").includes("device=tablet-7")) { + response.writeHead(403) + response.end() + return + } + try { + await receiveTransmitEnvelope({ runtime, envelope: JSON.parse(await readBody(request)) }) + await runtime.testing.drain({ roles: ["actors"] }) + response.writeHead(200, { "content-type": "application/json" }) + response.end("{}") + } catch { + response.writeHead(422) + response.end() + } +} + function readBody(request) { return new Promise((resolve, reject) => { const chunks = [] @@ -184,6 +253,7 @@ async function serveFile({ response, path }) { .replaceAll('"@sqlite.org/sqlite-wasm"', '"/vendor/sqlite-wasm/index.mjs"') .replaceAll('"signal-polyfill"', '"/vendor/signal-polyfill/index.js"') .replaceAll('"solid-objects/browser/host"', '"/browser/host.js"') + .replaceAll('"solid-objects/core"', '"/core.js"') } response.writeHead(200, { "content-type": contentType }) response.end(contents) diff --git a/test/browser/offline-first.browser.ts b/test/browser/offline-first.browser.ts new file mode 100644 index 0000000..7878a61 --- /dev/null +++ b/test/browser/offline-first.browser.ts @@ -0,0 +1,99 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" + +type InspectionCall = + | { actorId: string; operation: "record"; argumentsValue: { findingId: string; note: string } } + | { actorId: string; operation: "snapshot"; argumentsValue?: never } + +function callInspection(page: Page, call: InspectionCall): Promise { + return page.evaluate( + async ({ modulePath, callValue }) => { + const { callActor } = await import(modulePath) + return callActor(callValue) + }, + { modulePath: "/examples/guides/offline-first/page.js", callValue: call }, + ) +} + +async function serverFindingIds(request: APIRequestContext, actorId: string): Promise { + const response = await request.get(`/inspections/state?actorId=${actorId}`) + const body = (await response.json()) as { findings: { findingId: string }[] } + return body.findings.map((finding) => finding.findingId) +} + +test("records findings offline in two tabs and syncs them once, in order", async ({ + context, + request, + baseURL, +}) => { + test.setTimeout(60_000) + const actorId = `site-${Date.now()}` + await context.addCookies([{ name: "device", value: "tablet-7", url: baseURL ?? "" }]) + await request.post("/inspections/network?online=false") + const firstTab = await context.newPage() + const secondTab = await context.newPage() + await firstTab.goto("/") + await secondTab.goto("/") + + await callInspection(firstTab, { + actorId, + operation: "record", + argumentsValue: { findingId: "finding-1", note: "Crack in the north wall" }, + }) + await callInspection(firstTab, { + actorId, + operation: "record", + argumentsValue: { findingId: "finding-2", note: "Loose handrail" }, + }) + await callInspection(secondTab, { + actorId, + operation: "record", + argumentsValue: { findingId: "finding-3", note: "Exit sign is dark" }, + }) + await firstTab.waitForTimeout(1_500) + expect(await serverFindingIds(request, actorId)).toEqual([]) + + await request.post("/inspections/network?online=true") + + await expect + .poll(() => serverFindingIds(request, actorId), { timeout: 30_000 }) + .toEqual(["finding-1", "finding-2", "finding-3"]) + await firstTab.waitForTimeout(1_500) + expect(await serverFindingIds(request, actorId)).toEqual(["finding-1", "finding-2", "finding-3"]) +}) + +test("keeps the local findings after a reload", async ({ page }) => { + const actorId = `site-reload-${Date.now()}` + await page.goto("/") + await callInspection(page, { + actorId, + operation: "record", + argumentsValue: { findingId: "finding-1", note: "Crack in the north wall" }, + }) + + await page.reload() + + const snapshot = (await callInspection(page, { actorId, operation: "snapshot" })) as { + findings: { findingId: string }[] + } + expect(snapshot.findings.map((finding) => finding.findingId)).toEqual(["finding-1"]) +}) + +test("loses edits that wait in page memory when the page reloads", async ({ page }) => { + const modulePath = "/examples/guides/offline-first/memory-outbox.js" + await page.goto("/") + const before = await page.evaluate(async (path) => { + const outbox = await import(path) + outbox.queueFinding({ findingId: "finding-1", note: "Crack in the north wall" }) + outbox.queueFinding({ findingId: "finding-2", note: "Loose handrail" }) + return outbox.pendingFindings() + }, modulePath) + expect(before).toBe(2) + + await page.reload() + + const after = await page.evaluate( + async (path) => (await import(path)).pendingFindings(), + modulePath, + ) + expect(after).toBe(0) +}) diff --git a/test/fixtures/event-hold-process.ts b/test/fixtures/event-hold-process.ts new file mode 100644 index 0000000..382571c --- /dev/null +++ b/test/fixtures/event-hold-process.ts @@ -0,0 +1,24 @@ +import { eventRuntime } from "../../examples/guides/race-conditions/event-runtime.js" +import { EventSeats } from "../../examples/guides/race-conditions/event-seats.js" + +type Instructions = { path: string; eventId: string; holdIds: string[] } + +const buyer = { userId: "child-buyer", role: "buyer" as const } + +process.once("message", async (instructions: Instructions) => { + const runtime = eventRuntime({ path: instructions.path }) + await runtime.install() + process.send?.({ event: "ready" }) + process.once("message", async () => { + const results = await Promise.all( + instructions.holdIds.map((holdId) => + runtime + .ref(EventSeats, instructions.eventId) + .with({ authorizationContext: buyer }) + .hold({ holdId, buyer: holdId }), + ), + ) + await runtime.close() + process.send?.({ event: "done", results }) + }) +}) diff --git a/test/guide-offline-first.test.ts b/test/guide-offline-first.test.ts new file mode 100644 index 0000000..e9b6493 --- /dev/null +++ b/test/guide-offline-first.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "vitest" +import { configure, type SolidObjectsRuntime } from "../src/runtime.js" +import { sqlite } from "../src/database/sqlite.js" +import { Inspection } from "../examples/guides/offline-first/server-inspection.js" +import { handleInspectionSync } from "../examples/guides/offline-first/sync-route.js" + +const runtimes: SolidObjectsRuntime[] = [] + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() +}) + +async function serverRuntime(): Promise { + const runtime = configure({ + database: sqlite({ path: ":memory:" }), + authorizeQuery: () => true, + }) + runtimes.push(runtime) + runtime.register(Inspection) + await runtime.install() + return runtime +} + +const envelope = { + effectId: "effect-1", + actorType: "Inspection", + actorId: "site-12", + operation: "record", + arguments: { findingId: "finding-1", note: "Crack in the north wall" }, +} + +function syncRequest({ device, body }: { device: string | null; body: unknown }): Request { + const headers = new Headers({ "content-type": "application/json" }) + if (device) headers.set("x-device", device) + return new Request("https://example.test/inspections/sync", { + method: "POST", + headers, + body: JSON.stringify(body), + }) +} + +async function canWrite({ + request, + envelope: received, +}: { + request: Request + envelope: { actorId: string } +}): Promise { + return request.headers.get("x-device") === "tablet-7" && received.actorId === "site-12" +} + +async function findings(runtime: SolidObjectsRuntime): Promise { + await runtime.testing.drain({ roles: ["actors"] }) + return (await runtime.ref(Inspection, "site-12").snapshot()).findings +} + +describe("the inspection sync route", () => { + it("refuses a device that it cannot authorize", async () => { + const runtime = await serverRuntime() + + const response = await handleInspectionSync({ + request: syncRequest({ device: null, body: envelope }), + runtime, + canWrite, + }) + + expect(response.status).toBe(403) + expect(await findings(runtime)).toEqual([]) + }) + + it("applies a write from an authorized device", async () => { + const runtime = await serverRuntime() + + const response = await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: envelope }), + runtime, + canWrite, + }) + + expect(response.status).toBe(200) + expect(await findings(runtime)).toEqual([ + { findingId: "finding-1", note: "Crack in the north wall" }, + ]) + }) + + it("applies a repeated write once", async () => { + const runtime = await serverRuntime() + + await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: envelope }), + runtime, + canWrite, + }) + const repeated = await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: envelope }), + runtime, + canWrite, + }) + + expect(repeated.status).toBe(200) + expect(await findings(runtime)).toHaveLength(1) + }) + + it("answers 422 for a write that can never apply", async () => { + const runtime = await serverRuntime() + const malformed = { ...envelope, operation: "" } + + const response = await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: malformed }), + runtime, + canWrite, + }) + + expect(response.status).toBe(422) + }) +}) diff --git a/test/guide-persistent-rooms.test.ts b/test/guide-persistent-rooms.test.ts new file mode 100644 index 0000000..5abc958 --- /dev/null +++ b/test/guide-persistent-rooms.test.ts @@ -0,0 +1,233 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { Rejected, Unauthorized } from "../src/errors.js" +import type { SolidObjectsRuntime } from "../src/runtime.js" +import { createMemoryRooms } from "../examples/guides/persistent-rooms/memory-room.js" +import { resumeRoom } from "../examples/guides/persistent-rooms/reconnect.js" +import { roomRuntime } from "../examples/guides/persistent-rooms/room-runtime.js" +import { + dueTurnDeadlines, + loadRoom, + openRoomStore, + saveRoom, +} from "../examples/guides/persistent-rooms/sql-room.js" +import { TURN_MILLISECONDS, TurnRoom } from "../examples/guides/persistent-rooms/turn-room.js" + +let directory: string +const runtimes: SolidObjectsRuntime[] = [] + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "guide-rooms-")) +}) + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() + await rm(directory, { recursive: true, force: true }) +}) + +async function startRuntime(): Promise { + const runtime = roomRuntime({ path: join(directory, "rooms.sqlite3") }) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +async function stopRuntime(runtime: SolidObjectsRuntime): Promise { + await runtime.close() + runtimes.splice(runtimes.indexOf(runtime), 1) +} + +function asPlayer(playerId: string): { authorizationContext: { playerId: string } } { + return { authorizationContext: { playerId } } +} + +async function startedRoom(runtime: SolidObjectsRuntime): Promise { + const room = runtime.ref(TurnRoom, "table-1") + await room.with(asPlayer("ada")).join({ playerId: "ada" }) + await room.with(asPlayer("grace")).join({ playerId: "grace" }) + await room.with(asPlayer("ada")).start({ playerId: "ada" }) +} + +describe("a room in process memory", () => { + it("loses the room and its turn timer when the process restarts", () => { + const beforeRestart = createMemoryRooms({ turnMilliseconds: TURN_MILLISECONDS }) + beforeRestart.join({ roomId: "table-1", playerId: "ada" }) + beforeRestart.join({ roomId: "table-1", playerId: "grace" }) + beforeRestart.start({ roomId: "table-1" }) + expect(beforeRestart.pendingTimers()).toBe(1) + beforeRestart.close() + + const afterRestart = createMemoryRooms({ turnMilliseconds: TURN_MILLISECONDS }) + + expect(afterRestart.room({ roomId: "table-1" })).toBeUndefined() + expect(afterRestart.pendingTimers()).toBe(0) + afterRestart.close() + }) +}) + +describe("the native fix with a version column", () => { + it("keeps the room after a restart and rejects a write from an old version", () => { + const path = join(directory, "store.sqlite3") + const beforeRestart = openRoomStore({ path }) + saveRoom({ + database: beforeRestart, + roomId: "table-1", + state: { turn: 0 }, + expectedVersion: 0, + turnDeadline: 1_000, + }) + beforeRestart.close() + + const afterRestart = openRoomStore({ path }) + const loaded = loadRoom({ database: afterRestart, roomId: "table-1" }) + expect(loaded).toEqual({ state: { turn: 0 }, version: 1 }) + + expect( + saveRoom({ + database: afterRestart, + roomId: "table-1", + state: { turn: 1 }, + expectedVersion: 1, + turnDeadline: 2_000, + }), + ).toEqual({ saved: true }) + expect( + saveRoom({ + database: afterRestart, + roomId: "table-1", + state: { turn: 9 }, + expectedVersion: 1, + turnDeadline: 3_000, + }), + ).toEqual({ saved: false }) + expect(dueTurnDeadlines({ database: afterRestart, now: 2_000 })).toEqual(["table-1"]) + afterRestart.close() + }) +}) + +describe("the TurnRoom actor", () => { + it("applies one of two submissions for the same turn", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + const room = runtime.ref(TurnRoom, "table-1").with(asPlayer("ada")) + + const outcomes = await Promise.allSettled([ + room.play({ playerId: "ada", move: "e4", turnNumber: 0 }), + room.play({ playerId: "ada", move: "d4", turnNumber: 0 }), + ]) + + expect(outcomes.map((outcome) => outcome.status).sort()).toEqual(["fulfilled", "rejected"]) + const rejected = outcomes.find((outcome) => outcome.status === "rejected") + expect(rejected?.reason).toBeInstanceOf(Rejected) + expect((rejected?.reason as Rejected).code).toBe("stale_turn") + const snapshot = await runtime.ref(TurnRoom, "table-1").snapshot(asPlayer("ada")) + expect(snapshot.moves).toHaveLength(1) + expect(snapshot.turnNumber).toBe(1) + }) + + it("rejects a move from the player who does not have the turn", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + + await expect( + runtime + .ref(TurnRoom, "table-1") + .with(asPlayer("grace")) + .play({ playerId: "grace", move: "e5", turnNumber: 0 }), + ).rejects.toMatchObject({ code: "not_your_turn" }) + }) + + it("rejects a move for another player", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + + await expect( + runtime + .ref(TurnRoom, "table-1") + .with(asPlayer("grace")) + .play({ playerId: "ada", move: "e4", turnNumber: 0 }), + ).rejects.toBeInstanceOf(Unauthorized) + }) + + it("skips the turn after a restart when the turn timer is due", async () => { + const beforeRestart = await startRuntime() + await startedRoom(beforeRestart) + await stopRuntime(beforeRestart) + + const afterRestart = await startRuntime() + await afterRestart.testing.runDueReminders({ + now: new Date(Date.now() + TURN_MILLISECONDS + 1_000), + }) + await afterRestart.testing.drain({ roles: ["actors"] }) + + const snapshot = await afterRestart.ref(TurnRoom, "table-1").snapshot(asPlayer("grace")) + expect(snapshot.skippedTurns).toEqual([0]) + expect(snapshot.turnNumber).toBe(1) + expect(await afterRestart.ref(TurnRoom, "table-1").with(asPlayer("grace")).currentPlayer).toBe( + "grace", + ) + }) + + it("ignores a turn timer for a turn that already ended", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + await runtime + .ref(TurnRoom, "table-1") + .with(asPlayer("ada")) + .play({ playerId: "ada", move: "e4", turnNumber: 0 }) + + await runtime.testing.runDueReminders({ now: new Date(Date.now() + TURN_MILLISECONDS / 2) }) + await runtime.testing.drain({ roles: ["actors"] }) + + const snapshot = await runtime.ref(TurnRoom, "table-1").snapshot(asPlayer("ada")) + expect(snapshot.skippedTurns).toEqual([]) + expect(snapshot.turnNumber).toBe(1) + }) + + it("lets no caller run the turn timer directly", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + + await expect( + runtime.ref(TurnRoom, "table-1").with(asPlayer("ada")).turnTimeout({ turnNumber: 0 }), + ).rejects.toBeInstanceOf(Unauthorized) + }) +}) + +describe("reconnect", () => { + it("returns the current room to a member after a restart", async () => { + const beforeRestart = await startRuntime() + await startedRoom(beforeRestart) + const lastSeen = (await beforeRestart.ref(TurnRoom, "table-1").snapshot(asPlayer("grace"))) + .revision + await beforeRestart + .ref(TurnRoom, "table-1") + .with(asPlayer("ada")) + .play({ playerId: "ada", move: "e4", turnNumber: 0 }) + await stopRuntime(beforeRestart) + + const afterRestart = await startRuntime() + const resumed = await resumeRoom({ + runtime: afterRestart, + roomId: "table-1", + playerId: "grace", + lastSeenRevision: lastSeen, + }) + + expect(resumed).toMatchObject({ + changed: true, + room: { turnNumber: 1, moves: [{ playerId: "ada", move: "e4" }] }, + }) + }) + + it("returns nothing to a player who is not in the room", async () => { + const runtime = await startRuntime() + await startedRoom(runtime) + + expect( + await resumeRoom({ runtime, roomId: "table-1", playerId: "mallory", lastSeenRevision: 0 }), + ).toBeNull() + }) +}) diff --git a/test/guide-race-conditions.test.ts b/test/guide-race-conditions.test.ts new file mode 100644 index 0000000..5009c65 --- /dev/null +++ b/test/guide-race-conditions.test.ts @@ -0,0 +1,279 @@ +import { fork, type ChildProcess } from "node:child_process" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { Rejected, Unauthorized } from "../src/errors.js" +import type { SolidObjectsRuntime } from "../src/runtime.js" +import { + EventSeats, + HOLD_MILLISECONDS, + type EventCaller, +} from "../examples/guides/race-conditions/event-seats.js" +import { eventRuntime } from "../examples/guides/race-conditions/event-runtime.js" +import { holdSeatAtomically } from "../examples/guides/race-conditions/atomic-hold.js" +import { + createLocalMutex, + holdSeatWithRace, +} from "../examples/guides/race-conditions/lost-update.js" +import { openSeatInventory, seatCounts } from "../examples/guides/race-conditions/seat-inventory.js" + +const buyer: EventCaller = { userId: "buyer-1", role: "buyer" } +const organizer: EventCaller = { userId: "organizer-1", role: "organizer" } +const paymentJob: EventCaller = { userId: "payment-job", role: "system" } + +let directory: string +const runtimes: SolidObjectsRuntime[] = [] + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "guide-race-")) +}) + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() + await rm(directory, { recursive: true, force: true }) +}) + +async function startRuntime(): Promise { + const runtime = eventRuntime({ path: join(directory, "events.sqlite3") }) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +function startHoldProcess(): ChildProcess { + return fork(fileURLToPath(new URL("./fixtures/event-hold-process.ts", import.meta.url)), { + execArgv: [ + "--experimental-transform-types", + "--disable-warning=ExperimentalWarning", + "--import", + fileURLToPath(new URL("./support/register-source-hooks.mjs", import.meta.url)), + ], + stdio: ["ignore", "inherit", "inherit", "ipc"], + }) +} + +function nextMessage(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once("message", resolve) + child.once("exit", (code) => reject(new Error(`the hold process exited with code ${code}`))) + }) +} + +function waitForInput(): Promise { + return new Promise((resolve) => setTimeout(resolve, 5)) +} + +describe("the race without coordination", () => { + it("lets two async holds take the last seat", async () => { + const database = openSeatInventory({ + path: join(directory, "seats.sqlite3"), + eventId: "concert", + available: 1, + }) + + const results = await Promise.all([ + holdSeatWithRace({ database, eventId: "concert", buyer: "ada", checkBuyer: waitForInput }), + holdSeatWithRace({ database, eventId: "concert", buyer: "grace", checkBuyer: waitForInput }), + ]) + + expect(results.map((result) => result.held)).toEqual([true, true]) + expect(seatCounts({ database, eventId: "concert" })).toEqual({ available: 0, holds: 2 }) + database.close() + }) + + it("lets a mutex in each process take the last seat twice", async () => { + const path = join(directory, "seats.sqlite3") + const firstProcess = { + database: openSeatInventory({ path, eventId: "concert", available: 1 }), + mutex: createLocalMutex(), + } + const secondProcess = { + database: openSeatInventory({ path, eventId: "concert", available: 1 }), + mutex: createLocalMutex(), + } + + const results = await Promise.all( + [firstProcess, secondProcess].map(({ database, mutex }, index) => + mutex.run(() => + holdSeatWithRace({ + database, + eventId: "concert", + buyer: `buyer-${index}`, + checkBuyer: waitForInput, + }), + ), + ), + ) + + expect(results.map((result) => result.held)).toEqual([true, true]) + expect(seatCounts({ database: firstProcess.database, eventId: "concert" }).holds).toBe(2) + firstProcess.database.close() + secondProcess.database.close() + }) +}) + +describe("the native fix", () => { + it("lets one conditional UPDATE take the last seat once, across connections", async () => { + const path = join(directory, "seats.sqlite3") + const connections = [ + openSeatInventory({ path, eventId: "concert", available: 1 }), + openSeatInventory({ path, eventId: "concert", available: 1 }), + ] + + const results = connections.map((database, index) => + holdSeatAtomically({ database, eventId: "concert", buyer: `buyer-${index}` }), + ) + + expect(results.map((result) => result.held).sort()).toEqual([false, true]) + expect(seatCounts({ database: connections[0]!, eventId: "concert" })).toEqual({ + available: 0, + holds: 1, + }) + for (const database of connections) database.close() + }) +}) + +describe("the EventSeats actor", () => { + it("keeps the seat count when two processes hold seats at the same time", async () => { + const path = join(directory, "events.sqlite3") + const parent = await startRuntime() + await parent + .ref(EventSeats, "concert") + .with({ authorizationContext: organizer }) + .setCapacity({ capacity: 3, expectedRevision: 0 }) + const child = startHoldProcess() + const childHoldIds = ["child-1", "child-2", "child-3", "child-4"] + child.send({ path, eventId: "concert", holdIds: childHoldIds }) + await nextMessage(child) + + child.send({ event: "go" }) + const [parentResults, childMessage] = await Promise.all([ + Promise.all( + ["parent-1", "parent-2", "parent-3", "parent-4"].map((holdId) => + parent + .ref(EventSeats, "concert") + .with({ authorizationContext: buyer }) + .hold({ holdId, buyer: holdId }), + ), + ), + nextMessage(child), + ]) + + const childResults = (childMessage as { results: { held: boolean }[] }).results + const held = [...parentResults, ...childResults].filter((result) => result.held) + expect(held).toHaveLength(3) + const snapshot = await parent + .ref(EventSeats, "concert") + .snapshot({ authorizationContext: buyer }) + expect(Object.keys(snapshot.holds)).toHaveLength(3) + }) + + it("applies a repeated hold once", async () => { + const runtime = await startRuntime() + const seats = runtime.ref(EventSeats, "concert") + await seats + .with({ authorizationContext: organizer }) + .setCapacity({ capacity: 2, expectedRevision: 0 }) + + await seats.with({ authorizationContext: buyer }).hold({ holdId: "hold-1", buyer: "ada" }) + const repeated = await seats + .with({ authorizationContext: buyer }) + .hold({ holdId: "hold-1", buyer: "ada" }) + + expect(repeated).toEqual({ held: true, available: 1 }) + }) + + it("expires a hold after a restart, when the reminder is due", async () => { + const before = await startRuntime() + await before + .ref(EventSeats, "concert") + .with({ authorizationContext: organizer }) + .setCapacity({ capacity: 1, expectedRevision: 0 }) + await before + .ref(EventSeats, "concert") + .with({ authorizationContext: buyer }) + .hold({ holdId: "hold-1", buyer: "ada" }) + await before.close() + runtimes.splice(runtimes.indexOf(before), 1) + + const after = await startRuntime() + after.register(EventSeats) + await after.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS + 1_000) }) + await after.testing.drain({ roles: ["actors"] }) + + const snapshot = await after + .ref(EventSeats, "concert") + .snapshot({ authorizationContext: buyer }) + expect(snapshot.holds).toEqual({}) + expect( + await after.ref(EventSeats, "concert").with({ authorizationContext: buyer }).available, + ).toBe(1) + }) + + it("ignores a late expiry for a hold that the payment job confirmed", async () => { + const runtime = await startRuntime() + const seats = runtime.ref(EventSeats, "concert") + await seats + .with({ authorizationContext: organizer }) + .setCapacity({ capacity: 1, expectedRevision: 0 }) + await seats.with({ authorizationContext: buyer }).hold({ holdId: "hold-1", buyer: "ada" }) + await seats.with({ authorizationContext: paymentJob }).confirm({ holdId: "hold-1" }) + + await seats.with({ authorizationContext: paymentJob }).expire({ holdId: "hold-1" }) + + const snapshot = await seats.snapshot({ authorizationContext: buyer }) + expect(snapshot.sold).toEqual({ "hold-1": "ada" }) + expect(snapshot.holds).toEqual({}) + expect( + await runtime.testing.runDueReminders({ + now: new Date(Date.now() + HOLD_MILLISECONDS + 1_000), + }), + ).toBe(0) + }) + + it("rejects a confirmation that arrives after the hold expired", async () => { + const runtime = await startRuntime() + const seats = runtime.ref(EventSeats, "concert") + await seats + .with({ authorizationContext: organizer }) + .setCapacity({ capacity: 1, expectedRevision: 0 }) + await seats.with({ authorizationContext: buyer }).hold({ holdId: "hold-1", buyer: "ada" }) + await runtime.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS + 1_000) }) + await runtime.testing.drain({ roles: ["actors"] }) + + await expect( + seats.with({ authorizationContext: paymentJob }).confirm({ holdId: "hold-1" }), + ).rejects.toMatchObject({ + code: "hold_expired", + }) + }) + + it("rejects a capacity change from a stale form", async () => { + const runtime = await startRuntime() + const seats = runtime.ref(EventSeats, "concert").with({ authorizationContext: organizer }) + await seats.setCapacity({ capacity: 10, expectedRevision: 0 }) + + await seats.setCapacity({ capacity: 12, expectedRevision: 1 }) + const stale = seats.setCapacity({ capacity: 8, expectedRevision: 1 }) + + await expect(stale).rejects.toBeInstanceOf(Rejected) + await expect(stale).rejects.toMatchObject({ code: "stale_revision" }) + expect( + (await runtime.ref(EventSeats, "concert").snapshot({ authorizationContext: organizer })) + .capacity, + ).toBe(12) + }) + + it("lets only an organizer change the capacity", async () => { + const runtime = await startRuntime() + + await expect( + runtime + .ref(EventSeats, "concert") + .with({ authorizationContext: buyer }) + .setCapacity({ capacity: 5, expectedRevision: 0 }), + ).rejects.toBeInstanceOf(Unauthorized) + }) +}) diff --git a/test/support/register-source-hooks.mjs b/test/support/register-source-hooks.mjs new file mode 100644 index 0000000..b403273 --- /dev/null +++ b/test/support/register-source-hooks.mjs @@ -0,0 +1,3 @@ +import { register } from "node:module" + +register("./source-hooks.mjs", import.meta.url) diff --git a/test/support/source-hooks.mjs b/test/support/source-hooks.mjs new file mode 100644 index 0000000..35dca76 --- /dev/null +++ b/test/support/source-hooks.mjs @@ -0,0 +1,20 @@ +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const sourceRoot = new URL("../../src/", import.meta.url) +const packageEntries = new Map([ + ["solid-objects", "index.ts"], + ["solid-objects/core", "core.ts"], + ["solid-objects/database/sqlite", "database/sqlite.ts"], +]) + +export async function resolve(specifier, context, nextResolve) { + const entry = packageEntries.get(specifier) + if (entry) return { url: new URL(entry, sourceRoot).href, shortCircuit: true } + if (!specifier.startsWith(".") || !specifier.endsWith(".js") || !context.parentURL) { + return nextResolve(specifier, context) + } + const typescriptSource = new URL(specifier.replace(/\.js$/, ".ts"), context.parentURL) + if (!existsSync(fileURLToPath(typescriptSource))) return nextResolve(specifier, context) + return { url: typescriptSource.href, shortCircuit: true } +} diff --git a/tsconfig.examples.json b/tsconfig.examples.json index 95e7d2e..b719e41 100644 --- a/tsconfig.examples.json +++ b/tsconfig.examples.json @@ -7,9 +7,15 @@ "solid-objects": ["./src/index.ts"], "solid-objects/database/sqlite": ["./src/database/sqlite.ts"], "solid-objects/database/postgresql": ["./src/database/postgresql.ts"], - "solid-objects/database/mysql": ["./src/database/mysql.ts"] + "solid-objects/database/mysql": ["./src/database/mysql.ts"], + "solid-objects/core": ["./src/core.ts"] } }, - "include": ["examples/**/*.ts", "benchmarks/**/*.ts"], + "include": [ + "examples/**/*.ts", + "benchmarks/**/*.ts", + "test/guide-*.test.ts", + "test/fixtures/event-hold-process.ts" + ], "exclude": ["examples/cloudflare"] } diff --git a/tsconfig.json b/tsconfig.json index ef9dc16..e6e9890 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,10 @@ "rootDir": "." }, "include": ["src/**/*.ts", "test/**/*.ts"], - "exclude": ["src/cloudflare", "test/cloudflare"] + "exclude": [ + "src/cloudflare", + "test/cloudflare", + "test/guide-*.test.ts", + "test/fixtures/event-hold-process.ts" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index bf5665e..1dfe324 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,15 @@ +import { fileURLToPath } from "node:url" import { configDefaults, defineConfig } from "vitest/config" +const source = (path: string) => fileURLToPath(new URL(`./src/${path}`, import.meta.url)) + export default defineConfig({ + resolve: { + alias: [ + { find: /^solid-objects$/, replacement: source("index.ts") }, + { find: /^solid-objects\/core$/, replacement: source("core.ts") }, + { find: /^solid-objects\/database\/sqlite$/, replacement: source("database/sqlite.ts") }, + ], + }, test: { dir: "./test", exclude: [...configDefaults.exclude, "**/cloudflare/**"] }, }) From b9bff47d3ae850a3d7c4279e2a3bb5b282af1088 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 13:31:50 -0700 Subject: [PATCH 2/5] chore: prepare version 0.17.6 --- CHANGELOG.md | 22 ++++++++++++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6c371..f69e33c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.17.6 - 2026-10-09 + +- Add three problem guides in `docs/guides/`. Each one reproduces a failure, + shows the simplest fix without an actor, and then shows a tested Solid + Objects version with its limits: + - `race-conditions.md`: a lost update across an `await`, why a mutex in one + process does not help, the conditional `UPDATE` fix, and an `EventSeats` + actor whose holds, payment confirmations, and expiry reminders race. + - `persistent-rooms.md`: a turn-based room whose state and turn timer + survive a restart, the SQL version-column fix, and reconnect that returns + the current authorized room state. + - `offline-first.md`: browser state in SQLite WASM shared across tabs, and + writes queued with `solid-objects/transmit` that a server applies once, in + order, after the network returns. +- The examples live in `examples/guides/`. Vitest and Chromium tests prove each + claim, including two real processes that hold seats at the same time. + `pnpm run check:documentation` requires each guide to embed its current + example files and rejects an em dash in a guide. +- Tests resolve `solid-objects` to `src/` through the Vitest and TypeScript + configuration, so an example that imports the package name runs under test + before a build. + ## 0.17.5 - 2026-10-09 - Correct `docs/agents.md` and `docs/fit.md`: Solid Objects Pro ships for diff --git a/package.json b/package.json index 9506789..ad152fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.17.5", + "version": "0.17.6", "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index cd1e341..426eb9c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.5" +export const VERSION = "0.17.6" From 4268a54a1968b416ec24e20cccc43144ee1805d2 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 13:45:01 -0700 Subject: [PATCH 3/5] fix: address review of the problem guides - EventSeats rejects a capacity that is not a whole, non-negative number with invalid_capacity, and hold stops at available <= 0. A capacity of 1.5 let holds drive availability below zero. - The sync route answers 422 for UnknownOperation, so a device stops retrying a queued write for an operation that a server deploy removed. - The SQL room store uses a RoomState type and guards the stored JSON; loadRoom throws for an unexpected shape instead of returning it. - The cross-process race test kills its child process after each test, so a failed run leaves no waiting process. Each fix started from a failing test. The guides embed the new example files and describe the new behavior. --- docs/guides/offline-first.md | 10 +++++++-- docs/guides/persistent-rooms.md | 21 ++++++++++++++----- docs/guides/race-conditions.md | 8 +++++-- examples/guides/offline-first/sync-route.ts | 7 ++++++- examples/guides/persistent-rooms/sql-room.ts | 17 ++++++++++++--- .../guides/race-conditions/event-seats.ts | 5 ++++- test/guide-offline-first.test.ts | 13 ++++++++++++ test/guide-persistent-rooms.test.ts | 20 ++++++++++++++---- test/guide-race-conditions.test.ts | 18 +++++++++++++++- 9 files changed, 100 insertions(+), 19 deletions(-) diff --git a/docs/guides/offline-first.md b/docs/guides/offline-first.md index a743c84..6cbe8f9 100644 --- a/docs/guides/offline-first.md +++ b/docs/guides/offline-first.md @@ -189,6 +189,7 @@ import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope, + UnknownOperation, type SolidObjectsRuntime, type TransmitEnvelope, } from "solid-objects" @@ -209,7 +210,11 @@ export async function handleInspectionSync({ await receiveTransmitEnvelope({ runtime, envelope }) return Response.json({}) } catch (error) { - if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) { + if ( + error instanceof InvalidPayload || + error instanceof IdempotencyConflict || + error instanceof UnknownOperation + ) { return new Response(null, { status: 422 }) } throw error @@ -223,7 +228,7 @@ Delivery through `receiveTransmitEnvelope` skips `authorizeMessage`. The route m The example gives those checks to `canWrite`. If that callback denies the request, the route answers 403 and does not enqueue the write. The browser policy cannot replace these server checks. -The route answers 422 for `InvalidPayload` and `IdempotencyConflict`. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply. +The route answers 422 for `InvalidPayload`, `IdempotencyConflict`, and `UnknownOperation`. An `UnknownOperation` occurs when a device queued a write for an operation that a later server deploy removed. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply. A Rails server can receive the same envelopes with `SolidObjects::Transmission.receive`. That method also skips message authorization. Authenticate the device before the call. Check its permission to write the actor. @@ -265,6 +270,7 @@ The [route tests](../../test/guide-offline-first.test.ts) check these cases: - An authorized request receives 200 and adds the expected finding. - A repeated envelope receives 200 and leaves one finding. - An envelope with an empty operation receives 422. +- An envelope for an operation that the server removed receives 422. These tests do not check every browser engine or every interruption during delivery. They also do not test `IdempotencyConflict` or the browser response to 422. diff --git a/docs/guides/persistent-rooms.md b/docs/guides/persistent-rooms.md index 5637a01..cd68404 100644 --- a/docs/guides/persistent-rooms.md +++ b/docs/guides/persistent-rooms.md @@ -60,6 +60,8 @@ The test models a restart with two instances of `createMemoryRooms`. It starts a ```typescript import { DatabaseSync } from "node:sqlite" +export type RoomState = { players: string[]; turnNumber: number } + export function openRoomStore({ path }: { path: string }): DatabaseSync { const database = new DatabaseSync(path) database.exec(` @@ -82,7 +84,7 @@ export function saveRoom({ }: { database: DatabaseSync roomId: string - state: unknown + state: RoomState expectedVersion: number turnDeadline: number | null }): { saved: boolean } { @@ -108,11 +110,13 @@ export function loadRoom({ }: { database: DatabaseSync roomId: string -}): { state: unknown; version: number } | undefined { +}): { state: RoomState; version: number } | undefined { const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as { state: string; version: number } | undefined if (!row) return undefined - return { state: JSON.parse(row.state), version: row.version } + const state: unknown = JSON.parse(row.state) + if (!isRoomState(state)) throw new Error(`stored room ${roomId} has an unexpected shape`) + return { state, version: row.version } } export function dueTurnDeadlines({ @@ -127,9 +131,16 @@ export function dueTurnDeadlines({ .all(now) as { room_id: string }[] return rows.map((row) => row.room_id) } + +function isRoomState(value: unknown): value is RoomState { + if (typeof value !== "object" || value === null) return false + if (!("players" in value) || !Array.isArray(value.players)) return false + if (!value.players.every((player) => typeof player === "string")) return false + return "turnNumber" in value && Number.isSafeInteger(value.turnNumber) +} ``` -The SQL design stores each room as JSON in a row with a `version` column and a turn deadline. `loadRoom` returns the state and its version. The caller passes that version to `saveRoom` as `expectedVersion`. +The SQL design stores each room as JSON in a row with a `version` column and a turn deadline. `loadRoom` returns the state and its version. It checks the stored JSON against `RoomState` and throws when the shape is unexpected, so the caller never uses unchecked data. The caller passes that version to `saveRoom` as `expectedVersion`. `saveRoom` updates a row only when its version still matches. A successful update also increases the version. A write from an old version returns `{ saved: false }`. The caller then reloads the room and tries again. An old copy of the state cannot replace a newer copy. @@ -356,7 +367,7 @@ In tests, `runtime.testing.runDueReminders({ now })` finds due reminders at the The [tests for persistent rooms](../../test/guide-persistent-rooms.test.ts) check these cases in this order: 1. A new instance of the memory design has no room and no timer after a simulated restart. -2. The SQL room survives a database reopen. A write from an old version fails, and the sweep finds the due room. +2. The SQL room survives a database reopen. A write from an old version fails, and the sweep finds the due room. A stored room with an unexpected shape fails to load. 3. Two submissions for one turn produce one accepted move and one `stale_turn` rejection. 4. A move from the wrong player rejects with `not_your_turn`. 5. A caller cannot submit a move for another player. diff --git a/docs/guides/race-conditions.md b/docs/guides/race-conditions.md index 007a93a..619120c 100644 --- a/docs/guides/race-conditions.md +++ b/docs/guides/race-conditions.md @@ -184,6 +184,9 @@ export class EventSeats extends Actor { setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): { revision: number } { + if (!Number.isSafeInteger(capacity) || capacity < 0) { + this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" }) + } if (expectedRevision !== this.revision) { this.reject("stale_revision", { message: "The event changed after you loaded it", @@ -202,7 +205,7 @@ export class EventSeats extends Actor { if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) { return { held: true, available: this.available } } - if (this.available === 0) return { held: false, available: 0 } + if (this.available <= 0) return { held: false, available: 0 } const expiresAt = Date.now() + HOLD_MILLISECONDS this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } } @@ -252,7 +255,7 @@ Calls for one event run one at a time, in order, across processes. Different eve An old form can overwrite newer data even when calls run in order. The calls run in order, but the second call still carries old data. The actor needs a rule that detects this conflict. -`setCapacity` takes `expectedRevision` and compares it with `revision`. A mismatch rejects the change with `stale_revision`. An accepted capacity change increases the revision. +`setCapacity` takes `expectedRevision` and compares it with `revision`. A mismatch rejects the change with `stale_revision`. An accepted capacity change increases the revision. A capacity that is not a whole, non-negative number rejects with `invalid_capacity`, so a fractional capacity cannot leave a part of a seat to hold. The test accepts a change from 10 seats to 12 seats. An old form then requests eight seats with the previous revision. The actor rejects that request and keeps the capacity at 12. @@ -337,6 +340,7 @@ The [test file](../../test/guide-race-conditions.test.ts) checks these outcomes: - A late expiry after a confirmation changes nothing. - A confirmation after expiry rejects with `hold_expired`. - A capacity change from a stale form rejects with `stale_revision`. +- A fractional or negative capacity rejects with `invalid_capacity`. - A buyer cannot change capacity. The assertions check both results and stored state. They show the behavior of these examples and their conflict cases. diff --git a/examples/guides/offline-first/sync-route.ts b/examples/guides/offline-first/sync-route.ts index a2cb7e2..4e1682e 100644 --- a/examples/guides/offline-first/sync-route.ts +++ b/examples/guides/offline-first/sync-route.ts @@ -2,6 +2,7 @@ import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope, + UnknownOperation, type SolidObjectsRuntime, type TransmitEnvelope, } from "solid-objects" @@ -22,7 +23,11 @@ export async function handleInspectionSync({ await receiveTransmitEnvelope({ runtime, envelope }) return Response.json({}) } catch (error) { - if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) { + if ( + error instanceof InvalidPayload || + error instanceof IdempotencyConflict || + error instanceof UnknownOperation + ) { return new Response(null, { status: 422 }) } throw error diff --git a/examples/guides/persistent-rooms/sql-room.ts b/examples/guides/persistent-rooms/sql-room.ts index 3d1240f..17f7947 100644 --- a/examples/guides/persistent-rooms/sql-room.ts +++ b/examples/guides/persistent-rooms/sql-room.ts @@ -1,5 +1,7 @@ import { DatabaseSync } from "node:sqlite" +export type RoomState = { players: string[]; turnNumber: number } + export function openRoomStore({ path }: { path: string }): DatabaseSync { const database = new DatabaseSync(path) database.exec(` @@ -22,7 +24,7 @@ export function saveRoom({ }: { database: DatabaseSync roomId: string - state: unknown + state: RoomState expectedVersion: number turnDeadline: number | null }): { saved: boolean } { @@ -48,11 +50,13 @@ export function loadRoom({ }: { database: DatabaseSync roomId: string -}): { state: unknown; version: number } | undefined { +}): { state: RoomState; version: number } | undefined { const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as { state: string; version: number } | undefined if (!row) return undefined - return { state: JSON.parse(row.state), version: row.version } + const state: unknown = JSON.parse(row.state) + if (!isRoomState(state)) throw new Error(`stored room ${roomId} has an unexpected shape`) + return { state, version: row.version } } export function dueTurnDeadlines({ @@ -67,3 +71,10 @@ export function dueTurnDeadlines({ .all(now) as { room_id: string }[] return rows.map((row) => row.room_id) } + +function isRoomState(value: unknown): value is RoomState { + if (typeof value !== "object" || value === null) return false + if (!("players" in value) || !Array.isArray(value.players)) return false + if (!value.players.every((player) => typeof player === "string")) return false + return "turnNumber" in value && Number.isSafeInteger(value.turnNumber) +} diff --git a/examples/guides/race-conditions/event-seats.ts b/examples/guides/race-conditions/event-seats.ts index 1f2af3b..ce9ed4f 100644 --- a/examples/guides/race-conditions/event-seats.ts +++ b/examples/guides/race-conditions/event-seats.ts @@ -21,6 +21,9 @@ export class EventSeats extends Actor { setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): { revision: number } { + if (!Number.isSafeInteger(capacity) || capacity < 0) { + this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" }) + } if (expectedRevision !== this.revision) { this.reject("stale_revision", { message: "The event changed after you loaded it", @@ -39,7 +42,7 @@ export class EventSeats extends Actor { if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) { return { held: true, available: this.available } } - if (this.available === 0) return { held: false, available: 0 } + if (this.available <= 0) return { held: false, available: 0 } const expiresAt = Date.now() + HOLD_MILLISECONDS this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } } diff --git a/test/guide-offline-first.test.ts b/test/guide-offline-first.test.ts index e9b6493..166c5b1 100644 --- a/test/guide-offline-first.test.ts +++ b/test/guide-offline-first.test.ts @@ -113,4 +113,17 @@ describe("the inspection sync route", () => { expect(response.status).toBe(422) }) + + it("answers 422 for a write to an operation that the server removed", async () => { + const runtime = await serverRuntime() + const removed = { ...envelope, operation: "removedOperation" } + + const response = await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: removed }), + runtime, + canWrite, + }) + + expect(response.status).toBe(422) + }) }) diff --git a/test/guide-persistent-rooms.test.ts b/test/guide-persistent-rooms.test.ts index 5abc958..f95c051 100644 --- a/test/guide-persistent-rooms.test.ts +++ b/test/guide-persistent-rooms.test.ts @@ -74,7 +74,7 @@ describe("the native fix with a version column", () => { saveRoom({ database: beforeRestart, roomId: "table-1", - state: { turn: 0 }, + state: { players: ["ada"], turnNumber: 0 }, expectedVersion: 0, turnDeadline: 1_000, }) @@ -82,13 +82,13 @@ describe("the native fix with a version column", () => { const afterRestart = openRoomStore({ path }) const loaded = loadRoom({ database: afterRestart, roomId: "table-1" }) - expect(loaded).toEqual({ state: { turn: 0 }, version: 1 }) + expect(loaded).toEqual({ state: { players: ["ada"], turnNumber: 0 }, version: 1 }) expect( saveRoom({ database: afterRestart, roomId: "table-1", - state: { turn: 1 }, + state: { players: ["ada"], turnNumber: 1 }, expectedVersion: 1, turnDeadline: 2_000, }), @@ -97,7 +97,7 @@ describe("the native fix with a version column", () => { saveRoom({ database: afterRestart, roomId: "table-1", - state: { turn: 9 }, + state: { players: ["ada"], turnNumber: 9 }, expectedVersion: 1, turnDeadline: 3_000, }), @@ -107,6 +107,18 @@ describe("the native fix with a version column", () => { }) }) +describe("a stored room with an unexpected shape", () => { + it("fails to load instead of returning unchecked state", () => { + const database = openRoomStore({ path: join(directory, "store.sqlite3") }) + database + .prepare("INSERT INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, NULL)") + .run("table-9", JSON.stringify({ players: "ada" })) + + expect(() => loadRoom({ database, roomId: "table-9" })).toThrow("unexpected shape") + database.close() + }) +}) + describe("the TurnRoom actor", () => { it("applies one of two submissions for the same turn", async () => { const runtime = await startRuntime() diff --git a/test/guide-race-conditions.test.ts b/test/guide-race-conditions.test.ts index 5009c65..71fad63 100644 --- a/test/guide-race-conditions.test.ts +++ b/test/guide-race-conditions.test.ts @@ -25,12 +25,14 @@ const paymentJob: EventCaller = { userId: "payment-job", role: "system" } let directory: string const runtimes: SolidObjectsRuntime[] = [] +const children: ChildProcess[] = [] beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), "guide-race-")) }) afterEach(async () => { + for (const child of children.splice(0)) child.kill() for (const runtime of runtimes.splice(0)) await runtime.close() await rm(directory, { recursive: true, force: true }) }) @@ -43,7 +45,7 @@ async function startRuntime(): Promise { } function startHoldProcess(): ChildProcess { - return fork(fileURLToPath(new URL("./fixtures/event-hold-process.ts", import.meta.url)), { + const child = fork(fileURLToPath(new URL("./fixtures/event-hold-process.ts", import.meta.url)), { execArgv: [ "--experimental-transform-types", "--disable-warning=ExperimentalWarning", @@ -52,6 +54,8 @@ function startHoldProcess(): ChildProcess { ], stdio: ["ignore", "inherit", "inherit", "ipc"], }) + children.push(child) + return child } function nextMessage(child: ChildProcess): Promise { @@ -266,6 +270,18 @@ describe("the EventSeats actor", () => { ).toBe(12) }) + it("rejects a capacity that is not a whole number of seats", async () => { + const runtime = await startRuntime() + const seats = runtime.ref(EventSeats, "concert").with({ authorizationContext: organizer }) + + await expect(seats.setCapacity({ capacity: 1.5, expectedRevision: 0 })).rejects.toMatchObject({ + code: "invalid_capacity", + }) + await expect(seats.setCapacity({ capacity: -1, expectedRevision: 0 })).rejects.toMatchObject({ + code: "invalid_capacity", + }) + }) + it("lets only an organizer change the capacity", async () => { const runtime = await startRuntime() From c6ca93afbd343f82cb3224482a4af426f8d2b510 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 13:54:54 -0700 Subject: [PATCH 4/5] fix: treat a removed actor type as a permanent sync failure A device can queue a write for an actor type that a later server deploy removes. receiveTransmitEnvelope throws UnknownActorType at enqueue, and the route rethrew it as a 500, so the device retried a write that can never apply. The route now answers 422 for UnknownActorType as it does for UnknownOperation. A new route test failed with the 500 first. --- docs/guides/offline-first.md | 6 ++++-- examples/guides/offline-first/sync-route.ts | 2 ++ test/guide-offline-first.test.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/guides/offline-first.md b/docs/guides/offline-first.md index 6cbe8f9..3148dde 100644 --- a/docs/guides/offline-first.md +++ b/docs/guides/offline-first.md @@ -189,6 +189,7 @@ import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope, + UnknownActorType, UnknownOperation, type SolidObjectsRuntime, type TransmitEnvelope, @@ -213,6 +214,7 @@ export async function handleInspectionSync({ if ( error instanceof InvalidPayload || error instanceof IdempotencyConflict || + error instanceof UnknownActorType || error instanceof UnknownOperation ) { return new Response(null, { status: 422 }) @@ -228,7 +230,7 @@ Delivery through `receiveTransmitEnvelope` skips `authorizeMessage`. The route m The example gives those checks to `canWrite`. If that callback denies the request, the route answers 403 and does not enqueue the write. The browser policy cannot replace these server checks. -The route answers 422 for `InvalidPayload`, `IdempotencyConflict`, and `UnknownOperation`. An `UnknownOperation` occurs when a device queued a write for an operation that a later server deploy removed. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply. +The route answers 422 for `InvalidPayload`, `IdempotencyConflict`, `UnknownActorType`, and `UnknownOperation`. The last two occur when a device queued a write for an actor type or an operation that a later server deploy removed. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply. A Rails server can receive the same envelopes with `SolidObjects::Transmission.receive`. That method also skips message authorization. Authenticate the device before the call. Check its permission to write the actor. @@ -270,7 +272,7 @@ The [route tests](../../test/guide-offline-first.test.ts) check these cases: - An authorized request receives 200 and adds the expected finding. - A repeated envelope receives 200 and leaves one finding. - An envelope with an empty operation receives 422. -- An envelope for an operation that the server removed receives 422. +- An envelope for an actor type or an operation that the server removed receives 422. These tests do not check every browser engine or every interruption during delivery. They also do not test `IdempotencyConflict` or the browser response to 422. diff --git a/examples/guides/offline-first/sync-route.ts b/examples/guides/offline-first/sync-route.ts index 4e1682e..6a4c4a0 100644 --- a/examples/guides/offline-first/sync-route.ts +++ b/examples/guides/offline-first/sync-route.ts @@ -2,6 +2,7 @@ import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope, + UnknownActorType, UnknownOperation, type SolidObjectsRuntime, type TransmitEnvelope, @@ -26,6 +27,7 @@ export async function handleInspectionSync({ if ( error instanceof InvalidPayload || error instanceof IdempotencyConflict || + error instanceof UnknownActorType || error instanceof UnknownOperation ) { return new Response(null, { status: 422 }) diff --git a/test/guide-offline-first.test.ts b/test/guide-offline-first.test.ts index 166c5b1..273fac7 100644 --- a/test/guide-offline-first.test.ts +++ b/test/guide-offline-first.test.ts @@ -114,6 +114,19 @@ describe("the inspection sync route", () => { expect(response.status).toBe(422) }) + it("answers 422 for a write to an actor type that the server removed", async () => { + const runtime = await serverRuntime() + const removed = { ...envelope, actorType: "RemovedInspection" } + + const response = await handleInspectionSync({ + request: syncRequest({ device: "tablet-7", body: removed }), + runtime, + canWrite, + }) + + expect(response.status).toBe(422) + }) + it("answers 422 for a write to an operation that the server removed", async () => { const runtime = await serverRuntime() const removed = { ...envelope, operation: "removedOperation" } From 0eefeca7f847a7bc670c9e0f6605f7484f9c4eb3 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 14:13:27 -0700 Subject: [PATCH 5/5] ci: pull database images from the ECR Public mirror Docker Hub's unauthenticated pull limit failed every database and Redis job on this branch three times in a row. The same official images are mirrored at public.ecr.aws/docker/library, which has no such limit for GitHub runners. The tag run publishes to npm only after these jobs pass, so a rate-limited run would also block a release. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b35138e..fd2feb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: postgresql-version: ["14", "18"] services: postgres: - image: postgres:${{ matrix.postgresql-version }} + image: public.ecr.aws/docker/library/postgres:${{ matrix.postgresql-version }} env: POSTGRES_USER: solid_objects POSTGRES_PASSWORD: solid_objects @@ -88,7 +88,7 @@ jobs: mysql-version: ["8.0", "8.4"] services: mysql: - image: mysql:${{ matrix.mysql-version }} + image: public.ecr.aws/docker/library/mysql:${{ matrix.mysql-version }} env: MYSQL_ROOT_PASSWORD: solid_objects MYSQL_DATABASE: solid_objects_test @@ -130,7 +130,7 @@ jobs: timeout-minutes: 15 services: redis: - image: redis:7 + image: public.ecr.aws/docker/library/redis:7 ports: - 6379:6379 options: >-