solid-objects 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +284 -950
  3. package/dist/actor.d.ts +12 -0
  4. package/dist/actor.d.ts.map +1 -1
  5. package/dist/actor.js +25 -2
  6. package/dist/actor.js.map +1 -1
  7. package/dist/broadcast-worker.d.ts +2 -0
  8. package/dist/broadcast-worker.d.ts.map +1 -1
  9. package/dist/broadcast-worker.js +25 -5
  10. package/dist/broadcast-worker.js.map +1 -1
  11. package/dist/browser/components.d.ts.map +1 -1
  12. package/dist/browser/components.js +4 -1
  13. package/dist/browser/components.js.map +1 -1
  14. package/dist/browser/index.d.ts +1 -0
  15. package/dist/browser/index.d.ts.map +1 -1
  16. package/dist/browser/index.js +8 -0
  17. package/dist/browser/index.js.map +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +13 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/configuration.d.ts +3 -0
  22. package/dist/configuration.d.ts.map +1 -1
  23. package/dist/configuration.js +3 -0
  24. package/dist/configuration.js.map +1 -1
  25. package/dist/doctor.d.ts.map +1 -1
  26. package/dist/doctor.js +12 -3
  27. package/dist/doctor.js.map +1 -1
  28. package/dist/effect-worker.d.ts +2 -0
  29. package/dist/effect-worker.d.ts.map +1 -1
  30. package/dist/effect-worker.js +25 -5
  31. package/dist/effect-worker.js.map +1 -1
  32. package/dist/examples/sqlite-quickstart.js +83 -0
  33. package/dist/examples/sqlite-quickstart.js.map +1 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/polling-backoff.d.ts +23 -0
  39. package/dist/polling-backoff.d.ts.map +1 -0
  40. package/dist/polling-backoff.js +33 -0
  41. package/dist/polling-backoff.js.map +1 -0
  42. package/dist/records.d.ts +1 -0
  43. package/dist/records.d.ts.map +1 -1
  44. package/dist/reminder-scheduler.d.ts +2 -0
  45. package/dist/reminder-scheduler.d.ts.map +1 -1
  46. package/dist/reminder-scheduler.js +25 -5
  47. package/dist/reminder-scheduler.js.map +1 -1
  48. package/dist/repository.d.ts +5 -1
  49. package/dist/repository.d.ts.map +1 -1
  50. package/dist/repository.js +15 -4
  51. package/dist/repository.js.map +1 -1
  52. package/dist/runtime.d.ts +4 -0
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +71 -6
  55. package/dist/runtime.js.map +1 -1
  56. package/dist/schema.d.ts.map +1 -1
  57. package/dist/schema.js +15 -4
  58. package/dist/schema.js.map +1 -1
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/wake-up/postgresql.d.ts.map +1 -1
  62. package/dist/wake-up/postgresql.js +12 -11
  63. package/dist/wake-up/postgresql.js.map +1 -1
  64. package/dist/wake-up.d.ts +1 -1
  65. package/dist/wake-up.d.ts.map +1 -1
  66. package/dist/wake-up.js +11 -10
  67. package/dist/wake-up.js.map +1 -1
  68. package/dist/web/assets.d.ts +4 -0
  69. package/dist/web/assets.d.ts.map +1 -0
  70. package/dist/web/assets.js +11 -0
  71. package/dist/web/assets.js.map +1 -0
  72. package/dist/web/index.d.ts +28 -0
  73. package/dist/web/index.d.ts.map +1 -0
  74. package/dist/web/index.js +642 -0
  75. package/dist/web/index.js.map +1 -0
  76. package/dist/web/node.d.ts +3 -0
  77. package/dist/web/node.d.ts.map +1 -0
  78. package/dist/web/node.js +109 -0
  79. package/dist/web/node.js.map +1 -0
  80. package/dist/web/render.d.ts +64 -0
  81. package/dist/web/render.d.ts.map +1 -0
  82. package/dist/web/render.js +331 -0
  83. package/dist/web/render.js.map +1 -0
  84. package/dist/web/store.d.ts +65 -0
  85. package/dist/web/store.d.ts.map +1 -0
  86. package/dist/web/store.js +303 -0
  87. package/dist/web/store.js.map +1 -0
  88. package/dist/web/types.d.ts +82 -0
  89. package/dist/web/types.d.ts.map +1 -0
  90. package/dist/web/types.js +2 -0
  91. package/dist/web/types.js.map +1 -0
  92. package/dist/worker.d.ts +2 -0
  93. package/dist/worker.d.ts.map +1 -1
  94. package/dist/worker.js +25 -5
  95. package/dist/worker.js.map +1 -1
  96. package/docs/api.md +70 -4
  97. package/docs/architecture.md +20 -11
  98. package/docs/authorization.md +12 -3
  99. package/docs/benchmarks.md +163 -0
  100. package/docs/browser-protocol.md +15 -8
  101. package/docs/comparisons.md +36 -0
  102. package/docs/configuration.md +14 -3
  103. package/docs/correctness.md +23 -0
  104. package/docs/dashboard.md +196 -0
  105. package/docs/fit.md +58 -0
  106. package/docs/operations.md +25 -7
  107. package/docs/parity.md +37 -34
  108. package/docs/releasing.md +6 -4
  109. package/docs/state-and-lifecycle.md +10 -0
  110. package/docs/support.md +39 -0
  111. package/examples/failure-recovery/actor.ts +51 -0
  112. package/examples/failure-recovery/demo.ts +233 -0
  113. package/examples/failure-recovery/worker.ts +46 -0
  114. package/examples/sqlite-quickstart.ts +109 -0
  115. package/package.json +26 -4
package/docs/api.md CHANGED
@@ -16,6 +16,11 @@ generic signatures; this index explains the supported role of every export.
16
16
  - `Actor`: base class providing `ref()`, `actorId`, `currentMessage`,
17
17
  `observables()`, `reject()`, `emit()`, `commitAction()`, `schedule()`,
18
18
  `sendTo()`, and protected lifecycle hooks.
19
+ - `broadcastValue(value)`: mark an observable so its changed value enters the
20
+ durable invalidation envelope.
21
+ - `broadcastInvalidation(value)`: compare the real observable value but put
22
+ only its name in the durable envelope when it changes.
23
+ - `ObservableBroadcast`: the immutable marker type returned by either helper.
19
24
  - `VERSION`: running package version.
20
25
  - `ActorClass`, `ActorReference`, `ActorMessageSender`, `ActorSnapshot`,
21
26
  `ActorOperationNames`, `ActorQueryNames`, `StagedOperations`, and
@@ -31,6 +36,25 @@ generic signatures; this index explains the supported role of every export.
31
36
  `PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
32
37
  transactional work and typed personalized projections.
33
38
 
39
+ `observables()` returns a flat object. Unwrapped values are invalidation-only:
40
+ their real values participate in change detection, but only their names enter
41
+ the durable envelope. Use an explicit marker when wire behavior matters:
42
+
43
+ ```typescript
44
+ override observables(): Record<string, unknown> {
45
+ return {
46
+ version: broadcastValue(this.document.version),
47
+ sidebar: broadcastInvalidation(this.sidebarForCurrentState()),
48
+ }
49
+ }
50
+ ```
51
+
52
+ Both values must be JSON-compatible and are evaluated after each successful
53
+ turn. An invalidation-only value participates in change detection but is never
54
+ written to the broadcast outbox or invalidation envelope. The envelope carries
55
+ its name in `invalidations`, allowing component registries to refresh a
56
+ reauthorized endpoint without exposing the value.
57
+
34
58
  `MessageReference` does not retain an invocation's authorization context.
35
59
  Supply `authorizationContext` to each `status()`, `result()`, and `wait()` call;
36
60
  the runtime reauthorizes the persisted operation every time. Durable results
@@ -90,6 +114,10 @@ The manager types are `DeadLetter`; `ReminderPage`, `ReminderPageOptions`,
90
114
  `AdministrationOptions` carries the application-owned authorization context
91
115
  for administration calls.
92
116
 
117
+ The packaged `solid-objects quickstart` command is config-free and runs the
118
+ SQLite example shipped in the npm artifact. Every other CLI command loads the
119
+ application runtime configured through `--config`.
120
+
93
121
  `ProcessRecord.shutdownState` is `"running"`, `"draining"`, or `"stopped"`;
94
122
  there is no separate `running` field. `RetentionResult.count` means eligible
95
123
  rows for `preview()` and rows actually deleted for `prune()`.
@@ -124,6 +152,11 @@ rows for `preview()` and rows actually deleted for `prune()`.
124
152
  `DatabaseConnection`, `DatabaseFamily`, and `RunResult` support custom database
125
153
  and commit-action integration.
126
154
 
155
+ `BroadcastEvent.observables` contains changed value-broadcast projections.
156
+ `BroadcastEvent.invalidations` contains changed invalidation-only names. The
157
+ runtime always supplies the array; consumers should treat its absence from an
158
+ older or application-produced event as an empty array.
159
+
127
160
  ### Runtime extensions and manual workers
128
161
 
129
162
  `runtime.registerComponent(factory, { count = 1 })` adds application-owned
@@ -146,14 +179,17 @@ Factories should create fresh mutable state and `stop()` should be idempotent.
146
179
  exported for test runners and hosts that intentionally operate roles outside
147
180
  `runtime.run()`. Runtime factory methods create the same classes. Each provides
148
181
  `runOnce()`, bounded `runUntilIdle()`, `run(signal)`, `requestShutdown()`,
149
- `stopped()`, and `stop()`. Manual roles still register process ownership and
150
- must be stopped. Prefer `runtime.run()` in production and `runtime.testing` in
151
- tests.
182
+ `stopped()`, `stop()`, and the inspectable
183
+ `currentPollingIntervalMilliseconds`. Manual roles still register process
184
+ ownership and must be stopped. Prefer `runtime.run()` in production and
185
+ `runtime.testing` in tests.
152
186
 
153
187
  `InProcessWakeUpAdapter`, `WakeUpAdapter`, `WakeUpRole`, `WakeUpWatch`, and
154
188
  `WakeUpWaitOptions` define the notification extension. A watch must be obtained
155
189
  before checking durable state so a notification cannot fall between claim and
156
- wait.
190
+ wait. `WakeUpWatch.wait()` returns `true` for a notification and `false` for a
191
+ timeout or cancellation. A legacy `void` result remains accepted and preserves
192
+ the fast polling cadence.
157
193
 
158
194
  ### Errors
159
195
 
@@ -225,5 +261,35 @@ and `SyncTimeoutWaitingOn` type timeout diagnostics. See
225
261
  `ComponentRefreshFailure`, and `ComponentRegistryOptions`: framework-neutral
226
262
  refresh contract types.
227
263
 
264
+ `InvalidationEnvelope.observables` contains values and
265
+ `InvalidationEnvelope.invalidations` contains names without values. The
266
+ component registry reacts to names in either location.
267
+
228
268
  The wire format, trust boundary, revision rules, and component semantics are in
229
269
  [Browser protocol](browser-protocol.md).
270
+
271
+ ## `solid-objects/web`
272
+
273
+ - `createDashboard(options)` creates an immutable `SolidObjectsDashboard` with
274
+ a standard `fetch(request, context)` entry point.
275
+ - `createNodeDashboardHandler(options)` adapts the Fetch entry point to
276
+ `node:http` and Connect-compatible middleware.
277
+ - `DashboardOptions` selects the runtime, mount path, `DashboardAccess`, chart library,
278
+ `DashboardExtension` objects, and `DashboardMiddleware` functions.
279
+ - `DashboardRequestContext` supplies the existing administration authorization
280
+ context and an optional `DashboardSession`. Read/write access requires the
281
+ session so its `read()` and `write()` methods can hold the masked CSRF token
282
+ across requests; read-only modes do not create CSRF state.
283
+ - `DashboardRoute`, `DashboardRouteContext`, `DashboardPolicy`, `DashboardPage`,
284
+ and `DashboardTab` define extension pages. Every route requires a policy.
285
+ - `DashboardRenderer`, `DashboardRenderInput`, and `DashboardMiddlewareInput`
286
+ define immutable view overrides and middleware inputs.
287
+ - `DashboardChartLibrary` selects the CDN, a self-hosted script, or disabled
288
+ charts.
289
+ - `NodeDashboardHandler`, `NodeDashboardHandlerOptions`, and
290
+ `NodeDashboardRequestContextResolver` describe the Node adapter.
291
+ - `SolidObjectsDashboardContract` is the minimal Fetch contract accepted by the
292
+ Node adapter.
293
+
294
+ Mounting, authorization actions, CSRF behavior, pages, and extensions are in
295
+ [Operator dashboard](dashboard.md).
@@ -1,8 +1,9 @@
1
1
  # Architecture
2
2
 
3
- Solid Objects is a database-backed virtual actor runtime. An actor is addressed
4
- by `(actor type, actor id)`, processes one durable mailbox turn at a time, and
5
- persists JSON state between activations.
3
+ Solid Objects coordinates stateful TypeScript objects through database-backed
4
+ mailboxes. Each object, called an actor in the API, is addressed by
5
+ `(actor type, actor id)`, processes one durable turn at a time, and persists
6
+ JSON state between activations.
6
7
 
7
8
  A durable message envelope selects an actor `operation` and records its
8
9
  `delivery_mode` as `async`, `sync`, or `internal`. An operation is actor code;
@@ -24,7 +25,9 @@ Each role takes a generation watch before checking for work. A post-commit
24
25
  wake-up therefore cannot fall into the gap between an empty claim and the
25
26
  worker's wait. The default adapter broadcasts within one process; polling
26
27
  remains active as the durable fallback and custom adapters can bridge process
27
- boundaries.
28
+ boundaries. Empty passes double the role's wait up to the configured idle
29
+ ceiling. Work or a notification resets it to the fast interval, and an actor
30
+ worker's ceiling never exceeds its lease-renewal interval.
28
31
 
29
32
  Each runtime role occupies a supervised factory slot. An unexpected promise
30
33
  resolution or rejection cleans up that instance, waits with capped exponential
@@ -59,10 +62,11 @@ than waiting for a connection or serialized SQLite slot it cannot release.
59
62
 
60
63
  PostgreSQL notifications are an opt-in latency layer. One event-driven client
61
64
  per runtime listens on role-specific channels before the worker checks durable
62
- state, which closes the listener-startup race without Ruby's connection per
63
- blocking thread. A notification advances a process-local role generation and
64
- wakes every matching waiter. Reconnection and notification loss fall back to
65
- the ordinary polling interval.
65
+ state, which closes the listener-startup race without holding a polling
66
+ connection per worker. A notification advances a process-local role generation
67
+ and wakes every matching waiter. Reconnection and notification loss fall back
68
+ to adaptive polling, whose current wait can be as long as the configured idle
69
+ ceiling.
66
70
 
67
71
  The optional Redis adapter provides the same role generations through Pub/Sub
68
72
  for deployments that already operate Redis. It keeps commands and subscriptions
@@ -94,6 +98,12 @@ actor subscriptions, replays a committed observable projection, and follows
94
98
  the durable broadcast outbox in revision order. The browser client applies the
95
99
  same incarnation and revision fence without importing Node APIs.
96
100
 
101
+ Each observable is compared using its computed JSON value. Value-broadcast
102
+ observables persist that changed value in the outbox; invalidation-only
103
+ observables persist only the changed name. This lets a component registry
104
+ refresh a reauthorized view at the same dependency granularity without storing
105
+ or sending private projection values.
106
+
97
107
  Actors may also declare static personalized payload projections. Each requested
98
108
  name is reauthorized as a query and evaluated against a fresh actor hydrated
99
109
  from committed state under that subscriber's session context. Payload names use
@@ -108,6 +118,5 @@ broadcast events through an application-owned shared transport and calls
108
118
  another broker optional for a single Node process while making the
109
119
  cross-process boundary explicit.
110
120
 
111
- This package ports the Solid Objects programming model. It does not share a
112
- database schema or runtime protocol with the Ruby gem and does not reproduce
113
- Cloudflare's placement or edge-runtime guarantees.
121
+ Solid Objects provides database-backed state coordination for Node.js. It does
122
+ not reproduce Cloudflare's placement or edge-runtime guarantees.
@@ -18,9 +18,12 @@ before actor type lookup, so denied callers cannot probe the registry. A new
18
18
  connection must use a newly resolved authorization context; do not copy a user
19
19
  object from an earlier request or trust an actor ID supplied by the browser.
20
20
 
21
- Successful subscription authorization allows the explicit `observables()`
22
- projection for that actor, including the immediate committed replay. It does
23
- not authorize actor state, operations, queries, destruction, or administration.
21
+ Successful subscription authorization allows the `broadcastValue()` portion
22
+ of the explicit `observables()` projection for that actor, including the
23
+ immediate committed replay. Unwrapped observables and values marked with
24
+ `broadcastInvalidation()` disclose only their names, not their values.
25
+ Subscription authorization does not
26
+ authorize actor state, operations, queries, destruction, or administration.
24
27
  The runtime removes all registrations when the host calls `session.close()`.
25
28
 
26
29
  Requested personalized payloads cross both boundaries. The runtime first calls
@@ -29,3 +32,9 @@ the same fresh session authorization context before every projection. A false
29
32
  query decision omits that payload. Projection and authorization failures are
30
33
  confined to the payload name and emit metadata-only instrumentation; they do
31
34
  not reject observable delivery or expose exception messages.
35
+
36
+ The optional operator dashboard applies `authorizeAdministration` before every
37
+ data route. Each route supplies its own action, resource, optional resource ID,
38
+ and the host-provided request authorization context. Dashboard authorization
39
+ does not replace host authentication. State-changing routes additionally
40
+ require a masked token backed by the host's `DashboardSession`.
@@ -0,0 +1,163 @@
1
+ # Benchmarks
2
+
3
+ The benchmark harness measures committed actor operations. It is intended to
4
+ show tradeoffs and catch large regressions, not to predict application capacity.
5
+
6
+ ## Idle polling
7
+
8
+ The idle harness measures process CPU and empty database passes for the four
9
+ runtime roles:
10
+
11
+ ```bash
12
+ pnpm run benchmark:idle
13
+ ```
14
+
15
+ It warms each interval for three seconds, measures for ten seconds, and reports
16
+ process user plus system CPU time divided by wall time.
17
+
18
+ Measured on August 16, 2026 on an Apple M5 with Node.js 26.7.0 and in-memory
19
+ SQLite. The before run used 0.13.0; the after run used the prepared 0.13.1 tree.
20
+ Each run started one actor, effect, reminder, and broadcast role.
21
+
22
+ | Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
23
+ | ------------: | -------------: | ---------: | ------------: | --------: |
24
+ | 20 ms | 188.78 | 3.254% | 4.000 | 0.129% |
25
+ | 100 ms | 39.596 | 0.906% | 3.999 | 0.121% |
26
+ | 500 ms | 7.999 | 0.251% | 3.999 | 0.104% |
27
+
28
+ The after run reached the one-second ceiling for all four roles. These are
29
+ developer-laptop measurements, not a CPU guarantee; timer scheduling, JIT,
30
+ database path, and unrelated host activity affect short samples.
31
+
32
+ Five SQLite samples measured durable enqueue through committed completion after
33
+ 2.5 seconds of idleness. The polling-only multi-process harness submits just
34
+ after an empty pass, so it measures approximately the full polling wait rather
35
+ than average arrival latency.
36
+
37
+ | Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
38
+ | ------------------------------- | ---------: | ------------------: |
39
+ | One process, in-process wake-up | 2.589 ms | 2.662 ms |
40
+ | Two processes, polling only | 107.945 ms | 1,006.232 ms |
41
+
42
+ The local wake-up keeps the one-process path prompt after backoff. The
43
+ polling-only row is the explicit tradeoff: use PostgreSQL notifications or
44
+ optional Redis Pub/Sub when separate processes need low-latency delivery.
45
+
46
+ ## Scenarios
47
+
48
+ - `warm-hot`: all operations target one previously created identity.
49
+ - `warm-many`: operations rotate across 100 previously created identities.
50
+ - `cold-many`: every measured operation creates a new identity.
51
+ - `synchronous`: the actor method mutates state without yielding.
52
+ - `asynchronous`: the actor method yields through `setImmediate()` before the
53
+ same mutation.
54
+ - `one-process`: four actor workers run in the caller's Node process.
55
+ - `four-processes`: four Node worker processes share the database.
56
+
57
+ Latency begins before durable enqueue and ends when the committed result is
58
+ available. Throughput uses the wall time for the measured batch. Percentiles
59
+ use nearest rank. Defaults are 25 warmup operations, 250 measured operations,
60
+ and client concurrency 16.
61
+
62
+ ## Run the harness
63
+
64
+ SQLite needs no service:
65
+
66
+ ```bash
67
+ pnpm run benchmark -- --database sqlite
68
+ ```
69
+
70
+ PostgreSQL and MySQL runs require a disposable database. The harness deletes
71
+ benchmark rows but leaves its uniquely prefixed empty tables for inspection.
72
+
73
+ ```bash
74
+ SOLID_OBJECTS_POSTGRESQL_BENCHMARK_URL=postgresql://... \
75
+ pnpm run benchmark -- --database postgresql
76
+
77
+ SOLID_OBJECTS_MYSQL_BENCHMARK_URL=mysql://... \
78
+ pnpm run benchmark -- --database mysql
79
+ ```
80
+
81
+ Use `--operations`, `--warmup`, and `--concurrency` to change the recorded
82
+ dataset. Redirect stdout to retain the JSON result.
83
+
84
+ ## Observed results
85
+
86
+ Measured on August 15, 2026 with the prepared `0.13.0` source tree:
87
+
88
+ - Apple M5, 10 logical CPUs, 24 GiB memory
89
+ - macOS 26.6 (`darwin 25.6.0`)
90
+ - Node.js 26.7.0
91
+ - SQLite 3.53.4 on the internal SSD, PostgreSQL 18.4 and MySQL 8.4.11 in
92
+ Docker Desktop
93
+ - 25 warmup operations, 250 measured operations, concurrency 16
94
+
95
+ ### SQLite 3.53.4
96
+
97
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
98
+ | -------------- | --------- | ------------ | -----: | -----: | ------: | ------: |
99
+ | one process | warm hot | synchronous | 95.51 | 35.34 | 1159.07 | 1711.76 |
100
+ | one process | warm hot | asynchronous | 448.63 | 35.96 | 38.95 | 41.47 |
101
+ | one process | warm many | synchronous | 44.35 | 141.55 | 1886.35 | 2543.15 |
102
+ | one process | warm many | asynchronous | 240.64 | 48.62 | 75.10 | 671.64 |
103
+ | one process | cold many | synchronous | 30.91 | 436.98 | 1502.26 | 1729.10 |
104
+ | one process | cold many | asynchronous | 57.15 | 167.36 | 967.74 | 1066.49 |
105
+ | four processes | warm hot | synchronous | 453.10 | 27.24 | 72.09 | 77.88 |
106
+ | four processes | warm hot | asynchronous | 487.39 | 29.27 | 60.66 | 68.28 |
107
+ | four processes | warm many | synchronous | 78.88 | 86.33 | 890.29 | 1381.65 |
108
+ | four processes | warm many | asynchronous | 47.56 | 220.37 | 1130.53 | 1341.86 |
109
+ | four processes | cold many | synchronous | 70.45 | 174.67 | 643.53 | 668.46 |
110
+ | four processes | cold many | asynchronous | 39.92 | 221.01 | 1309.13 | 1442.30 |
111
+
112
+ ### PostgreSQL 18.4
113
+
114
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
115
+ | -------------- | --------- | ------------ | ----: | ------: | ------: | ------: |
116
+ | one process | warm hot | synchronous | 66.99 | 232.89 | 317.94 | 375.68 |
117
+ | one process | warm hot | asynchronous | 72.27 | 211.57 | 280.89 | 316.61 |
118
+ | one process | warm many | synchronous | 76.69 | 129.88 | 552.01 | 1963.27 |
119
+ | one process | warm many | asynchronous | 25.78 | 340.96 | 2326.38 | 6917.26 |
120
+ | one process | cold many | synchronous | 12.70 | 1262.08 | 1734.77 | 2199.20 |
121
+ | one process | cold many | asynchronous | 11.23 | 1254.46 | 2796.81 | 2905.99 |
122
+ | four processes | warm hot | synchronous | 83.71 | 191.83 | 220.87 | 231.84 |
123
+ | four processes | warm hot | asynchronous | 86.42 | 184.56 | 210.11 | 215.48 |
124
+ | four processes | warm many | synchronous | 95.47 | 100.31 | 330.27 | 1663.84 |
125
+ | four processes | warm many | asynchronous | 37.04 | 232.71 | 1601.29 | 4447.85 |
126
+ | four processes | cold many | synchronous | 14.79 | 1114.18 | 1263.33 | 1313.70 |
127
+ | four processes | cold many | asynchronous | 11.68 | 1231.94 | 2555.34 | 2848.80 |
128
+
129
+ ### MySQL 8.4.11
130
+
131
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
132
+ | -------------- | --------- | ------------ | ----: | ------: | ------: | ------: |
133
+ | one process | warm hot | synchronous | 28.11 | 504.36 | 1648.10 | 2088.05 |
134
+ | one process | warm hot | asynchronous | 25.29 | 508.26 | 1750.17 | 2222.72 |
135
+ | one process | warm many | synchronous | 61.79 | 165.62 | 463.59 | 2036.31 |
136
+ | one process | warm many | asynchronous | 22.45 | 446.17 | 2420.92 | 7818.65 |
137
+ | one process | cold many | synchronous | 10.23 | 1353.37 | 3038.76 | 3185.15 |
138
+ | one process | cold many | asynchronous | 10.04 | 1338.31 | 3344.18 | 3477.06 |
139
+ | four processes | warm hot | synchronous | 29.89 | 427.18 | 1483.88 | 1997.56 |
140
+ | four processes | warm hot | asynchronous | 27.59 | 448.16 | 1518.00 | 1589.57 |
141
+ | four processes | warm many | synchronous | 69.77 | 157.79 | 410.83 | 2263.13 |
142
+ | four processes | warm many | asynchronous | 36.21 | 265.24 | 1579.43 | 4594.33 |
143
+ | four processes | cold many | synchronous | 13.68 | 1088.93 | 2186.30 | 2519.92 |
144
+ | four processes | cold many | asynchronous | 11.38 | 1209.84 | 2532.38 | 2730.74 |
145
+
146
+ The poor throughput and tail latency in cold and asynchronous cases are
147
+ observed limitations, not capacity recommendations. The small asynchronous
148
+ yield changed scheduling enough to improve some cases and worsen others;
149
+ repeat runs on application-shaped payloads are required before drawing a
150
+ general conclusion. PostgreSQL 14, MySQL 8.0, and other database versions are
151
+ covered by integration tests but were not benchmarked.
152
+
153
+ ## Sources of bias
154
+
155
+ - A developer laptop shares CPU, memory, and storage with unrelated processes.
156
+ - Loopback database connections exclude production network latency.
157
+ - Filesystem cache, SQLite WAL state, Node JIT warmup, and garbage collection
158
+ affect short runs.
159
+ - Docker Desktop adds virtualization overhead to containerized databases.
160
+ - The payload is a small counter, not a representative application state size.
161
+ - The harness measures default durability settings and one client concurrency.
162
+ - Hot-identity results deliberately include serialization and cannot be scaled
163
+ by adding workers.
@@ -5,12 +5,13 @@ subscription requests and receives JSON invalidation envelopes over WebSocket.
5
5
  The application supplies the rendering callback and authenticated WebSocket
6
6
  server; `runtime.realtime` supplies the server-side session protocol.
7
7
 
8
- The `observables` object contains values, not only invalidation names, and every
9
- subscriber authorized for that actor receives the same projection. This differs
10
- from Ruby morph components, which re-fetch a signed endpoint without putting
11
- observable values on the wire. Keep secrets and subscriber-specific state out
12
- of `observables()`; use personalized payloads or an application endpoint that
13
- reauthorizes the component request.
8
+ The `observables` object contains changed values and every subscriber authorized
9
+ for that actor receives the same projection. The `invalidations` array contains
10
+ changed observable names whose values were deliberately withheld with
11
+ `broadcastInvalidation()`. Use those names to refresh an application endpoint
12
+ that reauthorizes the component request. Use personalized payloads when the
13
+ browser needs subscriber-specific data, and keep secrets out of value-broadcast
14
+ observables.
14
15
 
15
16
  The 0.1 subscription request is:
16
17
 
@@ -39,10 +40,15 @@ The 0.1 envelope is:
39
40
  "actorId": "primary",
40
41
  "instanceId": "019...",
41
42
  "revision": "42",
42
- "observables": { "count": 3 }
43
+ "observables": { "version": 3 },
44
+ "invalidations": ["playerOne"]
43
45
  }
44
46
  ```
45
47
 
48
+ `invalidations` was added as an optional version-1 field in 0.13.0. Clients
49
+ must treat a missing field as an empty array. A name appears in either
50
+ `observables` or `invalidations`, never both, in runtime-produced envelopes.
51
+
46
52
  Requested personalized projections use a separate envelope:
47
53
 
48
54
  ```json
@@ -74,7 +80,8 @@ stale revisions within an incarnation.
74
80
  `SolidObjectsComponentRegistry` maps changed observable names to keyed UI
75
81
  registrations. The browser supplies an asynchronous `refresh` function and a
76
82
  synchronous `apply` function, so HTML, virtual DOM, and framework-native render
77
- results use the same coordination contract without importing Turbo.
83
+ results use the same coordination contract without assuming a rendering
84
+ framework.
78
85
 
79
86
  Components may share a batch name. A microtask unions affected components in
80
87
  the same actor, batch, incarnation, and revision into one refresh request.
@@ -0,0 +1,36 @@
1
+ # System comparisons
2
+
3
+ This guide compares coordination models so an application can choose the
4
+ smallest mechanism that meets its requirements. It does not rank the projects.
5
+
6
+ | Approach | State and serialization unit | Deployment and durable substrate | Separate service | Replay versus state | Realtime and edge placement | Cross-identity transaction | Data access |
7
+ | --------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------- |
8
+ | SQL transaction or row lock | Rows selected by one transaction | Application process and SQL database | No | The application retries a failed transaction | Application-owned | Yes, for rows in the same database transaction | Ordinary application tables and SQL tools |
9
+ | Traditional job queue | A job, queue, or configured grouping key | Workers plus broker or queue database | Usually | The job is retried; mutable entity state remains application-owned | Application-owned | Not supplied by the queue | Queue administration plus application data stores |
10
+ | Solid Objects | Actor class and application-defined ID | Node processes plus existing SQLite, PostgreSQL, or MySQL | No; Redis wake-up is optional | The operation retries against durable actor state | Committed projections; application-owned transport; no edge placement | No | Relational tables, typed administration, CLI, and dashboard |
11
+ | Cloudflare Durable Objects | Object class and globally unique ID | Cloudflare Workers plus per-object managed storage | Cloudflare platform | Object activation with durable state, not workflow-step replay | WebSockets and Cloudflare-selected object location | Storage transactions are scoped to one object | Object storage APIs and platform tooling |
12
+ | 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 |
13
+ | 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 |
14
+ | 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 |
15
+
16
+ ## Primary references
17
+
18
+ - PostgreSQL documents row-lock behavior, transaction lifetime, and deadlock
19
+ handling in [Explicit Locking](https://www.postgresql.org/docs/18/explicit-locking.html).
20
+ - BullMQ is one representative traditional queue; its
21
+ [worker concurrency documentation](https://docs.bullmq.io/guide/workers/concurrency)
22
+ distinguishes local concurrency from multiple worker processes.
23
+ - Cloudflare documents global uniqueness, per-object storage, single-threaded
24
+ execution, and placement in [What are Durable Objects?](https://developers.cloudflare.com/durable-objects/concepts/what-are-durable-objects/).
25
+ - Rivet documents addressable actors and persistence in
26
+ [Actors](https://rivet.dev/docs/actors/) and
27
+ [Persistence](https://rivet.dev/docs/actors/persistence).
28
+ - DBOS documents its PostgreSQL checkpoints, recovery, distributed processes,
29
+ and optional control plane in [DBOS Architecture](https://docs.dbos.dev/architecture).
30
+ - Restate documents per-key write serialization in
31
+ [Virtual Objects](https://docs.restate.dev/foundations/services#virtual-object)
32
+ and its storage requirements in the
33
+ [self-hosted server overview](https://docs.restate.dev/server/overview).
34
+
35
+ External systems evolve. Recheck these primary sources before relying on one
36
+ row as a procurement or architecture decision.
@@ -11,6 +11,7 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately.
11
11
  | `database` | required | A `Database` adapter. |
12
12
  | `tableNamePrefix` | `"solid_objects_"` | Lowercase letters, digits, and underscores; must start with a letter. |
13
13
  | `pollingIntervalMilliseconds` | `100` | Positive durable-work polling interval. |
14
+ | `idlePollingIntervalMilliseconds` | `1_000` | Positive ceiling after consecutive empty polling passes. |
14
15
  | `syncPollingIntervalMilliseconds` | `50` | Positive result-wait polling interval. |
15
16
  | `leaseDurationMilliseconds` | `30_000` | Positive activation lease; must exceed renewal interval. |
16
17
  | `leaseRenewalIntervalMilliseconds` | `10_000` | Positive activation renewal cadence. |
@@ -28,7 +29,9 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately.
28
29
  The limits measure normalized JSON encoded as UTF-8. `maxResultBytes` also
29
30
  limits each computed snapshot getter and effect-handler result. Explicit
30
31
  `observables()` values are validated as JSON but do not currently have a
31
- separate aggregate byte limit. `retryDelayMilliseconds` should return a
32
+ separate aggregate byte limit. This includes values wrapped in
33
+ `broadcastInvalidation()`: they are compared in memory even though only their
34
+ names enter the broadcast outbox. `retryDelayMilliseconds` should return a
32
35
  non-negative finite number; an invalid application callback will fail the
33
36
  affected failure path rather than schedule an invalid timestamp.
34
37
 
@@ -50,8 +53,16 @@ affected failure path rather than schedule an invalid timestamp.
50
53
 
51
54
  Counts may be zero, but the complete configuration must leave at least one
52
55
  runtime role enabled. Broadcast workers are started only when `broadcast` or
53
- `authorizeSubscription` is configured. Wake-ups reduce latency; durable polling
54
- remains the correctness path.
56
+ `authorizeSubscription` is configured.
57
+
58
+ `pollingIntervalMilliseconds` is the fast interval after work or a wake-up.
59
+ Consecutive empty passes double it up to
60
+ `idlePollingIntervalMilliseconds`. Actor workers never wait longer than
61
+ `leaseRenewalIntervalMilliseconds`. Set the fast and idle values equal for a
62
+ fixed cadence. A custom wake-up adapter should return `true` for a notification
63
+ and `false` for a timeout; an older adapter that returns `void` remains
64
+ compatible and keeps the fast cadence. Wake-ups reduce latency, while database
65
+ polling remains the correctness path.
55
66
 
56
67
  ## Retention and cleanup
57
68
 
@@ -1,5 +1,7 @@
1
1
  # Correctness and delivery semantics
2
2
 
3
+ ## Guarantees
4
+
3
5
  - Delivery is ordered per actor identity and at least once.
4
6
  - Different identities may execute concurrently.
5
7
  - Sequence allocation and durable enqueue are one transaction.
@@ -51,3 +53,24 @@
51
53
  failing projection cannot stop its siblings or observable delivery. A state
52
54
  change on an actor declaring payloads creates a revision broadcast even when
53
55
  the actor declares no scalar observables.
56
+
57
+ ## Limitations and non-goals
58
+
59
+ - At-least-once execution means actor code may begin more than once. State and
60
+ staged intents from a failed turn roll back, but arbitrary external work does
61
+ not. External systems need stable idempotency keys.
62
+ - The activation fence protects the Solid Objects commit. It cannot revoke or
63
+ undo network calls, files, emails, payments, or other external effects.
64
+ - One identity processes one write operation at a time. This is the ordering
65
+ guarantee and also the hot-identity throughput limit.
66
+ - A commit is scoped to one actor turn. There is no transaction spanning two
67
+ actor identities.
68
+ - Processes with incompatible `stateVersion` definitions cannot safely overlap.
69
+ Once newer code persists a state version, older code rejects that actor.
70
+ - The application owns HTTP, WebSocket authentication, rendering, process
71
+ placement, capacity, database backups, and database failover.
72
+ - Redis and PostgreSQL notifications reduce wake-up latency but do not replace
73
+ durable polling or become a source of truth.
74
+ - Large documents, bulk pipelines, globally placed edge state, and global
75
+ counters are outside the intended workload. Prefer an ordinary row
76
+ transaction when it completely enforces the invariant.