solid-objects 0.14.1 → 0.14.2

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.2 - 2026-08-24
4
+
5
+ - State that background pickup needs `runtime.run(signal)`
6
+ ([#22](https://github.com/cardmagic/solid-objects-js/issues/22)). The
7
+ README's programming-model example works without it because the caller's
8
+ own path executes the call, and nothing on that page said that a process
9
+ which installs and then waits claims nothing. An external prober built a
10
+ two-process harness from the README and read the unclaimed messages as
11
+ stranded. The README and `docs/operations.md` now state it, and
12
+ `test/background-pickup.test.ts` pins it: a sent message reads `ready`
13
+ after `install()`, and `completed` once `run(signal)` starts the roles.
14
+ - Build the same example with `configure()` and address the actor as
15
+ `Cart.ref("cart-123")`, matching every other reference example in the
16
+ documentation. `createRuntime()` deliberately leaves the process default
17
+ unset, so the static form needs `configure()`.
18
+ - Add `examples/at-least-once` and `pnpm run test:at-least-once`
19
+ ([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an
20
+ executable proof that the at-least-once clause fires and that the
21
+ documented remedy absorbs it. An effect worker crashes between the
22
+ external sink write and the acknowledgement; after restart the sink
23
+ reads 2 with deduplication off, and 1 when a guard on the stable
24
+ effect id is in place. The state commit happens exactly once in both
25
+ runs, and both deliveries carry the same effect id. CI runs the demo
26
+ alongside the recovery demo.
27
+
3
28
  ## 0.14.1 - 2026-08-23
4
29
 
5
30
  - Add `solid-objects/signals`, live signals on actor references
package/README.md CHANGED
@@ -39,7 +39,7 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser).
39
39
  ## The programming model
40
40
 
41
41
  ```typescript
42
- import { Actor, createRuntime } from "solid-objects"
42
+ import { Actor, configure } from "solid-objects"
43
43
  import { sqlite } from "solid-objects/database/sqlite"
44
44
 
45
45
  class Cart extends Actor {
@@ -53,7 +53,7 @@ class Cart extends Actor {
53
53
  }
54
54
  }
55
55
 
56
- const runtime = createRuntime({
56
+ const runtime = configure({
57
57
  database: sqlite({ path: "cart.sqlite3" }),
58
58
  authorizeMessage: () => true,
59
59
  authorizeQuery: () => true,
@@ -62,7 +62,7 @@ const runtime = createRuntime({
62
62
  await runtime.install()
63
63
 
64
64
  try {
65
- const cart = runtime.ref(Cart, "cart-123")
65
+ const cart = Cart.ref("cart-123")
66
66
  await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
67
67
  } finally {
68
68
  await runtime.close()
@@ -73,6 +73,18 @@ Both calls enter the durable mailbox for `cart-123`. They execute in order and
73
73
  commit one state transition at a time, even when different requests or Node.js
74
74
  processes submit them concurrently.
75
75
 
76
+ `install()` prepares the database and starts nothing. The example above finishes
77
+ because the caller's own path executes each call. A process serves background
78
+ work only after `runtime.run(signal)` starts its roles, so a process that
79
+ installs and then waits never claims a ready message. Nothing is lost while no
80
+ process runs. The message stays ready until one does.
81
+
82
+ ```typescript
83
+ const controller = new AbortController()
84
+ process.on("SIGTERM", () => controller.abort())
85
+ await runtime.run(controller.signal)
86
+ ```
87
+
76
88
  ## Run it now with SQLite
77
89
 
78
90
  Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.14.1";
1
+ export declare const VERSION = "0.14.2";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.14.1";
1
+ export const VERSION = "0.14.2";
2
2
  //# sourceMappingURL=version.js.map
@@ -58,7 +58,12 @@
58
58
 
59
59
  - At-least-once execution means actor code may begin more than once. State and
60
60
  staged intents from a failed turn roll back, but arbitrary external work does
61
- not. External systems need stable idempotency keys.
61
+ not. External systems need stable idempotency keys. This clause is
62
+ observable, not decorative: `pnpm run test:at-least-once` crashes an
63
+ effect worker between the sink write and the acknowledgement, restarts
64
+ it, and shows the sink reading 2 with deduplication off — then shows a
65
+ guard on the stable effect id absorbing the same duplicate, with the
66
+ sink reading 1. The state commit happens exactly once in both runs.
62
67
  - The activation fence protects the Solid Objects commit. It cannot revoke or
63
68
  undo network calls, files, emails, payments, or other external effects.
64
69
  - One identity processes one write operation at a time. This is the ordering
@@ -1,5 +1,12 @@
1
1
  # Operations
2
2
 
3
+ `install()` prepares the database and starts nothing. A process serves
4
+ background work only after `runtime.run(signal)` starts its roles. A process
5
+ that registers actors, installs, and then waits never claims a ready message,
6
+ and work enqueued with `send` stays ready until some process runs the roles.
7
+ A direct call or an explicit `sync` needs no running role, because the caller's
8
+ own path executes it.
9
+
3
10
  Runtime roles use durable polling as the correctness fallback. Consecutive
4
11
  empty passes double each role's wait from `pollingIntervalMilliseconds` to
5
12
  `idlePollingIntervalMilliseconds`, which defaults to one second. Processed
@@ -0,0 +1,13 @@
1
+ import { Actor } from "solid-objects"
2
+
3
+ export class DeliveryCounter extends Actor {
4
+ static override readonly actorType = "DeliveryCounter"
5
+
6
+ count = 0
7
+
8
+ deliver(): number {
9
+ this.count += 1
10
+ this.emit("record", { arguments: {} })
11
+ return this.count
12
+ }
13
+ }
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict"
2
+ import { fork, type ChildProcess } from "node:child_process"
3
+ import { mkdtemp, rm } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+ import { createRuntime } from "solid-objects"
8
+ import { sqlite } from "solid-objects/database/sqlite"
9
+ import { DeliveryCounter } from "./actor.ts"
10
+ import { readSink } from "./sink.ts"
11
+
12
+ const directory = await mkdtemp(join(tmpdir(), "solid-objects-at-least-once-"))
13
+ const databasePath = join(directory, "state.sqlite3")
14
+ const runtime = createRuntime({
15
+ database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
16
+ leaseDurationMilliseconds: 250,
17
+ leaseRenewalIntervalMilliseconds: 50,
18
+ processHeartbeatIntervalMilliseconds: 75,
19
+ processAliveThresholdMilliseconds: 300,
20
+ workerCount: 1,
21
+ effectWorkerCount: 0,
22
+ reminderSchedulerCount: 0,
23
+ retentionIntervalMilliseconds: 0,
24
+ deadProcessCleanupIntervalMilliseconds: 0,
25
+ authorizeMessage: () => true,
26
+ authorizeQuery: () => true,
27
+ authorizeAdministration: () => true,
28
+ })
29
+
30
+ try {
31
+ runtime.register(DeliveryCounter)
32
+ await runtime.install()
33
+ const duplicate = await proveDuplicateAtSink()
34
+ const remedy = await proveDeduplicationAbsorbsIt()
35
+ process.stdout.write(`${JSON.stringify({ duplicate, remedy }, null, 2)}\n`)
36
+ } finally {
37
+ await runtime.close()
38
+ await rm(directory, { recursive: true })
39
+ }
40
+
41
+ async function proveDuplicateAtSink(): Promise<{
42
+ stateCommits: number
43
+ sinkDeliveries: number
44
+ sameEffectId: boolean
45
+ attempts: number[]
46
+ }> {
47
+ const sinkPath = join(directory, "sink-dedup-off.json")
48
+ await stageOneDelivery("dedup-off")
49
+ await crashThenRecover({ sinkPath, deduplicate: "off" })
50
+
51
+ const sink = await readSink(sinkPath)
52
+ const snapshot = await runtime.ref(DeliveryCounter, "dedup-off").snapshot()
53
+ assert.equal(snapshot.count, 1, "the state commit happened exactly once")
54
+ assert.equal(sink.deliveries.length, 2, "the sink observed the duplicate")
55
+ assert.equal(
56
+ sink.deliveries[0]?.effectId,
57
+ sink.deliveries[1]?.effectId,
58
+ "both deliveries carried the same stable effect id",
59
+ )
60
+ return {
61
+ stateCommits: snapshot.count,
62
+ sinkDeliveries: sink.deliveries.length,
63
+ sameEffectId: sink.deliveries[0]?.effectId === sink.deliveries[1]?.effectId,
64
+ attempts: sink.deliveries.map((delivery) => delivery.attempt),
65
+ }
66
+ }
67
+
68
+ async function proveDeduplicationAbsorbsIt(): Promise<{
69
+ stateCommits: number
70
+ sinkDeliveries: number
71
+ }> {
72
+ const sinkPath = join(directory, "sink-dedup-on.json")
73
+ await stageOneDelivery("dedup-on")
74
+ await crashThenRecover({ sinkPath, deduplicate: "on" })
75
+
76
+ const sink = await readSink(sinkPath)
77
+ const snapshot = await runtime.ref(DeliveryCounter, "dedup-on").snapshot()
78
+ assert.equal(snapshot.count, 1, "the state commit happened exactly once")
79
+ assert.equal(sink.deliveries.length, 1, "the stable effect id absorbed the duplicate")
80
+ return { stateCommits: snapshot.count, sinkDeliveries: sink.deliveries.length }
81
+ }
82
+
83
+ async function stageOneDelivery(actorId: string): Promise<void> {
84
+ const message = await runtime.ref(DeliveryCounter, actorId).send.deliver()
85
+ const worker = runtime.worker()
86
+ try {
87
+ let processed = 0
88
+ for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
89
+ processed = await worker.runOnce({ activationRetention: "release" })
90
+ if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
91
+ }
92
+ assert.equal(processed, 1, "the actor turn committed and staged the effect")
93
+ } finally {
94
+ await worker.stop()
95
+ }
96
+ assert.equal(await message.status(), "completed")
97
+ }
98
+
99
+ async function crashThenRecover(options: {
100
+ sinkPath: string
101
+ deduplicate: "on" | "off"
102
+ }): Promise<void> {
103
+ const crashing = spawnEffectWorker({ ...options, mode: "crash" })
104
+ const crashExit = await crashing.finished
105
+ assert.equal(crashExit, 1, "the first delivery crashed before acknowledgement")
106
+
107
+ await new Promise((resolve) => setTimeout(resolve, 400))
108
+
109
+ const recovering = spawnEffectWorker({ ...options, mode: "complete" })
110
+ const recoveryExit = await recovering.finished
111
+ assert.equal(recoveryExit, 0, "the second delivery completed and acknowledged")
112
+ }
113
+
114
+ function spawnEffectWorker(options: {
115
+ sinkPath: string
116
+ deduplicate: "on" | "off"
117
+ mode: "crash" | "complete"
118
+ }): { child: ChildProcess; finished: Promise<number | null> } {
119
+ const child = fork(
120
+ fileURLToPath(new URL("./effect-worker.ts", import.meta.url)),
121
+ [databasePath, options.sinkPath, options.mode, options.deduplicate],
122
+ { stdio: ["ignore", "inherit", "inherit", "ipc"] },
123
+ )
124
+ const finished = new Promise<number | null>((resolve, reject) => {
125
+ child.once("exit", (code) => resolve(code))
126
+ child.once("error", reject)
127
+ })
128
+ return { child, finished }
129
+ }
@@ -0,0 +1,62 @@
1
+ import { createRuntime } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+ import { DeliveryCounter } from "./actor.ts"
4
+ import { recordDelivery } from "./sink.ts"
5
+
6
+ const databasePath = requiredArgument(2)
7
+ const sinkPath = requiredArgument(3)
8
+ const mode = requiredArgument(4)
9
+ const deduplicate = requiredArgument(5) === "on"
10
+
11
+ const runtime = createRuntime({
12
+ database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
13
+ pollingIntervalMilliseconds: 10,
14
+ leaseDurationMilliseconds: 250,
15
+ leaseRenewalIntervalMilliseconds: 50,
16
+ processHeartbeatIntervalMilliseconds: 75,
17
+ processAliveThresholdMilliseconds: 300,
18
+ workerCount: 0,
19
+ effectWorkerCount: 1,
20
+ reminderSchedulerCount: 0,
21
+ retentionIntervalMilliseconds: 0,
22
+ deadProcessCleanupIntervalMilliseconds: 0,
23
+ authorizeMessage: () => true,
24
+ authorizeQuery: () => true,
25
+ authorizeAdministration: () => true,
26
+ })
27
+
28
+ runtime.register(DeliveryCounter)
29
+ runtime.registerEffect("record", async (_argumentsValue, context) => {
30
+ const { applied } = await recordDelivery({
31
+ path: sinkPath,
32
+ effectId: context.id,
33
+ attempt: context.attempt,
34
+ deduplicate,
35
+ })
36
+ process.send?.({ event: "sink.recorded", effectId: context.id, applied })
37
+ if (mode === "crash") {
38
+ process.exit(1)
39
+ }
40
+ return null
41
+ })
42
+ await runtime.install()
43
+ const effectWorker = runtime.effectWorker()
44
+
45
+ try {
46
+ let processed = 0
47
+ for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
48
+ processed = await effectWorker.runOnce()
49
+ if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
50
+ }
51
+ if (processed === 0) throw new Error("no effect became claimable")
52
+ process.send?.({ event: "effects.finished", processed })
53
+ } finally {
54
+ await effectWorker.stop()
55
+ await runtime.close()
56
+ }
57
+
58
+ function requiredArgument(index: number): string {
59
+ const value = process.argv[index]
60
+ if (!value) throw new TypeError(`argument ${index - 1} is required`)
61
+ return value
62
+ }
@@ -0,0 +1,34 @@
1
+ import { readFile, writeFile } from "node:fs/promises"
2
+
3
+ export interface SinkDelivery {
4
+ effectId: string
5
+ attempt: number
6
+ }
7
+
8
+ export interface SinkState {
9
+ deliveries: SinkDelivery[]
10
+ }
11
+
12
+ export async function readSink(path: string): Promise<SinkState> {
13
+ try {
14
+ const parsed: SinkState = JSON.parse(await readFile(path, "utf-8"))
15
+ return parsed
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { deliveries: [] }
18
+ throw error
19
+ }
20
+ }
21
+
22
+ export async function recordDelivery(options: {
23
+ path: string
24
+ effectId: string
25
+ attempt: number
26
+ deduplicate: boolean
27
+ }): Promise<{ applied: boolean }> {
28
+ const sink = await readSink(options.path)
29
+ const seen = sink.deliveries.some((delivery) => delivery.effectId === options.effectId)
30
+ if (options.deduplicate && seen) return { applied: false }
31
+ sink.deliveries.push({ effectId: options.effectId, attempt: options.attempt })
32
+ await writeFile(options.path, JSON.stringify(sink, null, 2))
33
+ return { applied: true }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Race-free realtime state per application identity, backed by your SQL database",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -107,6 +107,7 @@
107
107
  "test:mysql": "vitest run test/mysql.test.ts",
108
108
  "test:package": "node scripts/release-artifact-smoke.mjs",
109
109
  "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
110
+ "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",
110
111
  "test:redis": "vitest run test/redis-wake-up.test.ts",
111
112
  "test:watch": "vitest",
112
113
  "benchmark": "pnpm run build && node benchmarks/run.ts",