solid-objects 0.17.0 → 0.17.2

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 CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.2 - 2026-10-08
4
+
5
+ - The README names the agent guide at the start of Installation, and the agent
6
+ guide says that a reminder changes state only when it runs under
7
+ `runtime.run(signal)`, so a query must not compute expiry from the clock.
8
+ - Correct `docs/agents.md`: `reject` takes a code and an options object,
9
+ `this.reject(code, { message })`, not `this.reject(code, message)`.
10
+ - `docs/agents.md` now says that a worker process runs only the actor classes
11
+ that it knows. For an unregistered class, actor setup fails with
12
+ `UnknownActorType`, the worker reports `solid_objects.activation.failed`,
13
+ and the message returns to the queue without a counted attempt. Nothing
14
+ prints without an `instrumentation` callback. Register each class before
15
+ `runtime.run(signal)`.
16
+ It also tells agents to install the current release, puts the
17
+ deny-by-default reminder beside the install step, and lists the API
18
+ mistakes found in agent-written code.
19
+ - Claim the Context7 library: `context7.json` now carries the library `url` and
20
+ the maintainer `public_key`.
21
+ - Add the Context7 refresh workflow. A push to `main` that changes the README,
22
+ `context7.json`, `docs/`, or `examples/` asks Context7 to refresh the index.
23
+ It uses the `CONTEXT7_API_KEY` repository secret.
24
+
25
+ ## 0.17.1 - 2026-10-08
26
+
27
+ - Check holds with `Object.hasOwn` in the README and guide ticket sale
28
+ examples. The `in` operator also matched inherited names, so a buyer named
29
+ `constructor` could not hold a free ticket, and `expire` could add a ticket
30
+ that no hold had taken. `pnpm run test:package` now holds a ticket for that
31
+ buyer.
32
+ - Override `sharp` with 0.35.5 and `source-map-js` with 1.2.2. Two high
33
+ advisories reach the repository only through the development tooling:
34
+ `sharp` through Miniflare and `source-map-js` through Vite and PostCSS. The
35
+ published package does not depend on either.
36
+ - Name the category in the package metadata and the README: Solid Objects is
37
+ a SQL-backed virtual actor library for TypeScript and Node.js. The package
38
+ now declares `https://solidobjects.dev/js` as its homepage and adds the
39
+ `virtual-actors` and `actor-model` keywords.
40
+ - Add `docs/virtual-actors.md`, a category guide with the definition, a small
41
+ example, fit and poor-fit criteria, comparisons, an Orleans concept map, and
42
+ separate compatibility statements for the Node runtime, the browser client,
43
+ the browser runtime, and the Cloudflare backend.
44
+ - Add `docs/agents.md`, a consumer guide for coding agents with setup,
45
+ authorization, effect idempotency, verification, and troubleshooting steps.
46
+ - Add Dapr actors, Temporal, and a comparison vocabulary to
47
+ `docs/comparisons.md`.
48
+ - Add `examples/ticket-sale.ts`. `pnpm run test:package` runs it against the
49
+ packed tarball, and the documentation check fails when the guide no longer
50
+ embeds it.
51
+ - The documentation check now reads every `docs/*.md` file instead of a fixed
52
+ list.
53
+ - Add `context7.json` so that Context7 indexes the consumer documentation and
54
+ skips maintainer files.
55
+ - Record the clean-install artifact proof in `docs/parity.md`. Ruby now has a
56
+ matching `rake quickstart` check.
57
+
3
58
  ## 0.17.0 - 2026-10-03
4
59
 
5
60
  - **Breaking:** `solid_objects.activation.started` now fires before the actor's
package/README.md CHANGED
@@ -5,6 +5,12 @@
5
5
 
6
6
  **Open Source Durable Objects in your Node app.**
7
7
 
8
+ Solid Objects is a SQL-backed virtual actor library for TypeScript and Node.js,
9
+ with durable state, ordered operations, and automatic activation. Each actor
10
+ has a stable identity, and its state lives in the SQL database that your app
11
+ already uses. [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
12
+ explains the model and when to use it.
13
+
8
14
  In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Node site could be restarting before recovery finishes.
9
15
 
10
16
  To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
@@ -43,7 +49,8 @@ subscriptions. Start with the [Cloudflare example](examples/cloudflare/README.md
43
49
  ## Installation
44
50
 
45
51
  Solid Objects is ESM-only and requires Node.js 24.4 or newer. TypeScript users
46
- need TypeScript 5.9 or newer.
52
+ need TypeScript 5.9 or newer. Coding agents should follow the
53
+ [agent guide](docs/agents.md), which gives each step in order.
47
54
 
48
55
  ```bash
49
56
  npm install solid-objects
@@ -70,7 +77,7 @@ class TicketSale extends Actor {
70
77
  holds = {}
71
78
 
72
79
  hold({ buyer }) {
73
- if (this.available === 0 || buyer in this.holds) {
80
+ if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
74
81
  return { held: false, available: this.available }
75
82
  }
76
83
 
@@ -84,7 +91,7 @@ class TicketSale extends Actor {
84
91
  }
85
92
 
86
93
  expire({ buyer }) {
87
- if (!(buyer in this.holds)) return this.available
94
+ if (!Object.hasOwn(this.holds, buyer)) return this.available
88
95
 
89
96
  const remainingHolds = { ...this.holds }
90
97
  delete remainingHolds[buyer]
@@ -254,6 +261,8 @@ There is no exactly-once delivery. Read the
254
261
  ## Read more
255
262
 
256
263
  - [Five-minute Node guide](https://solidobjects.dev/5min/node)
264
+ - [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
265
+ - [Guide for coding agents](docs/agents.md)
257
266
  - [Choosing Solid Objects](docs/fit.md)
258
267
  - [Public API](docs/api.md)
259
268
  - [Operations and recovery](docs/operations.md)
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.17.0";
1
+ export declare const VERSION = "0.17.2";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.17.0";
1
+ export const VERSION = "0.17.2";
2
2
  //# sourceMappingURL=version.js.map
package/docs/agents.md ADDED
@@ -0,0 +1,331 @@
1
+ # Solid Objects for coding agents (TypeScript and Node.js)
2
+
3
+ This guide is for a coding agent that evaluates or adds Solid Objects in a
4
+ TypeScript or Node.js application. It gives the facts and steps in a fixed
5
+ order. Each step links to the maintained documentation for more detail.
6
+
7
+ ## 1. Check the fit first
8
+
9
+ Use Solid Objects only if you can answer yes to most of these questions:
10
+
11
+ 1. Does one identity, such as a room, cart, account, device, or document, own
12
+ the state?
13
+ 2. Must writes for that identity happen one at a time across requests and
14
+ processes?
15
+ 3. Must some work happen later or continue after a process exits?
16
+ 4. Is the state a bounded JSON document, not a large relational dataset?
17
+ 5. Can the application run `runtime.run(signal)` in at least one process for
18
+ reminders, effects, and realtime delivery?
19
+
20
+ Select a simpler tool in these cases:
21
+
22
+ | Requirement | Use instead |
23
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
24
+ | The invariant fits in one request | A transaction, a constraint, or `SELECT ... FOR UPDATE` |
25
+ | CPU-intensive or parallel work | `worker_threads` or a worker pool. An actor serializes work |
26
+ | Thousands of writes for each second to one key | A counter column or a cache. Solid Objects Pro covers this case for Rails; its Node build is in development |
27
+ | One atomic change across two actor identities | One SQL transaction on normal tables |
28
+ | Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
29
+ | Replay of named workflow steps from a step log | A durable execution engine, such as DBOS or Temporal |
30
+
31
+ The full list is in [Choosing Solid Objects](fit.md). The category guide is
32
+ [Virtual actors in TypeScript and Node.js](virtual-actors.md).
33
+
34
+ ## 2. Package identity
35
+
36
+ | Item | Value |
37
+ | ------------------ | ----------------------------------------------------------------------------------------------------- |
38
+ | npm package | `solid-objects` |
39
+ | Repository | <https://github.com/cardmagic/solid-objects-js> |
40
+ | Website | <https://solidobjects.dev/js> |
41
+ | Ruby on Rails gem | `solid_objects`, a separate implementation of the same model |
42
+ | Commercial package | `@solidobjects/pro`, a separate paid package in development. The open-source package does not need it |
43
+
44
+ Solid Objects is not SolidJS and has no relation to it. It is not affiliated
45
+ with Cloudflare.
46
+
47
+ ## 3. Requirements
48
+
49
+ - Node.js 24.4 or newer. Prefer 24.15 or newer, where `node:sqlite` is no
50
+ longer experimental.
51
+ - The package is ESM only. TypeScript applications need TypeScript 5.9 or
52
+ newer.
53
+ - SQLite through the `node:sqlite` module, PostgreSQL 14 or newer with the
54
+ `pg` package, or MySQL 8.0 or newer with InnoDB and the `mysql2` package.
55
+ - Redis is optional. It only shortens wake-up latency and holds no durable
56
+ state.
57
+
58
+ [Supported versions](support.md) lists the CI matrix.
59
+
60
+ ## 4. Install
61
+
62
+ ```bash
63
+ npm install solid-objects
64
+ npx solid-objects quickstart --yes
65
+ ```
66
+
67
+ The quickstart runs 25 concurrent calls against one identity on a temporary
68
+ SQLite database. It proves that no update is lost. Add `pg` or `mysql2` when
69
+ you use PostgreSQL or MySQL.
70
+
71
+ Install the current release. `npm install solid-objects` selects it. Do not
72
+ pin a version that you remember from earlier work; the API changed between
73
+ releases. The current version is on <https://www.npmjs.com/package/solid-objects>.
74
+
75
+ Create the runtime and its tables at startup:
76
+
77
+ ```typescript
78
+ import { configure } from "solid-objects"
79
+ import { sqlite } from "solid-objects/database/sqlite"
80
+
81
+ const runtime = configure({
82
+ database: sqlite({ path: "app.sqlite3" }),
83
+ })
84
+
85
+ await runtime.install()
86
+ ```
87
+
88
+ Every authorization callback denies by default. Do step 5 before you call an
89
+ actor. [Configuration](configuration.md) lists each option and database
90
+ adapter.
91
+
92
+ ## 5. Authorize
93
+
94
+ All authorization callbacks deny by default. A new runtime answers no actor
95
+ call until you write a policy. Do not remove this behavior.
96
+
97
+ For a local demonstration only, grant messages and queries:
98
+
99
+ ```typescript
100
+ const runtime = configure({
101
+ database: sqlite({ path: "app.sqlite3" }),
102
+ authorizeMessage: () => true,
103
+ authorizeQuery: () => true,
104
+ })
105
+ ```
106
+
107
+ Keep `authorizeDestroy`, `authorizeSubscription`, and
108
+ `authorizeAdministration` denied in a demonstration.
109
+
110
+ A production policy must bind the actor type and ID to the authenticated user
111
+ or tenant. An actor ID is not a permission:
112
+
113
+ ```typescript
114
+ const ownsCart = ({
115
+ actorType,
116
+ actorId,
117
+ authorizationContext,
118
+ }: {
119
+ actorType: string
120
+ actorId: string
121
+ authorizationContext: unknown
122
+ }) => {
123
+ if (actorType !== "ShoppingCart") return false
124
+ if (typeof authorizationContext !== "object" || authorizationContext === null) return false
125
+ if (!("userId" in authorizationContext)) return false
126
+ return typeof authorizationContext.userId === "string" && actorId === authorizationContext.userId
127
+ }
128
+
129
+ const runtime = configure({
130
+ database: sqlite({ path: "app.sqlite3" }),
131
+ authorizeMessage: ownsCart,
132
+ authorizeQuery: ownsCart,
133
+ })
134
+ ```
135
+
136
+ Pass the context on each call:
137
+
138
+ ```typescript
139
+ await ShoppingCart.ref(subject.userId)
140
+ .with({ authorizationContext: subject })
141
+ .addItem({ productId: "shirt-123" })
142
+ ```
143
+
144
+ [Authorization](authorization.md) covers each entry point.
145
+
146
+ ## 6. Define an actor
147
+
148
+ This actor is the example from the [virtual actor guide](virtual-actors.md):
149
+
150
+ ```typescript
151
+ import { Actor } from "solid-objects"
152
+
153
+ const HOLD_MILLISECONDS = 10 * 60 * 1000
154
+
155
+ export class TicketSale extends Actor {
156
+ static override readonly actorType = "TicketSale"
157
+
158
+ available = 1
159
+ holds: Record<string, number> = {}
160
+
161
+ hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
162
+ if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
163
+ return { held: false, available: this.available }
164
+ }
165
+
166
+ this.available -= 1
167
+ this.holds = { ...this.holds, [buyer]: Date.now() }
168
+ this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
169
+ return { held: true, available: this.available }
170
+ }
171
+
172
+ expire({ buyer }: { buyer: string }): number {
173
+ if (!Object.hasOwn(this.holds, buyer)) return this.available
174
+
175
+ const remainingHolds = { ...this.holds }
176
+ delete remainingHolds[buyer]
177
+ this.holds = remainingHolds
178
+ this.available += 1
179
+ return this.available
180
+ }
181
+ }
182
+ ```
183
+
184
+ Obey these rules in actor code:
185
+
186
+ - Give each actor class a stable `static actorType`. The database stores it.
187
+ - Enumerable public fields are the durable state. They must be
188
+ JSON-compatible.
189
+ - Public methods are ordered operations. Each one takes no argument or one
190
+ object argument.
191
+ - Use `this.schedule({ at, key })` for delayed work. A new `schedule` with the
192
+ same key moves the alarm.
193
+ - Use `this.reject(code, { message })` for a business rule failure that must
194
+ not retry. The second argument is an object, for example
195
+ `this.reject("room_full", { message: "The room is full" })`.
196
+ - Do not write application tables directly from an operation. Use
197
+ `this.commitAction()` for a short write in the same database.
198
+ - Do not call an external API in an operation. Use `this.emit()` and an effect
199
+ handler.
200
+ - Write each operation so that it can run again. Delivery is at least once.
201
+ - A reminder changes state only when it runs, and it runs only while a process
202
+ calls `runtime.run(signal)`. Do not compute expiry from the clock in a query;
203
+ read the state that the reminder committed.
204
+
205
+ Avoid these mistakes:
206
+
207
+ | Mistake | Correct form |
208
+ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
209
+ | `this.schedule({ at, key })` with no operation after it | `this.schedule({ at, key }).expire({ buyer })`. `schedule()` stages a reminder only when you call an operation on its result |
210
+ | `this.reject("room full")` or `this.reject(code, message)` | `this.reject("room_full", { message: "The room is full" })` |
211
+ | `registerEffect(name, (context, argumentsValue) => ...)` | `registerEffect(name, (argumentsValue, context) => ...)`. The staged arguments come first |
212
+ | A worker process that never names the actor class | `runtime.register(TicketSale)` before `runtime.run(signal)`. See step 7 |
213
+
214
+ [State and lifecycle](state-and-lifecycle.md) and the
215
+ [public API](api.md) give the full rules.
216
+
217
+ ## 7. Run the background roles
218
+
219
+ A direct call, such as `TicketSale.ref("event-42").hold({ buyer: "ada" })`,
220
+ runs in the caller. These features need a process that calls
221
+ `runtime.run(signal)`:
222
+
223
+ - Reminders from `schedule()`.
224
+ - Effects from `emit()` and their callbacks.
225
+ - Messages to other actors.
226
+ - Realtime broadcasts.
227
+
228
+ The worker process runs only the actor classes that it knows. For a message
229
+ whose class is not registered, actor setup fails with `UnknownActorType`. The
230
+ worker reports `solid_objects.activation.failed` through instrumentation,
231
+ returns the message to the queue without counting an attempt, and tries again.
232
+ Nothing prints unless the application sets an `instrumentation` callback, so
233
+ the message seems to wait with no error. Register each class before `run()`:
234
+
235
+ ```typescript
236
+ runtime.register(TicketSale)
237
+
238
+ const controller = new AbortController()
239
+ process.once("SIGTERM", () => controller.abort())
240
+ await runtime.run(controller.signal)
241
+ await runtime.close()
242
+ ```
243
+
244
+ `TicketSale.ref(...)` also registers the class, so a script that calls `ref()`
245
+ before `run()`, like the direct call above, already works.
246
+
247
+ The packaged `solid-objects start` command does the same for a runtime that
248
+ `solid-objects.config.js` exports. When no process runs, committed work waits
249
+ in SQL and runs later. [Operations](operations.md) covers roles and shutdown.
250
+
251
+ ## 8. Make external effects idempotent
252
+
253
+ Register an effect handler at startup. Use `context.id` as the provider
254
+ idempotency key:
255
+
256
+ ```typescript
257
+ runtime.registerEffect("charge_payment", async (argumentsValue, context) => {
258
+ return payments.charge({
259
+ idempotencyKey: context.id,
260
+ paymentId: argumentsValue.paymentId,
261
+ })
262
+ })
263
+ ```
264
+
265
+ Stage it from an operation:
266
+
267
+ ```typescript
268
+ this.emit("charge_payment", {
269
+ arguments: { paymentId: this.paymentId },
270
+ onSuccess: "charged",
271
+ })
272
+ ```
273
+
274
+ The effect can run more than once after a crash. The `context.id` value is the
275
+ same each time. [Effect recovery](effect-recovery.md) explains how to retire
276
+ abandoned work.
277
+
278
+ ## 9. Verify the implementation
279
+
280
+ Do these checks before you report that the work is complete:
281
+
282
+ 1. Call `await runtime.doctor.run()`. The report must contain no failed check.
283
+ 2. Send concurrent calls to one identity with `Promise.all`, and from two
284
+ processes if the application runs more than one. Assert the final state.
285
+ 3. Test delayed work without sleeps. `runDueReminders({ now })` enqueues the
286
+ reminders that are due at the `Date` you pass. `drain()` then runs the
287
+ enqueued actor work:
288
+
289
+ ```typescript
290
+ await runtime.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS) })
291
+ await runtime.testing.drain({ roles: ["actors"] })
292
+ ```
293
+
294
+ 4. Start a process that calls `runtime.run(signal)`, schedule a short
295
+ reminder, and stop the process. Start it again after the deadline and
296
+ confirm that the reminder ran.
297
+ 5. Confirm that each effect handler deduplicates with `context.id`.
298
+ 6. Confirm that production policies do not grant access to every caller.
299
+
300
+ The repository runs the same proofs. `pnpm run test:recovery` stops a worker
301
+ process and recovers its work. `pnpm run test:at-least-once` shows a repeated
302
+ effect and its deduplication.
303
+
304
+ ## 10. Troubleshooting
305
+
306
+ | Symptom | Cause and fix |
307
+ | --------------------------------------- | ------------------------------------------------------------------------------------ |
308
+ | `Unauthorized` | A policy denied the call. Write the policy, and pass `authorizationContext` |
309
+ | A reminder or effect does not run | No process calls `runtime.run(signal)`. Start one |
310
+ | `SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction |
311
+ | `SyncTimeout` | The call did not finish in time. The message is still durable. Wait on its reference |
312
+ | `ApplicationWriteForbidden` | An operation wrote an application table. Use `commitAction()` or `emit()` |
313
+ | `Rejected` | The actor called `reject()`. This is a business result, not a retry |
314
+ | Wrong integer values or SQLite warnings | Node.js is older than 24.4, or older than 24.15 for the warning. Upgrade Node.js |
315
+ | `ERR_REQUIRE_ESM` or import errors | The package is ESM only. Use `import` and an ES module file |
316
+
317
+ ## 11. Guarantees to state correctly
318
+
319
+ When you explain Solid Objects to a user, state these limits:
320
+
321
+ - Delivery is at least once, not exactly once.
322
+ - Calls for one identity are ordered. Different identities run concurrently.
323
+ - There are no transactions across actor identities.
324
+ - Fencing stops a stale activation from a commit, but its code can continue to
325
+ run.
326
+ - The package is pre-1.0. It has no measured scale and no known third-party
327
+ production use.
328
+ - The browser client is not the SQL runtime. The Cloudflare backend is
329
+ experimental.
330
+
331
+ The [correctness contract](correctness.md) is the source for each guarantee.
@@ -2,6 +2,9 @@
2
2
 
3
3
  This guide compares coordination models so an application can choose the
4
4
  smallest mechanism that meets its requirements. It does not rank the projects.
5
+ Solid Objects is a virtual actor library; the
6
+ [virtual actor guide](virtual-actors.md) defines the term. Facts about other
7
+ projects were checked on October 7, 2026.
5
8
 
6
9
  | Approach | State and serialization unit | Deployment and durable substrate | Separate service | Replay versus state | Realtime and edge placement | Cross-identity transaction | Data access |
7
10
  | --------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------- |
@@ -12,7 +15,25 @@ smallest mechanism that meets its requirements. It does not rank the projects.
12
15
  | celld | Object class and object name | celld nodes plus one object-storage bucket; each object is its own SQLite database | Yes, the celld daemon on every node | The new owner restores the object's SQLite database from the bucket and resumes | Cloudflare Workers APIs; an object runs on one node of your fleet, not at an edge location | No | Per-object SQLite through the Workers storage APIs, plus the bucket |
13
16
  | Rivet Actors | Addressable actor | Rivet Engine or managed compute with actor state, KV, or per-actor SQLite | Rivet Engine | Actor persistence and lifecycle; workflows add recorded steps | Actor events and deployment-dependent placement | No general transaction across actors | Actor APIs and selected persistence model |
14
17
  | DBOS | Workflow ID and checkpointed steps | Application processes plus PostgreSQL system database | No orchestration server for the library; Conductor is recommended for distributed recovery | Deterministic workflow replay skips checkpointed steps | Workflow events; application placement | PostgreSQL transactions remain separate from workflow identity | PostgreSQL system database, client, CLI, and optional Conductor |
18
+ | Dapr actors | Actor type and ID, one turn at a time | Application services plus Dapr sidecars and a transactional actor state store | Yes: the Dapr sidecar plus the placement and Scheduler services | Actor state persists in the state store; actors do not replay workflow steps | Sidecar-routed actor placement; no edge placement | No transaction across actors | Dapr actor APIs and the configured state store |
15
19
  | Restate | Service handler or keyed virtual object | Application services plus Restate's durable log and state store | Yes | Durable execution journals handler progress and object state | Service protocol and Restate deployment | No shared SQL transaction across object keys | Restate APIs, state tools, snapshots, and backups |
20
+ | Temporal | Workflow execution ID | Worker processes plus a Temporal Service, self-hosted or Temporal Cloud | Yes, the Temporal Service | Deterministic workflow code replays its event history | Workflow signals and queries; application placement | No shared SQL transaction across workflows | Temporal SDK, CLI, and web UI |
21
+
22
+ ## Comparison vocabulary
23
+
24
+ Each row above differs on the same dimensions. For Solid Objects they are:
25
+
26
+ | Dimension | Solid Objects |
27
+ | ---------------- | ---------------------------------------------------------------------------------- |
28
+ | Identity | An actor class and an application-defined ID |
29
+ | Activation | On demand, behind a fenced lease, released when idle |
30
+ | State storage | A JSON document in SQLite, PostgreSQL, or MySQL |
31
+ | Serialization | One durable mailbox for each identity |
32
+ | Delivery | At least once, in order for each identity |
33
+ | Reminders | Durable and keyed for each actor |
34
+ | Recovery | Another process claims the work after a lease expires; committed work waits in SQL |
35
+ | Deployment | A library inside the application's Node.js processes |
36
+ | Operational cost | One retained message row for each call, plus database load |
16
37
 
17
38
  ## Primary references
18
39
 
@@ -37,5 +58,20 @@ smallest mechanism that meets its requirements. It does not rank the projects.
37
58
  and its storage requirements in the
38
59
  [self-hosted server overview](https://docs.restate.dev/server/overview).
39
60
 
61
+ - Dapr documents virtual actors, the actor state store, and Scheduler-backed
62
+ reminders in [Actors overview](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-overview/)
63
+ and [Actor timers and reminders](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-timers-reminders/).
64
+ The [JavaScript SDK](https://docs.dapr.io/developing-applications/sdks/js/js-actors/)
65
+ supports actors.
66
+ - Temporal documents deterministic replay in
67
+ [Workflow definition](https://docs.temporal.io/workflow-definition) and its
68
+ service in [What is Temporal?](https://docs.temporal.io/temporal).
69
+ - Orleans documents virtual actors, turn-based scheduling, and reminders in its
70
+ [overview](https://learn.microsoft.com/en-us/dotnet/orleans/overview),
71
+ [Request scheduling](https://learn.microsoft.com/en-us/dotnet/orleans/grains/request-scheduling),
72
+ and [Timers and reminders](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders).
73
+ - Node.js documents CPU-bound parallelism in
74
+ [Worker threads](https://nodejs.org/api/worker_threads.html).
75
+
40
76
  External systems evolve. Recheck these primary sources before relying on one
41
77
  row as a procurement or architecture decision.
package/docs/parity.md CHANGED
@@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
4
4
  Parity preserves a capability and its correctness or security boundary. It does
5
5
  not copy a Rails API into Node.
6
6
 
7
- Reference: Ruby `solid_objects` 0.17.0. The JavaScript package began at the Ruby
7
+ Reference: Ruby `solid_objects` 0.17.2. The JavaScript package began at the Ruby
8
8
  design's `0.12` capability generation; that version number did not imply earlier
9
9
  JavaScript releases.
10
10
 
@@ -92,6 +92,7 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change
92
92
  | Structured instrumentation | Native | Versioned events, metric samples, isolated observers, and bounded authorized diagnostics match Ruby. Both test suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add their turn's message fields. Both runtimes log exporter and observer failures, require observer callbacks, and cap local observers at 1,000. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer attributes, and does not send `sync.timeout`. |
93
93
  | Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from `0.14.3`, as `warn_state_bytes`. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. |
94
94
  | Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. |
95
+ | Clean-install artifact proof | Native | `pnpm run test:package` installs the packed tarball in a new project. It runs the packaged quickstart, which sends 25 concurrent calls to one identity, and the `examples/ticket-sale.ts` guide example. `pnpm run test:recovery` proves recovery after a worker process stops. Ruby's `rake quickstart` builds the gem, installs it in a new Rails application, and proves ordering and reminder recovery after a runtime restart. |
95
96
 
96
97
  ## Databases and wake-up
97
98
 
package/docs/releasing.md CHANGED
@@ -35,8 +35,13 @@ npm trust github solid-objects \
35
35
  `pnpm run test:recovery`, `pnpm run test:browser`, and
36
36
  `pnpm audit --audit-level=high`. Run the PostgreSQL, MySQL, and Redis jobs
37
37
  against the versions in [the support matrix](support.md).
38
- 3. Commit and push `main`.
39
- 4. Create and push an annotated tag matching the package version:
38
+ 3. Read `docs/virtual-actors.md` and `docs/agents.md` against the release.
39
+ Correct any requirement, compatibility, or guarantee statement that the
40
+ release changed. `pnpm run check:documentation` fails when the category
41
+ guide no longer embeds `examples/ticket-sale.ts`, and `pnpm run
42
+ test:package` runs that example against the packed tarball.
43
+ 4. Commit and push `main`.
44
+ 5. Create and push an annotated tag matching the package version:
40
45
 
41
46
  ```shell
42
47
  git tag -a v0.13.3 -m "Version 0.13.3"
@@ -52,3 +57,9 @@ The job then builds the release notes with `scripts/release-notes.mjs`. That
52
57
  script prints the `CHANGELOG.md` section for the tagged version. The job then
53
58
  creates the GitHub release for the tag. If you run the job again on a tag that
54
59
  npm already holds, it still creates a missing release.
60
+
61
+ After the tag publishes, refresh the solidobjects.dev documentation snapshot
62
+ from the tag and redeploy the site. The site's `check:release` step refuses a
63
+ snapshot that is not the latest published tag. The Context7 refresh workflow
64
+ refreshes the index after each push to `main` that changes the documentation.
65
+ Confirm that its run succeeded.
@@ -0,0 +1,246 @@
1
+ # Virtual actors in TypeScript and Node.js
2
+
3
+ ## Short answer
4
+
5
+ Yes. Solid Objects is a SQL-backed virtual actor library for TypeScript and
6
+ Node.js. The npm package is `solid-objects`. It gives each actor a stable
7
+ identity, durable state, ordered operations, and automatic activation.
8
+
9
+ The actor runtime runs in your Node.js processes. State and mailboxes live in
10
+ SQLite, PostgreSQL, or MySQL. It needs no broker, no daemon, no Cloudflare
11
+ account, and no new datastore. Redis is optional and only shortens wake-up
12
+ latency.
13
+
14
+ Solid Objects is a pre-1.0 release. Read
15
+ [Compatibility and maturity](#compatibility-and-maturity) before you choose it.
16
+
17
+ ## What a virtual actor is
18
+
19
+ A virtual actor is a logical object that always exists by name. The caller
20
+ does not create it, start it, or stop it. The runtime loads it when a message
21
+ arrives and releases it when it is idle. Microsoft Orleans made this model
22
+ known as "virtual actors".
23
+
24
+ Solid Objects implements four properties of that model:
25
+
26
+ | Property | What it means | How Solid Objects does it |
27
+ | -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
28
+ | Stable identity | An actor is addressed by type and ID, for example one room for each game. | `Room.ref("room-7")` returns a reference. The reference does not load the actor. |
29
+ | Automatic activation | The first message activates the actor. An idle actor is released. | A process claims a fenced activation lease when work arrives. A worker releases it after `idleDeactivationTimeoutMilliseconds`. |
30
+ | Durable state | State survives process exits and deploys. | Enumerable public fields are a JSON document in SQL. |
31
+ | Ordered turns | One identity runs one operation at a time, in a fixed order. | Each call is a durable mailbox message with a per-actor sequence number. |
32
+
33
+ Different identities run concurrently across processes. One identity is a
34
+ serialization point on purpose.
35
+
36
+ ## A small example
37
+
38
+ This program holds one ticket for a buyer and releases the hold after ten
39
+ minutes. It uses the SQLite driver that Node.js includes. Save it as
40
+ `ticket-sale.mts`, so that Node.js loads it as an ES module:
41
+
42
+ ```typescript
43
+ import { Actor, configure } from "solid-objects"
44
+ import { sqlite } from "solid-objects/database/sqlite"
45
+
46
+ const HOLD_MILLISECONDS = 10 * 60 * 1000
47
+
48
+ export class TicketSale extends Actor {
49
+ static override readonly actorType = "TicketSale"
50
+
51
+ available = 1
52
+ holds: Record<string, number> = {}
53
+
54
+ hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
55
+ if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
56
+ return { held: false, available: this.available }
57
+ }
58
+
59
+ this.available -= 1
60
+ this.holds = { ...this.holds, [buyer]: Date.now() }
61
+ this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
62
+ return { held: true, available: this.available }
63
+ }
64
+
65
+ expire({ buyer }: { buyer: string }): number {
66
+ if (!Object.hasOwn(this.holds, buyer)) return this.available
67
+
68
+ const remainingHolds = { ...this.holds }
69
+ delete remainingHolds[buyer]
70
+ this.holds = remainingHolds
71
+ this.available += 1
72
+ return this.available
73
+ }
74
+ }
75
+
76
+ const runtime = configure({
77
+ database: sqlite({ path: process.env.TICKET_DATABASE ?? "tickets.sqlite3" }),
78
+ authorizeMessage: () => true,
79
+ authorizeQuery: () => true,
80
+ })
81
+
82
+ await runtime.install()
83
+
84
+ try {
85
+ const sale = TicketSale.ref("event-42")
86
+
87
+ if (process.argv[2] === "work") {
88
+ const controller = new AbortController()
89
+ process.once("SIGINT", () => controller.abort())
90
+ process.once("SIGTERM", () => controller.abort())
91
+ await runtime.run(controller.signal)
92
+ } else {
93
+ const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"]
94
+ const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer })))
95
+ console.log(JSON.stringify(results))
96
+ }
97
+ } finally {
98
+ await runtime.close()
99
+ }
100
+ ```
101
+
102
+ Run the background roles in one terminal. Place two concurrent holds in a
103
+ second terminal:
104
+
105
+ ```bash
106
+ npm install solid-objects
107
+ node ticket-sale.mts work
108
+ node ticket-sale.mts hold
109
+ ```
110
+
111
+ The two holds enter the same mailbox and commit one at a time, so only one
112
+ buyer gets the ticket. The hold and its reminder commit in one transaction. If
113
+ the worker stops, the reminder stays in `tickets.sqlite3`. It runs when the
114
+ worker starts again.
115
+
116
+ The `work` branch can run the reminder because `TicketSale.ref(...)` registers
117
+ the class first. A separate worker process must call
118
+ `runtime.register(TicketSale)` before `runtime.run(signal)`.
119
+
120
+ The authorization callbacks above allow every caller. Use them only for a
121
+ local example. The package release check runs this file against the packed
122
+ npm tarball. The source is [`examples/ticket-sale.ts`](../examples/ticket-sale.ts).
123
+
124
+ For more setup, use one of these guides:
125
+
126
+ - `npx solid-objects quickstart --yes` runs a packaged proof of concurrent
127
+ calls to one identity.
128
+ - [The README](../README.md#installation) has the installation steps.
129
+ - [The agent guide](agents.md) gives setup and verification steps for coding
130
+ agents.
131
+
132
+ ## When to use it
133
+
134
+ Solid Objects is a good candidate when most of these conditions are true:
135
+
136
+ - Concurrent requests can change the same room, cart, account, device,
137
+ document, or session.
138
+ - Each identity needs its own ordering boundary and durable mailbox.
139
+ - Work must happen later or continue after a Node.js process exits.
140
+ - A state change must stage reminders, effects, messages to other actors, or
141
+ realtime updates in the same commit.
142
+ - The application already runs SQLite, PostgreSQL, or MySQL and should keep
143
+ durable coordination there.
144
+
145
+ ## When to use something else
146
+
147
+ Do not use an actor when a simpler tool enforces the invariant:
148
+
149
+ - One short transaction, a constraint, or `SELECT ... FOR UPDATE` is enough.
150
+ - The work is CPU-intensive. Use `worker_threads` or a worker pool. An actor
151
+ serializes work. It does not add CPU parallelism.
152
+ - One global identity must accept more writes than one sequential mailbox can
153
+ commit, for example a request-path rate limiter.
154
+ - The state is a large document or a relational dataset.
155
+ - The operation must change two actor identities in one atomic transaction.
156
+ Solid Objects has no cross-actor transactions.
157
+ - You need exactly-once calls to an external API. No actor library can promise
158
+ that through every network failure. Solid Objects gives at-least-once
159
+ delivery and stable effect IDs for idempotency keys.
160
+ - You need replay of named workflow steps from a step log. That is a durable
161
+ execution engine, not an actor.
162
+ - State must be placed automatically near clients at the network edge.
163
+
164
+ [Choosing Solid Objects](fit.md) has the full list.
165
+
166
+ ## How it compares
167
+
168
+ This table compares coordination models for a Node.js application. It does
169
+ not rank the projects. Facts about other projects were checked on
170
+ October 7, 2026, against the sources in [System comparisons](comparisons.md).
171
+
172
+ | Approach | Unit of order | Durable state | Delayed work | Extra service |
173
+ | ------------------------------------------ | -------------------------------- | ------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
174
+ | SQL transaction or row lock | Rows in one transaction | Application tables | None | No |
175
+ | Job queue, such as BullMQ or pg-boss | A job or a queue | Owned by the application | Scheduled jobs | Redis for BullMQ; PostgreSQL for pg-boss |
176
+ | `worker_threads` or a pool such as Piscina | None | None | None | No |
177
+ | Solid Objects | Actor class and ID | JSON state in SQLite, PostgreSQL, or MySQL | Durable per-actor reminders | No |
178
+ | Cloudflare Durable Objects | Object class and ID | Per-object storage | One alarm for each object | Cloudflare's network. The open-source `workerd` runtime hosts objects on one instance only |
179
+ | Dapr actors (`@dapr/dapr`) | Actor type and ID | A transactional Dapr state store | Durable reminders through the Dapr Scheduler service | A Dapr sidecar, plus the placement and Scheduler services |
180
+ | Rivet Actors | Actor key | Actor state, plus per-actor SQLite and KV storage | Scheduled actions | The Rivet Engine, self-hosted or Rivet Cloud |
181
+ | Restate virtual objects | Object key, one writer at a time | Restate's state store | Durable timers | A Restate server |
182
+ | DBOS | A workflow and its steps | Checkpoints in PostgreSQL | Durable sleeps | No server; PostgreSQL is required |
183
+ | Temporal | A workflow execution | Temporal event history | Durable timers | A Temporal Service, self-hosted or Temporal Cloud |
184
+
185
+ [System comparisons](comparisons.md) has the full table, with primary
186
+ references for each row.
187
+
188
+ ### Orleans concept map
189
+
190
+ Orleans is the reference design for virtual actors on .NET. Solid Objects
191
+ uses the same programming model on a SQL database. It does not copy the
192
+ Orleans cluster, placement, or feature set.
193
+
194
+ | Orleans | Solid Objects | Difference |
195
+ | ---------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
196
+ | Grain class | `Actor` subclass with a static `actorType` | None in concept |
197
+ | Grain identity (key) | Actor type and actor ID | None in concept |
198
+ | Activation on first call | Activation lease on first claimed message | Solid Objects fences each activation with a database generation |
199
+ | Turn-based execution | Ordered mailbox, one turn at a time | Orleans can enable reentrancy. Solid Objects turns for one identity never interleave |
200
+ | Grain persistence | Public fields stored as JSON in SQL | An Orleans grain calls `WriteStateAsync`. Solid Objects persists state with the turn that changed it |
201
+ | Reminders | `schedule()` | Both are durable. Orleans skips a tick that falls due while the cluster is down. A due Solid Objects reminder runs when a runtime process starts. Solid Objects has no non-durable timers |
202
+ | Silos and cluster membership | Any Node.js process that calls `runtime.run(signal)` | Solid Objects has no placement, directory, or cluster membership. The database is the coordination point |
203
+ | Streams | Observables and realtime sessions | Solid Objects publishes committed revisions through an application-owned transport |
204
+
205
+ Solid Objects delivery is at least once. Write each operation so that it can
206
+ run again without harm.
207
+
208
+ ## Guarantees and boundaries
209
+
210
+ - Calls are durably ordered per identity. Different identities can run
211
+ concurrently.
212
+ - Delivery is at least once, not exactly once. An operation can start again
213
+ after a crash or a lost lease.
214
+ - Ordered turns do not cancel stale JavaScript. Fencing stops a stale
215
+ activation from a commit, but that code can continue to run.
216
+ - External effects can run more than once. Use the stable effect ID, or
217
+ another durable key, as the idempotency key at the provider.
218
+ - There are no transactions across actor identities, and there is no replay of
219
+ durable function steps.
220
+ - Reminders, effects, and realtime delivery need a process that calls
221
+ `runtime.run(signal)`. When no process runs, committed work waits in SQL.
222
+ The package does not supply a hosted worker.
223
+ - One hot identity is sequential. The core package is not a high-throughput
224
+ request-path rate limiter.
225
+ - The guarantees apply only to changes made through the actor APIs. Actor
226
+ fencing does not protect direct writes to the same data or other external
227
+ requests.
228
+
229
+ The [correctness contract](correctness.md) states each guarantee and its
230
+ limits.
231
+
232
+ ## Compatibility and maturity
233
+
234
+ These runtimes have different compatibility statements:
235
+
236
+ | Surface | Status |
237
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
238
+ | Node.js SQL runtime | Node.js 24.4 or newer, ESM only, TypeScript 5.9 or newer. SQLite through `node:sqlite`, PostgreSQL 14 or newer, MySQL 8.0 or newer with InnoDB |
239
+ | Browser client (`solid-objects/browser`) | A subscription client for a server runtime. It is not the SQL actor runtime |
240
+ | Browser runtime (`solid-objects/browser/host`) | The actor runtime in a browser module worker on SQLite WASM. Tested in Chromium |
241
+ | Cloudflare backend (`solid-objects/cloudflare`) | Experimental. It runs the actor API on Cloudflare Durable Objects, with different capability limits |
242
+
243
+ The package is pre-1.0. It has one deployed first-party reference
244
+ application, no measured scale, and no known third-party production use.
245
+ [Supported versions](support.md) lists the CI matrix. For Ruby on Rails, use
246
+ the [solid_objects](https://github.com/cardmagic/solid-objects-ruby) gem.
@@ -0,0 +1,57 @@
1
+ import { Actor, configure } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+
4
+ const HOLD_MILLISECONDS = 10 * 60 * 1000
5
+
6
+ export class TicketSale extends Actor {
7
+ static override readonly actorType = "TicketSale"
8
+
9
+ available = 1
10
+ holds: Record<string, number> = {}
11
+
12
+ hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
13
+ if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
14
+ return { held: false, available: this.available }
15
+ }
16
+
17
+ this.available -= 1
18
+ this.holds = { ...this.holds, [buyer]: Date.now() }
19
+ this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
20
+ return { held: true, available: this.available }
21
+ }
22
+
23
+ expire({ buyer }: { buyer: string }): number {
24
+ if (!Object.hasOwn(this.holds, buyer)) return this.available
25
+
26
+ const remainingHolds = { ...this.holds }
27
+ delete remainingHolds[buyer]
28
+ this.holds = remainingHolds
29
+ this.available += 1
30
+ return this.available
31
+ }
32
+ }
33
+
34
+ const runtime = configure({
35
+ database: sqlite({ path: process.env.TICKET_DATABASE ?? "tickets.sqlite3" }),
36
+ authorizeMessage: () => true,
37
+ authorizeQuery: () => true,
38
+ })
39
+
40
+ await runtime.install()
41
+
42
+ try {
43
+ const sale = TicketSale.ref("event-42")
44
+
45
+ if (process.argv[2] === "work") {
46
+ const controller = new AbortController()
47
+ process.once("SIGINT", () => controller.abort())
48
+ process.once("SIGTERM", () => controller.abort())
49
+ await runtime.run(controller.signal)
50
+ } else {
51
+ const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"]
52
+ const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer })))
53
+ console.log(JSON.stringify(results))
54
+ }
55
+ } finally {
56
+ await runtime.close()
57
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.17.0",
4
- "description": "Race-free realtime state per application identity, backed by your SQL database",
3
+ "version": "0.17.2",
4
+ "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Lucas Carlson",
8
+ "homepage": "https://solidobjects.dev/js",
8
9
  "keywords": [
9
10
  "concurrency",
10
11
  "durable-state",
@@ -16,7 +17,9 @@
16
17
  "state-management",
17
18
  "typescript",
18
19
  "actors",
19
- "durable-objects"
20
+ "durable-objects",
21
+ "virtual-actors",
22
+ "actor-model"
20
23
  ],
21
24
  "repository": {
22
25
  "type": "git",