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
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.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.12.0` implementation has spiritual parity with that reference. Its
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 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
 
@@ -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 return through actor operations. |
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
- | 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 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; only changed projected values enter the broadcast outbox. |
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
@@ -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 `pnpm-lock.yaml` and move the
30
- release notes out of the Unreleased section in `CHANGELOG.md`.
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`, and
33
- `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).
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 contain lowercase letters, digits, and
120
- underscores. The turn rolls back, staged work is discarded, the message becomes
121
- `rejected`, and later mailbox work remains eligible. The caller receives
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
@@ -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
+ }