solid-objects 0.12.1 → 0.13.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.
- package/CHANGELOG.md +38 -0
- package/README.md +228 -953
- package/dist/actor.d.ts +12 -0
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +25 -2
- package/dist/actor.js.map +1 -1
- package/dist/browser/components.d.ts.map +1 -1
- package/dist/browser/components.js +4 -1
- package/dist/browser/components.js.map +1 -1
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +8 -0
- package/dist/browser/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -1
- package/dist/cli.js.map +1 -1
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.d.ts.map +1 -1
- package/dist/configuration.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +12 -3
- package/dist/doctor.js.map +1 -1
- package/dist/examples/sqlite-quickstart.js +83 -0
- package/dist/examples/sqlite-quickstart.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/records.d.ts +1 -0
- package/dist/records.d.ts.map +1 -1
- package/dist/repository.d.ts +4 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +5 -4
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +37 -6
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +15 -4
- package/dist/schema.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/web/assets.d.ts +4 -0
- package/dist/web/assets.d.ts.map +1 -0
- package/dist/web/assets.js +11 -0
- package/dist/web/assets.js.map +1 -0
- package/dist/web/index.d.ts +28 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +642 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/node.d.ts +3 -0
- package/dist/web/node.d.ts.map +1 -0
- package/dist/web/node.js +109 -0
- package/dist/web/node.js.map +1 -0
- package/dist/web/render.d.ts +64 -0
- package/dist/web/render.d.ts.map +1 -0
- package/dist/web/render.js +331 -0
- package/dist/web/render.js.map +1 -0
- package/dist/web/store.d.ts +65 -0
- package/dist/web/store.d.ts.map +1 -0
- package/dist/web/store.js +303 -0
- package/dist/web/store.js.map +1 -0
- package/dist/web/types.d.ts +82 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +2 -0
- package/dist/web/types.js.map +1 -0
- package/docs/api.md +63 -0
- package/docs/architecture.md +16 -10
- package/docs/authorization.md +12 -3
- package/docs/benchmarks.md +123 -0
- package/docs/browser-protocol.md +15 -8
- package/docs/comparisons.md +36 -0
- package/docs/configuration.md +3 -1
- package/docs/correctness.md +23 -0
- package/docs/dashboard.md +196 -0
- package/docs/fit.md +58 -0
- package/docs/parity.md +37 -34
- package/docs/releasing.md +6 -4
- package/docs/state-and-lifecycle.md +10 -0
- package/docs/support.md +39 -0
- package/examples/failure-recovery/actor.ts +51 -0
- package/examples/failure-recovery/demo.ts +233 -0
- package/examples/failure-recovery/worker.ts +46 -0
- package/examples/sqlite-quickstart.ts +109 -0
- package/package.json +25 -4
package/README.md
CHANGED
|
@@ -1,997 +1,272 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Durable Objects for Node, backed by your existing SQL database
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/solid-objects)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
24
|
import { Actor } from "solid-objects"
|
|
71
25
|
|
|
72
|
-
|
|
73
|
-
static override readonly actorType = "
|
|
26
|
+
class Cart extends Actor {
|
|
27
|
+
static override readonly actorType = "Cart"
|
|
74
28
|
|
|
75
|
-
|
|
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
|
-
}
|
|
29
|
+
items: string[] = []
|
|
85
30
|
|
|
86
|
-
|
|
87
|
-
|
|
31
|
+
add({ sku }: { sku: string }): number {
|
|
32
|
+
this.items.push(sku)
|
|
33
|
+
return this.items.length
|
|
88
34
|
}
|
|
89
35
|
}
|
|
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
36
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
37
|
+
const cart = Cart.ref("cart-123")
|
|
38
|
+
await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
|
|
124
39
|
```
|
|
125
40
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
41
|
+
Both calls enter the durable mailbox for `cart-123`. They execute in order and
|
|
42
|
+
commit one state transition at a time, even when different requests or Node.js
|
|
43
|
+
processes submit them concurrently.
|
|
132
44
|
|
|
133
|
-
|
|
134
|
-
discovery, rolling deployment, activation hooks, snapshots, rejection, and
|
|
135
|
-
destruction.
|
|
45
|
+
## Run it now with SQLite
|
|
136
46
|
|
|
137
|
-
|
|
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:
|
|
47
|
+
Node.js 24.15 or newer is required. The `0.13.0` release includes a
|
|
48
|
+
packaged quickstart:
|
|
216
49
|
|
|
217
50
|
```bash
|
|
218
|
-
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
##
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
51
|
+
npm exec --yes --package=solid-objects@0.13.0 -- solid-objects quickstart
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The command needs no repository checkout, database server, Redis, container, or
|
|
55
|
+
application configuration. It uses Node's built-in SQLite module and removes
|
|
56
|
+
its scoped temporary database before exiting.
|
|
57
|
+
|
|
58
|
+
The executable asserts rather than merely printing a plausible result. In one
|
|
59
|
+
local run, it verifies that:
|
|
60
|
+
|
|
61
|
+
- 25 concurrent calls to one identity produce the exact committed state `25`;
|
|
62
|
+
- their return values are the complete sequence from `1` through `25`;
|
|
63
|
+
- operations for two different identities overlap in time; and
|
|
64
|
+
- the runtime closes and temporary state is removed.
|
|
65
|
+
|
|
66
|
+
## What Solid Objects is for
|
|
67
|
+
|
|
68
|
+
Use Solid Objects when more than one request, job, or process can act on the
|
|
69
|
+
same logical thing and the next action must use its latest committed state.
|
|
70
|
+
These are the stateful coordination patterns for which people often reach for
|
|
71
|
+
Durable Objects:
|
|
72
|
+
|
|
73
|
+
| Pattern | One identity per | What the object coordinates |
|
|
74
|
+
| --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
|
|
75
|
+
| Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
|
|
76
|
+
| Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
|
|
77
|
+
| Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
|
|
78
|
+
| Per-key rate limits | API key, account, or device | Token checks and decrements are serialized; a reminder can refill the bucket |
|
|
79
|
+
| Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
|
|
80
|
+
|
|
81
|
+
The common shape is one durable coordination boundary with an application
|
|
82
|
+
defined identity. Work for that identity is serialized, while unrelated rooms,
|
|
83
|
+
carts, accounts, or sessions can progress concurrently. A single global rate
|
|
84
|
+
limiter or another very hot identity is a poor fit because it becomes an
|
|
85
|
+
intentional bottleneck. If one ordinary row transaction solves the problem,
|
|
86
|
+
prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
An object is addressed by its TypeScript class and application-defined ID.
|
|
91
|
+
Public fields are JSON state, public methods are durable operations, and public
|
|
92
|
+
getters are ordered queries.
|
|
93
|
+
|
|
94
|
+
For each identity, Solid Objects:
|
|
95
|
+
|
|
96
|
+
1. commits calls to a durable per-ID mailbox;
|
|
97
|
+
2. claims one activation with a renewable lease;
|
|
98
|
+
3. executes one operation at a time outside the database transaction;
|
|
99
|
+
4. commits state, completion, and staged work in a short fenced transaction;
|
|
100
|
+
5. retries recoverable failures and exposes terminal failures as dead letters;
|
|
101
|
+
6. publishes committed realtime invalidations in revision order.
|
|
102
|
+
|
|
103
|
+
The fence includes the activation owner, token, generation, expiration, and
|
|
104
|
+
claimed message. A worker that finishes JavaScript after losing its lease
|
|
105
|
+
cannot commit. See the executable [failure-recovery demonstration](examples/failure-recovery/demo.ts)
|
|
106
|
+
and the full [architecture](docs/architecture.md).
|
|
107
|
+
|
|
108
|
+
Redis is optional wake-up infrastructure. It can reduce notification latency
|
|
109
|
+
for a multi-process MySQL deployment, but the relational database remains the
|
|
110
|
+
durable source of truth and polling remains the recovery path.
|
|
111
|
+
|
|
112
|
+
## Good and poor fits
|
|
113
|
+
|
|
114
|
+
| Good fit | Poor fit |
|
|
115
|
+
| --------------------------------------------------------- | --------------------------------------------------------- |
|
|
116
|
+
| Multiplayer rooms and collaborative sessions | A single-row update already solved by one SQL transaction |
|
|
117
|
+
| Shopping carts, accounts, devices, and per-user workflows | Bulk ingestion and data-parallel pipelines |
|
|
118
|
+
| Stateful agent sessions with ordered tool results | Very high-throughput global counters |
|
|
119
|
+
| Per-document or per-device reminders | Large JSON documents that should remain normalized rows |
|
|
120
|
+
| Realtime projections of committed state | Globally placed edge state or managed elastic placement |
|
|
121
|
+
|
|
122
|
+
One hot identity is intentionally serialized. Split an identity only when the
|
|
123
|
+
domain can tolerate independent ordering and transactions. Solid Objects does
|
|
124
|
+
not provide a transaction across object identities.
|
|
125
|
+
|
|
126
|
+
The longer decision guide is in [Choosing Solid Objects](docs/fit.md).
|
|
127
|
+
|
|
128
|
+
## Delivery boundaries
|
|
129
|
+
|
|
130
|
+
- Operations are ordered per identity and execute **at least once**.
|
|
131
|
+
- A crash after arbitrary external I/O but before the database commit can cause
|
|
132
|
+
that I/O to repeat. Use the stable effect ID or another durable idempotency
|
|
133
|
+
key at the external system.
|
|
134
|
+
- Fencing protects the Solid Objects database commit. It cannot undo an HTTP
|
|
135
|
+
request, email, payment, file write, or other external side effect.
|
|
136
|
+
- Different identities can execute concurrently; one hot identity cannot.
|
|
137
|
+
- State, result, actor-to-actor delivery, reminders, effects, commit actions,
|
|
138
|
+
and realtime invalidations commit together for one operation.
|
|
139
|
+
- Cross-object transactions are not provided.
|
|
140
|
+
- Application processes with incompatible `stateVersion` values must not run
|
|
141
|
+
together. Older code rejects state written by a newer version.
|
|
142
|
+
- Direct application-database writes are guarded only when the application
|
|
143
|
+
uses the supplied database facade. Unwrapped clients cannot be intercepted.
|
|
144
|
+
- Realtime sessions are process-local. A multi-process application must bridge
|
|
145
|
+
committed broadcast events to the processes holding live connections.
|
|
146
|
+
|
|
147
|
+
See [Correctness and delivery semantics](docs/correctness.md) and
|
|
148
|
+
[Errors and recovery](docs/errors-and-recovery.md) for the complete contract.
|
|
149
|
+
|
|
150
|
+
## Realtime committed state
|
|
151
|
+
|
|
152
|
+
Actors opt into browser-visible dependencies. In `0.13`, an unwrapped
|
|
153
|
+
observable triggers invalidation without storing or sending its value. Use
|
|
154
|
+
`broadcastValue()` only for a scalar that every authorized subscriber may see:
|
|
297
155
|
|
|
298
156
|
```typescript
|
|
299
|
-
|
|
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`.
|
|
157
|
+
import { Actor, broadcastValue } from "solid-objects"
|
|
314
158
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
```typescript
|
|
319
|
-
class Reservation extends Actor {
|
|
320
|
-
static override readonly actorType = "Reservation"
|
|
159
|
+
class Room extends Actor {
|
|
160
|
+
static override readonly actorType = "Room"
|
|
321
161
|
|
|
322
|
-
|
|
162
|
+
version = 0
|
|
163
|
+
privateHands: Record<string, string[]> = {}
|
|
323
164
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
this.
|
|
327
|
-
|
|
328
|
-
details: { available: this.available },
|
|
329
|
-
})
|
|
165
|
+
override observables(): Record<string, unknown> {
|
|
166
|
+
return {
|
|
167
|
+
version: broadcastValue(this.version),
|
|
168
|
+
hands: this.privateHands,
|
|
330
169
|
}
|
|
331
|
-
this.available -= quantity
|
|
332
170
|
}
|
|
333
171
|
}
|
|
334
172
|
```
|
|
335
173
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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:
|
|
174
|
+
`version` crosses the shared invalidation channel. `hands` contributes only its
|
|
175
|
+
name when its real value changes, allowing a reauthorized component endpoint to
|
|
176
|
+
render subscriber-specific state without a manual revision counter.
|
|
352
177
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
console.log(snapshot.count, snapshot.doubled)
|
|
359
|
-
```
|
|
178
|
+
The browser package handles replay, reconnection, incarnation/revision fences,
|
|
179
|
+
personalized payloads, and framework-neutral component refresh. Applications
|
|
180
|
+
provide authentication, WebSocket transport, and rendering. See the
|
|
181
|
+
[browser protocol](docs/browser-protocol.md) and [authorization guide](docs/authorization.md).
|
|
360
182
|
|
|
361
|
-
|
|
362
|
-
work. Because snapshots do not enter the mailbox, use an ordinary query when
|
|
363
|
-
the read must be ordered behind earlier messages.
|
|
183
|
+
## Comparison
|
|
364
184
|
|
|
365
|
-
|
|
185
|
+
These systems solve different coordination problems. The table describes their
|
|
186
|
+
default unit and deployment model, not a quality ranking.
|
|
366
187
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
one receives `ActorDestroyed`.
|
|
377
|
-
|
|
378
|
-
## Send background work without a queue service
|
|
379
|
-
|
|
380
|
-
Use the typed `send` dispatcher when the caller should not wait for execution:
|
|
381
|
-
|
|
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
|
-
```
|
|
188
|
+
| Approach | Serialization and state unit | Durable substrate | Additional runtime | Recovery model | Placement |
|
|
189
|
+
| --------------------------- | ----------------------------------------------------- | ------------------------------------- | ------------------------------------------------------- | ---------------------------------------------- | ---------------------------- |
|
|
190
|
+
| SQL transaction or row lock | Selected rows in one transaction | Application database | None | Application retries the transaction | Application deployment |
|
|
191
|
+
| 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 |
|
|
192
|
+
| 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 |
|
|
193
|
+
| Cloudflare Durable Objects | Object class plus globally unique ID | Per-object managed storage | Cloudflare Workers platform | Managed object activation | Cloudflare-selected location |
|
|
194
|
+
| Rivet Actors | Addressable actor | Actor state, KV, or per-actor SQLite | Rivet Engine or managed compute | Actor sleep, wake, and persistence | Configured Rivet deployment |
|
|
195
|
+
| DBOS | Workflow ID and checkpointed steps | PostgreSQL system database | Library; Conductor recommended for distributed recovery | Deterministic workflow replay from checkpoints | Application deployment |
|
|
196
|
+
| 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 |
|
|
397
197
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
including ordinary `void` methods, are normalized to JSON `null` when their
|
|
402
|
-
durable result is read or awaited.
|
|
198
|
+
The sourced, dimension-by-dimension comparison—including realtime projections,
|
|
199
|
+
edge placement, cross-identity transactions, and operational data access—is in
|
|
200
|
+
[docs/comparisons.md](docs/comparisons.md).
|
|
403
201
|
|
|
404
|
-
|
|
405
|
-
`sendTo()` so outbound delivery commits atomically with the source actor turn—no
|
|
406
|
-
separate broker or hand-built transactional outbox required:
|
|
202
|
+
## Requirements and supported systems
|
|
407
203
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
```
|
|
510
|
-
|
|
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.
|
|
204
|
+
- Node.js 24.15 or newer
|
|
205
|
+
- TypeScript 5.9 or newer for TypeScript applications
|
|
206
|
+
- SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer
|
|
207
|
+
with InnoDB
|
|
208
|
+
- optional `pg`, `mysql2`, or `redis` peer dependency only for the selected
|
|
209
|
+
adapter
|
|
210
|
+
|
|
211
|
+
The exact CI matrix and boundaries are documented in
|
|
212
|
+
[Supported versions](docs/support.md).
|
|
213
|
+
|
|
214
|
+
## Operations
|
|
215
|
+
|
|
216
|
+
`runtime.run(signal)` supervises actor, effect, reminder, broadcast, retention,
|
|
217
|
+
and stale-process recovery roles. The database-backed operator dashboard is an
|
|
218
|
+
optional `solid-objects/web` export with deny-by-default administration policy,
|
|
219
|
+
session-backed CSRF protection, and Fetch or Node/Connect mounting.
|
|
220
|
+
|
|
221
|
+
The dashboard defaults to authorized read/write access. An authorized
|
|
222
|
+
read-only mode removes mutations, while an explicitly public read-only mode is
|
|
223
|
+
appropriate only for synthetic demo data because it exposes stored arguments,
|
|
224
|
+
results, errors, identifiers, and operational metadata.
|
|
225
|
+
|
|
226
|
+
Administration remains available through the JSON CLI and typed runtime
|
|
227
|
+
managers. See [Operations](docs/operations.md), the [dashboard guide](docs/dashboard.md),
|
|
228
|
+
and [Configuration](docs/configuration.md).
|
|
229
|
+
|
|
230
|
+
## Design provenance
|
|
231
|
+
|
|
232
|
+
Solid Objects JS is a Node.js and TypeScript implementation informed by the
|
|
233
|
+
Ruby [`solid_objects`](https://github.com/cardmagic/solid_objects) design. It
|
|
234
|
+
began at the `0.12` capability generation because the initial implementation
|
|
235
|
+
targeted the Ruby `0.12` contract; the number does not represent twelve earlier
|
|
236
|
+
JavaScript release generations.
|
|
237
|
+
|
|
238
|
+
The TypeScript implementation is not a source translation. It redesigned the
|
|
239
|
+
API around inferred TypeScript references, Node runtime supervision,
|
|
240
|
+
`node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions,
|
|
241
|
+
Web Components, and browser-safe package exports. The
|
|
242
|
+
[parity ledger](docs/parity.md) records capability relationships and deliberate
|
|
243
|
+
runtime differences.
|
|
244
|
+
|
|
245
|
+
The Ruby project first appeared publicly on August 6, 2026, and this TypeScript
|
|
246
|
+
repository on August 13, 2026. Both remain early releases. The
|
|
247
|
+
[`mtg-playmat`](https://github.com/cardmagic/mtg-playmat) application uses the
|
|
248
|
+
Ruby actor and realtime design as current dogfood; that is not evidence of a
|
|
249
|
+
TypeScript deployment.
|
|
971
250
|
|
|
972
251
|
## Documentation
|
|
973
252
|
|
|
974
|
-
- [
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
- [
|
|
978
|
-
|
|
979
|
-
- [
|
|
980
|
-
|
|
981
|
-
- [
|
|
982
|
-
|
|
983
|
-
- [
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
- [
|
|
988
|
-
|
|
989
|
-
|
|
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.
|
|
253
|
+
- [Getting the architecture right](docs/architecture.md)
|
|
254
|
+
- [Correctness and delivery semantics](docs/correctness.md)
|
|
255
|
+
- [Choosing Solid Objects](docs/fit.md)
|
|
256
|
+
- [Benchmarks and methodology](docs/benchmarks.md)
|
|
257
|
+
- [Supported versions and test matrix](docs/support.md)
|
|
258
|
+
- [Public API](docs/api.md)
|
|
259
|
+
- [State and lifecycle](docs/state-and-lifecycle.md)
|
|
260
|
+
- [Operations](docs/operations.md)
|
|
261
|
+
- [Configuration](docs/configuration.md)
|
|
262
|
+
- [Authorization](docs/authorization.md)
|
|
263
|
+
- [Browser protocol](docs/browser-protocol.md)
|
|
264
|
+
- [Operator dashboard](docs/dashboard.md)
|
|
265
|
+
- [Errors and recovery](docs/errors-and-recovery.md)
|
|
266
|
+
- [Design parity](docs/parity.md)
|
|
267
|
+
- [Contributing](CONTRIBUTING.md)
|
|
268
|
+
- [Security policy](SECURITY.md)
|
|
994
269
|
|
|
995
270
|
## License
|
|
996
271
|
|
|
997
|
-
Solid Objects is released under the MIT License.
|
|
272
|
+
Solid Objects is released under the [MIT License](MIT-LICENSE).
|