solid-objects 0.17.5 → 0.17.6
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.
- package/CHANGELOG.md +22 -0
- package/README.md +10 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/agents.md +5 -0
- package/docs/guides/offline-first.md +283 -0
- package/docs/guides/persistent-rooms.md +392 -0
- package/docs/guides/race-conditions.md +362 -0
- package/examples/guides/offline-first/inspection-worker.js +59 -0
- package/examples/guides/offline-first/memory-outbox.js +16 -0
- package/examples/guides/offline-first/page.js +30 -0
- package/examples/guides/offline-first/server-inspection.ts +17 -0
- package/examples/guides/offline-first/sync-route.ts +37 -0
- package/examples/guides/persistent-rooms/memory-room.ts +44 -0
- package/examples/guides/persistent-rooms/reconnect.ts +18 -0
- package/examples/guides/persistent-rooms/room-runtime.ts +49 -0
- package/examples/guides/persistent-rooms/sql-room.ts +80 -0
- package/examples/guides/persistent-rooms/turn-room.ts +85 -0
- package/examples/guides/race-conditions/atomic-hold.ts +28 -0
- package/examples/guides/race-conditions/event-runtime.ts +48 -0
- package/examples/guides/race-conditions/event-seats.ts +76 -0
- package/examples/guides/race-conditions/lost-update.ts +37 -0
- package/examples/guides/race-conditions/seat-inventory.ts +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Prevent race conditions in Node.js
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## The failure: a lost update across an await
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
The example stores the available seat count in `seats` and each hold in `holds`. The test gives an event one available seat.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { DatabaseSync } from "node:sqlite"
|
|
13
|
+
|
|
14
|
+
export function openSeatInventory({
|
|
15
|
+
path,
|
|
16
|
+
eventId,
|
|
17
|
+
available,
|
|
18
|
+
}: {
|
|
19
|
+
path: string
|
|
20
|
+
eventId: string
|
|
21
|
+
available: number
|
|
22
|
+
}): DatabaseSync {
|
|
23
|
+
const database = new DatabaseSync(path)
|
|
24
|
+
database.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS seats (event_id TEXT PRIMARY KEY, available INTEGER NOT NULL);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS holds (event_id TEXT NOT NULL, buyer TEXT NOT NULL);
|
|
27
|
+
`)
|
|
28
|
+
database
|
|
29
|
+
.prepare("INSERT OR IGNORE INTO seats (event_id, available) VALUES (?, ?)")
|
|
30
|
+
.run(eventId, available)
|
|
31
|
+
return database
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function seatCounts({ database, eventId }: { database: DatabaseSync; eventId: string }): {
|
|
35
|
+
available: number
|
|
36
|
+
holds: number
|
|
37
|
+
} {
|
|
38
|
+
const seats = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as {
|
|
39
|
+
available: number
|
|
40
|
+
}
|
|
41
|
+
const holds = database
|
|
42
|
+
.prepare("SELECT COUNT(*) AS count FROM holds WHERE event_id = ?")
|
|
43
|
+
.get(eventId) as {
|
|
44
|
+
count: number
|
|
45
|
+
}
|
|
46
|
+
return { available: seats.available, holds: holds.count }
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
54
|
+
|
|
55
|
+
export async function holdSeatWithRace({
|
|
56
|
+
database,
|
|
57
|
+
eventId,
|
|
58
|
+
buyer,
|
|
59
|
+
checkBuyer,
|
|
60
|
+
}: {
|
|
61
|
+
database: DatabaseSync
|
|
62
|
+
eventId: string
|
|
63
|
+
buyer: string
|
|
64
|
+
checkBuyer: () => Promise<void>
|
|
65
|
+
}): Promise<{ held: boolean }> {
|
|
66
|
+
const row = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as {
|
|
67
|
+
available: number
|
|
68
|
+
}
|
|
69
|
+
if (row.available === 0) return { held: false }
|
|
70
|
+
|
|
71
|
+
await checkBuyer()
|
|
72
|
+
|
|
73
|
+
database
|
|
74
|
+
.prepare("UPDATE seats SET available = ? WHERE event_id = ?")
|
|
75
|
+
.run(row.available - 1, eventId)
|
|
76
|
+
database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer)
|
|
77
|
+
return { held: true }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createLocalMutex(): { run<Result>(task: () => Promise<Result>): Promise<Result> } {
|
|
81
|
+
let tail: Promise<unknown> = Promise.resolve()
|
|
82
|
+
return {
|
|
83
|
+
run<Result>(task: () => Promise<Result>): Promise<Result> {
|
|
84
|
+
const result = tail.then(task)
|
|
85
|
+
tail = result.catch(() => undefined)
|
|
86
|
+
return result
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
## Why a mutex in one process does not help
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
## Start with the database
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
| Problem | Start with | When an actor becomes relevant |
|
|
107
|
+
| ------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
108
|
+
| Duplicate records | A unique constraint | A larger lifecycle also needs ordered, durable work. |
|
|
109
|
+
| Concurrent decrement of a counter or stock | One conditional `UPDATE` in a transaction | The change forms part of holds, expiry, retries, and later commands. |
|
|
110
|
+
| Two people edit an old form | A revision column that the `UPDATE` checks | The record also receives commands from jobs and reminders. |
|
|
111
|
+
| 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. |
|
|
112
|
+
| Commands for one identity arrive from requests, jobs, and reminders | An explicit coordination design | This is the main case for an actor. |
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
118
|
+
|
|
119
|
+
export function holdSeatAtomically({
|
|
120
|
+
database,
|
|
121
|
+
eventId,
|
|
122
|
+
buyer,
|
|
123
|
+
}: {
|
|
124
|
+
database: DatabaseSync
|
|
125
|
+
eventId: string
|
|
126
|
+
buyer: string
|
|
127
|
+
}): { held: boolean } {
|
|
128
|
+
database.exec("BEGIN IMMEDIATE")
|
|
129
|
+
try {
|
|
130
|
+
const result = database
|
|
131
|
+
.prepare("UPDATE seats SET available = available - 1 WHERE event_id = ? AND available > 0")
|
|
132
|
+
.run(eventId)
|
|
133
|
+
if (result.changes === 0) {
|
|
134
|
+
database.exec("ROLLBACK")
|
|
135
|
+
return { held: false }
|
|
136
|
+
}
|
|
137
|
+
database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer)
|
|
138
|
+
database.exec("COMMIT")
|
|
139
|
+
return { held: true }
|
|
140
|
+
} catch (error) {
|
|
141
|
+
database.exec("ROLLBACK")
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
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`.
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
## When one statement is not enough
|
|
154
|
+
|
|
155
|
+
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.
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
## One actor owns the seats for one event
|
|
160
|
+
|
|
161
|
+
`EventSeats` keeps the capacity, holds, and sales for one event. It calculates availability from that state.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
import { Actor } from "solid-objects"
|
|
165
|
+
|
|
166
|
+
export const HOLD_MILLISECONDS = 10 * 60 * 1000
|
|
167
|
+
|
|
168
|
+
export type EventCaller = { userId: string; role: "buyer" | "organizer" | "system" }
|
|
169
|
+
|
|
170
|
+
type Hold = { buyer: string; expiresAt: number }
|
|
171
|
+
|
|
172
|
+
export class EventSeats extends Actor {
|
|
173
|
+
static override readonly actorType = "EventSeats"
|
|
174
|
+
|
|
175
|
+
capacity = 0
|
|
176
|
+
revision = 0
|
|
177
|
+
holds: Record<string, Hold> = {}
|
|
178
|
+
sold: Record<string, string> = {}
|
|
179
|
+
|
|
180
|
+
get available(): number {
|
|
181
|
+
return this.capacity - Object.keys(this.holds).length - Object.keys(this.sold).length
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): {
|
|
185
|
+
revision: number
|
|
186
|
+
} {
|
|
187
|
+
if (!Number.isSafeInteger(capacity) || capacity < 0) {
|
|
188
|
+
this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" })
|
|
189
|
+
}
|
|
190
|
+
if (expectedRevision !== this.revision) {
|
|
191
|
+
this.reject("stale_revision", {
|
|
192
|
+
message: "The event changed after you loaded it",
|
|
193
|
+
details: { revision: this.revision },
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
if (capacity < this.capacity - this.available) {
|
|
197
|
+
this.reject("capacity_below_sold", { message: "Holds and sales already use more seats" })
|
|
198
|
+
}
|
|
199
|
+
this.capacity = capacity
|
|
200
|
+
this.revision += 1
|
|
201
|
+
return { revision: this.revision }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
hold({ holdId, buyer }: { holdId: string; buyer: string }): { held: boolean; available: number } {
|
|
205
|
+
if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) {
|
|
206
|
+
return { held: true, available: this.available }
|
|
207
|
+
}
|
|
208
|
+
if (this.available <= 0) return { held: false, available: 0 }
|
|
209
|
+
|
|
210
|
+
const expiresAt = Date.now() + HOLD_MILLISECONDS
|
|
211
|
+
this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } }
|
|
212
|
+
this.schedule({ at: new Date(expiresAt), key: holdId }).expire({ holdId })
|
|
213
|
+
return { held: true, available: this.available }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
confirm({ holdId }: { holdId: string }): { confirmed: boolean } {
|
|
217
|
+
if (Object.hasOwn(this.sold, holdId)) return { confirmed: true }
|
|
218
|
+
const hold = this.holds[holdId]
|
|
219
|
+
if (!hold) {
|
|
220
|
+
this.reject("hold_expired", { message: "The hold expired before the payment arrived" })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.holds = withoutKey(this.holds, holdId)
|
|
224
|
+
this.sold = { ...this.sold, [holdId]: hold.buyer }
|
|
225
|
+
this.unschedule("expire", { key: holdId })
|
|
226
|
+
return { confirmed: true }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
expire({ holdId }: { holdId: string }): { expired: boolean } {
|
|
230
|
+
if (!Object.hasOwn(this.holds, holdId)) return { expired: false }
|
|
231
|
+
|
|
232
|
+
this.holds = withoutKey(this.holds, holdId)
|
|
233
|
+
return { expired: true }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function withoutKey<Value>(record: Record<string, Value>, key: string): Record<string, Value> {
|
|
238
|
+
return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key))
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
246
|
+
`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.
|
|
247
|
+
|
|
248
|
+
`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.
|
|
249
|
+
|
|
250
|
+
`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.
|
|
251
|
+
|
|
252
|
+
`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.
|
|
253
|
+
|
|
254
|
+
## Order alone does not stop a stale write
|
|
255
|
+
|
|
256
|
+
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.
|
|
257
|
+
|
|
258
|
+
`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.
|
|
259
|
+
|
|
260
|
+
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.
|
|
261
|
+
|
|
262
|
+
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.
|
|
263
|
+
|
|
264
|
+
## Authorize every caller
|
|
265
|
+
|
|
266
|
+
All authorization callbacks deny by default. The example supplies policies for messages and queries.
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
|
|
270
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
271
|
+
import { EventSeats, type EventCaller } from "./event-seats.js"
|
|
272
|
+
|
|
273
|
+
const SYSTEM_OPERATIONS = new Set(["confirm", "expire"])
|
|
274
|
+
|
|
275
|
+
export function authorizeEventMessage({
|
|
276
|
+
actorType,
|
|
277
|
+
operation,
|
|
278
|
+
authorizationContext,
|
|
279
|
+
}: {
|
|
280
|
+
actorType: string
|
|
281
|
+
operation: string
|
|
282
|
+
authorizationContext: unknown
|
|
283
|
+
}): boolean {
|
|
284
|
+
if (actorType !== EventSeats.actorType) return false
|
|
285
|
+
if (!isEventCaller(authorizationContext)) return false
|
|
286
|
+
if (operation === "setCapacity") return authorizationContext.role === "organizer"
|
|
287
|
+
if (SYSTEM_OPERATIONS.has(operation)) return authorizationContext.role === "system"
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function authorizeEventQuery({
|
|
292
|
+
actorType,
|
|
293
|
+
authorizationContext,
|
|
294
|
+
}: {
|
|
295
|
+
actorType: string
|
|
296
|
+
authorizationContext: unknown
|
|
297
|
+
}): boolean {
|
|
298
|
+
return actorType === EventSeats.actorType && isEventCaller(authorizationContext)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function eventRuntime({ path }: { path: string }): SolidObjectsRuntime {
|
|
302
|
+
const runtime = createRuntime({
|
|
303
|
+
database: sqlite({ path }),
|
|
304
|
+
authorizeMessage: authorizeEventMessage,
|
|
305
|
+
authorizeQuery: authorizeEventQuery,
|
|
306
|
+
})
|
|
307
|
+
runtime.register(EventSeats)
|
|
308
|
+
return runtime
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isEventCaller(value: unknown): value is EventCaller {
|
|
312
|
+
if (typeof value !== "object" || value === null) return false
|
|
313
|
+
if (!("userId" in value) || typeof value.userId !== "string") return false
|
|
314
|
+
if (!("role" in value)) return false
|
|
315
|
+
return value.role === "buyer" || value.role === "organizer" || value.role === "system"
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
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.
|
|
322
|
+
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
## Run the runtime process
|
|
326
|
+
|
|
327
|
+
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.
|
|
328
|
+
|
|
329
|
+
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).
|
|
330
|
+
|
|
331
|
+
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.
|
|
332
|
+
|
|
333
|
+
## What the tests prove
|
|
334
|
+
|
|
335
|
+
The [test file](../../test/guide-race-conditions.test.ts) checks these outcomes:
|
|
336
|
+
|
|
337
|
+
- Two processes attempt eight holds at the same time against a capacity of three. Only three holds succeed.
|
|
338
|
+
- A repeated hold applies once and leaves the same available count.
|
|
339
|
+
- A hold expires after a runtime restart when its reminder is due.
|
|
340
|
+
- A late expiry after a confirmation changes nothing.
|
|
341
|
+
- A confirmation after expiry rejects with `hold_expired`.
|
|
342
|
+
- A capacity change from a stale form rejects with `stale_revision`.
|
|
343
|
+
- A fractional or negative capacity rejects with `invalid_capacity`.
|
|
344
|
+
- A buyer cannot change capacity.
|
|
345
|
+
|
|
346
|
+
The assertions check both results and stored state. They show the behavior of these examples and their conflict cases.
|
|
347
|
+
|
|
348
|
+
## Limits and costs
|
|
349
|
+
|
|
350
|
+
Delivery is at least once. Write each operation so that it can run again. The runtime can retry work after a failure.
|
|
351
|
+
|
|
352
|
+
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).
|
|
353
|
+
|
|
354
|
+
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.
|
|
355
|
+
|
|
356
|
+
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.
|
|
357
|
+
|
|
358
|
+
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).
|
|
359
|
+
|
|
360
|
+
## Sources
|
|
361
|
+
|
|
362
|
+
[SQLite transaction documentation, BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html), checked October 9, 2026.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NonRetryableError } from "solid-objects/core"
|
|
2
|
+
import { Actor, configure, registerTransmit, sharedSqliteWasm } from "solid-objects/browser/host"
|
|
3
|
+
|
|
4
|
+
class Inspection extends Actor {
|
|
5
|
+
static actorType = "Inspection"
|
|
6
|
+
|
|
7
|
+
findings = []
|
|
8
|
+
|
|
9
|
+
record({ findingId, note }) {
|
|
10
|
+
if (this.findings.some((finding) => finding.findingId === findingId)) {
|
|
11
|
+
return this.findings.length
|
|
12
|
+
}
|
|
13
|
+
this.findings = [...this.findings, { findingId, note }]
|
|
14
|
+
this.transmit().record({ findingId, note })
|
|
15
|
+
return this.findings.length
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const allowInspections = ({ actorType }) => actorType === "Inspection"
|
|
20
|
+
|
|
21
|
+
const runtime = configure({
|
|
22
|
+
database: sharedSqliteWasm({ path: "inspections.db" }),
|
|
23
|
+
authorizeMessage: allowInspections,
|
|
24
|
+
authorizeQuery: allowInspections,
|
|
25
|
+
maxAttempts: 1_000,
|
|
26
|
+
retryDelayMilliseconds: (attempt) => Math.min(2 ** (attempt - 1), 30) * 1_000,
|
|
27
|
+
processAliveThresholdMilliseconds: 750,
|
|
28
|
+
leaseDurationMilliseconds: 750,
|
|
29
|
+
leaseRenewalIntervalMilliseconds: 250,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
registerTransmit({
|
|
33
|
+
runtime,
|
|
34
|
+
deliver: async (envelope) => {
|
|
35
|
+
const response = await fetch("/inspections/sync", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify(envelope),
|
|
39
|
+
})
|
|
40
|
+
if (response.status === 422) {
|
|
41
|
+
throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`)
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`)
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
runtime.register(Inspection)
|
|
47
|
+
const installed = runtime.install()
|
|
48
|
+
installed.then(() => runtime.run(new AbortController().signal))
|
|
49
|
+
|
|
50
|
+
self.onmessage = async (event) => {
|
|
51
|
+
const { requestId, actorId, operation, argumentsValue } = event.data
|
|
52
|
+
try {
|
|
53
|
+
await installed
|
|
54
|
+
const value = await Inspection.ref(actorId)[operation](argumentsValue)
|
|
55
|
+
postMessage({ requestId, ok: true, value })
|
|
56
|
+
} catch (error) {
|
|
57
|
+
postMessage({ requestId, ok: false, message: String(error?.message ?? error) })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
let pending = []
|
|
2
|
+
|
|
3
|
+
export function queueFinding(finding) {
|
|
4
|
+
pending = [...pending, finding]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function pendingFindings() {
|
|
8
|
+
return pending.length
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function flushFindings({ send }) {
|
|
12
|
+
while (pending.length > 0) {
|
|
13
|
+
await send(pending[0])
|
|
14
|
+
pending = pending.slice(1)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const worker = new Worker(new URL("./inspection-worker.js", import.meta.url), { type: "module" })
|
|
2
|
+
const pending = new Map()
|
|
3
|
+
let nextRequestId = 0
|
|
4
|
+
let workerFailure
|
|
5
|
+
|
|
6
|
+
worker.onmessage = (event) => {
|
|
7
|
+
const { requestId, ok, value, message } = event.data
|
|
8
|
+
const request = pending.get(requestId)
|
|
9
|
+
pending.delete(requestId)
|
|
10
|
+
if (ok) {
|
|
11
|
+
request.resolve(value)
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
request.reject(new Error(message))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
worker.onerror = (event) => {
|
|
18
|
+
workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`)
|
|
19
|
+
for (const request of pending.values()) request.reject(workerFailure)
|
|
20
|
+
pending.clear()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function callActor({ actorId, operation, argumentsValue }) {
|
|
24
|
+
if (workerFailure) return Promise.reject(workerFailure)
|
|
25
|
+
const requestId = nextRequestId++
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
pending.set(requestId, { resolve, reject })
|
|
28
|
+
worker.postMessage({ requestId, actorId, operation, argumentsValue })
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Actor } from "solid-objects"
|
|
2
|
+
|
|
3
|
+
type Finding = { findingId: string; note: string }
|
|
4
|
+
|
|
5
|
+
export class Inspection extends Actor {
|
|
6
|
+
static override readonly actorType = "Inspection"
|
|
7
|
+
|
|
8
|
+
findings: Finding[] = []
|
|
9
|
+
|
|
10
|
+
record({ findingId, note }: Finding): number {
|
|
11
|
+
if (this.findings.some((finding) => finding.findingId === findingId)) {
|
|
12
|
+
return this.findings.length
|
|
13
|
+
}
|
|
14
|
+
this.findings = [...this.findings, { findingId, note }]
|
|
15
|
+
return this.findings.length
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IdempotencyConflict,
|
|
3
|
+
InvalidPayload,
|
|
4
|
+
receiveTransmitEnvelope,
|
|
5
|
+
UnknownActorType,
|
|
6
|
+
UnknownOperation,
|
|
7
|
+
type SolidObjectsRuntime,
|
|
8
|
+
type TransmitEnvelope,
|
|
9
|
+
} from "solid-objects"
|
|
10
|
+
|
|
11
|
+
export async function handleInspectionSync({
|
|
12
|
+
request,
|
|
13
|
+
runtime,
|
|
14
|
+
canWrite,
|
|
15
|
+
}: {
|
|
16
|
+
request: Request
|
|
17
|
+
runtime: SolidObjectsRuntime
|
|
18
|
+
canWrite: (options: { request: Request; envelope: TransmitEnvelope }) => Promise<boolean>
|
|
19
|
+
}): Promise<Response> {
|
|
20
|
+
const envelope = (await request.json()) as TransmitEnvelope
|
|
21
|
+
if (!(await canWrite({ request, envelope }))) return new Response("Forbidden", { status: 403 })
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await receiveTransmitEnvelope({ runtime, envelope })
|
|
25
|
+
return Response.json({})
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (
|
|
28
|
+
error instanceof InvalidPayload ||
|
|
29
|
+
error instanceof IdempotencyConflict ||
|
|
30
|
+
error instanceof UnknownActorType ||
|
|
31
|
+
error instanceof UnknownOperation
|
|
32
|
+
) {
|
|
33
|
+
return new Response(null, { status: 422 })
|
|
34
|
+
}
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type MemoryRoom = { players: string[]; turnNumber: number; started: boolean }
|
|
2
|
+
|
|
3
|
+
export function createMemoryRooms({ turnMilliseconds }: { turnMilliseconds: number }) {
|
|
4
|
+
const rooms = new Map<string, MemoryRoom>()
|
|
5
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
6
|
+
|
|
7
|
+
function armTurnTimer(roomId: string): void {
|
|
8
|
+
clearTimeout(timers.get(roomId))
|
|
9
|
+
timers.set(
|
|
10
|
+
roomId,
|
|
11
|
+
setTimeout(() => skipTurn(roomId), turnMilliseconds),
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function skipTurn(roomId: string): void {
|
|
16
|
+
const room = rooms.get(roomId)
|
|
17
|
+
if (!room) return
|
|
18
|
+
rooms.set(roomId, { ...room, turnNumber: room.turnNumber + 1 })
|
|
19
|
+
armTurnTimer(roomId)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
join({ roomId, playerId }: { roomId: string; playerId: string }): void {
|
|
24
|
+
const room = rooms.get(roomId) ?? { players: [], turnNumber: 0, started: false }
|
|
25
|
+
rooms.set(roomId, { ...room, players: [...room.players, playerId] })
|
|
26
|
+
},
|
|
27
|
+
start({ roomId }: { roomId: string }): void {
|
|
28
|
+
const room = rooms.get(roomId)
|
|
29
|
+
if (!room) return
|
|
30
|
+
rooms.set(roomId, { ...room, started: true })
|
|
31
|
+
armTurnTimer(roomId)
|
|
32
|
+
},
|
|
33
|
+
room({ roomId }: { roomId: string }): MemoryRoom | undefined {
|
|
34
|
+
return rooms.get(roomId)
|
|
35
|
+
},
|
|
36
|
+
pendingTimers(): number {
|
|
37
|
+
return timers.size
|
|
38
|
+
},
|
|
39
|
+
close(): void {
|
|
40
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
41
|
+
timers.clear()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ActorSnapshot, SolidObjectsRuntime } from "solid-objects"
|
|
2
|
+
import { TurnRoom } from "./turn-room.js"
|
|
3
|
+
|
|
4
|
+
export async function resumeRoom({
|
|
5
|
+
runtime,
|
|
6
|
+
roomId,
|
|
7
|
+
playerId,
|
|
8
|
+
lastSeenRevision,
|
|
9
|
+
}: {
|
|
10
|
+
runtime: SolidObjectsRuntime
|
|
11
|
+
roomId: string
|
|
12
|
+
playerId: string
|
|
13
|
+
lastSeenRevision: number
|
|
14
|
+
}): Promise<{ room: ActorSnapshot<TurnRoom>; changed: boolean } | null> {
|
|
15
|
+
const room = await runtime.ref(TurnRoom, roomId).snapshot({ authorizationContext: { playerId } })
|
|
16
|
+
if (!room.players.includes(playerId)) return null
|
|
17
|
+
return { room, changed: room.revision !== lastSeenRevision }
|
|
18
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
|
|
2
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
3
|
+
import { TurnRoom } from "./turn-room.js"
|
|
4
|
+
|
|
5
|
+
export type RoomCaller = { playerId: string }
|
|
6
|
+
|
|
7
|
+
const PLAYER_OPERATIONS = new Set(["join", "start", "play"])
|
|
8
|
+
|
|
9
|
+
export function authorizeRoomMessage({
|
|
10
|
+
actorType,
|
|
11
|
+
operation,
|
|
12
|
+
arguments: argumentsValue,
|
|
13
|
+
authorizationContext,
|
|
14
|
+
}: {
|
|
15
|
+
actorType: string
|
|
16
|
+
operation: string
|
|
17
|
+
arguments: Record<string, unknown>
|
|
18
|
+
authorizationContext: unknown
|
|
19
|
+
}): boolean {
|
|
20
|
+
if (actorType !== TurnRoom.actorType) return false
|
|
21
|
+
if (!PLAYER_OPERATIONS.has(operation)) return false
|
|
22
|
+
if (!isRoomCaller(authorizationContext)) return false
|
|
23
|
+
return argumentsValue.playerId === authorizationContext.playerId
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function authorizeRoomQuery({
|
|
27
|
+
actorType,
|
|
28
|
+
authorizationContext,
|
|
29
|
+
}: {
|
|
30
|
+
actorType: string
|
|
31
|
+
authorizationContext: unknown
|
|
32
|
+
}): boolean {
|
|
33
|
+
return actorType === TurnRoom.actorType && isRoomCaller(authorizationContext)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function roomRuntime({ path }: { path: string }): SolidObjectsRuntime {
|
|
37
|
+
const runtime = createRuntime({
|
|
38
|
+
database: sqlite({ path }),
|
|
39
|
+
authorizeMessage: authorizeRoomMessage,
|
|
40
|
+
authorizeQuery: authorizeRoomQuery,
|
|
41
|
+
})
|
|
42
|
+
runtime.register(TurnRoom)
|
|
43
|
+
return runtime
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRoomCaller(value: unknown): value is RoomCaller {
|
|
47
|
+
if (typeof value !== "object" || value === null) return false
|
|
48
|
+
return "playerId" in value && typeof value.playerId === "string"
|
|
49
|
+
}
|