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
@@ -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.
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.
@@ -1,12 +1,30 @@
1
1
  # Operations
2
2
 
3
- Runtime roles use durable polling as the correctness fallback. The default
4
- generation-based wake-up adapter interrupts waits for new actor messages,
5
- effects, reminders, and broadcasts in the same Node process. Notification
6
- errors are isolated and logged by role and error class without failing the
7
- committed work. Graceful shutdown stops new claims and allows active turns to
8
- finish within `shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A
9
- component still running or stopping at the deadline emits
3
+ Runtime roles use durable polling as the correctness fallback. Consecutive
4
+ empty passes double each role's wait from `pollingIntervalMilliseconds` to
5
+ `idlePollingIntervalMilliseconds`, which defaults to one second. Processed
6
+ work and wake-up notifications reset the role to the fast interval. Actor
7
+ workers clamp the ceiling to `leaseRenewalIntervalMilliseconds` while they may
8
+ hold cached activations.
9
+
10
+ The default generation-based wake-up adapter interrupts waits for new actor
11
+ messages, effects, reminders, and broadcasts in the same Node process. It does
12
+ not cross a process boundary. When live processes share the database without a
13
+ configured adapter, the runtime logs
14
+ `solid_objects.polling_only_cross_process_wake_up` once. Use PostgreSQL
15
+ notifications or optional Redis Pub/Sub when separate processes need prompt
16
+ delivery; without one, newly committed work can wait up to the current idle
17
+ polling interval. Notification errors are isolated and logged by role and error
18
+ class without failing the committed work.
19
+
20
+ Each role exposes `currentPollingIntervalMilliseconds`.
21
+ `solid_objects.polling.interval_changed` reports the role, reason, previous
22
+ interval, and current interval. The polling-only warning is also emitted as
23
+ `solid_objects.polling.only_cross_process_wake_up` instrumentation.
24
+
25
+ Graceful shutdown stops new claims and allows active turns to finish within
26
+ `shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A component still
27
+ running or stopping at the deadline emits
10
28
  `solid_objects.supervisor.component_shutdown_timeout`; the runtime then returns
11
29
  without pretending JavaScript code was forcibly terminated. Operators should
12
30
  monitor oldest ready work, claimed work, dead letters, effect failures,
package/docs/parity.md CHANGED
@@ -1,18 +1,20 @@
1
- # Ruby parity ledger
1
+ # Design parity ledger
2
2
 
3
- This ledger tracks spiritual feature parity with the Ruby `solid_objects` gem.
3
+ This ledger tracks capability parity with the Ruby `solid_objects` gem.
4
4
  Parity means preserving a capability and its correctness or security boundary,
5
5
  not copying a Rails API into Node.
6
6
 
7
- Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
7
+ Reference: Ruby `solid_objects` 0.13.1. The JavaScript package began at the
8
+ Ruby design's `0.12` capability generation; that version number did not imply
9
+ earlier JavaScript releases.
8
10
 
9
- The Node `0.12.1` implementation has spiritual parity with that reference. Its
11
+ The Node `0.13.1` implementation has capability parity with that reference. Its
10
12
  relational runtime, correctness boundaries, administration, diagnostics,
11
- realtime projections, browser behavior, and supported adapters have native
12
- equivalents. Rails engine and rendering surfaces are intentionally replaced by
13
- transport- and framework-neutral JavaScript APIs. The partial guard row and the
14
- shared planned result-lookup row below are explicit scope boundaries, not
15
- missing Ruby capabilities.
13
+ operator dashboard, realtime projections, browser behavior, and supported
14
+ adapters have native equivalents. Rails-specific rendering surfaces are
15
+ replaced by transport- and framework-neutral JavaScript APIs. The partial guard
16
+ row and the shared planned result-lookup row below are explicit scope
17
+ boundaries, not missing Ruby capabilities.
16
18
 
17
19
  ## Status vocabulary
18
20
 
@@ -49,18 +51,19 @@ missing Ruby capabilities.
49
51
 
50
52
  ## Operations
51
53
 
52
- | Capability | Status | TypeScript shape or remaining work |
53
- | ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
54
- | Process registration, heartbeats, stale claim recovery, and graceful shutdown | Native | Runtime roles persist host, PID, runtime versions, draining and stopped transitions, cooperative cancellation, and a bounded shutdown deadline; cleanup recovers stale claims. |
55
- | Failed-role replacement | Native | Built-in and registered roles are rebuilt through their factories with capped backoff; shutdown is the terminal replacement boundary. |
56
- | Additional supervised components | Native | `registerComponent()` builds, validates, runs, and stops application components with the runtime. |
57
- | Dead-letter inspection and retry | Native | `runtime.deadLetters` provides deny-by-default immutable inspection and idempotent durable retry linkage. |
58
- | Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. |
59
- | Message, process, and opt-in instance retention | Native | Supervised scheduling bounds message and process growth; authorized manual APIs add preview and keep destructive instance expiration explicit. |
60
- | Doctor and schema verification | Native | Structured checks cover configuration, schema/version shape, adapter server versions, neutral-context policy probes, live roles, and a targeted round trip. |
61
- | CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. |
62
- | Structured instrumentation | Native | An isolated transport-neutral sink emits immutable lifecycle metadata and structurally excludes application payloads. |
63
- | Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. |
54
+ | Capability | Status | TypeScript shape or remaining work |
55
+ | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | Process registration, heartbeats, stale claim recovery, and graceful shutdown | Native | Runtime roles persist host, PID, runtime versions, draining and stopped transitions, cooperative cancellation, and a bounded shutdown deadline; cleanup recovers stale claims. |
57
+ | Failed-role replacement | Native | Built-in and registered roles are rebuilt through their factories with capped backoff; shutdown is the terminal replacement boundary. |
58
+ | Additional supervised components | Native | `registerComponent()` builds, validates, runs, and stops application components with the runtime. |
59
+ | Dead-letter inspection and retry | Native | `runtime.deadLetters` provides deny-by-default immutable inspection and idempotent durable retry linkage. |
60
+ | Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. |
61
+ | Message, process, and opt-in instance retention | Native | Supervised scheduling bounds message and process growth; authorized manual APIs add preview and keep destructive instance expiration explicit. |
62
+ | Doctor and schema verification | Native | Structured checks cover configuration, schema/version shape, adapter server versions, neutral-context policy probes, live roles, and a targeted round trip. |
63
+ | CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. |
64
+ | Operator dashboard | Native | The opt-in `solid-objects/web` export provides Fetch and Node/Connect mounting, authorized runtime views and actions, session-backed CSRF, filtering, paging, charts, and immutable extension hooks. |
65
+ | Structured instrumentation | Native | An isolated transport-neutral sink emits immutable lifecycle metadata and structurally excludes application payloads. |
66
+ | Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. |
64
67
 
65
68
  ## Databases and wake-up
66
69
 
@@ -76,19 +79,19 @@ missing Ruby capabilities.
76
79
 
77
80
  ## Realtime and browser behavior
78
81
 
79
- | Capability | Status | TypeScript shape or remaining work |
80
- | ------------------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
- | Explicit observable projection and durable invalidations | Native | `observables()` is opt-in; changed values enter invalidation envelopes and are visible to every authorized subscriber. Unlike Ruby morph refreshes, values cross the wire, so private or subscriber-specific state belongs in payloads or reauthorized component endpoints. |
82
- | Action Cable channels and signed stream names | Not applicable | `runtime.realtime` provides authenticated transport-neutral sessions; the host owns its HTTP/WebSocket server and authentication. |
83
- | Authorized subscriptions | Native | Each request is denied by default and authorized before actor lookup; sessions replay committed observables and fence ordered durable revisions. Multi-process hosts explicitly bridge their shared transport. |
84
- | Turbo scalar replacement | Not applicable | The browser client exposes invalidations to application rendering code. Framework adapters can be separate packages. |
85
- | Keyed component refresh, morph/replace, and batch coalescing | Native | A typed framework-neutral registry selects explicit dependencies, coalesces batch requests, aborts superseded work, fences each target, and delegates synchronous application strategy to the host. |
86
- | Personalized payload broadcasts | Native | Static typed projections run against committed state under each fresh subscriber context, reauthorize as queries, isolate failures, and carry independent revision fences. |
87
- | Real-browser compatibility suite | Native | Playwright exercises subscription replay over native WebSocket, incarnation/revision fences, payload delivery, component batching, and cancellation in Chromium. |
82
+ | Capability | Status | TypeScript shape or remaining work |
83
+ | ------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
84
+ | Explicit observable projection and durable invalidations | Native | `observables()` is opt-in and invalidation-only by default. `broadcastValue()` sends changed values; `broadcastInvalidation()` explicitly sends only changed names while comparing the real value. Private or subscriber-specific values belong behind invalidation-only component endpoints or in typed payloads. |
85
+ | Action Cable channels and signed stream names | Not applicable | `runtime.realtime` provides authenticated transport-neutral sessions; the host owns its HTTP/WebSocket server and authentication. |
86
+ | Authorized subscriptions | Native | Each request is denied by default and authorized before actor lookup; sessions replay committed observables and fence ordered durable revisions. Multi-process hosts explicitly bridge their shared transport. |
87
+ | Turbo scalar replacement | Not applicable | The browser client exposes invalidations to application rendering code. Framework adapters can be separate packages. |
88
+ | Keyed component refresh, morph/replace, and batch coalescing | Native | A typed framework-neutral registry selects explicit dependencies, coalesces batch requests, aborts superseded work, fences each target, and delegates synchronous application strategy to the host. |
89
+ | Personalized payload broadcasts | Native | Static typed projections run against committed state under each fresh subscriber context, reauthorize as queries, isolate failures, and carry independent revision fences. |
90
+ | Real-browser compatibility suite | Native | Playwright exercises subscription replay over native WebSocket, incarnation/revision fences, payload delivery, component batching, and cancellation in Chromium. |
88
91
 
89
92
  ## Rails-specific surfaces
90
93
 
91
- The Rails engine, generators, Active Record models/controllers, ERB helpers,
92
- Turbo renderer, and Action Cable channel are not copied into this package.
93
- Their underlying runtime, administration, authorization, and realtime
94
- capabilities are represented above by Node-native APIs and protocols.
94
+ Rails generators, Active Record models/controllers, Turbo rendering, and
95
+ Action Cable are not copied into this package. The Rack dashboard is represented
96
+ by the framework-neutral Fetch and Node adapter, renderer callbacks, and the
97
+ same authorization and CSRF boundaries.
package/docs/releasing.md CHANGED
@@ -30,14 +30,16 @@ npm trust github solid-objects \
30
30
  lockfile when needed, and move the release notes out of the Unreleased
31
31
  section in `CHANGELOG.md`.
32
32
  2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`,
33
- `pnpm run build`, `pnpm run pack:check`, and
34
- `pnpm audit --audit-level=high`.
33
+ `pnpm run build`, `pnpm run pack:check`, `pnpm run test:package`,
34
+ `pnpm run test:recovery`, `pnpm run test:browser`, and
35
+ `pnpm audit --audit-level=high`. Run the PostgreSQL, MySQL, and Redis jobs
36
+ against the versions in [the support matrix](support.md).
35
37
  3. Commit and push `main`.
36
38
  4. Create and push an annotated tag matching the package version:
37
39
 
38
40
  ```shell
39
- git tag -a v0.12.1 -m "Version 0.12.1"
40
- git push origin v0.12.1
41
+ git tag -a v0.13.1 -m "Version 0.13.1"
42
+ git push origin v0.13.1
41
43
  ```
42
44
 
43
45
  The tag runs the complete CI matrix. The publish job starts only after every
@@ -20,6 +20,16 @@ The constructor must establish every persisted field and must not depend on
20
20
  external state. Solid Objects invokes it while validating the class, creating
21
21
  defaults, hydrating state, and projecting a snapshot.
22
22
 
23
+ ## Observable broadcast modes
24
+
25
+ `observables()` defines the named values used for realtime change detection.
26
+ Unwrapped values are invalidation-only: the runtime compares their real values
27
+ but includes only each changed name in committed envelopes. Wrapping a value in
28
+ `broadcastInvalidation()` makes that contract explicit. Wrap it in
29
+ `broadcastValue()` only when every authorized actor subscriber may receive the
30
+ value itself. Every mode evaluates and JSON-validates its real value after a
31
+ successful turn.
32
+
23
33
  ## State migrations
24
34
 
25
35
  Every actor starts at state version 1. Increase `stateVersion` when existing
@@ -0,0 +1,39 @@
1
+ # Supported versions and test matrix
2
+
3
+ ## Runtime support
4
+
5
+ | Component | Supported or tested range |
6
+ | -------------- | ----------------------------------------------------------- |
7
+ | Node.js | 24.15 or newer; CI uses 24.15 |
8
+ | TypeScript | 5.9 or newer for TypeScript applications |
9
+ | SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
10
+ | PostgreSQL | 14 or newer; CI runs 14 and 18 |
11
+ | MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
12
+ | Redis wake-up | Optional; CI runs Redis 7 |
13
+ | Browser client | Chromium through Playwright |
14
+
15
+ The package is ESM-only. PostgreSQL, MySQL, and Redis require their optional
16
+ peer dependency. SQLite has no driver dependency beyond Node.js.
17
+
18
+ ## What the matrix covers
19
+
20
+ The default suite exercises actor definitions, mailbox ordering, state
21
+ migrations, leases, fencing, retries, dead letters, effects, reminders,
22
+ realtime outboxes, administration, authorization, retention, lifecycle,
23
+ timeouts, and SQLite behavior.
24
+
25
+ Database jobs run the real adapter suites against PostgreSQL and MySQL servers.
26
+ The Redis job runs wake-up behavior against a real Redis server. The browser
27
+ job uses native WebSocket connections and Chromium for replay, payload,
28
+ component, dashboard, and revision-fence behavior.
29
+
30
+ The quality job also builds the ESM package, inspects `npm pack`, installs the
31
+ generated tarball in a clean temporary project, runs its packaged SQLite
32
+ quickstart, and executes the multi-process recovery demonstration.
33
+
34
+ ## Boundaries
35
+
36
+ CI currently runs on Ubuntu. Local validation also occurs on macOS, but the
37
+ project does not claim a complete operating-system compatibility matrix. A
38
+ database version being accepted by configuration is not a substitute for its
39
+ listed integration job.
@@ -0,0 +1,51 @@
1
+ import { appendFile, access, writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import { Actor } from "solid-objects"
4
+
5
+ export class RecoveryCounter extends Actor {
6
+ static override readonly actorType = "RecoveryCounter"
7
+
8
+ count = 0
9
+
10
+ async recover({ controlDirectory }: { controlDirectory: string }): Promise<number> {
11
+ const message = this.currentMessage
12
+ if (!message) throw new Error("recover requires a durable message")
13
+ const attempt = message.attempt
14
+ await appendFile(
15
+ join(controlDirectory, "external-effects.jsonl"),
16
+ `${JSON.stringify({ messageId: message.id, attempt, processId: process.pid })}\n`,
17
+ )
18
+ await writeFile(join(controlDirectory, `started-${attempt}-${process.pid}`), "")
19
+ process.send?.({ event: "operation.started", attempt, processId: process.pid })
20
+ if (attempt === 1) await waitForFile(join(controlDirectory, "release-first-attempt"))
21
+ this.count += 1
22
+ return this.count
23
+ }
24
+
25
+ async serialize({ controlDirectory }: { controlDirectory: string }): Promise<number> {
26
+ const message = this.currentMessage
27
+ if (!message) throw new Error("serialize requires a durable message")
28
+ await appendFile(
29
+ join(controlDirectory, "serialization.jsonl"),
30
+ `${JSON.stringify({ event: "start", messageId: message.id, at: Date.now() })}\n`,
31
+ )
32
+ await new Promise((resolve) => setTimeout(resolve, 100))
33
+ this.count += 1
34
+ await appendFile(
35
+ join(controlDirectory, "serialization.jsonl"),
36
+ `${JSON.stringify({ event: "finish", messageId: message.id, at: Date.now() })}\n`,
37
+ )
38
+ return this.count
39
+ }
40
+ }
41
+
42
+ async function waitForFile(path: string): Promise<void> {
43
+ for (;;) {
44
+ try {
45
+ await access(path)
46
+ return
47
+ } catch {
48
+ await new Promise((resolve) => setTimeout(resolve, 10))
49
+ }
50
+ }
51
+ }