solid-objects 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +228 -914
  3. package/dist/actor.d.ts +12 -0
  4. package/dist/actor.d.ts.map +1 -1
  5. package/dist/actor.js +28 -5
  6. package/dist/actor.js.map +1 -1
  7. package/dist/browser/components.d.ts.map +1 -1
  8. package/dist/browser/components.js +4 -1
  9. package/dist/browser/components.js.map +1 -1
  10. package/dist/browser/index.d.ts +1 -0
  11. package/dist/browser/index.d.ts.map +1 -1
  12. package/dist/browser/index.js +8 -0
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +13 -1
  16. package/dist/cli.js.map +1 -1
  17. package/dist/configuration.d.ts +1 -0
  18. package/dist/configuration.d.ts.map +1 -1
  19. package/dist/configuration.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +12 -3
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/errors.d.ts +2 -0
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +2 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/examples/sqlite-quickstart.js +83 -0
  28. package/dist/examples/sqlite-quickstart.js.map +1 -0
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/records.d.ts +1 -0
  34. package/dist/records.d.ts.map +1 -1
  35. package/dist/reminder-scheduler.d.ts +3 -1
  36. package/dist/reminder-scheduler.d.ts.map +1 -1
  37. package/dist/reminder-scheduler.js +11 -3
  38. package/dist/reminder-scheduler.js.map +1 -1
  39. package/dist/repository.d.ts +10 -3
  40. package/dist/repository.d.ts.map +1 -1
  41. package/dist/repository.js +24 -14
  42. package/dist/repository.js.map +1 -1
  43. package/dist/runtime.d.ts +3 -1
  44. package/dist/runtime.d.ts.map +1 -1
  45. package/dist/runtime.js +39 -8
  46. package/dist/runtime.js.map +1 -1
  47. package/dist/schema.d.ts.map +1 -1
  48. package/dist/schema.js +15 -4
  49. package/dist/schema.js.map +1 -1
  50. package/dist/test-helper.d.ts +5 -0
  51. package/dist/test-helper.d.ts.map +1 -1
  52. package/dist/test-helper.js +24 -0
  53. package/dist/test-helper.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/web/assets.d.ts +4 -0
  57. package/dist/web/assets.d.ts.map +1 -0
  58. package/dist/web/assets.js +11 -0
  59. package/dist/web/assets.js.map +1 -0
  60. package/dist/web/index.d.ts +28 -0
  61. package/dist/web/index.d.ts.map +1 -0
  62. package/dist/web/index.js +642 -0
  63. package/dist/web/index.js.map +1 -0
  64. package/dist/web/node.d.ts +3 -0
  65. package/dist/web/node.d.ts.map +1 -0
  66. package/dist/web/node.js +109 -0
  67. package/dist/web/node.js.map +1 -0
  68. package/dist/web/render.d.ts +64 -0
  69. package/dist/web/render.d.ts.map +1 -0
  70. package/dist/web/render.js +331 -0
  71. package/dist/web/render.js.map +1 -0
  72. package/dist/web/store.d.ts +65 -0
  73. package/dist/web/store.d.ts.map +1 -0
  74. package/dist/web/store.js +303 -0
  75. package/dist/web/store.js.map +1 -0
  76. package/dist/web/types.d.ts +82 -0
  77. package/dist/web/types.d.ts.map +1 -0
  78. package/dist/web/types.js +2 -0
  79. package/dist/web/types.js.map +1 -0
  80. package/docs/api.md +99 -4
  81. package/docs/architecture.md +16 -10
  82. package/docs/authorization.md +12 -3
  83. package/docs/benchmarks.md +123 -0
  84. package/docs/browser-protocol.md +16 -2
  85. package/docs/comparisons.md +36 -0
  86. package/docs/configuration.md +22 -1
  87. package/docs/correctness.md +23 -0
  88. package/docs/dashboard.md +196 -0
  89. package/docs/errors-and-recovery.md +16 -1
  90. package/docs/fit.md +58 -0
  91. package/docs/parity.md +39 -36
  92. package/docs/releasing.md +7 -4
  93. package/docs/state-and-lifecycle.md +14 -4
  94. package/docs/support.md +39 -0
  95. package/examples/failure-recovery/actor.ts +51 -0
  96. package/examples/failure-recovery/demo.ts +233 -0
  97. package/examples/failure-recovery/worker.ts +46 -0
  98. package/examples/sqlite-quickstart.ts +109 -0
  99. package/package.json +25 -4
@@ -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;
@@ -59,10 +60,10 @@ than waiting for a connection or serialized SQLite slot it cannot release.
59
60
 
60
61
  PostgreSQL notifications are an opt-in latency layer. One event-driven client
61
62
  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.
63
+ state, which closes the listener-startup race without holding a polling
64
+ connection per worker. A notification advances a process-local role generation
65
+ and wakes every matching waiter. Reconnection and notification loss fall back
66
+ to the ordinary polling interval.
66
67
 
67
68
  The optional Redis adapter provides the same role generations through Pub/Sub
68
69
  for deployments that already operate Redis. It keeps commands and subscriptions
@@ -94,6 +95,12 @@ actor subscriptions, replays a committed observable projection, and follows
94
95
  the durable broadcast outbox in revision order. The browser client applies the
95
96
  same incarnation and revision fence without importing Node APIs.
96
97
 
98
+ Each observable is compared using its computed JSON value. Value-broadcast
99
+ observables persist that changed value in the outbox; invalidation-only
100
+ observables persist only the changed name. This lets a component registry
101
+ refresh a reauthorized view at the same dependency granularity without storing
102
+ or sending private projection values.
103
+
97
104
  Actors may also declare static personalized payload projections. Each requested
98
105
  name is reauthorized as a query and evaluated against a fresh actor hydrated
99
106
  from committed state under that subscriber's session context. Payload names use
@@ -108,6 +115,5 @@ broadcast events through an application-owned shared transport and calls
108
115
  another broker optional for a single Node process while making the
109
116
  cross-process boundary explicit.
110
117
 
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.
118
+ Solid Objects provides database-backed state coordination for Node.js. It does
119
+ 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,123 @@
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
+ ## Scenarios
7
+
8
+ - `warm-hot`: all operations target one previously created identity.
9
+ - `warm-many`: operations rotate across 100 previously created identities.
10
+ - `cold-many`: every measured operation creates a new identity.
11
+ - `synchronous`: the actor method mutates state without yielding.
12
+ - `asynchronous`: the actor method yields through `setImmediate()` before the
13
+ same mutation.
14
+ - `one-process`: four actor workers run in the caller's Node process.
15
+ - `four-processes`: four Node worker processes share the database.
16
+
17
+ Latency begins before durable enqueue and ends when the committed result is
18
+ available. Throughput uses the wall time for the measured batch. Percentiles
19
+ use nearest rank. Defaults are 25 warmup operations, 250 measured operations,
20
+ and client concurrency 16.
21
+
22
+ ## Run the harness
23
+
24
+ SQLite needs no service:
25
+
26
+ ```bash
27
+ pnpm run benchmark -- --database sqlite
28
+ ```
29
+
30
+ PostgreSQL and MySQL runs require a disposable database. The harness deletes
31
+ benchmark rows but leaves its uniquely prefixed empty tables for inspection.
32
+
33
+ ```bash
34
+ SOLID_OBJECTS_POSTGRESQL_BENCHMARK_URL=postgresql://... \
35
+ pnpm run benchmark -- --database postgresql
36
+
37
+ SOLID_OBJECTS_MYSQL_BENCHMARK_URL=mysql://... \
38
+ pnpm run benchmark -- --database mysql
39
+ ```
40
+
41
+ Use `--operations`, `--warmup`, and `--concurrency` to change the recorded
42
+ dataset. Redirect stdout to retain the JSON result.
43
+
44
+ ## Observed results
45
+
46
+ Measured on August 15, 2026 with the prepared `0.13.0` source tree:
47
+
48
+ - Apple M5, 10 logical CPUs, 24 GiB memory
49
+ - macOS 26.6 (`darwin 25.6.0`)
50
+ - Node.js 26.7.0
51
+ - SQLite 3.53.4 on the internal SSD, PostgreSQL 18.4 and MySQL 8.4.11 in
52
+ Docker Desktop
53
+ - 25 warmup operations, 250 measured operations, concurrency 16
54
+
55
+ ### SQLite 3.53.4
56
+
57
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
58
+ | -------------- | --------- | ------------ | -----: | -----: | ------: | ------: |
59
+ | one process | warm hot | synchronous | 95.51 | 35.34 | 1159.07 | 1711.76 |
60
+ | one process | warm hot | asynchronous | 448.63 | 35.96 | 38.95 | 41.47 |
61
+ | one process | warm many | synchronous | 44.35 | 141.55 | 1886.35 | 2543.15 |
62
+ | one process | warm many | asynchronous | 240.64 | 48.62 | 75.10 | 671.64 |
63
+ | one process | cold many | synchronous | 30.91 | 436.98 | 1502.26 | 1729.10 |
64
+ | one process | cold many | asynchronous | 57.15 | 167.36 | 967.74 | 1066.49 |
65
+ | four processes | warm hot | synchronous | 453.10 | 27.24 | 72.09 | 77.88 |
66
+ | four processes | warm hot | asynchronous | 487.39 | 29.27 | 60.66 | 68.28 |
67
+ | four processes | warm many | synchronous | 78.88 | 86.33 | 890.29 | 1381.65 |
68
+ | four processes | warm many | asynchronous | 47.56 | 220.37 | 1130.53 | 1341.86 |
69
+ | four processes | cold many | synchronous | 70.45 | 174.67 | 643.53 | 668.46 |
70
+ | four processes | cold many | asynchronous | 39.92 | 221.01 | 1309.13 | 1442.30 |
71
+
72
+ ### PostgreSQL 18.4
73
+
74
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
75
+ | -------------- | --------- | ------------ | ----: | ------: | ------: | ------: |
76
+ | one process | warm hot | synchronous | 66.99 | 232.89 | 317.94 | 375.68 |
77
+ | one process | warm hot | asynchronous | 72.27 | 211.57 | 280.89 | 316.61 |
78
+ | one process | warm many | synchronous | 76.69 | 129.88 | 552.01 | 1963.27 |
79
+ | one process | warm many | asynchronous | 25.78 | 340.96 | 2326.38 | 6917.26 |
80
+ | one process | cold many | synchronous | 12.70 | 1262.08 | 1734.77 | 2199.20 |
81
+ | one process | cold many | asynchronous | 11.23 | 1254.46 | 2796.81 | 2905.99 |
82
+ | four processes | warm hot | synchronous | 83.71 | 191.83 | 220.87 | 231.84 |
83
+ | four processes | warm hot | asynchronous | 86.42 | 184.56 | 210.11 | 215.48 |
84
+ | four processes | warm many | synchronous | 95.47 | 100.31 | 330.27 | 1663.84 |
85
+ | four processes | warm many | asynchronous | 37.04 | 232.71 | 1601.29 | 4447.85 |
86
+ | four processes | cold many | synchronous | 14.79 | 1114.18 | 1263.33 | 1313.70 |
87
+ | four processes | cold many | asynchronous | 11.68 | 1231.94 | 2555.34 | 2848.80 |
88
+
89
+ ### MySQL 8.4.11
90
+
91
+ | Topology | Shape | Handler | ops/s | p50 ms | p95 ms | p99 ms |
92
+ | -------------- | --------- | ------------ | ----: | ------: | ------: | ------: |
93
+ | one process | warm hot | synchronous | 28.11 | 504.36 | 1648.10 | 2088.05 |
94
+ | one process | warm hot | asynchronous | 25.29 | 508.26 | 1750.17 | 2222.72 |
95
+ | one process | warm many | synchronous | 61.79 | 165.62 | 463.59 | 2036.31 |
96
+ | one process | warm many | asynchronous | 22.45 | 446.17 | 2420.92 | 7818.65 |
97
+ | one process | cold many | synchronous | 10.23 | 1353.37 | 3038.76 | 3185.15 |
98
+ | one process | cold many | asynchronous | 10.04 | 1338.31 | 3344.18 | 3477.06 |
99
+ | four processes | warm hot | synchronous | 29.89 | 427.18 | 1483.88 | 1997.56 |
100
+ | four processes | warm hot | asynchronous | 27.59 | 448.16 | 1518.00 | 1589.57 |
101
+ | four processes | warm many | synchronous | 69.77 | 157.79 | 410.83 | 2263.13 |
102
+ | four processes | warm many | asynchronous | 36.21 | 265.24 | 1579.43 | 4594.33 |
103
+ | four processes | cold many | synchronous | 13.68 | 1088.93 | 2186.30 | 2519.92 |
104
+ | four processes | cold many | asynchronous | 11.38 | 1209.84 | 2532.38 | 2730.74 |
105
+
106
+ The poor throughput and tail latency in cold and asynchronous cases are
107
+ observed limitations, not capacity recommendations. The small asynchronous
108
+ yield changed scheduling enough to improve some cases and worsen others;
109
+ repeat runs on application-shaped payloads are required before drawing a
110
+ general conclusion. PostgreSQL 14, MySQL 8.0, and other database versions are
111
+ covered by integration tests but were not benchmarked.
112
+
113
+ ## Sources of bias
114
+
115
+ - A developer laptop shares CPU, memory, and storage with unrelated processes.
116
+ - Loopback database connections exclude production network latency.
117
+ - Filesystem cache, SQLite WAL state, Node JIT warmup, and garbage collection
118
+ affect short runs.
119
+ - Docker Desktop adds virtualization overhead to containerized databases.
120
+ - The payload is a small counter, not a representative application state size.
121
+ - The harness measures default durability settings and one client concurrency.
122
+ - Hot-identity results deliberately include serialization and cannot be scaled
123
+ by adding workers.
@@ -5,6 +5,14 @@ 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 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.
15
+
8
16
  The 0.1 subscription request is:
9
17
 
10
18
  ```json
@@ -32,10 +40,15 @@ The 0.1 envelope is:
32
40
  "actorId": "primary",
33
41
  "instanceId": "019...",
34
42
  "revision": "42",
35
- "observables": { "count": 3 }
43
+ "observables": { "version": 3 },
44
+ "invalidations": ["playerOne"]
36
45
  }
37
46
  ```
38
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
+
39
52
  Requested personalized projections use a separate envelope:
40
53
 
41
54
  ```json
@@ -67,7 +80,8 @@ stale revisions within an incarnation.
67
80
  `SolidObjectsComponentRegistry` maps changed observable names to keyed UI
68
81
  registrations. The browser supplies an asynchronous `refresh` function and a
69
82
  synchronous `apply` function, so HTML, virtual DOM, and framework-native render
70
- results use the same coordination contract without importing Turbo.
83
+ results use the same coordination contract without assuming a rendering
84
+ framework.
71
85
 
72
86
  Components may share a batch name. A microtask unions affected components in
73
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.
@@ -28,7 +28,9 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately.
28
28
  The limits measure normalized JSON encoded as UTF-8. `maxResultBytes` also
29
29
  limits each computed snapshot getter and effect-handler result. Explicit
30
30
  `observables()` values are validated as JSON but do not currently have a
31
- separate aggregate byte limit. `retryDelayMilliseconds` should return a
31
+ separate aggregate byte limit. This includes values wrapped in
32
+ `broadcastInvalidation()`: they are compared in memory even though only their
33
+ names enter the broadcast outbox. `retryDelayMilliseconds` should return a
32
34
  non-negative finite number; an invalid application callback will fail the
33
35
  affected failure path rather than schedule an invalid timestamp.
34
36
 
@@ -96,6 +98,25 @@ server.
96
98
 
97
99
  ## Database adapters
98
100
 
101
+ ### Database value mapping
102
+
103
+ `DatabaseConnection.get<Row>()` and `all<Row>()` trust the caller-supplied row
104
+ type; they do not validate or convert driver results. Type rows to the adapter's
105
+ runtime representation or normalize them at the application boundary.
106
+
107
+ | SQL value | SQLite (`node:sqlite`) | PostgreSQL (`pg`) | MySQL (`mysql2`) |
108
+ | ----------------------------------- | --------------------------- | ----------------------------------------------- | ----------------------------------- |
109
+ | ordinary integer | `bigint` | `number` for `int2`/`int4`; `bigint` for `int8` | `number` for ordinary integer types |
110
+ | arbitrary precision integer/decimal | `bigint` for SQLite INTEGER | `numeric` remains the driver's decimal string | `BIGINT` and `DECIMAL` are strings |
111
+ | floating point | `number` | `number` | `number` |
112
+ | text | `string` | `string` | `string` |
113
+ | binary | `Uint8Array` | `Buffer` | `Buffer` |
114
+ | null | `null` | `null` | `null` |
115
+
116
+ `RunResult.changes` is always a `number`. `lastInsertId`, when an adapter
117
+ provides it, is a decimal `string`; SQLite and MySQL set it for nonzero generated
118
+ IDs, while PostgreSQL callers should use `RETURNING` with `get()`.
119
+
99
120
  ### SQLite
100
121
 
101
122
  `sqlite({ path, timeoutMilliseconds = 5_000, lockRetryAttempts = 10 })` uses
@@ -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.
@@ -0,0 +1,196 @@
1
+ # Operator dashboard
2
+
3
+ `solid-objects/web` is an optional operator interface over the same relational
4
+ tables used by the runtime. It exposes instances and committed state, ready and
5
+ claimed messages, reminders, effects, broadcasts, dead letters, and processes.
6
+ It needs no separate store or agent.
7
+
8
+ The root `solid-objects` entry point does not import the dashboard. A worker
9
+ process that never mounts it does not load its HTTP adapter, renderer, or
10
+ assets.
11
+
12
+ ## Fetch mounting
13
+
14
+ `createDashboard()` returns an immutable Fetch handler. Give every request its
15
+ fresh server-side authentication context and the host session that should own
16
+ the CSRF token:
17
+
18
+ ```typescript
19
+ import { createDashboard } from "solid-objects/web"
20
+
21
+ const dashboard = createDashboard({
22
+ runtime,
23
+ mountPath: "/solid-objects/dashboard",
24
+ })
25
+
26
+ const response = await dashboard.fetch(request, {
27
+ authorizationContext: currentOperator,
28
+ session: {
29
+ read: (key) => session.get(key),
30
+ write: (key, value) => session.set(key, value),
31
+ },
32
+ })
33
+ ```
34
+
35
+ `mountPath` defaults to `/solid-objects/dashboard`. Pass `/` only when the
36
+ dashboard owns the whole origin.
37
+
38
+ ## Access modes
39
+
40
+ `access` defaults to `authorized`. Every route uses the runtime administration
41
+ policy, pages include mutation controls, and the request context must supply a
42
+ session for CSRF state.
43
+
44
+ Use `authorized-read-only` to retain the policy while removing mutation forms
45
+ and rejecting every dashboard POST with 405. Use `public-read-only` for an
46
+ explicitly public demo mount:
47
+
48
+ ```typescript
49
+ const demo = createDashboard({
50
+ runtime,
51
+ mountPath: "/solid-objects/demo",
52
+ access: "public-read-only",
53
+ })
54
+
55
+ await demo.fetch(request, {})
56
+ ```
57
+
58
+ Public read-only mode skips `authorizeAdministration` and requires neither an
59
+ authorization context nor a session. It still exposes committed state,
60
+ arguments, results, errors, actor identifiers, and operational metadata. Only
61
+ use it with synthetic or otherwise public demo data. All extension GET routes
62
+ are public in this mode, while extension POST routes are also rejected.
63
+
64
+ ## Node and Connect mounting
65
+
66
+ `createNodeDashboardHandler()` converts `IncomingMessage` and `ServerResponse`
67
+ to the Fetch contract. Register it after the application's authentication and
68
+ session middleware. A Connect-compatible host may pass `next`; paths outside
69
+ the mount and unknown dashboard paths cascade. Requests outside the mount
70
+ cascade before context resolution or body consumption, so downstream POST
71
+ handlers receive the original stream.
72
+
73
+ ```typescript
74
+ import { createDashboard, createNodeDashboardHandler } from "solid-objects/web"
75
+
76
+ const dashboard = createDashboard({ runtime })
77
+ const handler = createNodeDashboardHandler({
78
+ dashboard,
79
+ resolveContext: async (request) => ({
80
+ authorizationContext: await currentOperator(request),
81
+ session: sessionFor(request),
82
+ }),
83
+ })
84
+
85
+ app.use(handler)
86
+ ```
87
+
88
+ The adapter caps request bodies at 64 KiB by default. Set
89
+ `maximumBodyBytes` to another positive bound when the host requires one.
90
+
91
+ ## Authorization
92
+
93
+ In the default and authorized read-only modes, every data route declares an
94
+ `authorizeAdministration` action and resource. Authorization runs before the
95
+ route reads a record, so a denied caller cannot probe identifiers.
96
+
97
+ | Page or action | Action | Resource |
98
+ | ------------------------------------------ | -------------------------- | ----------------------------- |
99
+ | Dashboard, statistics, health | `index` | `dashboard` |
100
+ | Instance list/detail | `index` / `show` | `instances` |
101
+ | Instance pause/resume | `pause` / `resume` | `instances` |
102
+ | Mailbox/message detail | `index` / `show` | `messages` |
103
+ | Reminder, effect, broadcast, process lists | `index` | corresponding plural resource |
104
+ | Dead-letter list/detail/retry | `index` / `show` / `retry` | `dead_letters` |
105
+
106
+ The policy receives the route ID as `resourceId` when one exists and receives
107
+ the exact `authorizationContext` supplied by the host. The default policy
108
+ denies every route. Dashboard authorization does not authenticate requests;
109
+ resolve the operator before calling the dashboard.
110
+
111
+ ## Security
112
+
113
+ The host session persists one random CSRF secret. Every rendered page masks it
114
+ with fresh random bytes, so tokens differ between requests while every form
115
+ already open in the same session remains valid. POST requests without a valid
116
+ token receive 403 and do not perform the action.
117
+
118
+ Every stored or request-derived string is escaped before entering HTML,
119
+ including JSON placed in chart attributes. HTML and statistics responses are
120
+ private and not cached. The dashboard sends a nonce-backed content security
121
+ policy, denies framing, disables MIME sniffing, and limits referrers to the
122
+ same origin.
123
+
124
+ Page size defaults to 25 and is clamped to 200. Actor ID substring filtering is
125
+ parameterized with an adapter-specific SQL expression rather than interpolated
126
+ into a query.
127
+
128
+ ## Pages and actions
129
+
130
+ The dashboard page shows totals, instances per actor type, mailbox depth,
131
+ outbox/reminder status, recent processes, and recent dead letters. The summary
132
+ bar can poll `/stats` every five seconds when the operator enables Live. Lists
133
+ do not reload underneath an operator reading them.
134
+
135
+ Instance detail shows committed state and recent related messages, reminders,
136
+ effects, broadcasts, and dead letters. Pause prevents workers from claiming
137
+ new turns for that identity; a turn already executing may still commit. Resume
138
+ clears the brake and normal polling resumes delivery.
139
+
140
+ Dead-letter retry calls `runtime.deadLetters.retry()`, retaining its durable
141
+ idempotency and actor-operation validation. A retry refused by the runtime is
142
+ shown on the detail page with status 422.
143
+
144
+ `HEAD /` performs only a schema reachability query and creates no CSRF session
145
+ state. Use it for liveness checks instead of polling the full dashboard.
146
+
147
+ ## Charts
148
+
149
+ The dashboard defaults to Chart.js 4.5.0 from jsDelivr with subresource
150
+ integrity. Only that exact origin is added to `script-src`.
151
+
152
+ Use a self-hosted script without widening the policy:
153
+
154
+ ```typescript
155
+ createDashboard({
156
+ runtime,
157
+ chartLibrary: { url: "/assets/chart.umd.min.js", integrity: null },
158
+ })
159
+ ```
160
+
161
+ Set `chartLibrary: { url: null }` to render without charts. The dedicated chart
162
+ containers have fixed height so responsive redraws cannot grow the page.
163
+
164
+ ## Extensions
165
+
166
+ Extensions are supplied when the dashboard is created. Configuration is copied
167
+ and frozen; there is no global registry and no first-request mutation boundary.
168
+
169
+ ```typescript
170
+ const dashboard = createDashboard({
171
+ runtime,
172
+ extensions: [
173
+ {
174
+ tab: { label: "Tenants", path: "/tenants" },
175
+ routes: [
176
+ {
177
+ method: "GET",
178
+ path: "/tenants",
179
+ policy: { action: "index", resource: "tenants" },
180
+ handle: ({ render }) =>
181
+ render({
182
+ title: "Tenants",
183
+ content: renderTenants(),
184
+ }),
185
+ },
186
+ ],
187
+ },
188
+ ],
189
+ })
190
+ ```
191
+
192
+ Every extension route must carry a nonempty policy. Missing policies and
193
+ method/path collisions fail when the dashboard is created. Renderer callbacks
194
+ may replace named built-in views, and Fetch middleware may wrap the whole
195
+ dashboard. Extension HTML is trusted application code; escape dynamic values
196
+ with the route context's `escape()` helper.
@@ -19,11 +19,18 @@ do not parse error messages.
19
19
  | `PayloadTooLarge` | Arguments, state, result, snapshot getter, effect result, or personalized payload exceeded its configured limit. | Reduce the JSON value or deliberately raise the corresponding limit. |
20
20
  | `SyncInsideTransaction` | A committed call or message wait would self-deadlock inside this adapter's transaction. | Finish the transaction first or stage actor-owned work through a commit action. |
21
21
 
22
+ `this.reject()` accepts codes matching `[A-Za-z_][A-Za-z0-9_]*`, including
23
+ camelCase. An invalid code throws the non-retryable `InvalidRejectionCode`; the
24
+ operation fails on its first attempt and a synchronous caller receives
25
+ `MessageFailed` instead of waiting through retry backoff.
26
+
22
27
  `MessageReference.status()`, `result()`, and `wait()` reauthorize the stored
23
28
  operation. `result()` returns `undefined` while work is nonterminal, returns the
24
29
  committed result when complete, and raises `Rejected` or `MessageFailed` for a
25
30
  terminal refusal or failure. `wait()` blocks until the same terminal outcomes
26
- or its deadline.
31
+ or its deadline. A reference does not retain the authorization context used to
32
+ send it; supply the context to each of these methods so the stored operation is
33
+ reauthorized.
27
34
 
28
35
  ## Definition and programming errors
29
36
 
@@ -31,6 +38,8 @@ These errors normally require a code or deployment correction:
31
38
 
32
39
  - `InvalidActor`: invalid actor type, state version, migration declaration,
33
40
  member name, payload declaration, or duplicate registration.
41
+ - `InvalidRejectionCode`: `reject()` received a code outside the supported
42
+ identifier grammar. It is terminal because retrying cannot change actor code.
34
43
  - `UnknownActorType`, `UnknownOperation`, `UnknownEffect`, and
35
44
  `UnknownCommitAction`: deployed registration does not match durable work or
36
45
  code attempted an undeclared operation.
@@ -87,3 +96,9 @@ linked replacement message; repeating the call returns the same replacement.
87
96
 
88
97
  Effects are different: they execute outside the actor transaction and are at
89
98
  least once. Deduplicate external work with the stable `EffectContext.id`.
99
+ Success and failure callback operations receive the originally staged
100
+ `arguments` for actor-state correlation. A retryable failure scheduled into the
101
+ future is correctly considered idle for the present pass, so
102
+ `runtime.testing.drain()` does not advance retry backoff; use a
103
+ `NonRetryableError` when a test needs to exercise the exhausted failure callback
104
+ without waiting.
package/docs/fit.md ADDED
@@ -0,0 +1,58 @@
1
+ # Choosing Solid Objects
2
+
3
+ Solid Objects is useful when an application has many independently addressed
4
+ entities and every entity needs ordered state changes, durable work, recovery,
5
+ or realtime projections.
6
+
7
+ ## Use it when
8
+
9
+ - Concurrent requests can update the same room, cart, account, device,
10
+ document, or session.
11
+ - Each identity needs its own ordering boundary and durable mailbox.
12
+ - Operations must recover after a Node process exits.
13
+ - State changes stage reminders, effects, actor-to-actor messages, or realtime
14
+ invalidations atomically.
15
+ - The application already operates SQLite, PostgreSQL, or MySQL and should keep
16
+ durable coordination there.
17
+
18
+ ## Prefer a row transaction when
19
+
20
+ One short transaction with an update, constraint, or row lock completely
21
+ enforces the invariant. A direct transaction has less machinery, less stored
22
+ history, and no actor-state migration contract.
23
+
24
+ ## Prefer another design when
25
+
26
+ - Work is a bulk or data-parallel pipeline rather than per-identity state.
27
+ - One global identity must sustain more writes than one sequential mailbox can
28
+ commit.
29
+ - State is a large document or relational dataset that should be queried and
30
+ updated in smaller normalized pieces.
31
+ - The application needs a transaction spanning several independent object
32
+ identities.
33
+ - Compute and state must be automatically placed close to clients at the edge.
34
+ - The team wants a managed control plane to place, scale, and recover workers.
35
+ - Durable workflow replay across named steps is more important than a mutable
36
+ object with ordered operations.
37
+
38
+ ## Model identities deliberately
39
+
40
+ One hot identity is intentionally serialized. An identity should correspond to
41
+ the smallest domain boundary that requires one total order. Splitting a room by
42
+ player or a cart by item may improve parallelism, but it also gives up atomic
43
+ ordering across the split.
44
+
45
+ Different identities can run concurrently when worker capacity and the
46
+ database allow it. The [benchmark harness](benchmarks.md) measures both the hot
47
+ and independent-identity cases.
48
+
49
+ ## Operational cost
50
+
51
+ The relational database stores actor instances, ready and claimed mailbox
52
+ membership, message history, leases, effects, reminders, broadcasts, dead
53
+ letters, and process records. Retention policies and the operator dashboard
54
+ make that state inspectable, but they do not remove the need to monitor and
55
+ back up the database.
56
+
57
+ Redis is optional. It is a transient notification path rather than durable
58
+ state, so losing Redis increases polling latency without losing committed work.