solid-objects 0.12.0

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 (187) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/MIT-LICENSE +19 -0
  3. package/README.md +958 -0
  4. package/dist/actor.d.ts +81 -0
  5. package/dist/actor.d.ts.map +1 -0
  6. package/dist/actor.js +141 -0
  7. package/dist/actor.js.map +1 -0
  8. package/dist/application-database.d.ts +3 -0
  9. package/dist/application-database.d.ts.map +1 -0
  10. package/dist/application-database.js +59 -0
  11. package/dist/application-database.js.map +1 -0
  12. package/dist/broadcast-worker.d.ts +18 -0
  13. package/dist/broadcast-worker.d.ts.map +1 -0
  14. package/dist/broadcast-worker.js +74 -0
  15. package/dist/broadcast-worker.js.map +1 -0
  16. package/dist/browser/components.d.ts +64 -0
  17. package/dist/browser/components.d.ts.map +1 -0
  18. package/dist/browser/components.js +294 -0
  19. package/dist/browser/components.js.map +1 -0
  20. package/dist/browser/index.d.ts +46 -0
  21. package/dist/browser/index.d.ts.map +1 -0
  22. package/dist/browser/index.js +207 -0
  23. package/dist/browser/index.js.map +1 -0
  24. package/dist/cli.d.ts +9 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +216 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/configuration.d.ts +96 -0
  29. package/dist/configuration.d.ts.map +1 -0
  30. package/dist/configuration.js +169 -0
  31. package/dist/configuration.js.map +1 -0
  32. package/dist/context.d.ts +20 -0
  33. package/dist/context.d.ts.map +1 -0
  34. package/dist/context.js +24 -0
  35. package/dist/context.js.map +1 -0
  36. package/dist/database/deadline.d.ts +9 -0
  37. package/dist/database/deadline.d.ts.map +1 -0
  38. package/dist/database/deadline.js +49 -0
  39. package/dist/database/deadline.js.map +1 -0
  40. package/dist/database/mysql.d.ts +21 -0
  41. package/dist/database/mysql.d.ts.map +1 -0
  42. package/dist/database/mysql.js +192 -0
  43. package/dist/database/mysql.js.map +1 -0
  44. package/dist/database/postgresql-sql.d.ts +2 -0
  45. package/dist/database/postgresql-sql.d.ts.map +1 -0
  46. package/dist/database/postgresql-sql.js +102 -0
  47. package/dist/database/postgresql-sql.js.map +1 -0
  48. package/dist/database/postgresql.d.ts +31 -0
  49. package/dist/database/postgresql.d.ts.map +1 -0
  50. package/dist/database/postgresql.js +172 -0
  51. package/dist/database/postgresql.js.map +1 -0
  52. package/dist/database/sqlite.d.ts +27 -0
  53. package/dist/database/sqlite.d.ts.map +1 -0
  54. package/dist/database/sqlite.js +190 -0
  55. package/dist/database/sqlite.js.map +1 -0
  56. package/dist/database/transaction-context.d.ts +3 -0
  57. package/dist/database/transaction-context.d.ts.map +1 -0
  58. package/dist/database/transaction-context.js +19 -0
  59. package/dist/database/transaction-context.js.map +1 -0
  60. package/dist/database/types.d.ts +20 -0
  61. package/dist/database/types.d.ts.map +1 -0
  62. package/dist/database/types.js +2 -0
  63. package/dist/database/types.js.map +1 -0
  64. package/dist/dead-letters.d.ts +23 -0
  65. package/dist/dead-letters.d.ts.map +1 -0
  66. package/dist/dead-letters.js +13 -0
  67. package/dist/dead-letters.js.map +1 -0
  68. package/dist/default-runtime.d.ts +5 -0
  69. package/dist/default-runtime.d.ts.map +1 -0
  70. package/dist/default-runtime.js +14 -0
  71. package/dist/default-runtime.js.map +1 -0
  72. package/dist/definition.d.ts +32 -0
  73. package/dist/definition.d.ts.map +1 -0
  74. package/dist/definition.js +173 -0
  75. package/dist/definition.js.map +1 -0
  76. package/dist/doctor.d.ts +32 -0
  77. package/dist/doctor.d.ts.map +1 -0
  78. package/dist/doctor.js +500 -0
  79. package/dist/doctor.js.map +1 -0
  80. package/dist/effect-worker.d.ts +18 -0
  81. package/dist/effect-worker.d.ts.map +1 -0
  82. package/dist/effect-worker.js +74 -0
  83. package/dist/effect-worker.js.map +1 -0
  84. package/dist/errors.d.ts +145 -0
  85. package/dist/errors.d.ts.map +1 -0
  86. package/dist/errors.js +120 -0
  87. package/dist/errors.js.map +1 -0
  88. package/dist/executable.d.ts +3 -0
  89. package/dist/executable.d.ts.map +1 -0
  90. package/dist/executable.js +12 -0
  91. package/dist/executable.js.map +1 -0
  92. package/dist/index.d.ts +24 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +21 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/maintenance-scheduler.d.ts +17 -0
  97. package/dist/maintenance-scheduler.d.ts.map +1 -0
  98. package/dist/maintenance-scheduler.js +41 -0
  99. package/dist/maintenance-scheduler.js.map +1 -0
  100. package/dist/process-administration.d.ts +30 -0
  101. package/dist/process-administration.d.ts.map +1 -0
  102. package/dist/process-administration.js +13 -0
  103. package/dist/process-administration.js.map +1 -0
  104. package/dist/realtime.d.ts +33 -0
  105. package/dist/realtime.d.ts.map +1 -0
  106. package/dist/realtime.js +238 -0
  107. package/dist/realtime.js.map +1 -0
  108. package/dist/reconciliation.d.ts +41 -0
  109. package/dist/reconciliation.d.ts.map +1 -0
  110. package/dist/reconciliation.js +19 -0
  111. package/dist/reconciliation.js.map +1 -0
  112. package/dist/records.d.ts +137 -0
  113. package/dist/records.d.ts.map +1 -0
  114. package/dist/records.js +2 -0
  115. package/dist/records.js.map +1 -0
  116. package/dist/reference.d.ts +97 -0
  117. package/dist/reference.d.ts.map +1 -0
  118. package/dist/reference.js +157 -0
  119. package/dist/reference.js.map +1 -0
  120. package/dist/reminder-administration.d.ts +35 -0
  121. package/dist/reminder-administration.d.ts.map +1 -0
  122. package/dist/reminder-administration.js +13 -0
  123. package/dist/reminder-administration.js.map +1 -0
  124. package/dist/reminder-scheduler.d.ts +18 -0
  125. package/dist/reminder-scheduler.d.ts.map +1 -0
  126. package/dist/reminder-scheduler.js +88 -0
  127. package/dist/reminder-scheduler.js.map +1 -0
  128. package/dist/repository.d.ts +142 -0
  129. package/dist/repository.d.ts.map +1 -0
  130. package/dist/repository.js +1237 -0
  131. package/dist/repository.js.map +1 -0
  132. package/dist/retention.d.ts +17 -0
  133. package/dist/retention.d.ts.map +1 -0
  134. package/dist/retention.js +13 -0
  135. package/dist/retention.js.map +1 -0
  136. package/dist/runtime.d.ts +140 -0
  137. package/dist/runtime.d.ts.map +1 -0
  138. package/dist/runtime.js +1737 -0
  139. package/dist/runtime.js.map +1 -0
  140. package/dist/schema.d.ts +8 -0
  141. package/dist/schema.d.ts.map +1 -0
  142. package/dist/schema.js +260 -0
  143. package/dist/schema.js.map +1 -0
  144. package/dist/serialization.d.ts +11 -0
  145. package/dist/serialization.d.ts.map +1 -0
  146. package/dist/serialization.js +82 -0
  147. package/dist/serialization.js.map +1 -0
  148. package/dist/test-helper.d.ts +15 -0
  149. package/dist/test-helper.d.ts.map +1 -0
  150. package/dist/test-helper.js +69 -0
  151. package/dist/test-helper.js.map +1 -0
  152. package/dist/types.d.ts +71 -0
  153. package/dist/types.d.ts.map +1 -0
  154. package/dist/types.js +2 -0
  155. package/dist/types.js.map +1 -0
  156. package/dist/version.d.ts +2 -0
  157. package/dist/version.d.ts.map +1 -0
  158. package/dist/version.js +2 -0
  159. package/dist/version.js.map +1 -0
  160. package/dist/wake-up/postgresql.d.ts +42 -0
  161. package/dist/wake-up/postgresql.d.ts.map +1 -0
  162. package/dist/wake-up/postgresql.js +234 -0
  163. package/dist/wake-up/postgresql.js.map +1 -0
  164. package/dist/wake-up/redis.d.ts +35 -0
  165. package/dist/wake-up/redis.d.ts.map +1 -0
  166. package/dist/wake-up/redis.js +151 -0
  167. package/dist/wake-up/redis.js.map +1 -0
  168. package/dist/wake-up.d.ts +24 -0
  169. package/dist/wake-up.d.ts.map +1 -0
  170. package/dist/wake-up.js +66 -0
  171. package/dist/wake-up.js.map +1 -0
  172. package/dist/worker.d.ts +36 -0
  173. package/dist/worker.d.ts.map +1 -0
  174. package/dist/worker.js +281 -0
  175. package/dist/worker.js.map +1 -0
  176. package/docs/api.md +197 -0
  177. package/docs/architecture.md +113 -0
  178. package/docs/authorization.md +31 -0
  179. package/docs/browser-protocol.md +93 -0
  180. package/docs/configuration.md +168 -0
  181. package/docs/correctness.md +53 -0
  182. package/docs/errors-and-recovery.md +89 -0
  183. package/docs/operations.md +170 -0
  184. package/docs/parity.md +94 -0
  185. package/docs/releasing.md +45 -0
  186. package/docs/state-and-lifecycle.md +162 -0
  187. package/package.json +100 -0
package/README.md ADDED
@@ -0,0 +1,958 @@
1
+ # Solid Objects JS
2
+
3
+ **Stateful virtual actors for Node.js, powered entirely by your relational database.**
4
+
5
+ Get the programming model of Cloudflare Durable Objects without moving your
6
+ state into custom cloud isolates. Write ordinary TypeScript classes; Solid
7
+ Objects gives each instance a durable identity, persisted state, an ordered
8
+ mailbox, and safe one-at-a-time execution.
9
+
10
+ No Redis locks. No separate message broker for actor mailboxes. No proprietary
11
+ state service. The database you already understand provides the transaction,
12
+ lease, fencing, retry, timer, and outbox primitives.
13
+
14
+ > SQLite uses Node's built-in `node:sqlite` module. PostgreSQL 14 or newer and
15
+ > MySQL 8.0 or newer use optional driver peer dependencies.
16
+
17
+ ## The boring stack, with an actor model
18
+
19
+ Most stateful features eventually need the same machinery: load one entity,
20
+ serialize concurrent changes, persist the result, schedule follow-up work, and
21
+ recover after a process dies. Applications often assemble that machinery from
22
+ a database, Redis, a queue, distributed locks, and a pile of retry code.
23
+
24
+ Solid Objects keeps that coordination in one place: the relational database.
25
+
26
+ - An actor is addressed by its TypeScript class and ID.
27
+ - Public fields are JSON state.
28
+ - Public methods are durable operations.
29
+ - Public getters are ordered, read-only queries.
30
+ - Every actor has a durable, sequential mailbox.
31
+ - State, results, actor-to-actor delivery, effects, reminders, and observable
32
+ invalidations commit together.
33
+ - Renewable leases and fencing prevent stale workers from committing.
34
+
35
+ ## What you stop building
36
+
37
+ For workloads organized around durable identities—accounts, carts, game rooms,
38
+ workflows, devices, collaborative documents, or agent sessions—Solid Objects
39
+ replaces a recurring layer of infrastructure and application code:
40
+
41
+ - per-entity locking and race-condition handling;
42
+ - bespoke queue consumers that must preserve ordering;
43
+ - retry bookkeeping and poison-message handling;
44
+ - timer tables and scheduler claim logic;
45
+ - transactional outboxes for follow-up work; and
46
+ - durable invalidation bookkeeping.
47
+
48
+ This is not an in-memory actor library. A call is complete only after its state
49
+ and durable consequences commit to the database.
50
+
51
+ Solid Objects JS ports the programming model of the Ruby
52
+ [`solid_objects`](https://github.com/cardmagic/solid_objects) gem to idiomatic
53
+ TypeScript. The runtimes do not share a database schema or wire protocol.
54
+
55
+ ## Requirements
56
+
57
+ - Node.js 24.15 or newer
58
+ - TypeScript 5.9 or newer for TypeScript applications
59
+ - SQLite, PostgreSQL 14 or newer, or MySQL 8.0 or newer with InnoDB
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pnpm add solid-objects
65
+ ```
66
+
67
+ ## Write an ordinary TypeScript class
68
+
69
+ ```typescript
70
+ import { Actor } from "solid-objects"
71
+
72
+ export class Counter extends Actor {
73
+ static override readonly actorType = "Counter"
74
+
75
+ count = 0
76
+
77
+ get doubled(): number {
78
+ return this.count * 2
79
+ }
80
+
81
+ increment({ amount = 1 }: { amount?: number } = {}): number {
82
+ this.count += amount
83
+ return this.count
84
+ }
85
+
86
+ override observables(): Record<string, unknown> {
87
+ return { count: this.count }
88
+ }
89
+ }
90
+ ```
91
+
92
+ No wrapper, state interface, decorator, or operation union is required. Native
93
+ `#private` fields remain private and are not persisted. Persisted fields,
94
+ operation arguments, results, and observable values must be JSON-compatible.
95
+
96
+ `observables()` is deliberately explicit. State fields and getters do not
97
+ become realtime data automatically.
98
+
99
+ ## Evolve state with explicit migrations
100
+
101
+ Increase `stateVersion` and retain every adjacent migration when persisted
102
+ state changes shape:
103
+
104
+ ```typescript
105
+ import { Actor, type JsonObject } from "solid-objects"
106
+
107
+ export class ShoppingCart extends Actor {
108
+ static override readonly actorType = "ShoppingCart"
109
+ static override readonly stateVersion = 2
110
+ static override readonly migrations = [
111
+ {
112
+ from: 1,
113
+ to: 2,
114
+ migrate: (state: JsonObject): JsonObject => ({
115
+ ...state,
116
+ currency: "USD",
117
+ }),
118
+ },
119
+ ]
120
+
121
+ items: string[] = []
122
+ currency = "USD"
123
+ }
124
+ ```
125
+
126
+ Migrations run in order when an actor is next hydrated. They must be
127
+ deterministic, synchronous, JSON-compatible transformations and cannot write
128
+ through a guarded application database. New field defaults are filled from a
129
+ fresh actor after migration. Do not run application processes with different
130
+ `stateVersion` values at the same time: once new code persists a newer state,
131
+ old code rejects it.
132
+
133
+ See [`docs/state-and-lifecycle.md`](docs/state-and-lifecycle.md) for field
134
+ discovery, rolling deployment, activation hooks, snapshots, rejection, and
135
+ destruction.
136
+
137
+ ## Point it at SQLite, PostgreSQL, or MySQL
138
+
139
+ SQLite needs no database driver package:
140
+
141
+ ```typescript
142
+ import { configure } from "solid-objects"
143
+ import { sqlite } from "solid-objects/database/sqlite"
144
+ import { Counter } from "./counter.js"
145
+
146
+ const runtime = configure({
147
+ database: sqlite({
148
+ path: "storage/solid-objects.sqlite3",
149
+ timeoutMilliseconds: 5_000,
150
+ lockRetryAttempts: 10,
151
+ }),
152
+ authorizeMessage: ({ authorizationContext }) => authorizationContext !== undefined,
153
+ authorizeQuery: ({ authorizationContext }) => authorizationContext !== undefined,
154
+ authorizeDestroy: ({ authorizationContext }) => authorizationContext !== undefined,
155
+ authorizeAdministration: ({ authorizationContext }) => isOperator(authorizationContext),
156
+ authorizeSubscription: ({ actorId, authorizationContext }) =>
157
+ authorizationContext?.canViewCounter(actorId) === true,
158
+ })
159
+
160
+ runtime.register(Counter)
161
+ await runtime.install()
162
+
163
+ const shutdown = new AbortController()
164
+ process.once("SIGTERM", () => shutdown.abort())
165
+ process.once("SIGINT", () => shutdown.abort())
166
+ await runtime.run(shutdown.signal)
167
+ ```
168
+
169
+ SQLite serializes access inside one Node process. Across processes, it uses the
170
+ native busy timeout and retries transient `BEGIN IMMEDIATE` contention with
171
+ short capped backoff. `lockRetryAttempts` bounds those retries; synchronous
172
+ invocations remain bounded by their end-to-end `timeoutMilliseconds` deadline.
173
+
174
+ `configure()` installs this runtime as the default used by `Actor.ref()`. Use
175
+ `createRuntime()` when an application needs an isolated runtime and address its
176
+ actors through `runtime.ref(ActorClass, actorId)`. Actor code executing in that
177
+ runtime resolves its own actor references without changing the global default.
178
+
179
+ For PostgreSQL, install the optional driver and replace the database value:
180
+
181
+ ```bash
182
+ pnpm add pg
183
+ ```
184
+
185
+ ```typescript
186
+ import { postgresql } from "solid-objects/database/postgresql"
187
+
188
+ const connectionString = process.env.DATABASE_URL
189
+ if (!connectionString) throw new Error("DATABASE_URL is required")
190
+
191
+ const database = postgresql({
192
+ connectionString,
193
+ maximumConnections: 10,
194
+ })
195
+
196
+ const runtime = configure({
197
+ database,
198
+ wakeUp: database.wakeUp(),
199
+ })
200
+ ```
201
+
202
+ PostgreSQL uses a bounded `pg` pool, 64-bit database timestamps and sequences,
203
+ row-locked sequence allocation, and the same durable polling contract as
204
+ SQLite. Keep `pg` at 8.23 or newer within the supported major. Portable
205
+ `DatabaseConnection` SQL uses `?` parameters; write `??` when a PostgreSQL query
206
+ needs the literal JSON existence operator.
207
+
208
+ `database.wakeUp()` is opt-in. It uses one event-driven PostgreSQL client per
209
+ runtime to listen on role-specific channels and wake every matching local
210
+ waiter. Create it in every process that should send or receive notifications.
211
+ Polling remains the fallback if a notification is missed or the listener
212
+ reconnects. Because `LISTEN` is session-scoped, use a direct connection or
213
+ session pooling rather than transaction pooling for this client.
214
+
215
+ For MySQL, install `mysql2` and configure its bounded promise pool:
216
+
217
+ ```bash
218
+ pnpm add mysql2
219
+ ```
220
+
221
+ ```typescript
222
+ import { mysql } from "solid-objects/database/mysql"
223
+
224
+ const connectionString = process.env.DATABASE_URL
225
+ if (!connectionString) throw new Error("DATABASE_URL is required")
226
+
227
+ const database = mysql({
228
+ connectionString,
229
+ maximumConnections: 10,
230
+ })
231
+ ```
232
+
233
+ MySQL uses InnoDB tables, 64-bit database timestamps and sequences, row-locked
234
+ sequence allocation, and bounded retries around the side-effect-free enqueue
235
+ transaction when InnoDB selects it as a deadlock victim. Use the Redis wake-up
236
+ adapter when a MySQL deployment wants cross-process notification latency;
237
+ durable polling remains sufficient for correctness.
238
+
239
+ Authorization is deny-by-default. Actor IDs identify actors; they are not
240
+ capabilities.
241
+
242
+ `runtime.run()` supervises every built-in role and registered component. An
243
+ unexpected exit is cleaned up and rebuilt through its original factory with
244
+ capped exponential backoff. Shutdown stops replacement before asking the live
245
+ instances to finish, so no replacement can outlive the runtime. The shared
246
+ shutdown budget defaults to 15 seconds and can be changed with
247
+ `shutdownTimeoutMilliseconds`. Components and actor operations must cooperate
248
+ with cancellation where they receive an `AbortSignal`; actor operations and
249
+ other JavaScript code already running cannot be forcibly terminated.
250
+
251
+ The default in-process wake-up adapter interrupts role polling as soon as this
252
+ runtime commits new work. Polling remains the correctness fallback, so a missed
253
+ or failed signal costs latency rather than losing work. Multi-process hosts not
254
+ using PostgreSQL notifications can provide a `WakeUpAdapter` backed by their
255
+ existing notification system without adding a required broker to the default
256
+ SQLite stack.
257
+
258
+ Applications that already operate Redis can use its optional Pub/Sub adapter:
259
+
260
+ ```bash
261
+ pnpm add redis
262
+ ```
263
+
264
+ ```typescript
265
+ import { redisWakeUp } from "solid-objects/wake-up/redis"
266
+
267
+ const runtime = configure({
268
+ database,
269
+ wakeUp: redisWakeUp({ url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379" }),
270
+ })
271
+ ```
272
+
273
+ The adapter lazily opens separate publisher and subscriber connections because
274
+ a subscribed Redis client cannot issue ordinary commands. Role-specific
275
+ channels wake every matching waiter in the process. Redis Pub/Sub is transient;
276
+ the relational database remains durable truth and bounded polling covers a
277
+ missed notification or unavailable Redis server.
278
+
279
+ ## Call it like a local object
280
+
281
+ `await` is the committed call boundary.
282
+
283
+ ```typescript
284
+ const counter = Counter.ref("primary")
285
+
286
+ const count = await counter.increment({ amount: 2 })
287
+ const doubled = await counter.doubled
288
+ ```
289
+
290
+ The method call is still a durable database operation: it enters the actor's
291
+ mailbox, waits its turn, and resolves with the committed, deeply frozen result.
292
+ If the enqueue transaction commits, a later wait timeout does not cancel the
293
+ durable message. If enqueue itself cannot commit within the timeout,
294
+ `SyncEnqueueTimeout` is raised and no message exists to recover.
295
+
296
+ Use `with()` when invocation behavior needs configuration:
297
+
298
+ ```typescript
299
+ await counter
300
+ .with({
301
+ authorizationContext: currentUser,
302
+ timeoutMilliseconds: 2_000,
303
+ idempotencyKey: "increment-123",
304
+ })
305
+ .increment({ amount: 2 })
306
+ ```
307
+
308
+ Invocation options stay separate from actor arguments, so an actor may safely
309
+ use argument names such as `timeoutMilliseconds` or `authorizationContext`.
310
+ Every invocation receives a generated `requestId`; `idempotencyKey` remains the
311
+ caller's deduplication key and is never reused as request identity. During an
312
+ operation, `this.currentMessage` exposes both values along with `id`,
313
+ `enqueuedAt`, `actorType`, `actorId`, `sequence`, and `attempt`.
314
+
315
+ Use `this.reject()` for an expected domain refusal that should roll back the
316
+ turn without retrying or blocking later mailbox work:
317
+
318
+ ```typescript
319
+ class Reservation extends Actor {
320
+ static override readonly actorType = "Reservation"
321
+
322
+ available = 0
323
+
324
+ reserve({ quantity }: { quantity: number }): void {
325
+ if (quantity > this.available) {
326
+ this.reject("insufficient_inventory", {
327
+ message: "Not enough inventory is available",
328
+ details: { available: this.available },
329
+ })
330
+ }
331
+ this.available -= quantity
332
+ }
333
+ }
334
+ ```
335
+
336
+ Callers receive `Rejected` with `code`, frozen `details`, and the durable
337
+ `messageId`. Unexpected exceptions are retried and eventually surface as
338
+ `MessageFailed`.
339
+
340
+ Do not make a committed actor call or wait on a message from inside
341
+ `database.transaction(...)` on the Solid Objects database. The runtime raises
342
+ `SyncInsideTransaction` before enqueue or waiting, avoiding a self-deadlock on
343
+ the transaction's checked-out connection. Send background work outside the
344
+ transaction, or let the actor coordinate same-database changes through a commit
345
+ action.
346
+
347
+ ## Read snapshots and destroy actors
348
+
349
+ An authorized snapshot reads all persisted fields and getters from one
350
+ committed state image without entering the mailbox:
351
+
352
+ ```typescript
353
+ const snapshot = await Counter.ref("primary").snapshot({
354
+ authorizationContext: currentUser,
355
+ })
356
+
357
+ console.log(snapshot.count, snapshot.doubled)
358
+ ```
359
+
360
+ Snapshots are deeply frozen. Getters must not mutate state or stage durable
361
+ work. Because snapshots do not enter the mailbox, use an ordinary query when
362
+ the read must be ordered behind earlier messages.
363
+
364
+ Destroy an actor through its separate deny-by-default policy:
365
+
366
+ ```typescript
367
+ const destroyed = await Counter.ref("primary").destroy({
368
+ authorizationContext: currentUser,
369
+ })
370
+ ```
371
+
372
+ Destruction is idempotent and cascades through the current incarnation's
373
+ state, mailbox history, effects, reminders, broadcasts, and dead letters. A
374
+ later message creates a new incarnation, and an authorized waiter on the old
375
+ one receives `ActorDestroyed`.
376
+
377
+ ## Send background work without a queue service
378
+
379
+ Use the typed `send` dispatcher when the caller should not wait for execution:
380
+
381
+ ```typescript
382
+ const message = await counter.send.increment({ amount: 2 })
383
+
384
+ const delayed = await counter.send
385
+ .with({
386
+ availableAt: new Date(Date.now() + 60_000),
387
+ idempotencyKey: "increment-later",
388
+ authorizationContext: currentUser,
389
+ })
390
+ .increment({ amount: 2 })
391
+
392
+ await message.status()
393
+ await message.result()
394
+ await message.wait({ timeoutMilliseconds: 2_000 })
395
+ ```
396
+
397
+ Actor code must not call another reference directly or through `send`. Use
398
+ `sendTo()` so outbound delivery commits atomically with the source actor turn—no
399
+ separate broker or hand-built transactional outbox required:
400
+
401
+ ```typescript
402
+ class Account extends Actor {
403
+ static override readonly actorType = "Account"
404
+
405
+ disable({ auditLogId }: { auditLogId: string }): void {
406
+ this.sendTo(AuditLog.ref(auditLogId)).record({
407
+ eventName: "account_disabled",
408
+ })
409
+ }
410
+ }
411
+ ```
412
+
413
+ If `disable` fails or is rejected, the staged audit message is discarded.
414
+
415
+ ## Use database-backed timers
416
+
417
+ Reminders are actor-owned durable alarms. The operation name is also the
418
+ reminder identity, so scheduling it again moves the existing reminder.
419
+
420
+ ```typescript
421
+ class Trial extends Actor {
422
+ static override readonly actorType = "Trial"
423
+
424
+ expired = false
425
+
426
+ armExpiration(): void {
427
+ this.schedule({ at: new Date(Date.now() + 86_400_000) }).expire!()
428
+ }
429
+
430
+ reconcile(): void {
431
+ if (!this.expired) this.armExpiration()
432
+ }
433
+
434
+ expire(): void {
435
+ this.expired = true
436
+ }
437
+ }
438
+ ```
439
+
440
+ The non-null assertion is only needed by projects using
441
+ `noUncheckedIndexedAccess`; runtime registration still rejects unknown reminder
442
+ operations before persistence. Recurring reminders accept `everyMilliseconds`
443
+ and a `missed` policy of `"latest"` or `"all"`.
444
+
445
+ Authorized operators can inspect alarm metadata and resume a reminder that was
446
+ paused after a scheduler error:
447
+
448
+ ```typescript
449
+ const paused = await runtime.reminders.all({
450
+ status: "paused",
451
+ authorizationContext: currentUser,
452
+ })
453
+
454
+ const reminder = paused.items[0]
455
+ if (reminder) {
456
+ await runtime.reminders.resume(reminder.id, {
457
+ runAt: new Date(Date.now() + 60_000),
458
+ authorizationContext: currentUser,
459
+ })
460
+ }
461
+ ```
462
+
463
+ Inspection omits reminder arguments and error messages. Resume is idempotent;
464
+ completed reminders must be scheduled again by their owning actor.
465
+
466
+ ## Keep external I/O outside the transaction
467
+
468
+ Effects run outside the actor turn through a transactional outbox. Handlers
469
+ must deduplicate external work using `context.id` because delivery is at least
470
+ once.
471
+
472
+ ```typescript
473
+ class Checkout extends Actor {
474
+ static override readonly actorType = "Checkout"
475
+
476
+ status = "open"
477
+
478
+ checkout({ paymentId }: { paymentId: string }): void {
479
+ this.status = "pending"
480
+ this.emit("chargePayment", {
481
+ arguments: { paymentId },
482
+ onSuccess: "paymentSucceeded",
483
+ onFailure: "paymentFailed",
484
+ })
485
+ }
486
+
487
+ paymentSucceeded(): void {
488
+ this.status = "paid"
489
+ }
490
+
491
+ paymentFailed(): void {
492
+ this.status = "failed"
493
+ }
494
+ }
495
+
496
+ runtime.registerEffect("chargePayment", async ({ paymentId }, context) => {
497
+ return payments.charge({ paymentId, idempotencyKey: context.id })
498
+ })
499
+ ```
500
+
501
+ Effect context also exposes `attempt`, `sourceMessageId`, `actorType`, and
502
+ `actorId`. The effect `id` is stable across retries and remains the external
503
+ idempotency key.
504
+
505
+ Success callbacks receive `{ effectId, result }`. Failure callbacks receive
506
+ `{ effectId, error }`.
507
+
508
+ ## Commit actions
509
+
510
+ Commit actions make a short database-only write in the same fenced transaction
511
+ as actor state:
512
+
513
+ ```typescript
514
+ runtime.registerCommitAction("completeAttempt", async ({ attemptId }, context) => {
515
+ await context.connection.run("UPDATE attempts SET completed = 1 WHERE id = ?", [attemptId])
516
+ })
517
+ ```
518
+
519
+ Commit-action context includes the source message and request IDs, actor
520
+ identity, mailbox sequence, activation generation, and the active transaction
521
+ connection.
522
+
523
+ Do not perform network I/O in a commit action. Use an effect when work cannot
524
+ share the Solid Objects database transaction.
525
+
526
+ When actors also read an application database, wrap that database with the
527
+ guarded facade and use the same facade everywhere:
528
+
529
+ ```typescript
530
+ import { guardApplicationDatabase } from "solid-objects"
531
+ import { sqlite } from "solid-objects/database/sqlite"
532
+
533
+ const applicationDatabase = guardApplicationDatabase(sqlite({ path: "application.sqlite3" }))
534
+ ```
535
+
536
+ During actor execution, observable and payload projection, and state migration,
537
+ the facade permits `SELECT` through `get()` and `all()` and rejects `run()` or
538
+ row-returning write statements. A commit action stays inside the same read-only
539
+ context and writes only through its supplied fenced `context.connection`. This
540
+ boundary is opt-in: Solid Objects cannot intercept a separate ORM pool or an
541
+ unwrapped database client.
542
+
543
+ ## Inspect and retry terminal failures
544
+
545
+ A committed invocation that exhausts its attempts raises `MessageFailed`.
546
+ The exception carries the durable `messageId` and the persisted error record in
547
+ `details`, so callers can correlate the failure without parsing its message.
548
+ If an already-authorized actor is destroyed while a caller is waiting,
549
+ `ActorDestroyed` is raised instead.
550
+
551
+ Messages that exhaust their attempts remain available as dead letters. Access
552
+ is deny-by-default and goes through the administration policy:
553
+
554
+ ```typescript
555
+ const deadLetters = await runtime.deadLetters.all({
556
+ authorizationContext: currentUser,
557
+ })
558
+
559
+ const deadLetter = deadLetters[0]
560
+ if (deadLetter) {
561
+ await runtime.deadLetters.retry(deadLetter.id, {
562
+ authorizationContext: currentUser,
563
+ })
564
+ }
565
+ ```
566
+
567
+ Retry creates one durable replacement message and records that link. Repeating
568
+ the retry returns the same `MessageReference` instead of enqueueing duplicate
569
+ work.
570
+
571
+ ## Reconcile application-owned actors
572
+
573
+ Self-scheduling actors should have a low-frequency application reconciler for
574
+ lost alarms and lifecycle drift. The read side is bounded, immutable, and
575
+ administration-authorized:
576
+
577
+ ```typescript
578
+ const page = await runtime.reconciliation.withoutPendingWork({
579
+ actorType: Trial.actorType,
580
+ quietForMilliseconds: 24 * 60 * 60 * 1_000,
581
+ authorizationContext: currentUser,
582
+ })
583
+
584
+ for (const instance of page.items) {
585
+ await Trial.ref(instance.actorId).send.reconcile()
586
+ }
587
+ ```
588
+
589
+ `active()`, `statesFor()`, and `orphaned()` cover the other reconciliation
590
+ views. State batches are migrated to the registered actor's current version
591
+ before they are returned. Reconciliation never writes actor state directly;
592
+ repairs enter the ordinary durable mailbox.
593
+
594
+ ## Retain history deliberately
595
+
596
+ Message history defaults to 30 days, stopped process history to 7 days, and
597
+ actor instances never expire unless their actor type opts in:
598
+
599
+ ```typescript
600
+ const runtime = configure({
601
+ database,
602
+ messageRetentionMilliseconds: 30 * 24 * 60 * 60 * 1_000,
603
+ messageRetentionByActorType: {
604
+ [AuditEvent.actorType]: 365 * 24 * 60 * 60 * 1_000,
605
+ },
606
+ instanceRetentionByActorType: {
607
+ [EphemeralSession.actorType]: 7 * 24 * 60 * 60 * 1_000,
608
+ },
609
+ })
610
+ ```
611
+
612
+ Preview each resource before pruning it:
613
+
614
+ ```typescript
615
+ await runtime.retention.preview({
616
+ target: "messages",
617
+ authorizationContext: currentUser,
618
+ })
619
+
620
+ await runtime.retention.prune({
621
+ target: "messages",
622
+ authorizationContext: currentUser,
623
+ })
624
+ ```
625
+
626
+ Pruning rechecks every candidate in bounded transactions. Live mailbox work,
627
+ unfinished outboxes, scheduled reminders, dead letters, retry links, active
628
+ leases, and running processes are retained.
629
+
630
+ ## Verify an installation
631
+
632
+ The doctor returns a structured report for startup checks, deployment probes,
633
+ or an application-owned CLI:
634
+
635
+ ```typescript
636
+ const report = await runtime.doctor.run()
637
+
638
+ for (const check of report.checks) {
639
+ console.log(check.status, check.name, check.message)
640
+ }
641
+
642
+ if (!report.healthy) process.exitCode = 1
643
+ ```
644
+
645
+ It checks configuration, schema migrations and required columns, the database
646
+ server version and MySQL table engines, authorization-policy configuration and
647
+ neutral-context posture, live runtime roles, and a targeted durable actor round
648
+ trip. Pass `{ roundTrip: "skip" }` for a read-only report.
649
+
650
+ Inspect role liveness with `runtime.processes.all()`. A process remains recorded
651
+ as `running` until graceful shutdown or cleanup, so each record also exposes a
652
+ current `stale` calculation based on the configured heartbeat threshold plus
653
+ its hostname, host process ID, Node version, and Solid Objects version.
654
+ `runtime.processes.cleanup()` atomically marks stale owners stopped, releases
655
+ their actor activations, returns claimed messages to ready membership, and
656
+ releases their effect, reminder, and broadcast claims.
657
+
658
+ For application-owned long-running roles, call `runtime.registerComponent()`
659
+ before `run()`. Each factory-created component implements `run(signal)`,
660
+ `requestShutdown()`, `stopped()`, and `stop()`. The runtime supervises and
661
+ replaces failed components just like built-in roles. See
662
+ [`docs/api.md`](docs/api.md#runtime-extensions-and-manual-workers) for the full
663
+ contract.
664
+
665
+ ## Operate it from the command line
666
+
667
+ Export the configured runtime from an application module:
668
+
669
+ ```javascript
670
+ import { configure } from "solid-objects"
671
+ import { sqlite } from "solid-objects/database/sqlite"
672
+ import { Counter } from "./dist/counter.js"
673
+
674
+ const runtime = configure({
675
+ database: sqlite({ path: "storage/solid-objects.sqlite3" }),
676
+ authorizeAdministration: ({ authorizationContext }) => authorizationContext?.source === "cli",
677
+ })
678
+
679
+ runtime.register(Counter)
680
+ export default runtime
681
+ ```
682
+
683
+ The CLI loads `solid-objects.config.js` by default; use `--config` for another
684
+ compiled module:
685
+
686
+ ```bash
687
+ pnpm exec solid-objects start
688
+ pnpm exec solid-objects doctor
689
+ pnpm exec solid-objects status
690
+ pnpm exec solid-objects cleanup
691
+ pnpm exec solid-objects dead-letters
692
+ pnpm exec solid-objects retry-dead-letter DEAD_LETTER_ID
693
+ pnpm exec solid-objects reminders --status paused
694
+ pnpm exec solid-objects resume-reminder REMINDER_ID
695
+ pnpm exec solid-objects prune messages
696
+ pnpm exec solid-objects prune messages --execute
697
+ ```
698
+
699
+ Pruning is preview-only unless `--execute` is present. Administrative commands
700
+ use `{ source: "cli" }` as their authorization context and emit JSON for shell
701
+ automation.
702
+
703
+ Every command accepts `--config PATH` or `-c PATH`. `doctor` accepts
704
+ `--skip-round-trip`; `reminders` accepts `--actor-type TYPE` and `--status
705
+ scheduled|paused|completed`; `resume-reminder` accepts an ISO `--run-at DATE`;
706
+ and `prune` accepts `--execute`. Run `solid-objects --help` for the command
707
+ summary.
708
+
709
+ ## Test durable workflows without sleeps
710
+
711
+ `runtime.testing.drain()` runs configured roles in deterministic passes until
712
+ they are idle. Select roles when a test needs a narrower boundary:
713
+
714
+ ```typescript
715
+ const message = await Counter.ref("test").send.increment()
716
+
717
+ await runtime.testing.drain({ roles: ["actors"] })
718
+
719
+ expect(await message.status()).toBe("completed")
720
+ ```
721
+
722
+ `runtime.testing.reset()` stops and discards the cached caller worker, then
723
+ deletes every actor-owned table and process row in dependency order. Use it in
724
+ test setup and teardown; it does not rely on transactional tests or foreign-key
725
+ cascades.
726
+
727
+ ## Connect observability without coupling the runtime
728
+
729
+ Provide a synchronous instrumentation sink and forward events to the
730
+ observability system already used by the application:
731
+
732
+ ```typescript
733
+ const runtime = configure({
734
+ database,
735
+ instrumentation: (event) => diagnosticsChannel.publish(event),
736
+ })
737
+ ```
738
+
739
+ Events use names such as `solid_objects.message.enqueued`,
740
+ `solid_objects.message.completed`, `solid_objects.effect.failed`,
741
+ `solid_objects.dead_letter.created`, and `solid_objects.actor.destroyed`.
742
+ Records are immutable and contain operational metadata only. Arguments, actor
743
+ state, results, rejection messages and details, error messages, and broadcast
744
+ payloads never enter the instrumentation API. A sink failure is logged and
745
+ cannot fail durable work.
746
+
747
+ Moving an existing alarm to another time emits
748
+ `solid_objects.reminder.replaced` only after the actor turn commits. The event
749
+ contains the actor identity, operation, reminder ID, and previous and next run
750
+ times without reminder arguments.
751
+
752
+ ## Add realtime updates without exposing all state
753
+
754
+ Connect an authenticated socket to the transport-neutral subscription manager.
755
+ The application owns the WebSocket server and decides what object represents
756
+ the authenticated connection:
757
+
758
+ ```typescript
759
+ server.on("connection", (socket, request) => {
760
+ const session = runtime.realtime.connect({
761
+ authorizationContext: request.user,
762
+ send: (envelope) => socket.send(JSON.stringify(envelope)),
763
+ })
764
+
765
+ socket.on("message", (data) => {
766
+ session.receive(data).catch(() => socket.close(1008, "subscription rejected"))
767
+ })
768
+ socket.on("close", () => session.close())
769
+ })
770
+ ```
771
+
772
+ Every subscribe request calls `authorizeSubscription` before actor lookup. An
773
+ accepted subscription immediately receives the latest committed observable
774
+ projection with its actor incarnation and revision, without adding a mailbox
775
+ message. Later invalidations come from the durable outbox in actor revision
776
+ order. Duplicate and stale revisions are fenced, and one broken connection
777
+ cannot interrupt delivery to another.
778
+
779
+ Direct session delivery is process-local. When WebSocket connections and
780
+ workers run in several Node processes, configure `broadcast` to publish each
781
+ durable event through the application's shared transport, and have every
782
+ process feed received events to `runtime.realtime.publish(event)`. Polling and
783
+ the durable outbox remain the correctness fallback; the shared transport fans
784
+ a committed event out to the processes that own live connections.
785
+
786
+ The browser entry contains no Node imports. It validates versioned invalidation
787
+ envelopes, tracks actor incarnations and revisions, and ignores stale delivery:
788
+
789
+ ```typescript
790
+ import { SolidObjectsBrowserClient } from "solid-objects/browser"
791
+
792
+ const client = new SolidObjectsBrowserClient({
793
+ url: new URL("/solid-objects", window.location.href),
794
+ onInvalidation: ({ observables }) => render(observables),
795
+ })
796
+
797
+ client.subscribe({ actorType: "Counter", actorId: "primary" })
798
+ client.connect()
799
+ ```
800
+
801
+ For server-rendered or framework-owned UI fragments, register their observable
802
+ dependencies and let one invalidation refresh only the affected targets:
803
+
804
+ ```typescript
805
+ import { SolidObjectsBrowserClient, SolidObjectsComponentRegistry } from "solid-objects/browser"
806
+
807
+ const componentRegistry = new SolidObjectsComponentRegistry<string>({
808
+ refresh: async ({ actorType, actorId, instanceId, revision, batch, components, signal }) => {
809
+ const response = await fetch("/components/refresh", {
810
+ method: "POST",
811
+ signal,
812
+ headers: { "content-type": "application/json" },
813
+ body: JSON.stringify({ actorType, actorId, instanceId, revision, batch, components }),
814
+ })
815
+ if (!response.ok) throw new Error(`component refresh failed with ${response.status}`)
816
+ return response.json()
817
+ },
818
+ apply: ({ component, rendered }) => {
819
+ updateComponent(component.target, rendered, { strategy: component.strategy })
820
+ },
821
+ })
822
+
823
+ componentRegistry.register({
824
+ actorType: "GameRoom",
825
+ actorId: "table-1",
826
+ target: "player-one",
827
+ name: "player",
828
+ key: 1,
829
+ observes: ["playerOne"],
830
+ batch: "playmat",
831
+ strategy: "morph",
832
+ })
833
+
834
+ const client = new SolidObjectsBrowserClient({
835
+ url: new URL("/solid-objects", window.location.href),
836
+ onInvalidation: (envelope) => componentRegistry.invalidate(envelope),
837
+ })
838
+ ```
839
+
840
+ Registrations sharing a batch are refreshed in one request. Same-revision
841
+ invalidations merge in a microtask, a strictly newer request aborts the older
842
+ one, and per-target incarnation/revision fences prevent a late response from
843
+ overwriting current UI. `replace` and `morph` are strategies passed to the
844
+ application's synchronous `apply` callback; the library does not assume a DOM
845
+ framework. The refresh endpoint must authenticate the request and reauthorize
846
+ every requested component and dependency.
847
+
848
+ Run `pnpm run test:browser` after installing Playwright's Chromium build to
849
+ exercise the browser entry through native WebSocket and browser APIs.
850
+
851
+ For subscriber-specific views, declare a static payload map with a TypeScript
852
+ `satisfies` check:
853
+
854
+ ```typescript
855
+ import { Actor, type PayloadBroadcasts } from "solid-objects"
856
+
857
+ interface Viewer {
858
+ accountId: string
859
+ }
860
+
861
+ class GameRoom extends Actor {
862
+ static override readonly actorType = "GameRoom"
863
+ static override readonly payloads = {
864
+ playmat: (room, viewer) => ({
865
+ turn: room.turn,
866
+ hand: room.hands[viewer.accountId] ?? [],
867
+ }),
868
+ } satisfies PayloadBroadcasts<GameRoom, Viewer>
869
+
870
+ turn = 1
871
+ hands: Record<string, string[]> = {}
872
+ }
873
+ ```
874
+
875
+ Request payloads by name and render them separately from observable
876
+ invalidations:
877
+
878
+ ```typescript
879
+ const client = new SolidObjectsBrowserClient({
880
+ url: new URL("/solid-objects", window.location.href),
881
+ onInvalidation: ({ observables }) => renderScalars(observables),
882
+ onPayload: ({ name, payload }) => renderPayload(name, payload),
883
+ })
884
+
885
+ client.subscribe({
886
+ actorType: "GameRoom",
887
+ actorId: "primary",
888
+ payloads: ["playmat"],
889
+ })
890
+ ```
891
+
892
+ Each payload runs against committed state and the subscribing session's fresh
893
+ authorization context. `authorizeQuery` is called with the payload name before
894
+ projection. A denied or failing payload is omitted without stopping sibling
895
+ payloads or observable invalidations.
896
+
897
+ `broadcast` remains available when an application also needs to forward the
898
+ same durable events through another transport or broker. Browser-visible actor
899
+ IDs and observable values are not authorization.
900
+
901
+ ## Delivery contract
902
+
903
+ - Messages are ordered per actor identity and delivered at least once.
904
+ - Different actor identities may execute concurrently.
905
+ - A worker drains at most `maxMessagesPerActivationPass` turns from one actor,
906
+ then yields its still-due work behind actors that were already waiting.
907
+ - A global claim scans at most `claimScanLimit` ordered candidates, continuing
908
+ to another ready actor after a lost lease race.
909
+ - Long-running workers reuse hydrated actors for
910
+ `idleDeactivationTimeoutMilliseconds` while renewing the same fenced lease.
911
+ - State, completion, staged messages, effects, reminders, commit actions, and
912
+ observable broadcasts share one fenced commit.
913
+ - A lost or expired activation lease cannot commit.
914
+ - Failed turns roll state and staged intents back and block later work until
915
+ retry or dead-letter completion.
916
+ - Effects can execute more than once.
917
+ - Results and snapshots are deeply frozen copies. A snapshot contains every
918
+ persisted field and getter from one committed state image; snapshot getters
919
+ must not mutate state or stage durable work.
920
+
921
+ Override protected `onActivate()` and `onDeactivate()` methods when an actor
922
+ needs a process-local resource during that window. Hooks may be asynchronous,
923
+ cannot write through a guarded application database, and are nondurable;
924
+ `onDeactivate()` is best effort and must not carry correctness work.
925
+
926
+ ## Current scope
927
+
928
+ The current runtime supports Node.js 24, SQLite through built-in `node:sqlite`,
929
+ PostgreSQL 14 or newer through `pg` 8.23, and MySQL 8.0 or newer through
930
+ `mysql2` 3.23 with InnoDB. HTTP/WebSocket server adapters and Ruby schema
931
+ interoperability are not part of the compatibility contract.
932
+
933
+ ## Documentation
934
+
935
+ - [`docs/state-and-lifecycle.md`](docs/state-and-lifecycle.md) covers actor
936
+ discovery, migrations, lifecycle hooks, rejection, snapshots, and
937
+ destruction.
938
+ - [`docs/configuration.md`](docs/configuration.md) lists every runtime and
939
+ adapter option with its default and constraint.
940
+ - [`docs/errors-and-recovery.md`](docs/errors-and-recovery.md) maps public
941
+ errors to retry and recovery behavior.
942
+ - [`docs/api.md`](docs/api.md) indexes every supported public export and runtime
943
+ manager.
944
+ - [`docs/operations.md`](docs/operations.md),
945
+ [`docs/architecture.md`](docs/architecture.md), and
946
+ [`docs/correctness.md`](docs/correctness.md) define the operating and delivery
947
+ contracts.
948
+ - [`docs/authorization.md`](docs/authorization.md) and
949
+ [`docs/browser-protocol.md`](docs/browser-protocol.md) cover security and the
950
+ transport-neutral realtime protocol.
951
+ - The [`Ruby parity ledger`](docs/parity.md) tracks native equivalents and
952
+ explicit scope boundaries against Ruby Solid Objects 0.12.0.
953
+ - [`docs/releasing.md`](docs/releasing.md) documents the tag-driven npm release
954
+ workflow for maintainers.
955
+
956
+ ## License
957
+
958
+ Solid Objects is released under the MIT License.