solid-objects 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +7 -0
  3. package/dist/actor-runtime.d.ts +3 -0
  4. package/dist/actor-runtime.d.ts.map +1 -1
  5. package/dist/broadcast-worker.d.ts +1 -0
  6. package/dist/broadcast-worker.d.ts.map +1 -1
  7. package/dist/broadcast-worker.js +10 -1
  8. package/dist/broadcast-worker.js.map +1 -1
  9. package/dist/configuration.d.ts +2 -0
  10. package/dist/configuration.d.ts.map +1 -1
  11. package/dist/configuration.js +24 -1
  12. package/dist/configuration.js.map +1 -1
  13. package/dist/database/sqlite.d.ts +6 -0
  14. package/dist/database/sqlite.d.ts.map +1 -1
  15. package/dist/database/sqlite.js +23 -2
  16. package/dist/database/sqlite.js.map +1 -1
  17. package/dist/doctor.js +1 -1
  18. package/dist/doctor.js.map +1 -1
  19. package/dist/due-work.d.ts +15 -0
  20. package/dist/due-work.d.ts.map +1 -0
  21. package/dist/due-work.js +124 -0
  22. package/dist/due-work.js.map +1 -0
  23. package/dist/effect-worker.d.ts +1 -0
  24. package/dist/effect-worker.d.ts.map +1 -1
  25. package/dist/effect-worker.js +10 -1
  26. package/dist/effect-worker.js.map +1 -1
  27. package/dist/errors.d.ts +14 -0
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +11 -0
  30. package/dist/errors.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-signal.d.ts +15 -0
  36. package/dist/local-signal.d.ts.map +1 -0
  37. package/dist/local-signal.js +35 -0
  38. package/dist/local-signal.js.map +1 -0
  39. package/dist/redrive.d.ts +3 -0
  40. package/dist/redrive.d.ts.map +1 -1
  41. package/dist/redrive.js +30 -1
  42. package/dist/redrive.js.map +1 -1
  43. package/dist/reminder-scheduler.d.ts +1 -0
  44. package/dist/reminder-scheduler.d.ts.map +1 -1
  45. package/dist/reminder-scheduler.js +10 -1
  46. package/dist/reminder-scheduler.js.map +1 -1
  47. package/dist/repository.d.ts +8 -1
  48. package/dist/repository.d.ts.map +1 -1
  49. package/dist/repository.js +57 -21
  50. package/dist/repository.js.map +1 -1
  51. package/dist/runtime.d.ts +8 -7
  52. package/dist/runtime.d.ts.map +1 -1
  53. package/dist/runtime.js +50 -6
  54. package/dist/runtime.js.map +1 -1
  55. package/dist/single-owner.d.ts +17 -0
  56. package/dist/single-owner.d.ts.map +1 -0
  57. package/dist/single-owner.js +27 -0
  58. package/dist/single-owner.js.map +1 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/wake-up-selection.d.ts +1 -0
  62. package/dist/wake-up-selection.d.ts.map +1 -1
  63. package/dist/wake-up-selection.js +10 -0
  64. package/dist/wake-up-selection.js.map +1 -1
  65. package/dist/wall-clock-watch.d.ts +14 -0
  66. package/dist/wall-clock-watch.d.ts.map +1 -0
  67. package/dist/wall-clock-watch.js +35 -0
  68. package/dist/wall-clock-watch.js.map +1 -0
  69. package/dist/worker.d.ts +3 -0
  70. package/dist/worker.d.ts.map +1 -1
  71. package/dist/worker.js +29 -4
  72. package/dist/worker.js.map +1 -1
  73. package/docs/agents.md +10 -3
  74. package/docs/api.md +66 -16
  75. package/docs/benchmarks.md +208 -69
  76. package/docs/compare/ai-agents.md +252 -0
  77. package/docs/compare/dbos.md +12 -0
  78. package/docs/compare/graphile-worker.md +11 -0
  79. package/docs/compare/restate.md +12 -1
  80. package/docs/comparisons.md +1 -0
  81. package/docs/configuration.md +114 -18
  82. package/docs/correctness.md +61 -10
  83. package/docs/errors-and-recovery.md +4 -0
  84. package/docs/operations.md +45 -5
  85. package/docs/parity.md +7 -2
  86. package/docs/virtual-actors.md +2 -2
  87. package/package.json +5 -4
@@ -24,6 +24,16 @@ The DBOS facts come from the [DBOS documentation](https://docs.dbos.dev) and the
24
24
 
25
25
  Two DBOS pages differ on more than one process. The FAQ says "Conductor is required for correct workflow recovery in applications that use more than one process". The recovery page describes recovery without Conductor: each process has an executor ID, and a restarted process recovers its own pending workflows.
26
26
 
27
+ ## How do durable sleep, send, and recv work in DBOS?
28
+
29
+ `DBOS.sleep` is a durable sleep inside a workflow. It takes milliseconds. DBOS "saves the wakeup time in the database so that even if the workflow is interrupted and restarted multiple times while sleeping, it still wakes up on schedule" ([DBOS workflow tutorial](https://docs.dbos.dev/typescript/tutorials/workflow-tutorial), checked October 18, 2026). `DBOS.send` sends a message to a workflow, and `DBOS.recv` in that workflow waits for it. "Messages are dequeued first-in, first-out from a queue associated with the topic" ([DBOS methods reference](https://docs.dbos.dev/typescript/reference/methods)).
30
+
31
+ - `DBOS.recv` waits up to `timeoutSeconds` and then returns `null`. "The default timeout is 60 seconds" ([DBOS workflow communication](https://docs.dbos.dev/typescript/tutorials/workflow-communication)).
32
+ - "If you're sending a message from a workflow, DBOS guarantees exactly-once delivery" ([DBOS workflow communication](https://docs.dbos.dev/typescript/tutorials/workflow-communication)).
33
+ - With `delaySeconds` on enqueue, the workflow "is initially placed in `DELAYED` status and transitions to `ENQUEUED` after the delay expires" ([DBOS methods reference](https://docs.dbos.dev/typescript/reference/methods)).
34
+ - A partitioned queue with `partitionConcurrency: 1` runs one workflow at a time for each `queuePartitionKey`.
35
+ - In Solid Objects, a message to the actor replaces `send` and `recv`, and a reminder (`this.schedule({ at, key }).operation(input)`) replaces a durable sleep. Solid Objects has no durable wait inside a turn. The turn that stores the reminder ends, so other messages for the same cart run before the reminder runs.
36
+
27
37
  ## Choose DBOS when
28
38
 
29
39
  - The work is a sequence of steps, and a completed step must not run again after a crash.
@@ -381,6 +391,8 @@ Checked October 15, 2026:
381
391
  - [DBOS upgrading workflows](https://docs.dbos.dev/typescript/tutorials/upgrading-workflows)
382
392
  - [Migrating from Temporal to DBOS](https://docs.dbos.dev/explanations/migrating-from-temporal)
383
393
  - [DBOS pricing](https://www.dbos.dev/dbos-pricing)
394
+ - [DBOS methods reference](https://docs.dbos.dev/typescript/reference/methods) (checked October 18, 2026)
395
+ - [DBOS workflow communication](https://docs.dbos.dev/typescript/tutorials/workflow-communication) (checked October 18, 2026)
384
396
 
385
397
  ## More information
386
398
 
@@ -23,6 +23,16 @@ Solid Objects is an independent project. It is not affiliated with Graphile.
23
23
 
24
24
  The Graphile Worker facts come from the [Graphile Worker documentation](https://worker.graphile.org/docs) and its [source code](https://github.com/graphile/worker) (checked October 15, 2026).
25
25
 
26
+ ## Do jobs with the same `queue_name` run serially?
27
+
28
+ Yes. Jobs in the same named queue run one at a time. The SQL function `graphile_worker.add_job` takes `queue_name`, and the JavaScript `addJob` takes `queueName`: "if you want certain tasks to run one at a time, add them to the same named queue" ([Graphile Worker `add_job`](https://worker.graphile.org/docs/sql-add-job), checked October 18, 2026).
29
+
30
+ - The `TaskSpec` type describes `queueName` as "The queue to run this task under (only specify if you want jobs in this queue to run serially)" ([`addJob`](https://worker.graphile.org/docs/library/add-job)).
31
+ - Serial is not the same as ordered after a failure. See the "Order after a failure" row above.
32
+ - `job_key` (`jobKey` in JavaScript) is a "unique identifier for the job, used to replace, update or remove it later if needed" ([`add_job`](https://worker.graphile.org/docs/sql-add-job)). `job_key_mode` (`jobKeyMode` in JavaScript) is `replace` (the default), `preserve_run_at`, or `unsafe_dedupe` ([job keys](https://worker.graphile.org/docs/job-key)).
33
+ - `run_at` (`runAt` in JavaScript) is "a timestamp after which to run the job; defaults to now".
34
+ - In Solid Objects, the actor ID replaces `queue_name`, and `this.schedule({ at, key }).operation(input)` replaces `run_at` with a `job_key`.
35
+
26
36
  ## Choose Graphile Worker when
27
37
 
28
38
  - The work is background jobs, such as emails, webhooks, or reports.
@@ -277,6 +287,7 @@ Checked October 15, 2026:
277
287
 
278
288
  - [Graphile Worker introduction](https://worker.graphile.org/docs)
279
289
  - [Graphile Worker `addJob`](https://worker.graphile.org/docs/library/add-job)
290
+ - [Graphile Worker `add_job` in SQL](https://worker.graphile.org/docs/sql-add-job) (checked October 18, 2026)
280
291
  - [Graphile Worker job keys](https://worker.graphile.org/docs/job-key)
281
292
  - [Graphile Worker error handling](https://worker.graphile.org/docs/error-handling)
282
293
  - [Graphile Worker exponential backoff](https://worker.graphile.org/docs/exponential-backoff)
@@ -20,6 +20,15 @@ Solid Objects is an independent project. It is not affiliated with Restate.
20
20
 
21
21
  The Restate facts come from the [Restate documentation](https://docs.restate.dev) and the [Restate server licence](https://github.com/restatedev/restate/blob/main/LICENSE) (checked October 15, 2026).
22
22
 
23
+ ## How do delayed messages and timers work in Restate?
24
+
25
+ Restate names two ways to schedule work later in TypeScript. You can send a delayed message ([`restate.rpc.sendOpts({ delay })`](https://docs.restate.dev/develop/ts/service-communication)), or you can sleep (`ctx.sleep`) and then send a message. Restate recommends delayed messages, because "Other requests to the same Virtual Object can be processed during the delay period" ([Restate scheduling and timers](https://docs.restate.dev/develop/ts/durable-timers), checked October 18, 2026).
26
+
27
+ - Restate "tracks and manages timers, ensuring they survive failures and restarts".
28
+ - "If you sleep in an exclusive handler in a Virtual Object, all other calls to this object will be queued."
29
+ - The SDK pages, the TypeScript pages included, call this a delayed message. Some operations pages, such as versioning and managing invocations, call it a delayed call.
30
+ - In Solid Objects, `this.schedule({ at, key }).operation(input)` stores a reminder in the same SQL transaction as the turn. The turn ends at once, so other messages do not wait. One operation and key hold one alarm, and you can [cancel a reminder](../api.md#cancelling-a-reminder).
31
+
23
32
  ## Choose Restate when
24
33
 
25
34
  - You want a journal of each step, so that a retry resumes after the last completed step.
@@ -272,13 +281,15 @@ Restate reached 1.0 in June 2024. If a longer history matters more than keeping
272
281
  Checked October 15, 2026:
273
282
 
274
283
  - [Restate services and virtual objects](https://docs.restate.dev/foundations/services)
275
- - [Restate service communication and delayed calls](https://docs.restate.dev/develop/ts/service-communication)
284
+ - [Restate service communication and delayed messages](https://docs.restate.dev/develop/ts/service-communication)
276
285
  - [Restate durable steps](https://docs.restate.dev/develop/ts/durable-steps)
277
286
  - [Restate durable agents](https://docs.restate.dev/ai/patterns/durable-agents)
278
287
  - [Restate agent sessions](https://docs.restate.dev/ai/patterns/sessions)
279
288
  - [Restate sagas](https://docs.restate.dev/guides/sagas)
280
289
  - [Restate service configuration](https://docs.restate.dev/services/configuration)
281
290
  - [Restate server overview](https://docs.restate.dev/server/overview)
291
+ - [Restate databases guide](https://docs.restate.dev/guides/databases)
292
+ - [Restate scheduling and timers](https://docs.restate.dev/develop/ts/durable-timers) (checked October 18, 2026)
282
293
  - [Restate server licence](https://github.com/restatedev/restate/blob/main/LICENSE)
283
294
 
284
295
  ## More information
@@ -15,6 +15,7 @@ runtime code on each page runs in the test suite.
15
15
  | [DBOS Transact and Solid Objects](compare/dbos.md) | Shopping cart checkout |
16
16
  | [RivetKit actors and Solid Objects](compare/rivetkit.md) | Chat rooms |
17
17
  | [Graphile Worker named queues and Solid Objects](compare/graphile-worker.md) | Chat rooms |
18
+ | [Durable execution for TypeScript AI agents](compare/ai-agents.md) | AI agent sessions |
18
19
 
19
20
  ## Coordination models
20
21
 
@@ -65,19 +65,20 @@ as an instruction to divide the actor. See
65
65
 
66
66
  ## Runtime roles and supervision
67
67
 
68
- | Option | Default | Contract |
69
- | ------------------------------------------- | --------------: | ----------------------------------------------------------------- |
70
- | `workerCount` | `1` | Non-negative actor workers. |
71
- | `effectWorkerCount` | `1` | Non-negative effect workers. |
72
- | `broadcastWorkerCount` | `1` | Non-negative broadcast workers when realtime is enabled. |
73
- | `reminderSchedulerCount` | `1` | Non-negative reminder schedulers. |
74
- | `processHeartbeatIntervalMilliseconds` | `15_000` | Positive persisted heartbeat cadence. |
75
- | `processAliveThresholdMilliseconds` | `60_000` | Positive age after which an owner is stale. |
76
- | `shutdownTimeoutMilliseconds` | `15_000` | Positive shared graceful-shutdown budget. |
77
- | `supervisorRestartDelayMilliseconds` | `100` | Positive initial failed-role replacement delay. |
78
- | `supervisorMaximumRestartDelayMilliseconds` | `10_000` | Positive cap no smaller than the initial delay. |
79
- | `wakeUp` | `"automatic"` | A name or an adapter implementing `watch`, `notify`, and `close`. |
80
- | `logger` | console methods | Structured `debug`, `info`, `warn`, and `error` sink. |
68
+ | Option | Default | Contract |
69
+ | ------------------------------------------- | --------------: | ------------------------------------------------------------------- |
70
+ | `workerCount` | `1` | Non-negative actor workers. |
71
+ | `effectWorkerCount` | `1` | Non-negative effect workers. |
72
+ | `broadcastWorkerCount` | `1` | Non-negative broadcast workers when realtime is enabled. |
73
+ | `reminderSchedulerCount` | `1` | Non-negative reminder schedulers. |
74
+ | `processHeartbeatIntervalMilliseconds` | `15_000` | Positive persisted heartbeat cadence. |
75
+ | `processAliveThresholdMilliseconds` | `60_000` | Positive age after which an owner is stale. |
76
+ | `shutdownTimeoutMilliseconds` | `15_000` | Positive shared graceful-shutdown budget. |
77
+ | `supervisorRestartDelayMilliseconds` | `100` | Positive initial failed-role replacement delay. |
78
+ | `supervisorMaximumRestartDelayMilliseconds` | `10_000` | Positive cap no smaller than the initial delay. |
79
+ | `wakeUp` | `"automatic"` | A name or an adapter implementing `watch`, `notify`, and `close`. |
80
+ | `singleOwner` | `false` | `true` when this process is the only writer of its SQLite database. |
81
+ | `logger` | console methods | Structured `debug`, `info`, `warn`, and `error` sink. |
81
82
 
82
83
  Counts may be zero, but the complete configuration must leave at least one
83
84
  runtime role enabled. Broadcast workers are started only when `broadcast` or
@@ -90,7 +91,9 @@ Consecutive empty passes double it up to
90
91
  fixed cadence. A custom wake-up adapter must return `true` for a notification
91
92
  and `false` for a timeout. An older adapter that returns `void` remains
92
93
  compatible and keeps the fast cadence. Wake-ups reduce latency, while database
93
- polling remains the correctness path.
94
+ polling remains the correctness path. The one exception is
95
+ [`singleOwner`](#single-owner), which replaces polling with wake-ups and due
96
+ times.
94
97
 
95
98
  Busy SQL workers and testing drains give the event loop a turn between passes.
96
99
  In Node this lets timers and input run even with synchronous SQLite calls.
@@ -98,6 +101,79 @@ An instrumentation callback can abort `runtime.run()` before its next pass.
98
101
  This does not interrupt a synchronous handler: responsiveness is bounded by
99
102
  one pass, or by a single overlong handler that must return first.
100
103
 
104
+ ## Single owner
105
+
106
+ Set `singleOwner: true` when one process is the only writer of its SQLite
107
+ database, for example a daemon that embeds the runtime and holds its database
108
+ file. With the option, an idle runtime sends no query. The default is `false`,
109
+ and the default behavior does not change.
110
+
111
+ ```typescript
112
+ const runtime = createRuntime({
113
+ database: sqlite({ path: "app.sqlite" }),
114
+ singleOwner: true,
115
+ })
116
+ ```
117
+
118
+ With `singleOwner: true`, the runtime changes these behaviors:
119
+
120
+ - Each role waits for an in-process wake-up or for the next due time that the
121
+ database reports. A due time is, for example, the time of the next ready
122
+ message, effect, broadcast, reminder, or retry, the end of a lease, or the
123
+ time when the owner of a claim becomes stale. A role with nothing due waits
124
+ up to 24 hours and then reads its due time again.
125
+ - A write in this process that adds work or moves it earlier wakes the roles
126
+ that must react. A role that waits for a later due time then reads the new
127
+ due time.
128
+ - The actor worker renews a lease only while it holds a cached activation.
129
+ When idle deactivation releases the last activation, the worker stops waking
130
+ for leases.
131
+ - Each role writes its process row once at start and once at shutdown. A claim
132
+ also writes the row of the worker that claims, in the claim transaction, so
133
+ that running work always has a fresh owner. An empty pass writes nothing.
134
+ - No periodic dead-process cleanup runs, and
135
+ `deadProcessCleanupIntervalMilliseconds` has no effect. `run()` releases
136
+ stale process rows once, at start.
137
+ - A watch compares the wall clock with the process clock every 10 seconds.
138
+ Node timers stop while the computer sleeps, so after a sleep, or after a jump
139
+ of one second or more, every role reads its due time again. The watch sends
140
+ no query.
141
+ - When a due time is now or has passed and a claim still finds nothing, the
142
+ role tries again after 1 ms. A timer can end a few milliseconds before the
143
+ database clock reaches the due time, and this retry takes the work at once.
144
+ Each further retry doubles the wait, up to the polling interval, so stuck
145
+ work cannot spin. `pollingIntervalMilliseconds` and
146
+ `idlePollingIntervalMilliseconds` set only this limit.
147
+
148
+ Work that is active runs as before. A turn renews its lease, and an effect or a
149
+ broadcast sends its process heartbeat while it runs. Automatic retention still
150
+ runs at start and every `retentionIntervalMilliseconds`. Set that interval to
151
+ `0` to stop it, and prune with `runtime.retention.prune()` instead.
152
+
153
+ `run()` refuses to start with `SingleOwnerConflict` when another process row is
154
+ alive by the usual rule: the row is not stopped, and its heartbeat is newer
155
+ than `processAliveThresholdMilliseconds`. The error names each row and the time
156
+ at which the row becomes stale. The rows of the runtime's own synchronous
157
+ callers do not count.
158
+
159
+ `createRuntime()` refuses the option in these cases:
160
+
161
+ - The database is not SQLite. PostgreSQL and MySQL are servers that other
162
+ processes can reach. The runtime cannot know that it is the only writer, and
163
+ without polling a write from another process can wait up to 24 hours.
164
+ - `wakeUp` is `"postgresql"`, `"redis"`, or an adapter. A notification over a
165
+ connection can be lost when the connection fails, and the runtime cannot
166
+ prove that a custom adapter delivers every signal. Without polling, a lost
167
+ signal can delay work for up to 24 hours. With `"automatic"` or
168
+ `"in_process"`, the runtime uses in-process signalling and ignores
169
+ `SOLID_OBJECTS_REDIS_URL`.
170
+
171
+ Do not use the option with `sharedSqliteWasm`, because each tab runs its own
172
+ runtime against the shared database. See
173
+ [Correctness and delivery semantics](correctness.md#single-owner) for the
174
+ guarantee and its limits, and [Operations](operations.md#single-owner) for
175
+ monitoring.
176
+
101
177
  ## Retention and cleanup
102
178
 
103
179
  | Option | Default | Contract |
@@ -167,11 +243,30 @@ IDs, while PostgreSQL callers should use `RETURNING` with `get()`.
167
243
 
168
244
  ### SQLite
169
245
 
170
- `sqlite({ path, timeoutMilliseconds = 5_000, lockRetryAttempts = 10 })` uses
171
- Node's built-in `node:sqlite`. `lockRetryAttempts` is a positive integer. The
172
- timeout controls SQLite's busy timeout; transaction acquisition retries use
246
+ `sqlite({ path, timeoutMilliseconds = 5_000, lockRetryAttempts = 10, synchronous = "normal" })`
247
+ uses Node's built-in `node:sqlite`. `lockRetryAttempts` is a positive integer.
248
+ The timeout controls SQLite's busy timeout; transaction acquisition retries use
173
249
  short capped backoff only when no synchronous deadline is active.
174
250
 
251
+ A file database uses write-ahead logging (WAL). On its first access, the
252
+ adapter sets `journal_mode = WAL` and the `synchronous` level, with the same
253
+ retries as a transaction. WAL keeps two more files next to the database:
254
+ `<path>-wal` and `<path>-shm`. Committed data can be in the `-wal` file, so a
255
+ copy of the database file alone can miss it. To back up a database that is in
256
+ use, run `VACUUM INTO '<backup path>'` or use the SQLite backup command. Copy
257
+ the files only when no process has the database open. An in-memory database
258
+ (`":memory:"`) keeps its memory journal.
259
+
260
+ `synchronous` is `"normal"` or `"full"`:
261
+
262
+ - `"normal"`, the default, is the level that the Ruby gem gets from Active
263
+ Record. A commit makes almost no `fsync` call. A process crash loses no
264
+ commit. A power loss or an operating system crash can lose the last commits,
265
+ but the database stays consistent.
266
+ - `"full"` flushes each commit to disk before the commit returns. A commit
267
+ costs about one `fsync` call, and `node:sqlite` blocks the event loop while
268
+ it waits for the disk.
269
+
175
270
  ### PostgreSQL
176
271
 
177
272
  `postgresql(options)` accepts:
@@ -187,7 +282,8 @@ short capped backoff only when no synchronous deadline is active.
187
282
 
188
283
  `wakeUp` takes a name or an adapter, as `config.cache_store` does in Rails.
189
284
  `"automatic"` is the default. It prefers `SOLID_OBJECTS_REDIS_URL`, then
190
- PostgreSQL notifications, then polling. `"in_process"` opts out, `"postgresql"`
285
+ PostgreSQL notifications, then polling. With `singleOwner`, the runtime runs no
286
+ selection and uses in-process signalling. `"in_process"` opts out, `"postgresql"`
191
287
  and `"redis"` force one, and an unknown name throws rather than polls quietly.
192
288
 
193
289
  A requested adapter that the environment cannot provide polls instead and says
@@ -36,9 +36,10 @@
36
36
  - Observable outbox rows are claimed in actor revision order. Subscription
37
37
  sessions and browser clients reject duplicate or stale revisions within an
38
38
  incarnation, while a recreated actor starts a new revision sequence.
39
- - Wake-up notifications happen only after commit and never replace polling.
40
- Workers watch before claiming, so an in-process notification between an empty
41
- claim and the following wait is retained.
39
+ - Wake-up notifications happen only after commit and never replace polling,
40
+ except with [`singleOwner`](#single-owner). Workers watch before claiming, so
41
+ an in-process notification between an empty claim and the following wait is
42
+ retained.
42
43
  - Activation passes are bounded. Yielding changes ready-membership polling
43
44
  order only; it neither changes durable message sequence nor makes future work
44
45
  due early.
@@ -65,6 +66,56 @@
65
66
  delivery. A state change on an actor with payloads creates a revision
66
67
  broadcast, even when that actor declares no scalar observables.
67
68
 
69
+ ## Single owner
70
+
71
+ With `singleOwner: true`, an idle runtime sends no query, and it still runs all
72
+ work when the work arrives or comes due. This guarantee holds only while one
73
+ process writes the database.
74
+
75
+ The guarantee rests on these rules:
76
+
77
+ - Every write that adds work or moves it earlier happens in this process. After
78
+ the commit, the write notifies an in-process signal for each role that must
79
+ react. A host that enqueues with
80
+ `runtime.enqueueInternalMessageInTransaction()` must call
81
+ `runtime.announceInternalMessage()` after its commit, as it must without the
82
+ option. Without that call, the message waits for the next wake-up.
83
+ - A role watches the signal before it claims, so it cannot lose a notification
84
+ that arrives during a pass.
85
+ - After an empty pass, a role reads the earliest time at which a claim can take
86
+ something. The read uses the same conditions as the claims: the head of each
87
+ mailbox, lease ends, pending effects, broadcasts, and reminders, claims whose
88
+ owners become stale, and recovery timeouts. The role waits until that time,
89
+ or for at most 24 hours.
90
+ - A claim writes the row of the claiming worker in the claim transaction. A
91
+ worker that was idle for longer than `processAliveThresholdMilliseconds`
92
+ therefore never holds work under a stale row, and the alive rule protects
93
+ running work as it does without the option.
94
+
95
+ Fencing and at-least-once delivery do not change. Claims, activation leases,
96
+ the commit fence, retries, dead letters, and effect recovery use the same rows
97
+ and the same conditions.
98
+
99
+ The guarantee has these limits:
100
+
101
+ - One process only. A write from another process, for example a second
102
+ runtime, a script, or a command, does not wake this runtime. That work waits
103
+ for the next local wake-up or due time, for at most 24 hours.
104
+ - The start check reads the process rows once. It sees another writer only
105
+ while that writer's row is fresh, and an idle single owner does not refresh
106
+ its row. Two processes that start at the same moment can both pass the
107
+ check. Only an operating system lock, such as SQLite's exclusive locking
108
+ mode, keeps a second process out.
109
+ - A due time runs on a Node timer. A long synchronous handler delays the timer,
110
+ as it delays polling. Node timers stop while the computer sleeps. A watch
111
+ sees the jump of the wall clock within 10 seconds and makes every role read
112
+ its due time again, so work that came due during a sleep runs at most about
113
+ 10 seconds after the computer wakes.
114
+ - When a due time is now or has passed and a claim still finds nothing, for
115
+ example after an actor setup failure, the role retries after 1 ms and then
116
+ doubles the wait up to its polling interval. Stuck work is then retried at
117
+ the polling interval, as it is without the option.
118
+
68
119
  ## Limitations and non-goals
69
120
 
70
121
  - At-least-once execution means actor code may begin more than once. State and
@@ -93,13 +144,13 @@
93
144
  inside-transaction check) read as unset. Durable-state fencing, mailbox
94
145
  ordering, and the SQLite WASM deadline enforcement do not depend on those
95
146
  guards. The guards are best-effort in the browser and exact in Node.
96
- - `snapshotWithIncarnation`'s `createdAtMs` orders actor incarnations to the
97
- millisecond. Every adapter stores `created_at_ms` at that same precision. If
98
- you destroy and recreate the same actor identity inside one database-clock
99
- millisecond, the two incarnations get an equal `createdAtMs`. In that narrow
100
- case, a caller cannot tell which of the two is current, so it cannot fence a
101
- derived write on that value. `instanceId` still changes and shows that a
102
- recreation occurred. It is a random UUID and carries no order of its own.
147
+ - `snapshotWithIncarnation` identifies a persisted incarnation by `instanceId`.
148
+ Use exact ID equality, with numeric revision comparison only within that ID.
149
+ `createdAtMs` is millisecond creation metadata; destruction and recreation can
150
+ produce equal timestamps and revisions while their IDs differ. Neither a
151
+ timestamp nor a random UUID provides a total incarnation order. Snapshot
152
+ reads alone do not atomically fence an external write; see
153
+ [Snapshot incarnation identity](api.md#snapshot-incarnation-identity).
103
154
  - Large documents, bulk pipelines, globally placed edge state, and global
104
155
  counters are outside the intended workload. Prefer an ordinary row
105
156
  transaction when it completely enforces the invariant.
@@ -83,6 +83,10 @@ stable public code and does not classify the operation as failed.
83
83
  lookup found no record.
84
84
  - `ReminderNotPaused` means only a completed reminder remains; schedule it
85
85
  again through its actor instead of resuming it.
86
+ - `SingleOwnerConflict` means a `singleOwner` runtime found another live
87
+ process row at start. Its `processes` list names each row and the time at
88
+ which the row becomes stale. Stop the other process, or wait until that time,
89
+ and start again.
86
90
 
87
91
  Realtime protocol validation uses `TypeError` for malformed subscription or
88
92
  browser envelopes. Server delivery failures are isolated to the session and
@@ -7,7 +7,8 @@ and work enqueued with `send` stays ready until some process runs the roles.
7
7
  A direct call or an explicit `sync` needs no running role, because the caller's
8
8
  own path executes it.
9
9
 
10
- Runtime roles use durable polling as the correctness fallback. Consecutive
10
+ Runtime roles use durable polling as the correctness fallback, unless the
11
+ runtime runs as a [single owner](#single-owner). Consecutive
11
12
  empty passes double each role's wait from `pollingIntervalMilliseconds` to
12
13
  `idlePollingIntervalMilliseconds`, which defaults to one second. Processed
13
14
  work and wake-up notifications reset the role to the fast interval. Actor
@@ -149,10 +150,10 @@ state from reconciliation code.
149
150
  The runtime automatically prunes expired message and stopped-process history
150
151
  once at startup and every `retentionIntervalMilliseconds`, which defaults to
151
152
  one hour. Stale process ownership is recovered independently every
152
- `deadProcessCleanupIntervalMilliseconds`, which defaults to one minute. Set an
153
- interval to zero to disable its scheduler. Failed passes emit metadata-only
154
- events and retry with bounded exponential backoff without stopping other
155
- runtime roles.
153
+ `deadProcessCleanupIntervalMilliseconds`, which defaults to one minute. A
154
+ single owner recovers it once, at start. Set an interval to zero to disable its
155
+ scheduler. Failed passes emit metadata-only events and retry with bounded
156
+ exponential backoff without stopping other runtime roles.
156
157
 
157
158
  Operators can also use `runtime.retention.preview()` and
158
159
  `runtime.retention.prune()`. Both calls require administration authorization;
@@ -228,6 +229,45 @@ Scheduling an existing actor operation for a different time emits
228
229
  `solid_objects.reminder.replaced` after the fenced actor commit. Scheduling it
229
230
  for the same time emits nothing.
230
231
 
232
+ ## Single owner
233
+
234
+ With [`singleOwner: true`](configuration.md#single-owner), an idle runtime sends
235
+ no query. Its roles wake for an in-process notification, for a due time that
236
+ the database reports, or after the wall clock jumps. Expect these differences
237
+ from the default:
238
+
239
+ - `runtime.administration.processes()` reports the rows of an idle single owner
240
+ as `stale`, because a role writes its row only at start, at shutdown, and
241
+ when it claims work. The `runtime` doctor check can report no live roles for
242
+ the same reason. Both are normal while the runtime is idle.
243
+ - The `wakeUp` doctor check passes with in-process signalling, because no other
244
+ process writes the database.
245
+ - `cleanup()` can mark the rows of an idle single owner `stopped`. The next
246
+ claim of each role writes its row again. An idle role holds no claim, so the
247
+ cleanup releases nothing.
248
+ - `currentPollingIntervalMilliseconds` and the
249
+ `solid_objects.polling.interval_changed` events describe only the retry of
250
+ due work that a claim cannot take.
251
+
252
+ `run()` refuses to start with `SingleOwnerConflict` when another process row is
253
+ alive. A process that stopped without cleanup stays alive until its heartbeat
254
+ is older than `processAliveThresholdMilliseconds`. The error gives that time
255
+ for each row. Stop the other process, or wait until that time, and start again.
256
+
257
+ The start check reads the process rows once. It sees another writer only while
258
+ that writer's row is fresh, and an idle single owner does not refresh its row.
259
+ For a guarantee that the operating system enforces, take an exclusive lock on
260
+ the file before the runtime writes to it:
261
+
262
+ ```typescript
263
+ const database = sqlite({ path: "app.sqlite" })
264
+ await database.connection((connection) => connection.run("PRAGMA locking_mode = EXCLUSIVE"))
265
+ ```
266
+
267
+ SQLite then holds the lock from the first write until the database closes. A
268
+ second process, including the `solid-objects` command for the same file, gets
269
+ `database is locked`.
270
+
231
271
  ## Dead letters, retry, and redrive
232
272
 
233
273
  A handler failure that exhausts its attempts becomes a dead letter.
package/docs/parity.md CHANGED
@@ -22,7 +22,10 @@ Ruby capabilities. Ruby's equivalent primitives
22
22
  (`SolidObjects::Mailbox#enqueue`, `ActorSnapshot`) are already reachable
23
23
  in-process, and they need no dedicated public API. Node's `exports` map enforces
24
24
  a package-privacy boundary between a package and its dependents. Ruby has no
25
- such boundary between a gem and its dependents.
25
+ such boundary between a gem and its dependents. The TypeScript snapshot contract
26
+ uses `instanceId` equality for incarnation identity and numeric revisions within
27
+ that identity; creation timestamps are metadata and may tie. This clarifies the
28
+ existing integration API without changing shared commit fencing or Ruby behavior.
26
29
 
27
30
  ## Status vocabulary
28
31
 
@@ -101,6 +104,7 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change
101
104
  | Capability | Status | TypeScript shape or remaining work |
102
105
  | --------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
106
  | SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. Ruby uses a yielding busy handler for background writes so a waiting thread lets the lock holder commit, including on Rails 7.1/7.2; both preserve synchronous deadlines. |
107
+ | SQLite durability settings | Native | File databases use WAL with synchronous NORMAL in both runtimes. Ruby gets them from Active Record; the Node adapter sets them on first access, and `synchronous: "full"` is optional. |
104
108
  | PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
105
109
  | MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
106
110
  | Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with `FOR UPDATE SKIP LOCKED`. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. |
@@ -216,7 +220,8 @@ next section tracks it as a shared capability. All four milestones are
216
220
  complete:
217
221
 
218
222
  - M1: the shared modules no longer import Node built-in modules, a
219
- registered platform factory supplies async context propagation, and
223
+ registered platform factory supplies Node async context and synchronous-only
224
+ browser context (guards are unset after `await`), and
220
225
  `pnpm run check` enforces a Node-free import graph for the browser-safe
221
226
  modules.
222
227
  - M2: `solid-objects/database/sqlite-wasm` implements the `Database`
@@ -12,8 +12,8 @@ no Cloudflare account, and no new datastore. A process of your application
12
12
  calls `runtime.run(signal)` to run reminders, effects, and queued messages.
13
13
  Redis is optional and only shortens wake-up latency.
14
14
 
15
- Solid Objects 1.0 promises a stable public API. It makes no claim of measured
16
- scale. Read
15
+ Solid Objects 1.0 promises a stable public API. [Versioning](versioning.md)
16
+ states the limits of that promise. Read
17
17
  [Compatibility and maturity](#compatibility-and-maturity) before you choose it.
18
18
 
19
19
  ## What a virtual actor is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Fix race conditions across Node.js processes: ordered operations, expiring holds, and state that survives a restart, in your SQL database. A SQL-backed virtual actor library for TypeScript and Node.js.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -111,7 +111,7 @@
111
111
  "deploy:cloudflare": "wrangler deploy --config examples/cloudflare/wrangler.jsonc",
112
112
  "check:cloudflare": "node scripts/check-cloudflare-imports.mjs && tsc -p tsconfig.cloudflare-example.json --noEmit && wrangler deploy --dry-run --config examples/cloudflare/wrangler.jsonc",
113
113
  "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.cloudflare-build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs",
114
- "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit && tsc -p tsconfig.cloudflare.json --noEmit",
114
+ "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit && tsc -p tsconfig.cloudflare.json --noEmit && tsc -p tsconfig.benchmarks.json",
115
115
  "check:browser-imports": "node scripts/check-browser-imports.mjs",
116
116
  "check:documentation": "node scripts/check-documentation.mjs",
117
117
  "check:parameters": "node scripts/check-parameter-style.mjs",
@@ -121,8 +121,8 @@
121
121
  "test": "vitest run",
122
122
  "test:browser": "pnpm run build && playwright test",
123
123
  "test:coverage": "vitest run --coverage",
124
- "test:postgresql": "vitest run test/postgresql.test.ts test/activation-lease.test.ts test/activation-recovery-backoff.test.ts test/caller-lifecycle.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
125
- "test:mysql": "vitest run test/mysql.test.ts test/activation-lease.test.ts test/activation-recovery-backoff.test.ts test/caller-lifecycle.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
124
+ "test:postgresql": "vitest run test/postgresql.test.ts test/activation-lease.test.ts test/activation-recovery-backoff.test.ts test/caller-lifecycle.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts test/snapshot-with-incarnation.test.ts",
125
+ "test:mysql": "vitest run test/mysql.test.ts test/activation-lease.test.ts test/activation-recovery-backoff.test.ts test/caller-lifecycle.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts test/snapshot-with-incarnation.test.ts",
126
126
  "test:package": "node scripts/release-artifact-smoke.mjs",
127
127
  "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
128
128
  "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",
@@ -149,6 +149,7 @@
149
149
  "redis": "^6.2.1",
150
150
  "signal-polyfill": "^0.2.2",
151
151
  "typescript": "^5.9.0",
152
+ "vite": "^8.3.4",
152
153
  "vitest": "^4.1.11",
153
154
  "wrangler": "^4.129.0",
154
155
  "ws": "^8.21.3"