From ef9f0f1c851f35d8beec23e61931d3846fab9f03 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:59 -0700 Subject: [PATCH 1/4] feat!: fail a schedule call with no operation A turn that called schedule() and then called no operation on the result stored no reminder and gave no error. The hold or the timeout that the author intended did not exist, and nothing reported it. Three fresh agent sessions found this mistake in a published sample. The turn now fails with ScheduleWithoutOperation. The error is not retryable, because a retry cannot change the actor code. The turn can keep the result and call the operation later in the same turn. The Cloudflare backend uses the same turn code and has the same rule. The parity ledger records the rule, and it records the Ruby json gem check as not applicable to Node.js. BREAKING CHANGE: a turn with a schedule call that has no operation after it becomes a dead letter. Correct code does not change. --- docs/api.md | 23 +++- docs/errors-and-recovery.md | 2 + docs/parity.md | 36 +++--- src/actor.ts | 35 +++++- src/cloudflare/protocol.ts | 1 + src/errors.ts | 1 + src/index.ts | 1 + src/turn.ts | 1 + test/cloudflare/runtime.test.ts | 12 ++ test/cloudflare/worker.ts | 5 + test/schedule-without-operation.test.ts | 146 ++++++++++++++++++++++++ 11 files changed, 242 insertions(+), 21 deletions(-) create mode 100644 test/schedule-without-operation.test.ts diff --git a/docs/api.md b/docs/api.md index a0caea6..21da028 100644 --- a/docs/api.md +++ b/docs/api.md @@ -175,10 +175,27 @@ row per item. It also cannot strand an entry when the runtime coalesces an occurrence. Prefer it for a large queue of interchangeable items. Prefer `key` when one item needs an alarm that you can move on its own. +#### A schedule call needs an operation + +`schedule()` returns an object, and the reminder exists only after you call an +operation on that object. A turn that calls `schedule()` and then calls no +operation on the result fails with `ScheduleWithoutOperation`. The runtime +does not retry that turn, and the message becomes a dead letter. + +```typescript +// Wrong. The turn fails. +this.schedule({ at: holdEnd, key: buyer }) + +// Correct. +this.schedule({ at: holdEnd, key: buyer }).expire({ buyer }) +``` + +You can keep the result and call the operation later in the same turn. + #### Cancelling a reminder -`schedule()` returns a `ReminderHandle` (`{ name: string }`) naming the alarm it -armed. `unschedule()` cancels one alarm, by operation, by operation and key, or +The operation that you call on the result of `schedule()` returns a +`ReminderHandle` (`{ name: string }`) naming the alarm it armed. `unschedule()` cancels one alarm, by operation, by operation and key, or by that handle. `unscheduleAll()` cancels every key of one operation. ```typescript @@ -742,7 +759,7 @@ The root exports `SolidObjectsError` and its supported subclasses: - definition and execution failures: `InvalidActor`, `InvalidRejectionCode`, `UnknownActorType`, `UnknownOperation`, `ActorCallCycle`, `QueryMutatedState`, - `StateMigrationError`, `ApplicationWriteForbidden`, `UnknownEffect`, and + `ScheduleWithoutOperation`, `StateMigrationError`, `ApplicationWriteForbidden`, `UnknownEffect`, and `UnknownCommitAction`; - operational failures: `LostActivation`, `DatabaseDeadlineExceeded`, `UnknownDeadLetter`, `UnknownReminder`, `ReminderNotPaused`, and diff --git a/docs/errors-and-recovery.md b/docs/errors-and-recovery.md index aafc82a..859b954 100644 --- a/docs/errors-and-recovery.md +++ b/docs/errors-and-recovery.md @@ -50,6 +50,8 @@ These errors normally require a code or deployment correction: reference's `send`, or destroy an actor. Stage delivery with `this.sendTo()`. - `QueryMutatedState`: a field/getter query, snapshot, observable, or payload projection changed state or staged durable work. +- `ScheduleWithoutOperation`: a turn called `schedule()` and then called no + operation on the result, so the call stored no reminder. - `ApplicationWriteForbidden`: actor-owned code attempted a write through `guardApplicationDatabase()` outside the fenced commit-action connection. - `InvalidPayload`, `InvalidPayloadBroadcast`, and diff --git a/docs/parity.md b/docs/parity.md index b897e22..c35edee 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem. Parity preserves a capability and its correctness or security boundary. It does not copy a Rails API into Node. -Reference: Ruby `solid_objects` 0.17.5. The JavaScript package began at the Ruby +Reference: Ruby `solid_objects` 1.0.0. The JavaScript package began at the Ruby design's `0.12` capability generation; that version number did not imply earlier JavaScript releases. @@ -63,6 +63,7 @@ is needed for the JavaScript stale-result race fix. | 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. Typed callback envelopes are exported. | | Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. | | One-shot and recurring reminders | Native | Scheduling, cancellation by name/key/handle, read-your-writes inspection, catch-up policy, stale-claim recovery, pausing, and authorized resume. Cancellation cannot recall an already-enqueued occurrence. | +| Schedule call without an operation | Native | Both runtimes fail a turn that calls `schedule()` and then calls no operation on the result. The error is `ScheduleWithoutOperation`, which is not retried: the message becomes a dead letter, and the turn commits no state and no reminder. The turn can keep the result and call the operation later in the same turn. The Cloudflare backend applies the same rule. | | Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. | | Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. | | Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, migrations, and commit actions; only the supplied fenced commit-action connection may write. Unwrapped clients cannot be intercepted. | @@ -77,22 +78,23 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change ## Operations -| Capability | Status | TypeScript shape or remaining work | -| ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 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. | -| Failed-role replacement | Native | Built-in and registered roles are rebuilt through their factories with capped backoff; shutdown is the terminal replacement boundary. | -| Additional supervised components | Native | `registerComponent()` builds, validates, runs, and stops application components with the runtime. | -| Dead-letter inspection and retry | Native | `runtime.deadLetters` provides deny-by-default immutable inspection and idempotent durable retry linkage. | -| Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. | -| 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. Reused JS workers refresh their registration before each pass after cleanup or pruning; Ruby callers already reload and replace stale registrations. SQL instance pruning uses an index on actor type and update time. | -| 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. | -| CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. | -| Operator dashboard | Native | Fetch and Node/Connect mounting, authorized views and actions, CSRF, filtering, paging, charts, and extensions match the Rack dashboard. Both dashboards expose individual retries and pause/resume; bulk redrive is available through the administration APIs. Pause does not interrupt an in-flight turn. | -| Bulk redrive and administration audit | Native | Message, effect, and broadcast scopes support idempotent retry and durable, bounded, cancellable redrive. Retry and task transitions persist administration identity in audit rows. | -| Structured instrumentation | Native | Versioned events, metric samples, isolated observers, and bounded authorized diagnostics match Ruby. Both test suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add their turn's message fields. Both runtimes log exporter and observer failures, require observer callbacks, and cap local observers at 1,000. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer attributes, and does not send `sync.timeout`. | -| Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from `0.14.3`, as `warn_state_bytes`. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. | -| Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. | -| Clean-install artifact proof | Native | `pnpm run test:package` installs the packed tarball in a new project. It runs the packaged quickstart, which sends 25 concurrent calls to one identity, and the `examples/ticket-sale.ts` guide example. `pnpm run test:recovery` proves recovery after a worker process stops. Ruby's `rake quickstart` builds the gem, installs it in a new Rails application, and proves ordering and reminder recovery after a runtime restart. | +| Capability | Status | TypeScript shape or remaining work | +| ----------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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. | +| Failed-role replacement | Native | Built-in and registered roles are rebuilt through their factories with capped backoff; shutdown is the terminal replacement boundary. | +| Additional supervised components | Native | `registerComponent()` builds, validates, runs, and stops application components with the runtime. | +| Dead-letter inspection and retry | Native | `runtime.deadLetters` provides deny-by-default immutable inspection and idempotent durable retry linkage. | +| Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. | +| 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. Reused JS workers refresh their registration before each pass after cleanup or pruning; Ruby callers already reload and replace stale registrations. SQL instance pruning uses an index on actor type and update time. | +| 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. | +| `json` gem compatibility check | Not applicable | The Ruby doctor and the Rails boot decode one JSON document with Active Support, to find a `json` gem release that Active Support cannot use. Node.js has `JSON` built in, so this package has no such dependency. | +| CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. | +| Operator dashboard | Native | Fetch and Node/Connect mounting, authorized views and actions, CSRF, filtering, paging, charts, and extensions match the Rack dashboard. Both dashboards expose individual retries and pause/resume; bulk redrive is available through the administration APIs. Pause does not interrupt an in-flight turn. | +| Bulk redrive and administration audit | Native | Message, effect, and broadcast scopes support idempotent retry and durable, bounded, cancellable redrive. Retry and task transitions persist administration identity in audit rows. | +| Structured instrumentation | Native | Versioned events, metric samples, isolated observers, and bounded authorized diagnostics match Ruby. Both test suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add their turn's message fields. Both runtimes log exporter and observer failures, require observer callbacks, and cap local observers at 1,000. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer attributes, and does not send `sync.timeout`. | +| Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from `0.14.3`, as `warn_state_bytes`. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. | +| Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. | +| Clean-install artifact proof | Native | `pnpm run test:package` installs the packed tarball in a new project. It runs the packaged quickstart, which sends 25 concurrent calls to one identity, and the `examples/ticket-sale.ts` guide example. `pnpm run test:recovery` proves recovery after a worker process stops. Ruby's `rake quickstart` builds the gem, installs it in a new Rails application, and proves ordering and reminder recovery after a runtime restart. | ## Databases and wake-up diff --git a/src/actor.ts b/src/actor.ts index b1065c0..39fbf93 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -1,7 +1,12 @@ import { currentMessage, currentRuntime } from "./context.js" import { getDefaultRuntime } from "./default-runtime.js" import type { StateMigration } from "./definition.js" -import { InvalidRejectionCode, Rejected, UnknownOperation } from "./errors.js" +import { + InvalidRejectionCode, + Rejected, + ScheduleWithoutOperation, + UnknownOperation, +} from "./errors.js" import { TRANSMIT_EFFECT } from "./transmit-effect.js" import { randomUUID } from "./platform/uuid.js" import { @@ -163,6 +168,18 @@ export interface ReminderOptions { const REMINDER_NAME_LIMIT = 255 const REMINDER_KEY_SEPARATOR = ":" +interface ScheduleCall { + at: Date + key: string | undefined +} + +function scheduleCallArguments(call: ScheduleCall): string { + const at = `at: ${call.at.toISOString()}` + if (call.key === undefined) return `{ ${at} }` + + return `{ ${at}, key: ${JSON.stringify(call.key)} }` +} + function validatedReminderKey(key: string | number | undefined): string | undefined { if (key === undefined) return undefined @@ -256,6 +273,7 @@ export abstract class Actor { reminders: [], outboundMessages: [], } + readonly #openScheduleCalls = new Set() #operations: ReadonlySet = new Set() constructor(actorId = "") { @@ -384,8 +402,11 @@ export abstract class Actor { throw new TypeError("reminder interval must be positive") } const key = validatedReminderKey(options.key) + const call: ScheduleCall = { at: options.at, key } + this.#openScheduleCalls.add(call) return createStagedOperationMap(this.#operations, (operation, argumentsValue) => { + this.#openScheduleCalls.delete(call) const name = reminderName(operation, key) this.#intents.reminders.push({ name, @@ -525,6 +546,18 @@ export abstract class Actor { /** @internal */ discardIntents(): void { this.drainIntents() + this.#openScheduleCalls.clear() + } + + /** @internal */ + assertScheduleCallsHaveOperations(): void { + const [call] = this.#openScheduleCalls + if (!call) return + + throw new ScheduleWithoutOperation( + `schedule(${scheduleCallArguments(call)}) has no operation after it, so it stores no reminder. ` + + "Call an operation on the result, for example schedule({ at: time }).expire()", + ) } /** @internal */ diff --git a/src/cloudflare/protocol.ts b/src/cloudflare/protocol.ts index 10c4c53..913eab3 100644 --- a/src/cloudflare/protocol.ts +++ b/src/cloudflare/protocol.ts @@ -111,6 +111,7 @@ export function unwrapReply(reply: RpcReply): JsonValue { PayloadTooLarge: errors.PayloadTooLarge, InvalidPayload: errors.InvalidPayload, QueryMutatedState: errors.QueryMutatedState, + ScheduleWithoutOperation: errors.ScheduleWithoutOperation, ApplicationWriteForbidden: errors.ApplicationWriteForbidden, NonRetryableError: errors.NonRetryableError, UnsupportedCapability: errors.UnsupportedCapability, diff --git a/src/errors.ts b/src/errors.ts index 09a5260..a3605db 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -78,6 +78,7 @@ export class ActorSetupFailed extends SolidObjectsError { } export class ActorCallCycle extends NonRetryableError {} export class QueryMutatedState extends NonRetryableError {} +export class ScheduleWithoutOperation extends NonRetryableError {} export class ApplicationWriteForbidden extends NonRetryableError {} export class UnknownEffect extends NonRetryableError {} export class UnknownCommitAction extends NonRetryableError {} diff --git a/src/index.ts b/src/index.ts index 8633ef5..25c658f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,6 +200,7 @@ export { QueryMutatedState, Rejected, ReminderNotPaused, + ScheduleWithoutOperation, SolidObjectsError, StateMigrationError, SyncTimeout, diff --git a/src/turn.ts b/src/turn.ts index f34d5f7..7b57eb4 100644 --- a/src/turn.ts +++ b/src/turn.ts @@ -60,6 +60,7 @@ export async function evaluateActorTurn(options: { const rawResult = await withActorContext({ actor, runtime, message: options.message }, () => actor.invoke(operation, options.argumentsValue), ) + actor.assertScheduleCallsHaveOperations() const state = jsonObject(actorState(actor, definition.stateKeys), { maxBytes: options.maxStateBytes, }) diff --git a/test/cloudflare/runtime.test.ts b/test/cloudflare/runtime.test.ts index ea2c516..baebaa5 100644 --- a/test/cloudflare/runtime.test.ts +++ b/test/cloudflare/runtime.test.ts @@ -71,6 +71,18 @@ describe("Durable Objects runtime", () => { expect(await runtime().ref(Counter, "cancelled").with({ authorizationContext }).count).toBe(0) }) + it("fails a turn that has a schedule call without an operation", async () => { + const reference = runtime().ref(Counter, "bare-schedule").with({ authorizationContext }) + + await expect(reference.armWithoutOperation({ at: Date.now() + 60_000 })).rejects.toMatchObject({ + details: { name: "ScheduleWithoutOperation" }, + }) + expect(await remainingReminders("bare-schedule")).toEqual([]) + expect( + (await runtime().ref(Counter, "bare-schedule").snapshot({ authorizationContext })).count, + ).toBe(0) + }) + it("reads its own schedule inside a durable object", async () => { const reference = runtime().ref(Counter, "reads-schedule").with({ authorizationContext }) expect(await reference.readArmed()).toBeNull() diff --git a/test/cloudflare/worker.ts b/test/cloudflare/worker.ts index d6f8cb6..441de6e 100644 --- a/test/cloudflare/worker.ts +++ b/test/cloudflare/worker.ts @@ -69,6 +69,11 @@ export class Counter extends Actor { this.schedule({ at: new Date(options.at) }).increment() } + armWithoutOperation(options: { at: number }): void { + this.count = 100 + this.schedule({ at: new Date(options.at) }) + } + armKeyed(options: { at: number; keys: string[] }): void { for (const key of options.keys) { this.schedule({ at: new Date(options.at), key }).increment() diff --git a/test/schedule-without-operation.test.ts b/test/schedule-without-operation.test.ts new file mode 100644 index 0000000..92bd696 --- /dev/null +++ b/test/schedule-without-operation.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from "vitest" +import { Actor } from "../src/actor.js" +import { sqlite } from "../src/database/sqlite.js" +import { NonRetryableError, ScheduleWithoutOperation } from "../src/index.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" + +const HOLD_MILLISECONDS = 600_000 + +function holdEnd(holds = 1): Date { + return new Date(Date.now() + holds * HOLD_MILLISECONDS) +} + +class Holder extends Actor { + static override readonly actorType = "schedule-without-operation" + + status = "open" + + hold(): void { + this.status = "held" + this.schedule({ at: holdEnd(), key: "ada" }).expire() + } + + holdWithoutOperation(): boolean { + this.status = "held" + this.schedule({ at: holdEnd(), key: "ada" }) + return true + } + + holdWithOneOperationMissing(): boolean { + this.status = "held" + this.schedule({ at: holdEnd(), key: "ada" }).expire() + this.schedule({ at: holdEnd(2), key: "grace" }) + return true + } + + holdWithALateOperation(): void { + const reminder = this.schedule({ at: holdEnd(), key: "ada" }) + this.status = "held" + reminder.expire() + } + + expire(): void { + this.status = "open" + } +} + +let runtime: SolidObjectsRuntime | undefined + +afterEach(async () => { + await runtime?.close() + runtime = undefined +}) + +async function start(): Promise { + runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + maxAttempts: 3, + retryDelayMilliseconds: () => 0, + }) + runtime.register(Holder) + await runtime.install() + return runtime +} + +async function reminderNames(started: SolidObjectsRuntime): Promise { + const rows = await started.settings.database.connection((connection) => + connection.all<{ operation: string }>( + `SELECT operation FROM ${started.repository.table("reminders")}`, + ), + ) + return rows.map((row) => row.operation) +} + +describe("a schedule call without an operation", () => { + it("is a non-retryable error", () => { + expect(new ScheduleWithoutOperation("message")).toBeInstanceOf(NonRetryableError) + }) + + it("fails the message", async () => { + const started = await start() + + await expect(started.ref(Holder, "one").holdWithoutOperation()).rejects.toMatchObject({ + details: { + name: "ScheduleWithoutOperation", + message: expect.stringMatching(/schedule\(\{ at: .*key: "ada" \}\).*call an operation/is), + }, + }) + }) + + it("commits no state and no reminder, and it does not retry", async () => { + const started = await start() + const reference = started.ref(Holder, "one") + const message = await reference.send.holdWithoutOperation() + await started.worker().runUntilIdle() + + expect(await message.outcome()).toMatchObject({ + status: "dead", + error: { name: "ScheduleWithoutOperation" }, + attempts: 1, + }) + expect((await reference.snapshot()).status).toBe("open") + expect(await reminderNames(started)).toEqual([]) + }) + + it("fails a turn that also has a complete call", async () => { + const started = await start() + + await expect(started.ref(Holder, "one").holdWithOneOperationMissing()).rejects.toMatchObject({ + details: { + name: "ScheduleWithoutOperation", + message: expect.stringMatching(/^(?=.*key: "grace")(?!.*key: "ada")/s), + }, + }) + expect(await reminderNames(started)).toEqual([]) + }) + + it("does not stop a schedule call with an operation", async () => { + const started = await start() + const reference = started.ref(Holder, "one") + await reference.hold() + + expect((await reference.snapshot()).status).toBe("held") + expect(await reminderNames(started)).toEqual(["expire:ada"]) + }) + + it("lets a turn keep the schedule result and call the operation later", async () => { + const started = await start() + await started.ref(Holder, "one").holdWithALateOperation() + + expect(await reminderNames(started)).toEqual(["expire:ada"]) + }) + + it("does not make the next turn of the same actor fail", async () => { + const started = await start() + const reference = started.ref(Holder, "one") + await expect(reference.holdWithoutOperation()).rejects.toMatchObject({ + details: { name: "ScheduleWithoutOperation" }, + }) + + await reference.hold() + + expect(await reminderNames(started)).toEqual(["expire:ada"]) + }) +}) From a4c58c5a0ab30a0428dd5f38b1d237a39cc8c3bd Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:59 -0700 Subject: [PATCH 2/4] docs: state the 1.0 status and its limits Version 1.0 promises a stable public API under semantic versioning. docs/versioning.md gives the promise and what is outside it. The Cloudflare backend stays experimental and is outside the promise. The README now gives the status at the top: the release date, one maintainer, one first-party production application, and no production use by other people. It also names the projects that Solid Objects is not, because search results mix them. The package description starts with the problems that the package solves. "No daemon" was not accurate: reminders, effects, and queued messages run only while a process calls runtime.run(signal). The text now says that no separate actor service is necessary and names that process. --- README.md | 30 +++++++++++++++++------ docs/agents.md | 24 ++++++++++--------- docs/guides/persistent-rooms.md | 2 +- docs/guides/race-conditions.md | 2 +- docs/versioning.md | 42 +++++++++++++++++++++++++++++++++ docs/virtual-actors.md | 17 ++++++++----- package.json | 7 ++++-- test/package-metadata.test.ts | 17 +++++++++++++ 8 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 docs/versioning.md diff --git a/README.md b/README.md index 67b61fb..08dd4db 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ has a stable identity, and its state lives in the SQL database that your app already uses. [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md) explains the model and when to use it. +**Status.** Version 1.0: the public API follows semantic versioning and does +not break before 2.0. [Versioning](docs/versioning.md) gives the limits of the +promise. First public release: August 13, 2026. One maintainer. + +**Production use.** One first-party application with more than 100,000 users +runs it in production. That is a count of users, not a measure of load. No +production use by other people is known. The [benchmarks](docs/benchmarks.md) +are development measurements, and longer soak tests are open work. + +**It is not** Solid Queue, SolidJS, or the `service_actor` gem, and it is not +affiliated with Rails or Cloudflare. + In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Node site could be restarting before recovery finishes. To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part. @@ -150,8 +162,10 @@ that must agree with the write. The original transaction now depends on four oth Solid Objects makes the application-defined identity the coordination boundary. Its state, mailbox, retries, reminders, and staged consequences live in SQLite, -PostgreSQL, or MySQL. No daemon, broker, Cloudflare account, or new datastore is -required. Redis is optional wake-up plumbing, not durable state. +PostgreSQL, or MySQL. No separate actor service, broker, Cloudflare account, or +new datastore is required. A process of your application calls +`runtime.run(signal)` to run reminders, effects, and queued messages. Redis is +optional wake-up plumbing, not durable state. ## Good uses @@ -275,6 +289,7 @@ then shows a tested Solid Objects version with its limits: - [Guide for coding agents](docs/agents.md) - [Choosing Solid Objects](docs/fit.md) - [Public API](docs/api.md) +- [Versioning](docs/versioning.md) - [Operations and recovery](docs/operations.md) - [Observability and diagnostics](docs/observability.md) - [Detailed architecture](docs/architecture.md) @@ -285,11 +300,12 @@ notes remain in `docs/`, where long documentation can be long on purpose. ## Status and license -Solid Objects JS is a pre-1.0 early release. The correctness core has -automated coverage across the supported databases, browser runtime, recovery -paths, and packaged artifact. There is one deployed first-party reference -application, no measured scale, and no known third-party production use yet. -Pre-1.0 is doing actual work in that sentence. +Version 1.0 promises a stable public API. The correctness core has automated +coverage across the supported databases, browser runtime, recovery paths, and +packaged artifact. It makes no claim of measured scale: the +[benchmarks](docs/benchmarks.md) are development measurements, and longer soak +tests are open work. [Versioning](docs/versioning.md) says what a release can +change. The Cloudflare backend is experimental and is outside that promise. Solid Objects is released under the [MIT License](MIT-LICENSE). It is an independent project and is not affiliated with or endorsed by Cloudflare. diff --git a/docs/agents.md b/docs/agents.md index 1dc4684..ab10094 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -77,8 +77,10 @@ SQLite database. It proves that no update is lost. Add `pg` or `mysql2` when you use PostgreSQL or MySQL. Install the current release. `npm install solid-objects` selects it. Do not -pin a version that you remember from earlier work; the API changed between -releases. The current version is on . +use an API that you remember from a 0.x release; the API changed between 0.x +releases. From version 1.0, the public API follows +[semantic versioning](versioning.md). The current version is on +. Create the runtime and its tables at startup: @@ -215,12 +217,12 @@ Obey these rules in actor code: Avoid these mistakes: -| Mistake | Correct form | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `this.schedule({ at, key })` with no operation after it | `this.schedule({ at, key }).expire({ buyer })`. `schedule()` stages a reminder only when you call an operation on its result | -| `this.reject("room full")` or `this.reject(code, message)` | `this.reject("room_full", { message: "The room is full" })` | -| `registerEffect(name, (context, argumentsValue) => ...)` | `registerEffect(name, (argumentsValue, context) => ...)`. The staged arguments come first | -| A worker process that never names the actor class | `runtime.register(TicketSale)` before `runtime.run(signal)`. See step 7 | +| Mistake | Correct form | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `this.schedule({ at, key })` with no operation after it | `this.schedule({ at, key }).expire({ buyer })`. A `schedule()` call with no operation after it fails the turn with `ScheduleWithoutOperation` | +| `this.reject("room full")` or `this.reject(code, message)` | `this.reject("room_full", { message: "The room is full" })` | +| `registerEffect(name, (context, argumentsValue) => ...)` | `registerEffect(name, (argumentsValue, context) => ...)`. The staged arguments come first | +| A worker process that never names the actor class | `runtime.register(TicketSale)` before `runtime.run(signal)`. See step 7 | [State and lifecycle](state-and-lifecycle.md) and the [public API](api.md) give the full rules. @@ -334,11 +336,11 @@ When you explain Solid Objects to a user, state these limits: - There are no transactions across actor identities. - Fencing stops a stale activation from a commit, but its code can continue to run. -- The package is pre-1.0. It has no measured scale and no known third-party - production use. +- Version 1.0 promises a stable public API. It makes no claim of measured scale, + and no production use by other people is known. - `solid-objects/browser` is a WebSocket client, not the SQL runtime. The browser runtime in step 12 is tested in Chromium only. The Cloudflare backend - is experimental. + is experimental and is outside the version 1.0 promise. The [correctness contract](correctness.md) is the source for each guarantee. diff --git a/docs/guides/persistent-rooms.md b/docs/guides/persistent-rooms.md index cd68404..37b5c5d 100644 --- a/docs/guides/persistent-rooms.md +++ b/docs/guides/persistent-rooms.md @@ -383,7 +383,7 @@ The seventh test does not inject a late or repeated timer. The turn number check Delivery is at least once. Write each operation so that it can run again. One room runs one write at a time. This fits turn-based play, not a high-frequency game loop or fast real-time physics. -The application owns the WebSocket server, authentication, and the display of the room. There are no transactions across two rooms. The package requires Node.js 24.4 or newer and ESM only. The package is pre-1.0. +The application owns the WebSocket server, authentication, and the display of the room. There are no transactions across two rooms. The package requires Node.js 24.4 or newer and ESM only. Version 1.0 promises a stable public API. It makes no claim of measured scale. See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md). diff --git a/docs/guides/race-conditions.md b/docs/guides/race-conditions.md index 619120c..1763e91 100644 --- a/docs/guides/race-conditions.md +++ b/docs/guides/race-conditions.md @@ -355,7 +355,7 @@ There are no transactions across two actor identities. One identity runs one wri Each operation adds a message row. The runtime keeps completed messages for 30 days by default through `messageRetentionMilliseconds`. See [Configuration](../configuration.md#retention-and-cleanup) for retention and cleanup controls. -The package requires Node.js 24.4 or newer and supports ESM only. The package is pre-1.0. See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md). +The package requires Node.js 24.4 or newer and supports ESM only. Version 1.0 promises a stable public API. It makes no claim of measured scale. See [Correctness and delivery semantics](../correctness.md) and [Virtual actors in TypeScript and Node.js](../virtual-actors.md). ## Sources diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..4a87fd0 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,42 @@ +# Versioning + +From version 1.0.0, Solid Objects follows +[Semantic Versioning](https://semver.org/). + +## The promise + +The public API is the set of exports, commands, and configuration options that +`README.md` and the files in `docs/` describe. [Public API](api.md) lists each +export. + +| Release | What it can change | +| ------------------------ | ------------------------------------------------------------- | +| Patch, for example 1.0.1 | Corrections of defects. No new behavior | +| Minor, for example 1.1.0 | New behavior. Code that uses the public API continues to work | +| Major, for example 2.0.0 | A change that breaks code that uses the public API | + +## Outside the promise + +- The Cloudflare backend, `solid-objects/cloudflare`. It is experimental, and a + minor release can change it. +- Exports that the documentation does not describe. +- The database schema. A minor release can add tables, columns, and indexes. + `runtime.install()` applies each change. +- The text of error messages and log lines. The error classes are part of the + public API. +- The timing of background work, where no configuration option sets it. +- A correction of behavior that is different from the + [correctness contract](correctness.md). A patch release can make such a + correction. + +## What version 1.0 does not claim + +Version 1.0 is a promise about the API. It is not a claim of measured scale. +The [benchmarks](benchmarks.md) are development measurements and do not +guarantee capacity. Longer soak tests are open work. The project has one +maintainer, and no production use by other people is known. + +## Releases before 1.0 + +The 0.x releases changed the API between minor versions. +[CHANGELOG.md](../CHANGELOG.md) lists each change. diff --git a/docs/virtual-actors.md b/docs/virtual-actors.md index 1153e18..7763634 100644 --- a/docs/virtual-actors.md +++ b/docs/virtual-actors.md @@ -7,11 +7,13 @@ Node.js. The npm package is `solid-objects`. It gives each actor a stable identity, durable state, ordered operations, and automatic activation. The actor runtime runs in your Node.js processes. State and mailboxes live in -SQLite, PostgreSQL, or MySQL. It needs no broker, no daemon, no Cloudflare -account, and no new datastore. Redis is optional and only shortens wake-up -latency. +SQLite, PostgreSQL, or MySQL. It needs no separate actor service, no broker, +no Cloudflare account, and no new datastore. A process of your application +calls `runtime.run(signal)` to run reminders, effects, and queued messages. +Redis is optional and only shortens wake-up latency. -Solid Objects is a pre-1.0 release. Read +Solid Objects 1.0 promises a stable public API. It makes no claim of measured +scale. Read [Compatibility and maturity](#compatibility-and-maturity) before you choose it. ## What a virtual actor is @@ -244,7 +246,10 @@ These runtimes have different compatibility statements: | Browser runtime (`solid-objects/browser/host`) | The actor runtime in a browser module worker on SQLite WASM. Tested in Chromium | | Cloudflare backend (`solid-objects/cloudflare`) | Experimental. It runs the actor API on Cloudflare Durable Objects, with different capability limits | -The package is pre-1.0. It has one deployed first-party reference -application, no measured scale, and no known third-party production use. +Version 1.0: the public API follows [semantic versioning](versioning.md). The +Cloudflare backend is outside that promise. One first-party application with +more than 100,000 users runs the package in production. That is a count of +users, not a measure of load. The project makes no claim of measured scale, +and no production use by other people is known. [Supported versions](support.md) lists the CI matrix. For Ruby on Rails, use the [solid_objects](https://github.com/cardmagic/solid-objects-ruby) gem. diff --git a/package.json b/package.json index f1df76f..7ad41f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "solid-objects", "version": "0.17.8", - "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", + "description": "Fix race conditions across Node.js processes: ordered operations, expiring holds, and state that survives a restart, in your SQL database. A SQL-backed virtual actor library for TypeScript and Node.js.", "type": "module", "license": "MIT", "author": "Lucas Carlson", @@ -19,7 +19,10 @@ "actors", "durable-objects", "virtual-actors", - "actor-model" + "actor-model", + "race-conditions", + "reservations", + "transactional-outbox" ], "repository": { "type": "git", diff --git a/test/package-metadata.test.ts b/test/package-metadata.test.ts index 9647508..4b37db7 100644 --- a/test/package-metadata.test.ts +++ b/test/package-metadata.test.ts @@ -12,6 +12,23 @@ describe("package metadata", () => { ) }) + it("starts the description with the problems that the package solves", () => { + expect(packageDefinition.description).toMatch( + /^Fix race conditions across Node\.js processes: ordered operations, expiring holds/, + ) + }) + + it("has the keywords for the problems that the package solves", () => { + expect(packageDefinition.keywords).toEqual( + expect.arrayContaining([ + "race-conditions", + "concurrency", + "reservations", + "transactional-outbox", + ]), + ) + }) + it("keeps the existing keywords and adds the actor categories", () => { expect(packageDefinition.keywords).toEqual( expect.arrayContaining(["actors", "durable-objects", "virtual-actors", "actor-model"]), From 6e417e54598f2811ad236fee7bf405be4e505c51 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:59 -0700 Subject: [PATCH 3/4] chore: prepare JavaScript 1.0.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af9ae0b..e8de46c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 1.0.0 - 2026-10-13 + +- Version 1.0 freezes the public API under semantic versioning. + [docs/versioning.md](docs/versioning.md) gives the promise and its limits. + The Cloudflare backend stays experimental and is outside the promise. + Version 1.0 is not a claim of measured scale: the benchmarks are + development measurements, and longer soak tests are open work. +- **Breaking:** a turn that calls `schedule()` and then calls no operation on + the result now fails with `ScheduleWithoutOperation`. The runtime does not + retry that turn, and the message becomes a dead letter. Before this release, + such a call stored no reminder and gave no error. Correct code, such as + `this.schedule({ at }).expire()`, does not change. The turn can keep the + result and call the operation later. Ruby has the same behavior. +- The package description now starts with the problems that the package + solves, and the keywords add `race-conditions`, `reservations`, and + `transactional-outbox`. The README has a status statement and names the + projects that Solid Objects is not. +- The README and the category guide no longer say "no daemon". They say that + a process of your application calls `runtime.run(signal)` to run reminders, + effects, and queued messages. + ## 0.17.8 - 2026-10-12 - Back off repeated interrupted-message recovery using `retryDelayMilliseconds`. diff --git a/package.json b/package.json index 7ad41f9..5298f0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.17.8", + "version": "1.0.0", "description": "Fix race conditions across Node.js processes: ordered operations, expiring holds, and state that survives a restart, in your SQL database. A SQL-backed virtual actor library for TypeScript and Node.js.", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 3fad9f0..009c123 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.8" +export const VERSION = "1.0.0" From 46e582be98c26959e616f7f6e081fecbf4ca2f09 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:48:26 -0700 Subject: [PATCH 4/4] fix: catch a bare schedule call in an observable The check for a schedule call with no operation ran directly after the operation. An observable that called schedule() only after the state changed was read later, so the turn committed with no reminder and no error. The open call stayed on the retained actor and made the next turn fail, although that turn did nothing wrong. The projection guard now counts an open schedule call as staged work. The turn that reads the observable fails with QueryMutatedState, and the failure path clears the open call. The error text also kept the Date object of the caller. Code that changed that date to an invalid value afterward made toISOString() throw RangeError, which the runtime retried. The call now keeps the timestamp that schedule() already checked. --- CHANGELOG.md | 4 +- docs/api.md | 3 ++ docs/parity.md | 2 +- src/actor.ts | 11 +++-- test/schedule-without-operation.test.ts | 65 +++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8de46c..5b17664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ retry that turn, and the message becomes a dead letter. Before this release, such a call stored no reminder and gave no error. Correct code, such as `this.schedule({ at }).expire()`, does not change. The turn can keep the - result and call the operation later. Ruby has the same behavior. + result and call the operation later. A `schedule()` call with no operation + in `observables()` fails the turn with `QueryMutatedState`. Ruby has the + same behavior. - The package description now starts with the problems that the package solves, and the keywords add `race-conditions`, `reservations`, and `transactional-outbox`. The README has a status statement and names the diff --git a/docs/api.md b/docs/api.md index 21da028..766b123 100644 --- a/docs/api.md +++ b/docs/api.md @@ -192,6 +192,9 @@ this.schedule({ at: holdEnd, key: buyer }).expire({ buyer }) You can keep the result and call the operation later in the same turn. +`observables()` cannot call `schedule()`, with or without an operation. The +turn that reads the observables fails with `QueryMutatedState`. + #### Cancelling a reminder The operation that you call on the result of `schedule()` returns a diff --git a/docs/parity.md b/docs/parity.md index c35edee..55dd6ce 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -63,7 +63,7 @@ is needed for the JavaScript stale-result race fix. | 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. Typed callback envelopes are exported. | | Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. | | One-shot and recurring reminders | Native | Scheduling, cancellation by name/key/handle, read-your-writes inspection, catch-up policy, stale-claim recovery, pausing, and authorized resume. Cancellation cannot recall an already-enqueued occurrence. | -| Schedule call without an operation | Native | Both runtimes fail a turn that calls `schedule()` and then calls no operation on the result. The error is `ScheduleWithoutOperation`, which is not retried: the message becomes a dead letter, and the turn commits no state and no reminder. The turn can keep the result and call the operation later in the same turn. The Cloudflare backend applies the same rule. | +| Schedule call without an operation | Native | Both runtimes fail a turn that calls `schedule()` and then calls no operation on the result. The error is `ScheduleWithoutOperation`, which is not retried: the message becomes a dead letter, and the turn commits no state and no reminder. The turn can keep the result and call the operation later in the same turn. The projection guard counts such a call in an observable as staged work, so that turn fails with `QueryMutatedState`. The Cloudflare backend applies the same rules. | | Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. | | Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. | | Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, migrations, and commit actions; only the supplied fenced commit-action connection may write. Unwrapped clients cannot be intercepted. | diff --git a/src/actor.ts b/src/actor.ts index 39fbf93..8023412 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -169,12 +169,12 @@ const REMINDER_NAME_LIMIT = 255 const REMINDER_KEY_SEPARATOR = ":" interface ScheduleCall { - at: Date + atMilliseconds: number key: string | undefined } function scheduleCallArguments(call: ScheduleCall): string { - const at = `at: ${call.at.toISOString()}` + const at = `at: ${new Date(call.atMilliseconds).toISOString()}` if (call.key === undefined) return `{ ${at} }` return `{ ${at}, key: ${JSON.stringify(call.key)} }` @@ -402,7 +402,7 @@ export abstract class Actor { throw new TypeError("reminder interval must be positive") } const key = validatedReminderKey(options.key) - const call: ScheduleCall = { at: options.at, key } + const call: ScheduleCall = { atMilliseconds, key } this.#openScheduleCalls.add(call) return createStagedOperationMap(this.#operations, (operation, argumentsValue) => { @@ -571,7 +571,10 @@ export abstract class Actor { } intentSnapshot(): string { - return JSON.stringify(this.#intents) + return JSON.stringify({ + intents: this.#intents, + openScheduleCalls: [...this.#openScheduleCalls], + }) } } diff --git a/test/schedule-without-operation.test.ts b/test/schedule-without-operation.test.ts index 92bd696..25efafb 100644 --- a/test/schedule-without-operation.test.ts +++ b/test/schedule-without-operation.test.ts @@ -39,11 +39,37 @@ class Holder extends Actor { reminder.expire() } + holdWithAChangedDate(): boolean { + const at = holdEnd() + this.schedule({ at, key: "ada" }) + at.setTime(Number.NaN) + return true + } + expire(): void { this.status = "open" } } +class ProjectionScheduler extends Actor { + static override readonly actorType = "schedule-in-projection" + + status = "open" + + hold(): void { + this.status = "held" + } + + release(): void { + this.status = "open" + } + + override observables() { + if (this.status === "held") this.schedule({ at: holdEnd(), key: "ada" }) + return { status: this.status } + } +} + let runtime: SolidObjectsRuntime | undefined afterEach(async () => { @@ -60,6 +86,7 @@ async function start(): Promise { retryDelayMilliseconds: () => 0, }) runtime.register(Holder) + runtime.register(ProjectionScheduler) await runtime.install() return runtime } @@ -143,4 +170,42 @@ describe("a schedule call without an operation", () => { expect(await reminderNames(started)).toEqual(["expire:ada"]) }) + + it("reports the call when the turn changed its date afterward", async () => { + const started = await start() + const message = await started.ref(Holder, "one").send.holdWithAChangedDate() + await started.worker().runUntilIdle() + + expect(await message.outcome()).toMatchObject({ + status: "dead", + error: { name: "ScheduleWithoutOperation" }, + attempts: 1, + }) + }) + + it("fails the turn that reads an observable with such a call", async () => { + const started = await start() + const reference = started.ref(ProjectionScheduler, "one") + const message = await reference.send.hold() + await started.worker().runUntilIdle() + + expect(await message.outcome()).toMatchObject({ + status: "dead", + error: { name: "QueryMutatedState" }, + attempts: 1, + }) + expect((await reference.snapshot()).status).toBe("open") + }) + + it("does not make the turn after such an observable fail", async () => { + const started = await start() + const reference = started.ref(ProjectionScheduler, "one") + await expect(reference.hold()).rejects.toMatchObject({ + details: { name: "QueryMutatedState" }, + }) + + await reference.release() + + expect((await reference.snapshot()).status).toBe("open") + }) })