solid-objects 0.17.5 → 0.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +10 -0
  3. package/dist/repository.d.ts.map +1 -1
  4. package/dist/repository.js +6 -1
  5. package/dist/repository.js.map +1 -1
  6. package/dist/version.d.ts +1 -1
  7. package/dist/version.js +1 -1
  8. package/dist/worker.d.ts.map +1 -1
  9. package/dist/worker.js +3 -1
  10. package/dist/worker.js.map +1 -1
  11. package/docs/agents.md +5 -0
  12. package/docs/guides/offline-first.md +283 -0
  13. package/docs/guides/persistent-rooms.md +392 -0
  14. package/docs/guides/race-conditions.md +362 -0
  15. package/docs/parity.md +10 -2
  16. package/examples/guides/offline-first/inspection-worker.js +59 -0
  17. package/examples/guides/offline-first/memory-outbox.js +16 -0
  18. package/examples/guides/offline-first/page.js +30 -0
  19. package/examples/guides/offline-first/server-inspection.ts +17 -0
  20. package/examples/guides/offline-first/sync-route.ts +37 -0
  21. package/examples/guides/persistent-rooms/memory-room.ts +44 -0
  22. package/examples/guides/persistent-rooms/reconnect.ts +18 -0
  23. package/examples/guides/persistent-rooms/room-runtime.ts +49 -0
  24. package/examples/guides/persistent-rooms/sql-room.ts +80 -0
  25. package/examples/guides/persistent-rooms/turn-room.ts +85 -0
  26. package/examples/guides/race-conditions/atomic-hold.ts +28 -0
  27. package/examples/guides/race-conditions/event-runtime.ts +48 -0
  28. package/examples/guides/race-conditions/event-seats.ts +76 -0
  29. package/examples/guides/race-conditions/lost-update.ts +37 -0
  30. package/examples/guides/race-conditions/seat-inventory.ts +36 -0
  31. package/package.json +3 -3
@@ -0,0 +1,283 @@
1
+ # Offline-first state in the browser with SQLite WASM
2
+
3
+ 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.
4
+
5
+ ## The failure: an outbox in page memory
6
+
7
+ 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.
8
+
9
+ ```javascript
10
+ let pending = []
11
+
12
+ export function queueFinding(finding) {
13
+ pending = [...pending, finding]
14
+ }
15
+
16
+ export function pendingFindings() {
17
+ return pending.length
18
+ }
19
+
20
+ export async function flushFindings({ send }) {
21
+ while (pending.length > 0) {
22
+ await send(pending[0])
23
+ pending = pending.slice(1)
24
+ }
25
+ }
26
+ ```
27
+
28
+ 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.
29
+
30
+ 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.
31
+
32
+ ## The native fix
33
+
34
+ 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.
35
+
36
+ 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)
37
+
38
+ 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)
39
+
40
+ 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.
41
+
42
+ ## One actor per inspection, in a module worker
43
+
44
+ ```javascript
45
+ import { NonRetryableError } from "solid-objects/core"
46
+ import { Actor, configure, registerTransmit, sharedSqliteWasm } from "solid-objects/browser/host"
47
+
48
+ class Inspection extends Actor {
49
+ static actorType = "Inspection"
50
+
51
+ findings = []
52
+
53
+ record({ findingId, note }) {
54
+ if (this.findings.some((finding) => finding.findingId === findingId)) {
55
+ return this.findings.length
56
+ }
57
+ this.findings = [...this.findings, { findingId, note }]
58
+ this.transmit().record({ findingId, note })
59
+ return this.findings.length
60
+ }
61
+ }
62
+
63
+ const allowInspections = ({ actorType }) => actorType === "Inspection"
64
+
65
+ const runtime = configure({
66
+ database: sharedSqliteWasm({ path: "inspections.db" }),
67
+ authorizeMessage: allowInspections,
68
+ authorizeQuery: allowInspections,
69
+ maxAttempts: 1_000,
70
+ retryDelayMilliseconds: (attempt) => Math.min(2 ** (attempt - 1), 30) * 1_000,
71
+ processAliveThresholdMilliseconds: 750,
72
+ leaseDurationMilliseconds: 750,
73
+ leaseRenewalIntervalMilliseconds: 250,
74
+ })
75
+
76
+ registerTransmit({
77
+ runtime,
78
+ deliver: async (envelope) => {
79
+ const response = await fetch("/inspections/sync", {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify(envelope),
83
+ })
84
+ if (response.status === 422) {
85
+ throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`)
86
+ }
87
+ if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`)
88
+ },
89
+ })
90
+ runtime.register(Inspection)
91
+ const installed = runtime.install()
92
+ installed.then(() => runtime.run(new AbortController().signal))
93
+
94
+ self.onmessage = async (event) => {
95
+ const { requestId, actorId, operation, argumentsValue } = event.data
96
+ try {
97
+ await installed
98
+ const value = await Inspection.ref(actorId)[operation](argumentsValue)
99
+ postMessage({ requestId, ok: true, value })
100
+ } catch (error) {
101
+ postMessage({ requestId, ok: false, message: String(error?.message ?? error) })
102
+ }
103
+ }
104
+ ```
105
+
106
+ 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.
107
+
108
+ 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.
109
+
110
+ 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(...)`.
111
+
112
+ 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.
113
+
114
+ 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.
115
+
116
+ 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`.
117
+
118
+ 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.
119
+
120
+ 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.
121
+
122
+ 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`.
123
+
124
+ 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.
125
+
126
+ ```javascript
127
+ const worker = new Worker(new URL("./inspection-worker.js", import.meta.url), { type: "module" })
128
+ const pending = new Map()
129
+ let nextRequestId = 0
130
+ let workerFailure
131
+
132
+ worker.onmessage = (event) => {
133
+ const { requestId, ok, value, message } = event.data
134
+ const request = pending.get(requestId)
135
+ pending.delete(requestId)
136
+ if (ok) {
137
+ request.resolve(value)
138
+ return
139
+ }
140
+ request.reject(new Error(message))
141
+ }
142
+
143
+ worker.onerror = (event) => {
144
+ workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`)
145
+ for (const request of pending.values()) request.reject(workerFailure)
146
+ pending.clear()
147
+ }
148
+
149
+ export function callActor({ actorId, operation, argumentsValue }) {
150
+ if (workerFailure) return Promise.reject(workerFailure)
151
+ const requestId = nextRequestId++
152
+ return new Promise((resolve, reject) => {
153
+ pending.set(requestId, { resolve, reject })
154
+ worker.postMessage({ requestId, actorId, operation, argumentsValue })
155
+ })
156
+ }
157
+ ```
158
+
159
+ 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.
160
+
161
+ 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.
162
+
163
+ ## The server applies each write once
164
+
165
+ ```typescript
166
+ import { Actor } from "solid-objects"
167
+
168
+ type Finding = { findingId: string; note: string }
169
+
170
+ export class Inspection extends Actor {
171
+ static override readonly actorType = "Inspection"
172
+
173
+ findings: Finding[] = []
174
+
175
+ record({ findingId, note }: Finding): number {
176
+ if (this.findings.some((finding) => finding.findingId === findingId)) {
177
+ return this.findings.length
178
+ }
179
+ this.findings = [...this.findings, { findingId, note }]
180
+ return this.findings.length
181
+ }
182
+ }
183
+ ```
184
+
185
+ 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.
186
+
187
+ ```typescript
188
+ import {
189
+ IdempotencyConflict,
190
+ InvalidPayload,
191
+ receiveTransmitEnvelope,
192
+ UnknownActorType,
193
+ UnknownOperation,
194
+ type SolidObjectsRuntime,
195
+ type TransmitEnvelope,
196
+ } from "solid-objects"
197
+
198
+ export async function handleInspectionSync({
199
+ request,
200
+ runtime,
201
+ canWrite,
202
+ }: {
203
+ request: Request
204
+ runtime: SolidObjectsRuntime
205
+ canWrite: (options: { request: Request; envelope: TransmitEnvelope }) => Promise<boolean>
206
+ }): Promise<Response> {
207
+ const envelope = (await request.json()) as TransmitEnvelope
208
+ if (!(await canWrite({ request, envelope }))) return new Response("Forbidden", { status: 403 })
209
+
210
+ try {
211
+ await receiveTransmitEnvelope({ runtime, envelope })
212
+ return Response.json({})
213
+ } catch (error) {
214
+ if (
215
+ error instanceof InvalidPayload ||
216
+ error instanceof IdempotencyConflict ||
217
+ error instanceof UnknownActorType ||
218
+ error instanceof UnknownOperation
219
+ ) {
220
+ return new Response(null, { status: 422 })
221
+ }
222
+ throw error
223
+ }
224
+ }
225
+ ```
226
+
227
+ 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.
228
+
229
+ 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.
230
+
231
+ 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.
232
+
233
+ 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.
234
+
235
+ 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.
236
+
237
+ ## Several tabs
238
+
239
+ 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.
240
+
241
+ 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.
242
+
243
+ 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.
244
+
245
+ ## What this does not do
246
+
247
+ - It does not replicate server changes to the browser. Fetch the server state. Alternatively, subscribe with the WebSocket client in `solid-objects/browser`.
248
+ - 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.
249
+ - 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.
250
+
251
+ ## Limits
252
+
253
+ - 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.
254
+ - 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.
255
+ - The browser can clear the storage of an origin. Call `navigator.storage.persist()` to ask the browser to keep it.
256
+ - All authorization callbacks deny by default, also in the browser. The example explicitly permits messages and queries for `Inspection`.
257
+ - Delivery is at least once. The server must stay idempotent: a repeated write must not cause another state change.
258
+
259
+ [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.
260
+
261
+ ## What the tests prove
262
+
263
+ The [browser tests](../../test/browser/offline-first.browser.ts) check these cases:
264
+
265
+ - Two tabs record three findings while offline. After the network returns, the server state contains each finding once, in the same order.
266
+ - A finding in the local actor state survives a page reload.
267
+ - Two findings in the outbox in page memory disappear after a page reload.
268
+
269
+ The [route tests](../../test/guide-offline-first.test.ts) check these cases:
270
+
271
+ - A request without device authorization receives 403 and adds no finding.
272
+ - An authorized request receives 200 and adds the expected finding.
273
+ - A repeated envelope receives 200 and leaves one finding.
274
+ - An envelope with an empty operation receives 422.
275
+ - An envelope for an actor type or an operation that the server removed receives 422.
276
+
277
+ 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.
278
+
279
+ ## Sources
280
+
281
+ - MDN, [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API), checked October 9, 2026.
282
+ - MDN, [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API), checked October 9, 2026.
283
+ - [Yjs](https://yjs.dev), and [Automerge](https://automerge.org), checked October 9, 2026.
@@ -0,0 +1,392 @@
1
+ # Keep turn-based room state through restarts in Node.js
2
+
3
+ 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.
4
+
5
+ ## The failure: state and timers in process memory
6
+
7
+ ```typescript
8
+ type MemoryRoom = { players: string[]; turnNumber: number; started: boolean }
9
+
10
+ export function createMemoryRooms({ turnMilliseconds }: { turnMilliseconds: number }) {
11
+ const rooms = new Map<string, MemoryRoom>()
12
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
13
+
14
+ function armTurnTimer(roomId: string): void {
15
+ clearTimeout(timers.get(roomId))
16
+ timers.set(
17
+ roomId,
18
+ setTimeout(() => skipTurn(roomId), turnMilliseconds),
19
+ )
20
+ }
21
+
22
+ function skipTurn(roomId: string): void {
23
+ const room = rooms.get(roomId)
24
+ if (!room) return
25
+ rooms.set(roomId, { ...room, turnNumber: room.turnNumber + 1 })
26
+ armTurnTimer(roomId)
27
+ }
28
+
29
+ return {
30
+ join({ roomId, playerId }: { roomId: string; playerId: string }): void {
31
+ const room = rooms.get(roomId) ?? { players: [], turnNumber: 0, started: false }
32
+ rooms.set(roomId, { ...room, players: [...room.players, playerId] })
33
+ },
34
+ start({ roomId }: { roomId: string }): void {
35
+ const room = rooms.get(roomId)
36
+ if (!room) return
37
+ rooms.set(roomId, { ...room, started: true })
38
+ armTurnTimer(roomId)
39
+ },
40
+ room({ roomId }: { roomId: string }): MemoryRoom | undefined {
41
+ return rooms.get(roomId)
42
+ },
43
+ pendingTimers(): number {
44
+ return timers.size
45
+ },
46
+ close(): void {
47
+ for (const timer of timers.values()) clearTimeout(timer)
48
+ timers.clear()
49
+ },
50
+ }
51
+ }
52
+ ```
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ ## The native fix: a row with a version and a deadline
59
+
60
+ ```typescript
61
+ import { DatabaseSync } from "node:sqlite"
62
+
63
+ export type RoomState = { players: string[]; turnNumber: number }
64
+
65
+ export function openRoomStore({ path }: { path: string }): DatabaseSync {
66
+ const database = new DatabaseSync(path)
67
+ database.exec(`
68
+ CREATE TABLE IF NOT EXISTS rooms (
69
+ room_id TEXT PRIMARY KEY,
70
+ state TEXT NOT NULL,
71
+ version INTEGER NOT NULL,
72
+ turn_deadline INTEGER
73
+ )
74
+ `)
75
+ return database
76
+ }
77
+
78
+ export function saveRoom({
79
+ database,
80
+ roomId,
81
+ state,
82
+ expectedVersion,
83
+ turnDeadline,
84
+ }: {
85
+ database: DatabaseSync
86
+ roomId: string
87
+ state: RoomState
88
+ expectedVersion: number
89
+ turnDeadline: number | null
90
+ }): { saved: boolean } {
91
+ if (expectedVersion === 0) {
92
+ const inserted = database
93
+ .prepare(
94
+ "INSERT OR IGNORE INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, ?)",
95
+ )
96
+ .run(roomId, JSON.stringify(state), turnDeadline)
97
+ return { saved: inserted.changes === 1 }
98
+ }
99
+ const updated = database
100
+ .prepare(
101
+ "UPDATE rooms SET state = ?, version = version + 1, turn_deadline = ? WHERE room_id = ? AND version = ?",
102
+ )
103
+ .run(JSON.stringify(state), turnDeadline, roomId, expectedVersion)
104
+ return { saved: updated.changes === 1 }
105
+ }
106
+
107
+ export function loadRoom({
108
+ database,
109
+ roomId,
110
+ }: {
111
+ database: DatabaseSync
112
+ roomId: string
113
+ }): { state: RoomState; version: number } | undefined {
114
+ const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as
115
+ { state: string; version: number } | undefined
116
+ if (!row) return undefined
117
+ const state: unknown = JSON.parse(row.state)
118
+ if (!isRoomState(state)) throw new Error(`stored room ${roomId} has an unexpected shape`)
119
+ return { state, version: row.version }
120
+ }
121
+
122
+ export function dueTurnDeadlines({
123
+ database,
124
+ now,
125
+ }: {
126
+ database: DatabaseSync
127
+ now: number
128
+ }): string[] {
129
+ const rows = database
130
+ .prepare("SELECT room_id FROM rooms WHERE turn_deadline <= ? ORDER BY turn_deadline")
131
+ .all(now) as { room_id: string }[]
132
+ return rows.map((row) => row.room_id)
133
+ }
134
+
135
+ function isRoomState(value: unknown): value is RoomState {
136
+ if (typeof value !== "object" || value === null) return false
137
+ if (!("players" in value) || !Array.isArray(value.players)) return false
138
+ if (!value.players.every((player) => typeof player === "string")) return false
139
+ return "turnNumber" in value && Number.isSafeInteger(value.turnNumber)
140
+ }
141
+ ```
142
+
143
+ 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`.
144
+
145
+ `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.
146
+
147
+ 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.
148
+
149
+ The application still owns these parts:
150
+
151
+ - A retry loop for version conflicts.
152
+ - A sweep process that checks deadlines.
153
+ - A rule that permits only one process to apply each timeout.
154
+
155
+ 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.
156
+
157
+ ## One actor per room
158
+
159
+ ```typescript
160
+ import { Actor } from "solid-objects"
161
+
162
+ export const TURN_MILLISECONDS = 60_000
163
+
164
+ const MAXIMUM_PLAYERS = 4
165
+
166
+ type Move = { playerId: string; move: string; turnNumber: number }
167
+
168
+ export class TurnRoom extends Actor {
169
+ static override readonly actorType = "TurnRoom"
170
+
171
+ players: string[] = []
172
+ started = false
173
+ turnNumber = 0
174
+ moves: Move[] = []
175
+ skippedTurns: number[] = []
176
+ revision = 0
177
+
178
+ get currentPlayer(): string | null {
179
+ if (!this.started) return null
180
+ return this.players[this.turnNumber % this.players.length] ?? null
181
+ }
182
+
183
+ join({ playerId }: { playerId: string }): { players: string[] } {
184
+ if (this.players.includes(playerId)) return { players: this.players }
185
+ if (this.started) this.reject("game_started", { message: "The game already started" })
186
+ if (this.players.length === MAXIMUM_PLAYERS) {
187
+ this.reject("room_full", { message: "The room is full" })
188
+ }
189
+ this.players = [...this.players, playerId]
190
+ this.revision += 1
191
+ return { players: this.players }
192
+ }
193
+
194
+ start({ playerId }: { playerId: string }): { turnNumber: number } {
195
+ if (this.started) return { turnNumber: this.turnNumber }
196
+ if (!this.players.includes(playerId)) {
197
+ this.reject("not_in_room", { message: "Only a player in the room can start the game" })
198
+ }
199
+ if (this.players.length < 2) {
200
+ this.reject("not_enough_players", { message: "The game needs two players" })
201
+ }
202
+ this.started = true
203
+ this.revision += 1
204
+ this.#armTurnTimer()
205
+ return { turnNumber: this.turnNumber }
206
+ }
207
+
208
+ play({ playerId, move, turnNumber }: { playerId: string; move: string; turnNumber: number }): {
209
+ turnNumber: number
210
+ } {
211
+ if (!this.started) this.reject("game_not_started", { message: "The game has not started" })
212
+ if (turnNumber !== this.turnNumber) {
213
+ this.reject("stale_turn", {
214
+ message: "That turn already ended",
215
+ details: { turnNumber: this.turnNumber },
216
+ })
217
+ }
218
+ if (playerId !== this.currentPlayer) {
219
+ this.reject("not_your_turn", { message: "It is not your turn" })
220
+ }
221
+ this.moves = [...this.moves, { playerId, move, turnNumber }]
222
+ this.#advanceTurn()
223
+ return { turnNumber: this.turnNumber }
224
+ }
225
+
226
+ turnTimeout({ turnNumber }: { turnNumber: number }): { skipped: boolean } {
227
+ if (turnNumber !== this.turnNumber) return { skipped: false }
228
+ this.skippedTurns = [...this.skippedTurns, turnNumber]
229
+ this.#advanceTurn()
230
+ return { skipped: true }
231
+ }
232
+
233
+ #advanceTurn(): void {
234
+ this.turnNumber += 1
235
+ this.revision += 1
236
+ this.#armTurnTimer()
237
+ }
238
+
239
+ #armTurnTimer(): void {
240
+ this.schedule({ at: new Date(Date.now() + TURN_MILLISECONDS) }).turnTimeout({
241
+ turnNumber: this.turnNumber,
242
+ })
243
+ }
244
+ }
245
+ ```
246
+
247
+ 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.
248
+
249
+ `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`.
250
+
251
+ 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.
252
+
253
+ 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.
254
+
255
+ `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.
256
+
257
+ `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`.
258
+
259
+ ## Authorize players
260
+
261
+ ```typescript
262
+ import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
263
+ import { sqlite } from "solid-objects/database/sqlite"
264
+ import { TurnRoom } from "./turn-room.js"
265
+
266
+ export type RoomCaller = { playerId: string }
267
+
268
+ const PLAYER_OPERATIONS = new Set(["join", "start", "play"])
269
+
270
+ export function authorizeRoomMessage({
271
+ actorType,
272
+ operation,
273
+ arguments: argumentsValue,
274
+ authorizationContext,
275
+ }: {
276
+ actorType: string
277
+ operation: string
278
+ arguments: Record<string, unknown>
279
+ authorizationContext: unknown
280
+ }): boolean {
281
+ if (actorType !== TurnRoom.actorType) return false
282
+ if (!PLAYER_OPERATIONS.has(operation)) return false
283
+ if (!isRoomCaller(authorizationContext)) return false
284
+ return argumentsValue.playerId === authorizationContext.playerId
285
+ }
286
+
287
+ export function authorizeRoomQuery({
288
+ actorType,
289
+ authorizationContext,
290
+ }: {
291
+ actorType: string
292
+ authorizationContext: unknown
293
+ }): boolean {
294
+ return actorType === TurnRoom.actorType && isRoomCaller(authorizationContext)
295
+ }
296
+
297
+ export function roomRuntime({ path }: { path: string }): SolidObjectsRuntime {
298
+ const runtime = createRuntime({
299
+ database: sqlite({ path }),
300
+ authorizeMessage: authorizeRoomMessage,
301
+ authorizeQuery: authorizeRoomQuery,
302
+ })
303
+ runtime.register(TurnRoom)
304
+ return runtime
305
+ }
306
+
307
+ function isRoomCaller(value: unknown): value is RoomCaller {
308
+ if (typeof value !== "object" || value === null) return false
309
+ return "playerId" in value && typeof value.playerId === "string"
310
+ }
311
+ ```
312
+
313
+ 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`.
314
+
315
+ 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.
316
+
317
+ 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.
318
+
319
+ ## Reconnect: send the current room
320
+
321
+ ```typescript
322
+ import type { ActorSnapshot, SolidObjectsRuntime } from "solid-objects"
323
+ import { TurnRoom } from "./turn-room.js"
324
+
325
+ export async function resumeRoom({
326
+ runtime,
327
+ roomId,
328
+ playerId,
329
+ lastSeenRevision,
330
+ }: {
331
+ runtime: SolidObjectsRuntime
332
+ roomId: string
333
+ playerId: string
334
+ lastSeenRevision: number
335
+ }): Promise<{ room: ActorSnapshot<TurnRoom>; changed: boolean } | null> {
336
+ const room = await runtime.ref(TurnRoom, roomId).snapshot({ authorizationContext: { playerId } })
337
+ if (!room.players.includes(playerId)) return null
338
+ return { room, changed: room.revision !== lastSeenRevision }
339
+ }
340
+ ```
341
+
342
+ 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.
343
+
344
+ 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.
345
+
346
+ 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).
347
+
348
+ Adapter support also matters:
349
+
350
+ - The in-memory adapter supports recovery.
351
+ - The Redis adapter does not support recovery.
352
+ - The Redis Streams adapter supports recovery.
353
+ - The MongoDB adapter supports recovery.
354
+
355
+ 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.
356
+
357
+ ## Run the runtime process
358
+
359
+ 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.
360
+
361
+ 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.
362
+
363
+ 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.
364
+
365
+ ## What the tests prove
366
+
367
+ The [tests for persistent rooms](../../test/guide-persistent-rooms.test.ts) check these cases in this order:
368
+
369
+ 1. A new instance of the memory design has no room and no timer after a simulated restart.
370
+ 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.
371
+ 3. Two submissions for one turn produce one accepted move and one `stale_turn` rejection.
372
+ 4. A move from the wrong player rejects with `not_your_turn`.
373
+ 5. A caller cannot submit a move for another player.
374
+ 6. After a runtime restart, a due reminder skips turn zero and makes the second player current.
375
+ 7. After a move, a sweep before the next deadline leaves the turn unchanged and records no skipped turn.
376
+ 8. A caller cannot invoke `turnTimeout` directly.
377
+ 9. After a restart, a room member receives the current room, its move, and `changed: true`.
378
+ 10. A player outside the room receives `null` from `resumeRoom`.
379
+
380
+ The seventh test does not inject a late or repeated timer. The turn number check in `turnTimeout` handles that case.
381
+
382
+ ## Limits
383
+
384
+ 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.
385
+
386
+ 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.
387
+
388
+ See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md).
389
+
390
+ ## Sources
391
+
392
+ Socket.IO, [Connection state recovery](https://socket.io/docs/v4/connection-state-recovery), checked October 9, 2026.