Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Export effect failure/success payload types and `SerializedError` from the
root and core entry points. Check SQL and Cloudflare callback construction
against the same contracts without changing the delivered messages.

## 0.14.8 - 2026-09-09

- Hold the actor instance and message claim locks through every fenced commit
Expand Down
58 changes: 58 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
`PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
transactional work and typed personalized projections.

`EffectFailurePayload<Arguments>`, `EffectSuccessPayload<Arguments, Result>`,
and `SerializedError` describe effect callback messages. They are also exported
from the browser-safe `solid-objects/core` entry point.

`observables()` returns a flat object. Unwrapped values are invalidation-only:
their real values participate in change detection, but only their names enter
the durable envelope. Use an explicit marker when wire behavior matters:
Expand Down Expand Up @@ -155,6 +159,60 @@ 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.

### Typing your onFailure handler

An effect callback is an ordinary actor operation. Its payload always includes
the stable `effectId` and the original serialized `arguments`, including `{}`
when the effect was emitted without arguments. Use the exported types when a
watchdog or failure handler needs to correlate work with the current generation:

```typescript
import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects"

type RunArguments = { generation: number }

class ChatRun extends Actor {
static override readonly actorType = "ChatRun"
generation = 0
status = "idle"
reply = ""

start(): void {
this.emit("run_model", {
arguments: { generation: ++this.generation },
onSuccess: "finishTurn",
onFailure: "failTurn",
})
}

failTurn({ arguments: original, error }: EffectFailurePayload<RunArguments>): void {
if (original.generation !== this.generation) return
this.status = `${error.name}: ${error.message}`
}

finishTurn({
arguments: original,
result,
}: EffectSuccessPayload<RunArguments, { reply: string }>): void {
if (original.generation !== this.generation) return
this.status = "finished"
this.reply = result.reply
}
}
```

Failure payloads contain `error: SerializedError`, with string `name` and
`message` fields. They do not include a stack or cause. Success payloads contain
`result`, which can be any JSON value; an undefined effect return becomes
`null`. The default argument type is `JsonObject` and the default success result
type is `JsonValue`. Declare argument shapes with a JSON-compatible type alias.

These types describe the SQL and Cloudflare callback envelopes. Error messages
for non-Error throws retain each backend's existing serialization behavior.
The generic parameters express your application's contract; they do not add
runtime validation or infer types from `registerEffect()`. Keep registered
effect results and the handler's declared argument/result types in agreement.

### Runtime managers

Every manager below is available as a property on `SolidObjectsRuntime`; the
Expand Down
7 changes: 6 additions & 1 deletion docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ is needed for the JavaScript stale-result race fix.
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
| Backpressure and payload caps | Partial | Serialization enforces a shared maximum JSON nesting depth, raising `InvalidPayload`, and an optional caller-supplied `maxBytes` limit, raising `PayloadTooLarge`; reminder names are bounded to 255 characters. Distributed per-actor rate limits and global admission control do not exist yet, matching the open Ruby roadmap item. |
| 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. |
| 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. |
| 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, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
Expand All @@ -65,6 +65,11 @@ is needed for the JavaScript stale-result race fix.
| Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured `MessageFailed`; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |
| Result lookup by request ID | Planned | This is also an open Ruby roadmap item and will be implemented in both runtimes when its authorization shape is settled. |

Effect callback envelopes are typed with `EffectFailurePayload`,
`EffectSuccessPayload`, and `SerializedError` in both SQL and Cloudflare.
Ruby RBS contracts are tracked in cardmagic/solid-objects-ruby#64 and preserve
Ruby field names; this does not change runtime delivery semantics.

## Operations

| Capability | Status | TypeScript shape or remaining work |
Expand Down
25 changes: 24 additions & 1 deletion scripts/release-artifact-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { spawn } from "node:child_process"
Expand Down Expand Up @@ -57,6 +57,29 @@ try {
)
assert.equal(installedPackage.version, packageDefinition.version)

const consumerPath = join(projectDirectory, "effect-payload-consumer.mts")
await writeFile(
consumerPath,
await readFile(join(repositoryRoot, "test/fixtures/effect-payload-consumer.mts")),
)
await run(
process.execPath,
[
join(repositoryRoot, "node_modules/typescript/bin/tsc"),
"--noEmit",
"--strict",
"--noUncheckedIndexedAccess",
"--exactOptionalPropertyTypes",
"--skipLibCheck",
"--module",
"NodeNext",
"--target",
"ES2024",
consumerPath,
],
{ cwd: projectDirectory },
)

const resolvedModule = (
await run(
process.execPath,
Expand Down
25 changes: 15 additions & 10 deletions src/cloudflare/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ import {
} from "../errors.js"
import { deepCopy, jsonObject, normalizeJson, stableJson } from "../serialization.js"
import { evaluateActorTurn, readActorObservables, selectActorBroadcast } from "../turn.js"
import type { JsonObject, JsonValue } from "../types.js"
import type {
EffectFailurePayload,
EffectSuccessPayload,
JsonObject,
JsonValue,
SerializedError,
} from "../types.js"
import type { CloudflareSettings } from "./configuration.js"
import { actorName, callHost, type ActorIdentity, type HostRequest } from "./protocol.js"
import type { Instance, Message, Outbox, Reminder, Subscription } from "./records.js"
Expand Down Expand Up @@ -805,7 +811,7 @@ export class ActorEngine {
this.stageEffectCallback({
instance,
outbox,
result,
outcome: { result },
operation: outbox.payload.successOperation,
})
})
Expand All @@ -815,17 +821,18 @@ export class ActorEngine {
const exhausted =
error instanceof NonRetryableError || outbox.attempt >= this.settings.maxAttempts
outbox.status = exhausted ? "dead" : "pending"
outbox.error = {
const errorRecord: SerializedError = {
name: errorName(error),
message: error instanceof Error ? error.message : "delivery failed",
}
outbox.error = errorRecord
outbox.availableAt = Date.now() + this.retryDelay(outbox.attempt)
this.store.saveOutbox(outbox)
if (exhausted && outbox.kind === "effect")
this.stageEffectCallback({
instance,
outbox,
result: outbox.error,
outcome: { error: errorRecord },
operation: outbox.payload.failureOperation,
})
})
Expand All @@ -851,7 +858,7 @@ export class ActorEngine {
private stageEffectCallback(options: {
instance: Instance
outbox: Outbox
result: JsonValue
outcome: Pick<EffectSuccessPayload, "result"> | Pick<EffectFailurePayload, "error">
operation: JsonValue | undefined
}): void {
if (typeof options.operation !== "string") return
Expand All @@ -868,11 +875,9 @@ export class ActorEngine {
operation: options.operation,
arguments: {
effectId: options.outbox.id,
arguments: options.outbox.payload.arguments!,
...(options.outbox.status === "dead"
? { error: options.result }
: { result: options.result }),
},
arguments: options.outbox.payload.arguments as JsonObject,
...options.outcome,
} satisfies EffectSuccessPayload | EffectFailurePayload,
},
})
}
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export type {
DeepReadonly,
DestroyOptions,
EffectContext,
EffectFailurePayload,
EffectSuccessPayload,
InvocationOptions,
JsonObject,
JsonPrimitive,
Expand All @@ -131,6 +133,7 @@ export type {
LongRunningComponent,
MessageContext,
MessageStatus,
SerializedError,
SnapshotOptions,
} from "./types.js"
export type {
Expand Down
27 changes: 20 additions & 7 deletions src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ import type {
} from "./records.js"
import { jsonObject, normalizeJson } from "./serialization.js"
import type { RetentionTarget } from "./retention.js"
import type { JsonObject, JsonValue, MessageStatus } from "./types.js"
import type {
EffectFailurePayload,
EffectSuccessPayload,
JsonObject,
JsonValue,
MessageStatus,
SerializedError,
} from "./types.js"
import { VERSION } from "./version.js"

export interface SyncDiagnosticsRecord {
Expand Down Expand Up @@ -1486,7 +1493,7 @@ export class Repository {
effectId: effect.id,
arguments: jsonObject(JSON.parse(effect.arguments)),
result,
}),
} satisfies EffectSuccessPayload),
idempotencyKey: `effect:${effect.id}:success`,
})
void now
Expand Down Expand Up @@ -1532,7 +1539,7 @@ export class Repository {
effectId: effect.id,
arguments: jsonObject(JSON.parse(effect.arguments)),
error: errorRecord,
}),
} satisfies EffectFailurePayload),
idempotencyKey: `effect:${effect.id}:failure`,
})
})
Expand Down Expand Up @@ -2188,11 +2195,17 @@ function nextReminderRun(options: {
return previousRun + (Math.floor((now - previousRun) / interval) + 1) * interval
}

function safeError(error: unknown): Record<string, JsonValue> {
function safeError(error: unknown): SerializedError {
if (error instanceof Error) {
return jsonObject({ name: error.name, message: error.message })
}
return jsonObject({ name: "Error", message: String(normalizeJson(error)) })
return jsonObject({
name: error.name,
message: error.message,
} satisfies SerializedError) as SerializedError
}
return jsonObject({
name: "Error",
message: String(normalizeJson(error)),
} satisfies SerializedError) as SerializedError
}

function retentionPolicy(options: {
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ export type JsonPrimitive = null | boolean | number | string
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
export type JsonObject = { [key: string]: JsonValue }

export type SerializedError = {
name: string
message: string
}

export type EffectFailurePayload<Arguments extends JsonObject = JsonObject> = {
effectId: string
arguments: Arguments
error: SerializedError
}

export type EffectSuccessPayload<
Arguments extends JsonObject = JsonObject,
Result extends JsonValue = JsonValue,
> = {
effectId: string
arguments: Arguments
result: Result
}

export type DeepReadonly<Value> = Value extends JsonPrimitive
? Value
: Value extends readonly (infer Item)[]
Expand Down
58 changes: 58 additions & 0 deletions test/cloudflare/effect-payloads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { env } from "cloudflare:test"
import { describe, expect, it } from "vitest"
import { createRuntime, durableObjects } from "../../src/cloudflare/index.js"
import { EffectCallbacks, deliveries } from "./worker.js"

const authorizationContext = "allowed"
const runtime = () => createRuntime({ backend: durableObjects({ namespace: env.ACTORS }) })

describe("Cloudflare effect payloads", () => {
it.each([null, false, 42, "reply", ["reply"], { reply: "done" }])(
"delivers the complete success envelope for %j",
async (result) => {
const reference = runtime().ref(EffectCallbacks, `success-${JSON.stringify(result)}`)
const argumentsValue = { generation: 2, nested: { retained: true }, result }
await reference.with({ authorizationContext }).start(argumentsValue)
await expect
.poll(() =>
reference.snapshot({ authorizationContext }).then((snapshot) => snapshot.received),
)
.toEqual([{ effectId: expect.any(String), arguments: argumentsValue, result }])
const [payload] = (await reference.snapshot({ authorizationContext })).received
expect(deliveries.get(payload!.effectId)).toBe(1)
},
)

it("delivers empty arguments and normalizes an undefined result to null", async () => {
const reference = runtime().ref(EffectCallbacks, "empty")
await reference.with({ authorizationContext }).startEmpty()
await expect
.poll(() =>
reference.snapshot({ authorizationContext }).then((snapshot) => snapshot.received),
)
.toEqual([{ effectId: expect.any(String), arguments: {}, result: null }])
})

it.each([
{ mode: "retry", attempts: 5, name: "Error", message: "exhausted" },
{ mode: "terminal", attempts: 1, name: "NonRetryableError", message: "terminal" },
{ mode: "non-error", attempts: 5, name: "Error", message: "delivery failed" },
])("delivers the complete failure envelope for $mode", async (options) => {
const reference = runtime().ref(EffectCallbacks, options.mode)
const argumentsValue = { mode: options.mode, generation: 3 }
await reference.with({ authorizationContext }).start(argumentsValue)
await expect
.poll(() =>
reference.snapshot({ authorizationContext }).then((snapshot) => snapshot.received),
)
.toEqual([
{
effectId: expect.any(String),
arguments: argumentsValue,
error: { name: options.name, message: options.message },
},
])
const [payload] = (await reference.snapshot({ authorizationContext })).received
expect(deliveries.get(payload!.effectId)).toBe(options.attempts)
})
})
Loading