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.
- package/CHANGELOG.md +51 -0
- package/README.md +228 -914
- package/dist/actor.d.ts +12 -0
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +28 -5
- package/dist/actor.js.map +1 -1
- package/dist/browser/components.d.ts.map +1 -1
- package/dist/browser/components.js +4 -1
- package/dist/browser/components.js.map +1 -1
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +8 -0
- package/dist/browser/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -1
- package/dist/cli.js.map +1 -1
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.d.ts.map +1 -1
- package/dist/configuration.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +12 -3
- package/dist/doctor.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/examples/sqlite-quickstart.js +83 -0
- package/dist/examples/sqlite-quickstart.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/records.d.ts +1 -0
- package/dist/records.d.ts.map +1 -1
- package/dist/reminder-scheduler.d.ts +3 -1
- package/dist/reminder-scheduler.d.ts.map +1 -1
- package/dist/reminder-scheduler.js +11 -3
- package/dist/reminder-scheduler.js.map +1 -1
- package/dist/repository.d.ts +10 -3
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +24 -14
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +39 -8
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +15 -4
- package/dist/schema.js.map +1 -1
- package/dist/test-helper.d.ts +5 -0
- package/dist/test-helper.d.ts.map +1 -1
- package/dist/test-helper.js +24 -0
- package/dist/test-helper.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/web/assets.d.ts +4 -0
- package/dist/web/assets.d.ts.map +1 -0
- package/dist/web/assets.js +11 -0
- package/dist/web/assets.js.map +1 -0
- package/dist/web/index.d.ts +28 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +642 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/node.d.ts +3 -0
- package/dist/web/node.d.ts.map +1 -0
- package/dist/web/node.js +109 -0
- package/dist/web/node.js.map +1 -0
- package/dist/web/render.d.ts +64 -0
- package/dist/web/render.d.ts.map +1 -0
- package/dist/web/render.js +331 -0
- package/dist/web/render.js.map +1 -0
- package/dist/web/store.d.ts +65 -0
- package/dist/web/store.d.ts.map +1 -0
- package/dist/web/store.js +303 -0
- package/dist/web/store.js.map +1 -0
- package/dist/web/types.d.ts +82 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +2 -0
- package/dist/web/types.js.map +1 -0
- package/docs/api.md +99 -4
- package/docs/architecture.md +16 -10
- package/docs/authorization.md +12 -3
- package/docs/benchmarks.md +123 -0
- package/docs/browser-protocol.md +16 -2
- package/docs/comparisons.md +36 -0
- package/docs/configuration.md +22 -1
- package/docs/correctness.md +23 -0
- package/docs/dashboard.md +196 -0
- package/docs/errors-and-recovery.md +16 -1
- package/docs/fit.md +58 -0
- package/docs/parity.md +39 -36
- package/docs/releasing.md +7 -4
- package/docs/state-and-lifecycle.md +14 -4
- package/docs/support.md +39 -0
- package/examples/failure-recovery/actor.ts +51 -0
- package/examples/failure-recovery/demo.ts +233 -0
- package/examples/failure-recovery/worker.ts +46 -0
- package/examples/sqlite-quickstart.ts +109 -0
- package/package.json +25 -4
package/docs/parity.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Design parity ledger
|
|
2
2
|
|
|
3
|
-
This ledger tracks
|
|
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.
|
|
7
|
+
Reference: Ruby `solid_objects` 0.13.0. 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.
|
|
11
|
+
The Node `0.13.0` implementation has capability parity with that reference. Its
|
|
10
12
|
relational runtime, correctness boundaries, administration, diagnostics,
|
|
11
|
-
realtime projections, browser behavior, and supported
|
|
12
|
-
equivalents. Rails
|
|
13
|
-
transport- and framework-neutral JavaScript APIs. The partial guard
|
|
14
|
-
shared planned result-lookup row below are explicit scope
|
|
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
|
|
|
@@ -32,11 +34,11 @@ missing Ruby capabilities.
|
|
|
32
34
|
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
|
|
33
35
|
| Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
|
|
34
36
|
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, and adapter-appropriate sequence locking. |
|
|
35
|
-
| Domain rejection and strict poison ordering | Native | Rejections roll back without retry; retryable failures block later operations until completion or dead-lettering.
|
|
37
|
+
| Domain rejection and strict poison ordering | Native | Rejections accept JavaScript identifier-style codes and roll back without retry; invalid codes fail terminally, while retryable failures block later operations until completion or dead-lettering. |
|
|
36
38
|
| Bounded activation passes and hot-actor fairness | Native | Configurable turn-count and elapsed-time budgets bound each pass, then move only that actor's already-due memberships behind actors already waiting. |
|
|
37
39
|
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
|
|
38
40
|
| Idle activation cache | Native | Long-running workers retain hydrated actors under renewable fenced leases, restore public state after failed turns, and release on timeout, fairness yield, lease loss, or shutdown. |
|
|
39
|
-
| Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure
|
|
41
|
+
| Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation. |
|
|
40
42
|
| Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. |
|
|
41
43
|
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
|
|
42
44
|
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
|
|
@@ -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
|
-
|
|
|
63
|
-
|
|
|
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; only changed
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
@@ -26,11 +26,14 @@ npm trust github solid-objects \
|
|
|
26
26
|
|
|
27
27
|
## Release procedure
|
|
28
28
|
|
|
29
|
-
1. Update the version in `package.json` and `
|
|
30
|
-
release notes out of the Unreleased
|
|
29
|
+
1. Update the version in `package.json` and `src/version.ts`, refresh the
|
|
30
|
+
lockfile when needed, and move the release notes out of the Unreleased
|
|
31
|
+
section in `CHANGELOG.md`.
|
|
31
32
|
2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`,
|
|
32
|
-
`pnpm run build`, `pnpm run pack:check`,
|
|
33
|
-
`pnpm
|
|
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).
|
|
34
37
|
3. Commit and push `main`.
|
|
35
38
|
4. Create and push an annotated tag matching the package version:
|
|
36
39
|
|
|
@@ -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
|
|
@@ -116,10 +126,10 @@ crash, so it cannot establish correctness.
|
|
|
116
126
|
## Domain rejection and failure
|
|
117
127
|
|
|
118
128
|
Call `this.reject(code, { message, details })` when an operation is valid but
|
|
119
|
-
the domain refuses it. Codes
|
|
120
|
-
|
|
121
|
-
`rejected`, and later mailbox work remains eligible. The caller
|
|
122
|
-
`Rejected` with the code, frozen JSON details, and durable message ID.
|
|
129
|
+
the domain refuses it. Codes use the same identifier rule as actor members:
|
|
130
|
+
`[A-Za-z_][A-Za-z0-9_]*`. The turn rolls back, staged work is discarded, the
|
|
131
|
+
message becomes `rejected`, and later mailbox work remains eligible. The caller
|
|
132
|
+
receives `Rejected` with the code, frozen JSON details, and durable message ID.
|
|
123
133
|
|
|
124
134
|
Throwing another error rolls the turn back and schedules a retry according to
|
|
125
135
|
`maxAttempts` and `retryDelayMilliseconds`. Throw a custom subclass of
|
package/docs/support.md
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
import { fork, type ChildProcess } from "node:child_process"
|
|
8
|
+
import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
|
|
9
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
10
|
+
import { RecoveryCounter } from "./actor.ts"
|
|
11
|
+
|
|
12
|
+
interface WorkerMessage {
|
|
13
|
+
event: string
|
|
14
|
+
attempt?: number
|
|
15
|
+
processed?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SerializationEvent {
|
|
19
|
+
event: "start" | "finish"
|
|
20
|
+
messageId: string
|
|
21
|
+
at: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ExternalEffectEvent {
|
|
25
|
+
messageId: string
|
|
26
|
+
attempt: number
|
|
27
|
+
processId: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const directory = await mkdtemp(join(tmpdir(), "solid-objects-recovery-"))
|
|
31
|
+
const databasePath = join(directory, "state.sqlite3")
|
|
32
|
+
const runtime = createRuntime({
|
|
33
|
+
database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
|
|
34
|
+
leaseDurationMilliseconds: 250,
|
|
35
|
+
leaseRenewalIntervalMilliseconds: 50,
|
|
36
|
+
processHeartbeatIntervalMilliseconds: 75,
|
|
37
|
+
processAliveThresholdMilliseconds: 300,
|
|
38
|
+
workerCount: 1,
|
|
39
|
+
effectWorkerCount: 0,
|
|
40
|
+
reminderSchedulerCount: 0,
|
|
41
|
+
retentionIntervalMilliseconds: 0,
|
|
42
|
+
deadProcessCleanupIntervalMilliseconds: 0,
|
|
43
|
+
authorizeMessage: () => true,
|
|
44
|
+
authorizeQuery: () => true,
|
|
45
|
+
authorizeAdministration: () => true,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
runtime.register(RecoveryCounter)
|
|
50
|
+
await runtime.install()
|
|
51
|
+
const serialization = await proveSerialization()
|
|
52
|
+
const crash = await proveCrashRecovery()
|
|
53
|
+
const fencing = await proveFencing()
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ serialization, crash, fencing }, null, 2)}\n`)
|
|
55
|
+
} finally {
|
|
56
|
+
await runtime.close()
|
|
57
|
+
await rm(directory, { recursive: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
assert.equal(existsSync(directory), false)
|
|
61
|
+
|
|
62
|
+
async function proveSerialization(): Promise<{ finalState: number; overlap: false }> {
|
|
63
|
+
const controlDirectory = join(directory, "serialization")
|
|
64
|
+
await mkdir(controlDirectory)
|
|
65
|
+
const reference = runtime.ref(RecoveryCounter, "serialized")
|
|
66
|
+
const messages = await Promise.all([
|
|
67
|
+
reference.send.serialize({ controlDirectory }),
|
|
68
|
+
reference.send.serialize({ controlDirectory }),
|
|
69
|
+
])
|
|
70
|
+
const workers = [spawnWorker(), spawnWorker()]
|
|
71
|
+
await Promise.all(workers.map(({ finished }) => finished))
|
|
72
|
+
await Promise.all(messages.map((message) => message.result()))
|
|
73
|
+
const events = await readJsonLines(
|
|
74
|
+
join(controlDirectory, "serialization.jsonl"),
|
|
75
|
+
parseSerializationEvent,
|
|
76
|
+
)
|
|
77
|
+
assert.equal(events.length, 4)
|
|
78
|
+
const starts = events.filter((event) => event.event === "start")
|
|
79
|
+
const finishes = events.filter((event) => event.event === "finish")
|
|
80
|
+
assert.equal(starts.length, 2)
|
|
81
|
+
assert.equal(finishes.length, 2)
|
|
82
|
+
assert(Number(starts[1]?.at) >= Number(finishes[0]?.at))
|
|
83
|
+
const snapshot = await reference.snapshot()
|
|
84
|
+
assert.equal(snapshot.count, 2)
|
|
85
|
+
return { finalState: snapshot.count, overlap: false }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function proveCrashRecovery(): Promise<{
|
|
89
|
+
attempts: number
|
|
90
|
+
finalState: number
|
|
91
|
+
repeatedEffects: number
|
|
92
|
+
}> {
|
|
93
|
+
const controlDirectory = join(directory, "crash")
|
|
94
|
+
await mkdir(controlDirectory)
|
|
95
|
+
const reference = runtime.ref(RecoveryCounter, "crash")
|
|
96
|
+
const message = await reference.send.recover({ controlDirectory })
|
|
97
|
+
const firstWorker = spawnWorker()
|
|
98
|
+
await firstWorker.waitFor((entry) => entry.event === "operation.started" && entry.attempt === 1)
|
|
99
|
+
firstWorker.child.kill("SIGKILL")
|
|
100
|
+
await firstWorker.finished.catch(() => undefined)
|
|
101
|
+
await wait(350)
|
|
102
|
+
const recoveryWorker = spawnWorker()
|
|
103
|
+
await recoveryWorker.finished
|
|
104
|
+
await message.result()
|
|
105
|
+
return recoveryResult({ reference, message, controlDirectory })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function proveFencing(): Promise<{
|
|
109
|
+
attempts: number
|
|
110
|
+
finalState: number
|
|
111
|
+
repeatedEffects: number
|
|
112
|
+
}> {
|
|
113
|
+
const controlDirectory = join(directory, "fencing")
|
|
114
|
+
await mkdir(controlDirectory)
|
|
115
|
+
const reference = runtime.ref(RecoveryCounter, "fencing")
|
|
116
|
+
const message = await reference.send.recover({ controlDirectory })
|
|
117
|
+
const staleWorker = spawnWorker()
|
|
118
|
+
await staleWorker.waitFor((entry) => entry.event === "operation.started" && entry.attempt === 1)
|
|
119
|
+
staleWorker.child.kill("SIGSTOP")
|
|
120
|
+
await wait(350)
|
|
121
|
+
const recoveryWorker = spawnWorker()
|
|
122
|
+
await recoveryWorker.finished
|
|
123
|
+
await message.result()
|
|
124
|
+
staleWorker.child.kill("SIGCONT")
|
|
125
|
+
await writeFile(join(controlDirectory, "release-first-attempt"), "")
|
|
126
|
+
await staleWorker.waitFor((entry) => entry.event === "solid_objects.activation.lost")
|
|
127
|
+
await staleWorker.finished
|
|
128
|
+
return recoveryResult({ reference, message, controlDirectory })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function recoveryResult(options: {
|
|
132
|
+
reference: ActorReference<RecoveryCounter>
|
|
133
|
+
message: MessageReference
|
|
134
|
+
controlDirectory: string
|
|
135
|
+
}): Promise<{ attempts: number; finalState: number; repeatedEffects: number }> {
|
|
136
|
+
const stored = await runtime.repository.findMessage(options.message.id)
|
|
137
|
+
const attempts = Number(stored?.attempt_count)
|
|
138
|
+
const snapshot = await options.reference.snapshot()
|
|
139
|
+
const effects = await readJsonLines(
|
|
140
|
+
join(options.controlDirectory, "external-effects.jsonl"),
|
|
141
|
+
parseExternalEffectEvent,
|
|
142
|
+
)
|
|
143
|
+
assert.equal(attempts, 2)
|
|
144
|
+
assert.equal(snapshot.count, 1)
|
|
145
|
+
assert.equal(effects.length, 2)
|
|
146
|
+
return { attempts, finalState: snapshot.count, repeatedEffects: effects.length }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function spawnWorker(): {
|
|
150
|
+
child: ChildProcess
|
|
151
|
+
finished: Promise<void>
|
|
152
|
+
waitFor(predicate: (message: WorkerMessage) => boolean): Promise<WorkerMessage>
|
|
153
|
+
} {
|
|
154
|
+
const child = fork(fileURLToPath(new URL("./worker.ts", import.meta.url)), [databasePath], {
|
|
155
|
+
cwd: fileURLToPath(new URL("../..", import.meta.url)),
|
|
156
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
157
|
+
})
|
|
158
|
+
const messages: WorkerMessage[] = []
|
|
159
|
+
const listeners = new Set<(message: WorkerMessage) => void>()
|
|
160
|
+
let stderr = ""
|
|
161
|
+
child.stderr?.on("data", (chunk) => {
|
|
162
|
+
stderr += chunk
|
|
163
|
+
})
|
|
164
|
+
child.on("message", (message: WorkerMessage) => {
|
|
165
|
+
messages.push(message)
|
|
166
|
+
for (const listener of listeners) listener(message)
|
|
167
|
+
})
|
|
168
|
+
const finished = new Promise<void>((resolvePromise, reject) => {
|
|
169
|
+
child.once("error", reject)
|
|
170
|
+
child.once("exit", (code, signal) => {
|
|
171
|
+
if (code === 0) {
|
|
172
|
+
resolvePromise()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
reject(new Error(`worker exited with code ${code} and signal ${signal}\n${stderr}`))
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
return {
|
|
179
|
+
child,
|
|
180
|
+
finished,
|
|
181
|
+
waitFor: (predicate) => {
|
|
182
|
+
const existing = messages.find(predicate)
|
|
183
|
+
if (existing) return Promise.resolve(existing)
|
|
184
|
+
return new Promise((resolvePromise) => {
|
|
185
|
+
const listener = (message: WorkerMessage) => {
|
|
186
|
+
if (!predicate(message)) return
|
|
187
|
+
listeners.delete(listener)
|
|
188
|
+
resolvePromise(message)
|
|
189
|
+
}
|
|
190
|
+
listeners.add(listener)
|
|
191
|
+
})
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readJsonLines<Value>(
|
|
197
|
+
path: string,
|
|
198
|
+
parse: (line: string) => Value,
|
|
199
|
+
): Promise<Value[]> {
|
|
200
|
+
return (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).map(parse)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseSerializationEvent(line: string): SerializationEvent {
|
|
204
|
+
const event = JSON.parse(line) as Partial<SerializationEvent>
|
|
205
|
+
if (
|
|
206
|
+
(event.event !== "start" && event.event !== "finish") ||
|
|
207
|
+
typeof event.messageId !== "string" ||
|
|
208
|
+
typeof event.at !== "number"
|
|
209
|
+
) {
|
|
210
|
+
throw new TypeError("invalid serialization event")
|
|
211
|
+
}
|
|
212
|
+
return { event: event.event, messageId: event.messageId, at: event.at }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseExternalEffectEvent(line: string): ExternalEffectEvent {
|
|
216
|
+
const event = JSON.parse(line) as Partial<ExternalEffectEvent>
|
|
217
|
+
if (
|
|
218
|
+
typeof event.messageId !== "string" ||
|
|
219
|
+
typeof event.attempt !== "number" ||
|
|
220
|
+
typeof event.processId !== "number"
|
|
221
|
+
) {
|
|
222
|
+
throw new TypeError("invalid external effect event")
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
messageId: event.messageId,
|
|
226
|
+
attempt: event.attempt,
|
|
227
|
+
processId: event.processId,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function wait(milliseconds: number): Promise<void> {
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
233
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createRuntime } from "solid-objects"
|
|
2
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
3
|
+
import { RecoveryCounter } from "./actor.ts"
|
|
4
|
+
|
|
5
|
+
const databasePath = requiredArgument(2)
|
|
6
|
+
const runtime = createRuntime({
|
|
7
|
+
database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
|
|
8
|
+
pollingIntervalMilliseconds: 10,
|
|
9
|
+
leaseDurationMilliseconds: 250,
|
|
10
|
+
leaseRenewalIntervalMilliseconds: 50,
|
|
11
|
+
processHeartbeatIntervalMilliseconds: 75,
|
|
12
|
+
processAliveThresholdMilliseconds: 300,
|
|
13
|
+
workerCount: 1,
|
|
14
|
+
effectWorkerCount: 0,
|
|
15
|
+
reminderSchedulerCount: 0,
|
|
16
|
+
retentionIntervalMilliseconds: 0,
|
|
17
|
+
deadProcessCleanupIntervalMilliseconds: 0,
|
|
18
|
+
authorizeMessage: () => true,
|
|
19
|
+
authorizeQuery: () => true,
|
|
20
|
+
authorizeAdministration: () => true,
|
|
21
|
+
instrumentation: ({ name, attributes }) => {
|
|
22
|
+
process.send?.({ event: name, attributes })
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
runtime.register(RecoveryCounter)
|
|
27
|
+
await runtime.install()
|
|
28
|
+
const worker = runtime.worker()
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
let processed = 0
|
|
32
|
+
for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
|
|
33
|
+
processed = await worker.runOnce({ activationRetention: "release" })
|
|
34
|
+
if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
35
|
+
}
|
|
36
|
+
process.send?.({ event: "worker.finished", processed })
|
|
37
|
+
} finally {
|
|
38
|
+
await worker.stop()
|
|
39
|
+
await runtime.close()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requiredArgument(index: number): string {
|
|
43
|
+
const value = process.argv[index]
|
|
44
|
+
if (!value) throw new TypeError(`argument ${index - 1} is required`)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join, resolve } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
import { Actor, createRuntime } from "solid-objects"
|
|
8
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
9
|
+
|
|
10
|
+
class Counter extends Actor {
|
|
11
|
+
static override readonly actorType = "QuickstartCounter"
|
|
12
|
+
|
|
13
|
+
count = 0
|
|
14
|
+
|
|
15
|
+
increment(): number {
|
|
16
|
+
this.count += 1
|
|
17
|
+
return this.count
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async pause({ milliseconds }: { milliseconds: number }): Promise<{
|
|
21
|
+
startedAt: number
|
|
22
|
+
finishedAt: number
|
|
23
|
+
}> {
|
|
24
|
+
const startedAt = performance.now()
|
|
25
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))
|
|
26
|
+
return { startedAt, finishedAt: performance.now() }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runQuickstart(
|
|
31
|
+
options: {
|
|
32
|
+
signal?: AbortSignal
|
|
33
|
+
write?: (value: string) => void
|
|
34
|
+
} = {},
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const directory = await mkdtemp(join(tmpdir(), "solid-objects-quickstart-"))
|
|
37
|
+
const databasePath = join(directory, "state.sqlite3")
|
|
38
|
+
const runtime = createRuntime({
|
|
39
|
+
database: sqlite({ path: databasePath }),
|
|
40
|
+
workerCount: 2,
|
|
41
|
+
effectWorkerCount: 0,
|
|
42
|
+
reminderSchedulerCount: 0,
|
|
43
|
+
retentionIntervalMilliseconds: 0,
|
|
44
|
+
deadProcessCleanupIntervalMilliseconds: 0,
|
|
45
|
+
authorizeMessage: () => true,
|
|
46
|
+
authorizeQuery: () => true,
|
|
47
|
+
})
|
|
48
|
+
const shutdown = new AbortController()
|
|
49
|
+
const abort = () => shutdown.abort(options.signal?.reason)
|
|
50
|
+
options.signal?.addEventListener("abort", abort, { once: true })
|
|
51
|
+
let running: Promise<void> | undefined
|
|
52
|
+
let sameIdentityFinalState = 0
|
|
53
|
+
let independentIdentitiesOverlapped = false
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
runtime.register(Counter)
|
|
57
|
+
await runtime.install()
|
|
58
|
+
running = runtime.run(shutdown.signal)
|
|
59
|
+
|
|
60
|
+
const counter = runtime.ref(Counter, "room-1")
|
|
61
|
+
const results = await Promise.all(Array.from({ length: 25 }, () => counter.increment()))
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
[...results].sort((left, right) => left - right),
|
|
64
|
+
Array.from({ length: 25 }, (_value, index) => index + 1),
|
|
65
|
+
)
|
|
66
|
+
sameIdentityFinalState = await counter.count
|
|
67
|
+
assert.equal(sameIdentityFinalState, 25)
|
|
68
|
+
|
|
69
|
+
const pauses = await Promise.all([
|
|
70
|
+
runtime.ref(Counter, "room-2").send.pause({ milliseconds: 100 }),
|
|
71
|
+
runtime.ref(Counter, "room-3").send.pause({ milliseconds: 100 }),
|
|
72
|
+
])
|
|
73
|
+
const windows = await Promise.all(
|
|
74
|
+
pauses.map((message) => message.wait({ timeoutMilliseconds: 5_000 })),
|
|
75
|
+
)
|
|
76
|
+
const firstWindow = windows[0]
|
|
77
|
+
const secondWindow = windows[1]
|
|
78
|
+
if (!firstWindow || !secondWindow) throw new Error("two execution windows are required")
|
|
79
|
+
independentIdentitiesOverlapped =
|
|
80
|
+
firstWindow.startedAt < secondWindow.finishedAt &&
|
|
81
|
+
secondWindow.startedAt < firstWindow.finishedAt
|
|
82
|
+
assert.equal(independentIdentitiesOverlapped, true)
|
|
83
|
+
} finally {
|
|
84
|
+
shutdown.abort()
|
|
85
|
+
await running
|
|
86
|
+
await runtime.close()
|
|
87
|
+
options.signal?.removeEventListener("abort", abort)
|
|
88
|
+
await rm(directory, { recursive: true })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
assert.equal(existsSync(directory), false)
|
|
92
|
+
const write = options.write ?? ((value: string) => process.stdout.write(value))
|
|
93
|
+
write(
|
|
94
|
+
`${JSON.stringify(
|
|
95
|
+
{
|
|
96
|
+
sameIdentityCalls: 25,
|
|
97
|
+
sameIdentityFinalState,
|
|
98
|
+
independentIdentitiesOverlapped,
|
|
99
|
+
temporaryStateRemoved: true,
|
|
100
|
+
},
|
|
101
|
+
null,
|
|
102
|
+
2,
|
|
103
|
+
)}\n`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
108
|
+
await runQuickstart()
|
|
109
|
+
}
|