From 5ea315eb9d534bf9b4abaa36fc945f62b282592a Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 4 Sep 2026 22:28:41 -0700 Subject: [PATCH 01/10] feat: add Cloudflare Durable Objects backend --- .github/workflows/ci.yml | 2 + .gitignore | 1 + CHANGELOG.md | 16 + README.md | 4 + docs/api.md | 22 + docs/architecture.md | 8 +- docs/cloudflare.md | 155 ++++ docs/parity.md | 19 + docs/support.md | 23 +- examples/cloudflare/README.md | 36 + examples/cloudflare/counter.ts | 19 + examples/cloudflare/environment.d.ts | 14 + examples/cloudflare/worker.ts | 70 ++ examples/cloudflare/wrangler.jsonc | 15 + package.json | 23 +- pnpm-lock.yaml | 898 +++++++++++++++++- pnpm-workspace.yaml | 3 + scripts/check-cloudflare-imports.mjs | 32 + scripts/check-documentation.mjs | 2 + scripts/release-artifact-smoke.mjs | 4 + src/actor-runtime.ts | 65 ++ src/browser/index.ts | 2 +- src/cloudflare/configuration.ts | 82 ++ src/cloudflare/engine.ts | 1027 +++++++++++++++++++++ src/cloudflare/host.ts | 56 ++ src/cloudflare/index.ts | 14 + src/cloudflare/platform.ts | 4 + src/cloudflare/protocol.ts | 135 +++ src/cloudflare/records.ts | 68 ++ src/cloudflare/runtime.ts | 434 +++++++++ src/cloudflare/session.ts | 396 ++++++++ src/cloudflare/storage.ts | 276 ++++++ src/context.ts | 14 +- src/core.ts | 7 + src/default-runtime.ts | 10 +- src/errors.ts | 9 + src/realtime.ts | 21 +- src/reference.ts | 12 +- src/repository.ts | 23 +- src/runtime.ts | 82 +- src/turn.ts | 87 ++ test/cloudflare/contract.test.ts | 10 + test/cloudflare/environment.d.ts | 14 + test/cloudflare/realtime.test.ts | 196 ++++ test/cloudflare/recovery.test.ts | 351 +++++++ test/cloudflare/runtime.test.ts | 86 ++ test/cloudflare/worker.ts | 206 +++++ test/cloudflare/wrangler.jsonc | 15 + test/message-snapshot.test.ts | 30 + test/portable-runtime.test.ts | 31 + test/support/portable-actor.ts | 27 + test/support/portable-runtime-contract.ts | 61 ++ tsconfig.build.json | 2 +- tsconfig.cloudflare-build.json | 9 + tsconfig.cloudflare-example.json | 12 + tsconfig.cloudflare.json | 10 + tsconfig.examples.json | 3 +- tsconfig.json | 4 +- vitest.cloudflare.config.ts | 7 + vitest.config.ts | 5 + 60 files changed, 5158 insertions(+), 111 deletions(-) create mode 100644 docs/cloudflare.md create mode 100644 examples/cloudflare/README.md create mode 100644 examples/cloudflare/counter.ts create mode 100644 examples/cloudflare/environment.d.ts create mode 100644 examples/cloudflare/worker.ts create mode 100644 examples/cloudflare/wrangler.jsonc create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-cloudflare-imports.mjs create mode 100644 src/actor-runtime.ts create mode 100644 src/cloudflare/configuration.ts create mode 100644 src/cloudflare/engine.ts create mode 100644 src/cloudflare/host.ts create mode 100644 src/cloudflare/index.ts create mode 100644 src/cloudflare/platform.ts create mode 100644 src/cloudflare/protocol.ts create mode 100644 src/cloudflare/records.ts create mode 100644 src/cloudflare/runtime.ts create mode 100644 src/cloudflare/session.ts create mode 100644 src/cloudflare/storage.ts create mode 100644 src/core.ts create mode 100644 src/turn.ts create mode 100644 test/cloudflare/contract.test.ts create mode 100644 test/cloudflare/environment.d.ts create mode 100644 test/cloudflare/realtime.test.ts create mode 100644 test/cloudflare/recovery.test.ts create mode 100644 test/cloudflare/runtime.test.ts create mode 100644 test/cloudflare/worker.ts create mode 100644 test/cloudflare/wrangler.jsonc create mode 100644 test/message-snapshot.test.ts create mode 100644 test/portable-runtime.test.ts create mode 100644 test/support/portable-actor.ts create mode 100644 test/support/portable-runtime-contract.ts create mode 100644 tsconfig.cloudflare-build.json create mode 100644 tsconfig.cloudflare-example.json create mode 100644 tsconfig.cloudflare.json create mode 100644 vitest.cloudflare.config.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e9bb07..c1631dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: - run: pnpm run check - run: pnpm run test:coverage - run: pnpm run build + - run: pnpm run test:cloudflare + - run: pnpm run check:cloudflare - run: pnpm run pack:check - run: pnpm run test:package - run: pnpm run test:recovery diff --git a/.gitignore b/.gitignore index be9f6f0..d971ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ test-results/ .DS_Store .claude/ .codex/ +.wrangler/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fb90795..d1304a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +- Read SQL message results and completion status in one statement so concurrent + completion cannot return a stale `null` result. +- Add `solid-objects/core` and the Cloudflare Durable Objects runtime backend. + Each actor identity owns SQLite state, a durable mailbox, retry state, + reminders, and effect/message outboxes. Request IDs recover ambiguous RPC + acceptance; incarnation and execution generations fence stale commits. +- Add hibernating session Durable Objects for existing browser subscriptions, + including multi-actor connections, fresh authorization, personalized payloads, + revision fencing, and durable subscription cleanup. +- Share actor turn evaluation across SQL and Cloudflare. Keep Workers types and + imports separate from the Node target. Add Workers integration tests, a + runnable example, bundle checks, and an explicit backend capability matrix. + The Cloudflare backend is experimental pending deployed failover/soak testing. + ## 0.14.6 - 2026-09-03 - Poll effects, reminders, and broadcasts through ordered indexes installed by diff --git a/README.md b/README.md index 34b4c69..dd62e5f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ **Open Source Durable Objects in your Node app.** +Deploying on Cloudflare? The [Durable Objects backend](docs/cloudflare.md) runs +the same actor API on Workers, with durable mailboxes, alarms, and browser +subscriptions. Start with the [Cloudflare example](examples/cloudflare/README.md). + 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. diff --git a/docs/api.md b/docs/api.md index ae42d42..5c63621 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,6 +4,28 @@ The package exports one server entry point plus database, wake-up, and browser subpaths. The TypeScript declaration files are authoritative for exact generic signatures. This index explains the supported role of every export. +## `solid-objects/core` and `solid-objects/cloudflare` + +`solid-objects/core` exports portable actor definitions, errors, reference types, +the `ActorRuntime` interface, and request-scoped `withRuntime(runtime, callback)`. +It does not initialize a Node platform or SQL driver. + +`solid-objects/cloudflare` exports `CloudflareRuntime` and `createRuntime({ backend })`, +`durableObjects({ namespace, sessions })`, `createDurableObjectsHost({ actors, configure })`, +and `createDurableObjectsSessionHost({ backend, resolveAuthorizationContext, maxSubscriptions })`. +`CloudflareConfiguration` supplies host policies, limits, instrumentation, and effects. +`DurableObjectsBackend`, `ActorNamespace`, and `SessionNamespace` describe the bindings. + +Actor references retain the operation, snapshot, send, and message-result APIs. +`lookupMessage()` recovers acceptance by request ID; `actorAdministration()` provides +bounded per-actor dead-letter and reminder operations. `openWebSocket()` bridges an +authenticated HTTP upgrade to a session Durable Object. + +`EnqueueOutcomeUnknown` reports ambiguous RPC acceptance and carries recovery +identifiers. `UnsupportedCapability` rejects facilities requiring the shared SQL +runtime. See [Cloudflare Durable Objects](cloudflare.md) for configuration, +authorization, capability boundaries, and release validation. + ## `solid-objects` ### Runtime and actors diff --git a/docs/architecture.md b/docs/architecture.md index 645af2d..213ef8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,6 +144,8 @@ at-least-once delivery and retry backoff. Per-actor order comes from an ordered drain up to the claimed effect's mailbox sequence, and the server ingest deduplicates on the effect id through the normal idempotency-key path. -Solid Objects provides database-backed state coordination for Node.js and the -browser. It does not reproduce Cloudflare's placement or edge-runtime -guarantees. +The SQL runtime provides database-backed coordination for Node.js and the +browser. The optional [Cloudflare backend](cloudflare.md) uses native Durable +Objects for placement, private SQLite storage, activation, alarms, and +hibernating browser connections. Its runtime boundary shares actor evaluation +without importing SQL workers or their process/lease administration. diff --git a/docs/cloudflare.md b/docs/cloudflare.md new file mode 100644 index 0000000..4080b08 --- /dev/null +++ b/docs/cloudflare.md @@ -0,0 +1,155 @@ +# Cloudflare Durable Objects + +Import actor definitions from `solid-objects/core` to share them between Node +and Workers. On Cloudflare, import `createRuntime`, `durableObjects`, +`createDurableObjectsHost`, and `createDurableObjectsSessionHost` from +`solid-objects/cloudflare`. + +The [runnable example](../examples/cloudflare/README.md) includes both Durable +Object classes, Wrangler bindings, migrations, and a public counter. + +## Configure the hosts + +```typescript +import { createRuntime, durableObjects, withRuntime } from "solid-objects/cloudflare" + +const runtime = createRuntime({ + backend: durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }), +}) + +const counter = runtime.ref(Counter, "counter-1") +const count = await counter.with({ authorizationContext: subject }).increment() +await withRuntime( + runtime, + () => Counter.ref("counter-1").with({ authorizationContext: subject }).count, +) +``` + +Define the actor host once with `createDurableObjectsHost({ actors, configure })`. +`configure(env)` returns a `CloudflareConfiguration` with the same backend +bindings, authorization policies, and an `effects` map of named handlers. A +handler receives the existing effect arguments and `EffectContext` with its +stable delivery ID. Definitions and configuration stay inside the deployment; +they are not transmitted through RPC. + +Use `runtime.ref()` or request-scoped `withRuntime()` in Worker handlers. +`Actor.ref()` within actor operations and lifecycle callbacks retains its +runtime across awaits. The backend does not set a global default runtime. + +The host supplies storage and scheduling, so there is no `install()` or +`run()` step. Configure both exported classes using `new_sqlite_classes` and +enable `nodejs_compat` for async context propagation. Library schema migrations +run synchronously on activation and use a separate migration ledger. + +## Guarantees and recovery + +An actor type plus its string-normalized ID routes to one Durable Object. +Operations and getters enter its ordered durable mailbox. Snapshots read one +committed state image without entering the mailbox. A retryable head blocks +later turns, and permanent failures pause that actor until operator recovery. + +State, results, effects, outbound messages, reminders, and the next alarm commit +together. Actor code executes outside that short transaction. Destruction and +restart generations fence stale commits. State migrations and read-only +projection checks use the same implementation as the SQL runtime. + +Delivery is at least once. An effect can reach its external service before the +acknowledgement is stored. Deduplicate using `EffectContext.id`. `sendTo()` stores +an outbound intent in the source object and deduplicates acceptance in the +destination. This is eventual delivery, not a cross-object transaction. +Actors cannot synchronously call or wait on other actors. + +One alarm covers the next due mailbox turn, retry, reminder, outbox delivery, +subscription expiry, or retention deadline. Persistent retry state supplements +Cloudflare's finite automatic alarm retries. Idle objects have no recurring +polling loop. Completed records default to 30 days of retention; dead letters +remain available for inspection. Idempotency receipts expire with their messages, +so callers must not rely on deduplication after the retention window. + +An invocation timeout after acceptance raises `SyncTimeout` with a recoverable +`messageReference`. If RPC fails before an acceptance response arrives, +`EnqueueOutcomeUnknown.details` contains `actorType`, `actorId`, and `requestId`: + +```typescript +const message = await runtime.lookupMessage({ + ...error.details, + authorizationContext: subject, +}) +if (message) await message.wait({ authorizationContext: subject }) +``` + +A missing lookup is not proof that an outstanding enqueue cannot still arrive. +Look up again, or retry the application operation with its original explicit +idempotency key. Lookup requires query authorization for `__lookupMessage__`; +an existing message also requires authorization for its original operation. + +Use `runtime.actorAdministration({ actorType, actorId, authorizationContext })` +for `deadLetters()`, `retryDeadLetter(id)`, `reminders()`, and +`resumeReminder({ name, runAt })`. Inspection is bounded to 1,000 records per +call. These operations call `authorizeAdministration` with resource +`actor:` followed by the canonical JSON identity tuple. + +## Realtime sessions + +The application authenticates its HTTP upgrade before calling +`runtime.openWebSocket({ sessionId, expiresAt })`. Forward the returned response +to the browser. `sessionId` must be an opaque application session reference, +not a bearer credential or serialized user object. Never forward client-supplied +`X-Solid-Session-*` headers to the session namespace. + +The session host's `resolveAuthorizationContext({ sessionId, environment })` +loads current authorization data and returns JSON, or `null` when access has +expired or been revoked. It runs when opening the connection, subscribing, and +delivering events. Actor policies still authorize subscriptions and each named +personalized payload. Authentication remains application-owned. + +One hibernating WebSocket multiplexes up to 100 actors by default. Configure +`maxSubscriptions` on the session host to change that limit. The browser client, +version-1 envelopes, value versus invalidation projections, personalized +payloads, and revision fences use the existing protocol. + +The handshake uses Fetch because WebSocket upgrades cannot travel through +ordinary RPC. Actor-to-session events use RPC. Durable registrations survive +hibernation; disconnect and expiry trigger registration cleanup. Reconnects +replay current committed projections, not every event missed while offline. + +## Capability boundaries + +| Surface | Cloudflare backend | +| ------------------------------------------------------------------ | -------------------------------------- | +| Actor operations, getters, snapshots, state migrations | Supported | +| Durable sends, results, retries, rejection, destruction | Supported | +| Effects, reminders, cross-actor `sendTo()` | Supported | +| Browser subscriptions and personalized payloads | Supported through session hosts | +| Actor-scoped dead letters and reminder administration | Supported | +| `commitAction`, shared application SQL transactions | Unsupported | +| Global repository, reconciliation, process controls, SQL dashboard | Unsupported | +| Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions | +| SQL-to-Durable-Objects data migration | Not provided | + +Unsupported runtime facilities raise `UnsupportedCapability`; staging a commit +action permanently fails that turn before any state or intent commits. +Application and effect code must run within Workers' APIs and execution limits. +Arbitrary JavaScript is cooperative; caller timeout does not preempt it. +Eviction may discard private fields, and does not guarantee `onDeactivate()`. + +State defaults to a 1 MiB limit. Encoded records, including indexed copies of +fields, must also fit Cloudflare's 2 MB SQLite row limit. Oversized records raise +`PayloadTooLarge`; increasing a configured payload limit cannot bypass the +[platform limits](https://developers.cloudflare.com/durable-objects/platform/limits/). + +Cloudflare hosting is JavaScript-specific. It does not change Ruby's SQL backend +or the guarantees of the existing SQL drivers. + +## Validate and release + +`pnpm run test:cloudflare` runs the Workers integration suite. `pnpm run check` +type-checks the separate Workers target alongside Node; `pnpm run check:cloudflare` +checks the example bundle and its imports. Keep backend schema changes additive. +Once an actor persists a newer state version, rollback to older actor code may +be unsafe; test that rollback against the stored version before deploying it. + +Before calling this backend stable, run a deployed soak in a disposable +namespace covering alarms without traffic, eviction/restart recovery, +hibernation, and slow external effects. Local Workers tests do not establish +Cloudflare production failover behavior. diff --git a/docs/parity.md b/docs/parity.md index 2d88638..433ff0a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -40,6 +40,10 @@ such boundary between a gem and its dependents. ## Runtime and correctness +SQL waiters read results and status from one statement. Ruby already checks +completion and returns the result from the same loaded message; no Ruby change +is needed for the JavaScript stale-result race fix. + | Capability | Status | TypeScript shape or remaining work | | ------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. | @@ -180,6 +184,21 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it found (the optional `arguments` default) is fixed and pinned by the shared fixture. +## JavaScript-only: Cloudflare hosting + +The experimental `solid-objects/cloudflare` backend hosts each actor identity +in a SQLite-backed Durable Object. Portable actor definitions, turn evaluation, +state migrations, and authorization are shared with the SQL runtime. Alarms +drive durable mailbox/outbox recovery, and session Durable Objects host +hibernating browser subscriptions. + +Ruby hosting on Cloudflare is **Not applicable**. This backend introduces no +change to Ruby's SQL behavior or roadmap. Its JavaScript support is **Partial** +until deployed failover and soak validation completes. Shared SQL transactions, +commit actions, fleet administration, reconciliation, and the SQL dashboard +are explicit unsupported capabilities. The [backend matrix](cloudflare.md#capability-boundaries) +records these boundaries separately from SQL-runtime parity. + ## Rails-specific surfaces Rails generators, Active Record models/controllers, Turbo rendering, and diff --git a/docs/support.md b/docs/support.md index 800cff0..7f332ff 100644 --- a/docs/support.md +++ b/docs/support.md @@ -2,17 +2,18 @@ ## Runtime support -| Component | Supported or tested range | -| --------------- | ----------------------------------------------------------- | -| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 | -| TypeScript | 5.9 or newer for TypeScript applications | -| SQLite | Node's built-in `node:sqlite` on the supported Node runtime | -| PostgreSQL | 14 or newer; CI runs 14 and 18 | -| MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 | -| Redis wake-up | Optional; CI runs Redis 7 | -| Browser client | Chromium through Playwright | -| SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional | -| Browser runtime | Chromium through Playwright; OPFS for persistent storage | +| Component | Supported or tested range | +| ------------------ | ------------------------------------------------------------------ | +| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 | +| TypeScript | 5.9 or newer for TypeScript applications | +| SQLite | Node's built-in `node:sqlite` on the supported Node runtime | +| PostgreSQL | 14 or newer; CI runs 14 and 18 | +| MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 | +| Redis wake-up | Optional; CI runs Redis 7 | +| Browser client | Chromium through Playwright | +| SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional | +| Browser runtime | Chromium through Playwright; OPFS for persistent storage | +| Cloudflare runtime | Experimental; Workers integration tests and Wrangler bundle checks | The package is ESM-only. PostgreSQL, MySQL, Redis, and SQLite WASM require their optional peer dependency. The Node SQLite adapter has no driver diff --git a/examples/cloudflare/README.md b/examples/cloudflare/README.md new file mode 100644 index 0000000..b62b95b --- /dev/null +++ b/examples/cloudflare/README.md @@ -0,0 +1,36 @@ +# Cloudflare counter + +This is an intentionally public counter. Anyone can increment it. Its policies +grant access only to `Counter("public-demo")`; destruction and administration +remain denied. Replace the demo session resolver with your application's +session lookup before using this example for private data. + +From the repository root: + +```sh +pnpm install +pnpm run build +pnpm run dev:cloudflare +``` + +In another terminal: + +```sh +curl http://localhost:8787/counter +curl -X POST http://localhost:8787/increment +curl -X POST http://localhost:8787/increment-later +``` + +The last call schedules an increment five seconds later. Stop the local server +and start it again to verify persistence. Local state lives in Wrangler's +`.wrangler` directory. Each actor identity has its own SQLite-backed Durable +Object; browser connections use the separate `Sessions` class. + +Connect the existing `SolidObjectsBrowserClient` to `/events` and subscribe to +`{ actorType: "Counter", actorId: "public-demo" }`. The connection expires after +one hour. Reconnect and resubscribe to obtain the current committed projection. + +`pnpm run check:cloudflare` validates the production bundle without uploading it. +To deploy this example to your own account, run `fnox exec -- pnpm run deploy:cloudflare`. +Wrangler creates the two SQLite-backed namespaces using the configuration's +class migrations. No D1 database or external broker is required. diff --git a/examples/cloudflare/counter.ts b/examples/cloudflare/counter.ts new file mode 100644 index 0000000..b3ea3de --- /dev/null +++ b/examples/cloudflare/counter.ts @@ -0,0 +1,19 @@ +import { Actor, broadcastValue } from "solid-objects/core" + +export class Counter extends Actor { + static override readonly actorType = "Counter" + count = 0 + + increment(): number { + this.count += 1 + return this.count + } + + incrementLater(): void { + this.schedule({ at: new Date(Date.now() + 5_000) }).increment!() + } + + override observables() { + return { count: broadcastValue(this.count) } + } +} diff --git a/examples/cloudflare/environment.d.ts b/examples/cloudflare/environment.d.ts new file mode 100644 index 0000000..0060ca3 --- /dev/null +++ b/examples/cloudflare/environment.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=examples/cloudflare/wrangler.jsonc --include-runtime=false examples/cloudflare/environment.d.ts` (hash: 30c3823992a5154da09c838c96a8f2fb) +interface __BaseEnv_Env { + ACTORS: DurableObjectNamespace + SESSIONS: DurableObjectNamespace +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./worker") + durableNamespaces: "Actors" | "Sessions" + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/examples/cloudflare/worker.ts b/examples/cloudflare/worker.ts new file mode 100644 index 0000000..5788a07 --- /dev/null +++ b/examples/cloudflare/worker.ts @@ -0,0 +1,70 @@ +import { + createDurableObjectsHost, + createDurableObjectsSessionHost, + createRuntime, + durableObjects, + type CloudflareConfiguration, +} from "solid-objects/cloudflare" +import { Counter } from "./counter.js" + +function backend(environment: Env) { + return durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS }) +} + +function publicCounter(input: { + actorType: string + actorId: string + authorizationContext: unknown +}): boolean { + return ( + input.actorType === "Counter" && + input.actorId === "public-demo" && + input.authorizationContext === "public-demo" + ) +} + +export class Actors extends createDurableObjectsHost({ + actors: [Counter], + configure: (environment): CloudflareConfiguration => ({ + backend: backend(environment), + authorizeMessage: publicCounter, + authorizeQuery: publicCounter, + authorizeSubscription: publicCounter, + }), +}) {} + +export class Sessions extends createDurableObjectsSessionHost({ + backend, + resolveAuthorizationContext: ({ sessionId }) => + sessionId === "public-demo" ? "public-demo" : null, +}) {} + +export default { + async fetch(request: Request, environment: Env): Promise { + const url = new URL(request.url) + const runtime = createRuntime({ backend: backend(environment) }) + const counter = runtime + .ref(Counter, "public-demo") + .with({ authorizationContext: "public-demo" }) + if (url.pathname === "/events" && request.headers.get("Upgrade") === "websocket") { + const origin = request.headers.get("Origin") + if (origin !== null && origin !== url.origin) + return new Response("Forbidden", { status: 403 }) + return runtime.openWebSocket({ + sessionId: "public-demo", + expiresAt: new Date(Date.now() + 3_600_000), + }) + } + if (request.method === "GET" && url.pathname === "/counter") + return Response.json({ count: await counter.count }) + if (request.method === "POST" && url.pathname === "/increment") + return Response.json({ count: await counter.increment() }) + if (request.method === "POST" && url.pathname === "/increment-later") { + await counter.incrementLater() + return new Response(null, { status: 202 }) + } + return new Response("GET /counter; POST /increment; POST /increment-later; WebSocket /events", { + status: url.pathname === "/" ? 200 : 404, + }) + }, +} satisfies ExportedHandler diff --git a/examples/cloudflare/wrangler.jsonc b/examples/cloudflare/wrangler.jsonc new file mode 100644 index 0000000..6ef7757 --- /dev/null +++ b/examples/cloudflare/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "solid-objects-counter", + "main": "worker.ts", + "compatibility_date": "2026-09-04", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "ACTORS", "class_name": "Actors" }, + { "name": "SESSIONS", "class_name": "Sessions" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Actors", "Sessions"] }], + "observability": { "enabled": true }, +} diff --git a/package.json b/package.json index ae6d664..34d6875 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,14 @@ "MIT-LICENSE" ], "exports": { + "./core": { + "types": "./dist/core.d.ts", + "import": "./dist/core.js" + }, + "./cloudflare": { + "types": "./dist/cloudflare/index.d.ts", + "import": "./dist/cloudflare/index.js" + }, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" @@ -92,8 +100,12 @@ } }, "scripts": { - "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs", - "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit", + "test:cloudflare": "vitest run --config vitest.cloudflare.config.ts", + "dev:cloudflare": "wrangler dev --config examples/cloudflare/wrangler.jsonc", + "deploy:cloudflare": "wrangler deploy --config examples/cloudflare/wrangler.jsonc", + "check:cloudflare": "node scripts/check-cloudflare-imports.mjs && tsc -p tsconfig.cloudflare-example.json --noEmit && wrangler deploy --dry-run --config examples/cloudflare/wrangler.jsonc", + "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.cloudflare-build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs", + "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit && tsc -p tsconfig.cloudflare.json --noEmit", "check:browser-imports": "node scripts/check-browser-imports.mjs", "check:documentation": "node scripts/check-documentation.mjs", "check:parameters": "node scripts/check-parameter-style.mjs", @@ -117,6 +129,8 @@ "prepack": "pnpm run build" }, "devDependencies": { + "@cloudflare/vitest-plugin": "^1.1.4", + "@cloudflare/workers-types": "^5.20260904.1", "@playwright/test": "^1.62.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", "@types/node": "^24.0.0", @@ -130,14 +144,15 @@ "signal-polyfill": "^0.2.2", "typescript": "^5.9.0", "vitest": "^4.1.10", + "wrangler": "^4.129.0", "ws": "^8.21.3" }, "peerDependencies": { "@sqlite.org/sqlite-wasm": ">=3.50.0-build1", - "signal-polyfill": ">=0.2.2", "mysql2": "^3.23.3", "pg": "^8.23.0", - "redis": "^6.2.1" + "redis": "^6.2.1", + "signal-polyfill": ">=0.2.2" }, "peerDependenciesMeta": { "@sqlite.org/sqlite-wasm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74c1829..d63f715 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + '@cloudflare/vitest-plugin': + specifier: ^1.1.4 + version: 1.1.4(@cloudflare/workers-types@5.20260904.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + '@cloudflare/workers-types': + specifier: ^5.20260904.1 + version: 5.20260904.1 '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -46,7 +52,10 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + wrangler: + specifier: ^4.129.0 + version: 4.129.0(@cloudflare/workers-types@5.20260904.1) ws: specifier: ^8.21.3 version: 8.21.3 @@ -74,6 +83,384 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-plugin@1.1.4': + resolution: {integrity: sha512-fzZFMo8NUv1S2lMPDgZYq1jg+0FwnrkF28iYzjFuN/Z1c0Fsnp6xrjyxapLaqrTqjDO66fvKTgbHL0yV1tZnPQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260903.1': + resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260903.1': + resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260903.1': + resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260903.1': + resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260903.1': + resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260904.1': + resolution: {integrity: sha512-SbulPvrdb4j5iISDr81Iw+YpNCG4Bb6xRqV3arkgn/Ib6STMaoG63lhPnuWqmZdakOn0qS1OUudBMdxtBFx0Jg==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -84,6 +471,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} @@ -92,6 +482,15 @@ packages: engines: {node: '>=20'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@redis/bloom@6.2.1': resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} engines: {node: '>= 20.0.0'} @@ -221,6 +620,13 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@sqlite.org/sqlite-wasm@3.53.0-build1': resolution: {integrity: sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==} engines: {node: '>=22'} @@ -295,10 +701,16 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -306,13 +718,25 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -371,6 +795,10 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -462,6 +890,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + miniflare@5.20260903.0-alpha: + resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} + engines: {node: '>=22.0.0'} + mysql2@3.23.3: resolution: {integrity: sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==} engines: {node: '>= 8.0'} @@ -481,6 +913,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -577,6 +1012,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -601,6 +1040,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -620,6 +1063,9 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -628,6 +1074,13 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + vite@8.2.1: resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -717,6 +1170,33 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20260903.1: + resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.129.0: + resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260903.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -733,6 +1213,15 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/helper-string-parser@7.29.7': {} @@ -750,6 +1239,239 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260903.1 + + '@cloudflare/vitest-plugin@1.1.4(@cloudflare/workers-types@5.20260904.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + dependencies: + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260903.0-alpha + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + wrangler: 4.129.0(@cloudflare/workers-types@5.20260904.1) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260903.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260903.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260903.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260903.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260903.1': + optional: true + + '@cloudflare/workers-types@5.20260904.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -759,12 +1481,29 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@oxc-project/types@0.143.0': {} '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@redis/bloom@6.2.1(@redis/client@6.2.1)': dependencies: '@redis/client': 6.2.1 @@ -829,6 +1568,10 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@sqlite.org/sqlite-wasm@3.53.0-build1': {} '@standard-schema/spec@1.1.0': {} @@ -868,7 +1611,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) '@vitest/expect@4.1.10': dependencies: @@ -879,13 +1622,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3))': + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@24.13.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1) '@vitest/pretty-format@4.1.10': dependencies: @@ -921,16 +1664,53 @@ snapshots: aws-ssl-profiles@1.1.2: {} + blake3-wasm@2.1.5: {} + chai@6.2.2: {} + cjs-module-lexer@1.2.3: {} + cluster-key-slot@1.1.2: {} convert-source-map@2.0.0: {} + cookie@1.1.1: {} + detect-libc@2.1.2: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.1: {} + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -976,6 +1756,8 @@ snapshots: js-tokens@10.0.0: {} + kleur@4.1.5: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1043,6 +1825,18 @@ snapshots: dependencies: semver: 7.8.5 + miniflare@5.20260903.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260903.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + mysql2@3.23.3(@types/node@24.13.3): dependencies: '@types/node': 24.13.3 @@ -1062,6 +1856,8 @@ snapshots: obug@2.1.4: {} + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} pg-cloudflare@1.4.0: @@ -1164,6 +1960,38 @@ snapshots: semver@7.8.5: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + siginfo@2.0.0: {} signal-polyfill@0.2.2: {} @@ -1178,6 +2006,8 @@ snapshots: std-env@4.2.0: {} + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -1193,11 +2023,20 @@ snapshots: tinyrainbow@3.1.1: {} + tslib@2.8.1: + optional: true + typescript@5.9.3: {} undici-types@7.18.2: {} - vite@8.2.1(@types/node@24.13.3): + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -1206,12 +2045,13 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 + esbuild: 0.28.1 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1228,7 +2068,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@24.13.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -1241,6 +2081,48 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20260903.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260903.1 + '@cloudflare/workerd-darwin-arm64': 1.20260903.1 + '@cloudflare/workerd-linux-64': 1.20260903.1 + '@cloudflare/workerd-linux-arm64': 1.20260903.1 + '@cloudflare/workerd-windows-64': 1.20260903.1 + + wrangler@4.129.0(@cloudflare/workers-types@5.20260904.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260903.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260903.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260904.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + ws@8.21.3: {} xtend@4.0.2: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..02365ef --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + workerd: true diff --git a/scripts/check-cloudflare-imports.mjs b/scripts/check-cloudflare-imports.mjs new file mode 100644 index 0000000..fd132a8 --- /dev/null +++ b/scripts/check-cloudflare-imports.mjs @@ -0,0 +1,32 @@ +import fs from "node:fs" +import path from "node:path" +import assert from "node:assert/strict" +import ts from "typescript" + +const visited = new Set() +const forbidden = new Set(["pg", "mysql2", "redis", "ws"]) +visit(path.resolve("src/cloudflare/index.ts")) + +function visit(file) { + if (visited.has(file)) return + visited.add(file) + const source = ts.createSourceFile( + file, + fs.readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ) + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue + if (ts.isImportDeclaration(statement) && statement.importClause?.isTypeOnly) continue + if (ts.isExportDeclaration(statement) && statement.isTypeOnly) continue + const specifier = statement.moduleSpecifier + if (!specifier || !ts.isStringLiteral(specifier)) continue + const name = specifier.text + assert( + !forbidden.has(name) && (!name.startsWith("node:") || name === "node:async_hooks"), + `${file} imports unsupported Cloudflare dependency ${name}`, + ) + if (name.startsWith(".")) visit(path.resolve(path.dirname(file), name.replace(/\.js$/, ".ts"))) + } +} diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index 6345ba1..eb4586d 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -11,6 +11,7 @@ const documentationPaths = [ "docs/authorization.md", "docs/browser-protocol.md", "docs/configuration.md", + "docs/cloudflare.md", "docs/correctness.md", "docs/dashboard.md", "docs/errors-and-recovery.md", @@ -54,6 +55,7 @@ const configurationReference = await readFile( ) const entryPoints = [ "src/index.ts", + "src/cloudflare/index.ts", "src/database/sqlite.ts", "src/database/sqlite-wasm.ts", "src/database/shared-sqlite-wasm.ts", diff --git a/scripts/release-artifact-smoke.mjs b/scripts/release-artifact-smoke.mjs index f17b72e..fb1ef37 100644 --- a/scripts/release-artifact-smoke.mjs +++ b/scripts/release-artifact-smoke.mjs @@ -27,6 +27,10 @@ try { const packagedPaths = new Set(packed.files.map((file) => file.path)) for (const expectedPath of [ "dist/index.js", + "dist/core.js", + "dist/cloudflare/index.js", + "dist/cloudflare/host.d.ts", + "docs/cloudflare.md", "dist/executable.js", "dist/examples/sqlite-quickstart.js", "examples/sqlite-quickstart.ts", diff --git a/src/actor-runtime.ts b/src/actor-runtime.ts new file mode 100644 index 0000000..faa067b --- /dev/null +++ b/src/actor-runtime.ts @@ -0,0 +1,65 @@ +import type { RealtimeManager } from "./realtime.js" +import type { Actor, ActorClass } from "./actor.js" +import type { + ActorReference, + ActorReferenceCore, + ActorSnapshot, + MessageReference, +} from "./reference.js" +import type { + ActorIdentifier, + AsyncInvocationOptions, + DeepReadonly, + DestroyOptions, + InvocationOptions, + JsonObject, + Logger, + MessageStatus, + SnapshotOptions, +} from "./types.js" + +export interface SnapshotWithIncarnation { + snapshot: ActorSnapshot + instanceId: string + revision: string + createdAtMs: number +} + +export interface ActorRuntime { + ref( + actorClass: ActorClass, + actorId: ActorIdentifier, + ): ActorReference + invoke(options: { + reference: ActorReferenceCore + operation: string + argumentsValue?: JsonObject + options?: InvocationOptions + }): Promise> + sendMessage(options: { + reference: ActorReferenceCore + operation: string + argumentsValue?: JsonObject + options?: AsyncInvocationOptions + }): Promise> + wait( + reference: MessageReference, + options?: InvocationOptions, + ): Promise> + messageStatus(reference: MessageReference, options?: SnapshotOptions): Promise + messageResult( + reference: MessageReference, + options?: SnapshotOptions, + ): Promise | undefined> + snapshot( + reference: ActorReferenceCore, + options?: SnapshotOptions, + ): Promise> + snapshotWithIncarnation( + reference: ActorReferenceCore, + options?: SnapshotOptions, + ): Promise> + destroy(reference: ActorReferenceCore, options?: DestroyOptions): Promise + readonly settings: { readonly logger: Logger } + readonly realtime: Pick +} diff --git a/src/browser/index.ts b/src/browser/index.ts index bca42f0..f48a523 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -64,7 +64,7 @@ export class SolidObjectsBrowserClient { connect(): void { if (this.#socket && this.#socket.readyState < WebSocket.CLOSING) return const socket = - this.#options.createWebSocket?.(this.#options.url) ?? new WebSocket(this.#options.url) + this.#options.createWebSocket?.(this.#options.url) ?? new WebSocket(String(this.#options.url)) socket.addEventListener("open", () => { for (const subscription of this.#subscriptions.values()) this.sendSubscription(subscription) }) diff --git a/src/cloudflare/configuration.ts b/src/cloudflare/configuration.ts new file mode 100644 index 0000000..1110b55 --- /dev/null +++ b/src/cloudflare/configuration.ts @@ -0,0 +1,82 @@ +import type { SolidObjectsConfiguration } from "../configuration.js" +import type { EffectContext, JsonObject, Logger } from "../types.js" +import type { DurableObjectsBackend } from "./protocol.js" + +export type EffectHandler = ( + argumentsValue: JsonObject, + context: EffectContext, +) => unknown | Promise + +export type CloudflareConfiguration = Pick< + SolidObjectsConfiguration, + | "authorizeMessage" + | "authorizeQuery" + | "authorizeDestroy" + | "authorizeSubscription" + | "authorizeAdministration" + | "instrumentation" + | "logger" + | "maxAttempts" + | "maxMailboxLength" + | "maxPayloadBytes" + | "maxStateBytes" + | "maxResultBytes" + | "maxMessagesPerActivationPass" + | "maxActivationDurationMilliseconds" + | "retryDelayMilliseconds" + | "messageRetentionMilliseconds" + | "pruneBatchSize" +> & { + backend: DurableObjectsBackend + effects?: Readonly> +} + +export function buildCloudflareSettings(configuration: CloudflareConfiguration) { + const settings = { + ...configuration, + authorizeMessage: configuration.authorizeMessage ?? (() => false), + authorizeQuery: configuration.authorizeQuery ?? (() => false), + authorizeDestroy: configuration.authorizeDestroy ?? (() => false), + authorizeSubscription: configuration.authorizeSubscription ?? (() => false), + authorizeAdministration: configuration.authorizeAdministration ?? (() => false), + effects: configuration.effects ?? {}, + logger: configuration.logger ?? consoleLogger, + maxAttempts: configuration.maxAttempts ?? 5, + maxMailboxLength: configuration.maxMailboxLength ?? 10_000, + maxPayloadBytes: configuration.maxPayloadBytes ?? 1_048_576, + maxStateBytes: configuration.maxStateBytes ?? 1_048_576, + maxResultBytes: configuration.maxResultBytes ?? 1_048_576, + maxMessagesPerActivationPass: configuration.maxMessagesPerActivationPass ?? 50, + maxActivationDurationMilliseconds: configuration.maxActivationDurationMilliseconds ?? 5_000, + retryDelayMilliseconds: + configuration.retryDelayMilliseconds ?? + ((attempt: number) => Math.min(2 ** (attempt - 1), 60) * 1_000), + messageRetentionMilliseconds: configuration.messageRetentionMilliseconds ?? 30 * 86_400_000, + pruneBatchSize: configuration.pruneBatchSize ?? 1_000, + } + for (const name of [ + "maxAttempts", + "maxMailboxLength", + "maxPayloadBytes", + "maxStateBytes", + "maxResultBytes", + "maxMessagesPerActivationPass", + "maxActivationDurationMilliseconds", + "messageRetentionMilliseconds", + "pruneBatchSize", + ] as const) { + if (!Number.isSafeInteger(settings[name]) || settings[name] <= 0) { + throw new TypeError(`${name} must be a positive safe integer`) + } + } + return settings +} + +export type CloudflareSettings = ReturnType + +export const consoleLogger: Logger = { + debug: (entry) => console.debug(entry), + info: (entry) => console.info(entry), + warn: (entry) => console.warn(entry), + error: (entry) => console.error(entry), +} diff --git a/src/cloudflare/engine.ts b/src/cloudflare/engine.ts new file mode 100644 index 0000000..e205f4a --- /dev/null +++ b/src/cloudflare/engine.ts @@ -0,0 +1,1027 @@ +import type { Actor, ActorClass, ActorIntents } from "../actor.js" +import { withActorContext, withActorProjection, withRuntime } from "../context.js" +import { + actorState, + hydrateActor, + initialStateFor, + migrateState, + validateDefinition, + type ValidatedActorDefinition, +} from "../definition.js" +import { + ActorDestroyed, + IdempotencyConflict, + MailboxFull, + NonRetryableError, + PayloadTooLarge, + QueryMutatedState, + Rejected, + ReminderNotPaused, + Unauthorized, + UnknownActorType, + UnknownDeadLetter, + UnknownEffect, + UnknownOperation, + UnknownPayloadBroadcast, + UnknownReminder, + UnsupportedCapability, +} 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 { CloudflareSettings } from "./configuration.js" +import { actorName, callHost, type ActorIdentity, type HostRequest } from "./protocol.js" +import type { Instance, Message, Outbox, Reminder, Subscription } from "./records.js" +import { beforeDeadline, CloudflareRuntime } from "./runtime.js" +import { ActorStorage } from "./storage.js" + +const RECOVERY_INTERVAL = 30_000 + +export class ActorEngine { + readonly runtime: CloudflareRuntime + private readonly definitions = new Map() + private actorRunning = false + private readonly delivering = new Set() + private cached: { incarnation: string; actor: Actor } | undefined + + constructor( + readonly store: ActorStorage, + actors: readonly ActorClass[], + ) { + this.runtime = new CloudflareRuntime(store.settings.backend) + for (const actor of actors) { + const definition = validateDefinition(actor) + if (this.definitions.has(definition.type)) + throw new TypeError(`duplicate actor type ${definition.type}`) + this.definitions.set(definition.type, definition) + } + } + + get settings(): CloudflareSettings { + return this.store.settings + } + + async request(input: HostRequest): Promise { + if (input.method === "enqueue" || input.method === "internal") { + const operation = String(input.payload.operation) + if (input.method === "enqueue") + await this.authorizeOperation(input, { + operation, + arguments: jsonObject(input.payload.arguments), + }) + this.bind(input) + return this.store.atomic(() => normalizeJson(this.enqueue(input))) + } + if (input.method === "message" || input.method === "lookup") return this.readMessage(input) + if (input.method === "administration") return this.administer(input) + if (input.method === "destroy") { + if (!(await this.settings.authorizeDestroy(input))) + throw new Unauthorized("actor destruction is not authorized") + this.bind(input) + return this.store.atomic(() => this.destroy()) + } + if (input.method === "unsubscribe") { + this.bind(input) + await this.store.atomic(() => { + this.store.storage.sql.exec( + "DELETE FROM subscriptions WHERE id = ?", + String(input.payload.subscriptionId), + ) + this.store.storage.sql.exec( + "DELETE FROM outboxes WHERE kind = 'broadcast' AND destination = ?", + String(input.payload.subscriptionId), + ) + }) + return null + } + if (input.method === "snapshot") { + if ( + !(await this.settings.authorizeQuery({ + ...input, + operation: "__snapshot__", + arguments: {}, + })) + ) + throw new Unauthorized("actor snapshot is not authorized") + this.bind(input) + return this.snapshot(input) + } + if (!(await this.settings.authorizeSubscription(input))) + throw new Unauthorized("actor subscription is not authorized") + this.bind(input) + const payloadNames = stringList(input.payload.payloads ?? []) + const definition = this.definition(input.actorType) + for (const name of payloadNames) { + if (!definition.payloads[name]) throw new UnknownPayloadBroadcast(`unknown payload ${name}`) + } + if (input.method === "subscribe") { + const expiresAt = Number(input.payload.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) + throw new Unauthorized("subscription expired") + await this.store.atomic(() => + this.store.saveSubscription({ + id: String(input.payload.subscriptionId), + sessionName: String(input.payload.sessionName), + payloads: payloadNames, + expiresAt, + }), + ) + } + return this.projection({ input, payloadNames }) + } + + async pump(): Promise { + await this.store.atomic(() => { + this.store.prune() + this.scheduleReminders() + if (this.actorRunning) { + const head = this.store.head() + if (head?.status === "claimed") { + head.availableAt = Date.now() + RECOVERY_INTERVAL + this.store.saveMessage(head) + } + } + for (const outbox of this.store.outboxHeads()) { + if (!this.delivering.has(outbox.id)) continue + outbox.availableAt = Date.now() + RECOVERY_INTERVAL + this.store.saveOutbox(outbox) + } + }) + const work: Promise[] = [] + if (!this.actorRunning) work.push(this.drainActors()) + for (const outbox of this.store.outboxHeads()) { + if (outbox.availableAt > Date.now() || this.delivering.has(outbox.id)) continue + work.push(this.deliver(outbox)) + } + await Promise.all(work) + await this.store.atomic(() => undefined) + } + + private bind(identity: ActorIdentity): void { + const name = actorName(identity) + const existing = this.store.metadata("identity") + if (existing !== undefined && existing !== name) + throw new Unauthorized("Durable Object identity mismatch") + this.definition(identity.actorType) + if (existing === undefined) this.store.saveMetadata("identity", name) + } + + private definition(type: string): ValidatedActorDefinition { + const definition = this.definitions.get(type) + if (!definition) throw new UnknownActorType(`unknown actor type ${type}`) + return definition + } + + private ensureInstance(identity: ActorIdentity): Instance { + const existing = this.store.instance() + if (existing) return existing + const definition = this.definition(identity.actorType) + const incarnationOrder = String( + BigInt(this.store.metadata("incarnationOrder") ?? "0") + 1n, + ) + this.store.saveMetadata("incarnationOrder", incarnationOrder) + const instance: Instance = { + actorType: identity.actorType, + actorId: identity.actorId, + incarnation: crypto.randomUUID(), + incarnationOrder, + generation: "1", + revision: "0", + nextSequence: "1", + state: initialStateFor(definition), + stateVersion: definition.stateVersion, + createdAt: Date.now(), + paused: false, + } + this.store.saveInstance(instance) + return instance + } + + private enqueue(input: HostRequest): Message { + const definition = this.definition(input.actorType) + const operation = String(input.payload.operation) + const deliveryMode = input.method === "internal" ? "internal" : input.payload.deliveryMode + if (deliveryMode !== "sync" && deliveryMode !== "async" && deliveryMode !== "internal") + throw new TypeError("invalid delivery mode") + if ( + !definition.operations.includes(operation) && + !(deliveryMode === "sync" && definition.queries.includes(operation)) + ) + throw new UnknownOperation(`unknown operation ${operation}`) + const argumentsValue = jsonObject(input.payload.arguments, { + maxBytes: this.settings.maxPayloadBytes, + }) + const requestId = String(input.payload.requestId) + if (requestId.length === 0 || requestId.length > 512) throw new TypeError("invalid request ID") + const availableAt = Number(input.payload.availableAt) + if (!Number.isFinite(availableAt)) throw new TypeError("invalid availability time") + const idempotencyKey = + input.payload.idempotencyKey === null || input.payload.idempotencyKey === undefined + ? null + : String(input.payload.idempotencyKey) + const instance = this.ensureInstance(input) + const receipt = this.store.storage.sql + .exec<{ message_id: string }>( + "SELECT message_id FROM receipts WHERE request_id = ?", + requestId, + ) + .toArray()[0] + const previous = receipt + ? this.store.message(receipt.message_id) + : idempotencyKey === null + ? undefined + : this.store.rows( + "SELECT record FROM messages WHERE incarnation = ? AND idempotency_key = ?", + [instance.incarnation, idempotencyKey], + )[0] + if (previous) { + if (previous.incarnation !== instance.incarnation) + throw new ActorDestroyed("the accepted message belongs to a destroyed actor") + if ( + previous.operation !== operation || + previous.deliveryMode !== deliveryMode || + stableJson(previous.arguments) !== stableJson(argumentsValue) + ) + throw new IdempotencyConflict( + "request ID or idempotency key already identifies different work", + ) + this.store.storage.sql.exec( + "INSERT OR IGNORE INTO receipts(request_id, message_id) VALUES (?, ?)", + requestId, + previous.id, + ) + return previous + } + const count = this.store.storage.sql + .exec<{ count: number }>( + "SELECT COUNT(*) AS count FROM messages WHERE incarnation = ? AND status IN ('ready', 'claimed')", + instance.incarnation, + ) + .one().count + if (count >= this.settings.maxMailboxLength) throw new MailboxFull("actor mailbox is full") + if (BigInt(instance.nextSequence) > 9_223_372_036_854_775_807n) + throw new NonRetryableError("actor mailbox sequence exhausted") + const message: Message = { + id: crypto.randomUUID(), + requestId, + incarnation: instance.incarnation, + sequence: instance.nextSequence, + operation, + arguments: argumentsValue, + deliveryMode, + idempotencyKey, + status: "ready", + attempt: 0, + availableAt, + createdAt: Date.now(), + completedAt: null, + result: null, + error: null, + rejection: null, + generation: null, + reminder: null, + } + instance.nextSequence = String(BigInt(instance.nextSequence) + 1n) + this.store.saveInstance(instance) + this.store.saveMessage(message) + this.store.storage.sql.exec( + "INSERT INTO receipts(request_id, message_id) VALUES (?, ?)", + requestId, + message.id, + ) + return message + } + + private async authorizeOperation( + input: HostRequest, + message: { operation: string; arguments: JsonObject }, + ): Promise { + const query = + this.definitions.get(input.actorType)?.queries.includes(message.operation) ?? false + const policy = query ? this.settings.authorizeQuery : this.settings.authorizeMessage + if (!(await policy({ ...input, operation: message.operation, arguments: message.arguments }))) + throw new Unauthorized("actor operation is not authorized") + } + + private async readMessage(input: HostRequest): Promise { + let message: Message | undefined + if (input.method === "lookup") { + if ( + !(await this.settings.authorizeQuery({ + ...input, + operation: "__lookupMessage__", + arguments: {}, + })) + ) + throw new Unauthorized("message lookup is not authorized") + const receipt = this.store.storage.sql + .exec<{ message_id: string }>( + "SELECT message_id FROM receipts WHERE request_id = ?", + String(input.payload.requestId), + ) + .toArray()[0] + message = receipt ? this.store.message(receipt.message_id) : undefined + if (!message) return null + } else { + message = this.store.message(String(input.payload.id)) + if ( + !message || + message.requestId !== input.payload.requestId || + message.sequence !== input.payload.sequence + ) + throw new Unauthorized("message reference is not authorized") + } + await this.authorizeOperation(input, message) + this.bind(input) + if (message.incarnation !== this.store.instance()?.incarnation) + throw new ActorDestroyed("actor was destroyed") + return normalizeJson(message) + } + + private committed(identity: ActorIdentity) { + const definition = this.definition(identity.actorType) + const instance = this.store.instance() + const state = instance + ? migrateState({ + definition, + storedVersion: instance.stateVersion, + storedState: instance.state, + }) + : initialStateFor(definition) + return { definition, instance, state } + } + + private async snapshot(identity: ActorIdentity): Promise { + const { definition, instance, state } = this.committed(identity) + const actor = hydrateActor({ definition, actorId: identity.actorId, state }) + const before = stableJson(actorState(actor, definition.stateKeys)) + const snapshot: JsonObject = { ...state } + await withActorProjection({ actor, runtime: this.runtime }, async () => { + for (const query of definition.queries) { + if (definition.stateKeys.includes(query)) continue + const value = await actor.invoke(query, {}) + snapshot[query] = normalizeJson(value === undefined ? null : value, { + maxBytes: this.settings.maxResultBytes, + }) + } + }) + if (stableJson(actorState(actor, definition.stateKeys)) !== before || actor.hasIntents()) + throw new QueryMutatedState("snapshot getters must not mutate state or stage work") + return { + snapshot, + instanceId: instance?.incarnation ?? "0", + revision: instance?.revision ?? "0", + createdAtMs: instance?.createdAt ?? 0, + } + } + + private async projection(options: { + input: HostRequest + payloadNames: string[] + }): Promise { + const { input, payloadNames } = options + const { definition, instance, state } = this.committed(input) + const actor = hydrateActor({ definition, actorId: input.actorId, state }) + const identity = { + actorType: input.actorType, + actorId: input.actorId, + instanceId: instance?.incarnation ?? "0", + revision: instance?.revision ?? "0", + } + const event = { + version: 1, + kind: "invalidation", + ...identity, + ...selectActorBroadcast(readActorObservables({ actor, definition, runtime: this.runtime })), + } + const payloads: JsonObject[] = [] + for (const name of payloadNames) { + try { + if (!(await this.settings.authorizeQuery({ ...input, operation: name, arguments: {} }))) + continue + const projected = hydrateActor({ + definition, + actorId: input.actorId, + state: deepCopy(state), + }) + const before = stableJson(actorState(projected, definition.stateKeys)) + const handler = definition.payloads[name]! + const value = await withActorProjection({ actor: projected, runtime: this.runtime }, () => + handler(projected, input.authorizationContext), + ) + if ( + stableJson(actorState(projected, definition.stateKeys)) !== before || + projected.hasIntents() + ) + throw new QueryMutatedState("payload projection mutated state") + const payload = normalizeJson(value, { maxBytes: this.settings.maxPayloadBytes }) + if (payload === null || typeof payload !== "object") + throw new TypeError("payload must be an object or array") + payloads.push({ version: 1, kind: "payload", ...identity, name, payload }) + } catch (error) { + this.emit("payload_broadcast.failed", { payload: name, errorName: errorName(error) }) + } + } + return { + event, + payloads, + incarnationOrder: + instance?.incarnationOrder ?? this.store.metadata("incarnationOrder") ?? "0", + } + } + + private destroy(): boolean { + const instance = this.store.instance() + if (!instance) return false + this.store.storage.sql.exec("DELETE FROM metadata WHERE key = 'instance'") + this.store.storage.sql.exec("DELETE FROM outboxes") + this.store.storage.sql.exec("DELETE FROM reminders") + for (const message of this.store.rows( + "SELECT record FROM messages WHERE incarnation = ? AND completed_at IS NULL", + [instance.incarnation], + )) { + message.status = "completed" + message.completedAt = Date.now() + this.store.saveMessage(message) + } + this.cached = undefined + this.emit("actor.destroyed") + return true + } + + private async drainActors(): Promise { + this.actorRunning = true + const started = Date.now() + try { + for (let count = 0; count < this.settings.maxMessagesPerActivationPass; count += 1) { + if (Date.now() - started >= this.settings.maxActivationDurationMilliseconds) break + const head = this.store.head() + if (!head || head.status !== "ready" || head.availableAt > Date.now()) break + await this.execute(head) + } + } finally { + this.actorRunning = false + } + } + + private async execute(message: Message): Promise { + const instance = this.store.instance()! + const definition = this.definition(instance.actorType) + let actor: Actor + try { + actor = + this.cached?.incarnation === instance.incarnation + ? this.cached.actor + : hydrateActor({ + definition, + actorId: instance.actorId, + state: migrateState({ + definition, + storedVersion: instance.stateVersion, + storedState: instance.state, + }), + }) + if (this.cached?.actor !== actor) { + await withActorContext({ actor, runtime: this.runtime }, () => actor.activate()) + this.assertCurrent(instance) + this.cached = { incarnation: instance.incarnation, actor } + } + } catch (error) { + if (error instanceof ActorDestroyed) return + await this.store.atomic(() => { + if (this.store.instance()?.incarnation !== instance.incarnation) return + message.availableAt = Date.now() + RECOVERY_INTERVAL + message.error = { + name: "ActorSetupFailed", + message: "actor setup failed", + cause: { + name: errorName(error), + message: error instanceof Error ? error.message.slice(0, 4_096) : "actor setup failed", + }, + } + this.store.saveMessage(message) + }) + this.emit("actor.setup_failed", { errorName: errorName(error) }) + return + } + const stateBefore = deepCopy(actorState(actor, definition.stateKeys)) + try { + await this.store.atomic(() => { + this.assertCurrent(instance) + message.status = "claimed" + message.generation = instance.generation + message.attempt += 1 + message.availableAt = Date.now() + RECOVERY_INTERVAL + this.store.saveMessage(message) + }) + this.emit("message.started", { messageId: message.id, attempt: message.attempt }) + const evaluated = await evaluateActorTurn({ + actor, + definition, + runtime: this.runtime, + stateBefore, + operation: message.operation, + argumentsValue: message.arguments, + message: { + id: message.id, + requestId: message.requestId, + actorType: instance.actorType, + actorId: instance.actorId, + sequence: BigInt(message.sequence), + attempt: message.attempt, + enqueuedAt: new Date(message.createdAt), + idempotencyKey: message.idempotencyKey, + }, + maxStateBytes: this.settings.maxStateBytes, + maxResultBytes: this.settings.maxResultBytes, + }) + const intents = actor.drainIntents() + if (intents.commitActions.length > 0) + throw new UnsupportedCapability("the Durable Objects backend does not support commitAction") + await this.store.atomic(() => { + const current = this.assertCurrent(instance) + current.state = evaluated.state + current.stateVersion = definition.stateVersion + current.revision = message.sequence + this.store.saveInstance(current) + message.status = "completed" + message.result = evaluated.result + message.completedAt = Date.now() + this.store.saveMessage(message) + this.stage({ instance: current, message, intents, broadcast: evaluated.broadcast }) + this.completeReminder(message) + }) + this.emit("message.completed", { messageId: message.id }) + } catch (error) { + actor.discardIntents() + for (const key of definition.stateKeys) + Object.assign(actor, { [key]: deepCopy(stateBefore[key]) }) + if (error instanceof ActorDestroyed) return + await this.store.atomic(() => { + const current = this.store.instance() + if ( + !current || + current.incarnation !== instance.incarnation || + current.generation !== instance.generation + ) + return + message.result = null + message.completedAt = null + message.rejection = null + if (error instanceof Rejected) { + message.status = "rejected" + message.rejection = { + code: error.code, + message: error.message, + details: jsonObject(error.details), + } + message.completedAt = Date.now() + this.completeReminder(message) + } else { + message.error = { + name: errorName(error), + message: error instanceof Error ? error.message.slice(0, 4_096) : "operation failed", + } + const exhausted = + error instanceof NonRetryableError || message.attempt >= this.settings.maxAttempts + message.status = exhausted ? "dead" : "ready" + message.availableAt = Date.now() + this.retryDelay(message.attempt) + if (exhausted) { + current.paused = true + this.store.saveInstance(current) + this.pauseReminder(message) + } + } + try { + this.store.saveMessage(message) + } catch (storageError) { + if (!(storageError instanceof PayloadTooLarge)) throw storageError + message.status = "dead" + message.completedAt = null + message.rejection = null + message.error = { name: storageError.name, message: storageError.message } + current.paused = true + this.store.saveInstance(current) + this.pauseReminder(message) + this.store.saveMessage(message) + } + }) + this.emit("message.failed", { + messageId: message.id, + errorName: errorName(error), + status: message.status, + }) + } + } + + private assertCurrent(instance: Instance): Instance { + const current = this.store.instance() + if ( + !current || + current.incarnation !== instance.incarnation || + current.generation !== instance.generation + ) + throw new ActorDestroyed("actor incarnation or execution generation changed") + return current + } + + private stage(options: { + instance: Instance + message: Message + intents: ActorIntents + broadcast: { observables: JsonObject; invalidations: string[] } | undefined + }): void { + const { instance, message, intents, broadcast } = options + for (const effect of intents.effects) { + const id = crypto.randomUUID() + this.addOutbox({ + id, + instance, + message, + kind: "effect", + destination: id, + payload: jsonObject(effect), + }) + } + for (const outbound of intents.outboundMessages) { + this.addOutbox({ + id: crypto.randomUUID(), + instance, + message, + kind: "outbound", + destination: actorName(outbound), + payload: jsonObject(outbound), + }) + } + for (const intent of intents.reminders) { + this.store.saveReminder({ + name: intent.name, + generation: crypto.randomUUID(), + operation: intent.operation, + arguments: intent.arguments, + at: intent.atMilliseconds, + interval: intent.intervalMilliseconds ?? null, + missed: intent.missedPolicy, + status: "scheduled", + }) + } + if (!broadcast) return + for (const subscription of this.store.rows( + "SELECT record FROM subscriptions WHERE expires_at > ?", + [Date.now()], + )) { + this.addOutbox({ + id: crypto.randomUUID(), + instance, + message, + kind: "broadcast", + destination: subscription.id, + payload: { + sessionName: subscription.sessionName, + event: { + version: 1, + kind: "invalidation", + actorType: instance.actorType, + actorId: instance.actorId, + instanceId: instance.incarnation, + revision: message.sequence, + ...broadcast, + }, + }, + }) + } + } + + private addOutbox(options: { + id: string + instance: Instance + message: Message + kind: Outbox["kind"] + destination: string + payload: JsonObject + }): void { + this.store.saveOutbox({ + id: options.id, + incarnation: options.instance.incarnation, + messageId: options.message.id, + sequence: options.message.sequence, + kind: options.kind, + destination: options.destination, + payload: options.payload, + status: "pending", + attempt: 0, + availableAt: Date.now(), + completedAt: null, + error: null, + }) + } + + private async deliver(outbox: Outbox): Promise { + this.delivering.add(outbox.id) + const instance = this.store.instance() + if (!instance || instance.incarnation !== outbox.incarnation) { + this.delivering.delete(outbox.id) + return + } + try { + await this.store.atomic(() => { + if (!this.outboxCurrent(outbox, instance)) throw new ActorDestroyed("outbox was removed") + outbox.status = "claimed" + outbox.attempt += 1 + outbox.availableAt = Date.now() + RECOVERY_INTERVAL + this.store.saveOutbox(outbox) + }) + let result: JsonValue = null + if (outbox.kind === "effect") { + const handler = this.settings.effects[String(outbox.payload.name)] + if (!handler) throw new UnknownEffect(`unknown effect ${String(outbox.payload.name)}`) + const value = await withRuntime(this.runtime, () => + handler(jsonObject(outbox.payload.arguments), { + id: outbox.id, + attempt: outbox.attempt, + sourceMessageId: outbox.messageId, + actorType: instance.actorType, + actorId: instance.actorId, + }), + ) + result = normalizeJson(value === undefined ? null : value, { + maxBytes: this.settings.maxResultBytes, + }) + } else if (outbox.kind === "outbound") { + await callHost({ + backend: this.settings.backend, + request: { + actorType: String(outbox.payload.actorType), + actorId: String(outbox.payload.actorId), + method: "internal", + authorizationContext: null, + payload: { + requestId: outbox.id, + operation: outbox.payload.operation!, + arguments: outbox.payload.arguments!, + availableAt: outbox.payload.availableAtMilliseconds ?? Date.now(), + idempotencyKey: outbox.payload.idempotencyKey ?? outbox.id, + }, + }, + }) + } else { + const subscription = this.store.rows( + "SELECT record FROM subscriptions WHERE id = ? AND expires_at > ?", + [outbox.destination, Date.now()], + )[0] + if (subscription && this.settings.backend.sessions) { + await beforeDeadline( + this.settings.backend.sessions.getByName(subscription.sessionName).publish({ + subscriptionId: subscription.id, + event: jsonObject(outbox.payload.event), + }), + 5_000, + ) + } + } + await this.store.atomic(() => { + if (!this.outboxCurrent(outbox, instance)) return + outbox.status = "completed" + outbox.completedAt = Date.now() + this.store.saveOutbox(outbox) + if (outbox.kind === "effect") + this.effectCallback({ + instance, + outbox, + result, + operation: outbox.payload.successOperation, + }) + }) + } catch (error) { + await this.store.atomic(() => { + if (!this.outboxCurrent(outbox, instance)) return + const exhausted = + error instanceof NonRetryableError || outbox.attempt >= this.settings.maxAttempts + outbox.status = exhausted ? "dead" : "pending" + outbox.error = { + name: errorName(error), + message: error instanceof Error ? error.message : "delivery failed", + } + outbox.availableAt = Date.now() + this.retryDelay(outbox.attempt) + this.store.saveOutbox(outbox) + if (exhausted && outbox.kind === "effect") + this.effectCallback({ + instance, + outbox, + result: outbox.error, + operation: outbox.payload.failureOperation, + }) + }) + this.emit("outbox.failed", { + outboxId: outbox.id, + kind: outbox.kind, + errorName: errorName(error), + }) + } finally { + this.delivering.delete(outbox.id) + } + } + + private outboxCurrent(outbox: Outbox, instance: Instance): boolean { + const current = this.store.instance() + return ( + current?.incarnation === outbox.incarnation && + current.generation === instance.generation && + this.store.rows("SELECT record FROM outboxes WHERE id = ?", [outbox.id]).length > 0 + ) + } + + private effectCallback(options: { + instance: Instance + outbox: Outbox + result: JsonValue + operation: JsonValue | undefined + }): void { + if (typeof options.operation !== "string") return + this.enqueue({ + ...options.instance, + method: "internal", + authorizationContext: null, + payload: { + requestId: `${options.outbox.id}:callback`, + operation: options.operation, + arguments: { + effectId: options.outbox.id, + arguments: options.outbox.payload.arguments!, + ...(options.outbox.status === "dead" + ? { error: options.result } + : { result: options.result }), + }, + idempotencyKey: `${options.outbox.id}:callback`, + availableAt: Date.now(), + }, + }) + } + + private scheduleReminders(): void { + const instance = this.store.instance() + if (!instance || instance.paused) return + for (const reminder of this.store.rows( + "SELECT record FROM reminders WHERE status = 'scheduled' AND due_at <= ? ORDER BY due_at LIMIT ?", + [Date.now(), this.settings.maxMessagesPerActivationPass], + )) { + try { + const message = this.enqueue({ + ...instance, + method: "internal", + authorizationContext: null, + payload: { + requestId: `reminder:${reminder.generation}:${reminder.at}`, + operation: reminder.operation, + arguments: reminder.arguments, + idempotencyKey: `reminder:${reminder.generation}:${reminder.at}`, + availableAt: reminder.at, + }, + }) + message.reminder = { name: reminder.name, generation: reminder.generation } + this.store.saveMessage(message) + reminder.status = "completed" + this.store.saveReminder(reminder) + } catch (error) { + if (!(error instanceof MailboxFull)) throw error + break + } + } + } + + private completeReminder(message: Message): void { + if (!message.reminder) return + const reminder = this.store.rows("SELECT record FROM reminders WHERE name = ?", [ + message.reminder.name, + ])[0] + if ( + !reminder || + reminder.generation !== message.reminder.generation || + reminder.interval === null + ) + return + const steps = + reminder.missed === "latest" + ? Math.max(1, Math.floor((Date.now() - reminder.at) / reminder.interval) + 1) + : 1 + reminder.at += steps * reminder.interval + reminder.status = "scheduled" + this.store.saveReminder(reminder) + } + + private pauseReminder(message: Message): void { + if (!message.reminder) return + const reminder = this.store.rows("SELECT record FROM reminders WHERE name = ?", [ + message.reminder.name, + ])[0] + if (!reminder || reminder.generation !== message.reminder.generation) return + reminder.status = "paused" + this.store.saveReminder(reminder) + } + + private async administer(input: HostRequest): Promise { + const action = String(input.payload.action) + if ( + !(await this.settings.authorizeAdministration({ + action, + resource: `actor:${actorName(input)}`, + authorizationContext: input.authorizationContext, + })) + ) + throw new Unauthorized("actor administration is not authorized") + this.bind(input) + if (action === "deadLetters") + return normalizeJson({ + messages: this.store.rows( + "SELECT record FROM messages WHERE status = 'dead' ORDER BY sequence LIMIT 1000", + ), + outboxes: this.store.rows( + "SELECT record FROM outboxes WHERE status = 'dead' ORDER BY sequence LIMIT 1000", + ), + }) + if (action === "reminders") + return normalizeJson( + this.store.rows("SELECT record FROM reminders ORDER BY due_at LIMIT 1000"), + ) + return this.store.atomic(() => { + if (action === "retryDeadLetter") { + const id = String(input.payload.id) + const message = this.store.message(id) + if ( + message?.status === "dead" && + message.incarnation === this.store.instance()?.incarnation + ) { + message.status = "ready" + message.attempt = 0 + message.availableAt = Date.now() + this.store.saveMessage(message) + const instance = this.store.instance()! + instance.paused = false + this.store.saveInstance(instance) + return normalizeJson(message) + } + const outbox = this.store.rows( + "SELECT record FROM outboxes WHERE id = ? AND status = 'dead'", + [id], + )[0] + if (!outbox) throw new UnknownDeadLetter("unknown dead letter") + outbox.status = "pending" + outbox.attempt = 0 + outbox.availableAt = Date.now() + this.store.saveOutbox(outbox) + return normalizeJson(outbox) + } + if (action === "resumeReminder") { + const reminder = this.store.rows("SELECT record FROM reminders WHERE name = ?", [ + String(input.payload.name), + ])[0] + if (!reminder) throw new UnknownReminder("unknown reminder") + if (reminder.status !== "paused") throw new ReminderNotPaused("reminder is not paused") + const at = Number(input.payload.runAt) + if (!Number.isFinite(at)) throw new TypeError("invalid reminder runAt") + reminder.at = at + reminder.status = "scheduled" + reminder.generation = crypto.randomUUID() + this.store.saveReminder(reminder) + const instance = this.store.instance() + if (instance) { + instance.paused = false + this.store.saveInstance(instance) + } + return normalizeJson(reminder) + } + throw new UnsupportedCapability(`unsupported actor administration action ${action}`) + }) + } + + private retryDelay(attempt: number): number { + const delay = this.settings.retryDelayMilliseconds(attempt) + return Number.isFinite(delay) && delay >= 1 ? delay : 1_000 + } + + private emit(name: string, attributes: JsonObject = {}): void { + try { + this.settings.instrumentation?.({ + name: `solid_objects.${name}`, + occurredAt: new Date().toISOString(), + attributes, + }) + } catch { + this.settings.logger.error({ event: "solid_objects.instrumentation.failed", name }) + } + } +} + +function errorName(error: unknown): string { + return error instanceof Error ? error.name : "Error" +} + +function stringList(value: JsonValue): string[] { + if ( + !Array.isArray(value) || + value.length > 50 || + !value.every((item) => typeof item === "string") + ) + throw new TypeError("payloads must contain at most 50 names") + return [...new Set(value)] +} diff --git a/src/cloudflare/host.ts b/src/cloudflare/host.ts new file mode 100644 index 0000000..6955e54 --- /dev/null +++ b/src/cloudflare/host.ts @@ -0,0 +1,56 @@ +import "./platform.js" +import { DurableObject } from "cloudflare:workers" +import type { ActorClass } from "../actor.js" +import { withRuntime } from "../context.js" +import { buildCloudflareSettings, type CloudflareConfiguration } from "./configuration.js" +import { ActorEngine } from "./engine.js" +import { encodeError, type ActorHost, type HostRequest, type RpcReply } from "./protocol.js" +import { ActorStorage } from "./storage.js" + +export function createDurableObjectsHost(options: { + actors: readonly ActorClass[] + configure: (environment: Environment) => CloudflareConfiguration +}): new ( + context: DurableObjectState, + environment: Environment, +) => DurableObject & ActorHost { + return class SolidObjectsActorHost extends DurableObject { + readonly #engine: ActorEngine + + constructor(context: DurableObjectState, environment: Environment) { + super(context, environment) + this.#engine = new ActorEngine( + new ActorStorage(this.ctx.storage, buildCloudflareSettings(options.configure(this.env))), + options.actors, + ) + } + + async request(input: HostRequest): Promise { + try { + const value = await withRuntime(this.#engine.runtime, () => this.#engine.request(input)) + this.ctx.waitUntil(this.#pump().catch(() => undefined)) + return { ok: true, value } + } catch (error) { + return encodeError(error) + } + } + + override async alarm(): Promise { + await this.#pump() + } + + async #pump(): Promise { + try { + await this.#engine.pump() + } catch (error) { + const scheduled = await this.ctx.storage.getAlarm() + await this.ctx.storage.setAlarm(Math.min(scheduled ?? Infinity, Date.now() + 30_000)) + this.#engine.settings.logger.error({ + event: "solid_objects.alarm.failed", + errorName: error instanceof Error ? error.name : "Error", + }) + throw error + } + } + } +} diff --git a/src/cloudflare/index.ts b/src/cloudflare/index.ts new file mode 100644 index 0000000..2773686 --- /dev/null +++ b/src/cloudflare/index.ts @@ -0,0 +1,14 @@ +import "./platform.js" + +export { createRuntime, CloudflareRuntime } from "./runtime.js" +export { + durableObjects, + type DurableObjectsBackend, + type ActorNamespace, + type SessionNamespace, +} from "./protocol.js" +export { createDurableObjectsHost } from "./host.js" +export { createDurableObjectsSessionHost } from "./session.js" +export type { CloudflareConfiguration } from "./configuration.js" +export { withRuntime } from "../context.js" +export { EnqueueOutcomeUnknown, UnsupportedCapability } from "../errors.js" diff --git a/src/cloudflare/platform.ts b/src/cloudflare/platform.ts new file mode 100644 index 0000000..87d8652 --- /dev/null +++ b/src/cloudflare/platform.ts @@ -0,0 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks" +import { registerContextStoreFactory } from "../platform/context-store.js" + +registerContextStoreFactory(() => new AsyncLocalStorage()) diff --git a/src/cloudflare/protocol.ts b/src/cloudflare/protocol.ts new file mode 100644 index 0000000..2f3e9ff --- /dev/null +++ b/src/cloudflare/protocol.ts @@ -0,0 +1,135 @@ +import * as errors from "../errors.js" +import { jsonObject } from "../serialization.js" +import type { JsonObject, JsonValue } from "../types.js" + +export interface ActorIdentity { + actorType: string + actorId: string +} + +export interface HostRequest extends ActorIdentity { + method: + | "enqueue" + | "internal" + | "lookup" + | "message" + | "snapshot" + | "destroy" + | "subscribe" + | "unsubscribe" + | "projection" + | "administration" + authorizationContext: JsonValue + payload: JsonObject +} + +export type RpcReply = + | { ok: true; value: JsonValue } + | { ok: false; error: { name: string; message: string; details: JsonObject } } + +export interface ActorHost { + request(input: HostRequest): Promise +} + +export interface SessionHost { + fetch(input: string, options: { headers: Record }): Promise + publish(options: { subscriptionId: string; event: JsonObject }): Promise +} + +export interface ActorNamespace { + getByName(name: string): ActorHost +} + +export interface SessionNamespace { + getByName(name: string): SessionHost +} + +export interface DurableObjectsBackend { + readonly kind: "durable-objects" + readonly namespace: ActorNamespace + readonly sessions?: SessionNamespace +} + +export function durableObjects(options: { + namespace: ActorNamespace + sessions?: SessionNamespace +}): DurableObjectsBackend { + return Object.freeze({ kind: "durable-objects", ...options }) +} + +export function actorName(identity: ActorIdentity): string { + return JSON.stringify([identity.actorType, String(identity.actorId)]) +} + +export function encodeError(error: unknown): Extract { + const details: JsonObject = {} + if (error instanceof errors.Rejected) { + details.code = error.code + details.details = jsonObject(error.details) + if (error.messageId !== undefined) details.messageId = error.messageId + } + if (error instanceof errors.MessageFailed) { + details.messageId = error.messageId + details.details = jsonObject(error.details) + } + return { + ok: false, + error: { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : "operation failed", + details, + }, + } +} + +export function unwrapReply(reply: RpcReply): JsonValue { + if (reply.ok) return reply.value + const { name, message, details } = reply.error + if (name === "Rejected") { + const error = new errors.Rejected({ + code: String(details.code), + message, + details: jsonObject(details.details), + }) + if (typeof details.messageId === "string") error.messageId = details.messageId + throw error + } + if (name === "MessageFailed") { + throw new errors.MessageFailed({ + messageId: String(details.messageId), + details: jsonObject(details.details), + }) + } + const constructors: Record Error> = { + Unauthorized: errors.Unauthorized, + UnknownActorType: errors.UnknownActorType, + UnknownOperation: errors.UnknownOperation, + ActorDestroyed: errors.ActorDestroyed, + ActorCallCycle: errors.ActorCallCycle, + IdempotencyConflict: errors.IdempotencyConflict, + MailboxFull: errors.MailboxFull, + PayloadTooLarge: errors.PayloadTooLarge, + InvalidPayload: errors.InvalidPayload, + QueryMutatedState: errors.QueryMutatedState, + ApplicationWriteForbidden: errors.ApplicationWriteForbidden, + NonRetryableError: errors.NonRetryableError, + UnsupportedCapability: errors.UnsupportedCapability, + StateMigrationError: errors.StateMigrationError, + UnknownDeadLetter: errors.UnknownDeadLetter, + UnknownReminder: errors.UnknownReminder, + ReminderNotPaused: errors.ReminderNotPaused, + UnknownPayloadBroadcast: errors.UnknownPayloadBroadcast, + TypeError, + } + const Constructor = constructors[name] ?? errors.SolidObjectsError + throw new Constructor(message) +} + +export async function callHost(options: { + backend: DurableObjectsBackend + request: HostRequest +}): Promise { + return unwrapReply( + await options.backend.namespace.getByName(actorName(options.request)).request(options.request), + ) +} diff --git a/src/cloudflare/records.ts b/src/cloudflare/records.ts new file mode 100644 index 0000000..2408cba --- /dev/null +++ b/src/cloudflare/records.ts @@ -0,0 +1,68 @@ +import type { JsonObject, JsonValue, MessageStatus } from "../types.js" +import type { ActorIdentity } from "./protocol.js" + +export interface Instance extends ActorIdentity { + incarnation: string + incarnationOrder: string + generation: string + revision: string + nextSequence: string + state: JsonObject + stateVersion: number + createdAt: number + paused: boolean +} + +export interface Message { + id: string + requestId: string + incarnation: string + sequence: string + operation: string + arguments: JsonObject + deliveryMode: "sync" | "async" | "internal" + idempotencyKey: string | null + status: MessageStatus + attempt: number + availableAt: number + createdAt: number + completedAt: number | null + result: JsonValue + error: JsonObject | null + rejection: { code: string; message: string; details: JsonObject } | null + generation: string | null + reminder: { name: string; generation: string } | null +} + +export interface Outbox { + id: string + incarnation: string + messageId: string + kind: "effect" | "outbound" | "broadcast" + destination: string + sequence: string + payload: JsonObject + status: "pending" | "claimed" | "completed" | "dead" + attempt: number + availableAt: number + completedAt: number | null + error: JsonObject | null +} + +export interface Reminder { + name: string + generation: string + operation: string + arguments: JsonObject + at: number + interval: number | null + missed: "all" | "latest" + status: "scheduled" | "paused" | "completed" +} + +export interface Subscription { + id: string + sessionName: string + payloads: string[] + expiresAt: number +} diff --git a/src/cloudflare/runtime.ts b/src/cloudflare/runtime.ts new file mode 100644 index 0000000..f13ca31 --- /dev/null +++ b/src/cloudflare/runtime.ts @@ -0,0 +1,434 @@ +import "./platform.js" +import type { Actor, ActorClass } from "../actor.js" +import type { ActorRuntime } from "../actor-runtime.js" +import { currentActor } from "../context.js" +import { validateDefinition } from "../definition.js" +import { + ActorCallCycle, + ActorSetupFailed, + EnqueueOutcomeUnknown, + MessageFailed, + Rejected, + SyncTimeout, + UnsupportedCapability, +} from "../errors.js" +import { + ActorReferenceCore, + createActorReference, + MessageReference, + type ActorReference, + type ActorSnapshot, +} from "../reference.js" +import { jsonObject, normalizeJson, readonlyCopy } from "../serialization.js" +import type { + ActorIdentifier, + AsyncInvocationOptions, + DeepReadonly, + DestroyOptions, + InvocationOptions, + JsonObject, + JsonValue, + MessageStatus, + SnapshotOptions, +} from "../types.js" +import { consoleLogger } from "./configuration.js" +import { + actorName, + callHost, + unwrapReply, + type ActorIdentity, + type DurableObjectsBackend, + type HostRequest, +} from "./protocol.js" + +export function createRuntime(options: { backend: DurableObjectsBackend }): CloudflareRuntime { + return new CloudflareRuntime(options.backend) +} + +export class CloudflareRuntime implements ActorRuntime { + readonly settings = { logger: consoleLogger } + readonly realtime = { + connect: (): never => + unsupported("process-local realtime; use the session Durable Object WebSocket"), + } + readonly capabilities = Object.freeze({ + actors: true, + realtime: true, + sharedTransactions: false, + fleetAdministration: false, + }) + + constructor(readonly backend: DurableObjectsBackend) {} + + ref( + actorClass: ActorClass, + actorId: ActorIdentifier, + ): ActorReference { + const definition = validateDefinition(actorClass) + return createActorReference({ + runtime: this, + actorClass, + actorType: definition.type, + actorId: String(actorId), + operations: new Set(definition.operations), + queries: new Set(definition.queries), + }) + } + + async invoke(options: { + reference: ActorReferenceCore + operation: string + argumentsValue?: JsonObject + options?: InvocationOptions + }): Promise> { + assertOutsideActor() + const timeoutMilliseconds = timeout(options.options) + const deadline = Date.now() + timeoutMilliseconds + const message = await this.enqueue({ + reference: options.reference, + operation: options.operation, + argumentsValue: options.argumentsValue ?? {}, + options: options.options ?? {}, + deliveryMode: "sync", + timeoutMilliseconds, + }) + return this.wait(message, { + ...options.options, + timeoutMilliseconds: Math.max(0, deadline - Date.now()), + }) + } + + async sendMessage(options: { + reference: ActorReferenceCore + operation: string + argumentsValue?: JsonObject + options?: AsyncInvocationOptions + }): Promise> { + assertOutsideActor() + return this.enqueue({ + reference: options.reference, + operation: options.operation, + argumentsValue: options.argumentsValue ?? {}, + options: options.options ?? {}, + deliveryMode: "async", + timeoutMilliseconds: 5_000, + }) + } + + async lookupMessage( + options: ActorIdentity & { requestId: string; authorizationContext?: unknown }, + ): Promise | undefined> { + const value = await this.call({ + ...identity(options), + method: "lookup", + authorizationContext: context(options.authorizationContext), + payload: { requestId: options.requestId }, + }) + return value === null ? undefined : this.reference(options, jsonObject(value)) + } + + async messageStatus( + message: MessageReference, + options: SnapshotOptions = {}, + ): Promise { + const record = await this.readMessage(message, options) + return record.status as MessageStatus + } + + async messageResult( + message: MessageReference, + options: SnapshotOptions = {}, + ): Promise | undefined> { + const record = await this.readMessage(message, options) + return resultFromRecord(record) + } + + async wait( + message: MessageReference, + options: InvocationOptions = {}, + ): Promise> { + assertOutsideActor() + const timeoutMilliseconds = timeout(options) + const deadline = Date.now() + timeoutMilliseconds + let record: JsonObject = { status: "unknown", operation: "unknown" } + do { + try { + record = await beforeDeadline( + this.readMessage(message, options), + Math.max(0, deadline - Date.now()), + ) + } catch (error) { + if (!(error instanceof RpcDeadline)) throw error + break + } + const result = resultFromRecord(record) + if (record.status === "completed") return result as DeepReadonly + const remaining = deadline - Date.now() + if (remaining <= 0) break + await new Promise((resolve) => setTimeout(resolve, Math.min(50, remaining))) + } while (Date.now() < deadline) + throw new SyncTimeout({ + messageReference: message, + details: { + ...identity(message), + timeoutMilliseconds, + operation: String(record.operation), + messageId: message.id, + requestId: message.requestId, + sequence: message.sequence, + status: String(record.status), + waitingOn: "unknown", + activation: { ownerId: null, generation: 0n, expiresAt: null, process: null }, + blocker: null, + }, + }) + } + + async snapshot( + reference: ActorReferenceCore, + options: SnapshotOptions = {}, + ): Promise> { + return (await this.snapshotWithIncarnation(reference, options)).snapshot + } + + async snapshotWithIncarnation( + reference: ActorReferenceCore, + options: SnapshotOptions = {}, + ) { + const value = jsonObject( + await this.call({ + ...identity(reference), + method: "snapshot", + authorizationContext: context(options.authorizationContext), + payload: {}, + }), + ) + return { + snapshot: readonlyCopy(jsonObject(value.snapshot)) as ActorSnapshot, + instanceId: String(value.instanceId), + revision: String(value.revision), + createdAtMs: Number(value.createdAtMs), + } + } + + async destroy( + reference: ActorReferenceCore, + options: DestroyOptions = {}, + ): Promise { + assertOutsideActor() + return ( + (await this.call({ + ...identity(reference), + method: "destroy", + authorizationContext: context(options.authorizationContext), + payload: {}, + })) === true + ) + } + + actorAdministration(options: ActorIdentity & { authorizationContext?: unknown }) { + const call = (payload: JsonObject) => + this.call({ + ...identity(options), + method: "administration", + authorizationContext: context(options.authorizationContext), + payload, + }) + return { + deadLetters: () => call({ action: "deadLetters" }), + retryDeadLetter: (id: string) => call({ action: "retryDeadLetter", id }), + reminders: () => call({ action: "reminders" }), + resumeReminder: (request: { name: string; runAt?: Date }) => + call({ + action: "resumeReminder", + name: request.name, + runAt: request.runAt?.getTime() ?? Date.now(), + }), + } + } + + async openWebSocket(options: { sessionId: string; expiresAt: Date }): Promise { + if (!this.backend.sessions) return unsupported("realtime without a sessions binding") + if ( + !Number.isFinite(options.expiresAt.getTime()) || + options.expiresAt.getTime() <= Date.now() + ) { + throw new TypeError("session expiry must be in the future") + } + const sessionName = crypto.randomUUID() + return this.backend.sessions + .getByName(sessionName) + .fetch("https://solid-objects.internal/session", { + headers: { + Upgrade: "websocket", + "X-Solid-Session-Name": sessionName, + "X-Solid-Session-Id": options.sessionId, + "X-Solid-Session-Expiry": String(options.expiresAt.getTime()), + }, + }) + } + + install(): never { + return unsupported("install; storage initializes inside each Durable Object") + } + run(): never { + return unsupported("process workers; Durable Objects run on requests and alarms") + } + registerCommitAction(): never { + return unsupported("commitAction and shared SQL transactions") + } + get repository(): never { + return unsupported("the shared SQL repository") + } + get administration(): never { + return unsupported("fleet administration; use actorAdministration") + } + get reconciliation(): never { + return unsupported("fleet reconciliation") + } + get processes(): never { + return unsupported("process administration") + } + get doctor(): never { + return unsupported("SQL diagnostics") + } + get retention(): never { + return unsupported("fleet retention; each object prunes its own records") + } + + private async enqueue(options: { + reference: ActorReferenceCore + operation: string + argumentsValue: JsonObject + options: AsyncInvocationOptions + deliveryMode: "sync" | "async" + timeoutMilliseconds: number + }): Promise> { + const requestId = crypto.randomUUID() + const availableAt = options.options.availableAt?.getTime() ?? Date.now() + if (!Number.isFinite(availableAt)) throw new TypeError("availableAt must be a valid date") + const request: HostRequest = { + ...identity(options.reference), + method: "enqueue", + authorizationContext: context(options.options.authorizationContext), + payload: { + requestId, + operation: options.operation, + arguments: jsonObject(options.argumentsValue), + deliveryMode: options.deliveryMode, + idempotencyKey: options.options.idempotencyKey ?? null, + availableAt, + }, + } + let reply + try { + reply = await beforeDeadline( + this.backend.namespace.getByName(actorName(request)).request(request), + options.timeoutMilliseconds, + ) + } catch (error) { + throw new EnqueueOutcomeUnknown({ ...identity(request), requestId }, { cause: error }) + } + return this.reference(request, jsonObject(unwrapReply(reply))) + } + + private reference(actor: ActorIdentity, value: JsonObject): MessageReference { + return new MessageReference({ + runtime: this, + ...identity(actor), + id: String(value.id), + requestId: String(value.requestId), + sequence: BigInt(String(value.sequence)), + operation: String(value.operation), + }) + } + + private async readMessage( + message: MessageReference, + options: SnapshotOptions, + ): Promise { + return jsonObject( + await this.call({ + ...identity(message), + method: "message", + authorizationContext: context(options.authorizationContext), + payload: { + id: message.id, + requestId: message.requestId, + sequence: String(message.sequence), + }, + }), + ) + } + + private call(request: HostRequest): Promise { + return callHost({ backend: this.backend, request }) + } +} + +function resultFromRecord(record: JsonObject): DeepReadonly | undefined { + if ( + record.status === "ready" && + record.error && + jsonObject(record.error).name === "ActorSetupFailed" + ) + throw new ActorSetupFailed(jsonObject(record.error).cause) + if (record.status === "rejected") { + const rejection = jsonObject(record.rejection) + const error = new Rejected({ + code: String(rejection.code), + message: String(rejection.message), + details: jsonObject(rejection.details), + }) + error.messageId = String(record.id) + throw error + } + if (record.status === "dead") + throw new MessageFailed({ messageId: String(record.id), details: jsonObject(record.error) }) + return record.status === "completed" + ? (readonlyCopy(record.result) as DeepReadonly) + : undefined +} + +function assertOutsideActor(): void { + if (currentActor()) + throw new ActorCallCycle("actors must use this.sendTo(reference) for transactional delivery") +} + +function identity(value: ActorIdentity): ActorIdentity { + return { actorType: value.actorType, actorId: String(value.actorId) } +} + +function context(value: unknown): JsonValue { + return normalizeJson(value === undefined ? null : value) +} + +function timeout(options: InvocationOptions = {}): number { + const value = options.timeoutMilliseconds ?? 5_000 + if (!Number.isFinite(value) || value < 0) + throw new TypeError("timeoutMilliseconds must be a non-negative number") + return value +} + +function unsupported(capability: string): never { + throw new UnsupportedCapability(`the Durable Objects backend does not support ${capability}`) +} + +class RpcDeadline extends Error {} + +export async function beforeDeadline( + promise: Promise, + milliseconds: number, +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new RpcDeadline()), milliseconds) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} diff --git a/src/cloudflare/session.ts b/src/cloudflare/session.ts new file mode 100644 index 0000000..58807b8 --- /dev/null +++ b/src/cloudflare/session.ts @@ -0,0 +1,396 @@ +import { DurableObject } from "cloudflare:workers" +import { Unauthorized } from "../errors.js" +import { parseSubscriptionRequest } from "../realtime.js" +import { jsonObject, normalizeJson, utf8ByteLength } from "../serialization.js" +import type { JsonObject, JsonValue } from "../types.js" +import { + actorName, + callHost, + type ActorIdentity, + type DurableObjectsBackend, + type SessionHost, +} from "./protocol.js" + +interface SessionRecord { + sessionName: string + sessionId: string + expiresAt: number + closed: boolean +} + +interface SessionSubscription extends ActorIdentity { + id: string + payloads: string[] + status: "registering" | "active" | "removing" + retryAt: number + incarnationOrder: string +} + +export function createDurableObjectsSessionHost(options: { + backend: (environment: Environment) => DurableObjectsBackend + resolveAuthorizationContext: (input: { + sessionId: string + environment: Environment + }) => JsonValue | null | Promise + maxSubscriptions?: number +}): new ( + context: DurableObjectState, + environment: Environment, +) => DurableObject & Pick { + const maximum = options.maxSubscriptions ?? 100 + if (!Number.isSafeInteger(maximum) || maximum <= 0) + throw new TypeError("maxSubscriptions must be a positive safe integer") + return class SolidObjectsSessionHost extends DurableObject { + #receiving: Promise = Promise.resolve() + + constructor(context: DurableObjectState, environment: Environment) { + super(context, environment) + this.ctx.storage.transactionSync(() => { + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS subscriptions (identity TEXT PRIMARY KEY, id TEXT NOT NULL UNIQUE, record TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS revisions (subscription_id TEXT NOT NULL, channel TEXT NOT NULL, incarnation TEXT NOT NULL, revision TEXT NOT NULL, PRIMARY KEY(subscription_id, channel)); + `) + }) + } + + override async fetch(request: Request): Promise { + try { + if (request.headers.get("Upgrade") !== "websocket") + return new Response("WebSocket required", { status: 426 }) + return await this.#open({ + sessionName: request.headers.get("X-Solid-Session-Name") ?? "", + sessionId: request.headers.get("X-Solid-Session-Id") ?? "", + expiresAt: Number(request.headers.get("X-Solid-Session-Expiry")), + }) + } catch { + return new Response("Unauthorized", { status: 401 }) + } + } + + async #open(input: { + sessionName: string + sessionId: string + expiresAt: number + }): Promise { + if (this.#session()) throw new Unauthorized("session connection already exists") + if ( + !input.sessionId || + utf8ByteLength(input.sessionId) > 1_024 || + !Number.isFinite(input.expiresAt) || + input.expiresAt <= Date.now() + ) + throw new Unauthorized("invalid session") + const authorization = await options.resolveAuthorizationContext({ + sessionId: input.sessionId, + environment: this.env, + }) + if (authorization === null || input.expiresAt <= Date.now()) + throw new Unauthorized("session is not authorized") + await this.#atomic(() => { + if (this.#session()) throw new Unauthorized("session connection already exists") + this.ctx.storage.kv.put("session", { ...input, closed: false }) + }) + const pair = new WebSocketPair() + this.ctx.acceptWebSocket(pair[1]) + pair[1].serializeAttachment({ sessionName: input.sessionName }) + return new Response(null, { status: 101, webSocket: pair[0] }) + } + + override async webSocketMessage(socket: WebSocket, value: string | ArrayBuffer): Promise { + const receive = this.#receiving.then(async () => { + if (typeof value !== "string" || utf8ByteLength(value) > 16_384) + throw new TypeError("invalid subscription frame") + const request = parseSubscriptionRequest(JSON.parse(value)) + const session = this.#session() + if (!session || session.closed || session.expiresAt <= Date.now()) + throw new Unauthorized("session expired") + const identity = actorName(request) + const previous = this.#subscription(identity) + if (request.action === "unsubscribe") { + if (!previous) return + previous.status = "removing" + previous.retryAt = Date.now() + await this.#atomic(() => this.#save(previous)) + await this.#synchronize(previous) + return + } + if (!previous && this.#subscriptions().length >= maximum) + throw new TypeError("subscription limit exceeded") + const subscription: SessionSubscription = { + actorType: request.actorType, + actorId: request.actorId, + id: previous?.id ?? crypto.randomUUID(), + payloads: [...(request.payloads ?? [])], + status: "registering", + retryAt: Date.now(), + incarnationOrder: previous?.incarnationOrder ?? "0", + } + await this.#atomic(() => { + this.#save(subscription) + this.ctx.storage.sql.exec( + "DELETE FROM revisions WHERE subscription_id = ?", + subscription.id, + ) + }) + await this.#synchronize(subscription) + }) + this.#receiving = receive.catch(() => undefined) + try { + await receive + } catch { + socket.close(1008, "subscription unavailable") + await this.#close() + } + } + + async publish(input: { subscriptionId: string; event: JsonObject }): Promise { + const subscription = this.#subscriptions().find((entry) => entry.id === input.subscriptionId) + if (!subscription || subscription.status === "removing") return + const authorization = await this.#authorization() + if (authorization === null) { + await this.#close() + return + } + let projected: JsonObject + try { + projected = jsonObject( + await callHost({ + backend: options.backend(this.env), + request: { + ...subscription, + method: "projection", + authorizationContext: authorization, + payload: { payloads: subscription.payloads }, + }, + }), + ) + } catch (error) { + if (!(error instanceof Unauthorized)) throw error + subscription.status = "removing" + await this.#atomic(() => this.#save(subscription)) + await this.#synchronize(subscription) + return + } + const current = jsonObject(projected.event) + if (current.instanceId !== input.event.instanceId) return + this.#send(subscription, { + envelope: input.event, + incarnationOrder: String(projected.incarnationOrder), + }) + for (const payload of payloadArray(projected.payloads)) + this.#send(subscription, { + envelope: payload, + incarnationOrder: String(projected.incarnationOrder), + }) + } + + override async webSocketClose(socket: WebSocket): Promise { + socket.close(1000, "closed") + await this.#close() + } + + override async webSocketError(socket: WebSocket): Promise { + socket.close(1011, "connection failed") + await this.#close() + } + + override async alarm(): Promise { + const session = this.#session() + if (session && session.expiresAt <= Date.now()) { + for (const socket of this.ctx.getWebSockets()) socket.close(1008, "session expired") + await this.#close() + } + for (const subscription of this.#subscriptions()) { + if (subscription.status === "active" || subscription.retryAt > Date.now()) continue + try { + await this.#synchronize(subscription) + } catch { + await this.#defer(subscription) + } + } + await this.#atomic(() => undefined) + } + + #session(): SessionRecord | undefined { + return this.ctx.storage.kv.get("session") + } + + #subscriptions(): SessionSubscription[] { + return this.ctx.storage.sql + .exec<{ record: string }>("SELECT record FROM subscriptions") + .toArray() + .map((row) => JSON.parse(row.record) as SessionSubscription) + } + + #subscription(identity: string): SessionSubscription | undefined { + const row = this.ctx.storage.sql + .exec<{ record: string }>("SELECT record FROM subscriptions WHERE identity = ?", identity) + .toArray()[0] + return row ? (JSON.parse(row.record) as SessionSubscription) : undefined + } + + #save(subscription: SessionSubscription): void { + this.ctx.storage.sql.exec( + "INSERT INTO subscriptions(identity, id, record) VALUES (?, ?, ?) ON CONFLICT(identity) DO UPDATE SET id = excluded.id, record = excluded.record", + actorName(subscription), + subscription.id, + JSON.stringify(subscription), + ) + } + + async #authorization(): Promise { + const session = this.#session() + if (!session || session.closed || session.expiresAt <= Date.now()) return null + const value = await options.resolveAuthorizationContext({ + sessionId: session.sessionId, + environment: this.env, + }) + return value === null ? null : normalizeJson(value) + } + + async #synchronize(subscription: SessionSubscription): Promise { + const session = this.#session() + if (!session) return + const authorization = subscription.status === "removing" ? null : await this.#authorization() + if (authorization === null) { + subscription.status = "removing" + await this.#atomic(() => { + const current = this.#subscription(actorName(subscription)) + if (current?.id === subscription.id) this.#save({ ...current, status: "removing" }) + }) + } + await this.#defer(subscription) + const projected = await callHost({ + backend: options.backend(this.env), + request: { + ...subscription, + method: subscription.status === "removing" ? "unsubscribe" : "subscribe", + authorizationContext: authorization, + payload: { + subscriptionId: subscription.id, + sessionName: session.sessionName, + expiresAt: session.expiresAt, + payloads: subscription.payloads, + }, + }, + }) + await this.#atomic(() => { + const current = this.#subscription(actorName(subscription)) + if (!current || current.id !== subscription.id) return + if (subscription.status === "removing") { + this.ctx.storage.sql.exec("DELETE FROM subscriptions WHERE id = ?", subscription.id) + this.ctx.storage.sql.exec( + "DELETE FROM revisions WHERE subscription_id = ?", + subscription.id, + ) + return + } + if (current.status === "removing" || this.#session()?.closed) return + subscription.status = "active" + this.#save({ ...current, status: "active" }) + }) + if (subscription.status !== "active" || projected === null) return + const projection = jsonObject(projected) + this.#send(subscription, { + envelope: jsonObject(projection.event), + incarnationOrder: String(projection.incarnationOrder), + }) + for (const payload of payloadArray(projection.payloads)) + this.#send(subscription, { + envelope: payload, + incarnationOrder: String(projection.incarnationOrder), + }) + } + + async #defer(subscription: SessionSubscription): Promise { + subscription.retryAt = Date.now() + 30_000 + await this.#atomic(() => { + const current = this.#subscription(actorName(subscription)) + if (!current || current.id !== subscription.id) return + this.#save({ ...current, retryAt: subscription.retryAt }) + }) + } + + #send( + subscription: SessionSubscription, + delivery: { envelope: JsonObject; incarnationOrder: string }, + ): void { + const { envelope, incarnationOrder } = delivery + const session = this.#session() + const current = this.#subscription(actorName(subscription)) + if ( + !session || + session.closed || + session.expiresAt <= Date.now() || + !current || + current.id !== subscription.id || + current.status === "removing" + ) + return + const channel = + envelope.kind === "payload" ? `payload:${String(envelope.name)}` : "invalidation" + const incarnation = String(envelope.instanceId) + const revision = String(envelope.revision) + this.ctx.storage.transactionSync(() => { + if (BigInt(incarnationOrder) < BigInt(current.incarnationOrder)) return + if (BigInt(incarnationOrder) > BigInt(current.incarnationOrder)) { + this.ctx.storage.sql.exec( + "DELETE FROM revisions WHERE subscription_id = ?", + subscription.id, + ) + this.#save({ ...current, incarnationOrder }) + } + const previous = this.ctx.storage.sql + .exec<{ incarnation: string; revision: string }>( + "SELECT incarnation, revision FROM revisions WHERE subscription_id = ? AND channel = ?", + subscription.id, + channel, + ) + .toArray()[0] + if (previous?.incarnation === incarnation && BigInt(previous.revision) >= BigInt(revision)) + return + this.ctx.storage.sql.exec( + "INSERT INTO revisions(subscription_id, channel, incarnation, revision) VALUES (?, ?, ?, ?) ON CONFLICT(subscription_id, channel) DO UPDATE SET incarnation = excluded.incarnation, revision = excluded.revision", + subscription.id, + channel, + incarnation, + revision, + ) + for (const socket of this.ctx.getWebSockets()) socket.send(JSON.stringify(envelope)) + }) + } + + async #close(): Promise { + await this.#atomic(() => { + const session = this.#session() + if (session) this.ctx.storage.kv.put("session", { ...session, closed: true }) + for (const subscription of this.#subscriptions()) + this.#save({ ...subscription, status: "removing", retryAt: Date.now() }) + }) + for (const socket of this.ctx.getWebSockets()) { + if (socket.readyState < WebSocket.CLOSING) socket.close(1008, "session closed") + } + } + + async #atomic(callback: () => void): Promise { + await this.ctx.storage.transaction(async () => { + callback() + const session = this.#session() + const due = this.#subscriptions() + .filter((subscription) => subscription.status !== "active") + .map((subscription) => subscription.retryAt) + if (session && !session.closed) due.push(session.expiresAt) + if (due.length === 0) { + await this.ctx.storage.deleteAlarm() + return + } + await this.ctx.storage.setAlarm(Math.max(Date.now() + 1, Math.min(...due))) + }) + } + } +} + +function payloadArray(value: JsonValue | undefined): JsonObject[] { + if (!Array.isArray(value)) throw new TypeError("invalid projection payloads") + return value.map((payload) => jsonObject(payload)) +} diff --git a/src/cloudflare/storage.ts b/src/cloudflare/storage.ts new file mode 100644 index 0000000..53abbb3 --- /dev/null +++ b/src/cloudflare/storage.ts @@ -0,0 +1,276 @@ +import type { Instance, Message, Outbox, Reminder, Subscription } from "./records.js" +import type { CloudflareSettings } from "./configuration.js" +import { PayloadTooLarge } from "../errors.js" +import { utf8ByteLength } from "../serialization.js" + +export class ActorStorage { + constructor( + readonly storage: DurableObjectStorage, + readonly settings: CloudflareSettings, + ) { + storage.transactionSync(() => { + storage.sql.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY); + CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, incarnation TEXT NOT NULL, + sequence INTEGER NOT NULL, idempotency_key TEXT, status TEXT NOT NULL, + available_at INTEGER NOT NULL, completed_at INTEGER, record TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS message_idempotency ON messages(incarnation, idempotency_key) WHERE idempotency_key IS NOT NULL; + CREATE INDEX IF NOT EXISTS mailbox ON messages(incarnation, status, sequence); + CREATE INDEX IF NOT EXISTS message_retention ON messages(completed_at); + CREATE TABLE IF NOT EXISTS receipts (request_id TEXT PRIMARY KEY, message_id TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS receipt_message ON receipts(message_id); + CREATE TABLE IF NOT EXISTS outboxes ( + id TEXT PRIMARY KEY, incarnation TEXT NOT NULL, message_id TEXT NOT NULL, kind TEXT NOT NULL, + destination TEXT NOT NULL, sequence INTEGER NOT NULL, status TEXT NOT NULL, + available_at INTEGER NOT NULL, completed_at INTEGER, record TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS outbox_delivery ON outboxes(kind, destination, status, sequence); + CREATE INDEX IF NOT EXISTS outbox_retention ON outboxes(completed_at); + CREATE INDEX IF NOT EXISTS outbox_source ON outboxes(message_id); + CREATE TABLE IF NOT EXISTS reminders (name TEXT PRIMARY KEY, status TEXT NOT NULL, due_at INTEGER NOT NULL, record TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS reminder_due ON reminders(status, due_at); + CREATE TABLE IF NOT EXISTS subscriptions (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, record TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS subscription_expiry ON subscriptions(expires_at); + INSERT OR IGNORE INTO schema_migrations(version) VALUES (1); + `) + const version = storage.sql + .exec<{ version: number }>("SELECT MAX(version) AS version FROM schema_migrations") + .one().version + if (version !== 1) throw new Error("unsupported Durable Objects storage schema") + const instance = this.instance() + if (instance) { + instance.generation = String(BigInt(instance.generation) + 1n) + this.saveInstance(instance) + } + for (const message of this.rows( + "SELECT record FROM messages WHERE status = 'claimed'", + )) { + message.status = "ready" + message.availableAt = Date.now() + message.generation = null + this.saveMessage(message) + } + for (const outbox of this.rows( + "SELECT record FROM outboxes WHERE status = 'claimed'", + )) { + outbox.status = "pending" + outbox.availableAt = Date.now() + this.saveOutbox(outbox) + } + }) + } + + instance(): Instance | undefined { + return this.metadata("instance") + } + + metadata(key: string): Value | undefined { + const row = this.storage.sql + .exec<{ value: string }>("SELECT value FROM metadata WHERE key = ?", key) + .toArray()[0] + return row ? (JSON.parse(row.value) as Value) : undefined + } + + saveMetadata(key: string, value: unknown): void { + this.storage.sql.exec( + "INSERT INTO metadata(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + key, + encodedRecord(value, [key]), + ) + } + + saveInstance(instance: Instance): void { + this.saveMetadata("instance", instance) + } + + rows(query: string, parameters: readonly (string | number | null)[] = []): Row[] { + return this.storage.sql + .exec<{ record: string }>(query, ...parameters) + .toArray() + .map((row) => JSON.parse(row.record) as Row) + } + + message(id: string): Message | undefined { + return this.rows("SELECT record FROM messages WHERE id = ?", [id])[0] + } + + saveMessage(message: Message): void { + this.storage.sql.exec( + `INSERT INTO messages(id, request_id, incarnation, sequence, idempotency_key, status, available_at, completed_at, record) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, + available_at = excluded.available_at, completed_at = excluded.completed_at, record = excluded.record`, + message.id, + message.requestId, + message.incarnation, + message.sequence, + message.idempotencyKey, + message.status, + message.availableAt, + message.completedAt, + encodedRecord(message, [ + message.id, + message.requestId, + message.incarnation, + message.idempotencyKey ?? "", + message.status, + ]), + ) + } + + head(): Message | undefined { + const instance = this.instance() + if (!instance || instance.paused) return undefined + return this.rows( + "SELECT record FROM messages WHERE incarnation = ? AND status IN ('ready', 'claimed') ORDER BY sequence LIMIT 1", + [instance.incarnation], + )[0] + } + + saveOutbox(outbox: Outbox): void { + this.storage.sql.exec( + `INSERT INTO outboxes(id, incarnation, message_id, kind, destination, sequence, status, available_at, completed_at, record) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, + available_at = excluded.available_at, completed_at = excluded.completed_at, record = excluded.record`, + outbox.id, + outbox.incarnation, + outbox.messageId, + outbox.kind, + outbox.destination, + outbox.sequence, + outbox.status, + outbox.availableAt, + outbox.completedAt, + encodedRecord(outbox, [ + outbox.id, + outbox.incarnation, + outbox.messageId, + outbox.kind, + outbox.destination, + outbox.status, + ]), + ) + } + + outboxHeads(): Outbox[] { + return this.rows( + `SELECT current.record FROM outboxes current WHERE current.status IN ('pending', 'claimed') + AND NOT EXISTS (SELECT 1 FROM outboxes earlier WHERE earlier.kind = current.kind + AND earlier.destination = current.destination AND earlier.status IN ('pending', 'claimed', 'dead') + AND (earlier.sequence < current.sequence OR (earlier.sequence = current.sequence AND earlier.rowid < current.rowid))) + ORDER BY current.available_at, current.rowid LIMIT ?`, + [this.settings.maxMessagesPerActivationPass], + ) + } + + saveReminder(reminder: Reminder): void { + this.storage.sql.exec( + "INSERT INTO reminders(name, status, due_at, record) VALUES (?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET status = excluded.status, due_at = excluded.due_at, record = excluded.record", + reminder.name, + reminder.status, + reminder.at, + encodedRecord(reminder, [reminder.name, reminder.status]), + ) + } + + saveSubscription(subscription: Subscription): void { + this.storage.sql.exec( + "INSERT INTO subscriptions(id, expires_at, record) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET expires_at = excluded.expires_at, record = excluded.record", + subscription.id, + subscription.expiresAt, + encodedRecord(subscription, [subscription.id]), + ) + } + + async atomic(callback: () => Result): Promise { + return this.storage.transaction(async () => { + const result = callback() + await this.schedule() + return result + }) + } + + async schedule(): Promise { + const due: number[] = [] + if (this.metadata("receiptCleanupPending")) due.push(Date.now()) + const head = this.head() + if (head) due.push(head.availableAt) + for (const outbox of this.outboxHeads()) due.push(outbox.availableAt) + const reminder = this.instance()?.paused + ? null + : this.storage.sql + .exec<{ due: number | null }>( + "SELECT MIN(due_at) AS due FROM reminders WHERE status = 'scheduled'", + ) + .one().due + if (reminder !== null) { + const count = this.storage.sql + .exec<{ count: number }>( + "SELECT COUNT(*) AS count FROM messages WHERE status IN ('ready', 'claimed')", + ) + .one().count + const availableAt = + count >= this.settings.maxMailboxLength + ? Math.max(Date.now() + 1_000, head?.availableAt ?? Date.now() + 30_000) + : reminder + due.push(availableAt) + } + const subscription = this.storage.sql + .exec<{ due: number | null }>("SELECT MIN(expires_at) AS due FROM subscriptions") + .one().due + if (subscription !== null) due.push(subscription) + for (const query of [ + "SELECT MIN(completed_at) AS due FROM outboxes WHERE status = 'completed'", + "SELECT MIN(completed_at) AS due FROM messages WHERE status IN ('completed', 'rejected') AND NOT EXISTS (SELECT 1 FROM outboxes WHERE outboxes.message_id = messages.id)", + ]) { + const retention = this.storage.sql.exec<{ due: number | null }>(query).one().due + if (retention !== null) due.push(retention + this.settings.messageRetentionMilliseconds) + } + if (due.length === 0) { + await this.storage.deleteAlarm() + return + } + await this.storage.setAlarm(Math.max(Date.now() + 1, Math.min(...due))) + } + + prune(): void { + const before = Date.now() - this.settings.messageRetentionMilliseconds + const limit = this.settings.pruneBatchSize + this.storage.sql.exec( + "DELETE FROM subscriptions WHERE id IN (SELECT id FROM subscriptions WHERE expires_at <= ? LIMIT ?)", + Date.now(), + limit, + ) + this.storage.sql.exec( + "DELETE FROM outboxes WHERE id IN (SELECT id FROM outboxes WHERE status = 'completed' AND completed_at <= ? LIMIT ?)", + before, + limit, + ) + const removedMessages = this.storage.sql.exec( + `DELETE FROM messages WHERE id IN (SELECT id FROM messages WHERE completed_at <= ? AND status IN ('completed', 'rejected') + AND NOT EXISTS (SELECT 1 FROM outboxes WHERE outboxes.message_id = messages.id) LIMIT ?)`, + before, + limit, + ) + if (removedMessages.rowsWritten === 0 && !this.metadata("receiptCleanupPending")) + return + const removedReceipts = this.storage.sql.exec( + "DELETE FROM receipts WHERE request_id IN (SELECT request_id FROM receipts WHERE NOT EXISTS (SELECT 1 FROM messages WHERE messages.id = receipts.message_id) LIMIT ?)", + limit, + ) + this.saveMetadata("receiptCleanupPending", removedReceipts.rowsWritten >= limit) + } +} + +function encodedRecord(value: unknown, indexedValues: string[]): string { + const encoded = JSON.stringify(value) + const size = indexedValues.reduce( + (total, value) => total + utf8ByteLength(value), + utf8ByteLength(encoded), + ) + if (size > 1_999_000) + throw new PayloadTooLarge("Durable Objects storage record exceeds the SQLite row limit") + return encoded +} diff --git a/src/context.ts b/src/context.ts index 5e13564..0f3aa97 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,18 +1,18 @@ import { createContextStore } from "./platform/context-store.js" import type { Actor } from "./actor.js" -import type { SolidObjectsRuntime } from "./runtime.js" +import type { ActorRuntime } from "./actor-runtime.js" import type { MessageContext } from "./types.js" interface ExecutionContext { actor?: Actor - runtime?: SolidObjectsRuntime + runtime?: ActorRuntime message?: MessageContext applicationWritesForbidden?: true } interface ActorExecutionContext { actor: Actor - runtime: SolidObjectsRuntime + runtime: ActorRuntime message?: MessageContext } @@ -22,7 +22,7 @@ export function currentActor(): Actor | undefined { return storage.getStore()?.actor } -export function currentRuntime(): SolidObjectsRuntime | undefined { +export function currentRuntime(): ActorRuntime | undefined { return storage.getStore()?.runtime } @@ -42,7 +42,7 @@ export function withActorContext( } export function withActorProjection( - context: { actor: Actor; runtime: SolidObjectsRuntime }, + context: { actor: Actor; runtime: ActorRuntime }, callback: () => Result, ): Result { return storage.run({ ...context, applicationWritesForbidden: true }, callback) @@ -51,3 +51,7 @@ export function withActorProjection( export function withApplicationWritesForbidden(callback: () => Result): Result { return storage.run({ ...storage.getStore(), applicationWritesForbidden: true }, callback) } + +export function withRuntime(runtime: ActorRuntime, callback: () => Result): Result { + return storage.run({ ...storage.getStore(), runtime }, callback) +} diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..28ecd7b --- /dev/null +++ b/src/core.ts @@ -0,0 +1,7 @@ +export * from "./actor.js" +export * from "./errors.js" +export type * from "./types.js" +export type * from "./reference.js" +export type { ActorRuntime } from "./actor-runtime.js" +export type { StateMigration } from "./definition.js" +export { withRuntime } from "./context.js" diff --git a/src/default-runtime.ts b/src/default-runtime.ts index 2e49dff..e1cda40 100644 --- a/src/default-runtime.ts +++ b/src/default-runtime.ts @@ -1,16 +1,16 @@ -import type { SolidObjectsRuntime } from "./runtime.js" +import type { ActorRuntime } from "./actor-runtime.js" -let runtime: SolidObjectsRuntime | undefined +let runtime: ActorRuntime | undefined -export function setDefaultRuntime(value: SolidObjectsRuntime): void { +export function setDefaultRuntime(value: ActorRuntime): void { runtime = value } -export function getDefaultRuntime(): SolidObjectsRuntime { +export function getDefaultRuntime(): ActorRuntime { if (!runtime) throw new Error("SolidObjects.configure must be called before Actor.ref") return runtime } -export function clearDefaultRuntime(value?: SolidObjectsRuntime): void { +export function clearDefaultRuntime(value?: ActorRuntime): void { if (value === undefined || runtime === value) runtime = undefined } diff --git a/src/errors.ts b/src/errors.ts index ff48220..d4ba2a3 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -7,6 +7,15 @@ export class SolidObjectsError extends Error { } } export class NonRetryableError extends SolidObjectsError {} +export class UnsupportedCapability extends NonRetryableError {} +export class EnqueueOutcomeUnknown extends SolidObjectsError { + constructor( + readonly details: { actorType: string; actorId: string; requestId: string }, + options?: ErrorOptions, + ) { + super("message acceptance is unknown; recover using lookupMessage and the request ID", options) + } +} export class UnsupportedDatabase extends SolidObjectsError {} export class DatabaseDeadlineExceeded extends SolidObjectsError {} export class SyncEnqueueTimeout extends SolidObjectsError { diff --git a/src/realtime.ts b/src/realtime.ts index c7de164..717de3f 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -1,6 +1,23 @@ import type { InvalidationEnvelope, RealtimeEnvelope } from "./browser/index.js" import type { BroadcastEvent } from "./configuration.js" -import type { SolidObjectsRuntime } from "./runtime.js" +import type { PayloadEnvelope } from "./browser/index.js" +import type { JsonObject } from "./types.js" + +export interface RealtimeRuntime { + subscriptionSnapshot(options: { + actorType: string + actorId: string + authorizationContext: unknown + onAuthorized?: () => void + }): Promise + subscriptionPayloads(options: { + actorType: string + actorId: string + payloadNames: readonly string[] + authorizationContext: unknown + }): Promise + emitInstrumentation(name: string, attributes: JsonObject): void +} const MAXIMUM_PAYLOADS_PER_SUBSCRIPTION = 50 @@ -26,7 +43,7 @@ export interface RealtimeSession { export class RealtimeManager { private readonly subscriptions = new Map>() - constructor(private readonly runtime: SolidObjectsRuntime) {} + constructor(private readonly runtime: RealtimeRuntime) {} connect( options: RealtimeConnectionOptions, diff --git a/src/reference.ts b/src/reference.ts index cae7d08..f23d032 100644 --- a/src/reference.ts +++ b/src/reference.ts @@ -1,6 +1,6 @@ import type { Actor, ActorClass } from "./actor.js" import { SyncInsideTransaction, UnknownOperation } from "./errors.js" -import type { SolidObjectsRuntime } from "./runtime.js" +import type { ActorRuntime } from "./actor-runtime.js" import type { AsyncInvocationOptions, DeepReadonly, @@ -107,7 +107,7 @@ export type ActorInvoker = DirectMessages & DirectQueries export class MessageReference { - private readonly runtime: SolidObjectsRuntime + private readonly runtime: ActorRuntime private readonly databaseTransactionActive: () => boolean readonly id: string readonly requestId: string @@ -117,7 +117,7 @@ export class MessageReference { private readonly operation: string constructor(options: { - runtime: SolidObjectsRuntime + runtime: ActorRuntime id: string requestId: string actorType: string @@ -188,7 +188,7 @@ export function installLiveSignals(factory: LiveSignalsFactory): void { export class ActorReferenceCore { readonly send: ActorMessageSender - readonly runtime: SolidObjectsRuntime + readonly runtime: ActorRuntime readonly actorClass: ActorClass readonly actorType: string readonly actorId: string @@ -196,7 +196,7 @@ export class ActorReferenceCore { readonly queries: ReadonlySet constructor(options: { - runtime: SolidObjectsRuntime + runtime: ActorRuntime actorClass: ActorClass actorType: string actorId: string @@ -238,7 +238,7 @@ export class ActorReferenceCore { } export function createActorReference(options: { - runtime: SolidObjectsRuntime + runtime: ActorRuntime actorClass: ActorClass actorType: string actorId: string diff --git a/src/repository.ts b/src/repository.ts index 10e1012..80cdf52 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -26,7 +26,7 @@ import type { } from "./records.js" import { jsonObject, normalizeJson } from "./serialization.js" import type { RetentionTarget } from "./retention.js" -import type { JsonObject, JsonValue } from "./types.js" +import type { JsonObject, JsonValue, MessageStatus } from "./types.js" import { VERSION } from "./version.js" export interface SyncDiagnosticsRecord { @@ -892,6 +892,27 @@ export class Repository { ) } + async messageSnapshot( + id: string, + ): Promise<{ message: MessageRow | undefined; status: MessageStatus }> { + return this.settings.database.connection(async (connection) => { + const row = await connection.get( + `SELECT messages.*, CASE + WHEN messages.rejection IS NOT NULL THEN 'rejected' + WHEN EXISTS (SELECT 1 FROM ${this.table("dead_letters")} WHERE message_id = messages.id) THEN 'dead' + WHEN messages.completed_at_ms IS NOT NULL THEN 'completed' + WHEN EXISTS (SELECT 1 FROM ${this.table("claimed_messages")} WHERE message_id = messages.id) THEN 'claimed' + WHEN EXISTS (SELECT 1 FROM ${this.table("ready_messages")} WHERE message_id = messages.id) THEN 'ready' + ELSE 'unknown' END AS membership_status + FROM ${this.table("messages")} messages WHERE messages.id = ?`, + [id], + ) + if (!row) return { message: undefined, status: "unknown" } + const { membership_status: status, ...message } = row + return { message, status } + }) + } + async messageStatus( id: string, ): Promise<"ready" | "claimed" | "completed" | "rejected" | "dead" | "unknown"> { diff --git a/src/runtime.ts b/src/runtime.ts index 7c283b9..6c557bb 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -124,6 +124,7 @@ import { EffectWorker } from "./effect-worker.js" import type { WakeUpRole } from "./wake-up.js" import { withDatabaseDeadline } from "./database/deadline.js" import type { DatabaseConnection } from "./database/types.js" +import { evaluateActorTurn, readActorObservables } from "./turn.js" interface RegisteredActor { actorClass: ActorClass @@ -483,13 +484,7 @@ export class SolidObjectsRuntime { try { finalSnapshot = await withDatabaseDeadline( { timeoutMilliseconds: Math.max(this.settings.syncPollingIntervalMilliseconds, 100) }, - async () => { - const message = await this.repository.findMessage(messageReference.id) - return { - message, - status: message ? await this.repository.messageStatus(message.id) : "unknown", - } - }, + () => this.repository.messageSnapshot(messageReference.id), ) } catch (error) { if (!(error instanceof DatabaseDeadlineExceeded)) throw error @@ -518,13 +513,9 @@ export class SolidObjectsRuntime { deadline: number, ): Promise<{ message: MessageRow | undefined; status: MessageStatus }> { const remaining = Math.max(Math.floor(deadline - performance.now()), 0) - return withDatabaseDeadline({ timeoutMilliseconds: remaining }, async () => { - const message = await this.repository.findMessage(messageReference.id) - return { - message, - status: message ? await this.repository.messageStatus(message.id) : "unknown", - } - }) + return withDatabaseDeadline({ timeoutMilliseconds: remaining }, () => + this.repository.messageSnapshot(messageReference.id), + ) } private async syncTimeout( @@ -1161,44 +1152,21 @@ export class SolidObjectsRuntime { this.emitInstrumentation("message.started", messageInstrumentation(turn.message)) try { stateBefore = deepCopy(actorState(actor, definition.stateKeys)) - const before = stableJson(stateBefore) - const oldObservables = this.readObservables({ actor, definition, stateJson: before }) - const query = this.isQuery(definition, turn.message.operation) - const argumentsValue = jsonObject(JSON.parse(turn.message.arguments)) - const rawResult = await withActorContext( - { actor, runtime: this, message: messageContext }, - async () => { - if (definition.stateKeys.includes(turn.message.operation)) { - return (actor as unknown as Record)[turn.message.operation] - } - return actor.invoke(turn.message.operation, argumentsValue) - }, - ) - const committedState = jsonObject(actorState(actor, definition.stateKeys), { - maxBytes: this.settings.maxStateBytes, - }) - const committed = stableJson(committedState) - if (query && committed !== before) { - throw new QueryMutatedState(`query ${turn.message.operation} mutated actor state`) - } - if (query && actor.hasIntents()) { - throw new QueryMutatedState(`query ${turn.message.operation} staged durable work`) - } - const result = normalizeJson(rawResult === undefined ? null : rawResult, { - maxBytes: this.settings.maxResultBytes, + const evaluated = await evaluateActorTurn({ + actor, + definition, + runtime: this, + message: messageContext, + operation: turn.message.operation, + argumentsValue: jsonObject(JSON.parse(turn.message.arguments)), + stateBefore, + maxStateBytes: this.settings.maxStateBytes, + maxResultBytes: this.settings.maxResultBytes, }) - const observables = this.readObservables({ actor, definition, stateJson: committed }) - const changedObservableNames = Object.keys(observables.values).filter( - (name) => - stableJson(observables.values[name]) !== stableJson(oldObservables.values[name]) || - observables.modes[name] !== oldObservables.modes[name], - ) - const changedProjection = selectBroadcastProjection(observables, changedObservableNames) - const broadcastProjectionValue = - changedObservableNames.length > 0 || - (Object.keys(definition.payloads).length > 0 && committed !== before) - ? changedProjection - : undefined + const committedState = evaluated.state + const committed = evaluated.stateJson + const result = evaluated.result + const broadcastProjectionValue = evaluated.broadcast renewalController.abort() await renewal if (renewalError) throw renewalError @@ -1828,17 +1796,7 @@ export class SolidObjectsRuntime { definition: ValidatedActorDefinition stateJson?: string }): ObservableProjection { - const { actor, definition } = options - const stateBefore = options.stateJson ?? stableJson(actorState(actor, definition.stateKeys)) - const intentCount = actor.intentCount() - const values = withActorProjection({ actor, runtime: this }, () => actor.observableValues()) - if ( - stableJson(actorState(actor, definition.stateKeys)) !== stateBefore || - actor.intentCount() !== intentCount - ) { - throw new QueryMutatedState("observables must not mutate actor state or stage durable work") - } - return values + return readActorObservables({ ...options, runtime: this }) } private validatePayloadNames( diff --git a/src/turn.ts b/src/turn.ts new file mode 100644 index 0000000..99c5f83 --- /dev/null +++ b/src/turn.ts @@ -0,0 +1,87 @@ +import type { Actor, ObservableProjection } from "./actor.js" +import type { ActorRuntime } from "./actor-runtime.js" +import { withActorContext, withActorProjection } from "./context.js" +import { actorState, type ValidatedActorDefinition } from "./definition.js" +import { QueryMutatedState } from "./errors.js" +import { jsonObject, normalizeJson, stableJson } from "./serialization.js" +import type { JsonObject, MessageContext } from "./types.js" + +export function readActorObservables(options: { + actor: Actor + definition: ValidatedActorDefinition + runtime: ActorRuntime + stateJson?: string +}): ObservableProjection { + const { actor, definition, runtime } = options + const before = options.stateJson ?? stableJson(actorState(actor, definition.stateKeys)) + const intentCount = actor.intentCount() + const projection = withActorProjection({ actor, runtime }, () => actor.observableValues()) + if ( + stableJson(actorState(actor, definition.stateKeys)) !== before || + actor.intentCount() !== intentCount + ) { + throw new QueryMutatedState("observables must not mutate actor state or stage durable work") + } + return projection +} + +export function selectActorBroadcast( + projection: ObservableProjection, + names: readonly string[] = Object.keys(projection.values), +): { observables: JsonObject; invalidations: string[] } { + const observables: JsonObject = {} + const invalidations: string[] = [] + for (const name of names) { + if (projection.modes[name] === "invalidation") { + invalidations.push(name) + continue + } + const value = projection.values[name] + if (value !== undefined) observables[name] = value + } + return { observables, invalidations } +} + +export async function evaluateActorTurn(options: { + actor: Actor + definition: ValidatedActorDefinition + runtime: ActorRuntime + message: MessageContext + operation: string + argumentsValue: JsonObject + stateBefore?: JsonObject + maxStateBytes: number + maxResultBytes: number +}) { + const { actor, definition, runtime, operation } = options + const before = stableJson(options.stateBefore ?? actorState(actor, definition.stateKeys)) + const previous = readActorObservables({ actor, definition, runtime, stateJson: before }) + const query = definition.queries.includes(operation) + const rawResult = await withActorContext({ actor, runtime, message: options.message }, () => + actor.invoke(operation, options.argumentsValue), + ) + const state = jsonObject(actorState(actor, definition.stateKeys), { + maxBytes: options.maxStateBytes, + }) + const stateJson = stableJson(state) + if (query && stateJson !== before) { + throw new QueryMutatedState(`query ${operation} mutated actor state`) + } + if (query && actor.hasIntents()) { + throw new QueryMutatedState(`query ${operation} staged durable work`) + } + const result = normalizeJson(rawResult === undefined ? null : rawResult, { + maxBytes: options.maxResultBytes, + }) + const projection = readActorObservables({ actor, definition, runtime, stateJson }) + const changedNames = Object.keys(projection.values).filter( + (name) => + stableJson(projection.values[name]) !== stableJson(previous.values[name]) || + projection.modes[name] !== previous.modes[name], + ) + const broadcast = + changedNames.length > 0 || (Object.keys(definition.payloads).length > 0 && stateJson !== before) + ? selectActorBroadcast(projection, changedNames) + : undefined + return { state, stateJson, result, broadcast } +} diff --git a/test/cloudflare/contract.test.ts b/test/cloudflare/contract.test.ts new file mode 100644 index 0000000..5122f55 --- /dev/null +++ b/test/cloudflare/contract.test.ts @@ -0,0 +1,10 @@ +import { env } from "cloudflare:test" +import { describe } from "vitest" +import { createRuntime, durableObjects } from "../../src/cloudflare/index.js" +import { portableRuntimeContract } from "../support/portable-runtime-contract.js" + +describe("Durable Objects portable runtime contract", () => { + portableRuntimeContract(() => + createRuntime({ backend: durableObjects({ namespace: env.ACTORS }) }), + ) +}) diff --git a/test/cloudflare/environment.d.ts b/test/cloudflare/environment.d.ts new file mode 100644 index 0000000..8be9cee --- /dev/null +++ b/test/cloudflare/environment.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=test/cloudflare/wrangler.jsonc --include-runtime=false test/cloudflare/environment.d.ts` (hash: 30c3823992a5154da09c838c96a8f2fb) +interface __BaseEnv_Env { + ACTORS: DurableObjectNamespace + SESSIONS: DurableObjectNamespace +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./worker") + durableNamespaces: "Actors" | "Sessions" + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/test/cloudflare/realtime.test.ts b/test/cloudflare/realtime.test.ts new file mode 100644 index 0000000..0ce9969 --- /dev/null +++ b/test/cloudflare/realtime.test.ts @@ -0,0 +1,196 @@ +import { env, evictDurableObject, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test" +import { afterEach, describe, expect, it } from "vitest" +import { createRuntime, durableObjects } from "../../src/cloudflare/index.js" +import { Counter, revokedSessions } from "./worker.js" +import { SolidObjectsBrowserClient } from "../../src/browser/index.js" + +const sockets: WebSocket[] = [] +afterEach(() => { + for (const socket of sockets.splice(0)) socket.close() +}) + +async function connect() { + const sessionName = crypto.randomUUID() + const stub = env.SESSIONS.getByName(sessionName) + const response = await stub.fetch("https://solid-objects.internal/session", { + headers: { + Upgrade: "websocket", + "X-Solid-Session-Name": sessionName, + "X-Solid-Session-Id": "test-session", + "X-Solid-Session-Expiry": String(Date.now() + 60_000), + }, + }) + const socket = response.webSocket! + socket.accept() + sockets.push(socket) + const messages: Record[] = [] + socket.addEventListener("message", (event: MessageEvent) => { + messages.push(JSON.parse(String(event.data)) as Record) + }) + return { socket, messages, stub } +} + +describe("Cloudflare realtime", () => { + it("drops duplicate revisions and events from a destroyed incarnation", async () => { + const { socket, messages, stub } = await connect() + const runtime = createRuntime({ backend: durableObjects({ namespace: env.ACTORS }) }) + const reference = runtime.ref(Counter, "revision-fence") + socket.send( + JSON.stringify({ + version: 1, + action: "subscribe", + actorType: "Counter", + actorId: "revision-fence", + }), + ) + await expect.poll(() => messages.length).toBe(1) + await reference.with({ authorizationContext: "allowed" }).increment() + await expect.poll(() => messages.length).toBe(2) + const old = messages[1]! + await reference.destroy({ authorizationContext: "allowed" }) + await reference.with({ authorizationContext: "allowed" }).increment() + await expect.poll(() => messages.length).toBe(3) + const subscriptionId = await runInDurableObject( + stub, + (_object, state) => + state.storage.sql.exec<{ id: string }>("SELECT id FROM subscriptions").one().id, + ) + await stub.publish({ subscriptionId, event: old as import("../../src/core.js").JsonObject }) + await stub.publish({ + subscriptionId, + event: messages[2] as import("../../src/core.js").JsonObject, + }) + expect(messages).toHaveLength(3) + expect(messages[2]!.instanceId).not.toBe(old.instanceId) + }) + + it("expires idle sessions through their alarm", async () => { + const { stub } = await connect() + await runInDurableObject(stub, (_object, state) => { + const session = state.storage.kv.get>("session")! + state.storage.kv.put("session", { ...session, expiresAt: Date.now() - 1 }) + }) + await runDurableObjectAlarm(stub) + const closed = await runInDurableObject( + stub, + (_object, state) => state.storage.kv.get<{ closed: boolean }>("session")?.closed, + ) + expect(closed).toBe(true) + }) + + it("uses the existing browser client and isolates failed or denied payloads", async () => { + const { socket } = await connect() + const invalidations: unknown[] = [] + const payloads: { name: string }[] = [] + const client = new SolidObjectsBrowserClient({ + url: "wss://example.invalid/events", + createWebSocket: () => socket, + onInvalidation: (envelope) => invalidations.push(envelope), + onPayload: (envelope) => payloads.push(envelope), + }) + client.connect() + client.subscribe({ + actorType: "Counter", + actorId: "payloads", + payloads: ["personal", "broken", "denied"], + }) + await expect.poll(() => payloads.length).toBe(1) + expect(payloads[0]).toMatchObject({ + name: "personal", + payload: { count: 0, authorization: "allowed" }, + }) + expect(invalidations).toHaveLength(1) + client.close() + }) + + it("reauthorizes an existing connection before delivering a new revision", async () => { + const { socket, messages, stub } = await connect() + socket.send( + JSON.stringify({ version: 1, action: "subscribe", actorType: "Counter", actorId: "revoked" }), + ) + await expect.poll(() => messages.length).toBe(1) + revokedSessions.add("test-session") + try { + await createRuntime({ backend: durableObjects({ namespace: env.ACTORS }) }) + .ref(Counter, "revoked") + .with({ authorizationContext: "allowed" }) + .increment() + await expect + .poll(() => + runInDurableObject( + stub, + (_object, state) => state.storage.kv.get<{ closed: boolean }>("session")?.closed, + ), + ) + .toBe(true) + expect(messages).toHaveLength(1) + } finally { + revokedSessions.delete("test-session") + } + }) + + it("multiplexes actors and resumes a hibernating connection", async () => { + const { socket, messages, stub } = await connect() + for (const actorId of ["first", "second"]) + socket.send( + JSON.stringify({ version: 1, action: "subscribe", actorType: "Counter", actorId }), + ) + await expect.poll(() => messages.length).toBe(2) + expect(messages.map((message) => message.actorId).sort()).toEqual(["first", "second"]) + await evictDurableObject(stub) + const runtime = createRuntime({ + backend: durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }), + }) + await runtime.ref(Counter, "first").with({ authorizationContext: "allowed" }).increment() + const actor = env.ACTORS.getByName(JSON.stringify(["Counter", "first"])) + await runDurableObjectAlarm(actor) + await expect.poll(() => messages.length).toBe(3) + expect(messages[2]).toMatchObject({ actorId: "first", observables: { count: 1 } }) + }) + + it("rejects expired or unauthenticated sessions", async () => { + const name = crypto.randomUUID() + const response = await env.SESSIONS.getByName(name).fetch( + "https://solid-objects.internal/session", + { + headers: { + Upgrade: "websocket", + "X-Solid-Session-Name": name, + "X-Solid-Session-Id": "invalid", + "X-Solid-Session-Expiry": String(Date.now() + 60_000), + }, + }, + ) + expect(response.status).toBe(401) + }) + + it("cleans up actor subscriptions after disconnect", async () => { + const { socket, messages, stub } = await connect() + socket.send( + JSON.stringify({ version: 1, action: "subscribe", actorType: "Counter", actorId: "cleanup" }), + ) + await expect.poll(() => messages.length).toBe(1) + socket.close() + await expect + .poll(async () => + runInDurableObject( + stub, + (_object, state) => state.storage.kv.get<{ closed: boolean }>("session")?.closed, + ), + ) + .toBe(true) + await runDurableObjectAlarm(stub) + const actor = env.ACTORS.getByName(JSON.stringify(["Counter", "cleanup"])) + await expect + .poll(() => + runInDurableObject( + actor, + (_object, state) => + state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM subscriptions") + .one().count, + ), + ) + .toBe(0) + }) +}) diff --git a/test/cloudflare/recovery.test.ts b/test/cloudflare/recovery.test.ts new file mode 100644 index 0000000..9571d30 --- /dev/null +++ b/test/cloudflare/recovery.test.ts @@ -0,0 +1,351 @@ +import { env, evictDurableObject, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test" +import { describe, expect, it } from "vitest" +import { createRuntime, durableObjects } from "../../src/cloudflare/index.js" +import type { Instance, Message } from "../../src/cloudflare/records.js" +import { Counter, VersionedCounter, gates, deliveries } from "./worker.js" + +const authorizationContext = "allowed" +const backend = () => durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }) +const runtime = () => createRuntime({ backend: backend() }) +const stub = (actorId: string) => env.ACTORS.getByName(JSON.stringify(["Counter", actorId])) + +describe("Cloudflare recovery and fencing", () => { + it("migrates stored state on activation and rejects a newer stored version", async () => { + const reference = runtime().ref(VersionedCounter, "migration") + const actor = env.ACTORS.getByName(JSON.stringify(["VersionedCounter", "migration"])) + await reference.with({ authorizationContext }).increment() + await runInDurableObject(actor, (_object, state) => { + const instance = JSON.parse( + state.storage.sql + .exec<{ value: string }>("SELECT value FROM metadata WHERE key = 'instance'") + .one().value, + ) as Instance + instance.stateVersion = 1 + state.storage.sql.exec( + "UPDATE metadata SET value = ? WHERE key = 'instance'", + JSON.stringify(instance), + ) + }) + await evictDurableObject(actor) + expect(await reference.with({ authorizationContext }).increment()).toBe(12) + await runInDurableObject(actor, (_object, state) => { + const instance = JSON.parse( + state.storage.sql + .exec<{ value: string }>("SELECT value FROM metadata WHERE key = 'instance'") + .one().value, + ) as Instance + expect(instance.stateVersion).toBe(2) + instance.stateVersion = 3 + state.storage.sql.exec( + "UPDATE metadata SET value = ? WHERE key = 'instance'", + JSON.stringify(instance), + ) + }) + await evictDurableObject(actor) + await expect(reference.snapshot({ authorizationContext })).rejects.toMatchObject({ + name: "StateMigrationError", + }) + await expect(reference.with({ authorizationContext }).increment()).rejects.toMatchObject({ + name: "ActorSetupFailed", + setupError: { name: "StateMigrationError" }, + }) + }) + + it("coalesces missed recurring reminders and preserves the next deadline", async () => { + const reference = runtime().ref(Counter, "recurring") + await reference + .with({ authorizationContext }) + .armRecurring({ at: Date.now() - 120_000, interval: 60_000, missed: "latest" }) + await expect + .poll(() => reference.snapshot({ authorizationContext }).then((snapshot) => snapshot.count)) + .toBe(1) + const reminders = await runtime() + .actorAdministration({ actorType: "Counter", actorId: "recurring", authorizationContext }) + .reminders() + expect(reminders).toMatchObject([{ status: "scheduled" }]) + expect(Number((reminders as { at: number }[])[0]!.at)).toBeGreaterThan(Date.now()) + }) + + it("rolls back a result that exceeds the aggregate SQLite record limit", async () => { + const reference = runtime().ref(Counter, "oversized-record") + await expect( + reference + .with({ authorizationContext, timeoutMilliseconds: 500 }) + .echo({ value: "x".repeat(1_010_000) }), + ).rejects.toMatchObject({ name: "MessageFailed", details: { name: "PayloadTooLarge" } }) + expect((await reference.snapshot({ authorizationContext })).count).toBe(0) + }) + + it("rejects a stale in-flight commit while another identity continues", async () => { + const reference = runtime().ref(Counter, "in-flight") + const message = await reference.send.with({ authorizationContext }).pause() + await expect.poll(() => gates.has("in-flight")).toBe(true) + try { + expect( + await runtime().ref(Counter, "independent").with({ authorizationContext }).increment(), + ).toBe(1) + await reference.destroy({ authorizationContext }) + } finally { + await runInDurableObject(stub("in-flight"), () => gates.get("in-flight")!()) + } + expect(await reference.with({ authorizationContext }).increment()).toBe(1) + await expect(message.result({ authorizationContext })).rejects.toMatchObject({ + name: "ActorDestroyed", + }) + }) + + it("does not hold actor turns behind a slow effect", async () => { + const reference = runtime().ref(Counter, "slow-effect").with({ authorizationContext }) + await reference.slowEffect() + await expect.poll(() => gates.has("slow-effect")).toBe(true) + try { + expect(await reference.increment()).toBe(1) + } finally { + await runInDurableObject(stub("slow-effect"), () => gates.get("slow-effect")!()) + } + await expect + .poll(() => + runtime() + .ref(Counter, "slow-effect") + .snapshot({ authorizationContext }) + .then((snapshot) => snapshot.count), + ) + .toBe(11) + }) + + it("uses a stable effect ID across retries and delivers its callback once", async () => { + await runtime().ref(Counter, "repeat-effect").with({ authorizationContext }).repeatedEffect() + await expect + .poll(() => + runtime() + .ref(Counter, "repeat-effect") + .snapshot({ authorizationContext }) + .then((snapshot) => snapshot.count), + ) + .toBe(10) + const outbox = await runInDurableObject(stub("repeat-effect"), (_object, state) => + state.storage.sql.exec<{ id: string }>("SELECT id FROM outboxes WHERE kind = 'effect'").one(), + ) + expect(deliveries.get(outbox.id)).toBe(2) + }) + + it("deduplicates destination acceptance after an outbound acknowledgement is lost", async () => { + await runtime() + .ref(Counter, "lost-ack-source") + .with({ authorizationContext }) + .forward({ target: "lost-ack" }) + await expect + .poll(async () => + runInDurableObject( + stub("lost-ack-source"), + (_object, state) => + state.storage.sql + .exec<{ status: string }>("SELECT status FROM outboxes WHERE kind = 'outbound'") + .one().status, + ), + ) + .toBe("completed") + expect( + (await runtime().ref(Counter, "lost-ack").snapshot({ authorizationContext })).count, + ).toBe(1) + }) + + it("rolls back state and every staged outbox on failure", async () => { + await expect( + runtime().ref(Counter, "rollback").with({ authorizationContext }).failWithIntents(), + ).rejects.toMatchObject({ name: "MessageFailed" }) + const counts = await runInDurableObject(stub("rollback"), (_object, state) => ({ + outboxes: state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM outboxes") + .one().count, + reminders: state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM reminders") + .one().count, + })) + expect(counts).toEqual({ outboxes: 0, reminders: 0 }) + expect( + (await runtime().ref(Counter, "rollback").snapshot({ authorizationContext })).count, + ).toBe(0) + }) + + it("recovers ambiguous acceptance by request ID without repeating work", async () => { + const client = createRuntime({ + backend: durableObjects({ + namespace: { + getByName: (name) => ({ + request: async (request) => { + const reply = await env.ACTORS.getByName(name).request(request) + if (request.method === "enqueue" && reply.ok) + throw new Error("connection lost after durable acceptance") + return reply + }, + }), + }, + }), + }) + let requestId = "" + try { + await client.ref(Counter, "ambiguous").with({ authorizationContext }).increment() + } catch (error) { + expect(error).toMatchObject({ name: "EnqueueOutcomeUnknown" }) + requestId = (error as { details: { requestId: string } }).details.requestId + } + expect(requestId).not.toBe("") + const recovered = await runtime().lookupMessage({ + actorType: "Counter", + actorId: "ambiguous", + requestId, + authorizationContext, + }) + expect(await recovered!.wait({ authorizationContext })).toBe(1) + await expect( + runtime().lookupMessage({ actorType: "Counter", actorId: "ambiguous", requestId }), + ).rejects.toMatchObject({ name: "Unauthorized" }) + }) + + it("recovers a claimed turn on activation using only its alarm", async () => { + const reference = runtime().ref(Counter, "interrupted") + const message = await reference.send + .with({ authorizationContext, availableAt: new Date(Date.now() + 60_000) }) + .increment() + await runInDurableObject(stub("interrupted"), async (_object, state) => { + const row = state.storage.sql + .exec<{ record: string }>("SELECT record FROM messages WHERE id = ?", message.id) + .one() + const stored = JSON.parse(row.record) as Message + stored.status = "claimed" + stored.attempt = 1 + stored.availableAt = Date.now() + state.storage.sql.exec( + "UPDATE messages SET status = 'claimed', available_at = ?, record = ? WHERE id = ?", + stored.availableAt, + JSON.stringify(stored), + stored.id, + ) + await state.storage.setAlarm(Date.now() + 1) + }) + await evictDurableObject(stub("interrupted")) + await runDurableObjectAlarm(stub("interrupted")) + expect(await message.wait({ authorizationContext })).toBe(1) + }) + + it("keeps sequences exact above JavaScript's safe integer range", async () => { + const reference = runtime().ref(Counter, "large-sequence") + await reference.with({ authorizationContext }).increment() + await runInDurableObject(stub("large-sequence"), (_object, state) => { + const row = state.storage.sql + .exec<{ value: string }>("SELECT value FROM metadata WHERE key = 'instance'") + .one() + const instance = JSON.parse(row.value) as Instance + instance.nextSequence = "9007199254740993" + state.storage.sql.exec( + "UPDATE metadata SET value = ? WHERE key = 'instance'", + JSON.stringify(instance), + ) + }) + const first = await reference.send.with({ authorizationContext }).increment() + const second = await reference.send.with({ authorizationContext }).increment() + expect(first.sequence).toBe(9007199254740993n) + expect(second.sequence).toBe(first.sequence + 1n) + expect(await second.wait({ authorizationContext })).toBe(3) + }) + + it("keeps runtime context across awaits and rejects actor call cycles", async () => { + const source = runtime().ref(Counter, "async-source").with({ authorizationContext }) + await source.forwardAfterAwait({ target: "async-target" }) + await runDurableObjectAlarm(stub("async-source")) + await expect + .poll(() => + runtime() + .ref(Counter, "async-target") + .snapshot({ authorizationContext }) + .then((snapshot) => snapshot.count), + ) + .toBe(1) + await expect(source.forbiddenCall()).rejects.toMatchObject({ + name: "MessageFailed", + details: { name: "ActorCallCycle" }, + }) + }) + + it("retains dead letters, pauses later work, and rejects unsupported commits", async () => { + const reference = runtime().ref(Counter, "dead") + await expect(reference.with({ authorizationContext }).failPermanently()).rejects.toMatchObject({ + name: "MessageFailed", + }) + const waiting = await reference.send.with({ authorizationContext }).increment() + await expect( + waiting.wait({ authorizationContext, timeoutMilliseconds: 10 }), + ).rejects.toMatchObject({ name: "SyncTimeout" }) + const administration = runtime().actorAdministration({ + actorType: "Counter", + actorId: "dead", + authorizationContext, + }) + expect(await administration.deadLetters()).toMatchObject({ messages: [{ status: "dead" }] }) + expect((await reference.snapshot({ authorizationContext })).count).toBe(0) + await expect( + runtime().ref(Counter, "commit").with({ authorizationContext }).commitToDatabase(), + ).rejects.toMatchObject({ name: "MessageFailed", details: { name: "UnsupportedCapability" } }) + expect(() => runtime().run()).toThrow(/does not support/) + }) + + it("fences old references when an actor is destroyed and recreated", async () => { + const reference = runtime().ref(Counter, "recreated") + const old = await reference.send.with({ authorizationContext }).increment() + await old.wait({ authorizationContext }) + const before = await runtime().snapshotWithIncarnation(reference, { authorizationContext }) + expect(await reference.destroy({ authorizationContext })).toBe(true) + expect(await reference.with({ authorizationContext }).increment()).toBe(1) + await expect(old.result({ authorizationContext })).rejects.toMatchObject({ + name: "ActorDestroyed", + }) + expect( + (await runtime().snapshotWithIncarnation(reference, { authorizationContext })).instanceId, + ).not.toBe(before.instanceId) + }) + + it("stops alarms after retention removes completed work", async () => { + await runtime().ref(Counter, "pruned").with({ authorizationContext }).increment() + await runInDurableObject(stub("pruned"), (_object, state) => { + state.storage.sql.exec("UPDATE messages SET completed_at = 0") + }) + await runDurableObjectAlarm(stub("pruned")) + const alarm = await runInDurableObject(stub("pruned"), (_object, state) => + state.storage.getAlarm(), + ) + expect(alarm).toBeNull() + }) + + it("continues bounded receipt cleanup using its saved alarm", async () => { + const reference = runtime().ref(Counter, "receipt-cleanup") + const message = await reference.send.with({ authorizationContext }).increment() + await message.wait({ authorizationContext }) + await runInDurableObject(stub("receipt-cleanup"), (_object, state) => { + for (let index = 0; index < 1_002; index += 1) + state.storage.sql.exec( + "INSERT INTO receipts(request_id, message_id) VALUES (?, ?)", + `alias-${index}`, + message.id, + ) + state.storage.sql.exec("UPDATE messages SET completed_at = 0") + }) + await runDurableObjectAlarm(stub("receipt-cleanup")) + await expect + .poll(() => + runInDurableObject( + stub("receipt-cleanup"), + (_object, state) => + state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM receipts") + .one().count, + ), + ) + .toBe(0) + await expect + .poll(() => + runInDurableObject(stub("receipt-cleanup"), (_object, state) => state.storage.getAlarm()), + ) + .toBeNull() + }) +}) diff --git a/test/cloudflare/runtime.test.ts b/test/cloudflare/runtime.test.ts new file mode 100644 index 0000000..8a798d2 --- /dev/null +++ b/test/cloudflare/runtime.test.ts @@ -0,0 +1,86 @@ +import { env, runInDurableObject, runDurableObjectAlarm, evictDurableObject } from "cloudflare:test" +import { describe, expect, it } from "vitest" +import { createRuntime, durableObjects } from "../../src/cloudflare/index.js" +import { Counter } from "./worker.js" + +const runtime = () => createRuntime({ backend: durableObjects({ namespace: env.ACTORS }) }) +const authorizationContext = "allowed" + +describe("Durable Objects runtime", () => { + it("runs portable references and snapshots with durable state", async () => { + const reference = runtime().ref(Counter, "counter") + expect(await reference.with({ authorizationContext }).increment({ amount: 2 })).toBe(2) + expect(await reference.with({ authorizationContext }).doubled).toBe(4) + const stub = env.ACTORS.getByName(JSON.stringify(["Counter", "counter"])) + await evictDurableObject(stub) + expect(await reference.snapshot({ authorizationContext })).toEqual({ count: 2, doubled: 4 }) + }) + + it("serializes turns across awaits", async () => { + const reference = runtime().ref(Counter, "concurrent").with({ authorizationContext }) + const results = await Promise.all( + Array.from({ length: 8 }, () => reference.incrementAfterAwait()), + ) + expect(results.sort((left, right) => left - right)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + }) + + it("denies unauthenticated calls and deduplicates accepted work", async () => { + const reference = runtime().ref(Counter, "idempotent") + await expect(reference.increment()).rejects.toMatchObject({ name: "Unauthorized" }) + const options = { authorizationContext, idempotencyKey: "one" } + expect(await reference.with(options).increment()).toBe(1) + expect(await reference.with(options).increment()).toBe(1) + await expect(reference.with(options).increment({ amount: 2 })).rejects.toMatchObject({ + name: "IdempotencyConflict", + }) + }) + + it("rolls back rejected and retried turns", async () => { + const reference = runtime().ref(Counter, "retry").with({ authorizationContext }) + await expect(reference.rejectChange()).rejects.toMatchObject({ + name: "Rejected", + code: "unavailable", + }) + expect(await reference.retry()).toBe(1) + expect(await reference.count).toBe(1) + }) + + it("drives reminders, effects, and cross-actor intents", async () => { + const source = runtime().ref(Counter, "source").with({ authorizationContext }) + await source.arm({ at: Date.now() }) + await source.effect() + await source.forward({ target: "destination" }) + const sourceStub = env.ACTORS.getByName(JSON.stringify(["Counter", "source"])) + for (let attempt = 0; attempt < 10; attempt += 1) await runDurableObjectAlarm(sourceStub) + expect((await runtime().ref(Counter, "source").snapshot({ authorizationContext })).count).toBe( + 11, + ) + expect(await runtime().ref(Counter, "destination").with({ authorizationContext }).count).toBe(1) + }) + + it("retains a recoverable accepted message after caller timeout", async () => { + const reference = runtime().ref(Counter, "delayed") + const message = await reference.send + .with({ authorizationContext, availableAt: new Date(Date.now() + 60_000) }) + .increment() + await expect( + message.wait({ authorizationContext, timeoutMilliseconds: 5 }), + ).rejects.toMatchObject({ name: "SyncTimeout", messageReference: { id: message.id } }) + expect( + ( + await runtime().lookupMessage({ + actorType: "Counter", + actorId: "delayed", + requestId: message.requestId, + authorizationContext, + }) + )?.id, + ).toBe(message.id) + await runInDurableObject( + env.ACTORS.getByName(JSON.stringify(["Counter", "delayed"])), + async (_object, state) => { + expect(await state.storage.getAlarm()).not.toBeNull() + }, + ) + }) +}) diff --git a/test/cloudflare/worker.ts b/test/cloudflare/worker.ts new file mode 100644 index 0000000..709687f --- /dev/null +++ b/test/cloudflare/worker.ts @@ -0,0 +1,206 @@ +import { Actor, broadcastValue, NonRetryableError } from "../../src/core.js" +import { PortableCounter } from "../support/portable-actor.js" +import { + createDurableObjectsHost, + createDurableObjectsSessionHost, + durableObjects, +} from "../../src/cloudflare/index.js" + +export const gates = new Map void>() +export const revokedSessions = new Set() +export const deliveries = new Map() +const droppedAcknowledgements = new Set() + +export class Counter extends Actor { + static override readonly actorType = "Counter" + count = 0 + echo(options: { value: string }): string { + this.count += 1 + return options.value + } + static override readonly payloads = { + personal: (actor: Counter, authorization: unknown) => ({ count: actor.count, authorization }), + broken: () => { + throw new Error("projection unavailable") + }, + denied: () => ({ private: true }), + } + + increment(options: { amount?: number } = {}): number { + this.count += options.amount ?? 1 + return this.count + } + + async incrementAfterAwait(): Promise { + const before = this.count + await new Promise((resolve) => setTimeout(resolve, 10)) + this.count = before + 1 + return this.count + } + + get doubled(): number { + return this.count * 2 + } + + retry(): number { + if (this.currentMessage!.attempt < 2) { + this.count = 100 + throw new Error("retry once") + } + return this.increment() + } + + rejectChange(): void { + this.count = 100 + this.reject("unavailable", { message: "try something else" }) + } + + arm(options: { at: number }): void { + this.schedule({ at: new Date(options.at) }).increment!() + } + + forward(options: { target: string }): void { + this.sendTo(Counter.ref(options.target)).increment() + } + + async forwardAfterAwait(options: { target: string }): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) + this.sendTo(Counter.ref(options.target)).increment() + } + + async forbiddenCall(): Promise { + await Counter.ref("forbidden").increment() + } + + async pause(): Promise { + this.count = 100 + await waitForGate(this.actorId) + this.count = 200 + } + + slowEffect(): void { + this.emit("slow", { onSuccess: "effectDone" }) + } + repeatedEffect(): void { + this.emit("repeat", { onSuccess: "effectDone" }) + } + + failWithIntents(): void { + this.count = 100 + this.emit("increment") + this.sendTo(Counter.ref("should-not-receive")).increment() + this.schedule({ at: new Date(0) }).increment!() + throw new NonRetryableError("rollback staged work") + } + + failPermanently(): void { + this.count = 100 + throw new NonRetryableError("permanent failure") + } + + commitToDatabase(): void { + this.count = 100 + this.commitAction("database-write") + } + + armRecurring(options: { at: number; interval: number; missed: "all" | "latest" }): void { + this.schedule({ + at: new Date(options.at), + everyMilliseconds: options.interval, + missed: options.missed, + }).increment!() + } + + effect(): void { + this.emit("increment", { onSuccess: "effectDone" }) + } + effectDone(): void { + this.count += 10 + } + + override observables() { + return { count: broadcastValue(this.count) } + } +} + +export class VersionedCounter extends Actor { + static override readonly actorType = "VersionedCounter" + static override readonly stateVersion = 2 + static override readonly migrations = [ + { + from: 1, + to: 2, + migrate: (state: { [key: string]: import("../../src/core.js").JsonValue }) => ({ + count: Number(state.count) + 10, + }), + }, + ] + count = 0 + increment(): number { + this.count += 1 + return this.count + } +} + +export class Actors extends createDurableObjectsHost({ + actors: [Counter, PortableCounter, VersionedCounter], + configure: (environment) => ({ + backend: durableObjects({ + namespace: { + getByName: (name) => ({ + request: async (request) => { + const reply = await environment.ACTORS.getByName(name).request(request) + if ( + request.method === "internal" && + request.actorId === "lost-ack" && + !droppedAcknowledgements.has(String(request.payload.requestId)) + ) { + droppedAcknowledgements.add(String(request.payload.requestId)) + throw new Error("acknowledgement lost") + } + return reply + }, + }), + }, + sessions: environment.SESSIONS, + }), + authorizeMessage: (input) => input.authorizationContext === "allowed", + authorizeQuery: (input) => + input.authorizationContext === "allowed" && input.operation !== "denied", + authorizeDestroy: (input) => input.authorizationContext === "allowed", + authorizeSubscription: (input) => input.authorizationContext === "allowed", + authorizeAdministration: (input) => input.authorizationContext === "allowed", + retryDelayMilliseconds: () => 10, + effects: { + increment: () => ({ accepted: true }), + slow: async (_arguments, context) => { + await waitForGate(context.actorId) + }, + repeat: (_arguments, context) => { + const attempts = (deliveries.get(context.id) ?? 0) + 1 + deliveries.set(context.id, attempts) + if (attempts === 1) throw new Error("effect acknowledgement lost") + return { accepted: true } + }, + }, + }), +}) {} + +export class Sessions extends createDurableObjectsSessionHost({ + backend: (environment) => + durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS }), + resolveAuthorizationContext: ({ sessionId }) => + sessionId === "test-session" && !revokedSessions.has(sessionId) ? "allowed" : null, +}) {} + +export default { fetch: () => new Response("Solid Objects tests") } + +function waitForGate(actorId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, 3_000) + gates.set(actorId, () => { + clearTimeout(timer) + resolve() + }) + }) +} diff --git a/test/cloudflare/wrangler.jsonc b/test/cloudflare/wrangler.jsonc new file mode 100644 index 0000000..abf82bc --- /dev/null +++ b/test/cloudflare/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "solid-objects-tests", + "main": "worker.ts", + "compatibility_date": "2026-09-04", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "ACTORS", "class_name": "Actors" }, + { "name": "SESSIONS", "class_name": "Sessions" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Actors", "Sessions"] }], + "observability": { "enabled": true }, +} diff --git a/test/message-snapshot.test.ts b/test/message-snapshot.test.ts new file mode 100644 index 0000000..5f75ef5 --- /dev/null +++ b/test/message-snapshot.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from "vitest" +import { sqlite } from "../src/database/sqlite.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" +import { PortableCounter } from "./support/portable-actor.js" + +let runtime: SolidObjectsRuntime | undefined +afterEach(async () => { + vi.restoreAllMocks() + await runtime?.close() +}) + +it("does not combine an old result with a newer completed status", async () => { + runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + }) + runtime.register(PortableCounter) + await runtime.install() + const message = await runtime.ref(PortableCounter, "snapshot-race").send.increment() + const findMessage = runtime.repository.findMessage.bind(runtime.repository) + let reads = 0 + vi.spyOn(runtime.repository, "findMessage").mockImplementation(async (id) => { + const stale = await findMessage(id) + reads += 1 + if (reads === 2) await runtime!.testing.drain({ roles: ["actors"] }) + return stale + }) + expect(await message.wait()).toBe(1) +}) diff --git a/test/portable-runtime.test.ts b/test/portable-runtime.test.ts new file mode 100644 index 0000000..fd28d96 --- /dev/null +++ b/test/portable-runtime.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe } from "vitest" +import { sqlite } from "../src/database/sqlite.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" +import { PortableCounter } from "./support/portable-actor.js" +import { portableRuntimeContract } from "./support/portable-runtime-contract.js" + +describe("SQL portable runtime contract", () => { + let runtime: SolidObjectsRuntime + let controller: AbortController + let running: Promise + beforeEach(async () => { + runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: (input) => input.authorizationContext === "allowed", + authorizeQuery: (input) => input.authorizationContext === "allowed", + authorizeDestroy: (input) => input.authorizationContext === "allowed", + syncPollingIntervalMilliseconds: 1, + pollingIntervalMilliseconds: 1, + }) + runtime.register(PortableCounter) + await runtime.install() + controller = new AbortController() + running = runtime.run(controller.signal) + }) + afterEach(async () => { + controller?.abort() + await running + await runtime?.close() + }) + portableRuntimeContract(() => runtime) +}) diff --git a/test/support/portable-actor.ts b/test/support/portable-actor.ts new file mode 100644 index 0000000..ff39f73 --- /dev/null +++ b/test/support/portable-actor.ts @@ -0,0 +1,27 @@ +import { Actor } from "../../src/core.js" + +export class PortableCounter extends Actor { + static override readonly actorType = "PortableCounter" + count = 0 + + increment(options: { amount?: number } = {}): number { + this.count += options.amount ?? 1 + return this.count + } + + get doubled(): number { + return this.count * 2 + } + + async incrementAfterAwait(): Promise { + const previous = this.count + await new Promise((resolve) => setTimeout(resolve, 5)) + this.count = previous + 1 + return this.count + } + + rejectChange(): void { + this.count = 100 + this.reject("unavailable", { message: "try again later" }) + } +} diff --git a/test/support/portable-runtime-contract.ts b/test/support/portable-runtime-contract.ts new file mode 100644 index 0000000..7763b4c --- /dev/null +++ b/test/support/portable-runtime-contract.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest" +import { withRuntime, type ActorRuntime } from "../../src/core.js" +import { PortableCounter } from "./portable-actor.js" + +export function portableRuntimeContract(runtime: () => ActorRuntime): void { + const authorizationContext = "allowed" + + it("shares references, getters, snapshots, and async runtime context", async () => { + await withRuntime(runtime(), async () => { + await Promise.resolve() + const reference = PortableCounter.ref("contract-reference") + expect(await reference.with({ authorizationContext }).increment({ amount: 3 })).toBe(3) + expect(await reference.with({ authorizationContext }).doubled).toBe(6) + expect(await reference.snapshot({ authorizationContext })).toEqual({ count: 3, doubled: 6 }) + }) + }) + + it("preserves accepted message identity and idempotency conflicts", async () => { + const reference = runtime().ref(PortableCounter, "contract-message") + const options = { authorizationContext, idempotencyKey: "same-operation" } + const first = await reference.send.with(options).increment() + const duplicate = await reference.send.with(options).increment() + expect(duplicate.id).toBe(first.id) + expect(typeof first.sequence).toBe("bigint") + expect(await first.wait({ authorizationContext })).toBe(1) + await expect(reference.send.with(options).increment({ amount: 2 })).rejects.toMatchObject({ + name: "IdempotencyConflict", + }) + }) + + it("serializes concurrent turns through awaits", async () => { + const reference = runtime() + .ref(PortableCounter, "contract-concurrent") + .with({ authorizationContext }) + const results = await Promise.all( + Array.from({ length: 5 }, () => reference.incrementAfterAwait()), + ) + expect(results.sort((left, right) => left - right)).toEqual([1, 2, 3, 4, 5]) + }) + + it("rolls back rejection without pausing the next turn", async () => { + const reference = runtime() + .ref(PortableCounter, "contract-rejection") + .with({ authorizationContext }) + await expect(reference.rejectChange()).rejects.toMatchObject({ + name: "Rejected", + code: "unavailable", + }) + expect(await reference.increment()).toBe(1) + }) + + it("authorizes calls and fences references across destruction", async () => { + const reference = runtime().ref(PortableCounter, "contract-destroy") + await expect(reference.increment()).rejects.toMatchObject({ name: "Unauthorized" }) + const message = await reference.send.with({ authorizationContext }).increment() + await message.wait({ authorizationContext }) + await reference.destroy({ authorizationContext }) + expect(await reference.with({ authorizationContext }).increment()).toBe(1) + await expect(message.result({ authorizationContext })).rejects.toThrow() + }) +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 33db8f8..c67cac6 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,5 +9,5 @@ "stripInternal": true }, "include": ["src/**/*.ts"], - "exclude": ["test"] + "exclude": ["test", "src/cloudflare"] } diff --git a/tsconfig.cloudflare-build.json b/tsconfig.cloudflare-build.json new file mode 100644 index 0000000..0a2fcfe --- /dev/null +++ b/tsconfig.cloudflare-build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src/cloudflare/**/*.ts"], + "exclude": [] +} diff --git a/tsconfig.cloudflare-example.json b/tsconfig.cloudflare-example.json new file mode 100644 index 0000000..77f567c --- /dev/null +++ b/tsconfig.cloudflare-example.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.cloudflare.json", + "compilerOptions": { + "types": ["node", "@cloudflare/workers-types"], + "baseUrl": ".", + "paths": { + "solid-objects/core": ["./src/core.ts"], + "solid-objects/cloudflare": ["./src/cloudflare/index.ts"] + } + }, + "include": ["examples/cloudflare/**/*.ts"] +} diff --git a/tsconfig.cloudflare.json b/tsconfig.cloudflare.json new file mode 100644 index 0000000..dd4127f --- /dev/null +++ b/tsconfig.cloudflare.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node", "@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], + "noEmit": true + }, + "include": ["src/cloudflare/**/*.ts", "test/cloudflare/**/*.ts", "vitest.cloudflare.config.ts"], + "exclude": [] +} diff --git a/tsconfig.examples.json b/tsconfig.examples.json index df29592..95e7d2e 100644 --- a/tsconfig.examples.json +++ b/tsconfig.examples.json @@ -10,5 +10,6 @@ "solid-objects/database/mysql": ["./src/database/mysql.ts"] } }, - "include": ["examples/**/*.ts", "benchmarks/**/*.ts"] + "include": ["examples/**/*.ts", "benchmarks/**/*.ts"], + "exclude": ["examples/cloudflare"] } diff --git a/tsconfig.json b/tsconfig.json index ec207a5..ef9dc16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2024", + "types": ["node"], "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2024", "DOM", "DOM.Iterable"], @@ -17,5 +18,6 @@ "skipLibCheck": true, "rootDir": "." }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["src/cloudflare", "test/cloudflare"] } diff --git a/vitest.cloudflare.config.ts b/vitest.cloudflare.config.ts new file mode 100644 index 0000000..89a520c --- /dev/null +++ b/vitest.cloudflare.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" +import { cloudflareTest } from "@cloudflare/vitest-plugin" + +export default defineConfig({ + plugins: [cloudflareTest({ wrangler: { configPath: "./test/cloudflare/wrangler.jsonc" } })], + test: { include: ["test/cloudflare/**/*.test.ts"], testTimeout: 15_000 }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..bf5665e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,5 @@ +import { configDefaults, defineConfig } from "vitest/config" + +export default defineConfig({ + test: { dir: "./test", exclude: [...configDefaults.exclude, "**/cloudflare/**"] }, +}) From 89fdc9dbf76d164e4becd2fb53364e81d3b1ca18 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 07:47:32 -0700 Subject: [PATCH 02/10] fix: address Cloudflare review feedback --- examples/cloudflare/worker.ts | 14 ++++++----- src/actor-runtime.ts | 5 ++-- src/cloudflare/configuration.ts | 4 ++-- src/cloudflare/engine.ts | 41 +++++++++++++++++++++++--------- src/cloudflare/records.ts | 2 +- src/cloudflare/runtime.ts | 10 ++++---- src/cloudflare/storage.ts | 5 ++-- test/cloudflare/recovery.test.ts | 9 +++++++ 8 files changed, 61 insertions(+), 29 deletions(-) diff --git a/examples/cloudflare/worker.ts b/examples/cloudflare/worker.ts index 5788a07..97d6e28 100644 --- a/examples/cloudflare/worker.ts +++ b/examples/cloudflare/worker.ts @@ -6,6 +6,7 @@ import { type CloudflareConfiguration, } from "solid-objects/cloudflare" import { Counter } from "./counter.js" +import type { JsonValue } from "solid-objects/core" function backend(environment: Env) { return durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS }) @@ -14,7 +15,7 @@ function backend(environment: Env) { function publicCounter(input: { actorType: string actorId: string - authorizationContext: unknown + authorizationContext: JsonValue }): boolean { return ( input.actorType === "Counter" && @@ -46,15 +47,16 @@ export default { const counter = runtime .ref(Counter, "public-demo") .with({ authorizationContext: "public-demo" }) - if (url.pathname === "/events" && request.headers.get("Upgrade") === "websocket") { - const origin = request.headers.get("Origin") - if (origin !== null && origin !== url.origin) - return new Response("Forbidden", { status: 403 }) + const isWebSocketRequest = + url.pathname === "/events" && request.headers.get("Upgrade") === "websocket" + const origin = request.headers.get("Origin") + if (isWebSocketRequest && origin !== null && origin !== url.origin) + return new Response("Forbidden", { status: 403 }) + if (isWebSocketRequest) return runtime.openWebSocket({ sessionId: "public-demo", expiresAt: new Date(Date.now() + 3_600_000), }) - } if (request.method === "GET" && url.pathname === "/counter") return Response.json({ count: await counter.count }) if (request.method === "POST" && url.pathname === "/increment") diff --git a/src/actor-runtime.ts b/src/actor-runtime.ts index faa067b..e81e00b 100644 --- a/src/actor-runtime.ts +++ b/src/actor-runtime.ts @@ -13,6 +13,7 @@ import type { DestroyOptions, InvocationOptions, JsonObject, + JsonValue, Logger, MessageStatus, SnapshotOptions, @@ -30,13 +31,13 @@ export interface ActorRuntime { actorClass: ActorClass, actorId: ActorIdentifier, ): ActorReference - invoke(options: { + invoke(options: { reference: ActorReferenceCore operation: string argumentsValue?: JsonObject options?: InvocationOptions }): Promise> - sendMessage(options: { + sendMessage(options: { reference: ActorReferenceCore operation: string argumentsValue?: JsonObject diff --git a/src/cloudflare/configuration.ts b/src/cloudflare/configuration.ts index 1110b55..6349a0a 100644 --- a/src/cloudflare/configuration.ts +++ b/src/cloudflare/configuration.ts @@ -1,11 +1,11 @@ import type { SolidObjectsConfiguration } from "../configuration.js" -import type { EffectContext, JsonObject, Logger } from "../types.js" +import type { EffectContext, JsonObject, JsonValue, Logger } from "../types.js" import type { DurableObjectsBackend } from "./protocol.js" export type EffectHandler = ( argumentsValue: JsonObject, context: EffectContext, -) => unknown | Promise +) => JsonValue | void | Promise export type CloudflareConfiguration = Pick< SolidObjectsConfiguration, diff --git a/src/cloudflare/engine.ts b/src/cloudflare/engine.ts index e205f4a..c4d44b4 100644 --- a/src/cloudflare/engine.ts +++ b/src/cloudflare/engine.ts @@ -695,7 +695,7 @@ export class ActorEngine { private addOutbox(options: { id: string instance: Instance - message: Message + message: Pick kind: Outbox["kind"] destination: string payload: JsonObject @@ -747,6 +747,23 @@ export class ActorEngine { result = normalizeJson(value === undefined ? null : value, { maxBytes: this.settings.maxResultBytes, }) + } else if (outbox.kind === "effect-callback") { + await callHost({ + backend: this.settings.backend, + request: { + actorType: instance.actorType, + actorId: instance.actorId, + method: "internal", + authorizationContext: null, + payload: { + requestId: outbox.id, + operation: String(outbox.payload.operation), + arguments: jsonObject(outbox.payload.arguments), + availableAt: Date.now(), + idempotencyKey: outbox.id, + }, + }, + }) } else if (outbox.kind === "outbound") { await callHost({ backend: this.settings.backend, @@ -785,7 +802,7 @@ export class ActorEngine { outbox.completedAt = Date.now() this.store.saveOutbox(outbox) if (outbox.kind === "effect") - this.effectCallback({ + this.stageEffectCallback({ instance, outbox, result, @@ -805,7 +822,7 @@ export class ActorEngine { outbox.availableAt = Date.now() + this.retryDelay(outbox.attempt) this.store.saveOutbox(outbox) if (exhausted && outbox.kind === "effect") - this.effectCallback({ + this.stageEffectCallback({ instance, outbox, result: outbox.error, @@ -831,19 +848,23 @@ export class ActorEngine { ) } - private effectCallback(options: { + private stageEffectCallback(options: { instance: Instance outbox: Outbox result: JsonValue operation: JsonValue | undefined }): void { if (typeof options.operation !== "string") return - this.enqueue({ - ...options.instance, - method: "internal", - authorizationContext: null, + this.addOutbox({ + id: `${options.outbox.id}:callback`, + instance: options.instance, + message: { + id: options.outbox.messageId, + sequence: options.outbox.sequence, + }, + kind: "effect-callback", + destination: actorName(options.instance), payload: { - requestId: `${options.outbox.id}:callback`, operation: options.operation, arguments: { effectId: options.outbox.id, @@ -852,8 +873,6 @@ export class ActorEngine { ? { error: options.result } : { result: options.result }), }, - idempotencyKey: `${options.outbox.id}:callback`, - availableAt: Date.now(), }, }) } diff --git a/src/cloudflare/records.ts b/src/cloudflare/records.ts index 2408cba..8c5c944 100644 --- a/src/cloudflare/records.ts +++ b/src/cloudflare/records.ts @@ -38,7 +38,7 @@ export interface Outbox { id: string incarnation: string messageId: string - kind: "effect" | "outbound" | "broadcast" + kind: "effect" | "effect-callback" | "outbound" | "broadcast" destination: string sequence: string payload: JsonObject diff --git a/src/cloudflare/runtime.ts b/src/cloudflare/runtime.ts index f13ca31..5814acf 100644 --- a/src/cloudflare/runtime.ts +++ b/src/cloudflare/runtime.ts @@ -75,7 +75,7 @@ export class CloudflareRuntime implements ActorRuntime { }) } - async invoke(options: { + async invoke(options: { reference: ActorReferenceCore operation: string argumentsValue?: JsonObject @@ -98,7 +98,7 @@ export class CloudflareRuntime implements ActorRuntime { }) } - async sendMessage(options: { + async sendMessage(options: { reference: ActorReferenceCore operation: string argumentsValue?: JsonObject @@ -115,8 +115,8 @@ export class CloudflareRuntime implements ActorRuntime { }) } - async lookupMessage( - options: ActorIdentity & { requestId: string; authorizationContext?: unknown }, + async lookupMessage( + options: ActorIdentity & { requestId: string; authorizationContext?: JsonValue }, ): Promise | undefined> { const value = await this.call({ ...identity(options), @@ -226,7 +226,7 @@ export class CloudflareRuntime implements ActorRuntime { ) } - actorAdministration(options: ActorIdentity & { authorizationContext?: unknown }) { + actorAdministration(options: ActorIdentity & { authorizationContext?: JsonValue }) { const call = (payload: JsonObject) => this.call({ ...identity(options), diff --git a/src/cloudflare/storage.ts b/src/cloudflare/storage.ts index 53abbb3..31f23d7 100644 --- a/src/cloudflare/storage.ts +++ b/src/cloudflare/storage.ts @@ -2,6 +2,7 @@ import type { Instance, Message, Outbox, Reminder, Subscription } from "./record import type { CloudflareSettings } from "./configuration.js" import { PayloadTooLarge } from "../errors.js" import { utf8ByteLength } from "../serialization.js" +type EncodableValue = object | string | number | boolean | null export class ActorStorage { constructor( @@ -74,7 +75,7 @@ export class ActorStorage { return row ? (JSON.parse(row.value) as Value) : undefined } - saveMetadata(key: string, value: unknown): void { + saveMetadata(key: string, value: EncodableValue): void { this.storage.sql.exec( "INSERT INTO metadata(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, @@ -264,7 +265,7 @@ export class ActorStorage { } } -function encodedRecord(value: unknown, indexedValues: string[]): string { +function encodedRecord(value: EncodableValue, indexedValues: string[]): string { const encoded = JSON.stringify(value) const size = indexedValues.reduce( (total, value) => total + utf8ByteLength(value), diff --git a/test/cloudflare/recovery.test.ts b/test/cloudflare/recovery.test.ts index 9571d30..e1009e6 100644 --- a/test/cloudflare/recovery.test.ts +++ b/test/cloudflare/recovery.test.ts @@ -127,6 +127,15 @@ describe("Cloudflare recovery and fencing", () => { state.storage.sql.exec<{ id: string }>("SELECT id FROM outboxes WHERE kind = 'effect'").one(), ) expect(deliveries.get(outbox.id)).toBe(2) + const statuses = await runInDurableObject(stub("repeat-effect"), (_object, state) => + state.storage.sql + .exec<{ kind: string; status: string }>("SELECT kind, status FROM outboxes ORDER BY kind") + .toArray(), + ) + expect(statuses).toEqual([ + { kind: "effect", status: "completed" }, + { kind: "effect-callback", status: "completed" }, + ]) }) it("deduplicates destination acceptance after an outbound acknowledgement is lost", async () => { From 77f270f900c004f32be5bf74280091b1812af9fd Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 07:55:59 -0700 Subject: [PATCH 03/10] docs: update Cloudflare example --- docs/cloudflare.md | 2 +- examples/cloudflare/README.md | 41 +++++++++++------- examples/cloudflare/counter.ts | 19 --------- examples/cloudflare/shopping-cart.ts | 62 +++++++++++++++++++++++++++ examples/cloudflare/worker.ts | 63 +++++++++++++++++----------- examples/cloudflare/wrangler.jsonc | 2 +- 6 files changed, 130 insertions(+), 59 deletions(-) delete mode 100644 examples/cloudflare/counter.ts create mode 100644 examples/cloudflare/shopping-cart.ts diff --git a/docs/cloudflare.md b/docs/cloudflare.md index 4080b08..688642d 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -6,7 +6,7 @@ and Workers. On Cloudflare, import `createRuntime`, `durableObjects`, `solid-objects/cloudflare`. The [runnable example](../examples/cloudflare/README.md) includes both Durable -Object classes, Wrangler bindings, migrations, and a public counter. +Object classes, Wrangler bindings, migrations, and a public shopping cart. ## Configure the hosts diff --git a/examples/cloudflare/README.md b/examples/cloudflare/README.md index b62b95b..9a4244a 100644 --- a/examples/cloudflare/README.md +++ b/examples/cloudflare/README.md @@ -1,9 +1,16 @@ -# Cloudflare counter +# Cloudflare shopping cart -This is an intentionally public counter. Anyone can increment it. Its policies -grant access only to `Counter("public-demo")`; destruction and administration -remain denied. Replace the demo session resolver with your application's -session lookup before using this example for private data. +This is an intentionally public shopping cart. Anyone can add or remove items +from `ShoppingCart("demo-cart")`; destruction and administration remain denied. +Replace the demo session resolver with your application's session lookup before +using this example for private data. + +The example code is here: + +- [`shopping-cart.ts`](./shopping-cart.ts) — actor state, cart operations, and realtime observables +- [`worker.ts`](./worker.ts) — Worker routes, authorization, and Durable Object hosts +- [`wrangler.jsonc`](./wrangler.jsonc) — SQLite-backed Durable Object bindings and migrations +- [`environment.d.ts`](./environment.d.ts) — generated Wrangler binding types From the repository root: @@ -16,19 +23,25 @@ pnpm run dev:cloudflare In another terminal: ```sh -curl http://localhost:8787/counter -curl -X POST http://localhost:8787/increment -curl -X POST http://localhost:8787/increment-later +curl http://localhost:8787/cart +curl -X POST http://localhost:8787/cart/items \ + -H 'content-type: application/json' \ + --data '{"sku":"book","name":"Solid Objects book","priceCents":2500,"quantity":1}' +curl -X POST http://localhost:8787/cart/remove \ + -H 'content-type: application/json' \ + --data '{"sku":"book"}' +curl -X POST http://localhost:8787/cart/clear-later ``` -The last call schedules an increment five seconds later. Stop the local server -and start it again to verify persistence. Local state lives in Wrangler's -`.wrangler` directory. Each actor identity has its own SQLite-backed Durable -Object; browser connections use the separate `Sessions` class. +The last call schedules the cart to clear five seconds later. Stop the local +server and start it again to verify persistence. Local state lives in +Wrangler's `.wrangler` directory. Each actor identity has its own SQLite-backed +Durable Object; browser connections use the separate `Sessions` class. Connect the existing `SolidObjectsBrowserClient` to `/events` and subscribe to -`{ actorType: "Counter", actorId: "public-demo" }`. The connection expires after -one hour. Reconnect and resubscribe to obtain the current committed projection. +`{ actorType: "ShoppingCart", actorId: "demo-cart" }`. The connection expires +after one hour. Reconnect and resubscribe to obtain the current committed +projection with `itemCount` and `totalCents`. `pnpm run check:cloudflare` validates the production bundle without uploading it. To deploy this example to your own account, run `fnox exec -- pnpm run deploy:cloudflare`. diff --git a/examples/cloudflare/counter.ts b/examples/cloudflare/counter.ts deleted file mode 100644 index b3ea3de..0000000 --- a/examples/cloudflare/counter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Actor, broadcastValue } from "solid-objects/core" - -export class Counter extends Actor { - static override readonly actorType = "Counter" - count = 0 - - increment(): number { - this.count += 1 - return this.count - } - - incrementLater(): void { - this.schedule({ at: new Date(Date.now() + 5_000) }).increment!() - } - - override observables() { - return { count: broadcastValue(this.count) } - } -} diff --git a/examples/cloudflare/shopping-cart.ts b/examples/cloudflare/shopping-cart.ts new file mode 100644 index 0000000..13e83e6 --- /dev/null +++ b/examples/cloudflare/shopping-cart.ts @@ -0,0 +1,62 @@ +import { Actor, broadcastValue } from "solid-objects/core" + +interface CartItem { + name: string + priceCents: number + quantity: number +} + +export class ShoppingCart extends Actor { + static override readonly actorType = "ShoppingCart" + items: Record = {} + + addItem(input: { sku: string; name: string; priceCents: number; quantity?: number }): number { + if ( + !input.sku || + !input.name || + !Number.isSafeInteger(input.priceCents) || + input.priceCents < 0 + ) + throw new TypeError("sku, name, and a non-negative integer priceCents are required") + const quantity = input.quantity ?? 1 + if (!Number.isSafeInteger(quantity) || quantity <= 0) + throw new TypeError("quantity must be a positive safe integer") + const existing = this.items[input.sku] + this.items[input.sku] = { + name: input.name, + priceCents: input.priceCents, + quantity: (existing?.quantity ?? 0) + quantity, + } + return this.totalCents + } + + removeItem(input: { sku: string }): void { + delete this.items[input.sku] + } + + clear(): void { + this.items = {} + } + + clearLater(): void { + this.schedule({ at: new Date(Date.now() + 5_000), key: "clear" }).clear!() + } + + get itemCount(): number { + return Object.values(this.items).reduce((total, item) => total + item.quantity, 0) + } + + get totalCents(): number { + return Object.values(this.items).reduce( + (total, item) => total + item.priceCents * item.quantity, + 0, + ) + } + + override observables() { + return { + itemCount: broadcastValue(this.itemCount), + totalCents: broadcastValue(this.totalCents), + } + } +} diff --git a/examples/cloudflare/worker.ts b/examples/cloudflare/worker.ts index 97d6e28..a85b9bc 100644 --- a/examples/cloudflare/worker.ts +++ b/examples/cloudflare/worker.ts @@ -5,48 +5,46 @@ import { durableObjects, type CloudflareConfiguration, } from "solid-objects/cloudflare" -import { Counter } from "./counter.js" -import type { JsonValue } from "solid-objects/core" +import { ShoppingCart } from "./shopping-cart.js" function backend(environment: Env) { return durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS }) } -function publicCounter(input: { +function publicCart(input: { actorType: string actorId: string - authorizationContext: JsonValue + authorizationContext: unknown }): boolean { return ( - input.actorType === "Counter" && - input.actorId === "public-demo" && + input.actorType === "ShoppingCart" && + input.actorId === "demo-cart" && input.authorizationContext === "public-demo" ) } export class Actors extends createDurableObjectsHost({ - actors: [Counter], + actors: [ShoppingCart], configure: (environment): CloudflareConfiguration => ({ backend: backend(environment), - authorizeMessage: publicCounter, - authorizeQuery: publicCounter, - authorizeSubscription: publicCounter, + authorizeMessage: publicCart, + authorizeQuery: publicCart, + authorizeSubscription: publicCart, }), }) {} export class Sessions extends createDurableObjectsSessionHost({ backend, resolveAuthorizationContext: ({ sessionId }) => - sessionId === "public-demo" ? "public-demo" : null, + sessionId === "demo-cart" ? "public-demo" : null, }) {} export default { async fetch(request: Request, environment: Env): Promise { const url = new URL(request.url) const runtime = createRuntime({ backend: backend(environment) }) - const counter = runtime - .ref(Counter, "public-demo") - .with({ authorizationContext: "public-demo" }) + const cartReference = runtime.ref(ShoppingCart, "demo-cart") + const cart = cartReference.with({ authorizationContext: "public-demo" }) const isWebSocketRequest = url.pathname === "/events" && request.headers.get("Upgrade") === "websocket" const origin = request.headers.get("Origin") @@ -54,19 +52,36 @@ export default { return new Response("Forbidden", { status: 403 }) if (isWebSocketRequest) return runtime.openWebSocket({ - sessionId: "public-demo", + sessionId: "demo-cart", expiresAt: new Date(Date.now() + 3_600_000), }) - if (request.method === "GET" && url.pathname === "/counter") - return Response.json({ count: await counter.count }) - if (request.method === "POST" && url.pathname === "/increment") - return Response.json({ count: await counter.increment() }) - if (request.method === "POST" && url.pathname === "/increment-later") { - await counter.incrementLater() + if (request.method === "GET" && url.pathname === "/cart") + return Response.json( + await runtime.snapshot(cartReference, { authorizationContext: "public-demo" }), + ) + if (request.method === "POST" && url.pathname === "/cart/items") { + const item = (await request.json()) as { + sku: string + name: string + priceCents: number + quantity?: number + } + return Response.json({ totalCents: await cart.addItem(item) }) + } + if (request.method === "POST" && url.pathname === "/cart/remove") { + const item = (await request.json()) as { sku: string } + await cart.removeItem(item) + return new Response(null, { status: 204 }) + } + if (request.method === "POST" && url.pathname === "/cart/clear-later") { + await cart.clearLater() return new Response(null, { status: 202 }) } - return new Response("GET /counter; POST /increment; POST /increment-later; WebSocket /events", { - status: url.pathname === "/" ? 200 : 404, - }) + return new Response( + "GET /cart; POST /cart/items; POST /cart/remove; POST /cart/clear-later; WebSocket /events", + { + status: url.pathname === "/" ? 200 : 404, + }, + ) }, } satisfies ExportedHandler diff --git a/examples/cloudflare/wrangler.jsonc b/examples/cloudflare/wrangler.jsonc index 6ef7757..139f722 100644 --- a/examples/cloudflare/wrangler.jsonc +++ b/examples/cloudflare/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "../../node_modules/wrangler/config-schema.json", - "name": "solid-objects-counter", + "name": "solid-objects-shopping-cart", "main": "worker.ts", "compatibility_date": "2026-09-04", "compatibility_flags": ["nodejs_compat"], From 7d1c0dabacc682652c23b7534a570d9501b082f7 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:06:10 -0700 Subject: [PATCH 04/10] fix: address remaining PR feedback --- examples/cloudflare/shopping-cart.ts | 5 ++++- src/cloudflare/engine.ts | 2 +- src/cloudflare/protocol.ts | 2 +- src/cloudflare/runtime.ts | 2 +- test/cloudflare/example.test.ts | 12 ++++++++++++ 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 test/cloudflare/example.test.ts diff --git a/examples/cloudflare/shopping-cart.ts b/examples/cloudflare/shopping-cart.ts index 13e83e6..1a81d98 100644 --- a/examples/cloudflare/shopping-cart.ts +++ b/examples/cloudflare/shopping-cart.ts @@ -22,10 +22,13 @@ export class ShoppingCart extends Actor { if (!Number.isSafeInteger(quantity) || quantity <= 0) throw new TypeError("quantity must be a positive safe integer") const existing = this.items[input.sku] + const combinedQuantity = (existing?.quantity ?? 0) + quantity + if (!Number.isSafeInteger(combinedQuantity)) + throw new TypeError("combined quantity must be a positive safe integer") this.items[input.sku] = { name: input.name, priceCents: input.priceCents, - quantity: (existing?.quantity ?? 0) + quantity, + quantity: combinedQuantity, } return this.totalCents } diff --git a/src/cloudflare/engine.ts b/src/cloudflare/engine.ts index c4d44b4..c833e4f 100644 --- a/src/cloudflare/engine.ts +++ b/src/cloudflare/engine.ts @@ -1031,7 +1031,7 @@ export class ActorEngine { } } -function errorName(error: unknown): string { +function errorName(error: ErrorValue): string { return error instanceof Error ? error.name : "Error" } diff --git a/src/cloudflare/protocol.ts b/src/cloudflare/protocol.ts index 2f3e9ff..10c4c53 100644 --- a/src/cloudflare/protocol.ts +++ b/src/cloudflare/protocol.ts @@ -61,7 +61,7 @@ export function actorName(identity: ActorIdentity): string { return JSON.stringify([identity.actorType, String(identity.actorId)]) } -export function encodeError(error: unknown): Extract { +export function encodeError(error: ErrorValue): Extract { const details: JsonObject = {} if (error instanceof errors.Rejected) { details.code = error.code diff --git a/src/cloudflare/runtime.ts b/src/cloudflare/runtime.ts index 5814acf..c046a03 100644 --- a/src/cloudflare/runtime.ts +++ b/src/cloudflare/runtime.ts @@ -399,7 +399,7 @@ function identity(value: ActorIdentity): ActorIdentity { return { actorType: value.actorType, actorId: String(value.actorId) } } -function context(value: unknown): JsonValue { +function context(value: AuthorizationContext | undefined): JsonValue { return normalizeJson(value === undefined ? null : value) } diff --git a/test/cloudflare/example.test.ts b/test/cloudflare/example.test.ts new file mode 100644 index 0000000..70c1454 --- /dev/null +++ b/test/cloudflare/example.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest" +import { ShoppingCart } from "../../examples/cloudflare/shopping-cart.js" + +describe("Cloudflare shopping-cart example", () => { + it("rejects quantities that exceed JavaScript safe integer precision", () => { + const cart = new ShoppingCart("precision") + cart.addItem({ sku: "item", name: "Item", priceCents: 1, quantity: Number.MAX_SAFE_INTEGER }) + expect(() => cart.addItem({ sku: "item", name: "Item", priceCents: 1 })).toThrow( + "combined quantity must be a positive safe integer", + ) + }) +}) From dd26692b8331405d4b8e71de5538fd3431c0e2b8 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:08:03 -0700 Subject: [PATCH 05/10] fix: map Cloudflare tests to sources --- tsconfig.cloudflare.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tsconfig.cloudflare.json b/tsconfig.cloudflare.json index dd4127f..1d3d17b 100644 --- a/tsconfig.cloudflare.json +++ b/tsconfig.cloudflare.json @@ -3,6 +3,10 @@ "compilerOptions": { "lib": ["ES2024"], "types": ["node", "@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], + "paths": { + "solid-objects/core": ["./src/core.ts"], + "solid-objects/cloudflare": ["./src/cloudflare/index.ts"] + }, "noEmit": true }, "include": ["src/cloudflare/**/*.ts", "test/cloudflare/**/*.ts", "vitest.cloudflare.config.ts"], From 737c2f5968515341b7cc5bc9b47cc333f3274664 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:12:04 -0700 Subject: [PATCH 06/10] chore: bump version to 0.14.7 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1304a1..1a1aed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.14.7 - 2026-09-05 - Read SQL message results and completion status in one statement so concurrent completion cannot return a stale `null` result. diff --git a/package.json b/package.json index 34d6875..819b8c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.14.6", + "version": "0.14.7", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", From 78f4d7022d503d907464338dbc08d44210457c1a Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:14:48 -0700 Subject: [PATCH 07/10] Fix runtime version --- src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index 70013c3..adb2160 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.6" +export const VERSION = "0.14.7" From 70d6a5761d4f0c4edfd646900990f7c518798a57 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:29:31 -0700 Subject: [PATCH 08/10] Add backend roadmap --- roadmap.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 roadmap.md diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..987ff63 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,54 @@ +# Roadmap + +These are backend-neutral capabilities that should remain useful across SQL, +browser, and Cloudflare Durable Objects adapters. Provider-specific primitives +should stay behind each adapter rather than becoming part of the public API. + +## Portable durable scheduling + +Expand reminders into a complete durable scheduling abstraction with +cancellation, rescheduling, recurring schedules, missed-run policies, status +inspection, and next-run inspection while preserving idempotent, +at-least-once delivery. + +[Issue #37: portable durable scheduling](https://github.com/cardmagic/solid-objects-js/issues/37) + +## Portable actor snapshots and recovery + +Add committed actor checkpoints with revision metadata, authorization, +retention, restore, and fork semantics. Provider-specific point-in-time +recovery can remain an adapter implementation detail. + +[Issue #38: portable actor snapshots and recovery](https://github.com/cardmagic/solid-objects-js/issues/38) + +## Feature-level capability negotiation + +Replace coarse capability flags with a versioned feature-level contract for +scheduling, snapshots, transactions, realtime, administration, recovery, and +diagnostics. Unsupported operations should remain fail-fast and actionable. + +[Issue #39: feature-level capability negotiation](https://github.com/cardmagic/solid-objects-js/issues/39) + +## Portable realtime sessions + +Standardize session creation, expiry, reconnect, subscription, projection +replay, authorization, and backpressure independently of the transport. A +backend may use WebSockets, SSE, or another transport behind the adapter. + +[Issue #40: portable realtime sessions](https://github.com/cardmagic/solid-objects-js/issues/40) + +## Portable actor administration + +Standardize per-actor inspection and operations for mailbox state, dead +letters, reminders, outboxes, incarnation, and revision. Fleet-wide operations +remain optional where a backend cannot provide them. + +[Issue #41: portable actor administration](https://github.com/cardmagic/solid-objects-js/issues/41) + +## Portable observability and diagnostics + +Define common structured events, metrics, and diagnostic views for activation +duration, mailbox depth, retries, dead letters, reminder lateness, outbox age, +recovery failures, and realtime subscriptions without leaking provider APIs. + +[Issue #42: portable observability and diagnostics](https://github.com/cardmagic/solid-objects-js/issues/42) From b74f7b1b1627a8d78b06c13eb9f838495059f8a3 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:33:15 -0700 Subject: [PATCH 09/10] Add backend capability PRDs --- docs/prd/README.md | 23 ++++++++++ docs/prd/actor-administration.md | 53 ++++++++++++++++++++++ docs/prd/actor-snapshots-recovery.md | 55 +++++++++++++++++++++++ docs/prd/capability-negotiation.md | 52 ++++++++++++++++++++++ docs/prd/observability-diagnostics.md | 58 +++++++++++++++++++++++++ docs/prd/portable-durable-scheduling.md | 58 +++++++++++++++++++++++++ docs/prd/realtime-sessions.md | 56 ++++++++++++++++++++++++ 7 files changed, 355 insertions(+) create mode 100644 docs/prd/README.md create mode 100644 docs/prd/actor-administration.md create mode 100644 docs/prd/actor-snapshots-recovery.md create mode 100644 docs/prd/capability-negotiation.md create mode 100644 docs/prd/observability-diagnostics.md create mode 100644 docs/prd/portable-durable-scheduling.md create mode 100644 docs/prd/realtime-sessions.md diff --git a/docs/prd/README.md b/docs/prd/README.md new file mode 100644 index 0000000..5777570 --- /dev/null +++ b/docs/prd/README.md @@ -0,0 +1,23 @@ +# Backend portability PRDs + +These PRDs define the six backend-neutral capabilities on the Solid Objects +roadmap. They follow a consistent structure: problem, users, outcomes, +requirements, acceptance criteria, risks, and rollout. Requirements use +observable behavior so they can be implemented and verified independently. + +The documents are intentionally product-level. Adapter-specific design belongs +in implementation plans after the API and semantics are agreed. + +- [Portable durable scheduling](./portable-durable-scheduling.md) · [Issue #37](https://github.com/cardmagic/solid-objects-js/issues/37) +- [Actor snapshots and recovery](./actor-snapshots-recovery.md) · [Issue #38](https://github.com/cardmagic/solid-objects-js/issues/38) +- [Feature-level capability negotiation](./capability-negotiation.md) · [Issue #39](https://github.com/cardmagic/solid-objects-js/issues/39) +- [Portable realtime sessions](./realtime-sessions.md) · [Issue #40](https://github.com/cardmagic/solid-objects-js/issues/40) +- [Portable actor administration](./actor-administration.md) · [Issue #41](https://github.com/cardmagic/solid-objects-js/issues/41) +- [Portable observability and diagnostics](./observability-diagnostics.md) · [Issue #42](https://github.com/cardmagic/solid-objects-js/issues/42) + +## PRD standard + +Every PRD must state its target users, measurable outcomes, explicit +non-goals, authorization and failure semantics, compatibility expectations, +and testable acceptance criteria. No document promises a provider primitive +that another adapter cannot represent. diff --git a/docs/prd/actor-administration.md b/docs/prd/actor-administration.md new file mode 100644 index 0000000..701aa26 --- /dev/null +++ b/docs/prd/actor-administration.md @@ -0,0 +1,53 @@ +# PRD: portable actor administration + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#41](https://github.com/cardmagic/solid-objects-js/issues/41) + +## Problem and users + +Operators can inspect and repair actors only through adapter-specific tooling. +Users are on-call engineers and application administrators who need safe, +audited per-actor operations. + +## Outcome and success measures + +Every adapter offers the same authorized per-actor inspection and repair +workflow. Success means an operator can diagnose a stuck actor, retry a dead +letter, or resume a paused reminder without direct database access. + +## Non-goals + +- Fleet-wide operations where an adapter cannot provide a consistent index. +- Bypassing application authorization. +- Silent mutation of actor state. + +## Requirements + +- ADMIN-001: The API shall expose mailbox depth, oldest pending message, active + claims, incarnation, revision, and last activation information. +- ADMIN-002: The API shall list dead letters and include operation, arguments + metadata, attempts, failure, and timestamps without exposing secrets. +- ADMIN-003: Retrying a dead letter shall be idempotent and shall create a new + auditable delivery attempt. +- ADMIN-004: The API shall inspect reminders and support resuming paused + reminders with an optional run time. +- ADMIN-005: The API shall expose outbox status and age sufficient to diagnose + effect, callback, and broadcast backpressure. +- ADMIN-006: Every mutating administration operation shall require explicit + authorization and emit an audit event. +- ADMIN-007: Reads shall not mutate actor state; repairs shall preserve mailbox + ordering and idempotency guarantees. +- ADMIN-008: Fleet-level operations shall be capability-gated and must not be + simulated by unbounded per-actor scans. + +## Acceptance criteria + +- Tests cover authorization, idempotent retry, reminder resume, mailbox + inspection, outbox inspection, audit events, and concurrent repair attempts. +- An operator can diagnose and repair a failed actor using only the public API. +- Secrets and arbitrary payload contents are redacted according to documented + rules. + +## Risks and rollout + +Stabilize read-only inspection first. Add retry and resume operations only after +audit events and authorization hooks are available in every adapter. diff --git a/docs/prd/actor-snapshots-recovery.md b/docs/prd/actor-snapshots-recovery.md new file mode 100644 index 0000000..f587e6a --- /dev/null +++ b/docs/prd/actor-snapshots-recovery.md @@ -0,0 +1,55 @@ +# PRD: actor snapshots and recovery + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#38](https://github.com/cardmagic/solid-objects-js/issues/38) + +## Problem and users + +Operators need a portable way to back up, inspect, restore, or fork committed +actor state. Provider-native point-in-time recovery is useful but cannot be +assumed by every adapter. Users are operators recovering incidents and teams +testing migrations without mutating production actors. + +## Outcome and success measures + +An authorized operator can create a consistent actor checkpoint and restore or +fork it with an auditable revision boundary. Success is measured by successful +restore drills, zero partial snapshots, and a documented recovery point and +recovery time objective for each adapter. + +## Non-goals + +- A fleet backup service or cross-actor snapshot transaction. +- Restoring arbitrary provider-internal tables. +- Hiding incompatible application state migrations. + +## Requirements + +- SNAP-001: A snapshot shall represent one committed actor state, revision, + incarnation, schema version, and creation time. +- SNAP-002: Snapshot creation shall be consistent with actor turns and shall + never include half-committed state or in-flight mailbox claims. +- SNAP-003: The API shall support listing and deleting snapshots subject to + authorization and retention policy. +- SNAP-004: Restore shall require an explicit target actor and confirmation + token or equivalent idempotency guard. +- SNAP-005: Restore shall either replace an actor at a new incarnation or create + a fork; it shall never silently merge divergent state. +- SNAP-006: Incompatible state versions shall fail before any target mutation + and return an actionable migration error. +- SNAP-007: Snapshot and restore operations shall be observable and resumable + after transient adapter failure. +- SNAP-008: Adapters may map the API to PITR, serialized state, or database + backups, but all must expose the same consistency and authorization contract. + +## Acceptance criteria + +- Tests prove snapshots exclude in-flight turns and restore exact committed + state, revision, and schema metadata. +- A failed restore leaves the target unchanged and can be retried safely. +- A fork receives a new identity/incarnation and does not share mutable storage. +- Documentation states retention, size limits, encryption, and RPO/RTO per adapter. + +## Risks and rollout + +Start with export and restore to a new actor identity; defer destructive +in-place restore until operational tooling and backup verification exist. diff --git a/docs/prd/capability-negotiation.md b/docs/prd/capability-negotiation.md new file mode 100644 index 0000000..3b7a373 --- /dev/null +++ b/docs/prd/capability-negotiation.md @@ -0,0 +1,52 @@ +# PRD: feature-level capability negotiation + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#39](https://github.com/cardmagic/solid-objects-js/issues/39) + +## Problem and users + +Applications currently discover backend differences through coarse flags or +runtime exceptions. Users are library authors and application developers who +need portable code with deliberate fallbacks. + +## Outcome and success measures + +Every runtime reports a stable, versioned capability document. Applications can +choose a supported path before issuing an operation, while unsupported calls +remain fail-fast. Success means no adapter-specific feature is undocumented and +the conformance suite validates the same capability shape everywhere. + +## Non-goals + +- Making all adapters implement every feature. +- Exposing infrastructure limits as an unstable public API. +- Replacing authorization checks or runtime error handling. + +## Requirements + +- CAP-001: The runtime shall expose a versioned capability document with + feature name, support level (`supported`, `partial`, `unsupported`), and + semantic notes. +- CAP-002: Features shall include scheduling, snapshots, cross-actor + transactions, realtime sessions, administration, recovery, diagnostics, and + local storage queries. +- CAP-003: Partial support shall identify operation-level gaps and relevant + limits, not merely return `true`. +- CAP-004: Capability inspection shall be side-effect free and available before + actor invocation. +- CAP-005: Unsupported operations shall raise `UnsupportedCapability` with the + feature name and a supported alternative when one exists. +- CAP-006: Capability names and compatibility rules shall be documented and + versioned without exposing provider names in application logic. + +## Acceptance criteria + +- Each adapter returns schema-valid capability data. +- Tests verify supported, partial, and unsupported behavior, including an + operation that is rejected before provider I/O. +- A compatibility guide shows how to implement fallbacks for every partial + feature. + +## Risks and rollout + +Add the detailed document alongside existing flags first. Deprecate ambiguous +booleans only after one release with migration documentation. diff --git a/docs/prd/observability-diagnostics.md b/docs/prd/observability-diagnostics.md new file mode 100644 index 0000000..5fc0ada --- /dev/null +++ b/docs/prd/observability-diagnostics.md @@ -0,0 +1,58 @@ +# PRD: portable observability and diagnostics + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#42](https://github.com/cardmagic/solid-objects-js/issues/42) + +## Problem and users + +The same actor workload produces different operational signals depending on the +backend. Users are developers, operators, and support engineers who need to +compare latency, backlog, retries, and recovery behavior without learning each +provider’s telemetry system. + +## Outcome and success measures + +Solid Objects emits a small, stable set of structured events and metrics with +consistent meanings. Success means an operator can identify latency, backlog, +failure, and recovery regressions from adapter-neutral telemetry and correlate +an event to an actor, message, revision, and attempt. + +## Non-goals + +- Shipping a hosted metrics or tracing product. +- Prescribing a vendor, exporter, or dashboard. +- Logging sensitive arguments or state by default. + +## Requirements + +- OBS-001: Events shall cover activation start/completion/failure, message + retry/dead-letter, mailbox depth, reminder lateness, outbox age, recovery, + snapshot, and realtime session changes. +- OBS-002: Each event shall include timestamp, actor identity, incarnation, + revision or message ID when applicable, attempt, and adapter name. +- OBS-003: Metrics shall define units, aggregation, cardinality guidance, and + whether values are gauges, counters, or histograms. +- OBS-004: Instrumentation shall support structured logging, metrics, and + tracing hooks without requiring a provider SDK. +- OBS-005: Diagnostics shall expose bounded, authorization-aware summaries for + mailbox, outbox, reminders, retries, and recovery failures. +- OBS-006: Default telemetry shall exclude arguments, actor state, credentials, + and unredacted provider responses. +- OBS-007: Adapters shall map native telemetry to the common schema without + changing event meaning or delivery semantics. +- OBS-008: Instrumentation failure shall never fail an actor turn or alter its + committed result. + +## Acceptance criteria + +- Tests verify representative success, retry, failure, dead-letter, recovery, + reminder, outbox, and realtime events. +- A documented dashboard/query example works with emitted events from two + adapters. +- Redaction and cardinality rules are tested, and instrumentation failures are + isolated from application behavior. + +## Risks and rollout + +Begin with event names and field definitions, then add exporters. Keep the +existing logger hook compatible and make new metrics opt-in until cardinality +has been measured in production. diff --git a/docs/prd/portable-durable-scheduling.md b/docs/prd/portable-durable-scheduling.md new file mode 100644 index 0000000..f36c010 --- /dev/null +++ b/docs/prd/portable-durable-scheduling.md @@ -0,0 +1,58 @@ +# PRD: portable durable scheduling + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#37](https://github.com/cardmagic/solid-objects-js/issues/37) + +## Problem and users + +Actor authors need work to happen after a delay or on a recurring cadence even +when no request is active. Today each adapter exposes different scheduling +primitives and edge cases. Users are application developers building expiry, +renewal, retry, and maintenance workflows. + +## Outcome and success measures + +An actor author can schedule, inspect, reschedule, and cancel durable work +without knowing whether the adapter uses alarms, a scheduler process, or a +database. Success means every production adapter passes the same conformance +suite for idempotency, missed runs, cancellation, and recovery; scheduled work +has no silent loss and exposes its current status. + +## Non-goals + +- Exposing provider-specific alarm APIs. +- Guaranteeing exactly-once execution; delivery remains at least once. +- Replacing general-purpose queues or cron systems. + +## Requirements + +- SCHED-001: The API shall create a named schedule owned by one actor with an + operation, JSON arguments, and a first-run time. +- SCHED-002: A name shall identify at most one active schedule per actor; + rescheduling shall replace its next occurrence atomically. +- SCHED-003: The API shall cancel a schedule idempotently and report whether a + schedule was removed. +- SCHED-004: The API shall support one-shot and recurring schedules with an + explicit interval or recurrence policy. +- SCHED-005: The API shall define missed-run policies (`run_once`, `skip`, or + `catch_up`) and persist the selected policy. +- SCHED-006: Schedule execution shall use the normal actor mailbox, authorization, + retry, dead-letter, and idempotency semantics. +- SCHED-007: Inspection shall return status, next run, last run, attempt, and + failure information without mutating actor state. +- SCHED-008: Adapter implementations shall recover scheduled work after + process, object, or host restart. + +## Acceptance criteria + +- Conformance tests cover create, replace, cancel, recurring execution, each + missed-run policy, restart recovery, authorization failure, and duplicate + delivery. +- A schedule can never execute after a successful cancellation that preceded + its claim. +- Documentation includes time-zone, clock-skew, retention, and retry behavior. + +## Risks and rollout + +The first release should extend reminders rather than add a second scheduler +model. Ship behind the capability flag, migrate one adapter at a time, and +publish timing guarantees before enabling recurring schedules by default. diff --git a/docs/prd/realtime-sessions.md b/docs/prd/realtime-sessions.md new file mode 100644 index 0000000..0e3855a --- /dev/null +++ b/docs/prd/realtime-sessions.md @@ -0,0 +1,56 @@ +# PRD: portable realtime sessions + +Status: Proposed · Owner: Solid Objects maintainers · Issue: [#40](https://github.com/cardmagic/solid-objects-js/issues/40) + +## Problem and users + +Applications need committed actor changes delivered to browsers and services, +but transports differ across adapters. Users are application developers +building dashboards, collaborative interfaces, and live projections. + +## Outcome and success measures + +One session contract defines authorization, subscription, replay, expiry, and +delivery behavior. Adapters may use WebSockets, SSE, or another transport. +Success means reconnecting clients converge to the latest committed projection +and conformance tests pass without depending on a specific transport. + +## Non-goals + +- A universal transport or UI client. +- Exactly-once network delivery. +- Broadcasting uncommitted actor state. + +## Requirements + +- REAL-001: A session shall be created with an authenticated identity and an + explicit expiry. +- REAL-002: Subscribe and unsubscribe shall be idempotent and authorized for + each actor and payload projection. +- REAL-003: The first subscription and every reconnect shall provide a committed + projection before or with subsequent invalidations. +- REAL-004: Events shall identify actor identity, incarnation, and revision so + clients can detect stale or out-of-order delivery. +- REAL-005: A session shall close or reauthorize when its authorization expires + or is revoked. +- REAL-006: Backpressure shall be bounded; the API shall define whether events + are queued, coalesced, or cause a reconnect. +- REAL-007: Adapter transports shall preserve the session semantics while + keeping wire framing and connection lifecycle private to the adapter. +- REAL-008: Delivery shall be at least once and clients shall receive enough + metadata to deduplicate or refresh projections safely. + +## Acceptance criteria + +- Tests cover initial replay, reconnect, expiry, revocation, duplicate events, + stale revisions, unauthorized subscriptions, and backpressure. +- A reference client works against two transports or one transport plus a + deterministic in-memory conformance adapter. +- Documentation distinguishes committed projection replay from invalidation + delivery. + +## Risks and rollout + +Keep the existing browser protocol as one implementation. Introduce the +backend-neutral contract first, then add alternate transports only after the +semantics are stable. From cf54bc644ad563ca5cdb929ce7211c9aa99fe009 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 5 Sep 2026 08:36:14 -0700 Subject: [PATCH 10/10] Remove local PRD files --- docs/prd/README.md | 23 ---------- docs/prd/actor-administration.md | 53 ---------------------- docs/prd/actor-snapshots-recovery.md | 55 ----------------------- docs/prd/capability-negotiation.md | 52 ---------------------- docs/prd/observability-diagnostics.md | 58 ------------------------- docs/prd/portable-durable-scheduling.md | 58 ------------------------- docs/prd/realtime-sessions.md | 56 ------------------------ 7 files changed, 355 deletions(-) delete mode 100644 docs/prd/README.md delete mode 100644 docs/prd/actor-administration.md delete mode 100644 docs/prd/actor-snapshots-recovery.md delete mode 100644 docs/prd/capability-negotiation.md delete mode 100644 docs/prd/observability-diagnostics.md delete mode 100644 docs/prd/portable-durable-scheduling.md delete mode 100644 docs/prd/realtime-sessions.md diff --git a/docs/prd/README.md b/docs/prd/README.md deleted file mode 100644 index 5777570..0000000 --- a/docs/prd/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Backend portability PRDs - -These PRDs define the six backend-neutral capabilities on the Solid Objects -roadmap. They follow a consistent structure: problem, users, outcomes, -requirements, acceptance criteria, risks, and rollout. Requirements use -observable behavior so they can be implemented and verified independently. - -The documents are intentionally product-level. Adapter-specific design belongs -in implementation plans after the API and semantics are agreed. - -- [Portable durable scheduling](./portable-durable-scheduling.md) · [Issue #37](https://github.com/cardmagic/solid-objects-js/issues/37) -- [Actor snapshots and recovery](./actor-snapshots-recovery.md) · [Issue #38](https://github.com/cardmagic/solid-objects-js/issues/38) -- [Feature-level capability negotiation](./capability-negotiation.md) · [Issue #39](https://github.com/cardmagic/solid-objects-js/issues/39) -- [Portable realtime sessions](./realtime-sessions.md) · [Issue #40](https://github.com/cardmagic/solid-objects-js/issues/40) -- [Portable actor administration](./actor-administration.md) · [Issue #41](https://github.com/cardmagic/solid-objects-js/issues/41) -- [Portable observability and diagnostics](./observability-diagnostics.md) · [Issue #42](https://github.com/cardmagic/solid-objects-js/issues/42) - -## PRD standard - -Every PRD must state its target users, measurable outcomes, explicit -non-goals, authorization and failure semantics, compatibility expectations, -and testable acceptance criteria. No document promises a provider primitive -that another adapter cannot represent. diff --git a/docs/prd/actor-administration.md b/docs/prd/actor-administration.md deleted file mode 100644 index 701aa26..0000000 --- a/docs/prd/actor-administration.md +++ /dev/null @@ -1,53 +0,0 @@ -# PRD: portable actor administration - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#41](https://github.com/cardmagic/solid-objects-js/issues/41) - -## Problem and users - -Operators can inspect and repair actors only through adapter-specific tooling. -Users are on-call engineers and application administrators who need safe, -audited per-actor operations. - -## Outcome and success measures - -Every adapter offers the same authorized per-actor inspection and repair -workflow. Success means an operator can diagnose a stuck actor, retry a dead -letter, or resume a paused reminder without direct database access. - -## Non-goals - -- Fleet-wide operations where an adapter cannot provide a consistent index. -- Bypassing application authorization. -- Silent mutation of actor state. - -## Requirements - -- ADMIN-001: The API shall expose mailbox depth, oldest pending message, active - claims, incarnation, revision, and last activation information. -- ADMIN-002: The API shall list dead letters and include operation, arguments - metadata, attempts, failure, and timestamps without exposing secrets. -- ADMIN-003: Retrying a dead letter shall be idempotent and shall create a new - auditable delivery attempt. -- ADMIN-004: The API shall inspect reminders and support resuming paused - reminders with an optional run time. -- ADMIN-005: The API shall expose outbox status and age sufficient to diagnose - effect, callback, and broadcast backpressure. -- ADMIN-006: Every mutating administration operation shall require explicit - authorization and emit an audit event. -- ADMIN-007: Reads shall not mutate actor state; repairs shall preserve mailbox - ordering and idempotency guarantees. -- ADMIN-008: Fleet-level operations shall be capability-gated and must not be - simulated by unbounded per-actor scans. - -## Acceptance criteria - -- Tests cover authorization, idempotent retry, reminder resume, mailbox - inspection, outbox inspection, audit events, and concurrent repair attempts. -- An operator can diagnose and repair a failed actor using only the public API. -- Secrets and arbitrary payload contents are redacted according to documented - rules. - -## Risks and rollout - -Stabilize read-only inspection first. Add retry and resume operations only after -audit events and authorization hooks are available in every adapter. diff --git a/docs/prd/actor-snapshots-recovery.md b/docs/prd/actor-snapshots-recovery.md deleted file mode 100644 index f587e6a..0000000 --- a/docs/prd/actor-snapshots-recovery.md +++ /dev/null @@ -1,55 +0,0 @@ -# PRD: actor snapshots and recovery - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#38](https://github.com/cardmagic/solid-objects-js/issues/38) - -## Problem and users - -Operators need a portable way to back up, inspect, restore, or fork committed -actor state. Provider-native point-in-time recovery is useful but cannot be -assumed by every adapter. Users are operators recovering incidents and teams -testing migrations without mutating production actors. - -## Outcome and success measures - -An authorized operator can create a consistent actor checkpoint and restore or -fork it with an auditable revision boundary. Success is measured by successful -restore drills, zero partial snapshots, and a documented recovery point and -recovery time objective for each adapter. - -## Non-goals - -- A fleet backup service or cross-actor snapshot transaction. -- Restoring arbitrary provider-internal tables. -- Hiding incompatible application state migrations. - -## Requirements - -- SNAP-001: A snapshot shall represent one committed actor state, revision, - incarnation, schema version, and creation time. -- SNAP-002: Snapshot creation shall be consistent with actor turns and shall - never include half-committed state or in-flight mailbox claims. -- SNAP-003: The API shall support listing and deleting snapshots subject to - authorization and retention policy. -- SNAP-004: Restore shall require an explicit target actor and confirmation - token or equivalent idempotency guard. -- SNAP-005: Restore shall either replace an actor at a new incarnation or create - a fork; it shall never silently merge divergent state. -- SNAP-006: Incompatible state versions shall fail before any target mutation - and return an actionable migration error. -- SNAP-007: Snapshot and restore operations shall be observable and resumable - after transient adapter failure. -- SNAP-008: Adapters may map the API to PITR, serialized state, or database - backups, but all must expose the same consistency and authorization contract. - -## Acceptance criteria - -- Tests prove snapshots exclude in-flight turns and restore exact committed - state, revision, and schema metadata. -- A failed restore leaves the target unchanged and can be retried safely. -- A fork receives a new identity/incarnation and does not share mutable storage. -- Documentation states retention, size limits, encryption, and RPO/RTO per adapter. - -## Risks and rollout - -Start with export and restore to a new actor identity; defer destructive -in-place restore until operational tooling and backup verification exist. diff --git a/docs/prd/capability-negotiation.md b/docs/prd/capability-negotiation.md deleted file mode 100644 index 3b7a373..0000000 --- a/docs/prd/capability-negotiation.md +++ /dev/null @@ -1,52 +0,0 @@ -# PRD: feature-level capability negotiation - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#39](https://github.com/cardmagic/solid-objects-js/issues/39) - -## Problem and users - -Applications currently discover backend differences through coarse flags or -runtime exceptions. Users are library authors and application developers who -need portable code with deliberate fallbacks. - -## Outcome and success measures - -Every runtime reports a stable, versioned capability document. Applications can -choose a supported path before issuing an operation, while unsupported calls -remain fail-fast. Success means no adapter-specific feature is undocumented and -the conformance suite validates the same capability shape everywhere. - -## Non-goals - -- Making all adapters implement every feature. -- Exposing infrastructure limits as an unstable public API. -- Replacing authorization checks or runtime error handling. - -## Requirements - -- CAP-001: The runtime shall expose a versioned capability document with - feature name, support level (`supported`, `partial`, `unsupported`), and - semantic notes. -- CAP-002: Features shall include scheduling, snapshots, cross-actor - transactions, realtime sessions, administration, recovery, diagnostics, and - local storage queries. -- CAP-003: Partial support shall identify operation-level gaps and relevant - limits, not merely return `true`. -- CAP-004: Capability inspection shall be side-effect free and available before - actor invocation. -- CAP-005: Unsupported operations shall raise `UnsupportedCapability` with the - feature name and a supported alternative when one exists. -- CAP-006: Capability names and compatibility rules shall be documented and - versioned without exposing provider names in application logic. - -## Acceptance criteria - -- Each adapter returns schema-valid capability data. -- Tests verify supported, partial, and unsupported behavior, including an - operation that is rejected before provider I/O. -- A compatibility guide shows how to implement fallbacks for every partial - feature. - -## Risks and rollout - -Add the detailed document alongside existing flags first. Deprecate ambiguous -booleans only after one release with migration documentation. diff --git a/docs/prd/observability-diagnostics.md b/docs/prd/observability-diagnostics.md deleted file mode 100644 index 5fc0ada..0000000 --- a/docs/prd/observability-diagnostics.md +++ /dev/null @@ -1,58 +0,0 @@ -# PRD: portable observability and diagnostics - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#42](https://github.com/cardmagic/solid-objects-js/issues/42) - -## Problem and users - -The same actor workload produces different operational signals depending on the -backend. Users are developers, operators, and support engineers who need to -compare latency, backlog, retries, and recovery behavior without learning each -provider’s telemetry system. - -## Outcome and success measures - -Solid Objects emits a small, stable set of structured events and metrics with -consistent meanings. Success means an operator can identify latency, backlog, -failure, and recovery regressions from adapter-neutral telemetry and correlate -an event to an actor, message, revision, and attempt. - -## Non-goals - -- Shipping a hosted metrics or tracing product. -- Prescribing a vendor, exporter, or dashboard. -- Logging sensitive arguments or state by default. - -## Requirements - -- OBS-001: Events shall cover activation start/completion/failure, message - retry/dead-letter, mailbox depth, reminder lateness, outbox age, recovery, - snapshot, and realtime session changes. -- OBS-002: Each event shall include timestamp, actor identity, incarnation, - revision or message ID when applicable, attempt, and adapter name. -- OBS-003: Metrics shall define units, aggregation, cardinality guidance, and - whether values are gauges, counters, or histograms. -- OBS-004: Instrumentation shall support structured logging, metrics, and - tracing hooks without requiring a provider SDK. -- OBS-005: Diagnostics shall expose bounded, authorization-aware summaries for - mailbox, outbox, reminders, retries, and recovery failures. -- OBS-006: Default telemetry shall exclude arguments, actor state, credentials, - and unredacted provider responses. -- OBS-007: Adapters shall map native telemetry to the common schema without - changing event meaning or delivery semantics. -- OBS-008: Instrumentation failure shall never fail an actor turn or alter its - committed result. - -## Acceptance criteria - -- Tests verify representative success, retry, failure, dead-letter, recovery, - reminder, outbox, and realtime events. -- A documented dashboard/query example works with emitted events from two - adapters. -- Redaction and cardinality rules are tested, and instrumentation failures are - isolated from application behavior. - -## Risks and rollout - -Begin with event names and field definitions, then add exporters. Keep the -existing logger hook compatible and make new metrics opt-in until cardinality -has been measured in production. diff --git a/docs/prd/portable-durable-scheduling.md b/docs/prd/portable-durable-scheduling.md deleted file mode 100644 index f36c010..0000000 --- a/docs/prd/portable-durable-scheduling.md +++ /dev/null @@ -1,58 +0,0 @@ -# PRD: portable durable scheduling - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#37](https://github.com/cardmagic/solid-objects-js/issues/37) - -## Problem and users - -Actor authors need work to happen after a delay or on a recurring cadence even -when no request is active. Today each adapter exposes different scheduling -primitives and edge cases. Users are application developers building expiry, -renewal, retry, and maintenance workflows. - -## Outcome and success measures - -An actor author can schedule, inspect, reschedule, and cancel durable work -without knowing whether the adapter uses alarms, a scheduler process, or a -database. Success means every production adapter passes the same conformance -suite for idempotency, missed runs, cancellation, and recovery; scheduled work -has no silent loss and exposes its current status. - -## Non-goals - -- Exposing provider-specific alarm APIs. -- Guaranteeing exactly-once execution; delivery remains at least once. -- Replacing general-purpose queues or cron systems. - -## Requirements - -- SCHED-001: The API shall create a named schedule owned by one actor with an - operation, JSON arguments, and a first-run time. -- SCHED-002: A name shall identify at most one active schedule per actor; - rescheduling shall replace its next occurrence atomically. -- SCHED-003: The API shall cancel a schedule idempotently and report whether a - schedule was removed. -- SCHED-004: The API shall support one-shot and recurring schedules with an - explicit interval or recurrence policy. -- SCHED-005: The API shall define missed-run policies (`run_once`, `skip`, or - `catch_up`) and persist the selected policy. -- SCHED-006: Schedule execution shall use the normal actor mailbox, authorization, - retry, dead-letter, and idempotency semantics. -- SCHED-007: Inspection shall return status, next run, last run, attempt, and - failure information without mutating actor state. -- SCHED-008: Adapter implementations shall recover scheduled work after - process, object, or host restart. - -## Acceptance criteria - -- Conformance tests cover create, replace, cancel, recurring execution, each - missed-run policy, restart recovery, authorization failure, and duplicate - delivery. -- A schedule can never execute after a successful cancellation that preceded - its claim. -- Documentation includes time-zone, clock-skew, retention, and retry behavior. - -## Risks and rollout - -The first release should extend reminders rather than add a second scheduler -model. Ship behind the capability flag, migrate one adapter at a time, and -publish timing guarantees before enabling recurring schedules by default. diff --git a/docs/prd/realtime-sessions.md b/docs/prd/realtime-sessions.md deleted file mode 100644 index 0e3855a..0000000 --- a/docs/prd/realtime-sessions.md +++ /dev/null @@ -1,56 +0,0 @@ -# PRD: portable realtime sessions - -Status: Proposed · Owner: Solid Objects maintainers · Issue: [#40](https://github.com/cardmagic/solid-objects-js/issues/40) - -## Problem and users - -Applications need committed actor changes delivered to browsers and services, -but transports differ across adapters. Users are application developers -building dashboards, collaborative interfaces, and live projections. - -## Outcome and success measures - -One session contract defines authorization, subscription, replay, expiry, and -delivery behavior. Adapters may use WebSockets, SSE, or another transport. -Success means reconnecting clients converge to the latest committed projection -and conformance tests pass without depending on a specific transport. - -## Non-goals - -- A universal transport or UI client. -- Exactly-once network delivery. -- Broadcasting uncommitted actor state. - -## Requirements - -- REAL-001: A session shall be created with an authenticated identity and an - explicit expiry. -- REAL-002: Subscribe and unsubscribe shall be idempotent and authorized for - each actor and payload projection. -- REAL-003: The first subscription and every reconnect shall provide a committed - projection before or with subsequent invalidations. -- REAL-004: Events shall identify actor identity, incarnation, and revision so - clients can detect stale or out-of-order delivery. -- REAL-005: A session shall close or reauthorize when its authorization expires - or is revoked. -- REAL-006: Backpressure shall be bounded; the API shall define whether events - are queued, coalesced, or cause a reconnect. -- REAL-007: Adapter transports shall preserve the session semantics while - keeping wire framing and connection lifecycle private to the adapter. -- REAL-008: Delivery shall be at least once and clients shall receive enough - metadata to deduplicate or refresh projections safely. - -## Acceptance criteria - -- Tests cover initial replay, reconnect, expiry, revocation, duplicate events, - stale revisions, unauthorized subscriptions, and backpressure. -- A reference client works against two transports or one transport plus a - deterministic in-memory conformance adapter. -- Documentation distinguishes committed projection replay from invalidation - delivery. - -## Risks and rollout - -Keep the existing browser protocol as one implementation. Introduce the -backend-neutral contract first, then add alternate transports only after the -semantics are stable.