solid-objects 0.12.1 → 0.13.1

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 (115) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +284 -950
  3. package/dist/actor.d.ts +12 -0
  4. package/dist/actor.d.ts.map +1 -1
  5. package/dist/actor.js +25 -2
  6. package/dist/actor.js.map +1 -1
  7. package/dist/broadcast-worker.d.ts +2 -0
  8. package/dist/broadcast-worker.d.ts.map +1 -1
  9. package/dist/broadcast-worker.js +25 -5
  10. package/dist/broadcast-worker.js.map +1 -1
  11. package/dist/browser/components.d.ts.map +1 -1
  12. package/dist/browser/components.js +4 -1
  13. package/dist/browser/components.js.map +1 -1
  14. package/dist/browser/index.d.ts +1 -0
  15. package/dist/browser/index.d.ts.map +1 -1
  16. package/dist/browser/index.js +8 -0
  17. package/dist/browser/index.js.map +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +13 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/configuration.d.ts +3 -0
  22. package/dist/configuration.d.ts.map +1 -1
  23. package/dist/configuration.js +3 -0
  24. package/dist/configuration.js.map +1 -1
  25. package/dist/doctor.d.ts.map +1 -1
  26. package/dist/doctor.js +12 -3
  27. package/dist/doctor.js.map +1 -1
  28. package/dist/effect-worker.d.ts +2 -0
  29. package/dist/effect-worker.d.ts.map +1 -1
  30. package/dist/effect-worker.js +25 -5
  31. package/dist/effect-worker.js.map +1 -1
  32. package/dist/examples/sqlite-quickstart.js +83 -0
  33. package/dist/examples/sqlite-quickstart.js.map +1 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/polling-backoff.d.ts +23 -0
  39. package/dist/polling-backoff.d.ts.map +1 -0
  40. package/dist/polling-backoff.js +33 -0
  41. package/dist/polling-backoff.js.map +1 -0
  42. package/dist/records.d.ts +1 -0
  43. package/dist/records.d.ts.map +1 -1
  44. package/dist/reminder-scheduler.d.ts +2 -0
  45. package/dist/reminder-scheduler.d.ts.map +1 -1
  46. package/dist/reminder-scheduler.js +25 -5
  47. package/dist/reminder-scheduler.js.map +1 -1
  48. package/dist/repository.d.ts +5 -1
  49. package/dist/repository.d.ts.map +1 -1
  50. package/dist/repository.js +15 -4
  51. package/dist/repository.js.map +1 -1
  52. package/dist/runtime.d.ts +4 -0
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +71 -6
  55. package/dist/runtime.js.map +1 -1
  56. package/dist/schema.d.ts.map +1 -1
  57. package/dist/schema.js +15 -4
  58. package/dist/schema.js.map +1 -1
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/wake-up/postgresql.d.ts.map +1 -1
  62. package/dist/wake-up/postgresql.js +12 -11
  63. package/dist/wake-up/postgresql.js.map +1 -1
  64. package/dist/wake-up.d.ts +1 -1
  65. package/dist/wake-up.d.ts.map +1 -1
  66. package/dist/wake-up.js +11 -10
  67. package/dist/wake-up.js.map +1 -1
  68. package/dist/web/assets.d.ts +4 -0
  69. package/dist/web/assets.d.ts.map +1 -0
  70. package/dist/web/assets.js +11 -0
  71. package/dist/web/assets.js.map +1 -0
  72. package/dist/web/index.d.ts +28 -0
  73. package/dist/web/index.d.ts.map +1 -0
  74. package/dist/web/index.js +642 -0
  75. package/dist/web/index.js.map +1 -0
  76. package/dist/web/node.d.ts +3 -0
  77. package/dist/web/node.d.ts.map +1 -0
  78. package/dist/web/node.js +109 -0
  79. package/dist/web/node.js.map +1 -0
  80. package/dist/web/render.d.ts +64 -0
  81. package/dist/web/render.d.ts.map +1 -0
  82. package/dist/web/render.js +331 -0
  83. package/dist/web/render.js.map +1 -0
  84. package/dist/web/store.d.ts +65 -0
  85. package/dist/web/store.d.ts.map +1 -0
  86. package/dist/web/store.js +303 -0
  87. package/dist/web/store.js.map +1 -0
  88. package/dist/web/types.d.ts +82 -0
  89. package/dist/web/types.d.ts.map +1 -0
  90. package/dist/web/types.js +2 -0
  91. package/dist/web/types.js.map +1 -0
  92. package/dist/worker.d.ts +2 -0
  93. package/dist/worker.d.ts.map +1 -1
  94. package/dist/worker.js +25 -5
  95. package/dist/worker.js.map +1 -1
  96. package/docs/api.md +70 -4
  97. package/docs/architecture.md +20 -11
  98. package/docs/authorization.md +12 -3
  99. package/docs/benchmarks.md +163 -0
  100. package/docs/browser-protocol.md +15 -8
  101. package/docs/comparisons.md +36 -0
  102. package/docs/configuration.md +14 -3
  103. package/docs/correctness.md +23 -0
  104. package/docs/dashboard.md +196 -0
  105. package/docs/fit.md +58 -0
  106. package/docs/operations.md +25 -7
  107. package/docs/parity.md +37 -34
  108. package/docs/releasing.md +6 -4
  109. package/docs/state-and-lifecycle.md +10 -0
  110. package/docs/support.md +39 -0
  111. package/examples/failure-recovery/actor.ts +51 -0
  112. package/examples/failure-recovery/demo.ts +233 -0
  113. package/examples/failure-recovery/worker.ts +46 -0
  114. package/examples/sqlite-quickstart.ts +109 -0
  115. package/package.json +26 -4
package/README.md CHANGED
@@ -1,997 +1,331 @@
1
- # Solid Objects JS
1
+ # Durable Objects for Node, backed by your existing SQL database
2
2
 
3
- **Stateful virtual actors for Node.js, powered entirely by your relational database.**
3
+ [![CI](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/solid-objects)](https://www.npmjs.com/package/solid-objects)
4
5
 
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.
6
+ Build addressable TypeScript objects with serialized calls and durable state
7
+ using SQLite, PostgreSQL, or MySQL, without deploying to Cloudflare.
9
8
 
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.
9
+ Concurrent calls for one identity cannot overwrite each other. Calls for
10
+ different identities can run at the same time.
13
11
 
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.
12
+ Define ordinary TypeScript classes and run them in ordinary Node.js processes.
13
+ State, queued operations, retries, reminders, effects, and realtime
14
+ invalidations are stored in the database the application already operates.
16
15
 
17
- ## The boring stack, with an actor model
16
+ > **Early release:** the correctness core has automated coverage across the
17
+ > supported databases, the Chromium browser client, process recovery, and
18
+ > packaged artifacts, but the TypeScript implementation is new. Read the
19
+ > [delivery boundaries](#delivery-boundaries) before using it for important data.
18
20
 
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
21
+ ## The programming model
68
22
 
69
23
  ```typescript
70
- import { Actor } from "solid-objects"
71
-
72
- export class Counter extends Actor {
73
- static override readonly actorType = "Counter"
74
-
75
- count = 0
24
+ import { Actor, createRuntime } from "solid-objects"
25
+ import { sqlite } from "solid-objects/database/sqlite"
76
26
 
77
- get doubled(): number {
78
- return this.count * 2
79
- }
27
+ class Cart extends Actor {
28
+ static override readonly actorType = "Cart"
80
29
 
81
- increment({ amount = 1 }: { amount?: number } = {}): number {
82
- this.count += amount
83
- return this.count
84
- }
30
+ items: string[] = []
85
31
 
86
- override observables(): Record<string, unknown> {
87
- return { count: this.count }
32
+ add({ sku }: { sku: string }): number {
33
+ this.items.push(sku)
34
+ return this.items.length
88
35
  }
89
36
  }
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
37
 
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,
38
+ const runtime = createRuntime({
39
+ database: sqlite({ path: "cart.sqlite3" }),
40
+ authorizeMessage: () => true,
41
+ authorizeQuery: () => true,
158
42
  })
159
43
 
160
- runtime.register(Counter)
161
44
  await runtime.install()
162
45
 
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
46
+ try {
47
+ const cart = runtime.ref(Cart, "cart-123")
48
+ await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
49
+ } finally {
50
+ await runtime.close()
51
+ }
219
52
  ```
220
53
 
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")
54
+ Both calls enter the durable mailbox for `cart-123`. They execute in order and
55
+ commit one state transition at a time, even when different requests or Node.js
56
+ processes submit them concurrently.
226
57
 
227
- const database = mysql({
228
- connectionString,
229
- maximumConnections: 10,
230
- })
231
- ```
58
+ ## Run it now with SQLite
232
59
 
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:
60
+ Node.js 24.15 or newer is required. The `0.13.1` release includes a
61
+ packaged quickstart:
259
62
 
260
63
  ```bash
261
- pnpm add redis
262
- ```
64
+ npm exec --yes --package=solid-objects@0.13.1 -- solid-objects quickstart
65
+ ```
66
+
67
+ The command needs no repository checkout, database server, Redis, container, or
68
+ application configuration. It uses Node's built-in SQLite module and removes
69
+ its scoped temporary database before exiting.
70
+
71
+ The executable asserts rather than merely printing a plausible result. In one
72
+ local run, it verifies that:
73
+
74
+ - 25 concurrent calls to one identity produce the exact committed state `25`;
75
+ - their return values are the complete sequence from `1` through `25`;
76
+ - operations for two different identities overlap in time; and
77
+ - the runtime closes and temporary state is removed.
78
+
79
+ ## Running in a deployed application
80
+
81
+ [Shuffle Up and Play](https://shuffleupandplay.com/) is a deployed reference
82
+ application where two players create a table, load decks, and move cards while
83
+ realtime updates reach both browsers. Its
84
+ [source](https://github.com/cardmagic/shuffleupandplay) uses Node 24,
85
+ TypeScript, SQLite, `node:http`, and `ws`. Each table code addresses one
86
+ `GameRoom` actor that owns both seats, so mutations share one durable mailbox
87
+ while each player receives a separately authorized projection.
88
+
89
+ The application and its tests exercise more than a counter-shaped happy path:
90
+
91
+ | Production concern | Verifiable application evidence |
92
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | Concurrent mutations | One [`GameRoom`](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L45-L125) owns a table. [Mailbox tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L39-L94) submit concurrent life, draw, and shuffle operations and assert the final committed state. |
94
+ | Controlled restarts | [Restart tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/restart.test.ts#L38-L136) close and reopen the runtime against the same SQLite file, then assert recovery of committed state, an accepted asynchronous operation, an unfinished effect, and a scheduled reminder. |
95
+ | Persistent deployment | The [runtime uses SQLite](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L45-L63); the [container runs as an unprivileged user](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/Dockerfile#L20-L37), and [Kamal mounts a persistent volume](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/config/deploy.yml#L28-L43). |
96
+ | Private realtime state | [Subscription policy](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L65-L82) and [per-seat projection](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/game/room-snapshot.ts#L92-L130) run on the server. [HTTP](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L526-L567) and [WebSocket tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/realtime.test.ts#L184-L235) assert that opponent card identities are absent from player payloads and shared invalidation envelopes. |
97
+ | External work | Deck imports run as [durable effects with success and failure callbacks](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L192-L253). [Tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/game-room.test.ts#L209-L331) cover both outcomes and prevent a superseded callback from replacing a newer deck result. |
98
+ | Transactional staged work | A room operation stages an [actor-to-actor log message](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L339-L349) and a [database commit action](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L96-L113). Tests cover [rollback of staged messages](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L160-L192) and the [metrics write](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L467-L499). |
99
+ | Time and schema changes | The actor defines [versioned state migrations](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L47-L91) and a [durable reminder](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L276-L310). Tests load [stored version-one state](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/operations.test.ts#L135-L201) and [run the reminder scheduler](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L236-L257). |
100
+ | Operations and CI | The [operations tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/operations.test.ts#L39-L202) exercise doctor, process, retention, and reconciliation APIs; server suites cover the [dashboard](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L297-L326), [rate limits](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/rate-limit.test.ts), and [shutdown](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/shutdown.test.ts). The [current main CI run](https://github.com/cardmagic/shuffleupandplay/actions/runs/31963000789) passed typechecking, 171 tests, the build, the doctor, and a Docker image build. |
101
+
102
+ **Scope:** the checked-in deployment configuration runs one Node process using
103
+ SQLite on one Docker host. It demonstrates a real deployed workload, not a
104
+ measured traffic level or every supported topology. Its deck-import effect
105
+ reads an external API; effects that write to an external system still need a
106
+ stable idempotency key because delivery is at least once. The application
107
+ restart tests close the runtime cleanly; abrupt termination, PostgreSQL, MySQL,
108
+ and multi-process lease fencing are verified separately by the library's
109
+ [test matrix](docs/support.md),
110
+ [failure-recovery demonstration](examples/failure-recovery/demo.ts), and
111
+ [correctness contract](docs/correctness.md). Evaluate those guarantees and
112
+ limits against your own workload.
113
+
114
+ ## What Solid Objects is for
115
+
116
+ Use Solid Objects when more than one request, job, or process can act on the
117
+ same logical thing and the next action must use its latest committed state.
118
+ These are the stateful coordination patterns for which people often reach for
119
+ Durable Objects:
120
+
121
+ | Pattern | One identity per | What the object coordinates |
122
+ | --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
123
+ | Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
124
+ | Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
125
+ | Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
126
+ | Per-key rate limits | API key, account, or device | Token checks and decrements are serialized; a reminder can refill the bucket |
127
+ | Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
128
+
129
+ The common shape is one durable coordination boundary with an application
130
+ defined identity. Work for that identity is serialized, while unrelated rooms,
131
+ carts, accounts, or sessions can progress concurrently. A single global rate
132
+ limiter or another very hot identity is a poor fit because it becomes an
133
+ intentional bottleneck. If one ordinary row transaction solves the problem,
134
+ prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
135
+
136
+ ## How it works
137
+
138
+ An object is addressed by its TypeScript class and application-defined ID.
139
+ Public fields are JSON state, public methods are durable operations, and public
140
+ getters are ordered queries.
141
+
142
+ For each identity, Solid Objects:
143
+
144
+ 1. commits calls to a durable per-ID mailbox;
145
+ 2. claims one activation with a renewable lease;
146
+ 3. executes one operation at a time outside the database transaction;
147
+ 4. commits state, completion, and staged work in a short fenced transaction;
148
+ 5. retries recoverable failures and exposes terminal failures as dead letters;
149
+ 6. publishes committed realtime invalidations in revision order.
150
+
151
+ The fence includes the activation owner, token, generation, expiration, and
152
+ claimed message. A worker that finishes JavaScript after losing its lease
153
+ cannot commit. See the executable [failure-recovery demonstration](examples/failure-recovery/demo.ts)
154
+ and the full [architecture](docs/architecture.md).
155
+
156
+ Redis is optional wake-up infrastructure. It can reduce notification latency
157
+ for a multi-process MySQL deployment, but the relational database remains the
158
+ durable source of truth and polling remains the recovery path.
159
+
160
+ Idle roles back off from the configured 100 ms fast polling interval to one
161
+ second. Processed work and wake-up notifications reset that interval
162
+ immediately. The default wake-up reaches only the current Node process; use the
163
+ PostgreSQL or optional Redis adapter when separate processes need low-latency
164
+ delivery. The runtime warns once when it sees that topology without an adapter.
165
+
166
+ ## Good and poor fits
167
+
168
+ | Good fit | Poor fit |
169
+ | --------------------------------------------------------- | --------------------------------------------------------- |
170
+ | Multiplayer rooms and collaborative sessions | A single-row update already solved by one SQL transaction |
171
+ | Shopping carts, accounts, devices, and per-user workflows | Bulk ingestion and data-parallel pipelines |
172
+ | Stateful agent sessions with ordered tool results | Very high-throughput global counters |
173
+ | Per-document or per-device reminders | Large JSON documents that should remain normalized rows |
174
+ | Realtime projections of committed state | Globally placed edge state or managed elastic placement |
175
+
176
+ One hot identity is intentionally serialized. Split an identity only when the
177
+ domain can tolerate independent ordering and transactions. Solid Objects does
178
+ not provide a transaction across object identities.
179
+
180
+ The longer decision guide is in [Choosing Solid Objects](docs/fit.md).
181
+
182
+ ## Delivery boundaries
183
+
184
+ - Operations are ordered per identity and execute **at least once**.
185
+ - A crash after arbitrary external I/O but before the database commit can cause
186
+ that I/O to repeat. Use the stable effect ID or another durable idempotency
187
+ key at the external system.
188
+ - Fencing protects the Solid Objects database commit. It cannot undo an HTTP
189
+ request, email, payment, file write, or other external side effect.
190
+ - Different identities can execute concurrently; one hot identity cannot.
191
+ - State, result, actor-to-actor delivery, reminders, effects, commit actions,
192
+ and realtime invalidations commit together for one operation.
193
+ - Cross-object transactions are not provided.
194
+ - Application processes with incompatible `stateVersion` values must not run
195
+ together. Older code rejects state written by a newer version.
196
+ - Direct application-database writes are guarded only when the application
197
+ uses the supplied database facade. Unwrapped clients cannot be intercepted.
198
+ - Realtime sessions are process-local. A multi-process application must bridge
199
+ committed broadcast events to the processes holding live connections.
200
+
201
+ See [Correctness and delivery semantics](docs/correctness.md) and
202
+ [Errors and recovery](docs/errors-and-recovery.md) for the complete contract.
203
+
204
+ ## Realtime committed state
205
+
206
+ Actors opt into browser-visible dependencies. In `0.13`, an unwrapped
207
+ observable triggers invalidation without storing or sending its value. Use
208
+ `broadcastValue()` only for a scalar that every authorized subscriber may see:
263
209
 
264
210
  ```typescript
265
- import { redisWakeUp } from "solid-objects/wake-up/redis"
211
+ import { Actor, broadcastValue } from "solid-objects"
266
212
 
267
- const runtime = configure({
268
- database,
269
- wakeUp: redisWakeUp({ url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379" }),
270
- })
271
- ```
213
+ class Room extends Actor {
214
+ static override readonly actorType = "Room"
272
215
 
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")
216
+ version = 0
217
+ privateHands: Record<string, string[]> = {}
285
218
 
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("insufficientInventory", {
327
- message: "Not enough inventory is available",
328
- details: { available: this.available },
329
- })
219
+ override observables(): Record<string, unknown> {
220
+ return {
221
+ version: broadcastValue(this.version),
222
+ hands: this.privateHands,
330
223
  }
331
- this.available -= quantity
332
224
  }
333
225
  }
334
226
  ```
335
227
 
336
- Callers receive `Rejected` with `code`, frozen `details`, and the durable
337
- `messageId`. Unexpected exceptions are retried and eventually surface as
338
- `MessageFailed`. Rejection codes follow the same identifier rule as actor
339
- members: a letter or underscore followed by letters, digits, or underscores.
340
-
341
- Do not make a committed actor call or wait on a message from inside
342
- `database.transaction(...)` on the Solid Objects database. The runtime raises
343
- `SyncInsideTransaction` before enqueue or waiting, avoiding a self-deadlock on
344
- the transaction's checked-out connection. Send background work outside the
345
- transaction, or let the actor coordinate same-database changes through a commit
346
- action.
347
-
348
- ## Read snapshots and destroy actors
349
-
350
- An authorized snapshot reads all persisted fields and getters from one
351
- committed state image without entering the mailbox:
352
-
353
- ```typescript
354
- const snapshot = await Counter.ref("primary").snapshot({
355
- authorizationContext: currentUser,
356
- })
357
-
358
- console.log(snapshot.count, snapshot.doubled)
359
- ```
360
-
361
- Snapshots are deeply frozen. Getters must not mutate state or stage durable
362
- work. Because snapshots do not enter the mailbox, use an ordinary query when
363
- the read must be ordered behind earlier messages.
364
-
365
- Destroy an actor through its separate deny-by-default policy:
366
-
367
- ```typescript
368
- const destroyed = await Counter.ref("primary").destroy({
369
- authorizationContext: currentUser,
370
- })
371
- ```
372
-
373
- Destruction is idempotent and cascades through the current incarnation's
374
- state, mailbox history, effects, reminders, broadcasts, and dead letters. A
375
- later message creates a new incarnation, and an authorized waiter on the old
376
- one receives `ActorDestroyed`.
228
+ `version` crosses the shared invalidation channel. `hands` contributes only its
229
+ name when its real value changes, allowing a reauthorized component endpoint to
230
+ render subscriber-specific state without a manual revision counter.
377
231
 
378
- ## Send background work without a queue service
232
+ The browser package handles replay, reconnection, incarnation/revision fences,
233
+ personalized payloads, and framework-neutral component refresh. Applications
234
+ provide authentication, WebSocket transport, and rendering. See the
235
+ [browser protocol](docs/browser-protocol.md) and [authorization guide](docs/authorization.md).
379
236
 
380
- Use the typed `send` dispatcher when the caller should not wait for execution:
237
+ ## Comparison
381
238
 
382
- ```typescript
383
- const message = await counter.send.increment({ amount: 2 })
384
-
385
- const delayed = await counter.send
386
- .with({
387
- availableAt: new Date(Date.now() + 60_000),
388
- idempotencyKey: "increment-later",
389
- authorizationContext: currentUser,
390
- })
391
- .increment({ amount: 2 })
392
-
393
- await message.status()
394
- await message.result()
395
- await message.wait({ timeoutMilliseconds: 2_000 })
396
- ```
239
+ These systems solve different coordination problems. The table describes their
240
+ default unit and deployment model, not a quality ranking.
397
241
 
398
- `MessageReference` stores durable identity, not authorization context. Pass
399
- `authorizationContext` again to `status()`, `result()`, or `wait()`; every read
400
- reauthorizes the stored operation. Operations that return `undefined`,
401
- including ordinary `void` methods, are normalized to JSON `null` when their
402
- durable result is read or awaited.
242
+ | Approach | Serialization and state unit | Durable substrate | Additional runtime | Recovery model | Placement |
243
+ | --------------------------- | ----------------------------------------------------- | ------------------------------------- | ------------------------------------------------------- | ---------------------------------------------- | ---------------------------- |
244
+ | SQL transaction or row lock | Selected rows in one transaction | Application database | None | Application retries the transaction | Application deployment |
245
+ | Traditional job queue | Job or queue; ordering depends on queue configuration | Broker or queue database | Queue workers and usually a broker | Retry the job | Application deployment |
246
+ | Solid Objects | TypeScript class plus object ID | Existing SQLite, PostgreSQL, or MySQL | Library in application processes | Retry the per-ID operation from durable state | Application deployment |
247
+ | Cloudflare Durable Objects | Object class plus globally unique ID | Per-object managed storage | Cloudflare Workers platform | Managed object activation | Cloudflare-selected location |
248
+ | Rivet Actors | Addressable actor | Actor state, KV, or per-actor SQLite | Rivet Engine or managed compute | Actor sleep, wake, and persistence | Configured Rivet deployment |
249
+ | DBOS | Workflow ID and checkpointed steps | PostgreSQL system database | Library; Conductor recommended for distributed recovery | Deterministic workflow replay from checkpoints | Application deployment |
250
+ | Restate | Service handler or keyed virtual object | Restate log and state store | Restate server or cloud service | Durable handler execution and journal replay | Restate deployment |
403
251
 
404
- Actor code must not call another reference directly or through `send`. Use
405
- `sendTo()` so outbound delivery commits atomically with the source actor turn—no
406
- separate broker or hand-built transactional outbox required:
407
-
408
- ```typescript
409
- class Account extends Actor {
410
- static override readonly actorType = "Account"
411
-
412
- disable({ auditLogId }: { auditLogId: string }): void {
413
- this.sendTo(AuditLog.ref(auditLogId)).record({
414
- eventName: "account_disabled",
415
- })
416
- }
417
- }
418
- ```
252
+ The sourced, dimension-by-dimension comparison—including realtime projections,
253
+ edge placement, cross-identity transactions, and operational data access—is in
254
+ [docs/comparisons.md](docs/comparisons.md).
419
255
 
420
- If `disable` fails or is rejected, the staged audit message is discarded.
421
-
422
- ## Use database-backed timers
423
-
424
- Reminders are actor-owned durable alarms. The operation name is also the
425
- reminder identity, so scheduling it again moves the existing reminder.
426
-
427
- ```typescript
428
- class Trial extends Actor {
429
- static override readonly actorType = "Trial"
430
-
431
- expired = false
432
-
433
- armExpiration(): void {
434
- this.schedule({ at: new Date(Date.now() + 86_400_000) }).expire!()
435
- }
436
-
437
- reconcile(): void {
438
- if (!this.expired) this.armExpiration()
439
- }
440
-
441
- expire(): void {
442
- this.expired = true
443
- }
444
- }
445
- ```
446
-
447
- The non-null assertion is only needed by projects using
448
- `noUncheckedIndexedAccess`; runtime registration still rejects unknown reminder
449
- operations before persistence. Recurring reminders accept `everyMilliseconds`
450
- and a `missed` policy of `"latest"` or `"all"`.
451
-
452
- Authorized operators can inspect alarm metadata and resume a reminder that was
453
- paused after a scheduler error:
454
-
455
- ```typescript
456
- const paused = await runtime.reminders.all({
457
- status: "paused",
458
- authorizationContext: currentUser,
459
- })
460
-
461
- const reminder = paused.items[0]
462
- if (reminder) {
463
- await runtime.reminders.resume(reminder.id, {
464
- runAt: new Date(Date.now() + 60_000),
465
- authorizationContext: currentUser,
466
- })
467
- }
468
- ```
469
-
470
- Inspection omits reminder arguments and error messages. Resume is idempotent;
471
- completed reminders must be scheduled again by their owning actor.
472
-
473
- ## Keep external I/O outside the transaction
474
-
475
- Effects run outside the actor turn through a transactional outbox. Handlers
476
- must deduplicate external work using `context.id` because delivery is at least
477
- once.
478
-
479
- ```typescript
480
- class Checkout extends Actor {
481
- static override readonly actorType = "Checkout"
482
-
483
- status = "open"
484
-
485
- checkout({ paymentId }: { paymentId: string }): void {
486
- this.status = "pending"
487
- this.emit("chargePayment", {
488
- arguments: { paymentId },
489
- onSuccess: "paymentSucceeded",
490
- onFailure: "paymentFailed",
491
- })
492
- }
493
-
494
- paymentSucceeded(options: {
495
- arguments: { paymentId: string }
496
- result: { receiptId: string }
497
- }): void {
498
- this.status = "paid"
499
- }
500
-
501
- paymentFailed(options: { arguments: { paymentId: string }; error: JsonObject }): void {
502
- this.status = "failed"
503
- }
504
- }
505
-
506
- runtime.registerEffect("chargePayment", async ({ paymentId }, context) => {
507
- return payments.charge({ paymentId, idempotencyKey: context.id })
508
- })
509
- ```
256
+ ## Requirements and supported systems
510
257
 
511
- Effect context also exposes `attempt`, `sourceMessageId`, `actorType`, and
512
- `actorId`. The effect `id` is stable across retries and remains the external
513
- idempotency key.
514
-
515
- Success callbacks receive `{ effectId, arguments, result }`. Failure callbacks
516
- receive `{ effectId, arguments, error }`. The JSON `arguments` are the
517
- values originally staged by `emit()`, so actors can correlate concurrent
518
- effects without coupling the external handler to actor state.
519
-
520
- ## Commit actions
521
-
522
- Commit actions make a short database-only write in the same fenced transaction
523
- as actor state:
524
-
525
- ```typescript
526
- runtime.registerCommitAction("completeAttempt", async ({ attemptId }, context) => {
527
- await context.connection.run("UPDATE attempts SET completed = 1 WHERE id = ?", [attemptId])
528
- })
529
- ```
530
-
531
- Database row generics are assertions, not runtime conversions. In particular,
532
- SQLite integer columns are returned as `bigint`; type rows accordingly or
533
- convert deliberately. See the [adapter value mapping](docs/configuration.md#database-value-mapping).
534
-
535
- Commit-action context includes the source message and request IDs, actor
536
- identity, mailbox sequence, activation generation, and the active transaction
537
- connection.
538
-
539
- Do not perform network I/O in a commit action. Use an effect when work cannot
540
- share the Solid Objects database transaction.
541
-
542
- When actors also read an application database, wrap that database with the
543
- guarded facade and use the same facade everywhere:
544
-
545
- ```typescript
546
- import { guardApplicationDatabase } from "solid-objects"
547
- import { sqlite } from "solid-objects/database/sqlite"
548
-
549
- const applicationDatabase = guardApplicationDatabase(sqlite({ path: "application.sqlite3" }))
550
- ```
551
-
552
- During actor execution, observable and payload projection, and state migration,
553
- the facade permits `SELECT` through `get()` and `all()` and rejects `run()` or
554
- row-returning write statements. A commit action stays inside the same read-only
555
- context and writes only through its supplied fenced `context.connection`. This
556
- boundary is opt-in: Solid Objects cannot intercept a separate ORM pool or an
557
- unwrapped database client.
558
-
559
- ## Inspect and retry terminal failures
560
-
561
- A committed invocation that exhausts its attempts raises `MessageFailed`.
562
- The exception carries the durable `messageId` and the persisted error record in
563
- `details`, so callers can correlate the failure without parsing its message.
564
- If an already-authorized actor is destroyed while a caller is waiting,
565
- `ActorDestroyed` is raised instead.
566
-
567
- Messages that exhaust their attempts remain available as dead letters. Access
568
- is deny-by-default and goes through the administration policy:
569
-
570
- ```typescript
571
- const deadLetters = await runtime.deadLetters.all({
572
- authorizationContext: currentUser,
573
- })
574
-
575
- const deadLetter = deadLetters[0]
576
- if (deadLetter) {
577
- await runtime.deadLetters.retry(deadLetter.id, {
578
- authorizationContext: currentUser,
579
- })
580
- }
581
- ```
582
-
583
- Retry creates one durable replacement message and records that link. Repeating
584
- the retry returns the same `MessageReference` instead of enqueueing duplicate
585
- work.
586
-
587
- ## Reconcile application-owned actors
588
-
589
- Self-scheduling actors should have a low-frequency application reconciler for
590
- lost alarms and lifecycle drift. The read side is bounded, immutable, and
591
- administration-authorized:
592
-
593
- ```typescript
594
- const page = await runtime.reconciliation.withoutPendingWork({
595
- actorType: Trial.actorType,
596
- quietForMilliseconds: 24 * 60 * 60 * 1_000,
597
- authorizationContext: currentUser,
598
- })
599
-
600
- for (const instance of page.items) {
601
- await Trial.ref(instance.actorId).send.reconcile()
602
- }
603
- ```
604
-
605
- `active()`, `statesFor()`, and `orphaned()` cover the other reconciliation
606
- views. State batches are migrated to the registered actor's current version
607
- before they are returned. Reconciliation never writes actor state directly;
608
- repairs enter the ordinary durable mailbox.
609
-
610
- ## Retain history deliberately
611
-
612
- Message history defaults to 30 days, stopped process history to 7 days, and
613
- actor instances never expire unless their actor type opts in:
614
-
615
- ```typescript
616
- const runtime = configure({
617
- database,
618
- messageRetentionMilliseconds: 30 * 24 * 60 * 60 * 1_000,
619
- messageRetentionByActorType: {
620
- [AuditEvent.actorType]: 365 * 24 * 60 * 60 * 1_000,
621
- },
622
- instanceRetentionByActorType: {
623
- [EphemeralSession.actorType]: 7 * 24 * 60 * 60 * 1_000,
624
- },
625
- })
626
- ```
627
-
628
- Preview each resource before pruning it:
629
-
630
- ```typescript
631
- const preview = await runtime.retention.preview({
632
- target: "messages",
633
- authorizationContext: currentUser,
634
- })
635
-
636
- const pruned = await runtime.retention.prune({
637
- target: "messages",
638
- authorizationContext: currentUser,
639
- })
640
- ```
641
-
642
- `preview.count` is the number of rows currently eligible; `pruned.count` is the
643
- number actually deleted after candidates are rechecked.
644
-
645
- Pruning rechecks every candidate in bounded transactions. Live mailbox work,
646
- unfinished outboxes, scheduled reminders, dead letters, retry links, active
647
- leases, and running processes are retained.
648
-
649
- ## Verify an installation
650
-
651
- The doctor returns a structured report for startup checks, deployment probes,
652
- or an application-owned CLI:
653
-
654
- ```typescript
655
- const report = await runtime.doctor.run()
656
-
657
- for (const check of report.checks) {
658
- console.log(check.status, check.name, check.message)
659
- }
660
-
661
- if (!report.healthy) process.exitCode = 1
662
- ```
663
-
664
- It checks configuration, schema migrations and required columns, the database
665
- server version and MySQL table engines, authorization-policy configuration and
666
- neutral-context posture, live runtime roles, and a targeted durable actor round
667
- trip. Pass `{ roundTrip: "skip" }` for a read-only report.
668
-
669
- Inspect role liveness with `runtime.processes.all()`. Each record exposes
670
- `shutdownState` (`"running"`, `"draining"`, or `"stopped"`) plus a current
671
- `stale` calculation based on the configured heartbeat threshold, hostname,
672
- host process ID, Node version, and Solid Objects version.
673
- `runtime.processes.cleanup()` atomically marks stale owners stopped, releases
674
- their actor activations, returns claimed messages to ready membership, and
675
- releases their effect, reminder, and broadcast claims.
676
-
677
- For application-owned long-running roles, call `runtime.registerComponent()`
678
- before `run()`. Each factory-created component implements `run(signal)`,
679
- `requestShutdown()`, `stopped()`, and `stop()`. The runtime supervises and
680
- replaces failed components just like built-in roles. See
681
- [`docs/api.md`](docs/api.md#runtime-extensions-and-manual-workers) for the full
682
- contract.
683
-
684
- ## Operate it from the command line
685
-
686
- Export the configured runtime from an application module:
687
-
688
- ```javascript
689
- import { configure } from "solid-objects"
690
- import { sqlite } from "solid-objects/database/sqlite"
691
- import { Counter } from "./dist/counter.js"
692
-
693
- const runtime = configure({
694
- database: sqlite({ path: "storage/solid-objects.sqlite3" }),
695
- authorizeAdministration: ({ authorizationContext }) => authorizationContext?.source === "cli",
696
- })
697
-
698
- runtime.register(Counter)
699
- export default runtime
700
- ```
701
-
702
- The CLI loads `solid-objects.config.js` by default; use `--config` for another
703
- compiled module:
704
-
705
- ```bash
706
- pnpm exec solid-objects start
707
- pnpm exec solid-objects doctor
708
- pnpm exec solid-objects status
709
- pnpm exec solid-objects cleanup
710
- pnpm exec solid-objects dead-letters
711
- pnpm exec solid-objects retry-dead-letter DEAD_LETTER_ID
712
- pnpm exec solid-objects reminders --status paused
713
- pnpm exec solid-objects resume-reminder REMINDER_ID
714
- pnpm exec solid-objects prune messages
715
- pnpm exec solid-objects prune messages --execute
716
- ```
717
-
718
- Pruning is preview-only unless `--execute` is present. Administrative commands
719
- use `{ source: "cli" }` as their authorization context and emit JSON for shell
720
- automation.
721
-
722
- Every command accepts `--config PATH` or `-c PATH`. `doctor` accepts
723
- `--skip-round-trip`; `reminders` accepts `--actor-type TYPE` and `--status
724
- scheduled|paused|completed`; `resume-reminder` accepts an ISO `--run-at DATE`;
725
- and `prune` accepts `--execute`. Run `solid-objects --help` for the command
726
- summary.
727
-
728
- ## Test durable workflows without sleeps
729
-
730
- `runtime.testing.drain()` runs configured roles in deterministic passes until
731
- they are idle. It does not advance reminder schedules or effect retry backoff;
732
- retryable effects rescheduled into the future remain pending. Throw
733
- `NonRetryableError` in a test handler when the scenario is terminal failure.
734
- Select roles when a test needs a narrower boundary:
735
-
736
- ```typescript
737
- const message = await Counter.ref("test").send.increment()
738
-
739
- await runtime.testing.drain({ roles: ["actors"] })
740
-
741
- expect(await message.status()).toBe("completed")
742
- ```
743
-
744
- Run reminders against an explicit future instant without changing their stored
745
- schedules or sleeping:
746
-
747
- ```typescript
748
- await runtime.testing.runDueReminders({ now: fiveMinutesFromNow })
749
- await runtime.testing.drain({ roles: ["actors"] })
750
- ```
751
-
752
- `runtime.testing.reset()` stops and discards the cached caller worker, then
753
- deletes every actor-owned table and process row in dependency order. Use it in
754
- test setup and teardown; it does not rely on transactional tests or foreign-key
755
- cascades.
756
-
757
- ## Connect observability without coupling the runtime
758
-
759
- Provide a synchronous instrumentation sink and forward events to the
760
- observability system already used by the application:
761
-
762
- ```typescript
763
- const runtime = configure({
764
- database,
765
- instrumentation: (event) => diagnosticsChannel.publish(event),
766
- })
767
- ```
768
-
769
- Events use names such as `solid_objects.message.enqueued`,
770
- `solid_objects.message.completed`, `solid_objects.effect.failed`,
771
- `solid_objects.dead_letter.created`, and `solid_objects.actor.destroyed`.
772
- Records are immutable and contain operational metadata only. Arguments, actor
773
- state, results, rejection messages and details, error messages, and broadcast
774
- payloads never enter the instrumentation API. A sink failure is logged and
775
- cannot fail durable work.
776
-
777
- Moving an existing alarm to another time emits
778
- `solid_objects.reminder.replaced` only after the actor turn commits. The event
779
- contains the actor identity, operation, reminder ID, and previous and next run
780
- times without reminder arguments.
781
-
782
- ## Add realtime updates without exposing all state
783
-
784
- Connect an authenticated socket to the transport-neutral subscription manager.
785
- The application owns the WebSocket server and decides what object represents
786
- the authenticated connection:
787
-
788
- ```typescript
789
- server.on("connection", (socket, request) => {
790
- const session = runtime.realtime.connect({
791
- authorizationContext: request.user,
792
- send: (envelope) => socket.send(JSON.stringify(envelope)),
793
- })
794
-
795
- socket.on("message", (data) => {
796
- session.receive(data).catch(() => socket.close(1008, "subscription rejected"))
797
- })
798
- socket.on("close", () => session.close())
799
- })
800
- ```
801
-
802
- Every subscribe request calls `authorizeSubscription` before actor lookup. An
803
- accepted subscription immediately receives the latest committed observable
804
- projection with its actor incarnation and revision, without adding a mailbox
805
- message. Later invalidations come from the durable outbox in actor revision
806
- order. Duplicate and stale revisions are fenced, and one broken connection
807
- cannot interrupt delivery to another.
808
-
809
- Invalidation envelopes contain the actual `observables()` values and deliver
810
- the same projection to every authorized subscriber. Never put credentials,
811
- session identifiers, private cards, or other subscriber-specific data there;
812
- use a typed payload projection or a reauthorized component endpoint instead.
813
-
814
- Direct session delivery is process-local. When WebSocket connections and
815
- workers run in several Node processes, configure `broadcast` to publish each
816
- durable event through the application's shared transport, and have every
817
- process feed received events to `runtime.realtime.publish(event)`. Polling and
818
- the durable outbox remain the correctness fallback; the shared transport fans
819
- a committed event out to the processes that own live connections.
820
-
821
- The browser entry contains no Node imports. It validates versioned invalidation
822
- envelopes, tracks actor incarnations and revisions, and ignores stale delivery:
823
-
824
- ```typescript
825
- import { SolidObjectsBrowserClient } from "solid-objects/browser"
826
-
827
- const client = new SolidObjectsBrowserClient({
828
- url: new URL("/solid-objects", window.location.href),
829
- onInvalidation: ({ observables }) => render(observables),
830
- })
831
-
832
- client.subscribe({ actorType: "Counter", actorId: "primary" })
833
- client.connect()
834
- ```
835
-
836
- For server-rendered or framework-owned UI fragments, register their observable
837
- dependencies and let one invalidation refresh only the affected targets:
838
-
839
- ```typescript
840
- import { SolidObjectsBrowserClient, SolidObjectsComponentRegistry } from "solid-objects/browser"
841
-
842
- const componentRegistry = new SolidObjectsComponentRegistry<string>({
843
- refresh: async ({ actorType, actorId, instanceId, revision, batch, components, signal }) => {
844
- const response = await fetch("/components/refresh", {
845
- method: "POST",
846
- signal,
847
- headers: { "content-type": "application/json" },
848
- body: JSON.stringify({ actorType, actorId, instanceId, revision, batch, components }),
849
- })
850
- if (!response.ok) throw new Error(`component refresh failed with ${response.status}`)
851
- return response.json()
852
- },
853
- apply: ({ component, rendered }) => {
854
- updateComponent(component.target, rendered, { strategy: component.strategy })
855
- },
856
- })
857
-
858
- componentRegistry.register({
859
- actorType: "GameRoom",
860
- actorId: "table-1",
861
- target: "player-one",
862
- name: "player",
863
- key: 1,
864
- observes: ["playerOne"],
865
- batch: "playmat",
866
- strategy: "morph",
867
- })
868
-
869
- const client = new SolidObjectsBrowserClient({
870
- url: new URL("/solid-objects", window.location.href),
871
- onInvalidation: (envelope) => componentRegistry.invalidate(envelope),
872
- })
873
- ```
874
-
875
- Registrations sharing a batch are refreshed in one request. Same-revision
876
- invalidations merge in a microtask, a strictly newer request aborts the older
877
- one, and per-target incarnation/revision fences prevent a late response from
878
- overwriting current UI. `replace` and `morph` are strategies passed to the
879
- application's synchronous `apply` callback; the library does not assume a DOM
880
- framework. The refresh endpoint must authenticate the request and reauthorize
881
- every requested component and dependency.
882
-
883
- Run `pnpm run test:browser` after installing Playwright's Chromium build to
884
- exercise the browser entry through native WebSocket and browser APIs.
885
-
886
- For subscriber-specific views, declare a static payload map with a TypeScript
887
- `satisfies` check:
888
-
889
- ```typescript
890
- import { Actor, type PayloadBroadcasts } from "solid-objects"
891
-
892
- type Viewer = {
893
- accountId: string
894
- }
895
-
896
- class GameRoom extends Actor {
897
- static override readonly actorType = "GameRoom"
898
- static override readonly payloads = {
899
- playmat: (room, viewer) => ({
900
- turn: room.turn,
901
- hand: room.hands[viewer.accountId] ?? [],
902
- }),
903
- } satisfies PayloadBroadcasts<GameRoom, Viewer>
904
-
905
- turn = 1
906
- hands: Record<string, string[]> = {}
907
- }
908
- ```
909
-
910
- Declare named payload return shapes with `type`, not `interface`.
911
- `PayloadBroadcastValue` is a JSON object or array, and TypeScript interfaces do
912
- not implicitly provide the JSON object's string index signature.
913
-
914
- Request payloads by name and render them separately from observable
915
- invalidations:
916
-
917
- ```typescript
918
- const client = new SolidObjectsBrowserClient({
919
- url: new URL("/solid-objects", window.location.href),
920
- onInvalidation: ({ observables }) => renderScalars(observables),
921
- onPayload: ({ name, payload }) => renderPayload(name, payload),
922
- })
923
-
924
- client.subscribe({
925
- actorType: "GameRoom",
926
- actorId: "primary",
927
- payloads: ["playmat"],
928
- })
929
- ```
930
-
931
- Each payload runs against committed state and the subscribing session's fresh
932
- authorization context. `authorizeQuery` is called with the payload name before
933
- projection. A denied or failing payload is omitted without stopping sibling
934
- payloads or observable invalidations.
935
-
936
- `broadcast` remains available when an application also needs to forward the
937
- same durable events through another transport or broker. Browser-visible actor
938
- IDs and observable values are not authorization.
939
-
940
- ## Delivery contract
941
-
942
- - Messages are ordered per actor identity and delivered at least once.
943
- - Different actor identities may execute concurrently.
944
- - A worker drains at most `maxMessagesPerActivationPass` turns from one actor,
945
- then yields its still-due work behind actors that were already waiting.
946
- - A global claim scans at most `claimScanLimit` ordered candidates, continuing
947
- to another ready actor after a lost lease race.
948
- - Long-running workers reuse hydrated actors for
949
- `idleDeactivationTimeoutMilliseconds` while renewing the same fenced lease.
950
- - State, completion, staged messages, effects, reminders, commit actions, and
951
- observable broadcasts share one fenced commit.
952
- - A lost or expired activation lease cannot commit.
953
- - Failed turns roll state and staged intents back and block later work until
954
- retry or dead-letter completion.
955
- - Effects can execute more than once.
956
- - Results and snapshots are deeply frozen copies. A snapshot contains every
957
- persisted field and getter from one committed state image; snapshot getters
958
- must not mutate state or stage durable work.
959
-
960
- Override protected `onActivate()` and `onDeactivate()` methods when an actor
961
- needs a process-local resource during that window. Hooks may be asynchronous,
962
- cannot write through a guarded application database, and are nondurable;
963
- `onDeactivate()` is best effort and must not carry correctness work.
964
-
965
- ## Current scope
966
-
967
- The current runtime supports Node.js 24, SQLite through built-in `node:sqlite`,
968
- PostgreSQL 14 or newer through `pg` 8.23, and MySQL 8.0 or newer through
969
- `mysql2` 3.23 with InnoDB. HTTP/WebSocket server adapters and Ruby schema
970
- interoperability are not part of the compatibility contract.
258
+ - Node.js 24.15 or newer
259
+ - TypeScript 5.9 or newer for TypeScript applications
260
+ - SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer
261
+ with InnoDB
262
+ - optional `pg`, `mysql2`, or `redis` peer dependency only for the selected
263
+ adapter
264
+
265
+ The exact CI matrix and boundaries are documented in
266
+ [Supported versions](docs/support.md).
267
+
268
+ ## Operations
269
+
270
+ `runtime.run(signal)` supervises actor, effect, reminder, broadcast, retention,
271
+ and stale-process recovery roles. The database-backed operator dashboard is an
272
+ optional `solid-objects/web` export with deny-by-default administration policy,
273
+ session-backed CSRF protection, and Fetch or Node/Connect mounting.
274
+
275
+ The dashboard defaults to authorized read/write access. An authorized
276
+ read-only mode removes mutations, while an explicitly public read-only mode is
277
+ appropriate only for synthetic demo data because it exposes stored arguments,
278
+ results, errors, identifiers, and operational metadata.
279
+
280
+ Administration remains available through the JSON CLI and typed runtime
281
+ managers. See [Operations](docs/operations.md), the [dashboard guide](docs/dashboard.md),
282
+ and [Configuration](docs/configuration.md).
283
+
284
+ ## Design provenance
285
+
286
+ Solid Objects JS is a Node.js and TypeScript implementation informed by the
287
+ Ruby [`solid_objects`](https://github.com/cardmagic/solid_objects) design. It
288
+ began at the `0.12` capability generation because the initial implementation
289
+ targeted the Ruby `0.12` contract; the number does not represent twelve earlier
290
+ JavaScript release generations.
291
+
292
+ The TypeScript implementation is not a source translation. It redesigned the
293
+ API around inferred TypeScript references, Node runtime supervision,
294
+ `node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions,
295
+ Web Components, and browser-safe package exports. The
296
+ [parity ledger](docs/parity.md) records capability relationships and deliberate
297
+ runtime differences.
298
+
299
+ The Ruby project first appeared publicly on August 6, 2026, and this TypeScript
300
+ repository on August 13, 2026. Both remain early releases. The
301
+ [`mtg-playmat`](https://github.com/cardmagic/mtg-playmat) application uses the
302
+ Ruby actor and realtime design, while
303
+ [Shuffle Up and Play](https://github.com/cardmagic/shuffleupandplay) uses the
304
+ TypeScript package in the deployed Node and SQLite topology documented above.
971
305
 
972
306
  ## Documentation
973
307
 
974
- - [`docs/state-and-lifecycle.md`](docs/state-and-lifecycle.md) covers actor
975
- discovery, migrations, lifecycle hooks, rejection, snapshots, and
976
- destruction.
977
- - [`docs/configuration.md`](docs/configuration.md) lists every runtime and
978
- adapter option with its default and constraint.
979
- - [`docs/errors-and-recovery.md`](docs/errors-and-recovery.md) maps public
980
- errors to retry and recovery behavior.
981
- - [`docs/api.md`](docs/api.md) indexes every supported public export and runtime
982
- manager.
983
- - [`docs/operations.md`](docs/operations.md),
984
- [`docs/architecture.md`](docs/architecture.md), and
985
- [`docs/correctness.md`](docs/correctness.md) define the operating and delivery
986
- contracts.
987
- - [`docs/authorization.md`](docs/authorization.md) and
988
- [`docs/browser-protocol.md`](docs/browser-protocol.md) cover security and the
989
- transport-neutral realtime protocol.
990
- - The [`Ruby parity ledger`](docs/parity.md) tracks native equivalents and
991
- explicit scope boundaries against Ruby Solid Objects 0.12.0.
992
- - [`docs/releasing.md`](docs/releasing.md) documents the tag-driven npm release
993
- workflow for maintainers.
308
+ - [Getting the architecture right](docs/architecture.md)
309
+ - [Correctness and delivery semantics](docs/correctness.md)
310
+ - [Choosing Solid Objects](docs/fit.md)
311
+ - [Benchmarks and methodology](docs/benchmarks.md)
312
+ - [Supported versions and test matrix](docs/support.md)
313
+ - [Test suite](https://github.com/cardmagic/solid-objects-js/tree/main/test)
314
+ - [CI workflow](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
315
+ - [Public API](docs/api.md)
316
+ - [State and lifecycle](docs/state-and-lifecycle.md)
317
+ - [Operations, retention, and reconciliation](docs/operations.md)
318
+ - [Configuration](docs/configuration.md)
319
+ - [Authorization](docs/authorization.md)
320
+ - [Browser protocol](docs/browser-protocol.md)
321
+ - [Operator dashboard](docs/dashboard.md)
322
+ - [Errors and recovery](docs/errors-and-recovery.md)
323
+ - [Design parity](docs/parity.md)
324
+ - [Changelog](CHANGELOG.md)
325
+ - [Releases](https://github.com/cardmagic/solid-objects-js/releases)
326
+ - [Contributing](CONTRIBUTING.md)
327
+ - [Security policy](SECURITY.md)
994
328
 
995
329
  ## License
996
330
 
997
- Solid Objects is released under the MIT License.
331
+ Solid Objects is released under the [MIT License](MIT-LICENSE).