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,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.
package/docs/parity.md CHANGED
@@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
4
4
  Parity preserves a capability and its correctness or security boundary. It does
5
5
  not copy a Rails API into Node.
6
6
 
7
- Reference: Ruby `solid_objects` 0.17.2. The JavaScript package began at the Ruby
7
+ Reference: Ruby `solid_objects` 0.17.4. The JavaScript package began at the Ruby
8
8
  design's `0.12` capability generation; that version number did not imply earlier
9
9
  JavaScript releases.
10
10
 
@@ -54,7 +54,7 @@ is needed for the JavaScript stale-result race fix.
54
54
  | Query and projection purity | Native | Both runtimes reject state mutation and staged effects, recovery checks, commit actions, reminders, or outbound messages in queries, observable projections, and personalized payloads. Projection guards compare the complete staged work, including same-count replacements. Query and observable violations fail terminally with `QueryMutatedState`; payload violations fail only that payload. Snapshot projection reads enforce the same rule. |
55
55
  | JSON property preservation | Native | Reserved property names such as `__proto__`, `constructor`, and `prototype` remain ordinary data, including through size checks and immutable copies. Both suites consume `compatibility/json-values.json`. |
56
56
  | Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
57
- | Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |
57
+ | Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Both runtimes renew between turns when due and retain the fence while a claim remains, allowing expired-turn recovery in mailbox order even with one allowed attempt. Reclaiming increments the attempt count; lease loss alone does not dead-letter a message. Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |
58
58
  | Domain rejection and strict poison ordering | Native | Rejections accept JavaScript identifier-style codes and roll back without retry; invalid codes fail terminally, while retryable failures block later operations until completion or dead-lettering. |
59
59
  | Bounded activation passes and hot-actor fairness | Native | Configurable turn-count and elapsed-time budgets bound each pass, then move only that actor's already-due memberships behind actors already waiting. |
60
60
  | Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
@@ -284,6 +284,14 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it
284
284
  found (the optional `arguments` default) is fixed and pinned by the shared
285
285
  fixture.
286
286
 
287
+ The `Compatibility fixtures` workflow compares each `compatibility/*.json` file
288
+ with the same file in solid-objects-ruby at `main`, byte for byte. It runs on
289
+ each push to `main`, one time each day, and on demand. It does not run on a
290
+ pull request, because the other repository holds the old fixture until the two
291
+ pull requests of a paired change merge. Run the same check on a local clone of
292
+ the Ruby repository with
293
+ `node scripts/check-compatibility-fixtures.mjs compatibility ../solid-objects-ruby/compatibility`.
294
+
287
295
  ## JavaScript-only: Cloudflare hosting
288
296
 
289
297
  The experimental `solid-objects/cloudflare` backend hosts each actor identity
@@ -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
+ }