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