From 08f0df66b5e6efdc13642add6495a23a34564708 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 24 Aug 2026 14:53:02 -0700 Subject: [PATCH 1/2] docs: say background pickup needs run(signal) install() migrates the schema and starts nothing. The README's programming-model example completes without run() because the caller's own path executes the call, so the page never had to mention roles. A reader who builds a two-process harness from it hits a process that registers actors, installs, and then waits forever, because nothing polls. An external prober did exactly that against 0.14.0 and read the unclaimed messages as stranded. The README now says it after the model example, with the AbortController shape a real process uses, and docs/operations.md says it in the first paragraph. Both add the part that keeps the reader calm: nothing is lost while no process runs, the message stays ready until one does. test/background-pickup.test.ts keeps the sentence honest. A sent message reads ready 200ms after install, then completed once run(signal) starts the roles. Driving a worker before the first assertion turns it red, so the assertion detects pickup rather than asserting a constant. The same example addressed its actor as runtime.ref(Cart, "cart-123") while every other reference example in the README uses the static form. It now reads Cart.ref("cart-123"). Closes #22. --- CHANGELOG.md | 12 +++++++ README.md | 14 +++++++- docs/operations.md | 7 ++++ test/background-pickup.test.ts | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 test/background-pickup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7bc66..d473207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- State that background pickup needs `runtime.run(signal)` + ([#22](https://github.com/cardmagic/solid-objects-js/issues/22)). The + README's programming-model example works without it because the caller's + own path executes the call, and nothing on that page said that a process + which installs and then waits claims nothing. An external prober built a + two-process harness from the README and read the unclaimed messages as + stranded. The README and `docs/operations.md` now state it, and + `test/background-pickup.test.ts` pins it: a sent message reads `ready` + after `install()`, and `completed` once `run(signal)` starts the roles. +- Address the same example with `Cart.ref("cart-123")` instead of + `runtime.ref(Cart, "cart-123")`, matching every other reference example. + - Add `examples/at-least-once` and `pnpm run test:at-least-once` ([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an executable proof that the at-least-once clause fires and that the diff --git a/README.md b/README.md index 99a58ac..7cbdcbf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ const runtime = createRuntime({ await runtime.install() try { - const cart = runtime.ref(Cart, "cart-123") + const cart = Cart.ref("cart-123") await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })]) } finally { await runtime.close() @@ -73,6 +73,18 @@ Both calls enter the durable mailbox for `cart-123`. They execute in order and commit one state transition at a time, even when different requests or Node.js processes submit them concurrently. +`install()` prepares the database and starts nothing. The example above finishes +because the caller's own path executes each call. A process serves background +work only after `runtime.run(signal)` starts its roles, so a process that +installs and then waits never claims a ready message. Nothing is lost while no +process runs. The message stays ready until one does. + +```typescript +const controller = new AbortController() +process.on("SIGTERM", () => controller.abort()) +await runtime.run(controller.signal) +``` + ## Run it now with SQLite Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred, diff --git a/docs/operations.md b/docs/operations.md index 89b4199..e17def4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,5 +1,12 @@ # Operations +`install()` prepares the database and starts nothing. A process serves +background work only after `runtime.run(signal)` starts its roles. A process +that registers actors, installs, and then waits never claims a ready message, +and work enqueued with `send` stays ready until some process runs the roles. +A direct call or an explicit `sync` needs no running role, because the caller's +own path executes it. + Runtime roles use durable polling as the correctness fallback. Consecutive empty passes double each role's wait from `pollingIntervalMilliseconds` to `idlePollingIntervalMilliseconds`, which defaults to one second. Processed diff --git a/test/background-pickup.test.ts b/test/background-pickup.test.ts new file mode 100644 index 0000000..90e8139 --- /dev/null +++ b/test/background-pickup.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest" +import { Actor } from "../src/actor.js" +import { sqlite } from "../src/database/sqlite.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" + +class Mailbox extends Actor { + static override readonly actorType = "BackgroundPickupMailbox" + + delivered = 0 + + receive(): number { + this.delivered += 1 + return this.delivered + } +} + +let runtime: SolidObjectsRuntime | undefined + +afterEach(async () => { + await runtime?.close() + runtime = undefined +}) + +function startedRuntime(): SolidObjectsRuntime { + return createRuntime({ + database: sqlite({ path: ":memory:" }), + pollingIntervalMilliseconds: 10, + workerCount: 1, + effectWorkerCount: 0, + reminderSchedulerCount: 0, + retentionIntervalMilliseconds: 0, + deadProcessCleanupIntervalMilliseconds: 0, + authorizeMessage: () => true, + authorizeQuery: () => true, + }) +} + +describe("background pickup", () => { + it("leaves a message ready until run() starts the roles", async () => { + runtime = startedRuntime() + runtime.register(Mailbox) + await runtime.install() + + const message = await runtime.ref(Mailbox, "inbox").send.receive() + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(await message.status()).toBe("ready") + + const controller = new AbortController() + const running = runtime.run(controller.signal) + try { + await message.wait({ timeoutMilliseconds: 2_000 }) + } finally { + controller.abort() + await running + } + + expect(await message.status()).toBe("completed") + }) +}) From d6bfcf1c4864917f843029fd5967e34a9720138d Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 24 Aug 2026 15:02:08 -0700 Subject: [PATCH 2/2] fix: make the model example configure the default runtime Greptile caught that the example addressed Cart.ref("cart-123") while building the runtime with createRuntime(), which deliberately leaves the process default unset. A reader running the page as written would get "SolidObjects.configure must be called before Actor.ref" before either cart operation ran. Verified by running both variants against a built package: configure() prints [ 1, 2 ], createRuntime() throws. The example now calls configure(), which is what the browser quickstart further down the page already does, so both examples address actors the same way. The pickup test used message.wait() to observe the second phase, and wait() drives a caller worker through runOnce(). That path could have completed the message even if the roles started by run(signal) never claimed it, which is the one thing the test exists to prove. It now polls message.status(), a read that processes nothing. Replacing run(signal) with a resolved promise leaves the status at ready, so the completion is the roles' work and nothing else. --- CHANGELOG.md | 6 ++++-- README.md | 4 ++-- test/background-pickup.test.ts | 20 ++++++++++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d473207..a1136cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ stranded. The README and `docs/operations.md` now state it, and `test/background-pickup.test.ts` pins it: a sent message reads `ready` after `install()`, and `completed` once `run(signal)` starts the roles. -- Address the same example with `Cart.ref("cart-123")` instead of - `runtime.ref(Cart, "cart-123")`, matching every other reference example. +- Build the same example with `configure()` and address the actor as + `Cart.ref("cart-123")`, matching every other reference example in the + documentation. `createRuntime()` deliberately leaves the process default + unset, so the static form needs `configure()`. - Add `examples/at-least-once` and `pnpm run test:at-least-once` ([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an diff --git a/README.md b/README.md index 7cbdcbf..d557c5d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser). ## The programming model ```typescript -import { Actor, createRuntime } from "solid-objects" +import { Actor, configure } from "solid-objects" import { sqlite } from "solid-objects/database/sqlite" class Cart extends Actor { @@ -53,7 +53,7 @@ class Cart extends Actor { } } -const runtime = createRuntime({ +const runtime = configure({ database: sqlite({ path: "cart.sqlite3" }), authorizeMessage: () => true, authorizeQuery: () => true, diff --git a/test/background-pickup.test.ts b/test/background-pickup.test.ts index 90e8139..9efec19 100644 --- a/test/background-pickup.test.ts +++ b/test/background-pickup.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from "vitest" import { Actor } from "../src/actor.js" import { sqlite } from "../src/database/sqlite.js" +import type { MessageReference } from "../src/reference.js" +import type { MessageStatus } from "../src/types.js" import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" class Mailbox extends Actor { @@ -48,13 +50,27 @@ describe("background pickup", () => { const controller = new AbortController() const running = runtime.run(controller.signal) + let observed: MessageStatus try { - await message.wait({ timeoutMilliseconds: 2_000 }) + observed = await pollStatus({ message, timeoutMilliseconds: 2_000 }) } finally { controller.abort() await running } - expect(await message.status()).toBe("completed") + expect(observed).toBe("completed") }) }) + +async function pollStatus(options: { + message: MessageReference + timeoutMilliseconds: number +}): Promise { + const deadline = performance.now() + options.timeoutMilliseconds + let status = await options.message.status() + while (status !== "completed" && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)) + status = await options.message.status() + } + return status +}