diff --git a/CLAUDE.md b/CLAUDE.md index 12b79cbf2f..dc270bb2e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,11 +108,9 @@ git commit -m "chore(my-pkg): foo bar" - If `rivetkit` type or DTS builds fail with missing `@rivetkit/*` declarations, run `pnpm build -F rivetkit` from repo root (Turbo build path) before changing TypeScript `paths`. - Do not add temporary `@rivetkit/*` path aliases in `rivetkit-typescript/packages/rivetkit/tsconfig.json` to work around stale or missing built declarations. -### RivetKit Driver Registry Variants -- Keep `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts` as the canonical type anchor for fixtures and test typing. -- Run driver runtime suites through `registry-static.ts` and `registry-dynamic.ts` instead of executing `registry.ts` directly. -- Load static fixture actors with dynamic ESM `import()` from the `fixtures/driver-test-suite/actors/` directory. -- Skip dynamic registry parity only for the explicit nested dynamic harness gate or missing secure-exec dist, and still treat full static and dynamic compatibility as the target for all normal driver suites. +### RivetKit Test Fixtures +- Keep RivetKit test fixtures scoped to the engine-only runtime. +- Prefer targeted integration tests under `rivetkit-typescript/packages/rivetkit/tests/` over shared multi-driver matrices. ### SQLite Package - Use `@rivetkit/sqlite` for SQLite WebAssembly support. @@ -287,7 +285,7 @@ let error_with_meta = ApiRateLimited { limit: 100, reset_at: 1234567890 }.build( **Inspector HTTP API** - When updating the WebSocket inspector (`rivetkit-typescript/packages/rivetkit/src/inspector/`), also update the HTTP inspector endpoints in `rivetkit-typescript/packages/rivetkit/src/actor/router.ts`. The HTTP API mirrors the WebSocket inspector for agent-based debugging. -- When adding or modifying inspector endpoints, also update the driver test at `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inspector.ts` to cover all inspector HTTP endpoints. +- When adding or modifying inspector endpoints, also update the relevant RivetKit tests in `rivetkit-typescript/packages/rivetkit/tests/` to cover all inspector HTTP endpoints. - When adding or modifying inspector endpoints, also update the documentation in `website/src/metadata/skill-base-rivetkit.md` and `website/src/content/docs/actors/debugging.mdx` to keep them in sync. **Database Usage** diff --git a/README.md b/README.md index a473acc5aa..26f3b77c00 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,6 @@ Works with Claude Code, Cursor, Windsurf, and other AI coding tools. - [Node.js & Bun](https://www.rivet.dev/docs/actors/quickstart/backend) - [React](https://www.rivet.dev/docs/actors/quickstart/react) - [Next.js](https://www.rivet.dev/docs/actors/quickstart/next-js) -- [Cloudflare Workers](https://www.rivet.dev/docs/actors/quickstart/cloudflare-workers) [View documentation →](https://www.rivet.dev/docs) @@ -237,7 +236,7 @@ Serverless, containers, or your own servers — Rivet Actors work with your exis **Frameworks**: [React](https://www.rivet.dev/docs/clients/react) • [Next.js](https://www.rivet.dev/docs/clients/next-js) • [Hono](https://github.com/rivet-dev/rivet/tree/main/examples/hono) • [Express](https://github.com/rivet-dev/rivet/tree/main/examples/express) • [Elysia](https://github.com/rivet-dev/rivet/tree/main/examples/elysia) • [tRPC](https://github.com/rivet-dev/rivet/tree/main/examples/trpc) -**Runtimes**: [Node.js](https://www.rivet.dev/docs/actors/quickstart/backend) • [Bun](https://www.rivet.dev/docs/actors/quickstart/backend) • [Deno](https://github.com/rivet-dev/rivet/tree/main/examples/deno) • [Cloudflare Workers](https://www.rivet.dev/docs/actors/quickstart/cloudflare-workers) +**Runtimes**: [Node.js](https://www.rivet.dev/docs/actors/quickstart/backend) • [Bun](https://www.rivet.dev/docs/actors/quickstart/backend) • [Deno](https://github.com/rivet-dev/rivet/tree/main/examples/deno) **Tools**: [Vitest](https://www.rivet.dev/docs/actors/testing) • [Pino](https://www.rivet.dev/docs/general/logging) • [AI SDK](https://github.com/rivet-dev/rivet/tree/main/examples/ai-agent) • [OpenAPI](https://github.com/rivet-dev/rivet/tree/main/rivetkit-openapi) • [AsyncAPI](https://github.com/rivet-dev/rivet/tree/main/rivetkit-asyncapi) @@ -270,4 +269,4 @@ Serverless, containers, or your own servers — Rivet Actors work with your exis ## License -[Apache 2.0](LICENSE) \ No newline at end of file +[Apache 2.0](LICENSE) diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md index 306a2333c6..556f8fb318 100644 --- a/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md @@ -7,14 +7,14 @@ Dynamic actors let a registry entry resolve actor source code at actor start tim Dynamic actors are represented by `dynamicActor({ load, auth?, options? })` and still participate in normal registry routing and actor lifecycle. -Driver parity is verified by running the same driver test suites against two -fixture registries: +Dynamic actor parity is verified by running the same engine-focused integration +tests against two fixture registries: -- `fixtures/driver-test-suite/registry-static.ts` -- `fixtures/driver-test-suite/registry-dynamic.ts` +- `examples/sandbox/src/index.ts` for shared actor behavior +- dedicated static and dynamic registry fixtures in test coverage -Both registries are built from `fixtures/driver-test-suite/actors/` to keep -actor behavior consistent between static and dynamic execution. +The shared actor fixtures keep behavior consistent between static and dynamic +execution. ## Main Components diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md index 18d8c93d4c..2968951807 100644 --- a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_FAILED_START_RELOAD_SPEC.md @@ -424,7 +424,7 @@ export interface DynamicStartupOptions { ### Backoff Implementation Reuse the `p-retry` exponential backoff algorithm that is already used in -`remote-manager-driver/metadata.ts` and `client/actor-conn.ts`. The +`engine-client/metadata.ts` and `client/actor-conn.ts`. The implementation does not need to use `p-retry` directly (since retries are passive, not loop-driven), but must compute backoff delays using the same formula: `min(maxDelay, initialDelay * multiplier^attempt)` with optional @@ -658,9 +658,9 @@ Comments should explain intent and invariants, not implementation history. ## Test Requirements -All failed-start tests must be added to the shared driver-test-suite -(`src/driver-test-suite/`) so both file-system and engine drivers run the same -test cases. This enforces the parity requirement. +All failed-start tests must be added to the shared engine-focused integration +suite so the runtime path uses one common set of cases. This enforces the +parity requirement. Add or update tests for: diff --git a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md index 0e18b4be9c..435f1c784d 100644 --- a/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md +++ b/docs-internal/rivetkit-typescript/DYNAMIC_ACTOR_SQLITE_PROXY_SPEC.md @@ -244,7 +244,7 @@ All data crosses the bridge as JSON strings: ## Testing -Add a driver test in `src/driver-test-suite/tests/` that: +Add an engine-focused integration test that: 1. Creates a dynamic actor that uses `db()` (raw) with a simple schema 2. Runs migrations, inserts rows, queries them back @@ -252,7 +252,7 @@ Add a driver test in `src/driver-test-suite/tests/` that: 4. Creates a dynamic actor that uses `db()` from `rivetkit/db/drizzle` with schema + migrations 5. Verifies drizzle queries work through the proxy -Add corresponding fixture actors in `fixtures/driver-test-suite/`. +Add corresponding fixture actors in the shared sandbox-style test fixtures. ## Files to modify @@ -263,8 +263,8 @@ Add corresponding fixture actors in `fixtures/driver-test-suite/`. | `src/dynamic/isolate-runtime.ts` | Wire `sqliteExec`/`sqliteBatch` refs in `#setIsolateBridge()` | | `src/dynamic/host-runtime.ts` | Wire bridge refs + add `overrideRawDatabaseClient` to isolate-side `actorDriver` | | `src/db/drizzle/mod.ts` | Add override check at top of `createClient` | -| `src/driver-test-suite/tests/` | New test file for dynamic SQLite proxy | -| `fixtures/driver-test-suite/` | New fixture actors using `db()` in dynamic actors | +| `tests/` | New engine-focused integration test for dynamic SQLite proxy | +| shared test fixtures | New fixture actors using `db()` in dynamic actors | | `docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md` | Document SQLite proxy bridge | ## Non-goals diff --git a/engine/sdks/typescript/api-full/package.json b/engine/sdks/typescript/api-full/package.json index b9024853a8..5ee5dd9caa 100644 --- a/engine/sdks/typescript/api-full/package.json +++ b/engine/sdks/typescript/api-full/package.json @@ -1,7 +1,7 @@ { "name": "@rivetkit/engine-api-full", "version": "2.2.1", - "repository": "https://github.com/rivet-gg/rivet/tree/main/sdks/typescript", + "repository": "https://github.com/rivet-dev/rivet/tree/main/engine/sdks/typescript", "files": [ "dist", "types", diff --git a/examples/actor-actions/vite.config.ts b/examples/actor-actions/vite.config.ts index bd1498f19b..9b7f9fd17e 100644 --- a/examples/actor-actions/vite.config.ts +++ b/examples/actor-actions/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ // Disable screen clearing so concurrently output stays readable clearScreen: false, proxy: { - // Forward manager API and WebSocket requests to the backend + // Forward actor API and WebSocket requests to the backend "/actors": { target: "http://localhost:6420", ws: true }, "/metadata": { target: "http://localhost:6420" }, "/health": { target: "http://localhost:6420" }, diff --git a/examples/cloudflare-workers-hono/README.md b/examples/cloudflare-workers-hono/README.md deleted file mode 100644 index 59b4bc3bef..0000000000 --- a/examples/cloudflare-workers-hono/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Cloudflare Workers with Hono - -Example project demonstrating Cloudflare Workers deployment with Hono router. - -## Getting Started - -```sh -git clone https://github.com/rivet-dev/rivet.git -cd rivet/examples/cloudflare-workers-hono -npm install -npm run dev -``` - - -## Features - -- **Cloudflare Workers integration**: Deploy Rivet Actors to Cloudflare's edge network using Durable Objects -- **Hono routing**: Use Hono web framework for HTTP request handling -- **Edge-native execution**: Actors run at the edge for low-latency global access -- **Type-safe API endpoints**: Full TypeScript support across actor and HTTP layers - -## Implementation - -This example demonstrates combining Hono with Rivet Actors on Cloudflare Workers: - -- **Actor Definition** ([`src/backend/registry.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/cloudflare-workers-hono/src/backend/registry.ts)): Shows how to integrate Hono router with actors on Cloudflare Workers - -## Resources - -Read more about [Cloudflare Workers integration](/docs/platforms/cloudflare-workers) and [actions](/docs/actors/actions). - -## License - -MIT diff --git a/examples/cloudflare-workers-hono/package.json b/examples/cloudflare-workers-hono/package.json deleted file mode 100644 index 9085386bad..0000000000 --- a/examples/cloudflare-workers-hono/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "example-cloudflare-workers-hono", - "version": "2.0.21", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "check-types": "tsc --noEmit", - "client": "tsx scripts/client.ts", - "build": "tsc" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250129.0", - "rivetkit": "^2.2.1", - "@types/node": "^22.13.9", - "tsx": "^3.12.7", - "typescript": "^5.5.2", - "wrangler": "^4.22.0" - }, - "dependencies": { - "@rivetkit/cloudflare-workers": "^2.2.1", - "hono": "^4.8.0" - }, - "stableVersion": "0.8.0", - "license": "MIT" -} diff --git a/examples/cloudflare-workers-hono/scripts/client.ts b/examples/cloudflare-workers-hono/scripts/client.ts deleted file mode 100644 index 3a70a675be..0000000000 --- a/examples/cloudflare-workers-hono/scripts/client.ts +++ /dev/null @@ -1,9 +0,0 @@ -async function main() { - const endpoint = process.env.RIVET_ENDPOINT || "http://localhost:8787"; - const res = await fetch(`${endpoint}/increment/foo`, { - method: "POST", - }); - console.log("Output:", await res.text()); -} - -main(); diff --git a/examples/cloudflare-workers-hono/src/index.ts b/examples/cloudflare-workers-hono/src/index.ts deleted file mode 100644 index c19d76fc32..0000000000 --- a/examples/cloudflare-workers-hono/src/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createHandler } from "@rivetkit/cloudflare-workers"; -import { Hono } from "hono"; -import { createClient } from "rivetkit/client"; -import { registry } from "./registry"; - -const client = createClient(); - -// Setup router -const app = new Hono(); - -// Example HTTP endpoint -app.post("/increment/:name", async (c) => { - const name = c.req.param("name"); - - const counter = client.counter.getOrCreate(name); - const newCount = await counter.increment(1); - - return c.text(`New Count: ${newCount}`); -}); - -const { handler, ActorHandler } = createHandler(registry, { fetch: app.fetch }); -export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers-hono/src/registry.ts b/examples/cloudflare-workers-hono/src/registry.ts deleted file mode 100644 index 3e3d61c376..0000000000 --- a/examples/cloudflare-workers-hono/src/registry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { actor, setup } from "rivetkit"; - -export const counter = actor({ - state: { count: 0 }, - actions: { - increment: (c, x: number) => { - c.state.count += x; - return c.state.count; - }, - }, -}); - -export const registry = setup({ - use: { counter }, -}); diff --git a/examples/cloudflare-workers-hono/tsconfig.json b/examples/cloudflare-workers-hono/tsconfig.json deleted file mode 100644 index f4bdc4cddf..0000000000 --- a/examples/cloudflare-workers-hono/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "target": "esnext", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "lib": ["esnext"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "esnext", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["@cloudflare/workers-types"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "include": ["src/**/*"] -} diff --git a/examples/cloudflare-workers-hono/turbo.json b/examples/cloudflare-workers-hono/turbo.json deleted file mode 100644 index 125ee72dd9..0000000000 --- a/examples/cloudflare-workers-hono/turbo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "extends": ["//"], - "tasks": { - "build": { - "dependsOn": ["@rivetkit/cloudflare-workers#build", "rivetkit#build"] - } - } -} diff --git a/examples/cloudflare-workers-hono/wrangler.json b/examples/cloudflare-workers-hono/wrangler.json deleted file mode 100644 index f5b84c4ef6..0000000000 --- a/examples/cloudflare-workers-hono/wrangler.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "rivetkit-cloudflare-workers-example", - "main": "src/index.ts", - "compatibility_date": "2025-01-20", - "compatibility_flags": ["nodejs_compat"], - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["ActorHandler"] - } - ], - "durable_objects": { - "bindings": [ - { - "name": "ACTOR_DO", - "class_name": "ActorHandler" - } - ] - }, - "kv_namespaces": [ - { - "binding": "ACTOR_KV", - "id": "example_namespace", - "preview_id": "example_namespace_preview" - } - ], - "observability": { - "enabled": true - } -} diff --git a/examples/cloudflare-workers-inline-client/README.md b/examples/cloudflare-workers-inline-client/README.md deleted file mode 100644 index 97d28ee34d..0000000000 --- a/examples/cloudflare-workers-inline-client/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Cloudflare Workers Inline Client Example - -Simple example demonstrating accessing Rivet Actors via Cloudflare Workers without exposing a public API. This uses the `createInlineClient` function to connect directly to your Durable Object. - -## Getting Started - -```sh -git clone https://github.com/rivet-dev/rivet.git -cd rivet/examples/cloudflare-workers-inline-client -npm install -npm run dev -``` - - -## Features - -- **Inline client access**: Call actor actions directly from Cloudflare Worker without HTTP overhead -- **Private actor APIs**: Actors not exposed via public HTTP endpoints -- **Edge-native execution**: Actors and workers run together on Cloudflare's edge network -- **Type-safe communication**: Full TypeScript type safety between worker and actor - -## Implementation - -This example demonstrates using inline clients to call actors privately within Cloudflare Workers: - -- **Actor Definition** ([`src/backend/registry.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/cloudflare-workers-inline-client/src/backend/registry.ts)): Shows how to use `createInlineClient` for direct actor access without public HTTP endpoints - -## Resources - -Read more about [Cloudflare Workers integration](/docs/platforms/cloudflare-workers) and [actions](/docs/actors/actions). - -## License - -MIT diff --git a/examples/cloudflare-workers-inline-client/scripts/client-http.ts b/examples/cloudflare-workers-inline-client/scripts/client-http.ts deleted file mode 100644 index bdd5d51e5d..0000000000 --- a/examples/cloudflare-workers-inline-client/scripts/client-http.ts +++ /dev/null @@ -1,24 +0,0 @@ -const baseUrl = process.env.BASE_URL ?? "http://localhost:8787"; - -async function main() { - console.log("🚀 Cloudflare Workers Client Demo"); - - try { - for (let i = 0; i < 3; i++) { - // Increment counter - console.log("Incrementing counter..."); - const response = await fetch(`${baseUrl}/increment/demo`, { - method: "POST", - }); - const result = await response.text(); - console.log(result); - } - - console.log("✅ Demo completed!"); - } catch (error) { - console.error("❌ Error:", error); - process.exit(1); - } -} - -main().catch(console.error); diff --git a/examples/cloudflare-workers-inline-client/scripts/client-rivetkit.ts b/examples/cloudflare-workers-inline-client/scripts/client-rivetkit.ts deleted file mode 100644 index f2a23935af..0000000000 --- a/examples/cloudflare-workers-inline-client/scripts/client-rivetkit.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createClient } from "rivetkit/client"; -import type { registry } from "../src/registry"; - -// Create RivetKit client -const client = createClient( - process.env.RIVET_ENDPOINT ?? "http://localhost:8787/rivet", -); - -async function main() { - console.log("🚀 Cloudflare Workers Client Demo"); - - try { - const counter = client.counter.getOrCreate("demo").connect(); - - for (let i = 0; i < 3; i++) { - // Increment counter - console.log("Incrementing counter..."); - const result1 = await counter.increment(1); - console.log("New count:", result1); - } - - await counter.dispose(); - - console.log("✅ Demo completed!"); - } catch (error) { - console.error("❌ Error:", error); - process.exit(1); - } -} - -main().catch(console.error); diff --git a/examples/cloudflare-workers-inline-client/src/index.ts b/examples/cloudflare-workers-inline-client/src/index.ts deleted file mode 100644 index 08332edf98..0000000000 --- a/examples/cloudflare-workers-inline-client/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -// FIXME: Re-enable once inline client is fixed -// import { createInlineClient } from "@rivetkit/cloudflare-workers"; -// import { registry } from "./registry"; - -// const { -// client, -// fetch: rivetFetch, -// ActorHandler, -// } = createInlineClient(registry); - -// // IMPORTANT: Your Durable Object must be exported here -// export { ActorHandler }; - -// export default { -// fetch: async (request, env, ctx) => { -// const url = new URL(request.url); - -// // Custom request handler -// if ( -// request.method === "POST" && -// url.pathname.startsWith("/increment/") -// ) { -// const name = url.pathname.slice("/increment/".length); - -// const counter = client.counter.getOrCreate(name); -// const newCount = await counter.increment(1); - -// return new Response(`New Count: ${newCount}`, { -// headers: { "Content-Type": "text/plain" }, -// }); -// } - -// // Optional: If you want to access Rivet Actors publicly, mount the path -// if (url.pathname.startsWith("/rivet")) { -// const strippedPath = url.pathname.substring("/rivet".length); -// url.pathname = strippedPath; -// console.log("URL", url.toString()); -// const modifiedRequest = new Request(url.toString(), request); -// return rivetFetch(modifiedRequest, env, ctx); -// } - -// return new Response("Not Found", { status: 404 }); -// }, -// } satisfies ExportedHandler; diff --git a/examples/cloudflare-workers-inline-client/src/registry.ts b/examples/cloudflare-workers-inline-client/src/registry.ts deleted file mode 100644 index 5939fc3512..0000000000 --- a/examples/cloudflare-workers-inline-client/src/registry.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { actor, setup, event } from "rivetkit"; - -export const counter = actor({ - state: { count: 0 }, - events: { - newCount: event(), - }, - actions: { - increment: (c, x: number) => { - c.state.count += x; - c.broadcast("newCount", c.state.count); - return c.state.count; - }, - }, -}); - -export const registry = setup({ - use: { counter }, -}); diff --git a/examples/cloudflare-workers-inline-client/tsconfig.json b/examples/cloudflare-workers-inline-client/tsconfig.json deleted file mode 100644 index f4bdc4cddf..0000000000 --- a/examples/cloudflare-workers-inline-client/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "target": "esnext", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "lib": ["esnext"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "esnext", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["@cloudflare/workers-types"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "include": ["src/**/*"] -} diff --git a/examples/cloudflare-workers-inline-client/turbo.json b/examples/cloudflare-workers-inline-client/turbo.json deleted file mode 100644 index 29d4cb2625..0000000000 --- a/examples/cloudflare-workers-inline-client/turbo.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "extends": ["//"] -} diff --git a/examples/cloudflare-workers-inline-client/wrangler.json b/examples/cloudflare-workers-inline-client/wrangler.json deleted file mode 100644 index f5b84c4ef6..0000000000 --- a/examples/cloudflare-workers-inline-client/wrangler.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "rivetkit-cloudflare-workers-example", - "main": "src/index.ts", - "compatibility_date": "2025-01-20", - "compatibility_flags": ["nodejs_compat"], - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["ActorHandler"] - } - ], - "durable_objects": { - "bindings": [ - { - "name": "ACTOR_DO", - "class_name": "ActorHandler" - } - ] - }, - "kv_namespaces": [ - { - "binding": "ACTOR_KV", - "id": "example_namespace", - "preview_id": "example_namespace_preview" - } - ], - "observability": { - "enabled": true - } -} diff --git a/examples/cloudflare-workers/README.md b/examples/cloudflare-workers/README.md deleted file mode 100644 index 50184ff1ef..0000000000 --- a/examples/cloudflare-workers/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Cloudflare Workers - -Example project demonstrating Cloudflare Workers deployment. - -## Getting Started - -```sh -git clone https://github.com/rivet-dev/rivet.git -cd rivet/examples/cloudflare-workers -npm install -npm run dev -``` - - -## Features - -- **Cloudflare Workers integration**: Deploy Rivet Actors to Cloudflare's edge network using Durable Objects -- **Edge-native execution**: Actors run at the edge for low-latency global access -- **Native Durable Object SQLite**: Actor state is persisted through Cloudflare's built-in Durable Object SQLite storage -- **Built-in HTTP API**: Actors automatically exposed via HTTP endpoints -- **Wrangler CLI integration**: Standard Cloudflare tooling for development and deployment - -## Implementation - -This example demonstrates deploying Rivet Actors to Cloudflare Workers with native Durable Object SQLite: - -- **Actor Definition** ([`src/actors.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/cloudflare-workers/src/actors.ts)): Shows how to set up a SQLite-backed actor for Cloudflare Workers using Durable Objects - -## Resources - -Read more about [Cloudflare Workers integration](/docs/platforms/cloudflare-workers), [actions](/docs/actors/actions), and [state](/docs/actors/state). - -## License - -MIT diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json deleted file mode 100644 index e75f3d93c3..0000000000 --- a/examples/cloudflare-workers/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "example-cloudflare-workers", - "version": "2.0.21", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "check-types": "tsc --noEmit", - "client": "tsx scripts/client.ts", - "build": "tsc" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250129.0", - "@types/node": "^22.13.9", - "tsx": "^3.12.7", - "typescript": "^5.5.2", - "wrangler": "^4.22.0" - }, - "dependencies": { - "rivetkit": "^2.2.1", - "@rivetkit/cloudflare-workers": "^2.2.1" - }, - "stableVersion": "0.8.0", - "license": "MIT" -} diff --git a/examples/cloudflare-workers/scripts/client.ts b/examples/cloudflare-workers/scripts/client.ts deleted file mode 100644 index 660d1af99c..0000000000 --- a/examples/cloudflare-workers/scripts/client.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createClient } from "rivetkit/client"; -import type { registry } from "../src/actors"; - -// Create RivetKit client -const client = createClient({ - endpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:8787/api/rivet", - disableMetadataLookup: true, -}); - -async function main() { - console.log("🚀 Cloudflare Workers SQLite E2E Demo"); - - try { - const counterKey = "sqlite-demo"; - const counter = client.sqliteCounter.getOrCreate(counterKey); - - const initialCount = await counter.getCount(); - console.log("Initial count:", initialCount); - - const afterOne = await counter.increment(1); - console.log("After +1:", afterOne); - - const afterFive = await counter.increment(5); - console.log("After +5:", afterFive); - - const expected = initialCount + 6; - if (afterFive !== expected) { - throw new Error( - `Unexpected count after increments. Expected ${expected}, got ${afterFive}.`, - ); - } - - // Ensure the value persisted by re-resolving the actor handle. - const counterAgain = client.sqliteCounter.getOrCreate(counterKey); - const persistedCount = await counterAgain.getCount(); - console.log("Persisted count:", persistedCount); - - if (persistedCount !== expected) { - throw new Error( - `Persistence check failed. Expected ${expected}, got ${persistedCount}.`, - ); - } - - console.log("✅ SQLite E2E check passed"); - } catch (error) { - console.error("❌ Error:", error); - process.exit(1); - } -} - -main().catch(console.error); diff --git a/examples/cloudflare-workers/src/actors.ts b/examples/cloudflare-workers/src/actors.ts deleted file mode 100644 index 753e9fe32b..0000000000 --- a/examples/cloudflare-workers/src/actors.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { actor, setup, event } from "rivetkit"; -import { db } from "rivetkit/db"; - -const COUNTER_ROW_ID = 1; - -export const counter = actor({ - state: { count: 0 }, - events: { - newCount: event(), - }, - actions: { - increment: (c, x: number) => { - if (!Number.isFinite(x)) { - throw new Error("increment value must be a finite number"); - } - - const delta = Math.trunc(x); - c.state.count += delta; - c.broadcast("newCount", c.state.count); - return c.state.count; - }, - getCount: (c) => c.state.count, - }, -}); - -export const sqliteCounter = actor({ - db: db({ - onMigrate: async (database) => { - await database.execute(` - CREATE TABLE IF NOT EXISTS counter_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - count INTEGER NOT NULL - ) - `); - await database.execute( - "INSERT OR IGNORE INTO counter_state (id, count) VALUES (1, 0)", - ); - }, - }), - events: { - newCount: event(), - }, - actions: { - increment: async (c, x: number) => { - if (!Number.isFinite(x)) { - throw new Error("increment value must be a finite number"); - } - - const delta = Math.trunc(x); - await c.db.execute( - "UPDATE counter_state SET count = count + ? WHERE id = ?", - delta, - COUNTER_ROW_ID, - ); - const rows = await c.db.execute<{ count: number }>( - "SELECT count FROM counter_state WHERE id = ?", - COUNTER_ROW_ID, - ); - const count = Number(rows[0]?.count ?? 0); - c.broadcast("newCount", count); - return count; - }, - getCount: async (c) => { - const rows = await c.db.execute<{ count: number }>( - "SELECT count FROM counter_state WHERE id = ?", - COUNTER_ROW_ID, - ); - return Number(rows[0]?.count ?? 0); - }, - }, -}); - -export const registry = setup({ - use: { counter, sqliteCounter }, -}); diff --git a/examples/cloudflare-workers/src/index.ts b/examples/cloudflare-workers/src/index.ts deleted file mode 100644 index bd2759ac2a..0000000000 --- a/examples/cloudflare-workers/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createHandler } from "@rivetkit/cloudflare-workers"; -import { registry } from "./actors"; - -const { handler, ActorHandler } = createHandler(registry); -export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers/tsconfig.json b/examples/cloudflare-workers/tsconfig.json deleted file mode 100644 index f4bdc4cddf..0000000000 --- a/examples/cloudflare-workers/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "target": "esnext", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "lib": ["esnext"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "esnext", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["@cloudflare/workers-types"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "include": ["src/**/*"] -} diff --git a/examples/cloudflare-workers/turbo.json b/examples/cloudflare-workers/turbo.json deleted file mode 100644 index 125ee72dd9..0000000000 --- a/examples/cloudflare-workers/turbo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "extends": ["//"], - "tasks": { - "build": { - "dependsOn": ["@rivetkit/cloudflare-workers#build", "rivetkit#build"] - } - } -} diff --git a/examples/cloudflare-workers/wrangler.json b/examples/cloudflare-workers/wrangler.json deleted file mode 100644 index f5b84c4ef6..0000000000 --- a/examples/cloudflare-workers/wrangler.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "rivetkit-cloudflare-workers-example", - "main": "src/index.ts", - "compatibility_date": "2025-01-20", - "compatibility_flags": ["nodejs_compat"], - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["ActorHandler"] - } - ], - "durable_objects": { - "bindings": [ - { - "name": "ACTOR_DO", - "class_name": "ActorHandler" - } - ] - }, - "kv_namespaces": [ - { - "binding": "ACTOR_KV", - "id": "example_namespace", - "preview_id": "example_namespace_preview" - } - ], - "observability": { - "enabled": true - } -} diff --git a/examples/collaborative-document/vite.config.ts b/examples/collaborative-document/vite.config.ts index bd1498f19b..9b7f9fd17e 100644 --- a/examples/collaborative-document/vite.config.ts +++ b/examples/collaborative-document/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ // Disable screen clearing so concurrently output stays readable clearScreen: false, proxy: { - // Forward manager API and WebSocket requests to the backend + // Forward actor API and WebSocket requests to the backend "/actors": { target: "http://localhost:6420", ws: true }, "/metadata": { target: "http://localhost:6420" }, "/health": { target: "http://localhost:6420" }, diff --git a/examples/hello-world/vite.config.ts b/examples/hello-world/vite.config.ts index bd1498f19b..9b7f9fd17e 100644 --- a/examples/hello-world/vite.config.ts +++ b/examples/hello-world/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ // Disable screen clearing so concurrently output stays readable clearScreen: false, proxy: { - // Forward manager API and WebSocket requests to the backend + // Forward actor API and WebSocket requests to the backend "/actors": { target: "http://localhost:6420", ws: true }, "/metadata": { target: "http://localhost:6420" }, "/health": { target: "http://localhost:6420" }, diff --git a/frontend/e2e/cloud/onboarding.spec.ts b/frontend/e2e/cloud/onboarding.spec.ts index 2918dd7c4b..9e79f790a3 100644 --- a/frontend/e2e/cloud/onboarding.spec.ts +++ b/frontend/e2e/cloud/onboarding.spec.ts @@ -25,7 +25,7 @@ test.describe("onboarding wizard", () => { // skip local steps to reach provider selection await onboardingPage.skipToDeploy(); - // all expected providers should be visible (cloudflare-workers is filtered out as specializedPlatform) + // all expected providers should be visible for (const provider of [ "vercel", "gcp-cloud-run", diff --git a/frontend/packages/example-registry/src/_gen.ts b/frontend/packages/example-registry/src/_gen.ts index 0b5983b24f..9fc7a3d3bb 100644 --- a/frontend/packages/example-registry/src/_gen.ts +++ b/frontend/packages/example-registry/src/_gen.ts @@ -358,33 +358,6 @@ export const templates: Template[] = [ } } }, - { - "name": "cloudflare-workers", - "displayName": "Cloudflare Workers", - "description": "Example project demonstrating Cloudflare Workers deployment.", - "technologies": [ - "rivet", - "cloudflare-workers", - "typescript" - ], - "tags": [], - "noFrontend": true, - "providers": {} - }, - { - "name": "cloudflare-workers-hono", - "displayName": "Cloudflare Workers with Hono", - "description": "Example project demonstrating Cloudflare Workers deployment with Hono router.", - "technologies": [ - "rivet", - "cloudflare-workers", - "hono", - "typescript" - ], - "tags": [], - "noFrontend": true, - "providers": {} - }, { "name": "cursors-raw-websocket", "displayName": "Real-time Collaborative Cursors (Raw WebSocket)", diff --git a/frontend/packages/shared-data/src/deploy.ts b/frontend/packages/shared-data/src/deploy.ts index b5ff681f2d..775452dfa7 100644 --- a/frontend/packages/shared-data/src/deploy.ts +++ b/frontend/packages/shared-data/src/deploy.ts @@ -49,16 +49,6 @@ export const deployOptions = [ "Deploy containers to Railway's managed infrastructure", icon: faRailway as any, }, - { - displayName: "Cloudflare Workers", - name: "cloudflare-workers" as const, - shortTitle: "Cloudflare", - href: "/docs/connect/cloudflare-workers", - description: - "Run your app on Cloudflare's global edge network with Durable Objects", - icon: faCloudflare as any, - specializedPlatform: true, - }, { displayName: "Kubernetes", name: "kubernetes" as const, diff --git a/frontend/src/app/dialogs/connect-cloudflare-frame.tsx b/frontend/src/app/dialogs/connect-cloudflare-frame.tsx deleted file mode 100644 index b358ca232c..0000000000 --- a/frontend/src/app/dialogs/connect-cloudflare-frame.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { faCloudflare, Icon } from "@rivet-gg/icons"; -import { useMutation, usePrefetchInfiniteQuery } from "@tanstack/react-query"; -import confetti from "canvas-confetti"; -import { useWatch } from "react-hook-form"; -import z from "zod"; -import * as ConnectServerlessForm from "@/app/forms/connect-manual-serverless-form"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - CodeFrame, - CodeGroup, - CodePreview, - type DialogContentProps, - Frame, -} from "@/components"; -import { useEngineCompatDataProvider } from "@/components/actors"; -import { defineStepper } from "@/components/ui/stepper"; -import { successfulBackendSetupEffect } from "@/lib/effects"; -import { queryClient } from "@/queries/global"; -import { EnvVariables } from "../env-variables"; -import { StepperForm } from "../forms/stepper-form"; -import { - endpointSchema, - ServerlessConnectionCheck, -} from "../serverless-connection-check"; -import { useEndpoint } from "./connect-manual-serverfull-frame"; - -const CLOUDFLARE_MAX_REQUEST_DURATION = 30; - -const stepper = defineStepper( - { - id: "configure", - title: "Configure runner", - assist: false, - next: "Next", - schema: z.object({ - runnerName: z.string().min(1, "Runner name is required"), - datacenters: z - .record(z.boolean()) - .refine( - (data) => Object.values(data).some(Boolean), - "At least one datacenter must be selected", - ), - headers: z.array(z.tuple([z.string(), z.string()])).default([]), - slotsPerRunner: z.coerce.number().min(1, "Must be at least 1"), - maxRunners: z.coerce.number().min(0, "Must be 0 or greater"), - minRunners: z.coerce.number().min(0, "Must be 0 or greater"), - runnerMargin: z.coerce.number().min(0, "Must be 0 or greater"), - requestLifespan: z.coerce - .number() - .min(1, "Must be at least 1") - .max( - CLOUDFLARE_MAX_REQUEST_DURATION, - "Cloudflare Workers requests time out after 30s", - ), - }), - }, - { - id: "deploy", - title: "Deploy to Cloudflare Workers", - assist: false, - next: "Next", - schema: z.object({}), - }, - { - id: "verify", - title: "Connect & verify", - assist: true, - next: "Add", - schema: z.object({ - endpoint: endpointSchema, - success: z.boolean().refine((v) => v === true, { - message: "Runner must be connected to proceed", - }), - }), - }, -); - -interface ConnectCloudflareFrameContentProps extends DialogContentProps { - title?: React.ReactNode; - footer?: React.ReactNode; -} - -export default function ConnectCloudflareFrameContent({ - onClose, - title, - footer, -}: ConnectCloudflareFrameContentProps) { - usePrefetchInfiniteQuery({ - ...useEngineCompatDataProvider().datacentersQueryOptions(), - pages: Infinity, - }); - - return ( - <> - - - {title ?? ( -
- Add {" "} - Cloudflare Workers -
- )} -
-
- - - - - ); -} - -function FormStepper({ - onClose, - footer, -}: { - onClose?: () => void; - footer?: React.ReactNode; -}) { - const provider = useEngineCompatDataProvider(); - - const { mutateAsync } = useMutation({ - ...provider.upsertRunnerConfigMutationOptions(), - onSuccess: async () => { - successfulBackendSetupEffect(); - await queryClient.refetchQueries( - provider.runnerConfigsQueryOptions(), - ); - onClose?.(); - }, - }); - - return ( - , - deploy: () => , - verify: () => , - }} - onSubmit={async ({ values }) => { - const selectedDatacenters = Object.entries(values.datacenters) - .filter(([, selected]) => selected) - .map(([id]) => id); - - const config = { - serverless: { - url: values.endpoint, - maxRunners: values.maxRunners, - minRunners: values.minRunners, - slotsPerRunner: values.slotsPerRunner, - runnersMargin: values.runnerMargin, - requestLifespan: values.requestLifespan, - headers: Object.fromEntries( - values.headers.map(([key, value]) => [key, value]), - ), - }, - metadata: { provider: "cloudflare-workers" }, - }; - - const payload = Object.fromEntries( - selectedDatacenters.map((dc) => [dc, config]), - ); - - await mutateAsync({ name: values.runnerName, config: payload }); - }} - defaultValues={{ - runnerName: "default", - slotsPerRunner: 1, - minRunners: 1, - maxRunners: 100_000, - runnerMargin: 0, - requestLifespan: 25, - headers: [], - datacenters: {}, - }} - controls={footer} - /> - ); -} - -function StepConfigure() { - return ( -
- - - - - - Advanced - - - - - - - - - - - -
- ); -} - -function StepDeploy() { - const runnerName = useWatch({ name: "runnerName" }); - return ( -
-

- Make sure your Worker is integrated with RivetKit. See the{" "} - - Cloudflare Workers guide - {" "} - for wiring up Durable Objects. -

-

Set these environment variables in your Wrangler config:

- -
-

Deploy to Cloudflare's edge:

- - {[ - "wrangler deploy"} - > - - , - ]} - -
-

- Use your deployed Worker URL with{" "} - /rivet appended for the - endpoint. -

-
- ); -} - -function StepVerify() { - return ( - <> -

- Paste the deployed Worker endpoint (including /rivet) and wait - for the health check to pass. -

- - - - ); -} diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 72625577e1..19c6c8ae91 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -640,7 +640,6 @@ For detailed setup instructions, see the quickstart guides: - Node.js & Bun: https://rivet.dev/docs/actors/quickstart/backend - React: https://rivet.dev/docs/actors/quickstart/react - Next.js: https://rivet.dev/docs/actors/quickstart/next-js -- Cloudflare Workers: https://rivet.dev/docs/actors/quickstart/cloudflare-workers ## If You Get Stuck diff --git a/frontend/src/app/runner-config-table.tsx b/frontend/src/app/runner-config-table.tsx index 2aaaa85611..caba65876f 100644 --- a/frontend/src/app/runner-config-table.tsx +++ b/frontend/src/app/runner-config-table.tsx @@ -1,6 +1,5 @@ import { faAws, - faCloudflare, faEllipsisVertical, faGoogleCloud, faHetznerH, @@ -376,13 +375,6 @@ function Endpoints({ function Provider({ metadata }: { metadata: unknown }) { const provider = deriveProviderFromMetadata(metadata); - if (provider === "cloudflare-workers") { - return ( -
- Cloudflare Workers -
- ); - } if (provider === "vercel") { return (
diff --git a/frontend/src/app/serverless-connection-check.tsx b/frontend/src/app/serverless-connection-check.tsx index b84f2e448f..36171bce1e 100644 --- a/frontend/src/app/serverless-connection-check.tsx +++ b/frontend/src/app/serverless-connection-check.tsx @@ -121,10 +121,6 @@ export function ServerlessConnectionCheck({ .with("railway", () => "Railway") .with("vercel", () => "Vercel") .with("aws-ecs", () => "AWS ECS") - .with( - "cloudflare-workers", - () => "Cloudflare Worker", - ) .with("gcp-cloud-run", () => "GCP Cloud Run") .with("hetzner", () => "Hetzner") .with("kubernetes", () => "Kubernetes") diff --git a/frontend/src/app/use-dialog.tsx b/frontend/src/app/use-dialog.tsx index ac304d8b54..a01f78171b 100644 --- a/frontend/src/app/use-dialog.tsx +++ b/frontend/src/app/use-dialog.tsx @@ -26,9 +26,6 @@ export const useDialog = { ConnectManual: createDialogHook( () => import("@/app/dialogs/connect-manual-frame"), ), - ConnectCloudflare: createDialogHook( - () => import("@/app/dialogs/connect-cloudflare-frame"), - ), ConnectAws: createDialogHook( () => import("@/app/dialogs/connect-aws-frame"), ), diff --git a/package.json b/package.json index 4f02dc9c00..1bfd332ca8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "resolutions": { "rivetkit": "workspace:*", "@rivetkit/react": "workspace:*", - "@rivetkit/cloudflare-workers": "workspace:*", "@rivetkit/next-js": "workspace:*", "@rivetkit/db": "workspace:*", "@rivetkit/sqlite-vfs": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b8ef585e..789e8fd829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,6 @@ settings: overrides: rivetkit: workspace:* '@rivetkit/react': workspace:* - '@rivetkit/cloudflare-workers': workspace:* '@rivetkit/next-js': workspace:* '@rivetkit/db': workspace:* '@rivetkit/sqlite-vfs': workspace:* @@ -651,6 +650,82 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/ai-generated-actor: + dependencies: + '@ai-sdk/mcp': + specifier: ^1.0.25 + version: 1.0.35(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^0.0.66 + version: 0.0.66(zod@3.25.76) + '@hono/node-server': + specifier: ^1.19.7 + version: 1.19.9(hono@4.11.9) + '@hono/node-ws': + specifier: ^1.3.0 + version: 1.3.0(@hono/node-server@1.19.9(hono@4.11.9))(hono@4.11.9) + '@monaco-editor/react': + specifier: ^4.6.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@rivetkit/react': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/react + ai: + specifier: ^4.0.38 + version: 4.3.19(react@19.1.0)(zod@3.25.76) + hono: + specifier: ^4.11.3 + version: 4.11.9 + lucide-react: + specifier: ^0.344.0 + version: 0.344.0(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + secure-exec: + specifier: https://pkg.pr.new/rivet-dev/secure-exec@7659aba + version: https://pkg.pr.new/rivet-dev/secure-exec@7659aba + srvx: + specifier: ^0.10.0 + version: 0.10.0 + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + '@types/react': + specifier: ^19 + version: 19.2.13 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.13) + '@vitejs/plugin-react': + specifier: ^4.2.0 + version: 4.7.0(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite-plugin-srvx: + specifier: ^1.0.2 + version: 1.0.2(srvx@0.10.0)(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/chat-room: dependencies: '@rivetkit/react': @@ -801,59 +876,6 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@3.1.1)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - examples/cloudflare-workers: - dependencies: - '@rivetkit/cloudflare-workers': - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/cloudflare-workers - rivetkit: - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/rivetkit - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20250129.0 - version: 4.20251014.0 - '@types/node': - specifier: ^22.13.9 - version: 22.19.10 - tsx: - specifier: ^3.12.7 - version: 3.14.0 - typescript: - specifier: ^5.5.2 - version: 5.9.3 - wrangler: - specifier: ^4.22.0 - version: 4.44.0(@cloudflare/workers-types@4.20251014.0) - - examples/cloudflare-workers-hono: - dependencies: - '@rivetkit/cloudflare-workers': - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/cloudflare-workers - hono: - specifier: ^4.8.0 - version: 4.11.9 - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20250129.0 - version: 4.20251014.0 - '@types/node': - specifier: ^22.13.9 - version: 22.19.10 - rivetkit: - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/rivetkit - tsx: - specifier: ^3.12.7 - version: 3.14.0 - typescript: - specifier: ^5.5.2 - version: 5.9.3 - wrangler: - specifier: ^4.22.0 - version: 4.44.0(@cloudflare/workers-types@4.20251014.0) - examples/collaborative-document: dependencies: '@rivetkit/react': @@ -1253,6 +1275,58 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@3.1.1)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/dynamic-actors: + dependencies: + '@hono/node-server': + specifier: ^1.19.7 + version: 1.19.9(hono@4.11.9) + '@hono/node-ws': + specifier: ^1.2.0 + version: 1.3.0(@hono/node-server@1.19.9(hono@4.11.9))(hono@4.11.9) + hono: + specifier: ^4.11.3 + version: 4.11.9 + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + srvx: + specifier: ^0.10.0 + version: 0.10.0 + devDependencies: + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + '@types/react': + specifier: ^19 + version: 19.2.13 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.13) + '@vitejs/plugin-react': + specifier: ^4.2.0 + version: 4.7.0(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite-plugin-srvx: + specifier: ^1.0.2 + version: 1.0.2(srvx@0.10.0)(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/elysia: dependencies: elysia: @@ -4235,43 +4309,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - rivetkit-typescript/packages/cloudflare-workers: - dependencies: - hono: - specifier: ^4.7.0 - version: 4.11.9 - invariant: - specifier: ^2.2.4 - version: 2.2.4 - rivetkit: - specifier: workspace:* - version: link:../rivetkit - zod: - specifier: ^4.1.0 - version: 4.1.13 - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20250129.0 - version: 4.20251014.0 - '@types/invariant': - specifier: ^2 - version: 2.2.37 - '@types/node': - specifier: ^24.0.3 - version: 24.7.1 - tsup: - specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@24.7.1))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - typescript: - specifier: ^5.5.2 - version: 5.9.3 - vitest: - specifier: ^3.1.1 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - wrangler: - specifier: ^4.22.0 - version: 4.44.0(@cloudflare/workers-types@4.20251014.0) - rivetkit-typescript/packages/devtools: dependencies: '@floating-ui/react': @@ -4440,7 +4477,7 @@ importers: version: 1.1.5(hono@4.11.9)(zod@4.1.13) '@rivet-dev/agent-os-core': specifier: ^0.1.1 - version: 0.1.1 + version: 0.1.1(pyodide@0.28.3) '@rivetkit/bare-ts': specifier: ^0.6.2 version: 0.6.2 @@ -4543,7 +4580,7 @@ importers: version: 0.0.260331072558 '@rivet-dev/agent-os-pi': specifier: ^0.1.1 - version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) + version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(pyodide@0.28.3)(ws@8.19.0)(zod@4.1.13) '@standard-schema/spec': specifier: ^1.0.0 version: 1.0.0 @@ -5006,9 +5043,6 @@ importers: specifier: ^5.0.8 version: 5.0.8(@types/react@19.2.13)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: - '@rivetkit/cloudflare-workers': - specifier: workspace:* - version: link:../rivetkit-typescript/packages/cloudflare-workers '@rivetkit/react': specifier: workspace:* version: link:../rivetkit-typescript/packages/react @@ -5054,9 +5088,6 @@ importers: '@hono/node-server': specifier: ^1.14.1 version: 1.19.9(hono@4.11.9) - '@rivetkit/cloudflare-workers': - specifier: workspace:* - version: link:../../../rivetkit-typescript/packages/cloudflare-workers '@rivetkit/react': specifier: workspace:* version: link:../../../rivetkit-typescript/packages/react @@ -5134,6 +5165,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@1.0.35': + resolution: {integrity: sha512-YeFHyq3pq/tkD8rp/U0P9sJsQfDTT4F/8WdpRLLo30cCLx2kfuIYafRyTLfERnYayYlLbH5aMBpjttFunvIDCA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@0.0.66': resolution: {integrity: sha512-V4XeDnlNl5/AY3GB3ozJUjqnBLU5pK3DacKTbCNH3zH8/MggJoH6B8wRGdLUPVFMcsMz60mtvh4DC9JsIVFrKw==} engines: {node: '>=18'} @@ -5167,6 +5204,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': + resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@0.0.24': resolution: {integrity: sha512-XMsNGJdGO+L0cxhhegtqZ8+T6nn4EoShS819OvCgI2kLbYTIvk0GWFGD0AXJmxkxs3DrpsJxKAFukFR7bvTkgQ==} engines: {node: '>=18'} @@ -6262,49 +6305,7 @@ packages: '@clerk/types@4.101.12': resolution: {integrity: sha512-ePXOla3B1qgPtV0AzrLx2PVC3s/lsjOSYnuIFAxaIlRNT2+eb/BjeoqtTOcezwbdQ00jQ2RvXahdfZRSEuvZ7A==} engines: {node: '>=18.17.0'} - - '@cloudflare/kv-asset-handler@0.4.0': - resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} - engines: {node: '>=18.0.0'} - - '@cloudflare/unenv-preset@2.7.8': - resolution: {integrity: sha512-Ky929MfHh+qPhwCapYrRPwPVHtA2Ioex/DbGZyskGyNRDe9Ru3WThYZivyNVaPy5ergQSgMs9OKrM9Ajtz9F6w==} - peerDependencies: - unenv: 2.0.0-rc.21 - workerd: ^1.20250927.0 - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20251011.0': - resolution: {integrity: sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20251011.0': - resolution: {integrity: sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20251011.0': - resolution: {integrity: sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20251011.0': - resolution: {integrity: sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20251011.0': - resolution: {integrity: sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@cloudflare/workers-types@4.20251014.0': resolution: {integrity: sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==} @@ -6496,12 +6497,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.4': - resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -6532,12 +6527,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.4': - resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -6568,12 +6557,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.4': - resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -6604,12 +6587,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.4': - resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -6640,12 +6617,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.4': - resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -6676,12 +6647,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.4': - resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -6712,12 +6677,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.4': - resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -6748,12 +6707,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.4': - resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -6784,12 +6737,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.4': - resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -6820,12 +6767,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.4': - resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -6856,12 +6797,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.4': - resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -6892,12 +6827,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.4': - resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -6928,12 +6857,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.4': - resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -6964,12 +6887,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.4': - resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -7000,12 +6917,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.4': - resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -7036,12 +6947,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.4': - resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -7072,12 +6977,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.4': - resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -7090,12 +6989,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.4': - resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -7126,12 +7019,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.4': - resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -7144,12 +7031,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.4': - resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -7180,12 +7061,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.4': - resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -7228,12 +7103,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.4': - resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -7264,12 +7133,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.4': - resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -7300,12 +7163,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.4': - resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -7336,12 +7193,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.4': - resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -8978,15 +8829,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@poppinss/colors@4.1.5': - resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} - - '@poppinss/dumper@0.6.4': - resolution: {integrity: sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==} - - '@poppinss/exception@1.2.2': - resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} - '@posthog/core@1.5.3': resolution: {integrity: sha512-1cHCMR2uS/rAdBIFlBPJ4rPYaw1O42VkFy/LwQLtoy2hMQb2DdhCoSHfgA66R9TvcOybZsSANlbuihmGEZUKVQ==} @@ -10391,10 +10233,6 @@ packages: '@sinclair/typebox@0.34.41': resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} - '@sindresorhus/is@7.1.0': - resolution: {integrity: sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==} - engines: {node: '>=18'} - '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -10769,9 +10607,6 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@speed-highlight/core@1.2.7': - resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} - '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -11961,19 +11796,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} - acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -12378,9 +12204,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -13771,9 +13594,6 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -13827,11 +13647,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.4: - resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -14007,10 +13822,6 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} - engines: {node: '>=6'} - expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -14130,9 +13941,6 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -15099,6 +14907,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isolated-vm@6.1.2: + resolution: {integrity: sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==} + engines: {node: '>=22.0.0'} + isomorphic-timers-promises@1.0.1: resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} engines: {node: '>=10'} @@ -15327,10 +15139,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} @@ -15630,6 +15438,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.344.0: + resolution: {integrity: sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==} + peerDependencies: + react: 19.1.0 + lucide-react@0.439.0: resolution: {integrity: sha512-PafSWvDTpxdtNEndS2HIHxcNAbd54OaqSYJO90/b63rab2HWYqDbH194j0i82ZFdWOAcf0AHinRykXRRK2PJbw==} peerDependencies: @@ -16056,11 +15869,6 @@ packages: engines: {node: '>=4'} hasBin: true - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - mime@4.0.7: resolution: {integrity: sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==} engines: {node: '>=16'} @@ -16086,11 +15894,6 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true - miniflare@4.20251011.0: - resolution: {integrity: sha512-DlZ7vR5q/RE9eLsxsrXzfSZIF2f6O5k0YsFrSKhWUtdefyGtJt4sSpR6V+Af/waaZ6+zIFy9lsknHBCm49sEYA==} - engines: {node: '>=18.0.0'} - hasBin: true - minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -16387,6 +16190,10 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -17258,6 +17065,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pyodide@0.28.3: + resolution: {integrity: sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A==} + engines: {node: '>=18.0.0'} + qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true @@ -17885,6 +17696,10 @@ packages: secure-exec@0.2.1: resolution: {integrity: sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ==} + secure-exec@https://pkg.pr.new/rivet-dev/secure-exec@7659aba: + resolution: {tarball: https://pkg.pr.new/rivet-dev/secure-exec@7659aba} + version: 0.1.0 + secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -18181,10 +17996,6 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - stoppable@1.1.0: - resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} - engines: {node: '>=4', npm: '>=6'} - stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} @@ -18335,10 +18146,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -18447,6 +18254,9 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -18573,6 +18383,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -18811,17 +18625,10 @@ packages: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} - undici@7.14.0: - resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} - engines: {node: '>=20.18.1'} - undici@7.24.7: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} - unenv@2.0.0-rc.21: - resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} - unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -19482,6 +19289,10 @@ packages: resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} engines: {node: '>=8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -19513,6 +19324,10 @@ packages: resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} engines: {node: '>=10'} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -19549,21 +19364,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerd@1.20251011.0: - resolution: {integrity: sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.44.0: - resolution: {integrity: sha512-BLOUigckcWZ0r4rm7b5PuaTpb9KP9as0XeCRSJ8kqcNgXcKoUD3Ij8FlPvN25KybLnFnetaO0ZdfRYUPWle4qw==} - engines: {node: '>=18.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20251011.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -19613,18 +19413,6 @@ packages: utf-8-validate: optional: true - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - 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.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -19779,12 +19567,6 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - z-schema@5.0.5: resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} engines: {node: '>=8.0.0'} @@ -19801,9 +19583,6 @@ packages: typescript: ^4.9.4 || ^5.0.2 zod: ^3 - zod@3.22.3: - resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} - zod@3.24.4: resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} @@ -19904,6 +19683,13 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.1.13 + '@ai-sdk/mcp@1.0.35(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + pkce-challenge: 5.0.1 + zod: 3.25.76 + '@ai-sdk/openai@0.0.66(zod@3.25.76)': dependencies: '@ai-sdk/provider': 0.0.24 @@ -19961,6 +19747,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.13 + '@ai-sdk/provider-utils@4.0.23(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@0.0.24': dependencies: json-schema: 0.4.0 @@ -21799,33 +21592,9 @@ snapshots: - react - react-dom - '@cloudflare/kv-asset-handler@0.4.0': - dependencies: - mime: 3.0.0 - - '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0)': - dependencies: - unenv: 2.0.0-rc.21 - optionalDependencies: - workerd: 1.20251011.0 - - '@cloudflare/workerd-darwin-64@1.20251011.0': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20251011.0': - optional: true - - '@cloudflare/workerd-linux-64@1.20251011.0': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20251011.0': - optional: true - - '@cloudflare/workerd-windows-64@1.20251011.0': + '@cloudflare/workers-types@4.20251014.0': optional: true - '@cloudflare/workers-types@4.20251014.0': {} - '@codemirror/autocomplete@6.18.7': dependencies: '@codemirror/language': 6.11.3 @@ -22102,9 +21871,6 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.25.4': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true @@ -22120,9 +21886,6 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.25.4': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true @@ -22138,9 +21901,6 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.25.4': - optional: true - '@esbuild/android-arm@0.27.3': optional: true @@ -22156,9 +21916,6 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.25.4': - optional: true - '@esbuild/android-x64@0.27.3': optional: true @@ -22174,9 +21931,6 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.25.4': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true @@ -22192,9 +21946,6 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.25.4': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true @@ -22210,9 +21961,6 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.25.4': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true @@ -22228,9 +21976,6 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.25.4': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true @@ -22246,9 +21991,6 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.25.4': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true @@ -22264,9 +22006,6 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.25.4': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true @@ -22282,9 +22021,6 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.25.4': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true @@ -22300,9 +22036,6 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.25.4': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true @@ -22318,9 +22051,6 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.25.4': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true @@ -22336,9 +22066,6 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.25.4': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true @@ -22354,9 +22081,6 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.25.4': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true @@ -22372,9 +22096,6 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.25.4': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true @@ -22390,18 +22111,12 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.25.4': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.4': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true @@ -22417,18 +22132,12 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.25.4': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.4': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true @@ -22444,9 +22153,6 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.25.4': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true @@ -22468,9 +22174,6 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.25.4': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true @@ -22486,9 +22189,6 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.25.4': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true @@ -22504,9 +22204,6 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.25.4': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true @@ -22522,9 +22219,6 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.25.4': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -23372,12 +23066,12 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/confirm@5.1.21(@types/node@24.7.1)': + '@inquirer/confirm@5.1.21(@types/node@22.19.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.7.1) - '@inquirer/type': 3.0.10(@types/node@24.7.1) + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true '@inquirer/core@10.3.2(@types/node@20.19.13)': @@ -23407,18 +23101,18 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/core@10.3.2(@types/node@24.7.1)': + '@inquirer/core@10.3.2(@types/node@22.19.15)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.7.1) + '@inquirer/type': 3.0.10(@types/node@22.19.15) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true '@inquirer/figures@1.0.15': {} @@ -23432,9 +23126,9 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/type@3.0.10(@types/node@24.7.1)': + '@inquirer/type@3.0.10(@types/node@22.19.15)': optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true '@isaacs/balanced-match@4.0.1': {} @@ -23941,15 +23635,6 @@ snapshots: - '@types/node' optional: true - '@microsoft/api-extractor-model@7.31.2(@types/node@24.7.1)': - dependencies: - '@microsoft/tsdoc': 0.15.1 - '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.17.1(@types/node@24.7.1) - transitivePeerDependencies: - - '@types/node' - optional: true - '@microsoft/api-extractor-model@7.31.2(@types/node@25.0.7)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -24034,25 +23719,6 @@ snapshots: - '@types/node' optional: true - '@microsoft/api-extractor@7.53.2(@types/node@24.7.1)': - dependencies: - '@microsoft/api-extractor-model': 7.31.2(@types/node@24.7.1) - '@microsoft/tsdoc': 0.15.1 - '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.17.1(@types/node@24.7.1) - '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.19.2(@types/node@24.7.1) - '@rushstack/ts-command-line': 5.1.2(@types/node@24.7.1) - lodash: 4.17.23 - minimatch: 10.0.3 - resolve: 1.22.11 - semver: 7.5.4 - source-map: 0.6.1 - typescript: 5.8.2 - transitivePeerDependencies: - - '@types/node' - optional: true - '@microsoft/api-extractor@7.53.2(@types/node@25.0.7)': dependencies: '@microsoft/api-extractor-model': 7.31.2(@types/node@25.0.7) @@ -24924,18 +24590,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@poppinss/colors@4.1.5': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.4': - dependencies: - '@poppinss/colors': 4.1.5 - '@sindresorhus/is': 7.1.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.2': {} - '@posthog/core@1.5.3': dependencies: cross-spawn: 7.0.6 @@ -25868,10 +25522,10 @@ snapshots: '@rivet-dev/agent-os-sed': 0.0.260331072558 '@rivet-dev/agent-os-tar': 0.0.260331072558 - '@rivet-dev/agent-os-core@0.1.1': + '@rivet-dev/agent-os-core@0.1.1(pyodide@0.28.3)': dependencies: '@rivet-dev/agent-os-posix': 0.1.0 - '@rivet-dev/agent-os-python': 0.1.0 + '@rivet-dev/agent-os-python': 0.1.0(pyodide@0.28.3) '@secure-exec/core': 0.2.1 '@secure-exec/nodejs': 0.2.1 '@secure-exec/v8': 0.2.1 @@ -25898,7 +25552,7 @@ snapshots: '@agentclientprotocol/sdk': 0.16.1(zod@3.25.76) '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - '@rivet-dev/agent-os-core': 0.1.1 + '@rivet-dev/agent-os-core': 0.1.1(pyodide@0.28.3) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -25909,12 +25563,12 @@ snapshots: - ws - zod - '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13)': + '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(pyodide@0.28.3)(ws@8.19.0)(zod@4.1.13)': dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@4.1.13) '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) - '@rivet-dev/agent-os-core': 0.1.1 + '@rivet-dev/agent-os-core': 0.1.1(pyodide@0.28.3) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -25929,9 +25583,11 @@ snapshots: dependencies: '@secure-exec/core': 0.2.1 - '@rivet-dev/agent-os-python@0.1.0': + '@rivet-dev/agent-os-python@0.1.0(pyodide@0.28.3)': dependencies: '@secure-exec/core': 0.2.1 + optionalDependencies: + pyodide: 0.28.3 '@rivet-dev/agent-os-sed@0.0.260331072558': {} @@ -26107,20 +25763,6 @@ snapshots: '@types/node': 22.19.15 optional: true - '@rushstack/node-core-library@5.17.1(@types/node@24.7.1)': - dependencies: - ajv: 8.13.0 - ajv-draft-04: 1.0.0(ajv@8.13.0) - ajv-formats: 3.0.1(ajv@8.13.0) - fs-extra: 11.3.4 - import-lazy: 4.0.0 - jju: 1.4.0 - resolve: 1.22.11 - semver: 7.5.4 - optionalDependencies: - '@types/node': 24.7.1 - optional: true - '@rushstack/node-core-library@5.17.1(@types/node@25.0.7)': dependencies: ajv: 8.13.0 @@ -26150,11 +25792,6 @@ snapshots: '@types/node': 22.19.15 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@24.7.1)': - optionalDependencies: - '@types/node': 24.7.1 - optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@25.0.7)': optionalDependencies: '@types/node': 25.0.7 @@ -26205,15 +25842,6 @@ snapshots: '@types/node': 22.19.15 optional: true - '@rushstack/terminal@0.19.2(@types/node@24.7.1)': - dependencies: - '@rushstack/node-core-library': 5.17.1(@types/node@24.7.1) - '@rushstack/problem-matcher': 0.1.1(@types/node@24.7.1) - supports-color: 8.1.1 - optionalDependencies: - '@types/node': 24.7.1 - optional: true - '@rushstack/terminal@0.19.2(@types/node@25.0.7)': dependencies: '@rushstack/node-core-library': 5.17.1(@types/node@25.0.7) @@ -26262,16 +25890,6 @@ snapshots: - '@types/node' optional: true - '@rushstack/ts-command-line@5.1.2(@types/node@24.7.1)': - dependencies: - '@rushstack/terminal': 0.19.2(@types/node@24.7.1) - '@types/argparse': 1.0.38 - argparse: 1.0.10 - string-argv: 0.3.2 - transitivePeerDependencies: - - '@types/node' - optional: true - '@rushstack/ts-command-line@5.1.2(@types/node@25.0.7)': dependencies: '@rushstack/terminal': 0.19.2(@types/node@25.0.7) @@ -26729,8 +26347,6 @@ snapshots: '@sinclair/typebox@0.34.41': {} - '@sindresorhus/is@7.1.0': {} - '@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -27329,8 +26945,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@speed-highlight/core@1.2.7': {} - '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.0.0': {} @@ -28485,14 +28099,14 @@ snapshots: msw: 2.12.10(@types/node@22.19.10)(typescript@5.9.3) vite: 5.4.21(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0))': + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.10(@types/node@24.7.1)(typescript@5.9.3) - vite: 5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + msw: 2.12.10(@types/node@22.19.15)(typescript@5.9.3) + vite: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.13)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -28847,14 +28461,10 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-walk@8.3.2: {} - acorn-walk@8.3.4: dependencies: acorn: 8.15.0 - acorn@8.14.0: {} - acorn@8.15.0: {} acorn@8.16.0: {} @@ -29417,8 +29027,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - blake3-wasm@2.1.5: {} - bn.js@4.12.3: {} bn.js@5.2.3: {} @@ -30760,8 +30368,6 @@ snapshots: dependencies: is-arrayish: 0.2.1 - error-stack-parser-es@1.0.5: {} - error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -30910,34 +30516,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.25.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.4 - '@esbuild/android-arm': 0.25.4 - '@esbuild/android-arm64': 0.25.4 - '@esbuild/android-x64': 0.25.4 - '@esbuild/darwin-arm64': 0.25.4 - '@esbuild/darwin-x64': 0.25.4 - '@esbuild/freebsd-arm64': 0.25.4 - '@esbuild/freebsd-x64': 0.25.4 - '@esbuild/linux-arm': 0.25.4 - '@esbuild/linux-arm64': 0.25.4 - '@esbuild/linux-ia32': 0.25.4 - '@esbuild/linux-loong64': 0.25.4 - '@esbuild/linux-mips64el': 0.25.4 - '@esbuild/linux-ppc64': 0.25.4 - '@esbuild/linux-riscv64': 0.25.4 - '@esbuild/linux-s390x': 0.25.4 - '@esbuild/linux-x64': 0.25.4 - '@esbuild/netbsd-arm64': 0.25.4 - '@esbuild/netbsd-x64': 0.25.4 - '@esbuild/openbsd-arm64': 0.25.4 - '@esbuild/openbsd-x64': 0.25.4 - '@esbuild/sunos-x64': 0.25.4 - '@esbuild/win32-arm64': 0.25.4 - '@esbuild/win32-ia32': 0.25.4 - '@esbuild/win32-x64': 0.25.4 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -31182,8 +30760,6 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - exit-hook@2.2.1: {} - expand-template@2.0.3: {} expand-tilde@2.0.2: @@ -31366,8 +30942,6 @@ snapshots: transitivePeerDependencies: - supports-color - exsolve@1.0.7: {} - extend@3.0.2: {} extract-zip@2.0.1: @@ -32478,6 +32052,10 @@ snapshots: isexe@2.0.0: {} + isolated-vm@6.1.2: + dependencies: + node-gyp-build: 4.8.4 + isomorphic-timers-promises@1.0.1: {} isomorphic-ws@5.0.0(ws@8.19.0): @@ -32722,8 +32300,6 @@ snapshots: kleur@3.0.3: {} - kleur@4.1.5: {} - koa-compose@4.1.0: {} koa-convert@2.0.0: @@ -33009,6 +32585,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.344.0(react@19.1.0): + dependencies: + react: 19.1.0 + lucide-react@0.439.0(react@19.1.0): dependencies: react: 19.1.0 @@ -33065,7 +32645,7 @@ snapshots: md5.js@1.3.5: dependencies: - hash-base: 3.0.5 + hash-base: 3.1.2 inherits: 2.0.4 safe-buffer: 5.2.1 @@ -33937,8 +33517,6 @@ snapshots: mime@1.6.0: {} - mime@3.0.0: {} - mime@4.0.7: {} mimic-fn@1.2.0: {} @@ -33951,24 +33529,6 @@ snapshots: mini-svg-data-uri@1.4.4: {} - miniflare@4.20251011.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - acorn: 8.14.0 - acorn-walk: 8.3.2 - exit-hook: 2.2.1 - glob-to-regexp: 0.4.1 - sharp: 0.33.5 - stoppable: 1.1.0 - undici: 7.14.0 - workerd: 1.20251011.0 - ws: 8.18.0 - youch: 4.1.0-beta.10 - zod: 3.22.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -34146,9 +33706,9 @@ snapshots: - '@types/node' optional: true - msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3): + msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@24.7.1) + '@inquirer/confirm': 5.1.21(@types/node@22.19.15) '@mswjs/interceptors': 0.41.2 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 @@ -34325,6 +33885,8 @@ snapshots: dependencies: detect-libc: 2.1.2 + node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} node-mock-http@1.0.4: {} @@ -35219,6 +34781,13 @@ snapshots: punycode@2.3.1: {} + pyodide@0.28.3: + dependencies: + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + qrcode-terminal@0.11.0: {} qrcode.react@4.2.0(react@19.1.0): @@ -36029,6 +35598,20 @@ snapshots: '@secure-exec/core': 0.2.1 '@secure-exec/nodejs': 0.2.1 + secure-exec@https://pkg.pr.new/rivet-dev/secure-exec@7659aba: + dependencies: + buffer: 6.0.3 + esbuild: 0.27.3 + isolated-vm: 6.1.2 + node-stdlib-browser: 1.3.1 + pyodide: 0.28.3 + sucrase: 3.35.1 + text-encoding-utf-8: 1.0.2 + whatwg-url: 15.1.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + secure-json-parse@2.7.0: {} secure-json-parse@4.1.0: {} @@ -36424,8 +36007,6 @@ snapshots: std-env@3.9.0: {} - stoppable@1.1.0: {} - stream-browserify@3.0.0: dependencies: inherits: 2.0.4 @@ -36584,8 +36165,6 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 - supports-color@10.2.2: {} - supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -36743,6 +36322,8 @@ snapshots: transitivePeerDependencies: - react-native-b4a + text-encoding-utf-8@1.0.2: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -36838,6 +36419,10 @@ snapshots: tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -37022,37 +36607,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@24.7.1))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.3 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - '@microsoft/api-extractor': 7.53.2(@types/node@24.7.1) - '@swc/core': 1.15.11(@swc/helpers@0.5.17) - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -37229,18 +36784,8 @@ snapshots: undici@6.24.1: {} - undici@7.14.0: {} - undici@7.24.7: {} - unenv@2.0.0-rc.21: - dependencies: - defu: 6.1.4 - exsolve: 1.0.7 - ohash: 2.0.11 - pathe: 2.0.3 - ufo: 1.6.1 - unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -37644,13 +37189,13 @@ snapshots: - supports-color - terser - vite-node@3.2.4(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0): + vite-node@3.2.4(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) transitivePeerDependencies: - '@types/node' - less @@ -37730,6 +37275,11 @@ snapshots: srvx: 0.10.0 vite: 5.4.21(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite-plugin-srvx@1.0.2(srvx@0.10.0)(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)): + dependencies: + srvx: 0.10.0 + vite: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.13)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)): dependencies: debug: 4.4.3 @@ -37805,20 +37355,6 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 - vite@5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.57.1 - optionalDependencies: - '@types/node': 24.7.1 - fsevents: 2.3.3 - less: 4.4.1 - lightningcss: 1.32.0 - sass: 1.93.2 - stylus: 0.62.0 - terser: 5.46.0 - vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -38126,11 +37662,11 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -38148,12 +37684,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.21(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - vite-node: 3.2.4(@types/node@24.7.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + vite-node: 3.2.4(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.7.1 + '@types/node': 22.19.15 transitivePeerDependencies: - less - lightningcss @@ -38270,6 +37806,8 @@ snapshots: webidl-conversions@5.0.0: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack-sources@3.3.4: @@ -38320,6 +37858,11 @@ snapshots: punycode: 2.3.1 webidl-conversions: 5.0.0 + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -38358,31 +37901,6 @@ snapshots: word-wrap@1.2.5: {} - workerd@1.20251011.0: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20251011.0 - '@cloudflare/workerd-darwin-arm64': 1.20251011.0 - '@cloudflare/workerd-linux-64': 1.20251011.0 - '@cloudflare/workerd-linux-arm64': 1.20251011.0 - '@cloudflare/workerd-windows-64': 1.20251011.0 - - wrangler@4.44.0(@cloudflare/workers-types@4.20251014.0): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.4 - miniflare: 4.20251011.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.21 - workerd: 1.20251011.0 - optionalDependencies: - '@cloudflare/workers-types': 4.20251014.0 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -38426,8 +37944,6 @@ snapshots: ws@7.5.10: {} - ws@8.18.0: {} - ws@8.18.3: {} ws@8.19.0: {} @@ -38541,19 +38057,6 @@ snapshots: yoctocolors@2.1.2: {} - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.2 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.5 - '@poppinss/dumper': 0.6.4 - '@speed-highlight/core': 1.2.7 - cookie: 1.1.1 - youch-core: 0.3.3 - z-schema@5.0.5: dependencies: lodash.get: 4.4.2 @@ -38575,8 +38078,6 @@ snapshots: typescript: 5.9.3 zod: 3.25.76 - zod@3.22.3: {} - zod@3.24.4: {} zod@3.25.76: {} diff --git a/rivetkit-json-schema/registry-config.json b/rivetkit-json-schema/registry-config.json index f807f22cbb..0490551568 100644 --- a/rivetkit-json-schema/registry-config.json +++ b/rivetkit-json-schema/registry-config.json @@ -65,11 +65,11 @@ } }, "serveManager": { - "description": "Whether to start the local manager server. Auto-determined based on endpoint and NODE_ENV if not specified.", + "description": "Whether to start the local RivetKit server. Auto-determined based on endpoint and NODE_ENV if not specified.", "type": "boolean" }, "managerBasePath": { - "description": "Base path for the manager API. Default: '/'", + "description": "Base path for the local RivetKit API. Default: '/'", "type": "string" }, "managerPort": { @@ -207,4 +207,4 @@ ], "additionalProperties": false, "title": "RivetKit Registry Configuration" -} \ No newline at end of file +} diff --git a/rivetkit-python/client/README.md b/rivetkit-python/client/README.md index 043a17c5de..2d40cf70f3 100644 --- a/rivetkit-python/client/README.md +++ b/rivetkit-python/client/README.md @@ -8,7 +8,7 @@ Use this client to connect to RivetKit services from Python applications. - [Quickstart](https://rivetkit.org/introduction) - [Documentation](https://rivetkit.org/clients/python) -- [Examples](https://github.com/rivet-dev/rivetkit/tree/main/examples) +- [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) ## Getting Started @@ -25,7 +25,7 @@ from python_rivetkit_client import AsyncClient as ActorClient import asyncio async def main(): - # Create a client connected to your RivetKit manager + # Create a client connected to your RivetKit endpoint client = ActorClient("http://localhost:8080") # Connect to a chat room actor @@ -57,8 +57,8 @@ if __name__ == "__main__": - Join our [Discord](https://rivet.dev/discord) - Follow us on [X](https://x.com/rivet_gg) - Follow us on [Bluesky](https://bsky.app/profile/rivet.gg) -- File bug reports in [GitHub Issues](https://github.com/rivet-dev/rivetkit/issues) -- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-dev/rivetkit/discussions) +- File bug reports in [GitHub Issues](https://github.com/rivet-dev/rivet/issues) +- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-dev/rivet/discussions) ## License diff --git a/rivetkit-python/client/pyproject.toml b/rivetkit-python/client/pyproject.toml index f94962f51a..f07fec1d71 100644 --- a/rivetkit-python/client/pyproject.toml +++ b/rivetkit-python/client/pyproject.toml @@ -16,8 +16,8 @@ classifiers = [ dependencies = [] [project.urls] -Homepage = "https://github.com/rivet-dev/rivetkit" -Issues = "https://github.com/rivet-dev/rivetkit/issues" +Homepage = "https://github.com/rivet-dev/rivet" +Issues = "https://github.com/rivet-dev/rivet/issues" [project.optional-dependencies] tests = [ diff --git a/rivetkit-rust/packages/client/Cargo.toml b/rivetkit-rust/packages/client/Cargo.toml index 0ff9edf777..113a559a6b 100644 --- a/rivetkit-rust/packages/client/Cargo.toml +++ b/rivetkit-rust/packages/client/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" authors = ["Rivet Gaming, LLC "] license = "Apache-2.0" homepage = "https://rivetkit.org" -repository = "https://github.com/rivet-dev/rivetkit" +repository = "https://github.com/rivet-dev/rivet" [dependencies] anyhow = "1.0" diff --git a/rivetkit-rust/packages/client/README.md b/rivetkit-rust/packages/client/README.md index e17283b614..d0d709a723 100644 --- a/rivetkit-rust/packages/client/README.md +++ b/rivetkit-rust/packages/client/README.md @@ -8,7 +8,7 @@ Use this client to connect to RivetKit services from Rust applications. - [Quickstart](https://rivetkit.org/introduction) - [Documentation](https://rivetkit.org/clients/rust) -- [Examples](https://github.com/rivet-dev/rivetkit/tree/main/examples) +- [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) ## Getting Started @@ -29,7 +29,7 @@ use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Create a client connected to your RivetKit manager + // Create a client connected to your RivetKit endpoint let client = Client::new( "http://localhost:8080", TransportKind::Sse, @@ -82,8 +82,8 @@ The Rust client supports multiple encoding formats: - Join our [Discord](https://rivet.dev/discord) - Follow us on [X](https://x.com/rivet_gg) - Follow us on [Bluesky](https://bsky.app/profile/rivet.gg) -- File bug reports in [GitHub Issues](https://github.com/rivet-dev/rivetkit/issues) -- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-dev/rivetkit/discussions) +- File bug reports in [GitHub Issues](https://github.com/rivet-dev/rivet/issues) +- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-dev/rivet/discussions) ## License diff --git a/rivetkit-swift/Tests/RivetKitClientTests/TestServer.swift b/rivetkit-swift/Tests/RivetKitClientTests/TestServer.swift index 5394a922e1..d481344a64 100644 --- a/rivetkit-swift/Tests/RivetKitClientTests/TestServer.swift +++ b/rivetkit-swift/Tests/RivetKitClientTests/TestServer.swift @@ -41,13 +41,13 @@ actor TestServer { return info } let repoRoot = try resolveRepoRoot() - let distPath = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/dist/tsup/serve-test-suite/mod.js") - try await ensureBuildArtifacts(in: repoRoot, serveTestSuitePath: distPath) + let scriptPath = repoRoot.appendingPathComponent("examples/sandbox/src/swift-test-server.ts") + try await ensureBuildArtifacts(in: repoRoot, scriptPath: scriptPath) let process = Process() - process.currentDirectoryURL = repoRoot + process.currentDirectoryURL = repoRoot.appendingPathComponent("examples/sandbox") process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["node", distPath.path] + process.arguments = ["pnpm", "exec", "tsx", "src/swift-test-server.ts"] let outputPipe = Pipe() process.standardOutput = outputPipe @@ -139,7 +139,7 @@ actor TestServer { } } - private func ensureBuildArtifacts(in repoRoot: URL, serveTestSuitePath: URL) async throws { + private func ensureBuildArtifacts(in repoRoot: URL, scriptPath: URL) async throws { let nodeModules = repoRoot.appendingPathComponent("node_modules") if !FileManager.default.fileExists(atPath: nodeModules.path) { try await runProcess( @@ -172,11 +172,13 @@ actor TestServer { ) } - let serveTestSuiteSource = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/src/serve-test-suite/mod.ts") - let registrySource = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts") - let rejectSource = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/reject-connection.ts") - let connStateSource = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-state.ts") - if isArtifactStale(artifact: serveTestSuitePath, sources: [serveTestSuiteSource, registrySource, rejectSource, connStateSource]) { + let rivetkitDist = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/dist/tsup/mod.js") + let rivetkitSourceDir = repoRoot.appendingPathComponent("rivetkit-typescript/packages/rivetkit/src") + if isArtifactStale( + artifact: rivetkitDist, + sources: [scriptPath], + sourceDirectories: [rivetkitSourceDir] + ) { try await runProcess( in: repoRoot, command: "/usr/bin/env", @@ -190,7 +192,11 @@ actor TestServer { } } - private func isArtifactStale(artifact: URL, sources: [URL]) -> Bool { + private func isArtifactStale( + artifact: URL, + sources: [URL], + sourceDirectories: [URL] = [] + ) -> Bool { guard FileManager.default.fileExists(atPath: artifact.path) else { return true } @@ -205,6 +211,42 @@ actor TestServer { return true } } + for sourceDirectory in sourceDirectories { + if let latestSourceDate = latestModificationDate(in: sourceDirectory), artifactDate < latestSourceDate { + return true + } + } return false } + + private func latestModificationDate(in directory: URL) -> Date? { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var latestDate: Date? + for case let fileURL as URL in enumerator { + guard + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), + values.isRegularFile == true, + let modificationDate = values.contentModificationDate + else { + continue + } + + if let currentLatestDate = latestDate { + if modificationDate > currentLatestDate { + latestDate = modificationDate + } + } else { + latestDate = modificationDate + } + } + + return latestDate + } } diff --git a/rivetkit-typescript/CLAUDE.md b/rivetkit-typescript/CLAUDE.md index 7893d36453..aa7e54d117 100644 --- a/rivetkit-typescript/CLAUDE.md +++ b/rivetkit-typescript/CLAUDE.md @@ -16,21 +16,21 @@ ## Gateway Targets -For client-facing gateway operations on `ManagerDriver`, use the shared `GatewayTarget` type from `packages/rivetkit/src/manager/driver.ts` instead of ad hoc `string | ActorQuery` unions. Drivers should preserve direct actor ID behavior and resolve `ActorQuery` targets inside the driver implementation so higher-level client flows can widen their target type without duplicating query-resolution logic. +For client-facing gateway operations, use the shared `GatewayTarget` type from `packages/rivetkit/src/engine-client/driver.ts` instead of ad hoc `string | ActorQuery` unions. The engine control client should preserve direct actor ID behavior and resolve `ActorQuery` targets inside the client implementation so higher-level client flows can widen their target type without duplicating query-resolution logic. Query-backed remote gateway URLs use `rvt-*` query parameters: `/gateway/{name}/{path}?rvt-namespace=...&rvt-method=...&rvt-key=...`. The actor name is a clean path segment, and all routing params are standard query parameters with the `rvt-` prefix. The known `rvt-*` params are: `rvt-namespace`, `rvt-method`, `rvt-runner`, `rvt-key`, `rvt-input`, `rvt-region`, `rvt-crash-policy`, `rvt-token`. `rvt-runner` is required for `getOrCreate` and disallowed for `get`. For multi-component keys, use a single comma-separated `rvt-key` param (e.g. `rvt-key=tenant,room`). Use `URLSearchParams` to build and parse query strings. -In `packages/rivetkit/src/drivers/file-system/manager.ts`, keep `buildGatewayUrl()` query-backed for `get()` and `getOrCreate()` handles instead of pre-resolving to an actor ID. Local `getGatewayUrl()` flows should exercise the shared `actorGateway` query param parser on the served manager path, while direct actor ID targets still use `/gateway/{actorId}`. +Keep `buildGatewayUrl()` query-backed for `get()` and `getOrCreate()` handles instead of pre-resolving to an actor ID. Local `getGatewayUrl()` flows should exercise the shared `actorGateway` query param parser on the served runtime router path, while direct actor ID targets still use `/gateway/{actorId}`. -When parsing query gateway paths in `packages/rivetkit/src/manager/gateway.ts` or in parity implementations, detect query paths by checking if any query parameter starts with `rvt-`. Use `URLSearchParams` to parse query params. Partition into `rvt-*` params and actor params. Reject raw `@token` syntax, unknown `rvt-*` params, and duplicate scalar `rvt-*` params. Strip all `rvt-*` params from the query string before forwarding to the actor by reconstructing the query string from only the actor params. +When parsing query gateway paths in `packages/rivetkit/src/actor-gateway/gateway.ts` or in parity implementations, detect query paths by checking if any query parameter starts with `rvt-`. Use `URLSearchParams` to parse query params. Partition into `rvt-*` params and actor params. Reject raw `@token` syntax, unknown `rvt-*` params, and duplicate scalar `rvt-*` params. Strip all `rvt-*` params from the query string before forwarding to the actor by reconstructing the query string from only the actor params. -Once a query path has been parsed in `packages/rivetkit/src/manager/gateway.ts`, resolve it to an actor ID inside the shared path-based HTTP and WebSocket gateway helpers before calling `proxyRequest` or `proxyWebSocket`. After resolution, reuse the existing direct-ID proxy flow and preserve the original remaining path with `rvt-*` params stripped. +Once a query path has been parsed in `packages/rivetkit/src/actor-gateway/gateway.ts`, resolve it to an actor ID inside the shared path-based HTTP and WebSocket gateway helpers before calling `proxyRequest` or `proxyWebSocket`. After resolution, reuse the existing direct-ID proxy flow and preserve the original remaining path with `rvt-*` params stripped. -When adding or validating query input payloads in `packages/rivetkit/src/remote-manager-driver/actor-websocket-client.ts`, enforce `ClientConfig.maxInputSize` against the raw CBOR byte length before base64url encoding. This keeps the limit aligned with the actual serialized payload instead of the encoded URL expansion. +When adding or validating query input payloads in `packages/rivetkit/src/engine-client/actor-websocket-client.ts`, enforce `ClientConfig.maxInputSize` against the raw CBOR byte length before base64url encoding. This keeps the limit aligned with the actual serialized payload instead of the encoded URL expansion. For `ClientRaw.get()` and `ClientRaw.getOrCreate()` flows, do not cache a resolved actor ID on `ActorResolutionState`. Key-based handles and connections should resolve fresh for each operation so they do not stay pinned to an older actor selection after a destroy or recreate. -For gateway-facing client helpers in `packages/rivetkit/src/client`, derive the `ManagerDriver` target from `getGatewayTarget()` instead of calling `resolveActorId()` up front. `get()` and `getOrCreate()` handles must pass their `ActorQuery` through to `sendRequest`, `openWebSocket`, and `buildGatewayUrl` so each request and reconnect re-resolves at the gateway. Only `getForId()` and create-backed handles should collapse to a plain actor ID target. +For gateway-facing client helpers in `packages/rivetkit/src/client`, derive the `EngineControlClient` target from `getGatewayTarget()` instead of calling `resolveActorId()` up front. `get()` and `getOrCreate()` handles must pass their `ActorQuery` through to `sendRequest`, `openWebSocket`, and `buildGatewayUrl` so each request and reconnect re-resolves at the gateway. Only `getForId()` and create-backed handles should collapse to a plain actor ID target. ## Raw KV Limits diff --git a/rivetkit-typescript/artifacts/registry-config.json b/rivetkit-typescript/artifacts/registry-config.json index f807f22cbb..0490551568 100644 --- a/rivetkit-typescript/artifacts/registry-config.json +++ b/rivetkit-typescript/artifacts/registry-config.json @@ -65,11 +65,11 @@ } }, "serveManager": { - "description": "Whether to start the local manager server. Auto-determined based on endpoint and NODE_ENV if not specified.", + "description": "Whether to start the local RivetKit server. Auto-determined based on endpoint and NODE_ENV if not specified.", "type": "boolean" }, "managerBasePath": { - "description": "Base path for the manager API. Default: '/'", + "description": "Base path for the local RivetKit API. Default: '/'", "type": "string" }, "managerPort": { @@ -207,4 +207,4 @@ ], "additionalProperties": false, "title": "RivetKit Registry Configuration" -} \ No newline at end of file +} diff --git a/rivetkit-typescript/packages/cloudflare-workers/README.md b/rivetkit-typescript/packages/cloudflare-workers/README.md deleted file mode 100644 index 73c2a238b5..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# RivetKit Cloudflare Workers Adapter - -_Library to build and scale stateful workloads_ - -[Learn More →](https://github.com/rivet-dev/rivetkit) - -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) - -## License - -Apache 2.0 diff --git a/rivetkit-typescript/packages/cloudflare-workers/package.json b/rivetkit-typescript/packages/cloudflare-workers/package.json deleted file mode 100644 index 94d42e5568..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@rivetkit/cloudflare-workers", - "version": "2.2.1", - "description": "Cloudflare Workers adapter for RivetKit actors", - "keywords": [ - "rivetkit", - "cloudflare", - "actors", - "edge", - "platform", - "serverless" - ], - "files": [ - "src", - "dist", - "package.json" - ], - "type": "module", - "exports": { - ".": { - "import": { - "types": "./dist/mod.d.ts", - "default": "./dist/mod.js" - }, - "require": { - "types": "./dist/mod.d.cts", - "default": "./dist/mod.cjs" - } - }, - "./tsconfig": "./dist/tsconfig.json" - }, - "sideEffects": [ - "./dist/chunk-*.js", - "./dist/chunk-*.cjs" - ], - "scripts": { - "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit", - "test": "vitest run tests" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250129.0", - "@types/invariant": "^2", - "@types/node": "^24.0.3", - "tsup": "^8.4.0", - "typescript": "^5.5.2", - "vitest": "^3.1.1", - "wrangler": "^4.22.0" - }, - "dependencies": { - "invariant": "^2.2.4", - "zod": "^4.1.0", - "hono": "^4.7.0", - "rivetkit": "workspace:*" - }, - "stableVersion": "0.8.0" -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/actor-driver.ts b/rivetkit-typescript/packages/cloudflare-workers/src/actor-driver.ts deleted file mode 100644 index 58267e243d..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/actor-driver.ts +++ /dev/null @@ -1,373 +0,0 @@ -import invariant from "invariant"; -import type { - ActorKey, - ActorRouter, - AnyActorInstance as CoreAnyActorInstance, - RegistryConfig, -} from "rivetkit"; -import { lookupInRegistry } from "rivetkit"; -import type { - ActorDriver, - AnyActorInstance, - ManagerDriver, -} from "rivetkit/driver-helpers"; -import { promiseWithResolvers } from "rivetkit/utils"; -import { logger } from "./log"; -import { - kvDelete, - kvDeleteRange, - kvGet, - kvListPrefix, - kvListRange, - kvPut, -} from "./actor-kv"; -import { GLOBAL_KV_KEYS } from "./global-kv"; -import { getCloudflareAmbientEnv } from "./handler"; -import { parseActorId } from "./actor-id"; - -interface DurableObjectGlobalState { - ctx: DurableObjectState; - env: unknown; -} - -/** - * Cloudflare DO can have multiple DO running within the same global scope. - * - * This allows for storing the actor context globally and looking it up by ID in `CloudflareActorsActorDriver`. - */ -export class CloudflareDurableObjectGlobalState { - // Map of actor ID -> DO state - #dos: Map = new Map(); - - // WeakMap of DO state -> ActorGlobalState for proper GC - #actors: WeakMap = new WeakMap(); - - getDOState(doId: string): DurableObjectGlobalState { - const state = this.#dos.get(doId); - invariant( - state !== undefined, - "durable object state not in global state", - ); - return state; - } - - setDOState(doId: string, state: DurableObjectGlobalState) { - this.#dos.set(doId, state); - } - - getActorState(ctx: DurableObjectState): ActorGlobalState | undefined { - return this.#actors.get(ctx); - } - - setActorState(ctx: DurableObjectState, actorState: ActorGlobalState): void { - this.#actors.set(ctx, actorState); - } -} - -export interface DriverContext { - state: DurableObjectState; -} - -interface InitializedData { - name: string; - key: ActorKey; - generation: number; -} - -interface LoadedActor { - actorRouter: ActorRouter; - actorDriver: ActorDriver; - generation: number; -} - -// Actor global state to track running instances -export class ActorGlobalState { - // Initialization state - initialized?: InitializedData; - - // Loaded actor state - actor?: LoadedActor; - actorInstance?: AnyActorInstance; - actorPromise?: ReturnType>; - - /** - * Indicates if `startDestroy` has been called. - * - * This is stored in memory instead of SQLite since the destroy may be cancelled. - * - * See the corresponding `destroyed` property in SQLite metadata. - */ - destroying: boolean = false; - - reset() { - this.initialized = undefined; - this.actor = undefined; - this.actorInstance = undefined; - this.actorPromise = undefined; - this.destroying = false; - } -} - -export class CloudflareActorsActorDriver implements ActorDriver { - #registryConfig: RegistryConfig; - #managerDriver: ManagerDriver; - #inlineClient: any; - #globalState: CloudflareDurableObjectGlobalState; - - constructor( - registryConfig: RegistryConfig, - managerDriver: ManagerDriver, - inlineClient: any, - globalState: CloudflareDurableObjectGlobalState, - ) { - this.#registryConfig = registryConfig; - this.#managerDriver = managerDriver; - this.#inlineClient = inlineClient; - this.#globalState = globalState; - } - - #getDOCtx(actorId: string) { - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - return this.#globalState.getDOState(doId).ctx; - } - - async loadActor(actorId: string): Promise { - // Parse actor ID to get DO ID and generation - const [doId, expectedGeneration] = parseActorId(actorId); - - // Get the DO state - const doState = this.#globalState.getDOState(doId); - - // Check if actor is already loaded - let actorState = this.#globalState.getActorState(doState.ctx); - if (actorState?.actorInstance) { - // Actor is already loaded, return it - return actorState.actorInstance; - } - - // Create new actor state if it doesn't exist - if (!actorState) { - actorState = new ActorGlobalState(); - actorState.actorPromise = promiseWithResolvers((reason) => - logger().warn({ - msg: "unhandled actor promise rejection", - reason, - }), - ); - this.#globalState.setActorState(doState.ctx, actorState); - } else if (actorState.actorPromise) { - // Another request is already loading this actor, wait for it - await actorState.actorPromise.promise; - if (!actorState.actorInstance) { - throw new Error( - `Actor ${actorId} failed to load in concurrent request`, - ); - } - return actorState.actorInstance; - } - - // Load actor metadata - const sql = doState.ctx.storage.sql; - const cursor = sql.exec( - "SELECT name, key, destroyed, generation FROM _rivetkit_metadata LIMIT 1", - ); - const result = cursor.raw().next(); - - if (result.done || !result.value) { - throw new Error( - `Actor ${actorId} is not initialized - missing metadata`, - ); - } - - const name = result.value[0] as string; - const key = JSON.parse(result.value[1] as string) as string[]; - const destroyed = result.value[2] as number; - const generation = result.value[3] as number; - - // Check if actor is destroyed - if (destroyed) { - throw new Error(`Actor ${actorId} is destroyed`); - } - - // Check if generation matches - if (generation !== expectedGeneration) { - throw new Error( - `Actor ${actorId} generation mismatch - expected ${expectedGeneration}, got ${generation}`, - ); - } - - // Create actor instance - const definition = lookupInRegistry(this.#registryConfig, name); - actorState.actorInstance = definition.instantiate(); - - // Start actor - const actorInstance = actorState.actorInstance as any; - await actorInstance.start( - this, - this.#inlineClient, - actorId, - name, - key, - "unknown", // TODO: Support regions in Cloudflare - ); - - // Finish - actorState.actorPromise?.resolve(); - actorState.actorPromise = undefined; - - return actorState.actorInstance; - } - - getContext(actorId: string): DriverContext { - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - const state = this.#globalState.getDOState(doId); - return { state: state.ctx }; - } - - async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { - await this.#getDOCtx(actor.id).storage.setAlarm(timestamp); - } - - async getDatabase(actorId: string): Promise { - return this.#getDOCtx(actorId).storage.sql; - } - - // Batch KV operations - async kvBatchPut( - actorId: string, - entries: [Uint8Array, Uint8Array][], - ): Promise { - const sql = this.#getDOCtx(actorId).storage.sql; - - for (const [key, value] of entries) { - kvPut(sql, key, value); - } - } - - async kvBatchGet( - actorId: string, - keys: Uint8Array[], - ): Promise<(Uint8Array | null)[]> { - const sql = this.#getDOCtx(actorId).storage.sql; - - const results: (Uint8Array | null)[] = []; - for (const key of keys) { - results.push(kvGet(sql, key)); - } - - return results; - } - - async kvBatchDelete(actorId: string, keys: Uint8Array[]): Promise { - const sql = this.#getDOCtx(actorId).storage.sql; - - for (const key of keys) { - kvDelete(sql, key); - } - } - - async kvDeleteRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - ): Promise { - const sql = this.#getDOCtx(actorId).storage.sql; - kvDeleteRange(sql, start, end); - } - - async kvListPrefix( - actorId: string, - prefix: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - const sql = this.#getDOCtx(actorId).storage.sql; - - return kvListPrefix(sql, prefix, options); - } - - async kvListRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - const sql = this.#getDOCtx(actorId).storage.sql; - return kvListRange(sql, start, end, options); - } - - startDestroy(actorId: string): void { - // Parse actor ID to get DO ID and generation - const [doId, generation] = parseActorId(actorId); - - // Get the DO state - const doState = this.#globalState.getDOState(doId); - const actorState = this.#globalState.getActorState(doState.ctx); - - // Actor not loaded, nothing to destroy - if (!actorState?.actorInstance) { - return; - } - - // Check if already destroying - if (actorState.destroying) { - return; - } - actorState.destroying = true; - - // Spawn onStop in background - this.#callOnStopAsync(actorId, doId, actorState.actorInstance); - } - - async #callOnStopAsync( - actorId: string, - doId: string, - actor: CoreAnyActorInstance, - ) { - // Stop - await actor.onStop("destroy"); - - // Remove state - const doState = this.#globalState.getDOState(doId); - const sql = doState.ctx.storage.sql; - sql.exec("UPDATE _rivetkit_metadata SET destroyed = 1 WHERE 1=1"); - sql.exec("DELETE FROM _rivetkit_kv_storage"); - - // Clear any scheduled alarms - await doState.ctx.storage.deleteAlarm(); - - // Delete from ACTOR_KV in the background - use full actorId including generation - const env = getCloudflareAmbientEnv(); - doState.ctx.waitUntil( - env.ACTOR_KV.delete(GLOBAL_KV_KEYS.actorMetadata(actorId)), - ); - - // Reset global state using the DO context - const actorHandle = this.#globalState.getActorState(doState.ctx); - actorHandle?.reset(); - } -} - -export function createCloudflareActorsActorDriverBuilder( - globalState: CloudflareDurableObjectGlobalState, -) { - return ( - config: RegistryConfig, - managerDriver: ManagerDriver, - inlineClient: any, - ) => { - return new CloudflareActorsActorDriver( - config, - managerDriver, - inlineClient, - globalState, - ); - }; -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/actor-handler-do.ts b/rivetkit-typescript/packages/cloudflare-workers/src/actor-handler-do.ts deleted file mode 100644 index aac687f2ec..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/actor-handler-do.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { DurableObject, env } from "cloudflare:workers"; -import type { ExecutionContext } from "hono"; -import invariant from "invariant"; -import type { ActorKey, ActorRouter, Registry, RegistryConfig } from "rivetkit"; -import { createActorRouter, createClientWithDriver } from "rivetkit"; -import type { ActorDriver, ManagerDriver } from "rivetkit/driver-helpers"; -import { getInitialActorKvState } from "rivetkit/driver-helpers"; -import type { GetUpgradeWebSocket } from "rivetkit/utils"; -import { stringifyError } from "rivetkit/utils"; -import { - ActorGlobalState, - CloudflareDurableObjectGlobalState, - createCloudflareActorsActorDriverBuilder, -} from "./actor-driver"; -import { buildActorId, parseActorId } from "./actor-id"; -import { kvDelete, kvDeleteRange, kvGet, kvPut } from "./actor-kv"; -import { GLOBAL_KV_KEYS } from "./global-kv"; -import type { Bindings } from "./handler"; -import { getCloudflareAmbientEnv } from "./handler"; -import { logger } from "./log"; -import { CloudflareActorsManagerDriver } from "./manager-driver"; - -export interface ActorHandlerInterface extends DurableObject { - create(req: ActorInitRequest): Promise; - getMetadata(): Promise< - | { - actorId: string; - name: string; - key: ActorKey; - destroying: boolean; - } - | undefined - >; - managerKvGet(key: Uint8Array): Promise; - managerKvBatchGet(keys: Uint8Array[]): Promise<(Uint8Array | null)[]>; - managerKvBatchPut(entries: [Uint8Array, Uint8Array][]): Promise; - managerKvBatchDelete(keys: Uint8Array[]): Promise; - managerKvDeleteRange(start: Uint8Array, end: Uint8Array): Promise; -} - -export interface ActorInitRequest { - name: string; - key: ActorKey; - input?: unknown; - allowExisting: boolean; -} -export type ActorInitResponse = - | { success: { actorId: string; created: boolean } } - | { error: { actorAlreadyExists: true } }; - -export type DurableObjectConstructor = new ( - ...args: ConstructorParameters> -) => DurableObject; - -export function createActorDurableObject( - registry: Registry, - getUpgradeWebSocket: GetUpgradeWebSocket, -): DurableObjectConstructor { - const globalState = new CloudflareDurableObjectGlobalState(); - const parsedConfig = registry.parseConfig(); - - /** - * Startup steps: - * 1. If not already created call `initialize`, otherwise check KV to ensure it's initialized - * 2. Load actor - * 3. Start service requests - */ - return class ActorHandler - extends DurableObject - implements ActorHandlerInterface - { - /** - * This holds a strong reference to ActorGlobalState. - * CloudflareDurableObjectGlobalState holds a weak reference so we can - * access it elsewhere. - **/ - #state: ActorGlobalState; - - constructor( - ...args: ConstructorParameters> - ) { - super(...args); - - // Initialize SQL table for key-value storage - // - // We do this instead of using the native KV storage so we can store blob keys. The native CF KV API only supports string keys. - this.ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS _rivetkit_kv_storage( - key BLOB PRIMARY KEY, - value BLOB - ); - `); - - // Initialize SQL table for actor metadata - // - // id always equals 1 in order to ensure that there's always exactly 1 row in this table - this.ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS _rivetkit_metadata( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT NOT NULL, - key TEXT NOT NULL, - destroyed INTEGER DEFAULT 0, - generation INTEGER DEFAULT 0 - ); - `); - - // Get or create the actor state from the global WeakMap - const state = globalState.getActorState(this.ctx); - if (state) { - this.#state = state; - } else { - this.#state = new ActorGlobalState(); - globalState.setActorState(this.ctx, this.#state); - } - } - - async #loadActor() { - invariant(this.#state, "State should be initialized"); - - // Check if initialized - if (!this.#state.initialized) { - // Query SQL for initialization data - const cursor = this.ctx.storage.sql.exec( - "SELECT name, key, destroyed, generation FROM _rivetkit_metadata WHERE id = 1", - ); - const result = cursor.raw().next(); - - if (!result.done && result.value) { - const name = result.value[0] as string; - const key = JSON.parse( - result.value[1] as string, - ) as ActorKey; - const destroyed = result.value[2] as number; - const generation = result.value[3] as number; - - // Only initialize if not destroyed - if (!destroyed) { - logger().debug({ - msg: "already initialized", - name, - key, - generation, - }); - - this.#state.initialized = { name, key, generation }; - } else { - logger().debug("actor is destroyed, cannot load"); - throw new Error("Actor is destroyed"); - } - } else { - logger().debug("not initialized"); - throw new Error("Actor is not initialized"); - } - } - - // Check if already loaded - if (this.#state.actor) { - // Assert that the cached actor has the correct generation - // This will catch any cases where #state.actor has a stale generation - invariant( - !this.#state.initialized || - this.#state.actor.generation === - this.#state.initialized.generation, - `Stale actor cached: actor generation ${this.#state.actor.generation} != initialized generation ${this.#state.initialized?.generation}. This should not happen.`, - ); - return this.#state.actor; - } - - if (!this.#state.initialized) throw new Error("Not initialized"); - - // Register DO with global state first - // HACK: This leaks the DO context, but DO does not provide a native way - // of knowing when the DO shuts down. We're making a broad assumption - // that DO will boot a new isolate frequenlty enough that this is not an issue. - const actorId = this.ctx.id.toString(); - globalState.setDOState(actorId, { ctx: this.ctx, env: env }); - - // Create manager driver - const managerDriver = new CloudflareActorsManagerDriver(); - - // Create inline client - // Avoid expensive type expansion in downstream DTS generation. - const inlineClient: any = (createClientWithDriver as any)( - managerDriver, - ); - - // Create actor driver builder - const actorDriverBuilder = - createCloudflareActorsActorDriverBuilder(globalState); - - // Create actor driver - const actorDriver = actorDriverBuilder( - parsedConfig, - managerDriver, - inlineClient, - ); - - // Create actor router - const actorRouter = createActorRouter( - parsedConfig, - actorDriver, - getUpgradeWebSocket, - registry.config.test?.enabled ?? false, - ); - - // Save actor with generation - this.#state.actor = { - actorRouter, - actorDriver, - generation: this.#state.initialized.generation, - }; - - // Build actor ID with generation for loading - const actorIdWithGen = buildActorId( - actorId, - this.#state.initialized.generation, - ); - - // Initialize the actor instance with proper metadata - // This ensures the actor driver knows about this actor - await actorDriver.loadActor(actorIdWithGen); - - return this.#state.actor; - } - - /** RPC called to get actor metadata without creating it */ - async getMetadata(): Promise< - | { - actorId: string; - name: string; - key: ActorKey; - destroying: boolean; - } - | undefined - > { - // Query the metadata - const cursor = this.ctx.storage.sql.exec( - "SELECT name, key, destroyed, generation FROM _rivetkit_metadata WHERE id = 1", - ); - const result = cursor.raw().next(); - - if (!result.done && result.value) { - const name = result.value[0] as string; - const key = JSON.parse(result.value[1] as string) as ActorKey; - const destroyed = result.value[2] as number; - const generation = result.value[3] as number; - - // Check if destroyed - if (destroyed) { - logger().debug({ - msg: "getMetadata: actor is destroyed", - name, - key, - generation, - }); - return undefined; - } - - // Build actor ID with generation - const doId = this.ctx.id.toString(); - const actorId = buildActorId(doId, generation); - const destroying = - globalState.getActorState(this.ctx)?.destroying ?? false; - - logger().debug({ - msg: "getMetadata: found actor metadata", - actorId, - name, - key, - generation, - destroying, - }); - - return { actorId, name, key, destroying }; - } - - logger().debug({ - msg: "getMetadata: no metadata found", - }); - return undefined; - } - - /** RPC called by ManagerDriver.kvGet to read from KV. */ - async managerKvGet(key: Uint8Array): Promise { - return kvGet(this.ctx.storage.sql, key); - } - - /** RPC called by ManagerDriver.kvBatchGet to read multiple keys from KV. */ - async managerKvBatchGet(keys: Uint8Array[]): Promise<(Uint8Array | null)[]> { - const sql = this.ctx.storage.sql; - return keys.map((key) => kvGet(sql, key)); - } - - /** RPC called by ManagerDriver.kvBatchPut to write multiple entries to KV. */ - async managerKvBatchPut(entries: [Uint8Array, Uint8Array][]): Promise { - const sql = this.ctx.storage.sql; - for (const [key, value] of entries) { - kvPut(sql, key, value); - } - } - - /** RPC called by ManagerDriver.kvBatchDelete to delete multiple keys from KV. */ - async managerKvBatchDelete(keys: Uint8Array[]): Promise { - const sql = this.ctx.storage.sql; - for (const key of keys) { - kvDelete(sql, key); - } - } - - /** RPC called by ManagerDriver.kvDeleteRange to delete a key range from KV. */ - async managerKvDeleteRange(start: Uint8Array, end: Uint8Array): Promise { - kvDeleteRange(this.ctx.storage.sql, start, end); - } - - /** RPC called by the manager to create a DO. Can optionally allow existing actors. */ - async create(req: ActorInitRequest): Promise { - // Check if actor exists - const checkCursor = this.ctx.storage.sql.exec( - "SELECT destroyed, generation FROM _rivetkit_metadata WHERE id = 1", - ); - const checkResult = checkCursor.raw().next(); - - let created = false; - let generation = 0; - - if (!checkResult.done && checkResult.value) { - const destroyed = checkResult.value[0] as number; - generation = checkResult.value[1] as number; - - if (!destroyed) { - // Actor exists and is not destroyed - if (!req.allowExisting) { - // Fail if not allowing existing actors - logger().debug({ - msg: "create failed: actor already exists", - name: req.name, - key: req.key, - generation, - }); - return { error: { actorAlreadyExists: true } }; - } - - // Return existing actor - logger().debug({ - msg: "actor already exists", - key: req.key, - generation, - }); - const doId = this.ctx.id.toString(); - const actorId = buildActorId(doId, generation); - return { success: { actorId, created: false } }; - } - - // Actor exists but is destroyed - resurrect with incremented generation - generation = generation + 1; - created = true; - - // Clear stale actor from previous generation - // This is necessary because the DO instance may still be in memory - // with the old #state.actor field from before the destroy - if (this.#state) { - this.#state.actor = undefined; - } - - logger().debug({ - msg: "resurrecting destroyed actor", - key: req.key, - oldGeneration: generation - 1, - newGeneration: generation, - }); - } else { - // No actor exists - will create with generation 0 - generation = 0; - created = true; - logger().debug({ - msg: "creating new actor", - key: req.key, - generation, - }); - } - - // Perform upsert - either inserts new or updates destroyed actor - this.ctx.storage.sql.exec( - `INSERT INTO _rivetkit_metadata (id, name, key, destroyed, generation) - VALUES (1, ?, ?, 0, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - key = excluded.key, - destroyed = 0, - generation = excluded.generation`, - req.name, - JSON.stringify(req.key), - generation, - ); - - this.#state.initialized = { - name: req.name, - key: req.key, - generation, - }; - - // Build actor ID with generation - const doId = this.ctx.id.toString(); - const actorId = buildActorId(doId, generation); - - // Initialize storage and update KV when created or resurrected - if (created) { - // Initialize persist data in KV storage - initializeActorKvStorage(this.ctx.storage.sql, req.input); - - // Update metadata in the background - const env = getCloudflareAmbientEnv(); - const actorData = { name: req.name, key: req.key, generation }; - this.ctx.waitUntil( - env.ACTOR_KV.put( - GLOBAL_KV_KEYS.actorMetadata(actorId), - JSON.stringify(actorData), - ), - ); - } - - // Preemptively load actor so the lifecycle hooks are called - await this.#loadActor(); - - logger().debug({ - msg: created - ? "actor created/resurrected" - : "returning existing actor", - actorId, - created, - generation, - }); - - return { success: { actorId, created } }; - } - - async fetch(request: Request): Promise { - const { actorRouter, generation } = await this.#loadActor(); - - // Build actor ID with generation - const doId = this.ctx.id.toString(); - const actorId = buildActorId(doId, generation); - - return await actorRouter.fetch(request, { - actorId, - }); - } - - async alarm(): Promise { - const { actorDriver, generation } = await this.#loadActor(); - - // Build actor ID with generation - const doId = this.ctx.id.toString(); - const actorId = buildActorId(doId, generation); - - // Load the actor instance and trigger alarm - const actor = await actorDriver.loadActor(actorId); - await actor.onAlarm(); - } - }; -} - -function initializeActorKvStorage( - sql: SqlStorage, - input: unknown | undefined, -): void { - const initialKvState = getInitialActorKvState(input); - for (const [key, value] of initialKvState) { - kvPut(sql, key, value); - } -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/actor-id.ts b/rivetkit-typescript/packages/cloudflare-workers/src/actor-id.ts deleted file mode 100644 index a4e850f48f..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/actor-id.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Actor ID utilities for managing actor IDs with generation tracking. - * - * Actor IDs are formatted as: `{doId}:{generation}` - * This allows tracking actor resurrection and preventing stale references. - */ - -/** - * Build an actor ID from a Durable Object ID and generation number. - * @param doId The Durable Object ID - * @param generation The generation number (increments on resurrection) - * @returns The formatted actor ID - */ -export function buildActorId(doId: string, generation: number): string { - return `${doId}:${generation}`; -} - -/** - * Parse an actor ID into its components. - * @param actorId The actor ID to parse - * @returns A tuple of [doId, generation] - * @throws Error if the actor ID format is invalid - */ -export function parseActorId(actorId: string): [string, number] { - const parts = actorId.split(":"); - if (parts.length !== 2) { - throw new Error(`Invalid actor ID format: ${actorId}`); - } - - const [doId, generationStr] = parts; - const generation = parseInt(generationStr, 10); - - if (Number.isNaN(generation)) { - throw new Error(`Invalid generation number in actor ID: ${actorId}`); - } - - return [doId, generation]; -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/actor-kv.ts b/rivetkit-typescript/packages/cloudflare-workers/src/actor-kv.ts deleted file mode 100644 index 40714d411d..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/actor-kv.ts +++ /dev/null @@ -1,120 +0,0 @@ -const DEFAULT_LIST_LIMIT = 16_384; - -export function kvGet(sql: SqlStorage, key: Uint8Array): Uint8Array | null { - const cursor = sql.exec( - "SELECT value FROM _rivetkit_kv_storage WHERE key = ?", - key, - ); - const result = cursor.raw().next(); - - if (!result.done && result.value) { - return toUint8Array(result.value[0]); - } - return null; -} - -export function kvPut( - sql: SqlStorage, - key: Uint8Array, - value: Uint8Array, -): void { - sql.exec( - "INSERT OR REPLACE INTO _rivetkit_kv_storage (key, value) VALUES (?, ?)", - key, - value, - ); -} - -export function kvDelete(sql: SqlStorage, key: Uint8Array): void { - sql.exec("DELETE FROM _rivetkit_kv_storage WHERE key = ?", key); -} - -export function kvDeleteRange( - sql: SqlStorage, - start: Uint8Array, - end: Uint8Array, -): void { - sql.exec( - "DELETE FROM _rivetkit_kv_storage WHERE key >= ? AND key < ?", - start, - end, - ); -} - -export function kvListPrefix( - sql: SqlStorage, - prefix: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, -): [Uint8Array, Uint8Array][] { - const upperBound = computePrefixUpperBound(prefix); - if (upperBound) { - return kvListRange(sql, prefix, upperBound, options); - } - - const direction = options?.reverse ? "DESC" : "ASC"; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; - const cursor = sql.exec( - `SELECT key, value FROM _rivetkit_kv_storage WHERE key >= ? ORDER BY key ${direction} LIMIT ?`, - prefix, - limit, - ); - return readEntries(cursor); -} - -export function kvListRange( - sql: SqlStorage, - start: Uint8Array, - end: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, -): [Uint8Array, Uint8Array][] { - const direction = options?.reverse ? "DESC" : "ASC"; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; - const cursor = sql.exec( - `SELECT key, value FROM _rivetkit_kv_storage WHERE key >= ? AND key < ? ORDER BY key ${direction} LIMIT ?`, - start, - end, - limit, - ); - return readEntries(cursor); -} - -function toUint8Array( - value: string | number | ArrayBuffer | Uint8Array | null, -): Uint8Array { - if (value instanceof Uint8Array) { - return value; - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } - throw new Error( - `Unexpected SQL value type: ${typeof value} (${value?.constructor?.name})`, - ); -} - -function readEntries( - cursor: ReturnType, -): [Uint8Array, Uint8Array][] { - const entries: [Uint8Array, Uint8Array][] = []; - for (const row of cursor.raw()) { - entries.push([toUint8Array(row[0]), toUint8Array(row[1])]); - } - return entries; -} - -function computePrefixUpperBound(prefix: Uint8Array): Uint8Array | null { - const upperBound = prefix.slice(); - for (let i = upperBound.length - 1; i >= 0; i--) { - if (upperBound[i] !== 0xff) { - upperBound[i]++; - return upperBound.slice(0, i + 1); - } - } - return null; -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/config.ts b/rivetkit-typescript/packages/cloudflare-workers/src/config.ts deleted file mode 100644 index d00a359f9e..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Client } from "rivetkit"; -import { z } from "zod/v4"; - -const ConfigSchemaBase = z.object({ - /** Path that the Rivet manager API will be mounted. */ - managerPath: z.string().optional().default("/api/rivet"), - - /** Deprecated. Envoy key for authentication. */ - envoyKey: z.string().optional(), - - /** Disable the welcome message. */ - noWelcome: z.boolean().optional().default(false), - - fetch: z - .custom }, unknown>>() - .optional(), -}); -export const ConfigSchema = ConfigSchemaBase.default(() => - ConfigSchemaBase.parse({}), -); -export type InputConfig = z.input; -export type Config = z.infer; diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/global-kv.ts b/rivetkit-typescript/packages/cloudflare-workers/src/global-kv.ts deleted file mode 100644 index e9c205a2d4..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/global-kv.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** KV keys for using Workers KV to store actor metadata globally. */ -export const GLOBAL_KV_KEYS = { - actorMetadata: (actorId: string): string => { - return `actor:${actorId}:metadata`; - }, -}; diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/handler.ts b/rivetkit-typescript/packages/cloudflare-workers/src/handler.ts deleted file mode 100644 index f776db15ef..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/handler.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { env } from "cloudflare:workers"; -import type { Client, Registry } from "rivetkit"; -import { createClientWithDriver } from "rivetkit"; -import { buildManagerRouter } from "rivetkit/driver-helpers"; -import { - type ActorHandlerInterface, - createActorDurableObject, - type DurableObjectConstructor, -} from "./actor-handler-do"; -import { type Config, ConfigSchema, type InputConfig } from "./config"; -import { CloudflareActorsManagerDriver } from "./manager-driver"; -import { upgradeWebSocket } from "./websocket"; - -/** Cloudflare Workers env */ -export interface Bindings { - ACTOR_KV: KVNamespace; - ACTOR_DO: DurableObjectNamespace; -} - -/** - * Stores the env for the current request. Required since some contexts like the inline client driver does not have access to the Hono context. - * - * Use getCloudflareAmbientEnv unless using CF_AMBIENT_ENV.run. - */ -export function getCloudflareAmbientEnv(): Bindings { - return env as unknown as Bindings; -} - -export interface InlineOutput> { - /** Client to communicate with the actors. */ - client: Client; - - /** Fetch handler to manually route requests to the Rivet manager API. */ - fetch: (request: Request, ...args: any) => Response | Promise; - - config: Config; - - ActorHandler: DurableObjectConstructor; -} - -export interface HandlerOutput { - handler: ExportedHandler; - ActorHandler: DurableObjectConstructor; -} - -/** - * Creates an inline client for accessing Rivet Actors privately without a public manager API. - * - * If you want to expose a public manager API, either: - * - * - Use `createHandler` to expose the Rivet API on `/api/rivet` - * - Forward Rivet API requests to `InlineOutput::fetch` - */ -export function createInlineClient>( - registry: R, - inputConfig?: InputConfig, -): InlineOutput { - // HACK: Cloudflare does not support using `crypto.randomUUID()` before start, so we pass a default value - // - // Envoy key is not used on Cloudflare - inputConfig = { ...inputConfig, envoyKey: "" }; - - // Parse config - const config = ConfigSchema.parse(inputConfig); - - // Create Durable Object - const ActorHandler = createActorDurableObject( - registry, - () => upgradeWebSocket, - ); - - // Configure registry for cloudflare-workers - registry.config.noWelcome = true; - // Disable inspector since it's not supported on Cloudflare Workers - registry.config.inspector = { - enabled: false, - token: () => "", - }; - // Set manager base path to "/" since the cloudflare handler strips the /api/rivet prefix - registry.config.managerBasePath = "/"; - const parsedConfig = registry.parseConfig(); - - // Create manager driver - const managerDriver = new CloudflareActorsManagerDriver(); - - // Build the manager router (has actor management endpoints like /actors) - const { router } = buildManagerRouter( - parsedConfig, - managerDriver, - () => upgradeWebSocket, - ); - - // Create client using the manager driver - // Avoid excessive generic expansion in DTS generation. - const client = (createClientWithDriver as any)(managerDriver) as Client; - - return { client, fetch: router.fetch.bind(router), config, ActorHandler }; -} - -/** - * Creates a handler to be exported from a Cloudflare Worker. - * - * This will automatically expose the Rivet manager API on `/api/rivet`. - * - * This includes a `fetch` handler and `ActorHandler` Durable Object. - */ -export function createHandler( - registry: Registry, - inputConfig?: InputConfig, -): HandlerOutput { - const inline = (createInlineClient as any)(registry, inputConfig); - const client = inline.client as any; - const fetch = inline.fetch as ( - request: Request, - ...args: any - ) => Response | Promise; - const config = inline.config as Config; - const ActorHandler = inline.ActorHandler as DurableObjectConstructor; - - // Create Cloudflare handler - const handler = { - fetch: async (request, cfEnv, ctx) => { - const url = new URL(request.url); - - // Inject Rivet env - const env = Object.assign({ RIVET: client }, cfEnv); - - // Mount Rivet manager API - if (url.pathname.startsWith(config.managerPath)) { - const strippedPath = url.pathname.substring( - config.managerPath.length, - ); - url.pathname = strippedPath; - const modifiedRequest = new Request(url.toString(), request); - return fetch(modifiedRequest, env, ctx); - } - - if (config.fetch) { - return config.fetch(request, env, ctx); - } else { - return new Response( - "This is a RivetKit server.\n\nLearn more at https://rivet.dev\n", - { status: 200 }, - ); - } - }, - } satisfies ExportedHandler; - - return { handler, ActorHandler }; -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/log.ts b/rivetkit-typescript/packages/cloudflare-workers/src/log.ts deleted file mode 100644 index 599be73cad..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/log.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getLogger } from "rivetkit/log"; - -export function logger() { - return getLogger("driver-cloudflare-workers"); -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/manager-driver.ts b/rivetkit-typescript/packages/cloudflare-workers/src/manager-driver.ts deleted file mode 100644 index ecde6a8627..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/manager-driver.ts +++ /dev/null @@ -1,506 +0,0 @@ -import type { Hono, Context as HonoContext } from "hono"; -import type { Encoding, RegistryConfig, UniversalWebSocket } from "rivetkit"; -import { - type ActorOutput, - type CreateInput, - type GatewayTarget, - type GetForIdInput, - type GetOrCreateWithKeyInput, - type GetWithKeyInput, - type ListActorsInput, - type ManagerDisplayInformation, - type ManagerDriver, - resolveGatewayTarget, - WS_PROTOCOL_ACTOR, - WS_PROTOCOL_CONN_PARAMS, - WS_PROTOCOL_ENCODING, - WS_PROTOCOL_STANDARD, - WS_PROTOCOL_TARGET, -} from "rivetkit/driver-helpers"; -import { - ActorDuplicateKey, - ActorNotFound, - InternalError, -} from "rivetkit/errors"; -import { assertUnreachable } from "rivetkit/utils"; -import { parseActorId } from "./actor-id"; -import { getCloudflareAmbientEnv } from "./handler"; -import { logger } from "./log"; -import type { Bindings } from "./mod"; -import { serializeNameAndKey } from "./util"; - -const STANDARD_WEBSOCKET_HEADERS = [ - "connection", - "upgrade", - "sec-websocket-key", - "sec-websocket-version", - "sec-websocket-protocol", - "sec-websocket-extensions", -]; - -export class CloudflareActorsManagerDriver implements ManagerDriver { - async sendRequest( - target: GatewayTarget, - actorRequest: Request, - ): Promise { - const actorId = await resolveGatewayTarget(this, target); - const env = getCloudflareAmbientEnv(); - - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - - logger().debug({ - msg: "sending request to durable object", - actorId, - doId, - method: actorRequest.method, - url: actorRequest.url, - }); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - return await stub.fetch(actorRequest); - } - - async openWebSocket( - path: string, - target: GatewayTarget, - encoding: Encoding, - params: unknown, - ): Promise { - const actorId = await resolveGatewayTarget(this, target); - const env = getCloudflareAmbientEnv(); - - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - - logger().debug({ - msg: "opening websocket to durable object", - actorId, - doId, - path, - }); - - // Make a fetch request to the Durable Object with WebSocket upgrade - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - const protocols: string[] = []; - protocols.push(WS_PROTOCOL_STANDARD); - protocols.push(`${WS_PROTOCOL_TARGET}actor`); - protocols.push(`${WS_PROTOCOL_ACTOR}${encodeURIComponent(actorId)}`); - protocols.push(`${WS_PROTOCOL_ENCODING}${encoding}`); - if (params) { - protocols.push( - `${WS_PROTOCOL_CONN_PARAMS}${encodeURIComponent(JSON.stringify(params))}`, - ); - } - - const headers: Record = { - Upgrade: "websocket", - Connection: "Upgrade", - "sec-websocket-protocol": protocols.join(", "), - }; - - // Use the path parameter to determine the URL - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const url = `http://actor${normalizedPath}`; - - logger().debug({ msg: "rewriting websocket url", from: path, to: url }); - - const response = await stub.fetch(url, { - headers, - }); - const webSocket = response.webSocket; - - if (!webSocket) { - throw new InternalError( - `missing websocket connection in response from DO\n\nStatus: ${response.status}\nResponse: ${await response.text()}`, - ); - } - - logger().debug({ - msg: "durable object websocket connection open", - actorId, - }); - - webSocket.accept(); - - // TODO: Is this still needed? - // HACK: Cloudflare does not call onopen automatically, so we need - // to call this on the next tick - setTimeout(() => { - const event = new Event("open"); - (webSocket as any).onopen?.(event); - (webSocket as any).dispatchEvent(event); - }, 0); - - return webSocket as unknown as UniversalWebSocket; - } - - async buildGatewayUrl(target: GatewayTarget): Promise { - const actorId = await resolveGatewayTarget(this, target); - return `http://actor/gateway/${encodeURIComponent(actorId)}`; - } - - async proxyRequest( - c: HonoContext<{ Bindings: Bindings }>, - actorRequest: Request, - actorId: string, - ): Promise { - const env = getCloudflareAmbientEnv(); - - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - - logger().debug({ - msg: "forwarding request to durable object", - actorId, - doId, - method: actorRequest.method, - url: actorRequest.url, - }); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - return await stub.fetch(actorRequest); - } - - async proxyWebSocket( - c: HonoContext<{ Bindings: Bindings }>, - path: string, - actorId: string, - encoding: Encoding, - params: unknown, - ): Promise { - logger().debug({ - msg: "forwarding websocket to durable object", - actorId, - path, - }); - - // Validate upgrade - const upgradeHeader = c.req.header("Upgrade"); - if (!upgradeHeader || upgradeHeader !== "websocket") { - return new Response("Expected Upgrade: websocket", { - status: 426, - }); - } - - const newUrl = new URL(`http://actor${path}`); - const actorRequest = new Request(newUrl, c.req.raw); - - logger().debug({ - msg: "rewriting websocket url", - from: c.req.url, - to: actorRequest.url, - }); - - // Always build fresh request to prevent forwarding unwanted headers - // HACK: Since we can't build a new request, we need to remove - // non-standard headers manually - const headerKeys: string[] = []; - actorRequest.headers.forEach((v, k) => { - headerKeys.push(k); - }); - for (const k of headerKeys) { - if (!STANDARD_WEBSOCKET_HEADERS.includes(k)) { - actorRequest.headers.delete(k); - } - } - - // Build protocols for WebSocket connection - const protocols: string[] = []; - protocols.push(WS_PROTOCOL_STANDARD); - protocols.push(`${WS_PROTOCOL_TARGET}actor`); - protocols.push(`${WS_PROTOCOL_ACTOR}${encodeURIComponent(actorId)}`); - protocols.push(`${WS_PROTOCOL_ENCODING}${encoding}`); - if (params) { - protocols.push( - `${WS_PROTOCOL_CONN_PARAMS}${encodeURIComponent(JSON.stringify(params))}`, - ); - } - actorRequest.headers.set( - "sec-websocket-protocol", - protocols.join(", "), - ); - - // Parse actor ID to get DO ID - const env = getCloudflareAmbientEnv(); - const [doId] = parseActorId(actorId); - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - return await stub.fetch(actorRequest); - } - - async getForId({ - c, - name, - actorId, - }: GetForIdInput): Promise< - ActorOutput | undefined - > { - const env = getCloudflareAmbientEnv(); - - // Parse actor ID to get DO ID and expected generation - const [doId, expectedGeneration] = parseActorId(actorId); - - // Get the Durable Object stub - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - // Call the DO's getMetadata method - const result = await stub.getMetadata(); - - if (!result) { - logger().debug({ - msg: "getForId: actor not found", - actorId, - }); - return undefined; - } - - // Check if the actor IDs match in order to check if the generation matches - if (result.actorId !== actorId) { - logger().debug({ - msg: "getForId: generation mismatch", - requestedActorId: actorId, - actualActorId: result.actorId, - }); - return undefined; - } - - if (result.destroying) { - throw new ActorNotFound(actorId); - } - - return { - actorId: result.actorId, - name: result.name, - key: result.key, - }; - } - - async getWithKey({ - c, - name, - key, - }: GetWithKeyInput): Promise< - ActorOutput | undefined - > { - const env = getCloudflareAmbientEnv(); - - logger().debug({ msg: "getWithKey: searching for actor", name, key }); - - // Generate deterministic ID from the name and key - const nameKeyString = serializeNameAndKey(name, key); - const doId = env.ACTOR_DO.idFromName(nameKeyString).toString(); - - // Try to get the Durable Object to see if it exists - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - // Check if actor exists without creating it - const result = await stub.getMetadata(); - - if (result) { - logger().debug({ - msg: "getWithKey: found actor with matching name and key", - actorId: result.actorId, - name: result.name, - key: result.key, - }); - return { - actorId: result.actorId, - name: result.name, - key: result.key, - }; - } else { - logger().debug({ - msg: "getWithKey: no actor found with matching name and key", - name, - key, - doId, - }); - return undefined; - } - } - - async getOrCreateWithKey({ - c, - name, - key, - input, - }: GetOrCreateWithKeyInput): Promise { - const env = getCloudflareAmbientEnv(); - - // Create a deterministic ID from the actor name and key - // This ensures that actors with the same name and key will have the same ID - const nameKeyString = serializeNameAndKey(name, key); - const doId = env.ACTOR_DO.idFromName(nameKeyString); - - // Get or create actor using the Durable Object's method - const actor = env.ACTOR_DO.get(doId); - const result = await actor.create({ - name, - key, - input, - allowExisting: true, - }); - if ("success" in result) { - const { actorId, created } = result.success; - logger().debug({ - msg: "getOrCreateWithKey result", - actorId, - name, - key, - created, - }); - - return { - actorId, - name, - key, - }; - } else if ("error" in result) { - throw new Error(`Error: ${JSON.stringify(result.error)}`); - } else { - assertUnreachable(result); - } - } - - async createActor({ - c, - name, - key, - input, - }: CreateInput): Promise { - const env = getCloudflareAmbientEnv(); - - // Create a deterministic ID from the actor name and key - // This ensures that actors with the same name and key will have the same ID - const nameKeyString = serializeNameAndKey(name, key); - const doId = env.ACTOR_DO.idFromName(nameKeyString); - - // Create actor - this will fail if it already exists - const actor = env.ACTOR_DO.get(doId); - const result = await actor.create({ - name, - key, - input, - allowExisting: false, - }); - - if ("success" in result) { - const { actorId } = result.success; - return { - actorId, - name, - key, - }; - } else if ("error" in result) { - if (result.error.actorAlreadyExists) { - throw new ActorDuplicateKey(name, key); - } - - throw new InternalError( - `Unknown error creating actor: ${JSON.stringify(result.error)}`, - ); - } else { - assertUnreachable(result); - } - } - - async listActors({ c, name }: ListActorsInput): Promise { - logger().warn({ - msg: "listActors not fully implemented for Cloudflare Workers", - name, - }); - return []; - } - - displayInformation(): ManagerDisplayInformation { - return { - properties: { - Driver: "Cloudflare Workers", - }, - }; - } - - setGetUpgradeWebSocket(): void { - // No-op for Cloudflare Workers - WebSocket upgrades are handled by the DO - } - - async kvGet(actorId: string, key: Uint8Array): Promise { - const env = getCloudflareAmbientEnv(); - - // Parse actor ID to get DO ID - const [doId] = parseActorId(actorId); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - const value = await stub.managerKvGet(key); - return value !== null ? new TextDecoder().decode(value) : null; - } - - async kvBatchGet( - actorId: string, - keys: Uint8Array[], - ): Promise<(Uint8Array | null)[]> { - const env = getCloudflareAmbientEnv(); - - const [doId] = parseActorId(actorId); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - return await stub.managerKvBatchGet(keys); - } - - async kvBatchPut( - actorId: string, - entries: [Uint8Array, Uint8Array][], - ): Promise { - const env = getCloudflareAmbientEnv(); - - const [doId] = parseActorId(actorId); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - await stub.managerKvBatchPut(entries); - } - - async kvBatchDelete( - actorId: string, - keys: Uint8Array[], - ): Promise { - const env = getCloudflareAmbientEnv(); - - const [doId] = parseActorId(actorId); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - await stub.managerKvBatchDelete(keys); - } - - async kvDeleteRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - ): Promise { - const env = getCloudflareAmbientEnv(); - - const [doId] = parseActorId(actorId); - - const id = env.ACTOR_DO.idFromString(doId); - const stub = env.ACTOR_DO.get(id); - - await stub.managerKvDeleteRange(start, end); - } -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/mod.ts b/rivetkit-typescript/packages/cloudflare-workers/src/mod.ts deleted file mode 100644 index 40129e55fe..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/mod.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { Client } from "rivetkit"; -export type { DriverContext } from "./actor-driver"; -export { createActorDurableObject } from "./actor-handler-do"; -export type { InputConfig as Config } from "./config"; -export { - type Bindings, - createHandler, - createInlineClient, - HandlerOutput, - InlineOutput, -} from "./handler"; diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/util.ts b/rivetkit-typescript/packages/cloudflare-workers/src/util.ts deleted file mode 100644 index 27d7236d79..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/util.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Constants for key handling -export const EMPTY_KEY = "(none)"; -export const KEY_SEPARATOR = ","; - -/** - * Serializes an array of key strings into a single string for use with idFromName - * - * @param name The actor name - * @param key Array of key strings to serialize - * @returns A single string containing the serialized name and key - */ -export function serializeNameAndKey(name: string, key: string[]): string { - // Escape colons in the name - const escapedName = name.replace(/:/g, "\\:"); - - // For empty keys, just return the name and a marker - if (key.length === 0) { - return `${escapedName}:${EMPTY_KEY}`; - } - - // Serialize the key array - const serializedKey = serializeKey(key); - - // Combine name and serialized key - return `${escapedName}:${serializedKey}`; -} - -/** - * Serializes an array of key strings into a single string - * - * @param key Array of key strings to serialize - * @returns A single string containing the serialized key - */ -export function serializeKey(key: string[]): string { - // Use a special marker for empty key arrays - if (key.length === 0) { - return EMPTY_KEY; - } - - // Escape each key part to handle the separator and the empty key marker - const escapedParts = key.map((part) => { - // First check if it matches our empty key marker - if (part === EMPTY_KEY) { - return `\\${EMPTY_KEY}`; - } - - // Escape backslashes first, then commas - let escaped = part.replace(/\\/g, "\\\\"); - escaped = escaped.replace(/,/g, "\\,"); - return escaped; - }); - - return escapedParts.join(KEY_SEPARATOR); -} - -/** - * Deserializes a key string back into an array of key strings - * - * @param keyString The serialized key string - * @returns Array of key strings - */ -export function deserializeKey(keyString: string): string[] { - // Handle empty values - if (!keyString) { - return []; - } - - // Check for special empty key marker - if (keyString === EMPTY_KEY) { - return []; - } - - // Split by unescaped commas and unescape the escaped characters - const parts: string[] = []; - let currentPart = ""; - let escaping = false; - - for (let i = 0; i < keyString.length; i++) { - const char = keyString[i]; - - if (escaping) { - // This is an escaped character, add it directly - currentPart += char; - escaping = false; - } else if (char === "\\") { - // Start of an escape sequence - escaping = true; - } else if (char === KEY_SEPARATOR) { - // This is a separator - parts.push(currentPart); - currentPart = ""; - } else { - // Regular character - currentPart += char; - } - } - - // Add the last part if it exists - if (currentPart || parts.length > 0) { - parts.push(currentPart); - } - - return parts; -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/src/websocket.ts b/rivetkit-typescript/packages/cloudflare-workers/src/websocket.ts deleted file mode 100644 index 39bbd68e0a..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/src/websocket.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Modified from https://github.com/honojs/hono/blob/40ea0eee58e39b31053a0246c595434f1094ad31/src/adapter/cloudflare-workers/websocket.ts#L17 -// -// This version calls the open event by default - -import type { UpgradeWebSocket, WSEvents, WSReadyState } from "hono/ws"; -import { defineWebSocketHelper, WSContext } from "hono/ws"; -import { WS_PROTOCOL_STANDARD } from "rivetkit/driver-helpers"; - -// Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332 -export const upgradeWebSocket: UpgradeWebSocket< - WebSocket, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any, - WSEvents -> = defineWebSocketHelper(async (c, events) => { - const upgradeHeader = c.req.header("Upgrade"); - if (upgradeHeader !== "websocket") { - return; - } - - const webSocketPair = new WebSocketPair(); - const client: WebSocket = webSocketPair[0]; - const server: WebSocket = webSocketPair[1]; - - const wsContext = new WSContext({ - close: (code, reason) => server.close(code, reason), - get protocol() { - return server.protocol; - }, - raw: server, - get readyState() { - return server.readyState as WSReadyState; - }, - url: server.url ? new URL(server.url) : null, - send: (source) => server.send(source), - }); - - if (events.onClose) { - server.addEventListener("close", (evt: CloseEvent) => - events.onClose?.(evt, wsContext), - ); - } - if (events.onMessage) { - server.addEventListener("message", (evt: MessageEvent) => - events.onMessage?.(evt, wsContext), - ); - } - if (events.onError) { - server.addEventListener("error", (evt: Event) => - events.onError?.(evt, wsContext), - ); - } - - server.accept?.(); - - // note: cloudflare actors doesn't support 'open' event, so we call it immediately with a fake event - // - // we have to do this after `server.accept() is called` - events.onOpen?.(new Event("open"), wsContext); - - // Build response headers - const headers: Record = {}; - - // Set Sec-WebSocket-Protocol if does not exist - const protocols = c.req.header("Sec-WebSocket-Protocol"); - if ( - typeof protocols === "string" && - protocols - .split(",") - .map((x) => x.trim()) - .includes(WS_PROTOCOL_STANDARD) - ) { - headers["Sec-WebSocket-Protocol"] = WS_PROTOCOL_STANDARD; - } - - return new Response(null, { - status: 101, - headers, - webSocket: client, - }); -}); diff --git a/rivetkit-typescript/packages/cloudflare-workers/tests/driver-tests.test.ts b/rivetkit-typescript/packages/cloudflare-workers/tests/driver-tests.test.ts deleted file mode 100644 index cac192ba32..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tests/driver-tests.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// import { exec, spawn } from "node:child_process"; -// import crypto from "node:crypto"; -// import fs from "node:fs/promises"; -// import os from "node:os"; -// import path from "node:path"; -// import { promisify } from "node:util"; -// import { runDriverTests } from "rivetkit/driver-test-suite"; -// import { getPort } from "rivetkit/test"; -// -// const execPromise = promisify(exec); -// -// // Bypass createTestRuntime by providing an endpoint directly -// runDriverTests({ -// useRealTimers: true, -// HACK_skipCleanupNet: true, -// skip: { -// // CF does not support sleeping -// sleep: true, -// // CF does not support sleeping so we cannot test hibernation -// hibernation: true, -// }, -// async start() { -// // Setup project -// const projectPath = path.resolve( -// __dirname, -// "../../rivetkit/fixtures/driver-test-suite", -// ); -// if (!setupProjectOnce) { -// setupProjectOnce = setupProject(projectPath); -// } -// const projectDir = await setupProjectOnce; -// -// // Get an available port -// const port = await getPort(); -// const inspectorPort = await getPort(); -// -// // Start wrangler dev -// const wranglerProcess = spawn( -// "pnpm", -// [ -// "start", -// "src/index.ts", -// "--port", -// `${port}`, -// "--inspector-port", -// `${inspectorPort}`, -// "--persist-to", -// `/tmp/actors-test-${crypto.randomUUID()}`, -// ], -// { -// cwd: projectDir, -// stdio: "pipe", -// }, -// ); -// -// // Wait for wrangler to start -// await new Promise((resolve, reject) => { -// let isResolved = false; -// const timeout = setTimeout(() => { -// if (!isResolved) { -// isResolved = true; -// wranglerProcess.kill(); -// reject(new Error("Timeout waiting for wrangler to start")); -// } -// }, 30000); -// -// wranglerProcess.stdout?.on("data", (data) => { -// const output = data.toString(); -// console.log(`wrangler: ${output}`); -// if (output.includes(`Ready on http://localhost:${port}`)) { -// if (!isResolved) { -// isResolved = true; -// clearTimeout(timeout); -// resolve(); -// } -// } -// }); -// -// wranglerProcess.stderr?.on("data", (data) => { -// console.error(`wrangler: ${data}`); -// }); -// -// wranglerProcess.on("error", (error) => { -// if (!isResolved) { -// isResolved = true; -// clearTimeout(timeout); -// reject(error); -// } -// }); -// -// wranglerProcess.on("exit", (code) => { -// if (!isResolved && code !== 0) { -// isResolved = true; -// clearTimeout(timeout); -// reject(new Error(`wrangler exited with code ${code}`)); -// } -// }); -// }); -// -// return { -// endpoint: `http://localhost:${port}/rivet`, -// namespace: "default", -// runnerName: "default", -// async cleanup() { -// // Shut down wrangler process -// wranglerProcess.kill(); -// }, -// }; -// }, -// }); -// -// let setupProjectOnce: Promise | undefined; -// -// async function setupProject(projectPath: string) { -// // Create a temporary directory for the test -// const uuid = crypto.randomUUID(); -// const tmpDir = path.join(os.tmpdir(), `rivetkit-test-${uuid}`); -// await fs.mkdir(tmpDir, { recursive: true }); -// -// // Create package.json with workspace dependencies -// const wranglerVersion = "^4.37.1"; -// const packageJson = { -// name: "rivetkit-test", -// private: true, -// version: "1.0.0", -// type: "module", -// scripts: { -// start: "wrangler dev", -// }, -// dependencies: { -// wrangler: wranglerVersion, -// hono: "4.8.3", -// }, -// packageManager: -// "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808", -// }; -// await fs.writeFile( -// path.join(tmpDir, "package.json"), -// JSON.stringify(packageJson, null, 2), -// ); -// -// // Create node_modules directory and copy necessary packages -// const nodeModulesDir = path.join(tmpDir, "node_modules"); -// await fs.mkdir(nodeModulesDir, { recursive: true }); -// -// // Copy the built packages from workspace -// const workspaceRoot = path.resolve(__dirname, "../../.."); -// const rivetKitDir = path.join(nodeModulesDir, "@rivetkit"); -// await fs.mkdir(rivetKitDir, { recursive: true }); -// -// // Copy core package -// const corePackagePath = path.join(workspaceRoot, "packages/rivetkit"); -// const targetCorePath = path.join(rivetKitDir, "rivetkit"); -// await fs.cp(corePackagePath, targetCorePath, { recursive: true }); -// -// // Copy cloudflare-workers package -// const cfPackagePath = path.join( -// workspaceRoot, -// "packages/cloudflare-workers", -// ); -// const targetCfPath = path.join(rivetKitDir, "cloudflare-workers"); -// await fs.cp(cfPackagePath, targetCfPath, { recursive: true }); -// -// // Copy main rivetkit package -// const mainPackagePath = path.join(workspaceRoot, "packages/rivetkit"); -// const targetMainPath = path.join(nodeModulesDir, "rivetkit"); -// await fs.cp(mainPackagePath, targetMainPath, { recursive: true }); -// -// // Install wrangler and hono -// await execPromise(`pnpm install wrangler@${wranglerVersion} hono@4.8.3`, { -// cwd: tmpDir, -// }); -// -// // Create a wrangler.json file -// const wranglerConfig = { -// name: "rivetkit-test", -// compatibility_date: "2025-01-29", -// compatibility_flags: [ -// "nodejs_compat", -// // Required for passing log env vars -// "nodejs_compat_populate_process_env", -// ], -// migrations: [ -// { -// new_sqlite_classes: ["ActorHandler"], -// tag: "v1", -// }, -// ], -// durable_objects: { -// bindings: [ -// { -// class_name: "ActorHandler", -// name: "ACTOR_DO", -// }, -// ], -// }, -// kv_namespaces: [ -// { -// binding: "ACTOR_KV", -// id: "test", // Will be replaced with a mock in dev mode -// }, -// ], -// observability: { -// enabled: true, -// }, -// vars: { -// RIVET_LOG_LEVEL: "DEBUG", -// RIVET_LOG_TARGET: "1", -// RIVET_LOG_TIMESTAMP: "1", -// RIVET_LOG_ERROR_STACK: "1", -// RIVET_LOG_MESSAGE: "1", -// }, -// }; -// await fs.writeFile( -// path.join(tmpDir, "wrangler.json"), -// JSON.stringify(wranglerConfig, null, 2), -// ); -// -// // Copy project to test directory -// const projectDestDir = path.join(tmpDir, "src", "actors"); -// await fs.cp(projectPath, projectDestDir, { recursive: true }); -// -// // Write script -// const indexContent = `import { createHandler } from "@rivetkit/cloudflare-workers"; -// import { registry } from "./actors/registry"; -// -// // TODO: Find a cleaner way of flagging an registry as test mode (ideally not in the config itself) -// // Force enable test -// registry.config.test.enabled = true; -// -// const { handler, ActorHandler } = createHandler(registry); -// export { handler as default, ActorHandler }; -// `; -// await fs.writeFile(path.join(tmpDir, "src/index.ts"), indexContent); -// -// return tmpDir; -// } diff --git a/rivetkit-typescript/packages/cloudflare-workers/tests/id-generation.test.ts b/rivetkit-typescript/packages/cloudflare-workers/tests/id-generation.test.ts deleted file mode 100644 index 4f85e04bb8..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tests/id-generation.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// import { describe, expect, test } from "vitest"; -// import { serializeNameAndKey } from "../src/util"; -// -// describe("Deterministic ID generation", () => { -// test("should generate consistent IDs for the same name and key", () => { -// const name = "test-actor"; -// const key = ["key1", "key2"]; -// -// // Test that serializeNameAndKey produces a consistent string -// const serialized1 = serializeNameAndKey(name, key); -// const serialized2 = serializeNameAndKey(name, key); -// -// expect(serialized1).toBe(serialized2); -// expect(serialized1).toBe("test-actor:key1,key2"); -// }); -// -// test("should properly escape special characters in keys", () => { -// const name = "test-actor"; -// const key = ["key,with,commas", "normal-key"]; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test-actor:key\\,with\\,commas,normal-key"); -// }); -// -// test("should properly escape colons in actor names", () => { -// const name = "test:actor:with:colons"; -// const key = ["key1", "key2"]; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test\\:actor\\:with\\:colons:key1,key2"); -// }); -// -// test("should handle empty key arrays", () => { -// const name = "test-actor"; -// const key: string[] = []; -// -// const serialized = serializeNameAndKey(name, key); -// expect(serialized).toBe("test-actor:(none)"); -// }); -// }); diff --git a/rivetkit-typescript/packages/cloudflare-workers/tests/key-indexes.test.ts b/rivetkit-typescript/packages/cloudflare-workers/tests/key-indexes.test.ts deleted file mode 100644 index d8108967dc..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tests/key-indexes.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// import { describe, expect, test } from "vitest"; -// import { serializeKey } from "../src/util"; -// -// // Access internal KEYS directly -// // Since KEYS is a private constant in manager_driver.ts, we'll redefine it here for testing -// const KEYS = { -// ACTOR: { -// metadata: (actorId: string) => `actor:${actorId}:metadata`, -// keyIndex: (name: string, key: string[] = []) => { -// // Use serializeKey for consistent handling of all keys -// return `actor_key:${serializeKey(key)}`; -// }, -// }, -// }; -// -// describe("Key index functions", () => { -// test("keyIndex handles empty key array", () => { -// expect(KEYS.ACTOR.keyIndex("test-actor")).toBe("actor_key:(none)"); -// expect(KEYS.ACTOR.keyIndex("actor:with:colons")).toBe( -// "actor_key:(none)", -// ); -// }); -// -// test("keyIndex handles single-item key arrays", () => { -// // Note: keyIndex ignores the name parameter -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key1"])).toBe( -// "actor_key:key1", -// ); -// expect( -// KEYS.ACTOR.keyIndex("actor:with:colons", ["key:with:colons"]), -// ).toBe("actor_key:key:with:colons"); -// }); -// -// test("keyIndex handles multi-item array keys", () => { -// // Note: keyIndex ignores the name parameter -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key1", "key2"])).toBe( -// `actor_key:key1,key2`, -// ); -// -// // Test with special characters -// expect(KEYS.ACTOR.keyIndex("test-actor", ["key,with,commas"])).toBe( -// "actor_key:key\\,with\\,commas", -// ); -// }); -// -// test("metadata key creates proper pattern", () => { -// expect(KEYS.ACTOR.metadata("123-456")).toBe("actor:123-456:metadata"); -// }); -// }); diff --git a/rivetkit-typescript/packages/cloudflare-workers/tests/key-serialization.test.ts b/rivetkit-typescript/packages/cloudflare-workers/tests/key-serialization.test.ts deleted file mode 100644 index 6d925893b6..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tests/key-serialization.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -// import { describe, expect, test } from "vitest"; -// import { -// deserializeKey, -// EMPTY_KEY, -// KEY_SEPARATOR, -// serializeKey, -// serializeNameAndKey, -// } from "../src/util"; -// -// describe("Key serialization and deserialization", () => { -// // Test key serialization -// describe("serializeKey", () => { -// test("serializes empty key array", () => { -// expect(serializeKey([])).toBe(EMPTY_KEY); -// }); -// -// test("serializes single key", () => { -// expect(serializeKey(["test"])).toBe("test"); -// }); -// -// test("serializes multiple keys", () => { -// expect(serializeKey(["a", "b", "c"])).toBe( -// `a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`, -// ); -// }); -// -// test("escapes commas in keys", () => { -// expect(serializeKey(["a,b"])).toBe("a\\,b"); -// expect(serializeKey(["a,b", "c"])).toBe(`a\\,b${KEY_SEPARATOR}c`); -// }); -// -// test("escapes empty key marker in keys", () => { -// expect(serializeKey([EMPTY_KEY])).toBe(`\\${EMPTY_KEY}`); -// }); -// -// test("handles complex keys", () => { -// expect(serializeKey(["a,b", EMPTY_KEY, "c,d"])).toBe( -// `a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`, -// ); -// }); -// }); -// -// // Test key deserialization -// describe("deserializeKey", () => { -// test("deserializes empty string", () => { -// expect(deserializeKey("")).toEqual([]); -// }); -// -// test("deserializes undefined/null", () => { -// expect(deserializeKey(undefined as unknown as string)).toEqual([]); -// expect(deserializeKey(null as unknown as string)).toEqual([]); -// }); -// -// test("deserializes empty key marker", () => { -// expect(deserializeKey(EMPTY_KEY)).toEqual([]); -// }); -// -// test("deserializes single key", () => { -// expect(deserializeKey("test")).toEqual(["test"]); -// }); -// -// test("deserializes multiple keys", () => { -// expect( -// deserializeKey(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`), -// ).toEqual(["a", "b", "c"]); -// }); -// -// test("deserializes keys with escaped commas", () => { -// expect(deserializeKey("a\\,b")).toEqual(["a,b"]); -// expect(deserializeKey(`a\\,b${KEY_SEPARATOR}c`)).toEqual([ -// "a,b", -// "c", -// ]); -// }); -// -// test("deserializes keys with escaped empty key marker", () => { -// expect(deserializeKey(`\\${EMPTY_KEY}`)).toEqual([EMPTY_KEY]); -// }); -// -// test("deserializes complex keys", () => { -// expect( -// deserializeKey( -// `a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`, -// ), -// ).toEqual(["a,b", EMPTY_KEY, "c,d"]); -// }); -// }); -// -// // Test name+key serialization -// describe("serializeNameAndKey", () => { -// test("serializes name with empty key array", () => { -// expect(serializeNameAndKey("test", [])).toBe(`test:${EMPTY_KEY}`); -// }); -// -// test("serializes name with single key", () => { -// expect(serializeNameAndKey("test", ["key1"])).toBe("test:key1"); -// }); -// -// test("serializes name with multiple keys", () => { -// expect(serializeNameAndKey("test", ["a", "b", "c"])).toBe( -// `test:a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`, -// ); -// }); -// -// test("escapes commas in keys", () => { -// expect(serializeNameAndKey("test", ["a,b"])).toBe("test:a\\,b"); -// }); -// -// test("handles complex keys with name", () => { -// expect( -// serializeNameAndKey("actor", ["a,b", EMPTY_KEY, "c,d"]), -// ).toBe( -// `actor:a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`, -// ); -// }); -// }); -// -// // Removed createIndexKey tests as function was moved to KEYS.INDEX in manager_driver.ts -// -// // Test roundtrip -// describe("roundtrip", () => { -// const testKeys = [ -// [], -// ["test"], -// ["a", "b", "c"], -// ["a,b", "c"], -// [EMPTY_KEY], -// ["a,b", EMPTY_KEY, "c,d"], -// ["special\\chars", "more:complex,keys", "final key"], -// ]; -// -// testKeys.forEach((key) => { -// test(`roundtrip: ${JSON.stringify(key)}`, () => { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// }); -// -// test("handles all test cases in a large batch", () => { -// for (const key of testKeys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// }); -// -// // Test edge cases -// describe("edge cases", () => { -// test("handles backslash at the end", () => { -// const key = ["abc\\"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// -// test("handles backslashes in middle of string", () => { -// const keys = [["abc\\def"], ["abc\\\\def"], ["path\\to\\file"]]; -// -// for (const key of keys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// -// test("handles commas at the end of strings", () => { -// const serialized = serializeKey(["abc\\,"]); -// expect(deserializeKey(serialized)).toEqual(["abc\\,"]); -// }); -// -// test("handles mixed backslashes and commas", () => { -// const keys = [ -// ["path\\to\\file,dir"], -// ["file\\with,comma"], -// ["path\\to\\file", "with,comma"], -// ]; -// -// for (const key of keys) { -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// } -// }); -// -// test("handles multiple consecutive commas", () => { -// const key = ["a,,b"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// -// test("handles special characters", () => { -// const key = ["a💻b", "c🔑d"]; -// const serialized = serializeKey(key); -// const deserialized = deserializeKey(serialized); -// expect(deserialized).toEqual(key); -// }); -// }); -// -// // Test exact key matching -// describe("exact key matching", () => { -// test("differentiates [a,b] from [a,b,c]", () => { -// const key1 = ["a", "b"]; -// const key2 = ["a", "b", "c"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// }); -// -// test("differentiates [a,b] from [a]", () => { -// const key1 = ["a", "b"]; -// const key2 = ["a"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// }); -// -// test("differentiates [a,b] from [a:b]", () => { -// const key1 = ["a,b"]; -// const key2 = ["a", "b"]; -// -// const serialized1 = serializeKey(key1); -// const serialized2 = serializeKey(key2); -// -// expect(serialized1).not.toBe(serialized2); -// expect(deserializeKey(serialized1)).toEqual(key1); -// expect(deserializeKey(serialized2)).toEqual(key2); -// }); -// }); -// }); diff --git a/rivetkit-typescript/packages/cloudflare-workers/tsconfig.json b/rivetkit-typescript/packages/cloudflare-workers/tsconfig.json deleted file mode 100644 index cab0f6a370..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "types": ["@cloudflare/workers-types", "node"], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*", "tests/**/*"] -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/tsup.config.ts b/rivetkit-typescript/packages/cloudflare-workers/tsup.config.ts deleted file mode 100644 index 9fb6614245..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/tsup.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "tsup"; -import defaultConfig from "../../../tsup.base.ts"; - -export default defineConfig({ - external: [/cloudflare:.*/], - ...defaultConfig, -}); diff --git a/rivetkit-typescript/packages/cloudflare-workers/turbo.json b/rivetkit-typescript/packages/cloudflare-workers/turbo.json deleted file mode 100644 index 71992d4494..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/turbo.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "extends": ["//"], - "tasks": { - "test": { - "inputs": ["src/**", "tests/**", "package.json"], - // Also build this package, since the tests depend on the output scripts - "dependsOn": ["^build", "check-types", "build"] - } - } -} diff --git a/rivetkit-typescript/packages/cloudflare-workers/vitest.config.ts b/rivetkit-typescript/packages/cloudflare-workers/vitest.config.ts deleted file mode 100644 index 6d1db33bad..0000000000 --- a/rivetkit-typescript/packages/cloudflare-workers/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; -import defaultConfig from "../../../vitest.base.ts"; - -export default defineConfig({ - ...defaultConfig, - test: { - ...defaultConfig.test, - // Requires time for installing packages - testTimeout: 60_000, - }, -}); diff --git a/rivetkit-typescript/packages/framework-base/README.md b/rivetkit-typescript/packages/framework-base/README.md index 9cc4827a8a..dfd7fe55bf 100644 --- a/rivetkit-typescript/packages/framework-base/README.md +++ b/rivetkit-typescript/packages/framework-base/README.md @@ -2,9 +2,9 @@ _Library to build and scale stateful workloads_ -[Learn More →](https://github.com/rivet-dev/rivetkit) +[Learn More →](https://github.com/rivet-dev/rivet) -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivet/issues) ## Lifecycle @@ -83,4 +83,4 @@ Two components using the same actor opts: ## License -Apache 2.0 \ No newline at end of file +Apache 2.0 diff --git a/rivetkit-typescript/packages/next-js/README.md b/rivetkit-typescript/packages/next-js/README.md index 09d629198b..fc3b236cae 100644 --- a/rivetkit-typescript/packages/next-js/README.md +++ b/rivetkit-typescript/packages/next-js/README.md @@ -2,10 +2,10 @@ RivetKit Next.js is a framework for building serverless and edge applications using Next.js, leveraging RivetKit's actor model for scalable and efficient microservices. -[Learn More →](https://github.com/rivet-dev/rivetkit) +[Learn More →](https://github.com/rivet-dev/rivet) -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivet/issues) ## License -Apache 2.0 \ No newline at end of file +Apache 2.0 diff --git a/rivetkit-typescript/packages/react/README.md b/rivetkit-typescript/packages/react/README.md index 95638453c3..543a19a385 100644 --- a/rivetkit-typescript/packages/react/README.md +++ b/rivetkit-typescript/packages/react/README.md @@ -2,10 +2,10 @@ _Library to build and scale stateful workloads_ -[Learn More →](https://github.com/rivet-dev/rivetkit) +[Learn More →](https://github.com/rivet-dev/rivet) -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivet/issues) ## License -Apache 2.0 \ No newline at end of file +Apache 2.0 diff --git a/rivetkit-typescript/packages/rivetkit/README.md b/rivetkit-typescript/packages/rivetkit/README.md index fe71112fcd..34e38e6116 100644 --- a/rivetkit-typescript/packages/rivetkit/README.md +++ b/rivetkit-typescript/packages/rivetkit/README.md @@ -2,9 +2,9 @@ _Library to build and scale stateful workloads_ -[Learn More →](https://github.com/rivet-dev/rivetkit) +[Learn More →](https://github.com/rivet-dev/rivet) -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivet/issues) ## License diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts deleted file mode 100644 index 9a860685ab..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { actor, event, queue } from "rivetkit"; -import { Forbidden } from "rivetkit/errors"; - -export interface AccessControlConnParams { - allowRequest?: boolean; - allowWebSocket?: boolean; -} - -const accessControlEvents: Record< - string, - ReturnType> -> = { - allowedEvent: event<{ value: string }>({ - canSubscribe: (c) => { - c.state.lastCanSubscribeConnId = c.conn.id; - return true; - }, - }), - blockedEvent: event<{ value: string }>({ - canSubscribe: (c) => { - c.state.lastCanSubscribeConnId = c.conn.id; - return false; - }, - }), -}; - -const accessControlQueues: Record< - string, - ReturnType> -> = { - allowedQueue: queue<{ value: string }>({ - canPublish: (c) => { - c.state.lastCanPublishConnId = c.conn.id; - return true; - }, - }), - blockedQueue: queue<{ value: string }>({ - canPublish: (c) => { - c.state.lastCanPublishConnId = c.conn.id; - return false; - }, - }), -}; - -export const accessControlActor = actor({ - state: { - lastCanPublishConnId: "", - lastCanSubscribeConnId: "", - }, - events: accessControlEvents, - queues: accessControlQueues, - onBeforeConnect: (_c, params: AccessControlConnParams) => { - if ( - params?.allowRequest === false || - params?.allowWebSocket === false - ) { - throw new Forbidden(); - } - }, - onRequest(_c, request) { - const url = new URL(request.url); - if (url.pathname === "/status") { - return Response.json({ ok: true }); - } - return new Response("Not Found", { status: 404 }); - }, - onWebSocket(_c, websocket) { - websocket.send(JSON.stringify({ type: "welcome" })); - }, - actions: { - allowedAction: (_c, value: string) => { - return `allowed:${value}`; - }, - allowedGetLastCanPublishConnId: (c) => { - return c.state.lastCanPublishConnId; - }, - allowedGetLastCanSubscribeConnId: (c) => { - return c.state.lastCanSubscribeConnId; - }, - allowedReceiveQueue: async (c) => { - const message = await c.queue.tryNext({ - names: ["allowedQueue"], - }); - return message?.body ?? null; - }, - allowedReceiveAnyQueue: async (c) => { - const message = await c.queue.tryNext(); - return message?.body ?? null; - }, - allowedBroadcastAllowedEvent: (c, value: string) => { - c.broadcast("allowedEvent", { value }); - }, - allowedBroadcastBlockedEvent: (c, value: string) => { - c.broadcast("blockedEvent", { value }); - }, - allowedBroadcastUndefinedEvent: (c, value: string) => { - c.broadcast("undefinedEvent", { value }); - }, - }, -}); - -export const accessControlNoQueuesActor = actor({ - state: {}, - actions: { - readAnyQueue: async (c) => { - const message = await c.queue.tryNext(); - return message?.body ?? null; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-inputs.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-inputs.ts deleted file mode 100644 index 42e5664574..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-inputs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { actor } from "rivetkit"; - -export interface State { - initialInput?: unknown; - onCreateInput?: unknown; -} - -// Test actor that can capture input during creation -export const inputActor = actor({ - createState: (c, input): State => { - return { - initialInput: input, - onCreateInput: undefined, - }; - }, - - onCreate: (c, input) => { - c.state.onCreateInput = input; - }, - - actions: { - getInputs: (c) => { - return { - initialInput: c.state.initialInput, - onCreateInput: c.state.onCreateInput, - }; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-timeout.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-timeout.ts deleted file mode 100644 index b1ee4b3cd0..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-timeout.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { actor } from "rivetkit"; - -// Short timeout actor -export const shortTimeoutActor = actor({ - state: { value: 0 }, - options: { - actionTimeout: 50, // 50ms timeout - }, - actions: { - quickAction: async (c) => { - return "quick response"; - }, - slowAction: async (c) => { - // This action should timeout - await new Promise((resolve) => setTimeout(resolve, 100)); - return "slow response"; - }, - }, -}); - -// Long timeout actor -export const longTimeoutActor = actor({ - state: { value: 0 }, - options: { - actionTimeout: 200, // 200ms timeout - }, - actions: { - delayedAction: async (c) => { - // This action should complete within timeout - await new Promise((resolve) => setTimeout(resolve, 100)); - return "delayed response"; - }, - }, -}); - -// Default timeout actor -export const defaultTimeoutActor = actor({ - state: { value: 0 }, - actions: { - normalAction: async (c) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - return "normal response"; - }, - }, -}); - -// Sync actor (timeout shouldn't apply) -export const syncTimeoutActor = actor({ - state: { value: 0 }, - options: { - actionTimeout: 50, // 50ms timeout - }, - actions: { - syncAction: (c) => { - return "sync response"; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-types.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-types.ts deleted file mode 100644 index ad0707971e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/action-types.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -// Actor with synchronous actions -export const syncActionActor = actor({ - state: { value: 0 }, - actions: { - // Simple synchronous action that returns a value directly - increment: (c, amount = 1) => { - c.state.value += amount; - return c.state.value; - }, - // Synchronous action that returns an object - getInfo: (c) => { - return { - currentValue: c.state.value, - timestamp: Date.now(), - }; - }, - // Synchronous action with no return value (void) - reset: (c) => { - c.state.value = 0; - }, - }, -}); - -// Actor with asynchronous actions -export const asyncActionActor = actor({ - state: { value: 0, data: null as any }, - actions: { - // Async action with a delay - delayedIncrement: async (c, amount = 1) => { - await Promise.resolve(); - c.state.value += amount; - return c.state.value; - }, - // Async action that simulates an API call - fetchData: async (c, id: string) => { - await Promise.resolve(); - - // Simulate response data - const data = { id, timestamp: Date.now() }; - c.state.data = data; - return data; - }, - // Async action with error handling - asyncWithError: async (c, shouldError: boolean) => { - await Promise.resolve(); - - if (shouldError) { - throw new UserError("Intentional error"); - } - - return "Success"; - }, - }, -}); - -// Actor with promise actions -export const promiseActor = actor({ - state: { results: [] as string[] }, - actions: { - // Action that returns a resolved promise - resolvedPromise: (c) => { - return Promise.resolve("resolved value"); - }, - // Action that returns a promise that resolves after a delay - delayedPromise: (c): Promise => { - return new Promise((resolve) => { - c.state.results.push("delayed"); - resolve("delayed value"); - }); - }, - // Action that returns a rejected promise - rejectedPromise: (c) => { - return Promise.reject(new UserError("promised rejection")); - }, - // Action to check the collected results - getResults: (c) => { - return c.state.results; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-drizzle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-drizzle.ts deleted file mode 100644 index 4b23e4a81e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-drizzle.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db/drizzle"; -import { migrations } from "./db/migrations"; -import { schema } from "./db/schema"; -import { scheduleActorSleep } from "./schedule-sleep"; - -function firstRowValue(row: Record | undefined): unknown { - if (!row) { - return undefined; - } - - const values = Object.values(row); - return values.length > 0 ? values[0] : undefined; -} - -function toSafeInteger(value: unknown): number { - if (typeof value === "bigint") { - return Number(value); - } - if (typeof value === "number") { - return Number.isFinite(value) ? Math.trunc(value) : 0; - } - if (typeof value === "string") { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function normalizeRowIds(rowIds: number[]): number[] { - const normalized = rowIds - .map((id) => Math.trunc(id)) - .filter((id) => Number.isFinite(id) && id > 0); - return Array.from(new Set(normalized)); -} - -function makePayload(size: number): string { - const normalizedSize = Math.max(0, Math.trunc(size)); - return "x".repeat(normalizedSize); -} - -export const dbActorDrizzle = actor({ - state: { - disconnectInsertEnabled: false, - disconnectInsertDelayMs: 0, - }, - db: db({ - schema, - migrations, - }), - onDisconnect: async (c) => { - if (!c.state.disconnectInsertEnabled) { - return; - } - - if (c.state.disconnectInsertDelayMs > 0) { - await new Promise((resolve) => - setTimeout(resolve, c.state.disconnectInsertDelayMs), - ); - } - - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('__disconnect__', '', ${Date.now()})`, - ); - }, - actions: { - configureDisconnectInsert: (c, enabled: boolean, delayMs: number) => { - c.state.disconnectInsertEnabled = enabled; - c.state.disconnectInsertDelayMs = Math.max(0, Math.floor(delayMs)); - }, - getDisconnectInsertCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data WHERE value = '__disconnect__'`, - ); - return results[0]?.count ?? 0; - }, - reset: async (c) => { - await c.db.execute(`DELETE FROM test_data`); - }, - insertValue: async (c, value: string) => { - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()})`, - ); - const results = await c.db.execute<{ id: number }>( - `SELECT last_insert_rowid() as id`, - ); - return { id: results[0].id }; - }, - getValues: async (c) => { - const results = await c.db.execute<{ - id: number; - value: string; - payload: string; - created_at: number; - }>(`SELECT * FROM test_data ORDER BY id`); - return results; - }, - getValue: async (c, id: number) => { - const results = await c.db.execute<{ value: string }>( - `SELECT value FROM test_data WHERE id = ${id}`, - ); - return results[0]?.value ?? null; - }, - getCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data`, - ); - return results[0].count; - }, - rawSelectCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data`, - ); - return results[0]?.count ?? 0; - }, - insertMany: async (c, count: number) => { - if (count <= 0) { - return { count: 0 }; - } - const now = Date.now(); - const values: string[] = []; - for (let i = 0; i < count; i++) { - values.push(`('User ${i}', '', ${now})`); - } - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ${values.join(", ")}`, - ); - return { count }; - }, - updateValue: async (c, id: number, value: string) => { - await c.db.execute( - `UPDATE test_data SET value = '${value}' WHERE id = ${id}`, - ); - return { success: true }; - }, - deleteValue: async (c, id: number) => { - await c.db.execute(`DELETE FROM test_data WHERE id = ${id}`); - }, - transactionCommit: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); COMMIT;`, - ); - }, - transactionRollback: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); ROLLBACK;`, - ); - }, - insertPayloadOfSize: async (c, size: number) => { - const payload = "x".repeat(size); - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('payload', '${payload}', ${Date.now()})`, - ); - const results = await c.db.execute<{ id: number }>( - `SELECT last_insert_rowid() as id`, - ); - return { id: results[0].id, size }; - }, - getPayloadSize: async (c, id: number) => { - const results = await c.db.execute<{ size: number }>( - `SELECT length(payload) as size FROM test_data WHERE id = ${id}`, - ); - return results[0]?.size ?? 0; - }, - insertPayloadRows: async (c, count: number, payloadSize: number) => { - const normalizedCount = Math.max(0, Math.trunc(count)); - if (normalizedCount === 0) { - return { count: 0 }; - } - - const payload = makePayload(payloadSize); - const now = Date.now(); - for (let i = 0; i < normalizedCount; i++) { - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('bulk-${i}', '${payload}', ${now})`, - ); - } - - return { count: normalizedCount }; - }, - roundRobinUpdateValues: async ( - c, - rowIds: number[], - iterations: number, - ) => { - const normalizedRowIds = normalizeRowIds(rowIds); - const normalizedIterations = Math.max(0, Math.trunc(iterations)); - if (normalizedRowIds.length === 0 || normalizedIterations === 0) { - const emptyRows: Array<{ id: number; value: string }> = []; - return emptyRows; - } - - for (let i = 0; i < normalizedIterations; i++) { - const rowId = - normalizedRowIds[i % normalizedRowIds.length] ?? 0; - await c.db.execute( - `UPDATE test_data SET value = 'v-${i}' WHERE id = ${rowId}`, - ); - } - - return await c.db.execute<{ id: number; value: string }>( - `SELECT id, value FROM test_data WHERE id IN (${normalizedRowIds.join(",")}) ORDER BY id`, - ); - }, - getPageCount: async (c) => { - const rows = - await c.db.execute>( - "PRAGMA page_count", - ); - return toSafeInteger(firstRowValue(rows[0])); - }, - vacuum: async (c) => { - await c.db.execute("VACUUM"); - }, - integrityCheck: async (c) => { - const rows = await c.db.execute>( - "PRAGMA integrity_check", - ); - const value = firstRowValue(rows[0]); - return String(value ?? ""); - }, - runMixedWorkload: async (c, seedCount: number, churnCount: number) => { - const normalizedSeedCount = Math.max(1, Math.trunc(seedCount)); - const normalizedChurnCount = Math.max(0, Math.trunc(churnCount)); - const now = Date.now(); - - for (let i = 0; i < normalizedSeedCount; i++) { - const payload = makePayload(1024 + (i % 5) * 128); - await c.db.execute( - `INSERT OR REPLACE INTO test_data (id, value, payload, created_at) VALUES (${i + 1}, 'seed-${i}', '${payload}', ${now})`, - ); - } - - for (let i = 0; i < normalizedChurnCount; i++) { - const id = (i % normalizedSeedCount) + 1; - if (i % 9 === 0) { - await c.db.execute( - `DELETE FROM test_data WHERE id = ${id}`, - ); - } else { - const payload = makePayload(768 + (i % 7) * 96); - await c.db.execute( - `INSERT OR REPLACE INTO test_data (id, value, payload, created_at) VALUES (${id}, 'upd-${i}', '${payload}', ${now + i})`, - ); - } - } - }, - repeatUpdate: async (c, id: number, count: number) => { - let value = ""; - if (count <= 0) { - return { value }; - } - const statements: string[] = ["BEGIN"]; - for (let i = 0; i < count; i++) { - value = `Updated ${i}`; - statements.push( - `UPDATE test_data SET value = '${value}' WHERE id = ${id}`, - ); - } - statements.push("COMMIT"); - await c.db.execute(statements.join("; ")); - return { value }; - }, - multiStatementInsert: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); UPDATE test_data SET value = '${value}-updated' WHERE id = last_insert_rowid(); COMMIT;`, - ); - const results = await c.db.execute<{ value: string }>( - `SELECT value FROM test_data ORDER BY id DESC LIMIT 1`, - ); - return results[0]?.value ?? null; - }, - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - }, - options: { - actionTimeout: 120_000, - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-raw.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-raw.ts deleted file mode 100644 index df3d39afec..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-raw.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db"; -import { scheduleActorSleep } from "./schedule-sleep"; - -function firstRowValue(row: Record | undefined): unknown { - if (!row) { - return undefined; - } - - const values = Object.values(row); - return values.length > 0 ? values[0] : undefined; -} - -function toSafeInteger(value: unknown): number { - if (typeof value === "bigint") { - return Number(value); - } - if (typeof value === "number") { - return Number.isFinite(value) ? Math.trunc(value) : 0; - } - if (typeof value === "string") { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function normalizeRowIds(rowIds: number[]): number[] { - const normalized = rowIds - .map((id) => Math.trunc(id)) - .filter((id) => Number.isFinite(id) && id > 0); - return Array.from(new Set(normalized)); -} - -function makePayload(size: number): string { - const normalizedSize = Math.max(0, Math.trunc(size)); - return "x".repeat(normalizedSize); -} - -export const dbActorRaw = actor({ - state: { - disconnectInsertEnabled: false, - disconnectInsertDelayMs: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS test_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - value TEXT NOT NULL, - payload TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - ) - `); - }, - }), - onDisconnect: async (c) => { - if (!c.state.disconnectInsertEnabled) { - return; - } - - if (c.state.disconnectInsertDelayMs > 0) { - await new Promise((resolve) => - setTimeout(resolve, c.state.disconnectInsertDelayMs), - ); - } - - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('__disconnect__', '', ${Date.now()})`, - ); - }, - actions: { - configureDisconnectInsert: (c, enabled: boolean, delayMs: number) => { - c.state.disconnectInsertEnabled = enabled; - c.state.disconnectInsertDelayMs = Math.max(0, Math.floor(delayMs)); - }, - getDisconnectInsertCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data WHERE value = '__disconnect__'`, - ); - return results[0]?.count ?? 0; - }, - reset: async (c) => { - await c.db.execute(`DELETE FROM test_data`); - }, - insertValue: async (c, value: string) => { - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()})`, - ); - const results = await c.db.execute<{ id: number }>( - `SELECT last_insert_rowid() as id`, - ); - return { id: results[0].id }; - }, - getValues: async (c) => { - const results = await c.db.execute<{ - id: number; - value: string; - payload: string; - created_at: number; - }>(`SELECT * FROM test_data ORDER BY id`); - return results; - }, - getValue: async (c, id: number) => { - const results = await c.db.execute<{ value: string }>( - `SELECT value FROM test_data WHERE id = ${id}`, - ); - return results[0]?.value ?? null; - }, - getCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data`, - ); - return results[0].count; - }, - rawSelectCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM test_data`, - ); - return results[0].count; - }, - insertMany: async (c, count: number) => { - if (count <= 0) { - return { count: 0 }; - } - const now = Date.now(); - const values: string[] = []; - for (let i = 0; i < count; i++) { - values.push(`('User ${i}', '', ${now})`); - } - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ${values.join(", ")}`, - ); - return { count }; - }, - updateValue: async (c, id: number, value: string) => { - await c.db.execute( - `UPDATE test_data SET value = '${value}' WHERE id = ${id}`, - ); - return { success: true }; - }, - deleteValue: async (c, id: number) => { - await c.db.execute(`DELETE FROM test_data WHERE id = ${id}`); - }, - transactionCommit: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); COMMIT;`, - ); - }, - transactionRollback: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); ROLLBACK;`, - ); - }, - insertPayloadOfSize: async (c, size: number) => { - const payload = "x".repeat(size); - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('payload', '${payload}', ${Date.now()})`, - ); - const results = await c.db.execute<{ id: number }>( - `SELECT last_insert_rowid() as id`, - ); - return { id: results[0].id, size }; - }, - getPayloadSize: async (c, id: number) => { - const results = await c.db.execute<{ size: number }>( - `SELECT length(payload) as size FROM test_data WHERE id = ${id}`, - ); - return results[0]?.size ?? 0; - }, - insertPayloadRows: async (c, count: number, payloadSize: number) => { - const normalizedCount = Math.max(0, Math.trunc(count)); - if (normalizedCount === 0) { - return { count: 0 }; - } - - const payload = makePayload(payloadSize); - const now = Date.now(); - for (let i = 0; i < normalizedCount; i++) { - await c.db.execute( - `INSERT INTO test_data (value, payload, created_at) VALUES ('bulk-${i}', '${payload}', ${now})`, - ); - } - - return { count: normalizedCount }; - }, - roundRobinUpdateValues: async ( - c, - rowIds: number[], - iterations: number, - ) => { - const normalizedRowIds = normalizeRowIds(rowIds); - const normalizedIterations = Math.max(0, Math.trunc(iterations)); - if (normalizedRowIds.length === 0 || normalizedIterations === 0) { - const emptyRows: Array<{ id: number; value: string }> = []; - return emptyRows; - } - - for (let i = 0; i < normalizedIterations; i++) { - const rowId = - normalizedRowIds[i % normalizedRowIds.length] ?? 0; - await c.db.execute( - `UPDATE test_data SET value = 'v-${i}' WHERE id = ${rowId}`, - ); - } - - return await c.db.execute<{ id: number; value: string }>( - `SELECT id, value FROM test_data WHERE id IN (${normalizedRowIds.join(",")}) ORDER BY id`, - ); - }, - getPageCount: async (c) => { - const rows = - await c.db.execute>( - "PRAGMA page_count", - ); - return toSafeInteger(firstRowValue(rows[0])); - }, - vacuum: async (c) => { - await c.db.execute("VACUUM"); - }, - integrityCheck: async (c) => { - const rows = await c.db.execute>( - "PRAGMA integrity_check", - ); - const value = firstRowValue(rows[0]); - return String(value ?? ""); - }, - runMixedWorkload: async (c, seedCount: number, churnCount: number) => { - const normalizedSeedCount = Math.max(1, Math.trunc(seedCount)); - const normalizedChurnCount = Math.max(0, Math.trunc(churnCount)); - const now = Date.now(); - - for (let i = 0; i < normalizedSeedCount; i++) { - const payload = makePayload(1024 + (i % 5) * 128); - await c.db.execute( - `INSERT OR REPLACE INTO test_data (id, value, payload, created_at) VALUES (${i + 1}, 'seed-${i}', '${payload}', ${now})`, - ); - } - - for (let i = 0; i < normalizedChurnCount; i++) { - const id = (i % normalizedSeedCount) + 1; - if (i % 9 === 0) { - await c.db.execute( - `DELETE FROM test_data WHERE id = ${id}`, - ); - } else { - const payload = makePayload(768 + (i % 7) * 96); - await c.db.execute( - `INSERT OR REPLACE INTO test_data (id, value, payload, created_at) VALUES (${id}, 'upd-${i}', '${payload}', ${now + i})`, - ); - } - } - }, - repeatUpdate: async (c, id: number, count: number) => { - let value = ""; - if (count <= 0) { - return { value }; - } - const statements: string[] = ["BEGIN"]; - for (let i = 0; i < count; i++) { - value = `Updated ${i}`; - statements.push( - `UPDATE test_data SET value = '${value}' WHERE id = ${id}`, - ); - } - statements.push("COMMIT"); - await c.db.execute(statements.join("; ")); - return { value }; - }, - multiStatementInsert: async (c, value: string) => { - await c.db.execute( - `BEGIN; INSERT INTO test_data (value, payload, created_at) VALUES ('${value}', '', ${Date.now()}); UPDATE test_data SET value = '${value}-updated' WHERE id = last_insert_rowid(); COMMIT;`, - ); - const results = await c.db.execute<{ value: string }>( - `SELECT value FROM test_data ORDER BY id DESC LIMIT 1`, - ); - return results[0]?.value ?? null; - }, - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - }, - options: { - actionTimeout: 120_000, - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-onstatechange.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-onstatechange.ts deleted file mode 100644 index 1a51841a33..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-onstatechange.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { actor } from "rivetkit"; - -export const onStateChangeActor = actor({ - state: { - value: 0, - changeCount: 0, - }, - actions: { - // Action that modifies state - should trigger onStateChange - setValue: (c, newValue: number) => { - c.state.value = newValue; - return c.state.value; - }, - // Action that modifies state multiple times - should trigger onStateChange for each change - incrementMultiple: (c, times: number) => { - for (let i = 0; i < times; i++) { - c.state.value++; - } - return c.state.value; - }, - // Action that doesn't modify state - should NOT trigger onStateChange - getValue: (c) => { - return c.state.value; - }, - // Action that reads and returns without modifying - should NOT trigger onStateChange - getDoubled: (c) => { - const doubled = c.state.value * 2; - return doubled; - }, - // Get the count of how many times onStateChange was called - getChangeCount: (c) => { - return c.state.changeCount; - }, - // Reset change counter for testing - resetChangeCount: (c) => { - c.state.changeCount = 0; - }, - }, - // Track onStateChange calls - onStateChange: (c) => { - c.state.changeCount++; - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlActor.ts deleted file mode 100644 index 8dc746f136..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { accessControlActor } from "../access-control"; - -export default accessControlActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlNoQueuesActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlNoQueuesActor.ts deleted file mode 100644 index 1e601c6c45..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/accessControlNoQueuesActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { accessControlNoQueuesActor } from "../access-control"; - -export default accessControlNoQueuesActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/asyncActionActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/asyncActionActor.ts deleted file mode 100644 index 41326cf682..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/asyncActionActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { asyncActionActor } from "../action-types"; - -export default asyncActionActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connErrorSerializationActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connErrorSerializationActor.ts deleted file mode 100644 index af3e16fb03..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connErrorSerializationActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { connErrorSerializationActor } from "../conn-error-serialization"; - -export default connErrorSerializationActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connStateActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connStateActor.ts deleted file mode 100644 index d9c404da16..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/connStateActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { connStateActor } from "../conn-state"; - -export default connStateActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counter.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counter.ts deleted file mode 100644 index a937e5edf0..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counter.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { counter } from "../counter"; - -export default counter; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterConn.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterConn.ts deleted file mode 100644 index 7bb5a05ef6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterConn.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { counterConn } from "../counter-conn"; - -export default counterConn; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithLifecycle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithLifecycle.ts deleted file mode 100644 index 62d496badb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithLifecycle.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { counterWithLifecycle } from "../lifecycle"; - -export default counterWithLifecycle; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithParams.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithParams.ts deleted file mode 100644 index 35668eed44..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/counterWithParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { counterWithParams } from "../conn-params"; - -export default counterWithParams; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/customTimeoutActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/customTimeoutActor.ts deleted file mode 100644 index bbc6660c4b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/customTimeoutActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { customTimeoutActor } from "../error-handling"; - -export default customTimeoutActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorDrizzle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorDrizzle.ts deleted file mode 100644 index 39b693ccd9..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorDrizzle.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dbActorDrizzle } from "../actor-db-drizzle"; - -export default dbActorDrizzle; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorRaw.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorRaw.ts deleted file mode 100644 index bc87408b02..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbActorRaw.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dbActorRaw } from "../actor-db-raw"; - -export default dbActorRaw; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycle.ts deleted file mode 100644 index e9d603ec72..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycle.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dbLifecycle } from "../db-lifecycle"; - -export default dbLifecycle; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleFailing.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleFailing.ts deleted file mode 100644 index b0e3768119..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleFailing.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dbLifecycleFailing } from "../db-lifecycle"; - -export default dbLifecycleFailing; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleObserver.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleObserver.ts deleted file mode 100644 index 1c52c02398..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dbLifecycleObserver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dbLifecycleObserver } from "../db-lifecycle"; - -export default dbLifecycleObserver; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/defaultTimeoutActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/defaultTimeoutActor.ts deleted file mode 100644 index 9e4a352a26..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/defaultTimeoutActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defaultTimeoutActor } from "../action-timeout"; - -export default defaultTimeoutActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyActor.ts deleted file mode 100644 index 9fddc2a0ec..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { destroyActor } from "../destroy"; - -export default destroyActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyObserver.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyObserver.ts deleted file mode 100644 index 74fad5cf0e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/destroyObserver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { destroyObserver } from "../destroy"; - -export default destroyObserver; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/driverCtxActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/driverCtxActor.ts deleted file mode 100644 index 842ecb7a73..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/driverCtxActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { driverCtxActor } from "../vars"; - -export default driverCtxActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dynamicVarActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dynamicVarActor.ts deleted file mode 100644 index 080b9021a6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/dynamicVarActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dynamicVarActor } from "../vars"; - -export default dynamicVarActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/errorHandlingActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/errorHandlingActor.ts deleted file mode 100644 index 051e3d94eb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/errorHandlingActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { errorHandlingActor } from "../error-handling"; - -export default errorHandlingActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/fileSystemHibernationCleanupActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/fileSystemHibernationCleanupActor.ts deleted file mode 100644 index 7731a0cf03..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/fileSystemHibernationCleanupActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { fileSystemHibernationCleanupActor } from "../file-system-hibernation-cleanup"; - -export default fileSystemHibernationCleanupActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/hibernationActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/hibernationActor.ts deleted file mode 100644 index dee4c289b4..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/hibernationActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { hibernationActor } from "../hibernation"; - -export default hibernationActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inlineClientActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inlineClientActor.ts deleted file mode 100644 index d28d93e278..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inlineClientActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { inlineClientActor } from "../inline-client"; - -export default inlineClientActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inputActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inputActor.ts deleted file mode 100644 index f8d70f115e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/inputActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { inputActor } from "../action-inputs"; - -export default inputActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/kvActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/kvActor.ts deleted file mode 100644 index 20cc886971..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/kvActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { kvActor } from "../kv"; - -export default kvActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadActor.ts deleted file mode 100644 index a5ecf22cf5..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { largePayloadActor } from "../large-payloads"; - -export default largePayloadActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadConnActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadConnActor.ts deleted file mode 100644 index 4d68fc7b08..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/largePayloadConnActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { largePayloadConnActor } from "../large-payloads"; - -export default largePayloadConnActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/lifecycleObserver.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/lifecycleObserver.ts deleted file mode 100644 index 4257011d91..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/lifecycleObserver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lifecycleObserver } from "../start-stop-race"; - -export default lifecycleObserver; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/longTimeoutActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/longTimeoutActor.ts deleted file mode 100644 index c003ebd7d4..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/longTimeoutActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { longTimeoutActor } from "../action-timeout"; - -export default longTimeoutActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/metadataActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/metadataActor.ts deleted file mode 100644 index 02e6c5f99b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/metadataActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { metadataActor } from "../metadata"; - -export default metadataActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/nestedVarActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/nestedVarActor.ts deleted file mode 100644 index 141a596ebb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/nestedVarActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { nestedVarActor } from "../vars"; - -export default nestedVarActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/onStateChangeActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/onStateChangeActor.ts deleted file mode 100644 index 943cf32dad..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/onStateChangeActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { onStateChangeActor } from "../actor-onstatechange"; - -export default onStateChangeActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/promiseActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/promiseActor.ts deleted file mode 100644 index 7d02008c56..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/promiseActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { promiseActor } from "../action-types"; - -export default promiseActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueActor.ts deleted file mode 100644 index d46f89c171..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { queueActor } from "../queue"; - -export default queueActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueLimitedActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueLimitedActor.ts deleted file mode 100644 index d34b1b1bee..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/queueLimitedActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { queueLimitedActor } from "../queue"; - -export default queueLimitedActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpActor.ts deleted file mode 100644 index 96313ba964..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawHttpActor } from "../raw-http"; - -export default rawHttpActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpHonoActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpHonoActor.ts deleted file mode 100644 index 4fe276f2a8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpHonoActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawHttpHonoActor } from "../raw-http"; - -export default rawHttpHonoActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpNoHandlerActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpNoHandlerActor.ts deleted file mode 100644 index cfe52407ec..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpNoHandlerActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawHttpNoHandlerActor } from "../raw-http"; - -export default rawHttpNoHandlerActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpRequestPropertiesActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpRequestPropertiesActor.ts deleted file mode 100644 index fea6400de6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpRequestPropertiesActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawHttpRequestPropertiesActor } from "../raw-http-request-properties"; - -export default rawHttpRequestPropertiesActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpVoidReturnActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpVoidReturnActor.ts deleted file mode 100644 index be08a154cf..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawHttpVoidReturnActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawHttpVoidReturnActor } from "../raw-http"; - -export default rawHttpVoidReturnActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketActor.ts deleted file mode 100644 index d73be47996..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawWebSocketActor } from "../raw-websocket"; - -export default rawWebSocketActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketBinaryActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketBinaryActor.ts deleted file mode 100644 index ae14340a59..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rawWebSocketBinaryActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rawWebSocketBinaryActor } from "../raw-websocket"; - -export default rawWebSocketBinaryActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rejectConnectionActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rejectConnectionActor.ts deleted file mode 100644 index a16da798ae..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/rejectConnectionActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { rejectConnectionActor } from "../reject-connection"; - -export default rejectConnectionActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/requestAccessActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/requestAccessActor.ts deleted file mode 100644 index 2d2209eb88..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/requestAccessActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { requestAccessActor } from "../request-access"; - -export default requestAccessActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithEarlyExit.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithEarlyExit.ts deleted file mode 100644 index f58ea5b663..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithEarlyExit.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { runWithEarlyExit } from "../run"; - -export default runWithEarlyExit; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithError.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithError.ts deleted file mode 100644 index b23e70dd33..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithError.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { runWithError } from "../run"; - -export default runWithError; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithQueueConsumer.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithQueueConsumer.ts deleted file mode 100644 index 156d4a3d6c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithQueueConsumer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { runWithQueueConsumer } from "../run"; - -export default runWithQueueConsumer; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithTicks.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithTicks.ts deleted file mode 100644 index 410ae99a82..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithTicks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { runWithTicks } from "../run"; - -export default runWithTicks; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithoutHandler.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithoutHandler.ts deleted file mode 100644 index 6ae5de749c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/runWithoutHandler.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { runWithoutHandler } from "../run"; - -export default runWithoutHandler; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/scheduled.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/scheduled.ts deleted file mode 100644 index e60b841e44..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/scheduled.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { scheduled } from "../scheduled"; - -export default scheduled; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/shortTimeoutActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/shortTimeoutActor.ts deleted file mode 100644 index 8f7a7923ed..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/shortTimeoutActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { shortTimeoutActor } from "../action-timeout"; - -export default shortTimeoutActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleep.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleep.ts deleted file mode 100644 index ee07bf696c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleep.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sleep } from "../sleep"; - -export default sleep; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithLongRpc.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithLongRpc.ts deleted file mode 100644 index 809a2082b7..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithLongRpc.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sleepWithLongRpc } from "../sleep"; - -export default sleepWithLongRpc; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithNoSleepOption.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithNoSleepOption.ts deleted file mode 100644 index 716d18e992..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithNoSleepOption.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sleepWithNoSleepOption } from "../sleep"; - -export default sleepWithNoSleepOption; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawHttp.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawHttp.ts deleted file mode 100644 index b76288ea77..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawHttp.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sleepWithRawHttp } from "../sleep"; - -export default sleepWithRawHttp; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawWebSocket.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawWebSocket.ts deleted file mode 100644 index 46d984bf69..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/sleepWithRawWebSocket.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sleepWithRawWebSocket } from "../sleep"; - -export default sleepWithRawWebSocket; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/startStopRaceActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/startStopRaceActor.ts deleted file mode 100644 index 1697113ee3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/startStopRaceActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { startStopRaceActor } from "../start-stop-race"; - -export default startStopRaceActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/statelessActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/statelessActor.ts deleted file mode 100644 index 4343a545b5..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/statelessActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { statelessActor } from "../stateless"; - -export default statelessActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/staticVarActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/staticVarActor.ts deleted file mode 100644 index 8eedb2beb2..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/staticVarActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { staticVarActor } from "../vars"; - -export default staticVarActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncActionActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncActionActor.ts deleted file mode 100644 index 5e7144393f..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncActionActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { syncActionActor } from "../action-types"; - -export default syncActionActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncTimeoutActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncTimeoutActor.ts deleted file mode 100644 index 6e71adb859..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/syncTimeoutActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { syncTimeoutActor } from "../action-timeout"; - -export default syncTimeoutActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/uniqueVarActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/uniqueVarActor.ts deleted file mode 100644 index 0098e7fc8d..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/uniqueVarActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { uniqueVarActor } from "../vars"; - -export default uniqueVarActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowAccessActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowAccessActor.ts deleted file mode 100644 index 71dc9bbbd3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowAccessActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { workflowAccessActor } from "../workflow"; - -export default workflowAccessActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowCounterActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowCounterActor.ts deleted file mode 100644 index 66d5ff2167..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowCounterActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { workflowCounterActor } from "../workflow"; - -export default workflowCounterActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowQueueActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowQueueActor.ts deleted file mode 100644 index eb98b24ad7..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowQueueActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { workflowQueueActor } from "../workflow"; - -export default workflowQueueActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowSleepActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowSleepActor.ts deleted file mode 100644 index ccf46e4fc8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowSleepActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { workflowSleepActor } from "../workflow"; - -export default workflowSleepActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowStopTeardownActor.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowStopTeardownActor.ts deleted file mode 100644 index f962ce7da6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/workflowStopTeardownActor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { workflowStopTeardownActor } from "../workflow"; - -export default workflowStopTeardownActor; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts deleted file mode 100644 index 87f1bf28e6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { agentOs } from "rivetkit/agent-os"; -import common from "@rivet-dev/agent-os-common"; - -export const agentOsTestActor = agentOs({ options: { software: [common] } }); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-error-serialization.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-error-serialization.ts deleted file mode 100644 index 900943bbc8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-error-serialization.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { actor } from "rivetkit"; -import { ActorError } from "@/actor/errors"; - -// Custom error that will be thrown in createConnState -class CustomConnectionError extends ActorError { - constructor(message: string) { - super("connection", "custom_error", message, { public: true }); - } -} - -/** - * Actor that throws a custom error in createConnState to test error serialization - */ -export const connErrorSerializationActor = actor({ - state: { - value: 0, - }, - createConnState: (_c, params: { shouldThrow?: boolean }) => { - if (params.shouldThrow) { - throw new CustomConnectionError("Test error from createConnState"); - } - return { initialized: true }; - }, - actions: { - getValue: (c) => c.state.value, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-params.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-params.ts deleted file mode 100644 index 4116a4432c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-params.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { actor } from "rivetkit"; - -export const counterWithParams = actor({ - state: { count: 0, initializers: [] as string[] }, - createConnState: (c, params: { name?: string }) => { - return { - name: params.name || "anonymous", - }; - }, - onConnect: (c, conn) => { - // Record connection name - c.state.initializers.push(conn.state.name); - }, - actions: { - increment: (c, x: number) => { - c.state.count += x; - c.broadcast("newCount", { - count: c.state.count, - by: c.conn.state.name, - }); - return c.state.count; - }, - getInitializers: (c) => { - return c.state.initializers; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-state.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-state.ts deleted file mode 100644 index 8312f5aa3e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/conn-state.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { actor } from "rivetkit"; - -export type ConnState = { - username: string; - role: string; - counter: number; - createdAt: number; - noCount: boolean; -}; - -export const connStateActor = actor({ - state: { - sharedCounter: 0, - disconnectionCount: 0, - }, - // Define connection state - createConnState: ( - c, - params: { username?: string; role?: string; noCount?: boolean }, - ): ConnState => { - return { - username: params?.username || "anonymous", - role: params?.role || "user", - counter: 0, - createdAt: Date.now(), - noCount: params?.noCount ?? false, - }; - }, - // Lifecycle hook when a connection is established - onConnect: (c, conn) => { - // Broadcast event about the new connection - c.broadcast("userConnected", { - id: conn.id, - username: "anonymous", - role: "user", - }); - }, - // Lifecycle hook when a connection is closed - onDisconnect: (c, conn) => { - if (!conn.state?.noCount) { - c.state.disconnectionCount += 1; - c.broadcast("userDisconnected", { - id: conn.id, - }); - } - }, - actions: { - // Action to increment the connection's counter - incrementConnCounter: (c, amount = 1) => { - c.conn.state.counter += amount; - }, - - // Action to increment the shared counter - incrementSharedCounter: (c, amount = 1) => { - c.state.sharedCounter += amount; - return c.state.sharedCounter; - }, - - // Get the connection state - getConnectionState: (c) => { - return { id: c.conn.id, ...c.conn.state }; - }, - - // Check all active connections - getConnectionIds: (c) => { - return c.conns - .entries() - .filter((c) => !c[1].state?.noCount) - .map((x) => x[0]) - .toArray(); - }, - - // Get disconnection count - getDisconnectionCount: (c) => { - return c.state.disconnectionCount; - }, - - // Get all active connection states - getAllConnectionStates: (c) => { - return c.conns - .entries() - .map(([id, conn]) => ({ id, ...conn.state })) - .toArray(); - }, - - // Send message to a specific connection with matching ID - sendToConnection: (c, targetId: string, message: string) => { - if (c.conns.has(targetId)) { - c.conns - .get(targetId)! - .send("directMessage", { from: c.conn.id, message }); - return true; - } else { - return false; - } - }, - - // Update connection state (simulated for tests) - updateConnection: ( - c, - updates: Partial<{ username: string; role: string }>, - ) => { - if (updates.username) c.conn.state.username = updates.username; - if (updates.role) c.conn.state.role = updates.role; - return c.conn.state; - }, - disconnectSelf: (c, reason?: string) => { - c.conn.disconnect(reason ?? "test.disconnect"); - return true; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter-conn.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter-conn.ts deleted file mode 100644 index e5207982be..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter-conn.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { actor, event } from "rivetkit"; - -export const counterConn = actor({ - state: { - connectionCount: 0, - }, - connState: { count: 0 }, - events: { - newCount: event(), - }, - onConnect: (c, conn) => { - c.state.connectionCount += 1; - }, - onDisconnect: (c, conn) => { - // Note: We can't determine if disconnect was graceful from here - // For testing purposes, we'll decrement on all disconnects - // In real scenarios, you'd use connection tracking with timeouts - c.state.connectionCount -= 1; - }, - actions: { - increment: (c, x: number) => { - c.conn.state.count += x; - c.broadcast("newCount", c.conn.state.count); - }, - setCount: (c, x: number) => { - c.conn.state.count = x; - c.broadcast("newCount", x); - }, - getCount: (c) => { - return c.conn.state.count; - }, - getConnectionCount: (c) => { - return c.state.connectionCount; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter.ts deleted file mode 100644 index 682ec1b36b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/counter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { actor, event } from "rivetkit"; - -export const counter = actor({ - state: { count: 0 }, - events: { - newCount: event(), - }, - actions: { - increment: (c, x: number) => { - c.state.count += x; - c.broadcast("newCount", c.state.count); - return c.state.count; - }, - setCount: (c, x: number) => { - c.state.count = x; - c.broadcast("newCount", x); - return c.state.count; - }, - getCount: (c) => { - return c.state.count; - }, - getKey: (c) => { - return c.key; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-kv-stats.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-kv-stats.ts deleted file mode 100644 index 25f71d1a53..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-kv-stats.ts +++ /dev/null @@ -1,359 +0,0 @@ -// @ts-nocheck -import { actor } from "rivetkit"; -import type { - DatabaseProvider, - DatabaseProviderContext, - RawAccess, -} from "rivetkit/db"; -import { AsyncMutex, toSqliteBindings } from "../../src/db/shared"; -import type { KvVfsOptions } from "@rivetkit/sqlite-vfs"; - -export interface KvStats { - getBatchCalls: number; - getBatchKeys: number; - putBatchCalls: number; - putBatchEntries: number; - deleteBatchCalls: number; -} - -export interface KvLogEntry { - op: string; - keys: string[]; -} - -const FILE_TAGS: Record = { - 0: "main", - 1: "journal", - 2: "wal", - 3: "shm", -}; - -function decodeKey(key: Uint8Array): string { - if (key.length < 4 || key[0] !== 8 || key[1] !== 1) { - return `unknown(${Array.from(key).join(",")})`; - } - const prefix = key[2]; // 0 = meta, 1 = chunk - const fileTag = FILE_TAGS[key[3]] ?? `file${key[3]}`; - if (prefix === 0) { - return `meta:${fileTag}`; - } - if (prefix === 1 && key.length === 8) { - const chunkIndex = - (key[4] << 24) | (key[5] << 16) | (key[6] << 8) | key[7]; - return `chunk:${fileTag}[${chunkIndex}]`; - } - return `unknown(${Array.from(key).join(",")})`; -} - -function instrumentedKvStore( - kv: DatabaseProviderContext["kv"], - stats: KvStats, - log: KvLogEntry[], -): KvVfsOptions { - return { - get: async (key: Uint8Array) => { - stats.getBatchCalls++; - stats.getBatchKeys++; - log.push({ op: "get", keys: [decodeKey(key)] }); - const results = await kv.batchGet([key]); - return results[0] ?? null; - }, - getBatch: async (keys: Uint8Array[]) => { - stats.getBatchCalls++; - stats.getBatchKeys += keys.length; - log.push({ op: "getBatch", keys: keys.map(decodeKey) }); - return await kv.batchGet(keys); - }, - put: async (key: Uint8Array, value: Uint8Array) => { - stats.putBatchCalls++; - stats.putBatchEntries++; - log.push({ op: "put", keys: [decodeKey(key)] }); - await kv.batchPut([[key, value]]); - }, - putBatch: async (entries: [Uint8Array, Uint8Array][]) => { - stats.putBatchCalls++; - stats.putBatchEntries += entries.length; - log.push({ - op: "putBatch", - keys: entries.map(([k]) => decodeKey(k)), - }); - await kv.batchPut(entries); - }, - deleteBatch: async (keys: Uint8Array[]) => { - stats.deleteBatchCalls++; - log.push({ op: "deleteBatch", keys: keys.map(decodeKey) }); - await kv.batchDelete(keys); - }, - }; -} - -interface ActorKvData { - stats: KvStats; - log: KvLogEntry[]; -} - -const perActorData = new Map(); - -function getOrCreateData(actorId: string): ActorKvData { - let d = perActorData.get(actorId); - if (!d) { - d = { - stats: { - getBatchCalls: 0, - getBatchKeys: 0, - putBatchCalls: 0, - putBatchEntries: 0, - deleteBatchCalls: 0, - }, - log: [], - }; - perActorData.set(actorId, d); - } - return d; -} - -const provider: DatabaseProvider = { - createClient: async (ctx) => { - if (!ctx.sqliteVfs) { - throw new Error("SqliteVfs instance not provided in context."); - } - - const data = getOrCreateData(ctx.actorId); - const kvStore = instrumentedKvStore(ctx.kv, data.stats, data.log); - const db = await ctx.sqliteVfs.open(ctx.actorId, kvStore); - let closed = false; - const mutex = new AsyncMutex(); - const ensureOpen = () => { - if (closed) throw new Error("database is closed"); - }; - - return { - execute: async < - TRow extends Record = Record, - >( - query: string, - ...args: unknown[] - ): Promise => { - return await mutex.run(async () => { - ensureOpen(); - if (args.length > 0) { - const bindings = toSqliteBindings(args); - const token = query - .trimStart() - .slice(0, 16) - .toUpperCase(); - const returnsRows = - token.startsWith("SELECT") || - token.startsWith("PRAGMA") || - token.startsWith("WITH"); - if (returnsRows) { - const { rows, columns } = await db.query( - query, - bindings, - ); - return rows.map((row: unknown[]) => { - const rowObj: Record = {}; - for (let i = 0; i < columns.length; i++) { - rowObj[columns[i]] = row[i]; - } - return rowObj; - }) as TRow[]; - } - await db.run(query, bindings); - return [] as TRow[]; - } - const results: Record[] = []; - let columnNames: string[] | null = null; - await db.exec( - query, - (row: unknown[], columns: string[]) => { - if (!columnNames) columnNames = columns; - const rowObj: Record = {}; - for (let i = 0; i < row.length; i++) { - rowObj[columnNames[i]] = row[i]; - } - results.push(rowObj); - }, - ); - return results as TRow[]; - }); - }, - close: async () => { - const shouldClose = await mutex.run(async () => { - if (closed) return false; - closed = true; - return true; - }); - if (shouldClose) { - await db.close(); - } - }, - } satisfies RawAccess; - }, - onMigrate: async (client) => { - await client.execute(` - CREATE TABLE IF NOT EXISTS counter ( - id INTEGER PRIMARY KEY, - count INTEGER NOT NULL DEFAULT 0 - ) - `); - await client.execute( - `INSERT OR IGNORE INTO counter (id, count) VALUES (1, 0)`, - ); - }, - onDestroy: async (client) => { - await client.close(); - }, -}; - -export const dbKvStatsActor = actor({ - state: {} as Record, - db: provider, - actions: { - warmUp: async (c) => { - // Prime migrations and pager cache. The first execute triggers - // the migration (CREATE TABLE + INSERT), which loads pages - // from KV into the pager cache. The second write ensures all - // dirty pages are flushed and the cache is fully warmed. - await c.db.execute( - `UPDATE counter SET count = count + 1 WHERE id = 1`, - ); - await c.db.execute( - `UPDATE counter SET count = count + 1 WHERE id = 1`, - ); - const data = getOrCreateData(c.actorId); - data.stats.getBatchCalls = 0; - data.stats.getBatchKeys = 0; - data.stats.putBatchCalls = 0; - data.stats.putBatchEntries = 0; - data.stats.deleteBatchCalls = 0; - data.log.length = 0; - }, - - resetStats: (c) => { - const data = getOrCreateData(c.actorId); - data.stats.getBatchCalls = 0; - data.stats.getBatchKeys = 0; - data.stats.putBatchCalls = 0; - data.stats.putBatchEntries = 0; - data.stats.deleteBatchCalls = 0; - data.log.length = 0; - }, - - getStats: (c) => { - return { ...getOrCreateData(c.actorId).stats }; - }, - - getLog: (c) => { - return getOrCreateData(c.actorId).log; - }, - - increment: async (c) => { - await c.db.execute( - `UPDATE counter SET count = count + 1 WHERE id = 1`, - ); - }, - - getCount: async (c) => { - const rows = await c.db.execute<{ count: number }>( - `SELECT count FROM counter WHERE id = 1`, - ); - return rows[0]?.count ?? 0; - }, - - incrementAndRead: async (c) => { - await c.db.execute( - `UPDATE counter SET count = count + 1 WHERE id = 1`, - ); - const rows = await c.db.execute<{ count: number }>( - `SELECT count FROM counter WHERE id = 1`, - ); - return rows[0]?.count ?? 0; - }, - - insertWithIndex: async (c) => { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS indexed_data ( - id INTEGER PRIMARY KEY, - value TEXT NOT NULL - ) - `); - await c.db.execute(` - CREATE INDEX IF NOT EXISTS idx_indexed_data_value ON indexed_data(value) - `); - await c.db.execute( - `INSERT INTO indexed_data (value) VALUES (?)`, - `row-${Date.now()}`, - ); - }, - - rollbackTest: async (c) => { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS rollback_test ( - id INTEGER PRIMARY KEY, - value TEXT NOT NULL - ) - `); - await c.db.execute(` - BEGIN; - INSERT INTO rollback_test (value) VALUES ('should-not-persist'); - ROLLBACK; - `); - }, - - multiStmtTx: async (c) => { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS multi_stmt ( - id INTEGER PRIMARY KEY, - value TEXT NOT NULL - ) - `); - await c.db.execute(` - BEGIN; - INSERT INTO multi_stmt (value) VALUES ('row-a'); - INSERT INTO multi_stmt (value) VALUES ('row-b'); - COMMIT; - `); - }, - - bulkInsertLarge: async (c) => { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS bulk_data ( - id INTEGER PRIMARY KEY, - payload TEXT NOT NULL - ) - `); - const pad = "x".repeat(4000); - const stmts = ["BEGIN;"]; - for (let i = 0; i < 200; i++) { - const escaped = `bulk-${i}-${pad}`.replace(/'/g, "''"); - stmts.push( - `INSERT INTO bulk_data (payload) VALUES ('${escaped}');`, - ); - } - stmts.push("COMMIT;"); - await c.db.execute(stmts.join("\n")); - }, - - getRowCount: async (c) => { - const rows = await c.db.execute<{ cnt: number }>( - `SELECT COUNT(*) as cnt FROM bulk_data`, - ); - return rows[0]?.cnt ?? 0; - }, - - runIntegrityCheck: async (c) => { - const rows = await c.db.execute<{ integrity_check: string }>( - `PRAGMA integrity_check`, - ); - return rows[0]?.integrity_check ?? "unknown"; - }, - - triggerSleep: (c) => { - c.sleep(); - }, - }, - options: { - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-lifecycle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-lifecycle.ts deleted file mode 100644 index c828790ab9..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-lifecycle.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db"; -import { scheduleActorSleep } from "./schedule-sleep"; - -type LifecycleCounts = { - create: number; - migrate: number; - cleanup: number; -}; - -const clientActorIds = new WeakMap(); - -const createCounts = new Map(); -const migrateCounts = new Map(); -const cleanupCounts = new Map(); - -function increment(map: Map, actorId: string) { - map.set(actorId, (map.get(actorId) ?? 0) + 1); -} - -function getCounts(actorId: string): LifecycleCounts { - return { - create: createCounts.get(actorId) ?? 0, - migrate: migrateCounts.get(actorId) ?? 0, - cleanup: cleanupCounts.get(actorId) ?? 0, - }; -} - -function getTotalCleanupCount(): number { - let total = 0; - for (const count of cleanupCounts.values()) { - total += count; - } - return total; -} - -const baseProvider = db({ - onMigrate: async (dbHandle) => { - await dbHandle.execute(` - CREATE TABLE IF NOT EXISTS lifecycle_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - value TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, -}); - -const lifecycleProvider = { - createClient: async ( - ctx: Parameters[0], - ) => { - const client = await baseProvider.createClient(ctx); - clientActorIds.set(client as object, ctx.actorId); - increment(createCounts, ctx.actorId); - return client; - }, - onMigrate: async (client: Parameters[0]) => { - const actorId = clientActorIds.get(client as object); - if (actorId) { - increment(migrateCounts, actorId); - } - await baseProvider.onMigrate(client); - }, - onDestroy: async ( - client: Parameters>[0], - ) => { - const actorId = clientActorIds.get(client as object); - if (actorId) { - increment(cleanupCounts, actorId); - } - await baseProvider.onDestroy?.(client); - }, -}; - -const failingLifecycleProvider = { - createClient: async ( - ctx: Parameters[0], - ) => { - const client = await baseProvider.createClient(ctx); - clientActorIds.set(client as object, ctx.actorId); - increment(createCounts, ctx.actorId); - return client; - }, - onMigrate: async (client: Parameters[0]) => { - const actorId = clientActorIds.get(client as object); - if (actorId) { - increment(migrateCounts, actorId); - } - throw new Error("forced migrate failure"); - }, - onDestroy: async ( - client: Parameters>[0], - ) => { - const actorId = clientActorIds.get(client as object); - if (actorId) { - increment(cleanupCounts, actorId); - } - await baseProvider.onDestroy?.(client); - }, -}; - -export const dbLifecycle = actor({ - db: lifecycleProvider, - actions: { - getActorId: (c) => c.actorId, - ping: () => "pong", - insertValue: async (c, value: string) => { - await c.db.execute( - "INSERT INTO lifecycle_data (value, created_at) VALUES (?, ?)", - value, - Date.now(), - ); - }, - getCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM lifecycle_data`, - ); - return results[0]?.count ?? 0; - }, - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - triggerDestroy: (c) => { - c.destroy(); - }, - }, - options: { - sleepTimeout: 100, - }, -}); - -export const dbLifecycleFailing = actor({ - db: failingLifecycleProvider, - actions: { - ping: () => "pong", - }, -}); - -export const dbLifecycleObserver = actor({ - actions: { - getCounts: (_c, actorId: string) => { - return getCounts(actorId); - }, - getTotalCleanupCount: () => { - return getTotalCleanupCount(); - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-pragma-migration.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-pragma-migration.ts deleted file mode 100644 index e7b24a5d35..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-pragma-migration.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db"; - -export const dbPragmaMigrationActor = actor({ - state: {}, - db: db({ - onMigrate: async (db) => { - const [{ user_version }] = (await db.execute( - "PRAGMA user_version", - )) as { user_version: number }[]; - - if (user_version < 1) { - await db.execute(` - CREATE TABLE items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - `); - } - - if (user_version < 2) { - await db.execute(` - ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active' - `); - } - - await db.execute("PRAGMA user_version = 2"); - }, - }), - actions: { - insertItem: async (c, name: string) => { - await c.db.execute( - `INSERT INTO items (name) VALUES ('${name}')`, - ); - const results = await c.db.execute<{ id: number }>( - "SELECT last_insert_rowid() as id", - ); - return { id: results[0].id }; - }, - insertItemWithStatus: async (c, name: string, status: string) => { - await c.db.execute( - `INSERT INTO items (name, status) VALUES ('${name}', '${status}')`, - ); - const results = await c.db.execute<{ id: number }>( - "SELECT last_insert_rowid() as id", - ); - return { id: results[0].id }; - }, - getItems: async (c) => { - return await c.db.execute<{ - id: number; - name: string; - status: string; - }>("SELECT id, name, status FROM items ORDER BY id"); - }, - getUserVersion: async (c) => { - const results = (await c.db.execute("PRAGMA user_version")) as { - user_version: number; - }[]; - return results[0].user_version; - }, - getColumns: async (c) => { - const results = await c.db.execute<{ name: string }>( - "PRAGMA table_info(items)", - ); - return results.map((r) => r.name); - }, - triggerSleep: (c) => { - c.sleep(); - }, - }, - options: { - actionTimeout: 120_000, - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-stress.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-stress.ts deleted file mode 100644 index 9239c5b5ac..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-stress.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db"; - -export const dbStressActor = actor({ - state: {}, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS stress_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - value TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - actions: { - // Insert many rows in a single action. Used to create a long-running - // DB operation that can race with destroy/disconnect. - insertBatch: async (c, count: number) => { - const now = Date.now(); - const values: string[] = []; - for (let i = 0; i < count; i++) { - values.push(`('row-${i}', ${now})`); - } - await c.db.execute( - `INSERT INTO stress_data (value, created_at) VALUES ${values.join(", ")}`, - ); - return { count }; - }, - - getCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM stress_data`, - ); - return results[0].count; - }, - - // Measure event loop health during a DB operation. - // Runs a Promise.resolve() microtask check interleaved with DB - // inserts to detect if the event loop is being blocked between - // awaits. Reports the wall-clock duration so the test can verify - // the inserts complete in a reasonable time (not blocked by - // synchronous lifecycle operations). - measureEventLoopHealth: async (c, insertCount: number) => { - const startMs = Date.now(); - - // Do DB work that should NOT block the event loop. - // Insert rows one at a time to create many async round-trips. - for (let i = 0; i < insertCount; i++) { - await c.db.execute( - `INSERT INTO stress_data (value, created_at) VALUES ('drift-${i}', ${Date.now()})`, - ); - } - - const elapsedMs = Date.now() - startMs; - - return { - elapsedMs, - insertCount, - }; - }, - - // Write data to multiple rows that can be verified after a - // forced disconnect and reconnect. - writeAndVerify: async (c, count: number) => { - const now = Date.now(); - for (let i = 0; i < count; i++) { - await c.db.execute( - `INSERT INTO stress_data (value, created_at) VALUES ('verify-${i}', ${now})`, - ); - } - - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM stress_data WHERE value LIKE 'verify-%'`, - ); - return results[0].count; - }, - - integrityCheck: async (c) => { - const rows = await c.db.execute>( - "PRAGMA integrity_check", - ); - const value = Object.values(rows[0] ?? {})[0]; - return String(value ?? ""); - }, - - triggerSleep: (c) => { - c.sleep(); - }, - - reset: async (c) => { - await c.db.execute(`DELETE FROM stress_data`); - }, - - destroy: (c) => { - c.destroy(); - }, - }, - options: { - actionTimeout: 120_000, - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/migrations.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/migrations.ts deleted file mode 100644 index b6f73c5e83..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/migrations.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const migrations = { - journal: { - entries: [ - { - idx: 0, - when: 1700000000000, - tag: "0000_init", - breakpoints: false, - }, - ], - }, - migrations: { - m0000: ` - CREATE TABLE IF NOT EXISTS test_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - value TEXT NOT NULL, - payload TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - ); - `, - }, -}; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/schema.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/schema.ts deleted file mode 100644 index 5a6d5f63fe..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db/schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -export const testData = sqliteTable("test_data", { - id: integer("id").primaryKey({ autoIncrement: true }), - value: text("value").notNull(), - payload: text("payload").notNull().default(""), - createdAt: integer("created_at").notNull(), -}); - -export const schema = { - testData, -}; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/destroy.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/destroy.ts deleted file mode 100644 index 56c7de1f46..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/destroy.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { actor, queue } from "rivetkit"; -import type { registry } from "./registry"; - -export const destroyObserver = actor({ - state: { destroyedActors: [] as string[] }, - actions: { - notifyDestroyed: (c, actorKey: string) => { - c.state.destroyedActors.push(actorKey); - }, - wasDestroyed: (c, actorKey: string) => { - return c.state.destroyedActors.includes(actorKey); - }, - reset: (c) => { - c.state.destroyedActors = []; - }, - }, -}); - -export const destroyActor = actor({ - state: { value: 0, key: "" }, - queues: { - values: queue(), - }, - onWake: (c) => { - // Store the actor key so we can reference it in onDestroy - c.state.key = c.key.join("/"); - }, - onRequest: (c, request) => { - const url = new URL(request.url); - if (url.pathname === "/state") { - return Response.json({ - key: c.state.key, - value: c.state.value, - }); - } - - return new Response("Not Found", { status: 404 }); - }, - onWebSocket: (c, websocket) => { - websocket.send( - JSON.stringify({ - type: "welcome", - key: c.state.key, - value: c.state.value, - }), - ); - }, - onDestroy: async (c) => { - const client = c.client(); - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.notifyDestroyed(c.state.key); - }, - actions: { - setValue: async (c, newValue: number) => { - c.state.value = newValue; - await c.saveState({ immediate: true }); - return c.state.value; - }, - getValue: (c) => { - return c.state.value; - }, - receiveValue: async (c) => { - const message = await c.queue.next({ - names: ["values"], - timeout: 0, - }); - return message?.body ?? null; - }, - destroy: (c) => { - c.destroy(); - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/dynamic-registry.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/dynamic-registry.ts deleted file mode 100644 index 3f209ea1a3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/dynamic-registry.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { actor, setup, UserError } from "rivetkit"; -import { dynamicActor } from "rivetkit/dynamic"; - -export const DYNAMIC_SOURCE = ` -import { actor } from "rivetkit"; - -const SLEEP_TIMEOUT = 200; - -export default actor({ - state: { - count: 0, - wakeCount: 0, - sleepCount: 0, - alarmCount: 0, - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onRequest: async (_c, request) => { - return new Response( - JSON.stringify({ - method: request.method, - token: request.headers.get("x-dynamic-auth"), - }), - { - headers: { - "content-type": "application/json", - }, - }, - ); - }, - onWebSocket: (c, websocket) => { - websocket.send( - JSON.stringify({ - type: "welcome", - wakeCount: c.state.wakeCount, - }), - ); - - websocket.addEventListener("message", (event) => { - const data = event.data; - if (typeof data === "string") { - try { - const parsed = JSON.parse(data); - if (parsed.type === "ping") { - websocket.send(JSON.stringify({ type: "pong" })); - return; - } - if (parsed.type === "stats") { - websocket.send( - JSON.stringify({ - type: "stats", - count: c.state.count, - wakeCount: c.state.wakeCount, - sleepCount: c.state.sleepCount, - alarmCount: c.state.alarmCount, - }), - ); - return; - } - } catch {} - websocket.send(data); - return; - } - - websocket.send(data); - }); - }, - actions: { - increment: (c, amount = 1) => { - c.state.count += amount; - return c.state.count; - }, - getState: (c) => { - return { - count: c.state.count, - wakeCount: c.state.wakeCount, - sleepCount: c.state.sleepCount, - alarmCount: c.state.alarmCount, - }; - }, - getSourceCodeLength: async (c) => { - const source = (await c - .client() - .sourceCode.getOrCreate(["dynamic-source"]) - .getCode()); - return source.length; - }, - putText: async (c, key, value) => { - await c.kv.put(key, value); - return true; - }, - getText: async (c, key) => { - return await c.kv.get(key); - }, - listText: async (c, prefix) => { - const values = await c.kv.list(prefix, { keyType: "text" }); - return values.map(([key, value]) => ({ key, value })); - }, - triggerSleep: (c) => { - globalThis.setTimeout(() => { - c.sleep(); - }, 0); - return true; - }, - scheduleAlarm: async (c, duration) => { - await c.schedule.after(duration, "onAlarm"); - return true; - }, - onAlarm: (c) => { - c.state.alarmCount += 1; - return c.state.alarmCount; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); -`; - -const sourceCode = actor({ - actions: { - getCode: () => DYNAMIC_SOURCE, - }, -}); - -const dynamicFromUrl = dynamicActor({ - load: async () => { - const sourceUrl = process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL; - if (!sourceUrl) { - throw new Error( - "missing RIVETKIT_DYNAMIC_TEST_SOURCE_URL for dynamic actor URL loader", - ); - } - - const response = await fetch(sourceUrl); - if (!response.ok) { - throw new Error( - `dynamic actor URL loader failed with status ${response.status}`, - ); - } - - return { - source: await response.text(), - sourceFormat: "esm-js" as const, - nodeProcess: { - memoryLimit: 256, - cpuTimeLimitMs: 10_000, - }, - }; - }, -}); - -const dynamicFromActor = dynamicActor({ - load: async (c) => { - const source = (await c - .client() - .sourceCode.getOrCreate(["dynamic-source"]) - .getCode()) as string; - return { - source, - sourceFormat: "esm-js" as const, - nodeProcess: { - memoryLimit: 256, - cpuTimeLimitMs: 10_000, - }, - }; - }, -}); - -const dynamicWithAuth = dynamicActor({ - load: async (c) => { - const source = (await c - .client() - .sourceCode.getOrCreate(["dynamic-source"]) - .getCode()) as string; - return { - source, - sourceFormat: "esm-js" as const, - nodeProcess: { - memoryLimit: 256, - cpuTimeLimitMs: 10_000, - }, - }; - }, - auth: (c, params: unknown) => { - const authHeader = c.request?.headers.get("x-dynamic-auth"); - const authToken = - typeof params === "object" && - params !== null && - "token" in params && - typeof (params as { token?: unknown }).token === "string" - ? (params as { token: string }).token - : undefined; - if (authHeader === "allow" || authToken === "allow") { - return; - } - throw new UserError("auth required", { - code: "unauthorized", - metadata: { - hasRequest: c.request !== undefined, - }, - }); - }, -}); - -const dynamicLoaderThrows = dynamicActor({ - load: async () => { - throw new Error("dynamic.loader_failed_for_test"); - }, -}); - -const dynamicInvalidSource = dynamicActor({ - load: async () => { - return { - source: "export default 42;", - sourceFormat: "esm-js" as const, - }; - }, -}); - -export const registry = setup({ - use: { - sourceCode, - dynamicFromUrl, - dynamicFromActor, - dynamicWithAuth, - dynamicLoaderThrows, - dynamicInvalidSource, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/error-handling.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/error-handling.ts deleted file mode 100644 index e1501a0a0c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/error-handling.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -export const errorHandlingActor = actor({ - state: { - errorLog: [] as string[], - }, - actions: { - // Action that throws a UserError with just a message - throwSimpleError: () => { - throw new UserError("Simple error message"); - }, - - // Action that throws a UserError with code and metadata - throwDetailedError: () => { - throw new UserError("Detailed error message", { - code: "detailed_error", - metadata: { - reason: "test", - timestamp: Date.now(), - }, - }); - }, - - // Action that throws an internal error - throwInternalError: () => { - throw new Error("This is an internal error"); - }, - - // Action that returns successfully - successfulAction: () => { - return "success"; - }, - - // Action that times out (simulated with a long delay) - timeoutAction: async (c) => { - // This action should time out if the timeout is configured - return new Promise((resolve) => { - setTimeout(() => { - resolve("This should not be reached if timeout works"); - }, 10000); // 10 seconds - }); - }, - - // Action with configurable delay to test timeout edge cases - delayedAction: async (c, delayMs: number) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(`Completed after ${delayMs}ms`); - }, delayMs); - }); - }, - - // Log an error for inspection - logError: (c, error: string) => { - c.state.errorLog.push(error); - return c.state.errorLog; - }, - - // Get the error log - getErrorLog: (c) => { - return c.state.errorLog; - }, - - // Clear the error log - clearErrorLog: (c) => { - c.state.errorLog = []; - return true; - }, - }, - options: { - actionTimeout: 500, // 500ms timeout for actions - }, -}); - -// Actor with custom timeout -export const customTimeoutActor = actor({ - state: {}, - actions: { - quickAction: async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - return "Quick action completed"; - }, - slowAction: async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - return "Slow action completed"; - }, - }, - options: { - actionTimeout: 200, // 200ms timeout - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/file-system-hibernation-cleanup.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/file-system-hibernation-cleanup.ts deleted file mode 100644 index bdf0438778..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/file-system-hibernation-cleanup.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { actor } from "rivetkit"; -import { scheduleActorSleep } from "./schedule-sleep"; - -export const fileSystemHibernationCleanupActor = actor({ - state: { - wakeCount: 0, - sleepCount: 0, - disconnectWakeCounts: [] as number[], - }, - createConnState: () => ({}), - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onDisconnect: (c, conn) => { - // Only track WebSocket connection cleanup. HTTP actions are ephemeral. - if (conn.isHibernatable) { - c.state.disconnectWakeCounts.push(c.state.wakeCount); - } - }, - actions: { - ping: () => "pong", - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - getCounts: (c) => ({ - wakeCount: c.state.wakeCount, - sleepCount: c.state.sleepCount, - }), - getDisconnectWakeCounts: (c) => c.state.disconnectWakeCounts, - }, - options: { - sleepTimeout: 500, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/hibernation.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/hibernation.ts deleted file mode 100644 index 6e60086085..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/hibernation.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { actor, event } from "rivetkit"; -import { scheduleActorSleep } from "./schedule-sleep"; - -export const HIBERNATION_SLEEP_TIMEOUT = 500; - -export type HibernationConnState = { - count: number; - connectCount: number; - disconnectCount: number; -}; - -export const hibernationActor = actor({ - state: { - sleepCount: 0, - wakeCount: 0, - }, - createConnState: (c): HibernationConnState => { - return { - count: 0, - connectCount: 0, - disconnectCount: 0, - }; - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onConnect: (c, conn) => { - conn.state.connectCount += 1; - }, - onDisconnect: (c, conn) => { - conn.state.disconnectCount += 1; - }, - actions: { - // Basic RPC that returns a simple value - ping: (c) => { - return "pong"; - }, - // Increment the connection's count - connIncrement: (c) => { - c.conn.state.count += 1; - return c.conn.state.count; - }, - // Get the connection's count - getConnCount: (c) => { - return c.conn.state.count; - }, - // Get the connection's lifecycle counts - getConnLifecycleCounts: (c) => { - return { - connectCount: c.conn.state.connectCount, - disconnectCount: c.conn.state.disconnectCount, - }; - }, - // Get all connection IDs - getConnectionIds: (c) => { - return c.conns - .entries() - .map((x) => x[0]) - .toArray(); - }, - // Get actor sleep/wake counts - getActorCounts: (c) => { - return { - sleepCount: c.state.sleepCount, - wakeCount: c.state.wakeCount, - }; - }, - // Trigger sleep - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - }, - options: { - sleepTimeout: HIBERNATION_SLEEP_TIMEOUT, - }, -}); - -export const hibernationSleepWindowActor = actor({ - state: { - sleepCount: 0, - wakeCount: 0, - }, - connState: {}, - events: { - sleeping: event(), - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - c.broadcast("sleeping", undefined); - await new Promise((resolve) => setTimeout(resolve, 500)); - }, - actions: { - getActorCounts: (c) => { - return { - sleepCount: c.state.sleepCount, - wakeCount: c.state.wakeCount, - }; - }, - triggerSleep: (c) => { - c.sleep(); - }, - }, - options: { - sleepTimeout: HIBERNATION_SLEEP_TIMEOUT, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/inline-client.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/inline-client.ts deleted file mode 100644 index 596eb735bd..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/inline-client.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { actor } from "rivetkit"; -import type { registry } from "./registry"; - -export const inlineClientActor = actor({ - state: { messages: [] as string[] }, - actions: { - // Action that uses client to call another actor (stateless) - callCounterIncrement: async (c, amount: number) => { - const client = c.client(); - const result = await client.counter - .getOrCreate(["inline-test"]) - .increment(amount); - c.state.messages.push( - `Called counter.increment(${amount}), result: ${result}`, - ); - return result; - }, - - // Action that uses client to get counter state (stateless) - getCounterState: async (c) => { - const client = c.client(); - const count = await client.counter - .getOrCreate(["inline-test"]) - .getCount(); - c.state.messages.push(`Got counter state: ${count}`); - return count; - }, - - // Action that uses client with .connect() for stateful communication - connectToCounterAndIncrement: async (c, amount: number) => { - const client = c.client(); - const handle = client.counter.getOrCreate(["inline-test-stateful"]); - const connection = handle.connect(); - - // Set up event listener - const events: number[] = []; - connection.on("newCount", (count: number) => { - events.push(count); - }); - - // Perform increments - const result1 = await connection.increment(amount); - const result2 = await connection.increment(amount * 2); - - await connection.dispose(); - - c.state.messages.push( - `Connected to counter, incremented by ${amount} and ${amount * 2}, results: ${result1}, ${result2}, events: ${JSON.stringify(events)}`, - ); - - return { result1, result2, events }; - }, - - // Get all messages from this actor's state - getMessages: (c) => { - return c.state.messages; - }, - - // Clear messages - clearMessages: (c) => { - c.state.messages = []; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/kv.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/kv.ts deleted file mode 100644 index 7cc9bcaeed..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/kv.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { actor, type ActorContext } from "rivetkit"; - -export const kvActor = actor({ - actions: { - putText: async ( - c: ActorContext, - key: string, - value: string, - ) => { - await c.kv.put(key, value); - return true; - }, - getText: async ( - c: ActorContext, - key: string, - ) => { - return await c.kv.get(key); - }, - listText: async ( - c: ActorContext, - prefix: string, - ) => { - const results = await c.kv.list(prefix, { keyType: "text" }); - return results.map(([key, value]) => ({ - key, - value, - })); - }, - listTextRange: async ( - c: ActorContext, - start: string, - end: string, - options?: { - reverse?: boolean; - limit?: number; - }, - ) => { - const results = await c.kv.listRange(start, end, { - keyType: "text", - ...options, - }); - return results.map(([key, value]) => ({ - key, - value, - })); - }, - deleteTextRange: async ( - c: ActorContext, - start: string, - end: string, - ) => { - await c.kv.deleteRange(start, end); - return true; - }, - roundtripArrayBuffer: async ( - c: ActorContext, - key: string, - values: number[], - ) => { - const buffer = new Uint8Array(values).buffer; - await c.kv.put(key, buffer, { type: "arrayBuffer" }); - const result = await c.kv.get(key, { type: "arrayBuffer" }); - if (!result) { - return null; - } - return Array.from(new Uint8Array(result)); - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/large-payloads.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/large-payloads.ts deleted file mode 100644 index 52bc8ea6f4..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/large-payloads.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { actor } from "rivetkit"; - -/** - * Actor for testing large payloads without connections - */ -export const largePayloadActor = actor({ - state: {}, - actions: { - /** - * Accepts a large request payload and returns its size - */ - processLargeRequest: (c, data: { items: string[] }) => { - return { - itemCount: data.items.length, - firstItem: data.items[0], - lastItem: data.items[data.items.length - 1], - }; - }, - - /** - * Returns a large response payload - */ - getLargeResponse: (c, itemCount: number) => { - const items: string[] = []; - for (let i = 0; i < itemCount; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - return { items }; - }, - - /** - * Echo back the request data - */ - echo: (c, data: unknown) => { - return data; - }, - }, -}); - -/** - * Actor for testing large payloads with connections - */ -export const largePayloadConnActor = actor({ - state: {}, - connState: { - lastRequestSize: 0, - }, - actions: { - /** - * Accepts a large request payload and returns its size - */ - processLargeRequest: (c, data: { items: string[] }) => { - c.conn.state.lastRequestSize = data.items.length; - return { - itemCount: data.items.length, - firstItem: data.items[0], - lastItem: data.items[data.items.length - 1], - }; - }, - - /** - * Returns a large response payload - */ - getLargeResponse: (c, itemCount: number) => { - const items: string[] = []; - for (let i = 0; i < itemCount; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - return { items }; - }, - - /** - * Echo back the request data - */ - echo: (c, data: unknown) => { - return data; - }, - - /** - * Get the last request size - */ - getLastRequestSize: (c) => { - return c.conn.state.lastRequestSize; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/lifecycle.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/lifecycle.ts deleted file mode 100644 index 2fb790e734..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/lifecycle.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { actor } from "rivetkit"; - -type ConnParams = { trackLifecycle?: boolean } | undefined; - -export const counterWithLifecycle = actor({ - state: { - count: 0, - events: [] as string[], - }, - createConnState: (c, params: ConnParams) => ({ - joinTime: Date.now(), - }), - onWake: (c) => { - c.state.events.push("onWake"); - }, - onBeforeConnect: (c, params: ConnParams) => { - if (params?.trackLifecycle) c.state.events.push("onBeforeConnect"); - }, - onConnect: (c, conn) => { - if (conn.params?.trackLifecycle) c.state.events.push("onConnect"); - }, - onDisconnect: (c, conn) => { - if (conn.params?.trackLifecycle) c.state.events.push("onDisconnect"); - }, - actions: { - getEvents: (c) => { - return c.state.events; - }, - increment: (c, x: number) => { - c.state.count += x; - return c.state.count; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/metadata.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/metadata.ts deleted file mode 100644 index 7d8641d817..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/metadata.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { actor } from "rivetkit"; - -// Note: For testing only - metadata API will need to be mocked -// in tests since this is implementation-specific -export const metadataActor = actor({ - state: { - lastMetadata: null as any, - actorName: "", - // Store tags and region in state for testing since they may not be - // available in the context in all environments - storedTags: {} as Record, - storedRegion: null as string | null, - }, - onWake: (c) => { - // Store the actor name during initialization - c.state.actorName = c.name; - }, - actions: { - // Set up test tags - this will be called by tests to simulate tags - setupTestTags: (c, tags: Record) => { - c.state.storedTags = tags; - return tags; - }, - - // Set up test region - this will be called by tests to simulate region - setupTestRegion: (c, region: string) => { - c.state.storedRegion = region; - return region; - }, - - // Get all available metadata - getMetadata: (c) => { - // Create metadata object from stored values - const metadata = { - name: c.name, - tags: c.state.storedTags, - region: c.state.storedRegion, - }; - - // Store for later inspection - c.state.lastMetadata = metadata; - return metadata; - }, - - // Get the actor name - getActorName: (c) => { - return c.name; - }, - - // Get a specific tag by key - getTag: (c, key: string) => { - return c.state.storedTags[key] || null; - }, - - // Get all tags - getTags: (c) => { - return c.state.storedTags; - }, - - // Get the region - getRegion: (c) => { - return c.state.storedRegion; - }, - - // Get the stored actor name (from onWake) - getStoredActorName: (c) => { - return c.state.actorName; - }, - - // Get last retrieved metadata - getLastMetadata: (c) => { - return c.state.lastMetadata; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/queue.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/queue.ts deleted file mode 100644 index cf45ee01cb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/queue.ts +++ /dev/null @@ -1,313 +0,0 @@ -// @ts-nocheck -import { actor, queue } from "rivetkit"; -import type { registry } from "./registry"; - -const queueSchemas = { - greeting: queue<{ hello: string }>(), - self: queue<{ value: number }>(), - a: queue(), - b: queue(), - c: queue(), - one: queue(), - two: queue(), - missing: queue(), - abort: queue(), - tasks: queue<{ value: number }, { echo: { value: number } }>(), - timeout: queue<{ value: number }, { ok: true }>(), - nowait: queue<{ value: string }>(), - twice: queue<{ value: string }, { ok: true }>(), -} as const; - -type QueueName = keyof typeof queueSchemas; - -export const queueActor = actor({ - state: {}, - queues: queueSchemas, - actions: { - receiveOne: async (c, name: QueueName, opts?: { timeout?: number }) => { - const message = await c.queue.next({ - names: [name], - timeout: opts?.timeout, - }); - if (!message) { - return null; - } - return { name: message.name, body: message.body }; - }, - receiveMany: async ( - c, - names: QueueName[], - opts?: { count?: number; timeout?: number }, - ) => { - const messages = await c.queue.nextBatch({ - names, - count: opts?.count, - timeout: opts?.timeout, - }); - return messages.map((message) => ({ - name: message.name, - body: message.body, - })); - }, - receiveRequest: async ( - c, - request: { - names?: QueueName[]; - count?: number; - timeout?: number; - }, - ) => { - const messages = await c.queue.nextBatch(request); - return messages.map((message) => ({ - name: message.name, - body: message.body, - })); - }, - tryReceiveMany: async ( - c, - request: { - names?: QueueName[]; - count?: number; - }, - ) => { - const messages = await c.queue.tryNextBatch(request); - return messages.map((message) => ({ - name: message.name, - body: message.body, - })); - }, - receiveWithIterator: async (c, name: QueueName) => { - for await (const message of c.queue.iter({ names: [name] })) { - return { name: message.name, body: message.body }; - } - return null; - }, - receiveWithAsyncIterator: async (c) => { - for await (const message of c.queue.iter()) { - return { name: message.name, body: message.body }; - } - return null; - }, - sendToSelf: async (c, name: QueueName, body: unknown) => { - const client = c.client(); - const handle = client.queueActor.getForId(c.actorId); - await handle.send(name, body); - return true; - }, - waitForAbort: async (c) => { - setTimeout(() => { - c.destroy(); - }, 10); - await c.queue.next({ names: ["abort"], timeout: 10_000 }); - return true; - }, - waitForSignalAbort: async (c) => { - const controller = new AbortController(); - controller.abort(); - try { - await c.queue.next({ - names: ["abort"], - timeout: 10_000, - signal: controller.signal, - }); - return { ok: false }; - } catch (error) { - const actorError = error as { group?: string; code?: string }; - return { group: actorError.group, code: actorError.code }; - } - }, - waitForActorAbortWithSignal: async (c) => { - const controller = new AbortController(); - setTimeout(() => { - c.destroy(); - }, 10); - try { - await c.queue.next({ - names: ["abort"], - timeout: 10_000, - signal: controller.signal, - }); - return { ok: false }; - } catch (error) { - const actorError = error as { group?: string; code?: string }; - return { group: actorError.group, code: actorError.code }; - } - }, - iterWithSignalAbort: async (c) => { - const controller = new AbortController(); - controller.abort(); - for await (const _message of c.queue.iter({ - names: ["abort"], - signal: controller.signal, - })) { - return { ok: false }; - } - return { ok: true }; - }, - receiveAndComplete: async (c, name: "tasks") => { - const message = await c.queue.next({ - names: [name], - completable: true, - }); - if (!message) { - return null; - } - await message.complete({ echo: message.body }); - return { name: message.name, body: message.body }; - }, - receiveWithoutComplete: async (c, name: "tasks") => { - const message = await c.queue.next({ - names: [name], - completable: true, - }); - if (!message) { - return null; - } - return { name: message.name, body: message.body }; - }, - receiveManualThenNextWithoutComplete: async (c, name: "tasks") => { - const message = await c.queue.next({ - names: [name], - completable: true, - }); - if (!message) { - return { ok: false, reason: "no_message" }; - } - - try { - await c.queue.next({ names: [name], timeout: 0 }); - c.destroy(); - return { ok: false, reason: "next_succeeded" }; - } catch (error) { - c.destroy(); - const actorError = error as { group?: string; code?: string }; - return { group: actorError.group, code: actorError.code }; - } - }, - receiveAndCompleteTwice: async (c, name: "twice") => { - const message = await c.queue.next({ - names: [name], - completable: true, - }); - if (!message) { - return null; - } - await message.complete({ ok: true }); - try { - await message.complete({ ok: true }); - return { ok: false }; - } catch (error) { - const actorError = error as { group?: string; code?: string }; - return { group: actorError.group, code: actorError.code }; - } - }, - receiveWithoutCompleteMethod: async (c, name: "nowait") => { - const message = await c.queue.next({ - names: [name], - completable: true, - }); - return { - hasComplete: - message !== undefined && - typeof message.complete === "function", - }; - }, - }, -}); - -export const queueLimitedActor = actor({ - state: {}, - queues: { - message: queue(), - oversize: queue(), - }, - actions: {}, - options: { - maxQueueSize: 1, - maxQueueMessageSize: 64, - }, -}); - -export const MANY_QUEUE_NAMES = Array.from( - { length: 32 }, - (_, i) => `cmd.${i}` as const, -); - -const manyQueueSchemas = Object.fromEntries( - MANY_QUEUE_NAMES.map((name) => [ - name, - queue<{ index: number }, { ok: true; index: number }>(), - ]), -); - -export const manyQueueChildActor = actor({ - queues: manyQueueSchemas, - actions: { - ping: (c) => ({ label: c.state.label, pong: true }), - getSnapshot: (c) => c.state, - }, - createState: (_c, label: string) => ({ - label, - started: false, - processed: [] as string[], - }), - run: async (c) => { - c.state.started = true; - for await (const msg of c.queue.iter({ - names: [...MANY_QUEUE_NAMES], - completable: true, - })) { - c.state.processed.push(msg.name); - await msg.complete({ - ok: true, - index: msg.body.index, - }); - } - }, -}); - -export const manyQueueActionParentActor = actor({ - state: { - spawned: [] as string[], - }, - actions: { - spawnChild: async (c, key: string) => { - const client = c.client(); - await client.manyQueueChildActor.getOrCreate([key], { - createWithInput: key, - }); - c.state.spawned.push(key); - return { key }; - }, - getSpawned: (c) => c.state.spawned, - }, -}); - -export const manyQueueRunParentActor = actor({ - state: { - spawned: [] as string[], - }, - queues: { - spawn: queue<{ key: string }>(), - }, - actions: { - queueSpawn: async (c, key: string) => { - await c.queue.send("spawn", { key }); - return { queued: true }; - }, - getSpawned: (c) => c.state.spawned, - }, - run: async (c) => { - for await (const msg of c.queue.iter({ - names: ["spawn"], - completable: true, - })) { - const client = c.client(); - await client.manyQueueChildActor.getOrCreate([msg.body.key], { - createWithInput: msg.body.key, - }); - c.state.spawned.push(msg.body.key); - await msg.complete({ ok: true }); - } - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http-request-properties.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http-request-properties.ts deleted file mode 100644 index ef5d2c411e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http-request-properties.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { actor, type RequestContext } from "rivetkit"; - -export const rawHttpRequestPropertiesActor = actor({ - actions: {}, - onRequest( - ctx: RequestContext, - request: Request, - ) { - // Extract all relevant Request properties - const url = new URL(request.url); - const method = request.method; - - // Get all headers - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); - - // Handle body based on content type - const handleBody = async () => { - if (!request.body) { - return null; - } - - const contentType = request.headers.get("content-type") || ""; - - try { - if (contentType.includes("application/json")) { - const text = await request.text(); - return text ? JSON.parse(text) : null; - } else { - // For non-JSON, return as text - const text = await request.text(); - return text || null; // Return null for empty bodies - } - } catch (error) { - // If body parsing fails, return null - return null; - } - }; - - // Special handling for HEAD and OPTIONS requests - if (method === "HEAD") { - return new Response(null, { - status: 200, - }); - } - - if (method === "OPTIONS") { - return new Response(null, { - status: 204, - }); - } - - // Return all request properties as JSON - return handleBody().then((body) => { - const responseData = { - // URL properties - url: request.url, - pathname: url.pathname, - search: url.search, - searchParams: Object.fromEntries(url.searchParams.entries()), - hash: url.hash, - - // Method - method: request.method, - - // Headers - headers: headers, - - // Body - body, - bodyText: - typeof body === "string" - ? body - : body === null && request.body !== null - ? "" - : null, - - // Additional properties that might be available - // Note: Some properties like cache, credentials, mode, etc. - // might not be available in all environments - cache: request.cache || null, - credentials: request.credentials || null, - mode: request.mode || null, - redirect: request.redirect || null, - referrer: request.referrer || null, - }; - - return new Response(JSON.stringify(responseData), { - headers: { "Content-Type": "application/json" }, - }); - }); - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http.ts deleted file mode 100644 index 48cbf7121b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-http.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Hono } from "hono"; -import { actor, type RequestContext } from "rivetkit"; - -export const rawHttpActor = actor({ - state: { - requestCount: 0, - }, - onRequest( - ctx: RequestContext, - request: Request, - ) { - const url = new URL(request.url); - const method = request.method; - - // Track request count - ctx.state.requestCount++; - - // Handle different endpoints - if (url.pathname === "/api/hello") { - return new Response( - JSON.stringify({ message: "Hello from actor!" }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } - - if (url.pathname === "/api/echo" && method === "POST") { - return new Response(request.body, { - headers: request.headers, - }); - } - - if (url.pathname === "/api/state") { - return new Response( - JSON.stringify({ - requestCount: ctx.state.requestCount, - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } - - if (url.pathname === "/api/headers") { - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); - return new Response(JSON.stringify(headers), { - headers: { "Content-Type": "application/json" }, - }); - } - - // Return 404 for unhandled paths - return new Response("Not Found", { status: 404 }); - }, - actions: {}, -}); - -export const rawHttpNoHandlerActor = actor({ - actions: {}, -}); - -export const rawHttpVoidReturnActor = actor({ - onRequest(ctx, request) { - // Intentionally return void to test error handling - return undefined as any; - }, - actions: {}, -}); - -export const rawHttpHonoActor = actor({ - createVars() { - const router = new Hono(); - - // Set up routes - router.get("/", (c: any) => - c.json({ message: "Welcome to Hono actor!" }), - ); - - router.get("/users", (c: any) => - c.json([ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, - ]), - ); - - router.get("/users/:id", (c: any) => { - const id = c.req.param("id"); - return c.json({ - id: parseInt(id), - name: id === "1" ? "Alice" : "Bob", - }); - }); - - router.post("/users", async (c: any) => { - const body = await c.req.json(); - return c.json({ id: 3, ...body }, 201); - }); - - router.put("/users/:id", async (c: any) => { - const id = c.req.param("id"); - const body = await c.req.json(); - return c.json({ id: parseInt(id), ...body }); - }); - - router.delete("/users/:id", (c: any) => { - const id = c.req.param("id"); - return c.json({ message: `User ${id} deleted` }); - }); - - // Return the router as a var - return { router }; - }, - onRequest( - ctx: RequestContext, - request: Request, - ) { - // Use the Hono router from vars - return ctx.vars.router.fetch(request); - }, - actions: {}, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-websocket.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-websocket.ts deleted file mode 100644 index 929feb1b2a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/raw-websocket.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { type ActorContext, actor, type UniversalWebSocket } from "rivetkit"; -import { scheduleActorSleep } from "./schedule-sleep"; - -export const rawWebSocketActor = actor({ - options: { - canHibernateWebSocket: true, - sleepTimeout: 250, - }, - state: { - connectionCount: 0, - messageCount: 0, - }, - onWebSocket(ctx, websocket) { - ctx.state.connectionCount = ctx.state.connectionCount + 1; - console.log( - `[ACTOR] New connection, count: ${ctx.state.connectionCount}`, - ); - - // Send welcome message - websocket.send( - JSON.stringify({ - type: "welcome", - connectionCount: ctx.state.connectionCount, - }), - ); - console.log("[ACTOR] Sent welcome message"); - - // Echo messages back - websocket.addEventListener("message", (event: any) => { - ctx.state.messageCount = ctx.state.messageCount + 1; - console.log( - `[ACTOR] Message received, total count: ${ctx.state.messageCount}, data:`, - event.data, - ); - - const data = event.data; - if (typeof data === "string") { - try { - const parsed = JSON.parse(data); - if (parsed.type === "ping") { - websocket.send( - JSON.stringify({ - type: "pong", - timestamp: Date.now(), - }), - ); - } else if (parsed.type === "getStats") { - console.log( - `[ACTOR] Sending stats - connections: ${ctx.state.connectionCount}, messages: ${ctx.state.messageCount}`, - ); - websocket.send( - JSON.stringify({ - type: "stats", - connectionCount: ctx.state.connectionCount, - messageCount: ctx.state.messageCount, - }), - ); - } else if (parsed.type === "getRequestInfo") { - // Send back the request URL info if available - const url = ctx.request?.url || "ws://actor/websocket"; - const urlObj = new URL(url); - websocket.send( - JSON.stringify({ - type: "requestInfo", - url: url, - pathname: urlObj.pathname, - search: urlObj.search, - }), - ); - } else if (parsed.type === "indexedEcho") { - const rivetMessageIndex = - typeof event.rivetMessageIndex === "number" - ? event.rivetMessageIndex - : null; - ctx.state.indexedMessageOrder.push(rivetMessageIndex); - websocket.send( - JSON.stringify({ - type: "indexedEcho", - payload: parsed.payload ?? null, - rivetMessageIndex, - }), - ); - } else if (parsed.type === "indexedAckProbe") { - const rivetMessageIndex = - typeof event.rivetMessageIndex === "number" - ? event.rivetMessageIndex - : null; - ctx.state.indexedMessageOrder.push(rivetMessageIndex); - websocket.send( - JSON.stringify({ - type: "indexedAckProbe", - rivetMessageIndex, - payloadSize: - typeof parsed.payload === "string" - ? parsed.payload.length - : 0, - }), - ); - } else if (parsed.type === "getIndexedMessageOrder") { - websocket.send( - JSON.stringify({ - type: "indexedMessageOrder", - order: ctx.state.indexedMessageOrder, - }), - ); - } else if (parsed.type === "scheduleSleep") { - websocket.send( - JSON.stringify({ - type: "sleepScheduled", - }), - ); - globalThis.setTimeout(() => { - ctx.sleep(); - }, 25); - } else { - // Echo back - websocket.send(data); - } - } catch { - // If not JSON, just echo it back - websocket.send(data); - } - } else { - // Echo binary data - websocket.send(data); - } - }); - - // Handle close - websocket.addEventListener("close", () => { - ctx.state.connectionCount = ctx.state.connectionCount - 1; - console.log( - `[ACTOR] Connection closed, count: ${ctx.state.connectionCount}`, - ); - }); - }, - actions: { - triggerSleep: (c: ActorContext) => { - scheduleActorSleep(c); - return true; - }, - getStats(ctx: any) { - return { - connectionCount: ctx.state.connectionCount, - messageCount: ctx.state.messageCount, - }; - }, - }, -}); - -export const rawWebSocketBinaryActor = actor({ - onWebSocket(ctx, websocket) { - // Handle binary data - websocket.addEventListener("message", (event: any) => { - const data = event.data; - if (data instanceof ArrayBuffer || data instanceof Uint8Array) { - // Reverse the bytes and send back - const bytes = new Uint8Array(data); - const reversed = new Uint8Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - reversed[i] = bytes[bytes.length - 1 - i]; - } - websocket.send(reversed); - } - }); - }, - actions: {}, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-dynamic.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-dynamic.ts deleted file mode 100644 index 63de06bc58..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-dynamic.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { setup } from "rivetkit"; -import type { registry as DriverTestRegistryType } from "./registry"; -import { loadDynamicActors } from "./registry-loader"; - -const use = loadDynamicActors(); - -export const registry = setup({ - use, -}) as unknown as typeof DriverTestRegistryType; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-loader.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-loader.ts deleted file mode 100644 index 97cb765681..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-loader.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { readdirSync } from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import type { AnyActorDefinition } from "@/actor/definition"; -import { dynamicActor } from "rivetkit/dynamic"; -import { registry as staticRegistry } from "./registry"; - -const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url)); -const PACKAGE_ROOT = path.resolve(FIXTURE_DIR, "..", ".."); -const ACTOR_FIXTURE_DIR = path.join(FIXTURE_DIR, "actors"); -const TS_CONFIG_PATH = path.join(PACKAGE_ROOT, "tsconfig.json"); - -type DynamicActorDefinition = ReturnType; - -interface EsbuildOutputFile { - path: string; - text: string; -} - -interface EsbuildBuildResult { - outputFiles: EsbuildOutputFile[]; -} - -interface EsbuildModule { - build(options: Record): Promise; -} - -let esbuildModulePromise: Promise | undefined; -const bundledSourceCache = new Map>(); - -function listActorFixtureFiles(): string[] { - const entries = readdirSync(ACTOR_FIXTURE_DIR, { - withFileTypes: true, - }); - - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) - .map((entry) => path.join(ACTOR_FIXTURE_DIR, entry.name)) - .sort(); -} - -function actorNameFromFilePath(filePath: string): string { - return path.basename(filePath, ".ts"); -} - -async function importActorDefinition( - filePath: string, -): Promise { - const moduleSpecifier = pathToFileURL(filePath).href; - const module = (await import(moduleSpecifier)) as { - default?: AnyActorDefinition; - }; - - if (!module.default) { - throw new Error( - `driver test actor fixture is missing a default export: ${filePath}`, - ); - } - - return module.default; -} - -async function loadEsbuildModule(): Promise { - if (!esbuildModulePromise) { - esbuildModulePromise = (async () => { - const runtimeRequire = createRequire(import.meta.url); - const tsupEntryPath = runtimeRequire.resolve("tsup"); - const tsupRequire = createRequire(tsupEntryPath); - const esbuildEntryPath = tsupRequire.resolve("esbuild"); - const esbuildModule = (await import( - pathToFileURL(esbuildEntryPath).href - )) as EsbuildModule & { - default?: EsbuildModule; - }; - const esbuild = - typeof esbuildModule.build === "function" - ? esbuildModule - : esbuildModule.default; - if (!esbuild || typeof esbuild.build !== "function") { - throw new Error("failed to load esbuild build function"); - } - return esbuild; - })(); - } - - return esbuildModulePromise; -} - -async function bundleActorFixture(filePath: string): Promise { - const cached = bundledSourceCache.get(filePath); - if (cached) { - return await cached; - } - - const pendingBundle = (async () => { - const esbuild = await loadEsbuildModule(); - const result = await esbuild.build({ - absWorkingDir: PACKAGE_ROOT, - entryPoints: [filePath], - outfile: "driver-test-actor-bundle.js", - bundle: true, - write: false, - platform: "node", - format: "esm", - target: "node22", - tsconfig: TS_CONFIG_PATH, - external: ["rivetkit", "rivetkit/*", "@rivetkit/*"], - logLevel: "silent", - }); - - const outputFile = result.outputFiles.find((file) => - file.path.endsWith(".js"), - ); - if (!outputFile) { - throw new Error( - `failed to bundle dynamic actor source for ${filePath}`, - ); - } - - return outputFile.text; - })(); - - bundledSourceCache.set(filePath, pendingBundle); - return await pendingBundle; -} - -export async function loadStaticActors(): Promise< - Record -> { - const actors: Record = {}; - for (const actorFixturePath of listActorFixtureFiles()) { - actors[actorNameFromFilePath(actorFixturePath)] = - await importActorDefinition(actorFixturePath); - } - return actors; -} - -export function loadDynamicActors(): Record { - const actors: Record = {}; - const staticDefinitions = staticRegistry.config.use as Record< - string, - AnyActorDefinition - >; - for (const actorFixturePath of listActorFixtureFiles()) { - const actorName = actorNameFromFilePath(actorFixturePath); - const staticDefinition = staticDefinitions[actorName]; - if (!staticDefinition) { - throw new Error( - `missing static actor definition for dynamic fixture ${actorName}`, - ); - } - actors[actorName] = dynamicActor({ - options: staticDefinition.config.options, - load: async () => { - return { - source: await bundleActorFixture(actorFixturePath), - sourceFormat: "esm-js" as const, - }; - }, - }); - } - return actors; -} diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts deleted file mode 100644 index 4c852b12ad..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { setup } from "rivetkit"; -import type { registry as DriverTestRegistryType } from "./registry"; -import { loadStaticActors } from "./registry-loader"; - -const use = await loadStaticActors(); - -export const registry = setup({ - use, -}) as typeof DriverTestRegistryType; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts deleted file mode 100644 index 6958d42368..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { setup } from "rivetkit"; -// This registry remains the canonical type anchor for driver fixtures. -// Driver runtime tests execute through registry-static.ts and registry-dynamic.ts. -import { - accessControlActor, - accessControlNoQueuesActor, -} from "./access-control"; - -import { inputActor } from "./action-inputs"; -import { - defaultTimeoutActor, - longTimeoutActor, - shortTimeoutActor, - syncTimeoutActor, -} from "./action-timeout"; -import { - asyncActionActor, - promiseActor, - syncActionActor, -} from "./action-types"; -import { dbActorDrizzle } from "./actor-db-drizzle"; -import { dbActorRaw } from "./actor-db-raw"; -import { onStateChangeActor } from "./actor-onstatechange"; -import { connErrorSerializationActor } from "./conn-error-serialization"; -import { dbPragmaMigrationActor } from "./db-pragma-migration"; -import { counterWithParams } from "./conn-params"; -import { connStateActor } from "./conn-state"; -// Import actors from individual files -import { counter } from "./counter"; -import { counterConn } from "./counter-conn"; -import { dbKvStatsActor } from "./db-kv-stats"; -import { - dbLifecycle, - dbLifecycleFailing, - dbLifecycleObserver, -} from "./db-lifecycle"; -import { destroyActor, destroyObserver } from "./destroy"; -import { customTimeoutActor, errorHandlingActor } from "./error-handling"; -import { fileSystemHibernationCleanupActor } from "./file-system-hibernation-cleanup"; -import { - hibernationActor, - hibernationSleepWindowActor, -} from "./hibernation"; -import { inlineClientActor } from "./inline-client"; -import { kvActor } from "./kv"; -import { largePayloadActor, largePayloadConnActor } from "./large-payloads"; -import { counterWithLifecycle } from "./lifecycle"; -import { metadataActor } from "./metadata"; -import { - manyQueueActionParentActor, - manyQueueChildActor, - manyQueueRunParentActor, - queueActor, - queueLimitedActor, -} from "./queue"; -import { - rawHttpActor, - rawHttpHonoActor, - rawHttpNoHandlerActor, - rawHttpVoidReturnActor, -} from "./raw-http"; -import { rawHttpRequestPropertiesActor } from "./raw-http-request-properties"; -import { rawWebSocketActor, rawWebSocketBinaryActor } from "./raw-websocket"; -import { rejectConnectionActor } from "./reject-connection"; -import { requestAccessActor } from "./request-access"; -import { - runWithEarlyExit, - runWithError, - runWithoutHandler, - runWithQueueConsumer, - runWithTicks, -} from "./run"; -import { dockerSandboxActor } from "./sandbox"; -import { scheduled } from "./scheduled"; -import { dbStressActor } from "./db-stress"; -import { scheduledDb } from "./scheduled-db"; -import { - sleep, - sleepRawWsAddEventListenerClose, - sleepRawWsAddEventListenerMessage, - sleepWithLongRpc, - sleepWithNoSleepOption, - sleepWithPreventSleep, - sleepWithRawHttp, - sleepWithRawWebSocket, - sleepWithWaitUntilMessage, - sleepRawWsOnClose, - sleepRawWsOnMessage, - sleepRawWsSendOnSleep, - sleepRawWsDelayedSendOnSleep, - sleepWithWaitUntilInOnWake, -} from "./sleep"; -import { - sleepWithDb, - sleepWithSlowScheduledDb, - sleepWithDbConn, - sleepWithDbAction, - sleepWithRawWsCloseDb, - sleepWithRawWsCloseDbListener, - sleepWsMessageExceedsGrace, - sleepWsConcurrentDbExceedsGrace, - sleepWaitUntil, - sleepNestedWaitUntil, - sleepEnqueue, - sleepScheduleAfter, - sleepOnSleepThrows, - sleepWaitUntilRejects, - sleepWaitUntilState, - sleepWithRawWs, - sleepWsActiveDbExceedsGrace, - sleepWsRawDbAfterClose, -} from "./sleep-db"; -import { lifecycleObserver, startStopRaceActor } from "./start-stop-race"; -import { statelessActor } from "./stateless"; -import { stateZodCoercionActor } from "./state-zod-coercion"; -import { - driverCtxActor, - dynamicVarActor, - nestedVarActor, - staticVarActor, - uniqueVarActor, -} from "./vars"; -import { - workflowAccessActor, - workflowCompleteActor, - workflowCounterActor, - workflowDestroyActor, - workflowErrorHookActor, - workflowErrorHookEffectsActor, - workflowErrorHookSleepActor, - workflowFailedStepActor, - workflowNestedJoinActor, - workflowNestedLoopActor, - workflowNestedRaceActor, - workflowQueueActor, - workflowRunningStepActor, - workflowReplayActor, - workflowSleepActor, - workflowSpawnChildActor, - workflowSpawnParentActor, - workflowStopTeardownActor, - workflowTryActor, -} from "./workflow"; - -let agentOsTestActor: - | (Awaited["agentOsTestActor"]) - | undefined; - -try { - ({ agentOsTestActor } = await import("./agent-os")); -} catch (error) { - if (!(error instanceof Error) || !error.message.includes("agent-os")) { - throw error; - } -} - -// Consolidated setup with all actors -export const registry = setup({ - use: { - // From counter.ts - counter, - // From counter-conn.ts - counterConn, - // From lifecycle.ts - counterWithLifecycle, - // From scheduled.ts - scheduled, - // From db-stress.ts - dbStressActor, - // From scheduled-db.ts - scheduledDb, - // From sandbox.ts - dockerSandboxActor, - // From sleep.ts - sleep, - sleepWithLongRpc, - sleepWithRawHttp, - sleepWithRawWebSocket, - sleepWithNoSleepOption, - sleepWithPreventSleep, - sleepWithWaitUntilMessage, - sleepRawWsAddEventListenerMessage, - sleepRawWsAddEventListenerClose, - sleepRawWsOnMessage, - sleepRawWsOnClose, - sleepRawWsSendOnSleep, - sleepRawWsDelayedSendOnSleep, - sleepWithWaitUntilInOnWake, - // From sleep-db.ts - sleepWithDb, - sleepWithSlowScheduledDb, - sleepWithDbConn, - sleepWithDbAction, - sleepWaitUntil, - sleepNestedWaitUntil, - sleepEnqueue, - sleepScheduleAfter, - sleepOnSleepThrows, - sleepWaitUntilRejects, - sleepWaitUntilState, - sleepWithRawWs, - sleepWithRawWsCloseDb, - sleepWithRawWsCloseDbListener, - sleepWsMessageExceedsGrace, - sleepWsConcurrentDbExceedsGrace, - sleepWsActiveDbExceedsGrace, - sleepWsRawDbAfterClose, - // From error-handling.ts - errorHandlingActor, - customTimeoutActor, - // From inline-client.ts - inlineClientActor, - // From kv.ts - kvActor, - // From queue.ts - queueActor, - queueLimitedActor, - manyQueueChildActor, - manyQueueActionParentActor, - manyQueueRunParentActor, - // From action-inputs.ts - inputActor, - // From action-timeout.ts - shortTimeoutActor, - longTimeoutActor, - defaultTimeoutActor, - syncTimeoutActor, - // From action-types.ts - syncActionActor, - asyncActionActor, - promiseActor, - // From conn-params.ts - counterWithParams, - // From conn-state.ts - connStateActor, - // From metadata.ts - metadataActor, - // From vars.ts - staticVarActor, - nestedVarActor, - dynamicVarActor, - uniqueVarActor, - driverCtxActor, - // From raw-http.ts - rawHttpActor, - rawHttpNoHandlerActor, - rawHttpVoidReturnActor, - rawHttpHonoActor, - // From raw-http-request-properties.ts - rawHttpRequestPropertiesActor, - // From raw-websocket.ts - rawWebSocketActor, - rawWebSocketBinaryActor, - // From reject-connection.ts - rejectConnectionActor, - // From request-access.ts - requestAccessActor, - // From actor-onstatechange.ts - onStateChangeActor, - // From destroy.ts - destroyActor, - destroyObserver, - // From hibernation.ts - hibernationActor, - hibernationSleepWindowActor, - // From file-system-hibernation-cleanup.ts - fileSystemHibernationCleanupActor, - // From large-payloads.ts - largePayloadActor, - largePayloadConnActor, - // From run.ts - runWithTicks, - runWithQueueConsumer, - runWithEarlyExit, - runWithError, - runWithoutHandler, - // From workflow.ts - workflowCounterActor, - workflowQueueActor, - workflowAccessActor, - workflowCompleteActor, - workflowDestroyActor, - workflowFailedStepActor, - workflowRunningStepActor, - workflowReplayActor, - workflowSleepActor, - workflowTryActor, - workflowStopTeardownActor, - workflowErrorHookActor, - workflowErrorHookEffectsActor, - workflowErrorHookSleepActor, - workflowNestedLoopActor, - workflowNestedJoinActor, - workflowNestedRaceActor, - workflowSpawnChildActor, - workflowSpawnParentActor, - // From actor-db-raw.ts - dbActorRaw, - // From actor-db-drizzle.ts - dbActorDrizzle, - // From db-lifecycle.ts - dbLifecycle, - dbLifecycleFailing, - dbLifecycleObserver, - // From stateless.ts - statelessActor, - // From access-control.ts - accessControlActor, - accessControlNoQueuesActor, - // From start-stop-race.ts - startStopRaceActor, - lifecycleObserver, - // From conn-error-serialization.ts - connErrorSerializationActor, - // From db-kv-stats.ts - dbKvStatsActor, - // From db-pragma-migration.ts - dbPragmaMigrationActor, - // From state-zod-coercion.ts - stateZodCoercionActor, - ...(agentOsTestActor - ? { - // From agent-os.ts - agentOsTestActor, - } - : {}), - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/reject-connection.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/reject-connection.ts deleted file mode 100644 index dc082448dd..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/reject-connection.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -export const rejectConnectionActor = actor({ - onBeforeConnect: async (_c, params: { reject?: boolean }) => { - if (params?.reject) { - await new Promise((resolve) => setTimeout(resolve, 500)); - throw new UserError("Rejected connection", { - code: "rejected", - }); - } - }, - actions: { - ping: () => "pong", - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/request-access.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/request-access.ts deleted file mode 100644 index c176e0f0ea..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/request-access.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { actor, type RivetMessageEvent } from "rivetkit"; - -/** - * Test fixture to verify request object access in all lifecycle hooks - */ -export const requestAccessActor = actor({ - state: { - // Track request info from different hooks - onBeforeConnectRequest: { - hasRequest: false, - requestUrl: null as string | null, - requestMethod: null as string | null, - requestHeaders: {} as Record, - }, - createConnStateRequest: { - hasRequest: false, - requestUrl: null as string | null, - requestMethod: null as string | null, - requestHeaders: {} as Record, - }, - onRequestRequest: { - hasRequest: false, - requestUrl: null as string | null, - requestMethod: null as string | null, - requestHeaders: {} as Record, - }, - onWebSocketRequest: { - hasRequest: false, - requestUrl: null as string | null, - requestMethod: null as string | null, - requestHeaders: {} as Record, - }, - }, - createConnState: (c, params: { trackRequest?: boolean }) => { - // In createConnState, the state isn't available yet. - - let requestInfo: { - hasRequest: boolean; - requestUrl: string; - requestMethod: string; - requestHeaders: Record; - } | null = null; - - if (params?.trackRequest && c.request) { - const headers: Record = {}; - c.request.headers.forEach((value, key) => { - headers[key] = value; - }); - requestInfo = { - hasRequest: true, - requestUrl: c.request.url, - requestMethod: c.request.method, - requestHeaders: headers, - }; - } - - return { - trackRequest: params?.trackRequest || false, - requestInfo, - }; - }, - onConnect: (c, conn) => { - // Copy request info from connection state if it was tracked - if (conn.state.requestInfo) { - c.state.createConnStateRequest = conn.state.requestInfo; - } - }, - onBeforeConnect: (c, params) => { - if (params?.trackRequest) { - if (c.request) { - c.state.onBeforeConnectRequest.hasRequest = true; - c.state.onBeforeConnectRequest.requestUrl = c.request.url; - c.state.onBeforeConnectRequest.requestMethod = c.request.method; - - // Store select headers - const headers: Record = {}; - c.request.headers.forEach((value, key) => { - headers[key] = value; - }); - c.state.onBeforeConnectRequest.requestHeaders = headers; - } else { - // Track that we tried but request was not available - c.state.onBeforeConnectRequest.hasRequest = false; - } - } - }, - onRequest: (c, request) => { - // Store request info - c.state.onRequestRequest.hasRequest = true; - c.state.onRequestRequest.requestUrl = request.url; - c.state.onRequestRequest.requestMethod = request.method; - - // Store select headers - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); - c.state.onRequestRequest.requestHeaders = headers; - - // Return response with request info - return new Response( - JSON.stringify({ - hasRequest: true, - requestUrl: request.url, - requestMethod: request.method, - requestHeaders: headers, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }, - onWebSocket: (c, websocket) => { - if (!c.request) throw "Missing request"; - // Store request info - c.state.onWebSocketRequest.hasRequest = true; - c.state.onWebSocketRequest.requestUrl = c.request.url; - c.state.onWebSocketRequest.requestMethod = c.request.method; - - // Store select headers - const headers: Record = {}; - c.request.headers.forEach((value, key) => { - headers[key] = value; - }); - c.state.onWebSocketRequest.requestHeaders = headers; - - // Send request info on connection - websocket.send( - JSON.stringify({ - hasRequest: true, - requestUrl: c.request.url, - requestMethod: c.request.method, - requestHeaders: headers, - }), - ); - - // Echo messages back - websocket.addEventListener("message", (event: RivetMessageEvent) => { - websocket.send(event.data); - }); - }, - actions: { - ping: () => { - return "pong"; - }, - getRequestInfo: (c) => { - return { - onBeforeConnect: c.state.onBeforeConnectRequest, - createConnState: c.state.createConnStateRequest, - onRequest: c.state.onRequestRequest, - onWebSocket: c.state.onWebSocketRequest, - }; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts deleted file mode 100644 index ab259793c5..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { actor } from "rivetkit"; -import type { registry } from "./registry"; - -export const RUN_SLEEP_TIMEOUT = 1000; - -// Actor that tracks tick counts and respects abort signal -export const runWithTicks = actor({ - state: { - tickCount: 0, - lastTickAt: 0, - runStarted: false, - runExited: false, - }, - run: async (c) => { - c.state.runStarted = true; - c.log.info("run handler started"); - - while (!c.aborted) { - c.state.tickCount += 1; - c.state.lastTickAt = Date.now(); - c.log.info({ msg: "tick", tickCount: c.state.tickCount }); - - // Wait 50ms between ticks, or exit early if aborted - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 50); - c.abortSignal.addEventListener( - "abort", - () => { - clearTimeout(timeout); - resolve(); - }, - { once: true }, - ); - }); - } - - c.state.runExited = true; - c.log.info("run handler exiting gracefully"); - }, - actions: { - getState: (c) => ({ - tickCount: c.state.tickCount, - lastTickAt: c.state.lastTickAt, - runStarted: c.state.runStarted, - runExited: c.state.runExited, - }), - }, - options: { - sleepTimeout: RUN_SLEEP_TIMEOUT, - runStopTimeout: 1000, - }, -}); - -// Actor that consumes from a queue in the run handler -export const runWithQueueConsumer = actor({ - state: { - messagesReceived: [] as Array<{ name: string; body: unknown }>, - runStarted: false, - wakeCount: 0, - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - run: async (c) => { - c.state.runStarted = true; - c.log.info("run handler started, waiting for messages"); - - while (!c.aborted) { - const message = await c.queue.next({ names: ["messages"] }); - if (message) { - c.log.info({ msg: "received message", body: message.body }); - c.state.messagesReceived.push({ - name: message.name, - body: message.body, - }); - } - } - - c.log.info("run handler exiting gracefully"); - }, - actions: { - getState: (c) => ({ - messagesReceived: c.state.messagesReceived, - runStarted: c.state.runStarted, - wakeCount: c.state.wakeCount, - }), - sendMessage: async (c, body: unknown) => { - const client = c.client(); - const handle = client.runWithQueueConsumer.getForId(c.actorId); - await handle.send("messages", body); - return true; - }, - }, - options: { - sleepTimeout: RUN_SLEEP_TIMEOUT, - runStopTimeout: 1000, - }, -}); - -// Actor that exits the run handler after a short delay to test crash behavior -export const runWithEarlyExit = actor({ - state: { - runStarted: false, - destroyCalled: false, - sleepCount: 0, - wakeCount: 0, - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - run: async (c) => { - c.state.runStarted = true; - c.log.info("run handler started, will exit after delay"); - // Wait a bit so we can observe the runStarted state before exit - await new Promise((resolve) => setTimeout(resolve, 200)); - c.log.info("run handler exiting early"); - // Exit without respecting abort signal - }, - onDestroy: (c) => { - c.state.destroyCalled = true; - }, - actions: { - getState: (c) => ({ - runStarted: c.state.runStarted, - destroyCalled: c.state.destroyCalled, - sleepCount: c.state.sleepCount, - wakeCount: c.state.wakeCount, - }), - }, - options: { - sleepTimeout: RUN_SLEEP_TIMEOUT, - }, -}); - -// Actor that throws an error in the run handler to test crash behavior -export const runWithError = actor({ - state: { - runStarted: false, - destroyCalled: false, - sleepCount: 0, - wakeCount: 0, - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - run: async (c) => { - c.state.runStarted = true; - c.log.info("run handler started, will throw error"); - await new Promise((resolve) => setTimeout(resolve, 200)); - throw new Error("intentional error in run handler"); - }, - onDestroy: (c) => { - c.state.destroyCalled = true; - }, - actions: { - getState: (c) => ({ - runStarted: c.state.runStarted, - destroyCalled: c.state.destroyCalled, - sleepCount: c.state.sleepCount, - wakeCount: c.state.wakeCount, - }), - }, - options: { - sleepTimeout: RUN_SLEEP_TIMEOUT, - }, -}); - -// Actor without a run handler for comparison -export const runWithoutHandler = actor({ - state: { - wakeCount: 0, - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - actions: { - getState: (c) => ({ - wakeCount: c.state.wakeCount, - }), - }, - options: { - sleepTimeout: RUN_SLEEP_TIMEOUT, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sandbox.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sandbox.ts deleted file mode 100644 index 9c82ef3518..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sandbox.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { sandboxActor } from "rivetkit/sandbox"; -import { docker } from "rivetkit/sandbox/docker"; - -export const dockerSandboxActor = sandboxActor({ - provider: docker({ - image: "node:22-bookworm-slim", - }), -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/schedule-sleep.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/schedule-sleep.ts deleted file mode 100644 index 9aeb79e722..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/schedule-sleep.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function scheduleActorSleep(context: { sleep: () => void }): void { - // Schedule sleep after the current request finishes so transport replay - // tests do not race actor shutdown against the sleep response itself. - globalThis.setTimeout(() => { - context.sleep(); - }, 0); -} diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled-db.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled-db.ts deleted file mode 100644 index 3867221bd3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled-db.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "rivetkit/db"; - -export const scheduledDb = actor({ - state: { - scheduledCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS scheduled_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - action TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - actions: { - scheduleDbWrite: (c, delayMs: number) => { - c.schedule.after(delayMs, "onScheduledDbWrite"); - }, - - onScheduledDbWrite: async (c) => { - c.state.scheduledCount++; - await c.db.execute( - `INSERT INTO scheduled_log (action, created_at) VALUES ('scheduled', ${Date.now()})`, - ); - }, - - getLogCount: async (c) => { - const results = await c.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM scheduled_log`, - ); - return results[0].count; - }, - - getScheduledCount: (c) => { - return c.state.scheduledCount; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled.ts deleted file mode 100644 index 7bac35bac8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/scheduled.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { actor } from "rivetkit"; - -export const scheduled = actor({ - state: { - lastRun: 0, - scheduledCount: 0, - taskHistory: [] as string[], - }, - actions: { - // Schedule using 'at' with specific timestamp - scheduleTaskAt: (c, timestamp: number) => { - c.schedule.at(timestamp, "onScheduledTask"); - return timestamp; - }, - - // Schedule using 'after' with delay - scheduleTaskAfter: (c, delayMs: number) => { - c.schedule.after(delayMs, "onScheduledTask"); - return Date.now() + delayMs; - }, - - // Schedule with a task ID for ordering tests - scheduleTaskAfterWithId: (c, taskId: string, delayMs: number) => { - c.schedule.after(delayMs, "onScheduledTaskWithId", taskId); - return { taskId, scheduledFor: Date.now() + delayMs }; - }, - - // Original method for backward compatibility - scheduleTask: (c, delayMs: number) => { - const timestamp = Date.now() + delayMs; - c.schedule.at(timestamp, "onScheduledTask"); - return timestamp; - }, - - // Getters for state - getLastRun: (c) => { - return c.state.lastRun; - }, - - getScheduledCount: (c) => { - return c.state.scheduledCount; - }, - - getTaskHistory: (c) => { - return c.state.taskHistory; - }, - - clearHistory: (c) => { - c.state.taskHistory = []; - c.state.scheduledCount = 0; - c.state.lastRun = 0; - return true; - }, - - // Scheduled task handlers - onScheduledTask: (c) => { - c.state.lastRun = Date.now(); - c.state.scheduledCount++; - c.broadcast("scheduled", { - time: c.state.lastRun, - count: c.state.scheduledCount, - }); - }, - - onScheduledTaskWithId: (c, taskId: string) => { - c.state.lastRun = Date.now(); - c.state.scheduledCount++; - c.state.taskHistory.push(taskId); - c.broadcast("scheduledWithId", { - taskId, - time: c.state.lastRun, - count: c.state.scheduledCount, - }); - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep-db.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep-db.ts deleted file mode 100644 index b22d99dbcc..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep-db.ts +++ /dev/null @@ -1,1349 +0,0 @@ -import type { UniversalWebSocket } from "rivetkit"; -import { actor, event, queue } from "rivetkit"; -import { db } from "rivetkit/db"; -import type { IDatabase, KvVfsOptions } from "@rivetkit/sqlite-vfs"; -import { - RAW_WS_HANDLER_DELAY, - RAW_WS_HANDLER_SLEEP_TIMEOUT, -} from "./sleep"; - -export const SLEEP_DB_TIMEOUT = 1000; - -export const sleepWithDb = actor({ - state: { - startCount: 0, - sleepCount: 0, - onSleepDbWriteSuccess: false, - onSleepDbWriteError: null as string | null, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - c.state.onSleepDbWriteSuccess = true; - } catch (error) { - c.state.onSleepDbWriteError = - error instanceof Error ? error.message : String(error); - } - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - onSleepDbWriteSuccess: c.state.onSleepDbWriteSuccess, - onSleepDbWriteError: c.state.onSleepDbWriteError, - }; - }, - getLogEntries: async (c) => { - const results = await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - return results; - }, - insertLogEntry: async (c, event: string) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('${event}', ${Date.now()})`, - ); - }, - setAlarm: (c, delayMs: number) => { - c.schedule.after(delayMs, "onAlarm"); - }, - onAlarm: async (c) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('alarm', ${Date.now()})`, - ); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWithSlowScheduledDb = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - actions: { - scheduleSlowAlarm: (c, delayMs: number, workMs: number) => { - c.schedule.after(delayMs, "onSlowAlarm", workMs); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - onSlowAlarm: async (c, workMs: number) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('slow-alarm-start', ${Date.now()})`, - ); - await new Promise((resolve) => setTimeout(resolve, workMs)); - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('slow-alarm-finish', ${Date.now()})`, - ); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWithDbConn = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - connState: {}, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onDisconnect: async (c, _conn) => { - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('disconnect', ${Date.now()})`, - ); - } catch (error) { - c.log.warn({ - msg: "onDisconnect db write failed", - error: error instanceof Error ? error.message : String(error), - }); - } - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - } catch (error) { - c.log.warn({ - msg: "onSleep db write failed", - error: error instanceof Error ? error.message : String(error), - }); - } - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - getLogEntries: async (c) => { - const results = await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - return results; - }, - insertLogEntry: async (c, event: string) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('${event}', ${Date.now()})`, - ); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWithDbAction = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - connState: {}, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - events: { - sleeping: event(), - }, - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep-start', ${Date.now()})`, - ); - c.broadcast("sleeping", undefined); - await new Promise((resolve) => setTimeout(resolve, 500)); - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep-end', ${Date.now()})`, - ); - } catch (error) { - c.log.warn({ - msg: "onSleep error", - error: error instanceof Error ? error.message : String(error), - }); - } - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - getLogEntries: async (c) => { - const results = await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - return results; - }, - insertLogEntry: async (c, event: string) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('${event}', ${Date.now()})`, - ); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWaitUntil = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep-start', ${Date.now()})`, - ); - c.waitUntil((async () => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('waituntil-write', ${Date.now()})`, - ); - })()); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepNestedWaitUntil = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep-start', ${Date.now()})`, - ); - c.waitUntil((async () => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('outer-waituntil', ${Date.now()})`, - ); - // Nested waitUntil inside a waitUntil callback - c.waitUntil((async () => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('nested-waituntil', ${Date.now()})`, - ); - })()); - })()); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepEnqueue = actor({ - state: { - startCount: 0, - sleepCount: 0, - enqueueSuccess: false, - enqueueError: null as string | null, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - queues: { - work: queue(), - }, - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - try { - await c.queue.send("work", "enqueued-during-sleep"); - c.state.enqueueSuccess = true; - } catch (error) { - c.state.enqueueError = - error instanceof Error ? error.message : String(error); - } - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - enqueueSuccess: c.state.enqueueSuccess, - enqueueError: c.state.enqueueError, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepScheduleAfter = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - // Schedule an alarm during onSleep. It should be persisted - // but not fire a local timeout during shutdown. - c.schedule.after(100, "onScheduledAction"); - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - onScheduledAction: async (c) => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('scheduled-action', ${Date.now()})`, - ); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepOnSleepThrows = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep-before-throw', ${Date.now()})`, - ); - throw new Error("onSleep intentional error"); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWaitUntilRejects = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - // Register a waitUntil that rejects. Shutdown should still complete. - c.waitUntil(Promise.reject(new Error("waitUntil intentional rejection"))); - // Also register one that succeeds, to verify it still runs. - c.waitUntil((async () => { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('waituntil-after-reject', ${Date.now()})`, - ); - })()); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWaitUntilState = actor({ - state: { - startCount: 0, - sleepCount: 0, - waitUntilRan: false, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - c.waitUntil((async () => { - c.state.waitUntilRan = true; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('waituntil-state', ${Date.now()})`, - ); - })()); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - waitUntilRan: c.state.waitUntilRan, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWithRawWs = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - // Delay so there is a window to attempt raw WS connection during shutdown - await new Promise((resolve) => setTimeout(resolve, 500)); - }, - onWebSocket: (_c, ws: UniversalWebSocket) => { - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: SLEEP_DB_TIMEOUT, - }, -}); - -export const sleepWithRawWsCloseDb = actor({ - state: { - startCount: 0, - sleepCount: 0, - closeStarted: 0, - closeFinished: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.onclose = async () => { - c.state.closeStarted += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('close-start', ${Date.now()})`, - ); - await new Promise((resolve) => - setTimeout(resolve, RAW_WS_HANDLER_DELAY), - ); - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('close-finish', ${Date.now()})`, - ); - c.state.closeFinished += 1; - }; - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - closeStarted: c.state.closeStarted, - closeFinished: c.state.closeFinished, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: RAW_WS_HANDLER_SLEEP_TIMEOUT, - }, -}); - -// Grace period shorter than the handler's async work, so the DB gets -// cleaned up while the handler is still running. -const EXCEEDS_GRACE_HANDLER_DELAY = 2000; -const EXCEEDS_GRACE_PERIOD = 200; -const EXCEEDS_GRACE_SLEEP_TIMEOUT = 100; - -export { EXCEEDS_GRACE_HANDLER_DELAY, EXCEEDS_GRACE_PERIOD, EXCEEDS_GRACE_SLEEP_TIMEOUT }; - -// Number of sequential DB writes the handler performs. The loop runs long -// enough that shutdown (close()) runs between two writes. The write that -// follows close() hits the destroyed DB. -const ACTIVE_DB_WRITE_COUNT = 500; -const ACTIVE_DB_WRITE_DELAY_MS = 5; -const ACTIVE_DB_GRACE_PERIOD = 50; -const ACTIVE_DB_SLEEP_TIMEOUT = 500; - -export { - ACTIVE_DB_WRITE_COUNT, - ACTIVE_DB_WRITE_DELAY_MS, - ACTIVE_DB_GRACE_PERIOD, - ACTIVE_DB_SLEEP_TIMEOUT, -}; - -// Reproduces the production "disk I/O error" scenario: the handler is -// actively performing sequential DB writes (each one acquires and releases -// the wrapper mutex) when the grace period expires. Between two writes, -// client.close() acquires the mutex, sets closed=true, then calls -// db.close() outside the mutex. The next write acquires the mutex and -// calls ensureOpen() which throws "Database is closed". -// -// Without ensureOpen (as in the production version), the write would -// call db.exec() on the already-closing database concurrently with -// db.close(), producing "disk I/O error" or "cannot start a transaction -// within a transaction". -export const sleepWsActiveDbExceedsGrace = actor({ - state: { - startCount: 0, - sleepCount: 0, - writesCompleted: 0, - writeError: null as string | null, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.addEventListener("message", async (event: any) => { - if (event.data !== "start-writes") return; - - ws.send(JSON.stringify({ type: "started" })); - - // Perform many sequential DB writes. Each write acquires and - // releases the DB wrapper mutex. Between two writes, the - // shutdown's client.close() can slip in and close the DB. - for (let i = 0; i < ACTIVE_DB_WRITE_COUNT; i++) { - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('write-${i}', ${Date.now()})`, - ); - c.state.writesCompleted = i + 1; - } catch (error) { - c.state.writeError = - error instanceof Error ? error.message : String(error); - ws.send( - JSON.stringify({ - type: "error", - index: i, - error: c.state.writeError, - }), - ); - return; - } - - // Small delay between writes to yield the event loop and - // allow shutdown tasks to run. - await new Promise((resolve) => - setTimeout(resolve, ACTIVE_DB_WRITE_DELAY_MS), - ); - } - - ws.send(JSON.stringify({ type: "finished" })); - }); - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - writesCompleted: c.state.writesCompleted, - writeError: c.state.writeError, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: ACTIVE_DB_SLEEP_TIMEOUT, - sleepGracePeriod: ACTIVE_DB_GRACE_PERIOD, - }, -}); - -export const sleepWsMessageExceedsGrace = actor({ - state: { - startCount: 0, - sleepCount: 0, - messageStarted: 0, - messageFinished: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.addEventListener("message", async (event: any) => { - if (event.data !== "slow-db-work") return; - - c.state.messageStarted += 1; - - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('msg-start', ${Date.now()})`, - ); - - ws.send(JSON.stringify({ type: "started" })); - - // Wait longer than the grace period so shutdown times out - // and cleans up the database while this handler is still running. - await new Promise((resolve) => - setTimeout(resolve, EXCEEDS_GRACE_HANDLER_DELAY), - ); - - // This DB write runs after the grace period expired and - // #cleanupDatabase already destroyed the SQLite VFS. - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('msg-finish', ${Date.now()})`, - ); - - c.state.messageFinished += 1; - }); - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - messageStarted: c.state.messageStarted, - messageFinished: c.state.messageFinished, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: EXCEEDS_GRACE_SLEEP_TIMEOUT, - sleepGracePeriod: EXCEEDS_GRACE_PERIOD, - }, -}); - -// Reproduces the "cannot start a transaction within a transaction" error. -// Multiple concurrent WS message handlers do DB writes. The grace period -// is shorter than the handler delay, so the VFS gets destroyed while -// handlers are still running. The first handler's DB write fails -// (leaving a transaction open in SQLite), and subsequent handlers get -// "cannot start a transaction within a transaction". -export const sleepWsConcurrentDbExceedsGrace = actor({ - state: { - startCount: 0, - sleepCount: 0, - handlerStarted: 0, - handlerFinished: 0, - handlerErrors: [] as string[], - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - try { - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - } catch { - // DB may already be torn down - } - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.addEventListener("message", async (event: any) => { - const data = JSON.parse(String(event.data)); - if (data.type !== "slow-db-work") return; - - const index = data.index ?? 0; - c.state.handlerStarted += 1; - - // Each handler captures the db reference before awaiting. - // After the delay, the VFS may be destroyed. - const dbRef = c.db; - - ws.send(JSON.stringify({ type: "started", index })); - - // Stagger the delay slightly per index so handlers resume at - // different times relative to VFS teardown. - await new Promise((resolve) => - setTimeout(resolve, EXCEEDS_GRACE_HANDLER_DELAY + index * 50), - ); - - // Use the captured dbRef directly. After VFS teardown, the - // underlying sqlite connection is broken. The first handler - // to hit it may get "disk I/O error" (leaving a transaction - // open), and subsequent handlers may get "cannot start a - // transaction within a transaction". - // - // Do NOT catch the error here. Let it propagate so - // #trackWebSocketCallback logs the actual error message - // (visible in test output as "websocket callback failed"). - await dbRef.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('handler-${index}-finish', ${Date.now()})`, - ); - c.state.handlerFinished += 1; - }); - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - handlerStarted: c.state.handlerStarted, - handlerFinished: c.state.handlerFinished, - handlerErrors: c.state.handlerErrors, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: EXCEEDS_GRACE_SLEEP_TIMEOUT, - sleepGracePeriod: EXCEEDS_GRACE_PERIOD, - }, -}); - -export const sleepWithRawWsCloseDbListener = actor({ - state: { - startCount: 0, - sleepCount: 0, - closeStarted: 0, - closeFinished: 0, - }, - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - }), - onWake: async (c) => { - c.state.startCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('wake', ${Date.now()})`, - ); - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('sleep', ${Date.now()})`, - ); - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.addEventListener("close", async () => { - c.state.closeStarted += 1; - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('close-start', ${Date.now()})`, - ); - await new Promise((resolve) => - setTimeout(resolve, RAW_WS_HANDLER_DELAY), - ); - await c.db.execute( - `INSERT INTO sleep_log (event, created_at) VALUES ('close-finish', ${Date.now()})`, - ); - c.state.closeFinished += 1; - }); - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => ({ - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - closeStarted: c.state.closeStarted, - closeFinished: c.state.closeFinished, - }), - getLogEntries: async (c) => { - return await c.db.execute<{ - id: number; - event: string; - created_at: number; - }>(`SELECT * FROM sleep_log ORDER BY id`); - }, - }, - options: { - sleepTimeout: RAW_WS_HANDLER_SLEEP_TIMEOUT, - }, -}); - -// Reproduces the production "disk I/O error". Uses the raw IDatabase -// handle (bypassing ensureOpen). The handler opens a transaction via the -// raw handle, waits for shutdown to destroy the VFS, then tries to -// commit. The commit hits the destroyed VFS and fails with the raw SQLite -// error instead of the "Database is closed" guard. -const RAW_DB_GRACE_PERIOD = 100; -const RAW_DB_SLEEP_TIMEOUT = 500; - -export { RAW_DB_GRACE_PERIOD, RAW_DB_SLEEP_TIMEOUT }; - -type LeakedDbClient = { - execute: (query: string) => Promise; - close: () => Promise; - _rawDb: IDatabase; - _poisonKv: () => void; -}; - -function dbWithLeakedHandle() { - return { - createClient: async (ctx: { - actorId: string; - kv: { - batchGet: (keys: Uint8Array[]) => Promise<(Uint8Array | null)[]>; - batchPut: (entries: [Uint8Array, Uint8Array][]) => Promise; - batchDelete: (keys: Uint8Array[]) => Promise; - }; - sqliteVfs?: { open: (fileName: string, options: KvVfsOptions) => Promise }; - }): Promise => { - let poisoned = false; - const poisonError = () => { - throw new Error("KV transport unavailable (simulated WebSocket closure)"); - }; - - const kvStore: KvVfsOptions = { - get: async (key) => { - if (poisoned) poisonError(); - const results = await ctx.kv.batchGet([key]); - return results[0] ?? null; - }, - getBatch: (keys) => { - if (poisoned) poisonError(); - return ctx.kv.batchGet(keys); - }, - put: (key, value) => { - if (poisoned) poisonError(); - return ctx.kv.batchPut([[key, value]]); - }, - putBatch: (entries) => { - if (poisoned) poisonError(); - return ctx.kv.batchPut(entries); - }, - deleteBatch: (keys) => { - if (poisoned) poisonError(); - return ctx.kv.batchDelete(keys); - }, - }; - - const rawDb = await ctx.sqliteVfs!.open(ctx.actorId, kvStore); - - return { - execute: async (query: string): Promise => { - const results: Record[] = []; - let columnNames: string[] | null = null; - await rawDb.exec(query, (row: unknown[], columns: string[]) => { - if (!columnNames) columnNames = columns; - const rowObj: Record = {}; - for (let i = 0; i < row.length; i++) { - rowObj[columnNames[i]] = row[i]; - } - results.push(rowObj); - }); - return results; - }, - close: async () => { - await rawDb.close(); - }, - _rawDb: rawDb, - _poisonKv: () => { poisoned = true; }, - }; - }, - onMigrate: async (client: LeakedDbClient) => { - await client.execute(` - CREATE TABLE IF NOT EXISTS sleep_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - }, - onDestroy: async (client: LeakedDbClient) => { - await client.close(); - }, - }; -} - -export const sleepWsRawDbAfterClose = actor({ - state: { - startCount: 0, - sleepCount: 0, - }, - db: dbWithLeakedHandle(), - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onWebSocket: (c, ws: UniversalWebSocket) => { - ws.addEventListener("message", async (event: any) => { - if (event.data !== "raw-db-after-close") return; - - // Access the raw handle and poison function directly from c.db. - // The custom db provider exposes these as _rawDb and _poisonKv. - const dbClient = c.db as unknown as LeakedDbClient; - const rawDb = dbClient._rawDb; - const poisonKv = dbClient._poisonKv; - - // Start a transaction using the raw handle. - await rawDb.exec("BEGIN"); - await rawDb.exec( - `INSERT INTO sleep_log (event, created_at) VALUES ('before-poison', ${Date.now()})`, - ); - - // Poison the KV store to simulate the runner WebSocket dying. - poisonKv(); - - ws.send(JSON.stringify({ type: "started" })); - - // Try to COMMIT. The VFS will try to write pages via KV, - // but KV is poisoned so the write fails. The VFS returns - // SQLITE_IOERR to SQLite, which throws "disk I/O error". - try { - await rawDb.exec("COMMIT"); - ws.send(JSON.stringify({ type: "committed" })); - } catch (error) { - ws.send( - JSON.stringify({ - type: "error", - error: error instanceof Error ? error.message : String(error), - }), - ); - } - }); - - ws.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - }, - options: { - sleepTimeout: RAW_DB_SLEEP_TIMEOUT, - sleepGracePeriod: RAW_DB_GRACE_PERIOD, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep.ts deleted file mode 100644 index e28e875870..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { actor, event, type UniversalWebSocket } from "rivetkit"; -import { promiseWithResolvers } from "rivetkit/utils"; -import { scheduleActorSleep } from "./schedule-sleep"; - -export const SLEEP_TIMEOUT = 1000; -export const PREVENT_SLEEP_TIMEOUT = 250; -export const RAW_WS_HANDLER_SLEEP_TIMEOUT = 100; -export const RAW_WS_HANDLER_DELAY = 250; - -type AsyncRawWebSocketState = { - startCount: number; - sleepCount: number; - messageStarted: number; - messageFinished: number; - closeStarted: number; - closeFinished: number; -}; - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function createAsyncRawWebSocketSleepActor( - registration: "listener" | "property", - eventType: "message" | "close", -) { - return actor({ - state: { - startCount: 0, - sleepCount: 0, - messageStarted: 0, - messageFinished: 0, - closeStarted: 0, - closeFinished: 0, - } satisfies AsyncRawWebSocketState, - createVars: () => ({ - websocket: null as UniversalWebSocket | null, - }), - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onWebSocket: (c, websocket: UniversalWebSocket) => { - c.vars.websocket = websocket; - - const onMessage = async (event: any) => { - if (event.data !== "track-message") return; - - c.state.messageStarted += 1; - websocket.send(JSON.stringify({ type: "message-started" })); - await delay(RAW_WS_HANDLER_DELAY); - c.state.messageFinished += 1; - }; - - const onClose = async () => { - c.state.closeStarted += 1; - await delay(RAW_WS_HANDLER_DELAY); - c.state.closeFinished += 1; - }; - - if (registration === "listener") { - if (eventType === "message") { - websocket.addEventListener("message", onMessage); - } else { - websocket.addEventListener("close", onClose); - } - } else if (eventType === "message") { - websocket.onmessage = onMessage; - } else { - websocket.onclose = onClose; - } - - websocket.send(JSON.stringify({ type: "connected" })); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - messageStarted: c.state.messageStarted, - messageFinished: c.state.messageFinished, - closeStarted: c.state.closeStarted, - closeFinished: c.state.closeFinished, - }; - }, - }, - options: { - sleepTimeout: RAW_WS_HANDLER_SLEEP_TIMEOUT, - }, - }); -} - -export const sleep = actor({ - state: { startCount: 0, sleepCount: 0 }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - actions: { - triggerSleep: (c) => { - scheduleActorSleep(c); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - setAlarm: async (c, duration: number) => { - await c.schedule.after(duration, "onAlarm"); - }, - onAlarm: (c) => { - c.log.info("alarm called"); - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepRawWsAddEventListenerMessage = - createAsyncRawWebSocketSleepActor("listener", "message"); - -export const sleepRawWsAddEventListenerClose = - createAsyncRawWebSocketSleepActor("listener", "close"); - -export const sleepRawWsOnMessage = - createAsyncRawWebSocketSleepActor("property", "message"); - -export const sleepRawWsOnClose = - createAsyncRawWebSocketSleepActor("property", "close"); - -export const sleepWithLongRpc = actor({ - state: { startCount: 0, sleepCount: 0 }, - createVars: () => - ({}) as { longRunningResolve: PromiseWithResolvers }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - actions: { - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - longRunningRpc: async (c) => { - c.log.info("starting long running rpc"); - c.vars.longRunningResolve = promiseWithResolvers((reason) => - c.log.warn({ - msg: "unhandled long running rpc rejection", - reason, - }), - ); - c.broadcast("waiting"); - await c.vars.longRunningResolve.promise; - c.log.info("finished long running rpc"); - }, - finishLongRunningRpc: (c) => c.vars.longRunningResolve?.resolve(), - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepWithWaitUntilMessage = actor({ - state: { - startCount: 0, - sleepCount: 0, - waitUntilMessageCount: 0, - }, - events: { - sleeping: event<{ sleepCount: number; startCount: number }>(), - }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - actions: { - triggerSleep: (c) => { - c.waitUntil( - new Promise((resolve) => { - setTimeout(() => { - c.state.waitUntilMessageCount += 1; - c.conn.send("sleeping", { - sleepCount: c.state.sleepCount, - startCount: c.state.startCount, - }); - resolve(); - }, 10); - }), - ); - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - waitUntilMessageCount: c.state.waitUntilMessageCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepWithRawHttp = actor({ - state: { startCount: 0, sleepCount: 0, requestCount: 0 }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onRequest: async (c, request) => { - c.state.requestCount += 1; - const url = new URL(request.url); - - if (url.pathname === "/long-request") { - const duration = parseInt( - url.searchParams.get("duration") || "1000", - ); - c.log.info({ msg: "starting long fetch request", duration }); - await new Promise((resolve) => setTimeout(resolve, duration)); - c.log.info("finished long fetch request"); - return new Response(JSON.stringify({ completed: true }), { - headers: { "Content-Type": "application/json" }, - }); - } - - return new Response("Not Found", { status: 404 }); - }, - actions: { - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - requestCount: c.state.requestCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepWithRawWebSocket = actor({ - state: { startCount: 0, sleepCount: 0, connectionCount: 0 }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - onWebSocket: (c, websocket: UniversalWebSocket) => { - c.state.connectionCount += 1; - c.log.info({ - msg: "websocket connected", - connectionCount: c.state.connectionCount, - }); - - websocket.send( - JSON.stringify({ - type: "connected", - connectionCount: c.state.connectionCount, - }), - ); - - websocket.addEventListener("message", (event: any) => { - const data = event.data; - if (typeof data === "string") { - try { - const parsed = JSON.parse(data); - if (parsed.type === "getCounts") { - websocket.send( - JSON.stringify({ - type: "counts", - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - connectionCount: c.state.connectionCount, - }), - ); - } else if (parsed.type === "keepAlive") { - // Just acknowledge to keep connection alive - websocket.send(JSON.stringify({ type: "ack" })); - } - } catch { - // Echo non-JSON messages - websocket.send(data); - } - } - }); - - websocket.addEventListener("close", () => { - c.state.connectionCount -= 1; - c.log.info({ - msg: "websocket disconnected", - connectionCount: c.state.connectionCount, - }); - }); - }, - actions: { - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - connectionCount: c.state.connectionCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepRawWsSendOnSleep = actor({ - state: { startCount: 0, sleepCount: 0 }, - createVars: () => ({ - websockets: [] as UniversalWebSocket[], - }), - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - for (const ws of c.vars.websockets) { - ws.send(JSON.stringify({ type: "sleeping", sleepCount: c.state.sleepCount })); - } - }, - onWebSocket: (c, websocket: UniversalWebSocket) => { - c.vars.websockets.push(websocket); - - websocket.send(JSON.stringify({ type: "connected" })); - - websocket.addEventListener("close", () => { - c.vars.websockets = c.vars.websockets.filter((ws) => ws !== websocket); - }); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepRawWsDelayedSendOnSleep = actor({ - state: { startCount: 0, sleepCount: 0 }, - createVars: () => ({ - websockets: [] as UniversalWebSocket[], - }), - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: async (c) => { - c.state.sleepCount += 1; - // Wait before sending - await new Promise((resolve) => setTimeout(resolve, 100)); - for (const ws of c.vars.websockets) { - ws.send(JSON.stringify({ type: "sleeping", sleepCount: c.state.sleepCount })); - } - // Wait after sending before completing sleep - await new Promise((resolve) => setTimeout(resolve, 100)); - }, - onWebSocket: (c, websocket: UniversalWebSocket) => { - c.vars.websockets.push(websocket); - - websocket.send(JSON.stringify({ type: "connected" })); - - websocket.addEventListener("close", () => { - c.vars.websockets = c.vars.websockets.filter((ws) => ws !== websocket); - }); - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepWithWaitUntilInOnWake = actor({ - state: { - startCount: 0, - sleepCount: 0, - waitUntilCalled: false, - waitUntilCompleted: false, - }, - onWake: (c) => { - c.state.startCount += 1; - // This should not throw. Before the fix, assertReady() would throw - // because #ready is false during onWake. - c.waitUntil( - (async () => { - c.state.waitUntilCompleted = true; - })(), - ); - c.state.waitUntilCalled = true; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - waitUntilCalled: c.state.waitUntilCalled, - waitUntilCompleted: c.state.waitUntilCompleted, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - }, -}); - -export const sleepWithNoSleepOption = actor({ - state: { startCount: 0, sleepCount: 0 }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - actions: { - getCounts: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - }; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - noSleep: true, - }, -}); - -export const sleepWithPreventSleep = actor({ - state: { - startCount: 0, - sleepCount: 0, - preventSleepOnWake: false, - delayPreventSleepDuringShutdown: false, - preventSleepClearedDuringShutdown: false, - }, - onWake: (c) => { - c.state.startCount += 1; - c.setPreventSleep(c.state.preventSleepOnWake); - }, - onSleep: (c) => { - c.state.sleepCount += 1; - if (c.state.delayPreventSleepDuringShutdown) { - c.setPreventSleep(true); - setTimeout(() => { - c.state.preventSleepClearedDuringShutdown = true; - c.setPreventSleep(false); - }, PREVENT_SLEEP_TIMEOUT / 2); - } - }, - actions: { - triggerSleep: (c) => { - c.sleep(); - }, - getStatus: (c) => { - return { - startCount: c.state.startCount, - sleepCount: c.state.sleepCount, - preventSleep: c.preventSleep, - preventSleepOnWake: c.state.preventSleepOnWake, - delayPreventSleepDuringShutdown: - c.state.delayPreventSleepDuringShutdown, - preventSleepClearedDuringShutdown: - c.state.preventSleepClearedDuringShutdown, - }; - }, - setPreventSleep: (c, prevent: boolean) => { - c.setPreventSleep(prevent); - return c.preventSleep; - }, - setPreventSleepOnWake: (c, prevent: boolean) => { - c.state.preventSleepOnWake = prevent; - return c.state.preventSleepOnWake; - }, - setDelayPreventSleepDuringShutdown: (c, enabled: boolean) => { - c.state.delayPreventSleepDuringShutdown = enabled; - c.state.preventSleepClearedDuringShutdown = false; - return c.state.delayPreventSleepDuringShutdown; - }, - }, - options: { - sleepTimeout: SLEEP_TIMEOUT, - sleepGracePeriod: PREVENT_SLEEP_TIMEOUT, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/start-stop-race.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/start-stop-race.ts deleted file mode 100644 index 9fad609233..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/start-stop-race.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { actor } from "rivetkit"; - -/** - * Actor designed to test start/stop race conditions. - * Has a slow initialization to make race conditions easier to trigger. - */ -export const startStopRaceActor = actor({ - state: { - initialized: false, - startTime: 0, - destroyCalled: false, - startCompleted: false, - }, - onWake: async (c) => { - c.state.startTime = Date.now(); - - // Simulate slow initialization to create window for race condition - await new Promise((resolve) => setTimeout(resolve, 100)); - - c.state.initialized = true; - c.state.startCompleted = true; - }, - onDestroy: (c) => { - c.state.destroyCalled = true; - // Don't save state here - the actor framework will save it automatically - }, - actions: { - getState: (c) => { - return { - initialized: c.state.initialized, - startTime: c.state.startTime, - destroyCalled: c.state.destroyCalled, - startCompleted: c.state.startCompleted, - }; - }, - ping: (c) => { - return "pong"; - }, - destroy: (c) => { - c.destroy(); - }, - }, -}); - -/** - * Observer actor to track lifecycle events from other actors - */ -export const lifecycleObserver = actor({ - state: { - events: [] as Array<{ - actorKey: string; - event: string; - timestamp: number; - }>, - }, - actions: { - recordEvent: (c, params: { actorKey: string; event: string }) => { - c.state.events.push({ - actorKey: params.actorKey, - event: params.event, - timestamp: Date.now(), - }); - }, - getEvents: (c) => { - return c.state.events; - }, - clearEvents: (c) => { - c.state.events = []; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/state-zod-coercion.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/state-zod-coercion.ts deleted file mode 100644 index b5c31068ef..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/state-zod-coercion.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { actor } from "rivetkit"; -import { z } from "zod"; - -const stateSchema = z.object({ - count: z.number().default(0), - label: z.string().default("default"), -}); - -type State = z.infer; - -export const stateZodCoercionActor = actor({ - state: { count: 0, label: "default" } as State, - onWake: (c) => { - Object.assign(c.state, stateSchema.parse(c.state)); - }, - actions: { - getState: (c) => ({ count: c.state.count, label: c.state.label }), - setCount: (c, count: number) => { - c.state.count = count; - }, - setLabel: (c, label: string) => { - c.state.label = label; - }, - triggerSleep: (c) => { - c.sleep(); - }, - }, - options: { - sleepTimeout: 100, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/stateless.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/stateless.ts deleted file mode 100644 index 0f2b8bbd35..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/stateless.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { actor } from "rivetkit"; - -// Actor without state - only has actions -export const statelessActor = actor({ - actions: { - ping: () => "pong", - echo: (c, message: string) => message, - getActorId: (c) => c.actorId, - // Try to access state - should throw StateNotEnabled - tryGetState: (c) => { - try { - // State is typed as undefined, but we want to test runtime behavior - const state = c.state; - return { success: true, state }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - // Try to access db - should throw DatabaseNotEnabled - tryGetDb: (c) => { - try { - // DB is typed as undefined, but we want to test runtime behavior - const db = c.db; - return { success: true, db }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/vars.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/vars.ts deleted file mode 100644 index 7a62319824..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/vars.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { actor } from "rivetkit"; - -// Actor with static vars -export const staticVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - vars: { counter: 42, name: "test-actor" }, - actions: { - getVars: (c) => { - return c.vars; - }, - getName: (c) => { - return c.vars.name; - }, - }, -}); - -// Actor with nested vars -export const nestedVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - vars: { - counter: 42, - nested: { - value: "original", - array: [1, 2, 3], - obj: { key: "value" }, - }, - }, - actions: { - getVars: (c) => { - return c.vars; - }, - modifyNested: (c) => { - // Attempt to modify the nested object - c.vars.nested.value = "modified"; - c.vars.nested.array.push(4); - c.vars.nested.obj.key = "new-value"; - return c.vars; - }, - }, -}); - -// Actor with dynamic vars -export const dynamicVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: () => { - return { - random: Math.random(), - computed: `Actor-${Math.floor(Math.random() * 1000)}`, - }; - }, - actions: { - getVars: (c) => { - return c.vars; - }, - }, -}); - -// Actor with unique vars per instance -export const uniqueVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: () => { - return { - id: Math.floor(Math.random() * 1000000), - }; - }, - actions: { - getVars: (c) => { - return c.vars; - }, - }, -}); - -// Actor that uses driver context -export const driverCtxActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: (c, driverCtx: any) => { - return { - hasDriverCtx: Boolean(driverCtx?.isTest), - }; - }, - actions: { - getVars: (c) => { - return c.vars; - }, - }, -}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/workflow.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/workflow.ts deleted file mode 100644 index 54be4941b4..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/workflow.ts +++ /dev/null @@ -1,818 +0,0 @@ -// @ts-nocheck -import { Loop } from "@rivetkit/workflow-engine"; -import { actor, event, queue } from "@/actor/mod"; -import { db } from "@/db/mod"; -import { WORKFLOW_GUARD_KV_KEY } from "@/workflow/constants"; -import { - type WorkflowErrorEvent, - type WorkflowLoopContextOf, - workflow, -} from "@/workflow/mod"; -import type { registry } from "./registry"; - -const WORKFLOW_QUEUE_NAME = "workflow-default"; -const WORKFLOW_NESTED_QUEUE_NAME = "workflow-nested"; - -export const workflowCounterActor = actor({ - state: { - runCount: 0, - guardTriggered: false, - history: [] as number[], - }, - run: workflow(async (ctx) => { - await ctx.loop("counter", async (loopCtx) => { - try { - // Accessing state outside a step should throw. - // biome-ignore lint/style/noUnusedExpressions: intentionally checking accessor. - loopCtx.state; - } catch {} - - await loopCtx.step("increment", async () => { - incrementWorkflowCounter(loopCtx); - }); - - await loopCtx.sleep("idle", 25); - return Loop.continue(undefined); - }); - }), - actions: { - getState: async (c) => { - const guardFlag = await c.kv.get(WORKFLOW_GUARD_KV_KEY); - if (guardFlag === "true") { - c.state.guardTriggered = true; - } - return c.state; - }, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowQueueActor = actor({ - state: { - received: [] as unknown[], - }, - queues: { - [WORKFLOW_QUEUE_NAME]: queue(), - }, - run: workflow(async (ctx) => { - await ctx.loop("queue", async (loopCtx) => { - const message = await loopCtx.queue.next("queue-wait", { - names: [WORKFLOW_QUEUE_NAME], - completable: true, - }); - if (!message.complete) { - return Loop.continue(undefined); - } - const complete = message.complete; - await loopCtx.step("store-message", async () => { - await storeWorkflowQueueMessage( - loopCtx, - message.body, - complete, - ); - }); - return Loop.continue(undefined); - }); - }), - actions: { - getMessages: (c) => c.state.received, - sendAndWait: async (c, payload: unknown) => { - const client = c.client(); - const handle = client.workflowQueueActor.getForId(c.actorId); - return await handle.send(WORKFLOW_QUEUE_NAME, payload, { - wait: true, - timeout: 1_000, - }); - }, - }, -}); - -export const workflowNestedLoopActor = actor({ - state: { - processed: [] as string[], - }, - queues: { - [WORKFLOW_NESTED_QUEUE_NAME]: queue< - { items: string[] }, - { processed: number } - >(), - }, - run: workflow(async (ctx) => { - await ctx.loop("command-loop", async (loopCtx) => { - const message = await loopCtx.queue.next<{ - items: string[]; - }>("wait", { - names: [WORKFLOW_NESTED_QUEUE_NAME], - completable: true, - }); - let itemIndex = 0; - await loopCtx.loop("process-items", async (subLoopCtx) => { - const item = message.body.items[itemIndex]; - if (item === undefined) { - return Loop.break(undefined); - } - - await subLoopCtx.step(`process-item-${itemIndex}`, async () => { - subLoopCtx.state.processed.push(item); - }); - itemIndex += 1; - return Loop.continue(undefined); - }); - - await message.complete?.({ processed: message.body.items.length }); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowNestedJoinActor = actor({ - state: { - processed: [] as string[], - }, - queues: { - [WORKFLOW_NESTED_QUEUE_NAME]: queue< - { items: string[] }, - { processed: number } - >(), - }, - run: workflow(async (ctx) => { - await ctx.loop("command-loop", async (loopCtx) => { - const message = await loopCtx.queue.next<{ - items: string[]; - }>("wait", { - names: [WORKFLOW_NESTED_QUEUE_NAME], - completable: true, - }); - - await loopCtx.join( - "process-items", - Object.fromEntries( - message.body.items.map((item, index) => [ - `item-${index}`, - { - run: async (branchCtx) => - await branchCtx.step( - `process-item-${index}`, - async () => { - branchCtx.state.processed.push(item); - return item; - }, - ), - }, - ]), - ), - ); - - await message.complete?.({ processed: message.body.items.length }); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowNestedRaceActor = actor({ - state: { - processed: [] as string[], - }, - queues: { - [WORKFLOW_NESTED_QUEUE_NAME]: queue< - { items: string[] }, - { processed: number } - >(), - }, - run: workflow(async (ctx) => { - await ctx.loop("command-loop", async (loopCtx) => { - const message = await loopCtx.queue.next<{ - items: string[]; - }>("wait", { - names: [WORKFLOW_NESTED_QUEUE_NAME], - completable: true, - }); - const item = message.body.items[0]; - - if (item !== undefined) { - await loopCtx.race("process-item", [ - { - name: "fast", - run: async (raceCtx) => - await raceCtx.step("process-fast", async () => { - raceCtx.state.processed.push(item); - return item; - }), - }, - { - name: "slow", - run: async (raceCtx) => { - await new Promise((resolve) => { - if (raceCtx.abortSignal.aborted) { - resolve(); - return; - } - raceCtx.abortSignal.addEventListener( - "abort", - () => resolve(), - { once: true }, - ); - }); - return "slow"; - }, - }, - ]); - } - - await message.complete?.({ processed: message.body.items.length }); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowSpawnChildActor = actor({ - createState: (_c, input?: string) => ({ - label: input ?? "", - started: false, - processed: [] as string[], - }), - queues: { - work: queue<{ task: string }, { ok: true }>(), - }, - run: workflow(async (ctx) => { - await ctx.step("mark-started", async () => { - ctx.state.started = true; - }); - - await ctx.loop("cmd-loop", async (loopCtx) => { - const message = await loopCtx.queue.next<{ task: string }>( - "wait-cmd", - { - names: ["work"], - completable: true, - }, - ); - await loopCtx.step("process-cmd", async () => { - loopCtx.state.processed.push(message.body.task); - }); - await message.complete?.({ ok: true }); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowSpawnParentActor = actor({ - state: { - results: [] as Array<{ - key: string; - result: unknown | null; - error: string | null; - }>, - }, - queues: { - spawn: queue<{ key: string }>(), - }, - run: workflow(async (ctx) => { - await ctx.loop("parent-loop", async (loopCtx) => { - const message = await loopCtx.queue.next<{ key: string }>( - "wait-parent", - { - names: ["spawn"], - completable: true, - }, - ); - - await loopCtx.step("spawn-child", async () => { - try { - const client = loopCtx.client(); - const handle = client.workflowSpawnChildActor.getOrCreate( - [message.body.key], - { - createWithInput: message.body.key, - }, - ); - const result = await handle.send( - "work", - { task: "hello" }, - { - wait: true, - timeout: 500, - }, - ); - loopCtx.state.results.push({ - key: message.body.key, - result, - error: null, - }); - } catch (error) { - loopCtx.state.results.push({ - key: message.body.key, - result: null, - error: - error instanceof Error - ? error.message - : String(error), - }); - } - }); - - await message.complete?.({ ok: true }); - return Loop.continue(undefined); - }); - }), - actions: { - triggerSpawn: async (c, key: string) => { - await c.queue.send("spawn", { key }); - return { queued: true }; - }, - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowAccessActor = actor({ - db: db({ - onMigrate: async (rawDb) => { - await rawDb.execute(` - CREATE TABLE IF NOT EXISTS workflow_access_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at INTEGER NOT NULL - ) - `); - }, - }), - state: { - outsideDbError: null as string | null, - outsideClientError: null as string | null, - insideDbCount: 0, - insideClientAvailable: false, - }, - run: workflow(async (ctx) => { - await ctx.loop("access", async (loopCtx) => { - let outsideDbError: string | null = null; - let outsideClientError: string | null = null; - - try { - // Accessing db outside a step should throw. - // biome-ignore lint/style/noUnusedExpressions: intentionally checking accessor. - loopCtx.db; - } catch (error) { - outsideDbError = - error instanceof Error ? error.message : String(error); - } - - try { - loopCtx.client(); - } catch (error) { - outsideClientError = - error instanceof Error ? error.message : String(error); - } - - await loopCtx.step("access-step", async () => { - await updateWorkflowAccessState( - loopCtx, - outsideDbError, - outsideClientError, - ); - }); - - await loopCtx.sleep("idle", 25); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, -}); - -export const workflowSleepActor = actor({ - state: { - ticks: 0, - }, - run: workflow(async (ctx) => { - await ctx.loop("sleep", async (loopCtx) => { - await loopCtx.step("tick", async () => { - incrementWorkflowSleepTick(loopCtx); - }); - await loopCtx.sleep("delay", 40); - return Loop.continue(undefined); - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowTryActor = actor({ - state: { - innerWrites: 0, - tryStepFailure: null as - | { - kind: string; - message: string; - attempts: number; - } - | null, - tryJoinFailure: null as string | null, - }, - run: workflow(async (ctx) => { - const stepResult = await ctx.tryStep({ - name: "charge-card", - maxRetries: 0, - run: async () => { - ctx.state.innerWrites += 1; - throw new Error("card declined"); - }, - }); - - const joinResult = await ctx.try("parallel-flow", async (blockCtx) => { - return await blockCtx.join("parallel", { - ok: { - run: async () => "ok", - }, - bad: { - run: async () => { - throw new Error("join failed"); - }, - }, - }); - }); - - await ctx.step("store-try-results", async () => { - if (!stepResult.ok) { - ctx.state.tryStepFailure = { - kind: stepResult.failure.kind, - message: stepResult.failure.error.message, - attempts: stepResult.failure.attempts, - }; - } - if (!joinResult.ok) { - ctx.state.tryJoinFailure = `${joinResult.failure.source}:${joinResult.failure.name}`; - } - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowStopTeardownActor = actor({ - state: { - wakeAts: [] as number[], - sleepAts: [] as number[], - }, - queues: { - never: queue(), - }, - onWake: (c) => { - c.state.wakeAts.push(Date.now()); - }, - onSleep: (c) => { - c.state.sleepAts.push(Date.now()); - }, - run: workflow(async (ctx) => { - await ctx.loop("wait-forever", async (loopCtx) => { - await loopCtx.queue.next("wait-for-never", { - names: ["never"], - }); - return Loop.continue(undefined); - }); - }), - actions: { - getTimeline: (c) => ({ - wakeAts: [...c.state.wakeAts], - sleepAts: [...c.state.sleepAts], - }), - }, - options: { - sleepTimeout: 75, - runStopTimeout: 2_000, - }, -}); - -export const workflowCompleteActor = actor({ - state: { - startCount: 0, - sleepCount: 0, - runCount: 0, - }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - run: workflow(async (ctx) => { - await ctx.step("complete", async () => { - ctx.state.runCount += 1; - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowDestroyActor = actor({ - onDestroy: async (c) => { - const client = c.client(); - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.notifyDestroyed(c.key.join("/")); - }, - run: workflow(async (ctx) => { - await ctx.step("destroy", async () => { - ctx.destroy(); - }); - }), -}); - -export const workflowFailedStepActor = actor({ - state: { - startCount: 0, - sleepCount: 0, - timeline: [] as string[], - runCount: 0, - }, - onWake: (c) => { - c.state.startCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - run: workflow(async (ctx) => { - await ctx.step("prepare", async () => { - ctx.state.timeline.push("prepare"); - }); - await ctx.step({ - name: "fail", - maxRetries: 2, - run: async () => { - ctx.state.runCount += 1; - ctx.state.timeline.push("fail"); - throw new Error("workflow step failed"); - }, - }); - }), - actions: { - getState: (c) => c.state, - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowErrorHookActor = actor({ - state: { - attempts: 0, - events: [] as WorkflowErrorEvent[], - }, - run: workflow( - async (ctx) => { - await ctx.step({ - name: "flaky", - maxRetries: 2, - retryBackoffBase: 1, - retryBackoffMax: 1, - run: async () => { - ctx.state.attempts += 1; - if (ctx.state.attempts === 1) { - throw new Error("workflow hook failed"); - } - }, - }); - await ctx.sleep("idle", 60_000); - }, - { - onError: (c, event) => { - c.state.events.push(event); - }, - }, - ), - actions: { - getErrorState: (c) => c.state, - }, -}); - -export const workflowErrorHookSleepActor = actor({ - state: { - attempts: 0, - wakeCount: 0, - sleepCount: 0, - events: [] as WorkflowErrorEvent[], - }, - onWake: (c) => { - c.state.wakeCount += 1; - }, - onSleep: (c) => { - c.state.sleepCount += 1; - }, - run: workflow( - async (ctx) => { - await ctx.step({ - name: "flaky", - maxRetries: 2, - retryBackoffBase: 1, - retryBackoffMax: 1, - run: async () => { - ctx.state.attempts += 1; - if (ctx.state.attempts === 1) { - throw new Error("workflow hook failed"); - } - }, - }); - await ctx.sleep("idle", 60_000); - }, - { - onError: (c, event) => { - c.state.events.push(event); - }, - }, - ), - actions: { - getErrorState: (c) => c.state, - triggerSleep: (c) => { - c.sleep(); - }, - }, -}); - -export const workflowErrorHookEffectsActor = actor({ - state: { - attempts: 0, - lastError: null as WorkflowErrorEvent | null, - errorCount: 0, - }, - events: { - workflowError: event<[WorkflowErrorEvent]>(), - }, - queues: { - start: queue(), - errors: queue(), - }, - run: workflow( - async (ctx) => { - await ctx.queue.next("start", { - names: ["start"], - }); - await ctx.step({ - name: "flaky", - maxRetries: 2, - retryBackoffBase: 1, - retryBackoffMax: 1, - run: async () => { - ctx.state.attempts += 1; - if (ctx.state.attempts === 1) { - throw new Error("workflow hook failed"); - } - }, - }); - await ctx.sleep("idle", 60_000); - }, - { - onError: async (c, event) => { - c.state.lastError = event; - c.state.errorCount += 1; - c.broadcast("workflowError", event); - await c.queue.send("errors", event); - }, - }, - ), - actions: { - getErrorState: (c) => c.state, - startWorkflow: async (c) => { - const client = c.client(); - const handle = client.workflowErrorHookEffectsActor.getForId( - c.actorId, - ); - await handle.send("start", null); - }, - receiveQueuedError: async (c) => { - const message = await c.queue.next({ - names: ["errors"], - timeout: 1_000, - }); - return message?.body ?? null; - }, - }, -}); - -export const workflowReplayActor = actor({ - state: { - timeline: [] as string[], - }, - run: workflow(async (ctx) => { - await ctx.step("one", async () => { - ctx.state.timeline.push("one"); - }); - await ctx.step("two", async () => { - ctx.state.timeline.push("two"); - }); - }), - actions: { - getTimeline: (c) => [...c.state.timeline], - }, - options: { - sleepTimeout: 50, - }, -}); - -export const workflowRunningStepActor = actor({ - state: { - preparedAt: null as number | null, - startedAt: null as number | null, - }, - run: workflow(async (ctx) => { - await ctx.step("prepare", async () => { - ctx.state.preparedAt = Date.now(); - }); - await ctx.step({ - name: "block", - timeout: 0, - run: async () => { - ctx.state.startedAt = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 250)); - }, - }); - }), - actions: { - getState: (c) => ({ ...c.state }), - }, - options: { - sleepTimeout: 50, - }, -}); - -function incrementWorkflowCounter( - ctx: WorkflowLoopContextOf, -): void { - ctx.state.runCount += 1; - ctx.state.history.push(ctx.state.runCount); -} - -async function storeWorkflowQueueMessage( - ctx: WorkflowLoopContextOf, - body: unknown, - complete: (response: { echo: unknown }) => Promise, -): Promise { - ctx.state.received.push(body); - await complete({ echo: body }); -} - -async function updateWorkflowAccessState( - ctx: WorkflowLoopContextOf, - outsideDbError: string | null, - outsideClientError: string | null, -): Promise { - await ctx.db.execute( - `INSERT INTO workflow_access_log (created_at) VALUES (${Date.now()})`, - ); - const counts = await ctx.db.execute<{ count: number }>( - `SELECT COUNT(*) as count FROM workflow_access_log`, - ); - const client = ctx.client(); - - ctx.state.outsideDbError = outsideDbError; - ctx.state.outsideClientError = outsideClientError; - ctx.state.insideDbCount = counts[0]?.count ?? 0; - ctx.state.insideClientAvailable = - typeof client.workflowQueueActor.getForId === "function"; -} - -function incrementWorkflowSleepTick( - ctx: WorkflowLoopContextOf, -): void { - ctx.state.ticks += 1; -} - -export { WORKFLOW_NESTED_QUEUE_NAME, WORKFLOW_QUEUE_NAME }; diff --git a/rivetkit-typescript/packages/rivetkit/package.json b/rivetkit-typescript/packages/rivetkit/package.json index 8402aa17a1..742efed918 100644 --- a/rivetkit-typescript/packages/rivetkit/package.json +++ b/rivetkit-typescript/packages/rivetkit/package.json @@ -117,26 +117,6 @@ "default": "./dist/tsup/common/websocket.cjs" } }, - "./driver-test-suite": { - "import": { - "types": "./dist/tsup/driver-test-suite/mod.d.ts", - "default": "./dist/tsup/driver-test-suite/mod.js" - }, - "require": { - "types": "./dist/tsup/driver-test-suite/mod.d.cts", - "default": "./dist/tsup/driver-test-suite/mod.cjs" - } - }, - "./serve-test-suite": { - "import": { - "types": "./dist/tsup/serve-test-suite/mod.d.ts", - "default": "./dist/tsup/serve-test-suite/mod.js" - }, - "require": { - "types": "./dist/tsup/serve-test-suite/mod.d.cts", - "default": "./dist/tsup/serve-test-suite/mod.cjs" - } - }, "./topologies/coordinate": { "import": { "types": "./dist/tsup/topologies/coordinate/mod.d.ts", @@ -322,7 +302,7 @@ "./dist/tsup/chunk-*.cjs" ], "scripts": { - "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/common/websocket.ts src/actor/errors.ts src/topologies/coordinate/mod.ts src/topologies/partition/mod.ts src/utils.ts src/driver-helpers/mod.ts src/driver-test-suite/mod.ts src/serve-test-suite/mod.ts src/test/mod.ts src/inspector/mod.ts src/workflow/mod.ts src/dynamic/mod.ts src/db/mod.ts src/db/drizzle/mod.ts src/sandbox/index.ts src/sandbox/client.ts src/sandbox/providers/docker.ts src/sandbox/providers/e2b.ts src/sandbox/providers/daytona.ts src/sandbox/providers/local.ts src/sandbox/providers/vercel.ts src/sandbox/providers/modal.ts src/sandbox/providers/computesdk.ts src/sandbox/providers/sprites.ts && tsup src/agent-os/index.ts --no-clean --out-dir dist/tsup/agent-os", + "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/common/websocket.ts src/actor/errors.ts src/topologies/coordinate/mod.ts src/topologies/partition/mod.ts src/utils.ts src/driver-helpers/mod.ts src/test/mod.ts src/inspector/mod.ts src/workflow/mod.ts src/dynamic/mod.ts src/db/mod.ts src/db/drizzle/mod.ts src/sandbox/index.ts src/sandbox/client.ts src/sandbox/providers/docker.ts src/sandbox/providers/e2b.ts src/sandbox/providers/daytona.ts src/sandbox/providers/local.ts src/sandbox/providers/vercel.ts src/sandbox/providers/modal.ts src/sandbox/providers/computesdk.ts src/sandbox/providers/sprites.ts && tsup src/agent-os/index.ts --no-clean --out-dir dist/tsup/agent-os", "build:dynamic-isolate-runtime": "tsup --config tsup.dynamic-isolate-runtime.config.ts", "build:browser": "tsup --config tsup.browser.config.ts", "build:schema": "./scripts/compile-all-bare.ts", @@ -334,7 +314,6 @@ "format:write": "biome format --write .", "test": "vitest run", "test:watch": "vitest", - "manager-openapi-gen": "tsx scripts/manager-openapi-gen.ts", "dump-asyncapi": "tsx scripts/dump-asyncapi.ts", "registry-config-schema-gen": "tsx scripts/registry-config-schema-gen.ts", "actor-config-schema-gen": "tsx scripts/actor-config-schema-gen.ts", @@ -443,4 +422,4 @@ } }, "stableVersion": "0.8.0" -} \ No newline at end of file +} diff --git a/rivetkit-typescript/packages/rivetkit/runtime/index.ts b/rivetkit-typescript/packages/rivetkit/runtime/index.ts index 51971cdb7f..c883b1d7aa 100644 --- a/rivetkit-typescript/packages/rivetkit/runtime/index.ts +++ b/rivetkit-typescript/packages/rivetkit/runtime/index.ts @@ -1,28 +1,30 @@ import invariant from "invariant"; +import { convertRegistryConfigToClientConfig } from "@/client/config"; import { createClientWithDriver } from "@/client/client"; import { configureBaseLogger, configureDefaultLogger } from "@/common/log"; -import { chooseDefaultDriver } from "@/drivers/default"; -import { ENGINE_PORT, ensureEngineProcess } from "@/engine-process/mod"; +import { ENGINE_ENDPOINT, ENGINE_PORT, ensureEngineProcess } from "@/engine-process/mod"; +import { + getDatacenters, + updateRunnerConfig, +} from "@/engine-client/api-endpoints"; +import { type EngineControlClient } from "@/engine-client/driver"; +import { RemoteEngineControlClient } from "@/engine-client/mod"; import { getInspectorUrl } from "@/inspector/utils"; -import { buildManagerRouter } from "@/manager/router"; +import { type RegistryActors, type RegistryConfig } from "@/registry/config"; +import { logger } from "../src/registry/log"; +import { buildRuntimeRouter } from "@/runtime-router/router"; +import { EngineActorDriver } from "@/drivers/engine/mod"; +import { buildServerlessRouter } from "@/serverless/router"; import { configureServerlessPool } from "@/serverless/configure"; import { detectRuntime, type GetUpgradeWebSocket } from "@/utils"; -import pkg from "../package.json" with { type: "json" }; -import { - type DriverConfig, - type RegistryActors, - type RegistryConfig, -} from "@/registry/config"; -import { logger } from "../src/registry/log"; import { crossPlatformServe, findFreePort, loadRuntimeServeStatic, } from "@/utils/serve"; -import { ManagerDriver } from "@/manager/driver"; -import { buildServerlessRouter } from "@/serverless/router"; import type { Registry } from "@/registry"; import { getNodeFsSync } from "@/utils/node"; +import pkg from "../package.json" with { type: "json" }; /** Tracks whether the runtime was started as serverless or serverful. */ export type StartKind = "serverless" | "serverful"; @@ -32,21 +34,32 @@ function logLine(label: string, value: string): void { console.log(` - ${label}:${padding}${value}`); } -/** - * Manages the lifecycle of RivetKit. - * - * Startup happens in two phases: - * 1. `Runtime.create()` initializes shared infrastructure like the manager - * server and engine process. This runs before we know the deployment mode. - * 2. `startServerless()` or `startEnvoy()` configures mode-specific behavior. - * These are idempotent and called lazily when the first request arrives - * or when explicitly starting a envoy. - */ +async function ensureLocalRunnerConfig(config: RegistryConfig): Promise { + if (config.endpoint !== ENGINE_ENDPOINT) { + return; + } + + const clientConfig = convertRegistryConfigToClientConfig(config); + const dcsRes = await getDatacenters(clientConfig); + + await updateRunnerConfig(clientConfig, config.envoy.poolName, { + datacenters: Object.fromEntries( + dcsRes.datacenters.map((dc) => [ + dc.name, + { + normal: {}, + drain_on_version_upgrade: true, + }, + ]), + ), + }); +} + export class Runtime { #registry: Registry; #config: RegistryConfig; - #driver: DriverConfig; - #managerDriver: ManagerDriver; + #engineClient: EngineControlClient; + #actorDriver?: EngineActorDriver; #startKind?: StartKind; managerPort?: number; @@ -56,26 +69,19 @@ export class Runtime { return this.#config; } - get driver() { - return this.#driver; + get engineClient() { + return this.#engineClient; } - get managerDriver() { - return this.#managerDriver; - } - - /** Use Runtime.create() instead */ private constructor( registry: Registry, config: RegistryConfig, - driver: DriverConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, managerPort?: number, ) { this.#registry = registry; this.#config = config; - this.#driver = driver; - this.#managerDriver = managerDriver; + this.#engineClient = engineClient; this.managerPort = managerPort; } @@ -92,33 +98,11 @@ export class Runtime { configureDefaultLogger(config.logging?.level); } - // This should be unreachable: Zod defaults serveManager to false when - // spawnEngine is enabled (since endpoint gets set to ENGINE_ENDPOINT). - // We check anyway as a safety net for explicit misconfiguration. - invariant( - !(config.serverless.spawnEngine && config.serveManager), - "cannot specify both spawnEngine and serveManager", - ); + const shouldSpawnEngine = + config.serverless.spawnEngine || (config.serveManager && !config.endpoint); + if (shouldSpawnEngine) { + config.endpoint = ENGINE_ENDPOINT; - const driver = chooseDefaultDriver(config); - const managerDriver = driver.manager(config); - - // Start main server. This is either: - // - Manager: Run a server in-process on port 6420 that mimics the - // engine's API for development. - // - Engine: Download and run the full Rivet engine binary on port - // 6420. This is a fallback for platforms that cannot use the manager - // like Next.js. - // - // We do this before startServerless or startEnvoy has been called - // since the engine API needs to be available on port 6420 before - // anything else happens. For example, serverless platforms use - // `registry.handler(req)` so `startServerless` is called lazily. - // Starting the server preemptively allows for clients to reach 6420 - // BEFORE `startServerless` is called. - let managerPort: number | undefined; - if (config.serverless.spawnEngine) { - managerPort = ENGINE_PORT; logger().debug({ msg: "spawning engine", version: config.serverless.engineVersion, @@ -126,17 +110,25 @@ export class Runtime { await ensureEngineProcess({ version: config.serverless.engineVersion, }); - } else if (config.serveManager) { + } + + const engineClient: EngineControlClient = new RemoteEngineControlClient( + convertRegistryConfigToClientConfig(config), + ); + await ensureLocalRunnerConfig(config); + + let managerPort: number | undefined; + if (config.serveManager) { const configuredManagerPort = config.managerPort; const serveRuntime = detectRuntime(); let upgradeWebSocket: any; const getUpgradeWebSocket: GetUpgradeWebSocket = () => upgradeWebSocket; - managerDriver.setGetUpgradeWebSocket(getUpgradeWebSocket); + engineClient.setGetUpgradeWebSocket(getUpgradeWebSocket); - const { router: managerRouter } = buildManagerRouter( + const { router: runtimeRouter } = buildRuntimeRouter( config, - managerDriver, + engineClient, getUpgradeWebSocket, serveRuntime, ); @@ -150,16 +142,10 @@ export class Runtime { } logger().debug({ - msg: "serving manager", + msg: "serving runtime router", port: managerPort, }); - // `publicEndpoint` is derived from `config.managerPort` during config parsing, - // but we may have chosen a different free port at runtime. Keep them in sync - // so browser clients that rely on `/metadata` connect to the correct manager. - // - // Only rewrite when `publicEndpoint` is still on the default localhost pattern, - // to avoid clobbering explicitly-configured public endpoints. if ( config.publicEndpoint === `http://127.0.0.1:${configuredManagerPort}` @@ -169,16 +155,14 @@ export class Runtime { } config.managerPort = managerPort; - // Wrap with static file serving if publicDir is configured - // and the directory actually exists on disk. - let serverApp = managerRouter; + let serverApp = runtimeRouter; if (config.publicDir) { let dirExists = false; try { const fsSync = getNodeFsSync(); dirExists = fsSync.existsSync(config.publicDir); } catch { - // Node fs not available + // Node fs not available. } if (dirExists) { @@ -186,13 +170,11 @@ export class Runtime { const serveStaticFn = await loadRuntimeServeStatic(serveRuntime); const wrapper = new Hono(); - // Serve static files first. Passes through to API - // routes when no file matches. wrapper.use( "*", serveStaticFn({ root: `./${config.publicDir}` }), ); - wrapper.route("/", managerRouter); + wrapper.route("/", runtimeRouter); serverApp = wrapper; } } @@ -205,13 +187,6 @@ export class Runtime { ); upgradeWebSocket = out.upgradeWebSocket; - // Close the server on SIGTERM/SIGINT so the port is freed quickly - // during hot reload. Dev servers like tsx --watch, nodemon, etc. - // sometimes start the new process before the old one fully exits, - // causing findFreePort to skip the preferred port. - // - // Only do this for the manager, not the engine. Closing the engine - // server on signal would cause running actors to hard fail. if (out.closeServer && process.env.NODE_ENV !== "production") { const shutdown = () => { out.closeServer!(); @@ -221,22 +196,13 @@ export class Runtime { } } - // Create runtime - const runtime = new Runtime( - registry, - config, - driver, - managerDriver, - managerPort, - ); + const runtime = new Runtime(registry, config, engineClient, managerPort); - // Log ready - const driverLog = managerDriver.extraStartupLog?.() ?? {}; logger().info({ msg: "rivetkit ready", - driver: driver.name, + driver: "engine", definitions: Object.keys(config.use).length, - ...driverLog, + ...(engineClient.extraStartupLog?.() ?? {}), }); return runtime; @@ -247,10 +213,7 @@ export class Runtime { invariant(!this.#startKind, "Runtime already started as serverful"); this.#startKind = "serverless"; - this.#serverlessRouter = buildServerlessRouter( - this.#driver, - this.#config, - ).router; + this.#serverlessRouter = buildServerlessRouter(this.#config).router; this.#printWelcome(); @@ -260,17 +223,22 @@ export class Runtime { } } - startEnvoy(): void { + async startEnvoy(): Promise { if (this.#startKind === "serverful") return; invariant(!this.#startKind, "Runtime already started as serverless"); this.#startKind = "serverful"; - if (this.#config.envoy && this.#driver.autoStartActorDriver) { - logger().debug("starting actor driver"); + if (this.#config.envoy && !this.#actorDriver) { + logger().debug("starting engine actor driver"); const inlineClient = createClientWithDriver>( - this.#managerDriver, + this.#engineClient, + ); + this.#actorDriver = new EngineActorDriver( + this.#config, + this.#engineClient, + inlineClient, ); - this.#driver.actor(this.#config, this.#managerDriver, inlineClient); + await this.#actorDriver.waitForReady(); } this.#printWelcome(); @@ -285,30 +253,24 @@ export class Runtime { console.log(); console.log( - ` RivetKit ${pkg.version} (${this.#driver.displayName} - ${this.#startKind === "serverless" ? "Serverless" : "Serverful"})`, + ` RivetKit ${pkg.version} (Engine - ${this.#startKind === "serverless" ? "Serverless" : "Serverful"})`, ); - // Show namespace if (this.#config.namespace !== "default") { logLine("Namespace", this.#config.namespace); } - // Show backend endpoint (where we connect to engine) if (this.#config.endpoint) { - const endpointType = this.#config.serverless.spawnEngine + const endpointType = this.#config.endpoint === ENGINE_ENDPOINT ? "local native" - : this.#config.serveManager - ? "local manager" - : "remote"; + : "remote"; logLine("Endpoint", `${this.#config.endpoint} (${endpointType})`); } - // Show public endpoint (where clients connect) if (this.#startKind === "serverless" && this.#config.publicEndpoint) { logLine("Client", this.#config.publicEndpoint); } - // Show static file serving if (this.#config.publicDir) { try { const fsSync = getNodeFsSync(); @@ -316,21 +278,17 @@ export class Runtime { logLine("Static", `./${this.#config.publicDir}`); } } catch { - // Node fs not available (e.g. Deno, Bun, or importNodeDependencies not called) + // Node fs not available. } } - // Show inspector if (inspectorUrl && this.#config.inspector.enabled) { logLine("Inspector", inspectorUrl); } - // Show actor count - const actorCount = Object.keys(this.#config.use).length; - logLine("Actors", actorCount.toString()); + logLine("Actors", Object.keys(this.#config.use).length.toString()); - // Show driver-specific info - const displayInfo = this.#managerDriver.displayInformation(); + const displayInfo = this.#engineClient.displayInformation(); for (const [k, v] of Object.entries(displayInfo.properties)) { logLine(k, v); } @@ -338,7 +296,6 @@ export class Runtime { console.log(); } - /** Handle serverless request */ handleServerlessRequest(request: Request): Response | Promise { invariant( this.#startKind === "serverless", diff --git a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v1.bare b/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v1.bare deleted file mode 100644 index 0a2ffb7240..0000000000 --- a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v1.bare +++ /dev/null @@ -1,18 +0,0 @@ -# File System Driver Schema (v1) - -# MARK: Actor State -# Represents the persisted state for an actor on disk. -type ActorState struct { - actorId: str - name: str - key: list - persistedData: data - createdAt: u64 -} - -# MARK: Actor Alarm -type ActorAlarm struct { - actorId: str - timestamp: uint -} - diff --git a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v2.bare b/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v2.bare deleted file mode 100644 index 73ba10773d..0000000000 --- a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v2.bare +++ /dev/null @@ -1,23 +0,0 @@ -# File System Driver Schema (v2) - -# MARK: Actor State -type ActorKvEntry struct { - key: data - value: data -} - -type ActorState struct { - actorId: str - name: str - key: list - # KV storage map for actor and connection data - # Keys are strings (base64 encoded), values are byte arrays - kvStorage: list - createdAt: u64 -} - -# MARK: Actor Alarm -type ActorAlarm struct { - actorId: str - timestamp: uint -} diff --git a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v3.bare b/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v3.bare deleted file mode 100644 index 60eb48d3b3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/schemas/file-system-driver/v3.bare +++ /dev/null @@ -1,28 +0,0 @@ -# File System Driver Schema (v3) - -# MARK: Actor State -type ActorKvEntry struct { - key: data - value: data -} - -type ActorState struct { - actorId: str - name: str - key: list - # KV storage map for actor and connection data - # Keys are strings (base64 encoded), values are byte arrays - kvStorage: list - createdAt: u64 - # New timestamp fields for lifecycle tracking - startTs: optional - connectableTs: optional - sleepTs: optional - destroyTs: optional -} - -# MARK: Actor Alarm -type ActorAlarm struct { - actorId: str - timestamp: uint -} diff --git a/rivetkit-typescript/packages/rivetkit/scripts/bench-sqlite.ts b/rivetkit-typescript/packages/rivetkit/scripts/bench-sqlite.ts deleted file mode 100755 index c58d79a777..0000000000 --- a/rivetkit-typescript/packages/rivetkit/scripts/bench-sqlite.ts +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env -S tsx -// @ts-nocheck - -/** - * SQLite Benchmark Script - * - * Compares batch vs non-batch performance across driver configurations. - */ - -import Table from "cli-table3"; -import { - createFileSystemDriver, - createMemoryDriver, -} from "@/drivers/file-system/mod"; -import { registry } from "../fixtures/driver-test-suite/registry"; - -interface TimingResult { - batch: number; - nonBatch: number; -} - -interface BenchmarkResult { - name: string; - insert: TimingResult; - select: TimingResult; - update: TimingResult; -} - -const ROW_COUNT = 100; -const QUERY_COUNT = 100; - -type Client = Awaited>["client"]; - -async function runBenchmark( - client: Client, - name: string, -): Promise { - const results: BenchmarkResult = { - name, - insert: { batch: 0, nonBatch: 0 }, - select: { batch: 0, nonBatch: 0 }, - update: { batch: 0, nonBatch: 0 }, - }; - - // --- INSERT --- - // Non-batch - { - const handle = await client.dbActorRaw.getOrCreate([ - `bench-nonbatch-${Date.now()}`, - ]); - const start = performance.now(); - for (let i = 0; i < ROW_COUNT; i++) { - await handle.insertValue(`User ${i}`); - } - results.insert.nonBatch = performance.now() - start; - } - - // Batch - { - const handle = await client.dbActorRaw.getOrCreate([ - `bench-batch-${Date.now()}`, - ]); - const start = performance.now(); - await handle.insertMany(ROW_COUNT); - results.insert.batch = performance.now() - start; - } - - // --- SELECT --- - const handle = await client.dbActorRaw.getOrCreate([ - `bench-select-${Date.now()}`, - ]); - await handle.insertMany(ROW_COUNT); - - // Batch (single query) - { - const start = performance.now(); - await handle.getValues(); - results.select.batch = performance.now() - start; - } - - // Non-batch (100 queries) - { - const start = performance.now(); - for (let i = 0; i < QUERY_COUNT; i++) { - await handle.getCount(); - } - results.select.nonBatch = performance.now() - start; - } - - // --- UPDATE --- - // Non-batch - { - const start = performance.now(); - for (let i = 1; i <= QUERY_COUNT; i++) { - await handle.updateValue(i, `Updated ${i}`); - } - results.update.nonBatch = performance.now() - start; - } - - // Batch - { - const start = performance.now(); - // Single request that performs multiple updates in the actor. - await handle.repeatUpdate(1, QUERY_COUNT); - results.update.batch = performance.now() - start; - } - - return results; -} - -function ms(n: number): string { - return n === 0 ? "-" : `${n.toFixed(2)}ms`; -} - -function perOp(total: number, count: number): string { - return total === 0 ? "-" : `${(total / count).toFixed(3)}ms`; -} - -function speedup(nonBatch: number, batch: number): string { - if (nonBatch === 0 || batch === 0) return "-"; - return `${(nonBatch / batch).toFixed(1)}x`; -} - -function printResults(results: BenchmarkResult[]): void { - console.log(`\nBenchmark: ${ROW_COUNT} rows, ${QUERY_COUNT} queries\n`); - - // INSERT table - const insertTable = new Table({ - head: ["Driver", "Batch", "Per-Op", "Non-Batch", "Per-Op", "Speedup"], - }); - for (const r of results) { - insertTable.push([ - r.name, - ms(r.insert.batch), - perOp(r.insert.batch, ROW_COUNT), - ms(r.insert.nonBatch), - perOp(r.insert.nonBatch, ROW_COUNT), - speedup(r.insert.nonBatch, r.insert.batch), - ]); - } - console.log("INSERT"); - console.log(insertTable.toString()); - - // SELECT table - const selectTable = new Table({ - head: ["Driver", "Batch", "Non-Batch", "Per-Query", "Speedup"], - }); - for (const r of results) { - selectTable.push([ - r.name, - ms(r.select.batch), - ms(r.select.nonBatch), - perOp(r.select.nonBatch, QUERY_COUNT), - speedup(r.select.nonBatch, r.select.batch), - ]); - } - console.log("\nSELECT"); - console.log(selectTable.toString()); - - // UPDATE table - const updateTable = new Table({ - head: ["Driver", "Batch", "Per-Op", "Non-Batch", "Per-Op", "Speedup"], - }); - for (const r of results) { - updateTable.push([ - r.name, - ms(r.update.batch), - perOp(r.update.batch, QUERY_COUNT), - ms(r.update.nonBatch), - perOp(r.update.nonBatch, QUERY_COUNT), - speedup(r.update.nonBatch, r.update.batch), - ]); - } - console.log("\nUPDATE"); - console.log(updateTable.toString()); - - // Cross-driver comparison - const baseline = results[0]; - if (baseline && results.length > 1) { - const compTable = new Table({ - head: [ - "Driver", - "Insert (batch)", - "vs Baseline", - "Select (batch)", - "vs Baseline", - ], - }); - for (const r of results) { - compTable.push([ - r.name, - ms(r.insert.batch), - `${(r.insert.batch / baseline.insert.batch).toFixed(1)}x`, - ms(r.select.batch), - `${(r.select.batch / baseline.select.batch).toFixed(1)}x`, - ]); - } - console.log("\nCROSS-DRIVER (batch mode)"); - console.log(compTable.toString()); - } -} - -async function main(): Promise { - console.log("SQLite Benchmark\n"); - - const results: BenchmarkResult[] = []; - - // 1. File System - console.log("1. File System..."); - try { - const { client } = await registry.start({ - driver: createFileSystemDriver({ - path: `/tmp/rivetkit-bench-${crypto.randomUUID()}`, - }), - defaultServerPort: 6430, - }); - results.push(await runBenchmark(client, "File System")); - console.log(" Done"); - } catch (err) { - console.log(` Skipped: ${err}`); - } - - // 2. Memory - console.log("2. Memory..."); - try { - const { client } = await registry.start({ - driver: createMemoryDriver(), - defaultServerPort: 6431, - }); - results.push(await runBenchmark(client, "Memory")); - console.log(" Done"); - } catch (err) { - console.log(` Skipped: ${err}`); - } - - // 3. Engine (connects to running engine on 6420) - console.log("3. Engine (localhost:6420)..."); - try { - const { client } = await registry.start({ - endpoint: "http://localhost:6420", - }); - results.push(await runBenchmark(client, "Engine")); - console.log(" Done"); - } catch (err) { - console.log(` Skipped: ${err}`); - } - - printResults(results); -} - -main().catch(console.error); diff --git a/rivetkit-typescript/packages/rivetkit/scripts/manager-openapi-gen.ts b/rivetkit-typescript/packages/rivetkit/scripts/manager-openapi-gen.ts deleted file mode 100644 index 3724f94025..0000000000 --- a/rivetkit-typescript/packages/rivetkit/scripts/manager-openapi-gen.ts +++ /dev/null @@ -1,582 +0,0 @@ -import * as fs from "node:fs/promises"; -import { resolve } from "node:path"; -import { z } from "zod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import type { ManagerDriver } from "@/manager/driver"; -import { buildManagerRouter } from "@/manager/router"; -import { type RegistryConfig, RegistryConfigSchema } from "@/registry/config"; -import { VERSION } from "@/utils"; -import { toJsonSchema } from "./schema-utils"; - -async function main() { - const config: RegistryConfig = RegistryConfigSchema.parse({ - use: {}, - driver: createFileSystemOrMemoryDriver(false), - getUpgradeWebSocket: () => () => unimplemented(), - inspector: { - enabled: false, - }, - }); - // const registry = setup(registryConfig); - - const managerDriver: ManagerDriver = { - getForId: unimplemented, - getWithKey: unimplemented, - getOrCreateWithKey: unimplemented, - createActor: unimplemented, - listActors: unimplemented, - sendRequest: unimplemented, - openWebSocket: unimplemented, - proxyRequest: unimplemented, - proxyWebSocket: unimplemented, - displayInformation: unimplemented, - setGetUpgradeWebSocket: unimplemented, - buildGatewayUrl: unimplemented, - kvGet: unimplemented, - kvBatchGet: unimplemented, - kvBatchPut: unimplemented, - kvBatchDelete: unimplemented, - kvDeleteRange: unimplemented, - }; - - // const client = createClientWithDriver( - // managerDriver, - // ClientConfigSchema.parse({}), - // ); - // - const { openapi: managerOpenapi } = buildManagerRouter( - config, - managerDriver, - undefined, - ); - - // Get OpenAPI document - const managerOpenApiDoc = managerOpenapi.getOpenAPIDocument({ - openapi: "3.0.0", - info: { - version: VERSION, - title: "RivetKit API", - }, - }); - - // Inject actor router paths - injectActorRouter(managerOpenApiDoc); - - const outputPath = resolve( - import.meta.dirname, - "..", - "..", - "..", - "..", - "rivetkit-openapi", - "openapi.json", - ); - await fs.writeFile(outputPath, JSON.stringify(managerOpenApiDoc, null, 2)); - console.log("Dumped OpenAPI to", outputPath); -} - -// Schemas for action request/response -const HttpActionRequestSchema = z.object({ - args: z.unknown(), -}); - -const HttpActionResponseSchema = z.object({ - output: z.unknown(), -}); - -/** - * Manually inject actor router paths into the OpenAPI spec. - * - * We do this manually instead of extracting from the actual router since the - * actor routes support multiple encodings (JSON, CBOR, bare), but OpenAPI - * specs are JSON-focused and don't cleanly represent multi-encoding routes. - */ -function injectActorRouter(openApiDoc: any) { - if (!openApiDoc.paths) { - openApiDoc.paths = {}; - } - - // Convert Zod schemas to JSON Schema - const actionRequestSchema = toJsonSchema(HttpActionRequestSchema); - delete (actionRequestSchema as any).$schema; - - const actionResponseSchema = toJsonSchema(HttpActionResponseSchema); - delete (actionResponseSchema as any).$schema; - - // Common actorId parameter - const actorIdParam = { - name: "actorId", - in: "path" as const, - required: true, - schema: { - type: "string", - }, - description: "The ID of the actor to target", - }; - - // GET /gateway/{actorId}/health - openApiDoc.paths["/gateway/{actorId}/health"] = { - get: { - parameters: [actorIdParam], - responses: { - 200: { - description: "Health check", - content: { - "text/plain": { - schema: { - type: "string", - }, - }, - }, - }, - }, - }, - }; - - // POST /gateway/{actorId}/action/{action} - openApiDoc.paths["/gateway/{actorId}/action/{action}"] = { - post: { - parameters: [ - actorIdParam, - { - name: "action", - in: "path" as const, - required: true, - schema: { - type: "string", - }, - description: "The name of the action to execute", - }, - ], - requestBody: { - content: { - "application/json": { - schema: actionRequestSchema, - }, - }, - }, - responses: { - 200: { - description: "Action executed successfully", - content: { - "application/json": { - schema: actionResponseSchema, - }, - }, - }, - 400: { - description: "Invalid action", - }, - 500: { - description: "Internal error", - }, - }, - }, - }; - - // ALL /gateway/{actorId}/request/{path} - const requestPath = { - parameters: [ - actorIdParam, - { - name: "path", - in: "path" as const, - required: true, - schema: { - type: "string", - }, - description: "The HTTP path to forward to the actor", - }, - ], - responses: { - 200: { - description: "Response from actor's raw HTTP handler", - }, - }, - }; - - openApiDoc.paths["/gateway/{actorId}/request/{path}"] = { - get: requestPath, - post: requestPath, - put: requestPath, - delete: requestPath, - patch: requestPath, - head: requestPath, - options: requestPath, - }; - - // Inspector endpoints - const inspectorAuthHeader = { - name: "Authorization", - in: "header" as const, - required: false, - schema: { - type: "string", - }, - description: - "Bearer token for inspector authentication. Required in production, optional in development.", - }; - - // GET /gateway/{actorId}/inspector/state - openApiDoc.paths["/gateway/{actorId}/inspector/state"] = { - get: { - parameters: [actorIdParam, inspectorAuthHeader], - responses: { - 200: { - description: "Current actor state", - content: { - "application/json": { - schema: { - type: "object", - properties: { - state: {}, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - patch: { - parameters: [actorIdParam, inspectorAuthHeader], - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - properties: { - state: {}, - }, - required: ["state"], - }, - }, - }, - }, - responses: { - 200: { - description: "State updated", - content: { - "application/json": { - schema: { - type: "object", - properties: { - ok: { type: "boolean" }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/connections - openApiDoc.paths["/gateway/{actorId}/inspector/connections"] = { - get: { - parameters: [actorIdParam, inspectorAuthHeader], - responses: { - 200: { - description: "Current actor connections", - content: { - "application/json": { - schema: { - type: "object", - properties: { - connections: { - type: "array", - items: { type: "object" }, - }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/rpcs - openApiDoc.paths["/gateway/{actorId}/inspector/rpcs"] = { - get: { - parameters: [actorIdParam, inspectorAuthHeader], - responses: { - 200: { - description: "Available actor actions/RPCs", - content: { - "application/json": { - schema: { - type: "object", - properties: { - rpcs: { type: "object" }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // POST /gateway/{actorId}/inspector/action/{name} - openApiDoc.paths["/gateway/{actorId}/inspector/action/{name}"] = { - post: { - parameters: [ - actorIdParam, - { - name: "name", - in: "path" as const, - required: true, - schema: { - type: "string", - }, - description: "The name of the action to execute", - }, - inspectorAuthHeader, - ], - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - properties: { - args: { - type: "array", - items: {}, - }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "Action executed successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - output: {}, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/queue - openApiDoc.paths["/gateway/{actorId}/inspector/queue"] = { - get: { - parameters: [ - actorIdParam, - { - name: "limit", - in: "query" as const, - required: false, - schema: { - type: "integer", - default: 50, - }, - description: "Maximum number of queue messages to return", - }, - inspectorAuthHeader, - ], - responses: { - 200: { - description: "Queue status", - content: { - "application/json": { - schema: { - type: "object", - properties: { - size: { type: "integer" }, - maxSize: { type: "integer" }, - truncated: { type: "boolean" }, - messages: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - name: { type: "string" }, - createdAtMs: { - type: "integer", - }, - }, - }, - }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/traces - openApiDoc.paths["/gateway/{actorId}/inspector/traces"] = { - get: { - parameters: [ - actorIdParam, - { - name: "startMs", - in: "query" as const, - required: false, - schema: { - type: "integer", - default: 0, - }, - description: "Start of time range in epoch milliseconds", - }, - { - name: "endMs", - in: "query" as const, - required: false, - schema: { - type: "integer", - }, - description: - "End of time range in epoch milliseconds. Defaults to now.", - }, - { - name: "limit", - in: "query" as const, - required: false, - schema: { - type: "integer", - default: 1000, - }, - description: "Maximum number of spans to return", - }, - inspectorAuthHeader, - ], - responses: { - 200: { - description: "Trace spans in OTLP JSON format", - content: { - "application/json": { - schema: { - type: "object", - properties: { - otlp: { type: "object" }, - clamped: { type: "boolean" }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/workflow-history - openApiDoc.paths["/gateway/{actorId}/inspector/workflow-history"] = { - get: { - parameters: [actorIdParam, inspectorAuthHeader], - responses: { - 200: { - description: "Workflow history and status", - content: { - "application/json": { - schema: { - type: "object", - properties: { - history: {}, - isWorkflowEnabled: { type: "boolean" }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // POST /gateway/{actorId}/inspector/workflow/replay - openApiDoc.paths["/gateway/{actorId}/inspector/workflow/replay"] = { - post: { - parameters: [actorIdParam, inspectorAuthHeader], - requestBody: { - required: false, - content: { - "application/json": { - schema: { - type: "object", - properties: { - entryId: { type: "string" }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "Workflow history after scheduling a replay", - content: { - "application/json": { - schema: { - type: "object", - properties: { - history: {}, - isWorkflowEnabled: { type: "boolean" }, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; - - // GET /gateway/{actorId}/inspector/summary - openApiDoc.paths["/gateway/{actorId}/inspector/summary"] = { - get: { - parameters: [actorIdParam, inspectorAuthHeader], - responses: { - 200: { - description: "Full actor inspector summary", - content: { - "application/json": { - schema: { - type: "object", - properties: { - state: {}, - connections: { - type: "array", - items: { type: "object" }, - }, - rpcs: { type: "object" }, - queueSize: { type: "integer" }, - isStateEnabled: { type: "boolean" }, - isDatabaseEnabled: { type: "boolean" }, - isWorkflowEnabled: { type: "boolean" }, - workflowHistory: {}, - }, - }, - }, - }, - }, - 401: { description: "Unauthorized" }, - }, - }, - }; -} - -function unimplemented(): never { - throw new Error("UNIMPLEMENTED"); -} - -// biome-ignore lint/nursery/noFloatingPromises: main -main(); diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/actor-path.ts b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/actor-path.ts similarity index 99% rename from rivetkit-typescript/packages/rivetkit/src/manager/actor-path.ts rename to rivetkit-typescript/packages/rivetkit/src/actor-gateway/actor-path.ts index 3c640ad998..eb3100d004 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/actor-path.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/actor-path.ts @@ -12,7 +12,7 @@ import { type CrashPolicy, GetForKeyRequestSchema, GetOrCreateRequestSchema, -} from "./protocol/query"; +} from "@/client/query"; /** * The `rvt-` query parameter prefix is reserved for Rivet gateway routing. @@ -42,8 +42,8 @@ export interface ParsedDirectActorPath { * * The actor name is a clean path segment, and all routing params are rvt-* * query parameters that get stripped before forwarding to the actor. This path - * must be resolved to a concrete actor ID before proxying, using the manager - * driver's getWithKey or getOrCreateWithKey methods. + * must be resolved to a concrete actor ID before proxying, using the engine + * control client's getWithKey or getOrCreateWithKey methods. * * This is the engine-side reference implementation's TypeScript equivalent. * See `engine/packages/guard/src/routing/actor_path.rs` for the Rust counterpart. diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts similarity index 96% rename from rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts rename to rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts index 039bdcf0e5..d98f403eea 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts @@ -16,8 +16,8 @@ import type { UniversalWebSocket } from "@/mod"; import type { RegistryConfig } from "@/registry/config"; import { promiseWithResolvers } from "@/utils"; import type { GetUpgradeWebSocket } from "@/utils"; +import type { EngineControlClient } from "@/engine-client/driver"; import { parseActorPath } from "./actor-path"; -import type { ManagerDriver } from "./driver"; import { logger } from "./log"; import { resolvePathBasedActorPath } from "./resolve-query"; @@ -34,7 +34,7 @@ export { parseActorPath } from "./actor-path"; */ async function handleWebSocketGatewayPathBased( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, c: HonoContext, actorPathInfo: ReturnType & {}, getUpgradeWebSocket: GetUpgradeWebSocket | undefined, @@ -46,7 +46,7 @@ async function handleWebSocketGatewayPathBased( const resolvedActorPathInfo = await resolvePathBasedActorPath( config, - managerDriver, + engineClient, c, actorPathInfo, ); @@ -65,7 +65,7 @@ async function handleWebSocketGatewayPathBased( encoding, }); - return await managerDriver.proxyWebSocket( + return await engineClient.proxyWebSocket( c, resolvedActorPathInfo.remainingPath, resolvedActorPathInfo.actorId, @@ -79,13 +79,13 @@ async function handleWebSocketGatewayPathBased( */ async function handleHttpGatewayPathBased( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, c: HonoContext, actorPathInfo: ReturnType & {}, ): Promise { const resolvedActorPathInfo = await resolvePathBasedActorPath( config, - managerDriver, + engineClient, c, actorPathInfo, ); @@ -115,7 +115,7 @@ async function handleHttpGatewayPathBased( duplex: "half", } as RequestInit); - return await managerDriver.proxyRequest( + return await engineClient.proxyRequest( c, proxyRequest, resolvedActorPathInfo.actorId, @@ -138,7 +138,7 @@ async function handleHttpGatewayPathBased( */ export async function actorGateway( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, getUpgradeWebSocket: GetUpgradeWebSocket | undefined, c: HonoContext, next: Next, @@ -185,7 +185,7 @@ export async function actorGateway( if (isWebSocket) { return await handleWebSocketGatewayPathBased( config, - managerDriver, + engineClient, c, actorPathInfo, getUpgradeWebSocket, @@ -195,7 +195,7 @@ export async function actorGateway( // Handle regular HTTP requests return await handleHttpGatewayPathBased( config, - managerDriver, + engineClient, c, actorPathInfo, ); @@ -206,7 +206,7 @@ export async function actorGateway( if (c.req.header("upgrade") === "websocket") { return await handleWebSocketGateway( config, - managerDriver, + engineClient, getUpgradeWebSocket, c, strippedPath, @@ -214,7 +214,7 @@ export async function actorGateway( } // Handle regular HTTP requests - return await handleHttpGateway(managerDriver, c, next, strippedPath); + return await handleHttpGateway(engineClient, c, next, strippedPath); } /** @@ -222,7 +222,7 @@ export async function actorGateway( */ async function handleWebSocketGateway( _config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, getUpgradeWebSocket: GetUpgradeWebSocket | undefined, c: HonoContext, strippedPath: string, @@ -265,7 +265,7 @@ async function handleWebSocketGateway( ? strippedPath + c.req.url.substring(c.req.url.indexOf("?")) : strippedPath; - return await managerDriver.proxyWebSocket( + return await engineClient.proxyWebSocket( c, pathWithQuery, actorId, @@ -278,7 +278,7 @@ async function handleWebSocketGateway( * Handle HTTP requests using x-rivet headers for routing */ async function handleHttpGateway( - managerDriver: ManagerDriver, + engineClient: EngineControlClient, c: HonoContext, next: Next, strippedPath: string, @@ -318,7 +318,7 @@ async function handleHttpGateway( duplex: "half", } as RequestInit); - return await managerDriver.proxyRequest(c, proxyRequest, actorId); + return await engineClient.proxyRequest(c, proxyRequest, actorId); } /** diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/log.ts b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/log.ts similarity index 66% rename from rivetkit-typescript/packages/rivetkit/src/driver-test-suite/log.ts rename to rivetkit-typescript/packages/rivetkit/src/actor-gateway/log.ts index 7318dcbcec..b6727b9362 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/log.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/log.ts @@ -1,5 +1,5 @@ import { getLogger } from "@/common/log"; export function logger() { - return getLogger("test-suite"); + return getLogger("actor-gateway"); } diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/resolve-query.ts b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/resolve-query.ts similarity index 82% rename from rivetkit-typescript/packages/rivetkit/src/manager/resolve-query.ts rename to rivetkit-typescript/packages/rivetkit/src/actor-gateway/resolve-query.ts index bc547fd64e..e7a7b73ec4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/resolve-query.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/resolve-query.ts @@ -2,7 +2,7 @@ * Query gateway path resolution. * * Resolves a parsed query gateway path to a concrete actor ID by calling the - * appropriate manager driver method (getWithKey or getOrCreateWithKey). + * appropriate engine control client method (getWithKey or getOrCreateWithKey). * * This is the TypeScript equivalent of the engine resolver at * `engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs`. @@ -15,7 +15,7 @@ import type { ParsedDirectActorPath, ParsedQueryActorPath, } from "./actor-path"; -import type { ManagerDriver } from "./driver"; +import type { EngineControlClient } from "@/engine-client/driver"; import { logger } from "./log"; /** @@ -25,7 +25,7 @@ import { logger } from "./log"; */ export async function resolvePathBasedActorPath( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, c: HonoContext, actorPathInfo: ParsedActorPath, ): Promise { @@ -36,7 +36,7 @@ export async function resolvePathBasedActorPath( assertQueryNamespaceMatchesConfig(config, actorPathInfo.namespace); const actorId = await resolveQueryActorId( - managerDriver, + engineClient, c, actorPathInfo, ); @@ -57,17 +57,17 @@ export async function resolvePathBasedActorPath( /** * Resolve a query actor path to a concrete actor ID by dispatching to the - * appropriate manager driver method. + * appropriate engine control client method. */ async function resolveQueryActorId( - managerDriver: ManagerDriver, + engineClient: EngineControlClient, c: HonoContext, actorPathInfo: ParsedQueryActorPath, ): Promise { const { query, crashPolicy } = actorPathInfo; if ("getForKey" in query) { - const actorOutput = await managerDriver.getWithKey({ + const actorOutput = await engineClient.getWithKey({ c, name: query.getForKey.name, key: query.getForKey.key, @@ -81,7 +81,7 @@ async function resolveQueryActorId( } if ("getOrCreateForKey" in query) { - const actorOutput = await managerDriver.getOrCreateWithKey({ + const actorOutput = await engineClient.getOrCreateWithKey({ c, name: query.getOrCreateForKey.name, key: query.getOrCreateForKey.key, @@ -104,7 +104,7 @@ function assertQueryNamespaceMatchesConfig( return; } - throw new errors.InvalidRequest( - `query gateway namespace '${namespace}' does not match manager namespace '${config.namespace}'`, + throw new errors.InvalidRequest( + `query gateway namespace '${namespace}' does not match runtime namespace '${config.namespace}'`, ); } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts b/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts index 2c665b81cf..e92d710af5 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts @@ -1,6 +1,6 @@ import type { Context as HonoContext } from "hono"; import type { AnyClient } from "@/client/client"; -import type { ManagerDriver } from "@/manager/driver"; +import type { EngineControlClient } from "@/engine-client/driver"; import type { AnyActorInstance, AnyStaticActorInstance } from "./instance/mod"; import type { RegistryConfig } from "@/registry/config"; import type { @@ -12,7 +12,7 @@ import type { ISqliteVfs } from "@rivetkit/sqlite-vfs"; export type ActorDriverBuilder = ( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, inlineClient: AnyClient, ) => ActorDriver; diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index c0c477ff2b..80c061b2fd 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -14,7 +14,7 @@ import { getBaseLogger, getIncludeTarget, type Logger } from "@/common/log"; import { stringifyError } from "@/common/utils"; import type { UniversalWebSocket } from "@/common/websocket-interface"; import { ActorInspector } from "@/inspector/actor-inspector"; -import type { ActorKey } from "@/manager/protocol/query"; +import type { ActorKey } from "@/client/query"; import type { Registry } from "@/mod"; import { ACTOR_VERSIONED, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts index e2131b9feb..68f0708175 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts @@ -17,7 +17,7 @@ export type { RivetMessageEvent, UniversalWebSocket, } from "@/common/websocket-interface"; -export type { ActorKey } from "@/manager/protocol/query"; +export type { ActorKey } from "@/client/query"; export type * from "./config"; export { CONN_STATE_MANAGER_SYMBOL } from "./conn/mod"; export type { AnyConn, Conn } from "./conn/mod"; diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.test.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.test.ts index bffe444461..a6f50b4863 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.test.ts @@ -12,7 +12,7 @@ import { * * NOTE: The driver-file-system end-to-end tests pass because the driver * correctly strips query parameters before calling routeWebSocket - * (see FileSystemManagerDriver.openWebSocket). However, the bug still + * (see FileSystemEngineControlClient.openWebSocket). However, the bug still * exists in routeWebSocket itself and could be triggered by other callers * (e.g., engine driver's runnerWebSocket which passes requestPath directly). */ diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts index cab6c635d3..52615d3328 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts @@ -276,14 +276,16 @@ export async function handleWebSocketConnect( return; } + const actionRequest = message.body.val; + void actor.processMessage(message, conn).catch((error) => { const { group, code } = deconstructError( error, actor.rLog, { wsEvent: "message", - actionId: message.body.val.id, - actionName: message.body.val.name, + actionId: actionRequest.id, + actionName: actionRequest.name, }, exposeInternalError, ); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router.ts index 3175fc4382..d5e65424e6 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router.ts @@ -65,7 +65,7 @@ async function loadStaticActor(actorDriver: ActorDriver, actorId: string) { * * You only need to pass `getUpgradeWebSocket` if this router is exposed * directly publicly. Usually WebSockets are routed manually in the - * ManagerDriver instead of via Hono. The only platform that uses this + * EngineControlClient instead of via Hono. The only platform that uses this * currently is Cloudflare Workers. */ export function createActorRouter( @@ -137,8 +137,8 @@ export function createActorRouter( // Route all WebSocket paths using the same handler // // All WebSockets use a separate underlying router in routeWebSocket since - // WebSockets also need to be routed from ManagerDriver.proxyWebSocket and - // ManagerDriver.openWebSocket. + // WebSockets also need to be routed from EngineControlClient.proxyWebSocket and + // EngineControlClient.openWebSocket. if (getUpgradeWebSocket) { router.on( "GET", @@ -201,7 +201,7 @@ export function createActorRouter( return undefined; } - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); if ( actor.inspectorToken && timingSafeEqual(userToken, actor.inspectorToken) @@ -310,7 +310,7 @@ export function createActorRouter( const authResponse = await inspectorAuth(c); if (authResponse) return authResponse; - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); const body = await c.req.json<{ entryId?: string }>(); const result = await actor.inspector.replayWorkflowFromStepJson( body.entryId, @@ -322,7 +322,7 @@ export function createActorRouter( const authResponse = await inspectorAuth(c); if (authResponse) return authResponse; - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); const schema = await actor.inspector.getDatabaseSchemaJson(); return c.json({ schema }); }); @@ -331,7 +331,7 @@ export function createActorRouter( const authResponse = await inspectorAuth(c); if (authResponse) return authResponse; - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); const table = c.req.query("table"); if (!table) { return c.json( @@ -354,7 +354,7 @@ export function createActorRouter( const authResponse = await inspectorAuth(c); if (authResponse) return authResponse; - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); const body = await c.req.json<{ sql?: unknown; args?: unknown; @@ -446,7 +446,7 @@ export function createActorRouter( const authResponse = await inspectorAuth(c); if (authResponse) return authResponse; - const actor = await actorDriver.loadActor(c.env.actorId); + const actor = await loadStaticActor(actorDriver, c.env.actorId); return c.json(actor.metrics.snapshot()); }); } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts index b4146bbf83..66ae5ad21f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts @@ -8,7 +8,7 @@ import { type Encoding, jsonStringifyCompat } from "@/actor/protocol/serde"; import { PATH_CONNECT } from "@/common/actor-router-consts"; import { assertUnreachable, stringifyError } from "@/common/utils"; import type { UniversalWebSocket } from "@/common/websocket-interface"; -import type { ManagerDriver } from "@/driver-helpers/mod"; +import type { EngineControlClient } from "@/driver-helpers/mod"; import type * as protocol from "@/schemas/client-protocol/mod"; import { CURRENT_VERSION as CLIENT_PROTOCOL_CURRENT_VERSION, @@ -165,7 +165,7 @@ export class ActorConnRaw { #websocket?: UniversalWebSocket; #client: ClientRaw; - #driver: ManagerDriver; + #driver: EngineControlClient; #params: unknown; #getParams?: () => Promise; #encoding: Encoding; @@ -182,7 +182,7 @@ export class ActorConnRaw { */ public constructor( client: ClientRaw, - driver: ManagerDriver, + driver: EngineControlClient, params: unknown, getParams: (() => Promise) | undefined, encoding: Encoding, diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts index af22117bbe..1e1586678d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts @@ -6,7 +6,7 @@ import { HEADER_CONN_PARAMS, HEADER_ENCODING, resolveGatewayTarget, - type ManagerDriver, + type EngineControlClient, } from "@/driver-helpers/mod"; import type * as protocol from "@/schemas/client-protocol/mod"; import { @@ -52,7 +52,7 @@ import { sendHttpRequest } from "./utils"; */ export class ActorHandleRaw { #client: ClientRaw; - #driver: ManagerDriver; + #driver: EngineControlClient; #encoding: Encoding; #actorResolutionState: ActorResolutionState; #params: unknown; @@ -68,7 +68,7 @@ export class ActorHandleRaw { */ public constructor( client: any, - driver: ManagerDriver, + driver: EngineControlClient, params: unknown, getParams: (() => Promise) | undefined, encoding: Encoding, diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts index f079edbc23..8fde2ecf30 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts @@ -1,7 +1,7 @@ import * as errors from "@/actor/errors"; import { stringifyError } from "@/common/utils"; -import { type GatewayTarget, type ManagerDriver } from "@/driver-helpers/mod"; -import type { ActorQuery } from "@/manager/protocol/query"; +import { type GatewayTarget, type EngineControlClient } from "@/driver-helpers/mod"; +import type { ActorQuery } from "@/client/query"; import { ActorSchedulingError } from "./errors"; import { logger } from "./log"; @@ -58,7 +58,7 @@ export async function checkForSchedulingError( code: string, actorId: string, query: ActorQuery, - driver: ManagerDriver, + driver: EngineControlClient, ): Promise { const name = getActorNameFromQuery(query); diff --git a/rivetkit-typescript/packages/rivetkit/src/client/client.ts b/rivetkit-typescript/packages/rivetkit/src/client/client.ts index f2f2823785..2ec7addf7d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/client.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/client.ts @@ -2,9 +2,9 @@ import type { AnyActorDefinition } from "@/actor/definition"; import type { Encoding } from "@/actor/protocol/serde"; import { resolveGatewayTarget, - type ManagerDriver, + type EngineControlClient, } from "@/driver-helpers/mod"; -import type { ActorQuery } from "@/manager/protocol/query"; +import type { ActorQuery } from "@/client/query"; import type { Registry } from "@/registry"; import type { ActorActionFunction } from "./actor-common"; import { @@ -178,13 +178,13 @@ export class ClientRaw { [ACTOR_CONNS_SYMBOL] = new Set(); - #driver: ManagerDriver; + #driver: EngineControlClient; #encodingKind: Encoding; /** * Creates an instance of Client. */ - public constructor(driver: ManagerDriver, encoding: Encoding | undefined) { + public constructor(driver: EngineControlClient, encoding: Encoding | undefined) { this.#driver = driver; this.#encodingKind = encoding ?? "bare"; @@ -436,7 +436,7 @@ export type Client> = ClientRaw & { export type AnyClient = Client>; export function createClientWithDriver>( - driver: ManagerDriver, + driver: EngineControlClient, config: { encoding?: Encoding } = {}, ): Client { const client = new ClientRaw(driver, config.encoding); diff --git a/rivetkit-typescript/packages/rivetkit/src/client/config.ts b/rivetkit-typescript/packages/rivetkit/src/client/config.ts index 0be674193e..68edebc3e5 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/config.ts @@ -21,7 +21,7 @@ let hasWarnedMissingEndpoint = false; */ export const ClientConfigSchemaBase = z.object({ /** - * Endpoint to connect to for Rivet Engine or RivetKit manager API. + * Endpoint to connect to for Rivet Engine or the local RivetKit runtime API. * * Supports URL auth syntax for namespace and token: * - `https://namespace:token@api.rivet.dev` diff --git a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts index f001436911..77cd76648a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts @@ -1,6 +1,6 @@ import { injectDevtools } from "@/devtools-loader"; import type { Registry } from "@/registry"; -import { RemoteManagerDriver } from "@/remote-manager-driver/mod"; +import { RemoteEngineControlClient } from "@/engine-client/mod"; import { type Client, type ClientConfigInput, @@ -18,7 +18,7 @@ export { MalformedResponseMessage, ManagerError, } from "@/client/errors"; -export type { CreateRequest } from "@/manager/protocol/query"; +export type { CreateRequest } from "@/client/query"; export { KEYS as KV_KEYS } from "../actor/instance/keys"; export type { ActorActionFunction } from "./actor-common"; export type { @@ -61,7 +61,7 @@ export function createClient>( const config = ClientConfigSchema.parse(configInput); // Create client - const driver = new RemoteManagerDriver(config); + const driver = new RemoteEngineControlClient(config); if (config.devtools) { injectDevtools(config); diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/protocol/query.ts b/rivetkit-typescript/packages/rivetkit/src/client/query.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/manager/protocol/query.ts rename to rivetkit-typescript/packages/rivetkit/src/client/query.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts b/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts index d503bc43fe..3d2bbd0d25 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts @@ -3,7 +3,7 @@ import { deconstructError } from "@/common/utils"; import { type GatewayTarget, HEADER_CONN_PARAMS, - type ManagerDriver, + type EngineControlClient, } from "@/driver-helpers/mod"; import { ActorError } from "./errors"; import { logger } from "./log"; @@ -12,7 +12,7 @@ import { logger } from "./log"; * Shared implementation for raw HTTP fetch requests */ export async function rawHttpFetch( - driver: ManagerDriver, + driver: EngineControlClient, target: GatewayTarget, params: unknown, input: string | URL | Request, @@ -105,7 +105,7 @@ export async function rawHttpFetch( * Shared implementation for raw WebSocket connections */ export async function rawWebSocket( - driver: ManagerDriver, + driver: EngineControlClient, target: GatewayTarget, params: unknown, path?: string, diff --git a/rivetkit-typescript/packages/rivetkit/src/common/inline-websocket-adapter.ts b/rivetkit-typescript/packages/rivetkit/src/common/inline-websocket-adapter.ts index 7ad2d51adb..e695746035 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/inline-websocket-adapter.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/inline-websocket-adapter.ts @@ -114,7 +114,13 @@ export class InlineWebSocketAdapter { { data, rivetMessageIndex }, this.#wsContext, ); - (this.#actorWs as any).triggerMessage(data, rivetMessageIndex); + (this.#actorWs as any).dispatchEvent({ + type: "message", + data, + rivetMessageIndex, + target: this.#actorWs, + currentTarget: this.#actorWs, + }); } catch (err) { this.#handleError(err); this.#close(1011, "Internal error processing message"); diff --git a/rivetkit-typescript/packages/rivetkit/src/db/native-adapter.ts b/rivetkit-typescript/packages/rivetkit/src/db/native-adapter.ts index fee30e710f..dafb28e2c6 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/native-adapter.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/native-adapter.ts @@ -5,6 +5,7 @@ import { disconnectKvChannelIfCurrent, getOrCreateKvChannel, toNativeBindings, + type NativeBindParam, type NativeKvChannel, type NativeDatabase, } from "./native-sqlite"; @@ -51,7 +52,10 @@ function resolveNamedSqliteBinding( return undefined; } -function normalizeNativeBindings(sql: string, params?: unknown): unknown[] { +function normalizeNativeBindings( + sql: string, + params?: unknown, +): NativeBindParam[] { if (params === undefined || params === null) { return []; } diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-helpers/mod.ts b/rivetkit-typescript/packages/rivetkit/src/driver-helpers/mod.ts index a00804b45d..87d33b34d2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-helpers/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-helpers/mod.ts @@ -1,4 +1,3 @@ -export type { ActorDriver } from "@/actor/driver"; export { KEYS, makeConnKey } from "@/actor/instance/keys"; export type { BaseActorInstance, @@ -26,15 +25,15 @@ export { export type { ActorOutput, CreateInput, + EngineControlClient, GatewayTarget, GetForIdInput, GetOrCreateWithKeyInput, GetWithKeyInput, ListActorsInput, - ManagerDisplayInformation, - ManagerDriver, -} from "@/manager/driver"; -export { buildManagerRouter } from "@/manager/router"; + RuntimeDisplayInformation, +} from "@/engine-client/driver"; +export { buildRuntimeRouter } from "@/runtime-router/router"; export { resolveGatewayTarget } from "./resolve-gateway-target"; export { SqliteVfsPoolManager } from "./sqlite-pool"; export { getInitialActorKvState } from "./utils"; diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-helpers/resolve-gateway-target.ts b/rivetkit-typescript/packages/rivetkit/src/driver-helpers/resolve-gateway-target.ts index 1f8eea7632..e5b7e74ea4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-helpers/resolve-gateway-target.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-helpers/resolve-gateway-target.ts @@ -1,14 +1,14 @@ import { ActorNotFound, InvalidRequest } from "@/actor/errors"; -import type { GatewayTarget, ManagerDriver } from "@/manager/driver"; +import type { GatewayTarget, EngineControlClient } from "@/engine-client/driver"; /** * Resolves a GatewayTarget to a concrete actor ID string. * - * Shared across all ManagerDriver implementations to avoid duplicating the + * Shared across all EngineControlClient implementations to avoid duplicating the * same query-to-actorId dispatch logic. */ export async function resolveGatewayTarget( - driver: ManagerDriver, + driver: EngineControlClient, target: GatewayTarget, ): Promise { if ("directId" in target) { diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts deleted file mode 100644 index ecc36afa4a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { serve as honoServe } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import invariant from "invariant"; -import { describe } from "vitest"; -import type { Encoding } from "@/client/mod"; -import { buildManagerRouter } from "@/manager/router"; -import { createClientWithDriver, type Registry } from "@/mod"; -import { - type DriverConfig, - RegistryConfig, - RegistryConfigSchema, -} from "@/registry/config"; -import { logger } from "./log"; -import { runActionFeaturesTests } from "./tests/action-features"; -import { runAccessControlTests } from "./tests/access-control"; -import { runActorConnTests } from "./tests/actor-conn"; -import { runActorConnHibernationTests } from "./tests/actor-conn-hibernation"; -import { runActorConnStateTests } from "./tests/actor-conn-state"; -import { runActorDbTests } from "./tests/actor-db"; -import { runActorDbStressTests } from "./tests/actor-db-stress"; -import { runConnErrorSerializationTests } from "./tests/conn-error-serialization"; -import { runActorDestroyTests } from "./tests/actor-destroy"; -import { runActorDriverTests } from "./tests/actor-driver"; -import { runActorErrorHandlingTests } from "./tests/actor-error-handling"; -import { runActorHandleTests } from "./tests/actor-handle"; -import { runActorInlineClientTests } from "./tests/actor-inline-client"; -import { runActorInspectorTests } from "./tests/actor-inspector"; -import { runActorKvTests } from "./tests/actor-kv"; -import { runActorMetadataTests } from "./tests/actor-metadata"; -import { runActorOnStateChangeTests } from "./tests/actor-onstatechange"; -import { runActorQueueTests } from "./tests/actor-queue"; -import { runDynamicReloadTests } from "./tests/dynamic-reload"; -import { runActorRunTests } from "./tests/actor-run"; -import { runActorSandboxTests } from "./tests/actor-sandbox"; -import { runActorStatelessTests } from "./tests/actor-stateless"; -import { runActorVarsTests } from "./tests/actor-vars"; -import { runActorWorkflowTests } from "./tests/actor-workflow"; -import { runCrossBackendVfsTests } from "./tests/cross-backend-vfs"; -import { runManagerDriverTests } from "./tests/manager-driver"; -import { runRawHttpTests } from "./tests/raw-http"; -import { runRawHttpRequestPropertiesTests } from "./tests/raw-http-request-properties"; -import { runRawWebSocketTests } from "./tests/raw-websocket"; -import { runActorDbKvStatsTests } from "./tests/actor-db-kv-stats"; -import { runActorDbPragmaMigrationTests } from "./tests/actor-db-pragma-migration"; -import { runActorStateZodCoercionTests } from "./tests/actor-state-zod-coercion"; -import { runActorAgentOsTests } from "./tests/actor-agent-os"; -import { runGatewayQueryUrlTests } from "./tests/gateway-query-url"; -import { runHibernatableWebSocketProtocolTests } from "./tests/hibernatable-websocket-protocol"; -import { runRequestAccessTests } from "./tests/request-access"; - -export interface SkipTests { - schedule?: boolean; - sleep?: boolean; - hibernation?: boolean; - inline?: boolean; - sandbox?: boolean; - agentOs?: boolean; -} - -export interface DriverTestFeatures { - hibernatableWebSocketProtocol?: boolean; -} - -export interface DriverTestConfig { - /** Deploys an registry and returns the connection endpoint. */ - start(): Promise; - - /** - * If we're testing with an external system, we should use real timers - * instead of Vitest's mocked timers. - **/ - useRealTimers?: boolean; - - /** Cloudflare Workers has some bugs with cleanup. */ - HACK_skipCleanupNet?: boolean; - - skip?: SkipTests; - - features?: DriverTestFeatures; - - encoding?: Encoding; - - isDynamic?: boolean; - - clientType: ClientType; - - cleanup?: () => Promise; -} - -/** - * The type of client to run the test with. - * - * The logic for HTTP vs inline is very different, so this helps validate all behavior matches. - **/ -type ClientType = "http" | "inline"; - -export interface DriverDeployOutput { - endpoint: string; - namespace: string; - runnerName: string; - hardCrashActor?: (actorId: string) => Promise; - hardCrashPreservesData?: boolean; - - /** Cleans up the test. */ - cleanup(): Promise; -} - -/** Runs all Vitest tests against the provided drivers. */ -export function runDriverTests( - driverTestConfigPartial: Omit, -) { - describe("Driver Tests", () => { - const clientTypes: ClientType[] = driverTestConfigPartial.skip?.inline - ? ["http"] - : ["http", "inline"]; - for (const clientType of clientTypes) { - describe(`client type (${clientType})`, () => { - const encodings: Encoding[] = ["bare", "cbor", "json"]; - - for (const encoding of encodings) { - describe(`encoding (${encoding})`, () => { - const driverTestConfig: DriverTestConfig = { - ...driverTestConfigPartial, - clientType, - encoding, - }; - - runActorDriverTests(driverTestConfig); - runManagerDriverTests(driverTestConfig); - - runActorConnTests(driverTestConfig); - - runActorConnStateTests(driverTestConfig); - - runActorConnHibernationTests(driverTestConfig); - - runConnErrorSerializationTests(driverTestConfig); - - runActorDbTests(driverTestConfig); - - runActorDestroyTests(driverTestConfig); - - runRequestAccessTests(driverTestConfig); - - runActorHandleTests(driverTestConfig); - - runActionFeaturesTests(driverTestConfig); - - runAccessControlTests(driverTestConfig); - - runActorVarsTests(driverTestConfig); - - runActorMetadataTests(driverTestConfig); - - runActorOnStateChangeTests(driverTestConfig); - - runActorErrorHandlingTests(driverTestConfig); - - runActorQueueTests(driverTestConfig); - - runActorRunTests(driverTestConfig); - - runActorSandboxTests(driverTestConfig); - - runDynamicReloadTests(driverTestConfig); - - runActorInlineClientTests(driverTestConfig); - - runActorKvTests(driverTestConfig); - - runActorWorkflowTests(driverTestConfig); - - runActorStatelessTests(driverTestConfig); - - runRawHttpTests(driverTestConfig); - - runRawHttpRequestPropertiesTests(driverTestConfig); - - runRawWebSocketTests(driverTestConfig); - runHibernatableWebSocketProtocolTests(driverTestConfig); - - // TODO: re-expose this once we can have actor queries on the gateway - // runRawHttpDirectRegistryTests(driverTestConfig); - - // TODO: re-expose this once we can have actor queries on the gateway - // runRawWebSocketDirectRegistryTests(driverTestConfig); - - runActorInspectorTests(driverTestConfig); - runGatewayQueryUrlTests(driverTestConfig); - - runActorDbKvStatsTests(driverTestConfig); - - runActorDbPragmaMigrationTests(driverTestConfig); - - runActorStateZodCoercionTests(driverTestConfig); - - runActorAgentOsTests(driverTestConfig); - }); - } - }); - } - - // Cross-backend VFS compatibility runs once, independent of - // client type and encoding. Skips when native SQLite is unavailable. - runCrossBackendVfsTests({ - ...driverTestConfigPartial, - clientType: "http", - encoding: "bare", - }); - - // Stress tests for DB lifecycle races, event loop blocking, and - // KV channel resilience. Run once, not per-encoding. - runActorDbStressTests({ - ...driverTestConfigPartial, - clientType: "http", - encoding: "bare", - }); - }); -} - -/** - * Helper function to adapt the drivers to the Node.js runtime for tests. - * - * This is helpful for drivers that run in-process as opposed to drivers that rely on external tools. - */ -export async function createTestRuntime( - registryPath: string, - driverFactory: (registry: Registry) => Promise<{ - rivetEngine?: { - endpoint: string; - namespace: string; - runnerName: string; - token: string; - }; - driver: DriverConfig; - hardCrashActor?: (actorId: string) => Promise; - hardCrashPreservesData?: boolean; - cleanup?: () => Promise; - }>, -): Promise { - // Import using dynamic imports with vitest alias resolution - // - // Vitest is configured to resolve `import ... from "rivetkit"` to the - // appropriate source files - // - // We need to preserve the `import ... from "rivetkit"` in the fixtures so - // targets that run the server separately from the Vitest tests (such as - // Cloudflare Workers) still function. - const { registry } = (await import(registryPath)) as { - registry: Registry; - }; - - // TODO: Find a cleaner way of flagging an registry as test mode (ideally not in the config itself) - // Force enable test - registry.config.test = { ...registry.config.test, enabled: true }; - registry.config.inspector = { - enabled: true, - token: () => "token", - }; - - // Build drivers - const { - driver, - cleanup: driverCleanup, - rivetEngine, - hardCrashActor, - hardCrashPreservesData, - } = await driverFactory(registry); - - if (rivetEngine) { - // TODO: We don't need createTestRuntime fort his - // Using external Rivet engine - - const cleanup = async () => { - await driverCleanup?.(); - }; - - return { - endpoint: rivetEngine.endpoint, - namespace: rivetEngine.namespace, - runnerName: rivetEngine.runnerName, - hardCrashActor, - hardCrashPreservesData, - cleanup, - }; - } else { - // Start server for Rivet engine - - // Build driver config - // biome-ignore lint/style/useConst: Assigned later - let upgradeWebSocket: any; - - // Create router - const parsedConfig = registry.parseConfig(); - const managerDriver = driver.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - // const client = createClientWithDriver( - // managerDriver, - // ClientConfigSchema.parse({}), - // ); - const { router } = buildManagerRouter( - parsedConfig, - managerDriver, - () => upgradeWebSocket, - ); - - // Inject WebSocket - const nodeWebSocket = createNodeWebSocket({ app: router }); - upgradeWebSocket = nodeWebSocket.upgradeWebSocket; - managerDriver.setGetUpgradeWebSocket(() => upgradeWebSocket); - - // TODO: I think this whole function is fucked, we should probably switch to calling registry.serve() directly - // Start server - const server = honoServe({ - fetch: router.fetch, - hostname: "127.0.0.1", - port: 0, - }); - if (!server.listening) { - await new Promise((resolve) => { - server.once("listening", () => resolve()); - }); - } - invariant( - nodeWebSocket.injectWebSocket !== undefined, - "should have injectWebSocket", - ); - nodeWebSocket.injectWebSocket(server); - const address = server.address(); - invariant( - address && typeof address !== "string", - "missing server address", - ); - const port = address.port; - const serverEndpoint = `http://127.0.0.1:${port}`; - managerDriver.setNativeSqliteConfig?.({ - endpoint: serverEndpoint, - namespace: "default", - }); - - logger().info({ msg: "test serer listening", port }); - - // Cleanup - const cleanup = async () => { - // Disconnect only the current test runtime's native KV channel so - // concurrent local runtimes do not shut down each other's channel. - try { - const { disconnectKvChannelForCurrentConfig } = await import( - "@/db/native-sqlite" - ); - await disconnectKvChannelForCurrentConfig({ - endpoint: serverEndpoint, - namespace: "default", - }); - } catch { - // Native module may not be available. - } - - // Stop server - await new Promise((resolve) => - server.close(() => resolve(undefined)), - ); - - // Extra cleanup - await driverCleanup?.(); - }; - - return { - endpoint: serverEndpoint, - namespace: "default", - runnerName: "default", - hardCrashActor: managerDriver.hardCrashActor?.bind(managerDriver), - hardCrashPreservesData: driver.name !== "memory", - cleanup, - }; - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts deleted file mode 100644 index 14bc92d800..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts +++ /dev/null @@ -1,334 +0,0 @@ -import * as cbor from "cbor-x"; -import type { Context as HonoContext } from "hono"; -import invariant from "invariant"; -import type { Encoding } from "@/actor/protocol/serde"; -import { assertUnreachable } from "@/actor/utils"; -import { ActorError as ClientActorError } from "@/client/errors"; -import { - WS_PROTOCOL_ACTOR, - WS_PROTOCOL_CONN_PARAMS, - WS_PROTOCOL_ENCODING, - WS_PROTOCOL_STANDARD, - WS_PROTOCOL_TARGET, - WS_TEST_PROTOCOL_PATH, -} from "@/common/actor-router-consts"; -import { type DeconstructedError, noopNext } from "@/common/utils"; -import { importWebSocket } from "@/common/websocket"; -import { - type ActorOutput, - type CreateInput, - type GatewayTarget, - type GetForIdInput, - type GetOrCreateWithKeyInput, - type GetWithKeyInput, - HEADER_ACTOR_ID, - type ListActorsInput, - type ManagerDisplayInformation, - type ManagerDriver, - resolveGatewayTarget, -} from "@/driver-helpers/mod"; -import type { UniversalWebSocket } from "@/mod"; -import type { GetUpgradeWebSocket } from "@/utils"; -import { logger } from "./log"; - -export interface TestInlineDriverCallRequest { - encoding: Encoding; - method: string; - args: unknown[]; -} - -export type TestInlineDriverCallResponse = - | { - ok: T; - } - | { - err: DeconstructedError; - }; - -/** - * Creates a client driver used for testing the inline client driver. This will send a request to the HTTP server which will then internally call the internal client and return the response. - */ -export function createTestInlineClientDriver( - endpoint: string, - encoding: Encoding, -): ManagerDriver { - let getUpgradeWebSocket: GetUpgradeWebSocket; - const driver: ManagerDriver = { - getForId(input: GetForIdInput): Promise { - return makeInlineRequest(endpoint, encoding, "getForId", [input]); - }, - getWithKey(input: GetWithKeyInput): Promise { - return makeInlineRequest(endpoint, encoding, "getWithKey", [input]); - }, - getOrCreateWithKey( - input: GetOrCreateWithKeyInput, - ): Promise { - return makeInlineRequest(endpoint, encoding, "getOrCreateWithKey", [ - input, - ]); - }, - createActor(input: CreateInput): Promise { - return makeInlineRequest(endpoint, encoding, "createActor", [ - input, - ]); - }, - listActors(input: ListActorsInput): Promise { - return makeInlineRequest(endpoint, encoding, "listActors", [input]); - }, - async sendRequest( - target: GatewayTarget, - actorRequest: Request, - ): Promise { - const actorId = await resolveGatewayTarget(driver, target); - - // Normalize path to match other drivers - const oldUrl = new URL(actorRequest.url); - const normalizedPath = oldUrl.pathname.startsWith("/") - ? oldUrl.pathname.slice(1) - : oldUrl.pathname; - const pathWithQuery = normalizedPath + oldUrl.search; - - logger().debug({ - msg: "sending raw http request via test inline driver", - actorId, - encoding, - path: pathWithQuery, - }); - - // Use the dedicated raw HTTP endpoint - const url = `${endpoint}/.test/inline-driver/send-request/${pathWithQuery}`; - - logger().debug({ - msg: "rewriting http url", - from: oldUrl, - to: url, - }); - - // Merge headers with our metadata - const headers = new Headers(actorRequest.headers); - headers.set(HEADER_ACTOR_ID, actorId); - - // Forward the request directly - const response = await fetch( - new Request(url, { - method: actorRequest.method, - headers, - body: actorRequest.body, - signal: actorRequest.signal, - duplex: "half", - } as RequestInit), - ); - - // Check if it's an error response from our handler - if ( - !response.ok && - response.headers - .get("content-type") - ?.includes("application/json") - ) { - try { - // Clone the response to avoid consuming the body - const clonedResponse = response.clone(); - const errorData = (await clonedResponse.json()) as any; - if (errorData.error) { - // Handle both error formats: - // 1. { error: { code, message, metadata } } - structured format - // 2. { error: "message" } - simple string format (from custom onRequest handlers) - if (typeof errorData.error === "object") { - throw new ClientActorError( - errorData.error.code, - errorData.error.message, - errorData.error.metadata, - ); - } - // For simple string errors, just return the response as-is - // This allows custom onRequest handlers to return their own error formats - } - } catch (e) { - // If it's not our error format, just return the response as-is - if (!(e instanceof ClientActorError)) { - return response; - } - throw e; - } - } - - return response; - }, - async openWebSocket( - path: string, - target: GatewayTarget, - encoding: Encoding, - params: unknown, - ): Promise { - const actorId = await resolveGatewayTarget(driver, target); - const WebSocket = await importWebSocket(); - - // Normalize path to match other drivers - const normalizedPath = path.startsWith("/") ? path.slice(1) : path; - - // Create WebSocket connection to the test endpoint - const wsUrl = new URL( - `${endpoint}/.test/inline-driver/connect-websocket/ws`, - ); - - logger().debug({ - msg: "creating websocket connection via test inline driver", - url: wsUrl.toString(), - }); - - // Convert http/https to ws/wss - const wsProtocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; - const finalWsUrl = `${wsProtocol}//${wsUrl.host}${wsUrl.pathname}`; - - // Build protocols for the connection - const protocols: string[] = []; - protocols.push(WS_PROTOCOL_STANDARD); - protocols.push(`${WS_PROTOCOL_TARGET}actor`); - protocols.push( - `${WS_PROTOCOL_ACTOR}${encodeURIComponent(actorId)}`, - ); - protocols.push(`${WS_PROTOCOL_ENCODING}${encoding}`); - protocols.push( - `${WS_TEST_PROTOCOL_PATH}${encodeURIComponent(normalizedPath)}`, - ); - if (params !== undefined) { - protocols.push( - `${WS_PROTOCOL_CONN_PARAMS}${encodeURIComponent(JSON.stringify(params))}`, - ); - } - - logger().debug({ - msg: "connecting to websocket", - url: finalWsUrl, - protocols, - }); - - // Create and return the WebSocket - // Node & browser WebSocket types are incompatible - const ws = new WebSocket(finalWsUrl, protocols) as any; - - return ws; - }, - async proxyRequest( - _c: HonoContext, - actorRequest: Request, - actorId: string, - ): Promise { - return await this.sendRequest({ directId: actorId }, actorRequest); - }, - proxyWebSocket( - c: HonoContext, - path: string, - actorId: string, - encoding: Encoding, - params: unknown, - ): Promise { - const upgradeWebSocket = getUpgradeWebSocket?.(); - invariant(upgradeWebSocket, "missing getUpgradeWebSocket"); - - const wsHandler = this.openWebSocket( - path, - { directId: actorId }, - encoding, - params, - ); - return upgradeWebSocket(() => wsHandler)(c, noopNext()); - }, - async buildGatewayUrl(target: GatewayTarget): Promise { - const resolvedActorId = await resolveGatewayTarget(driver, target); - return `${endpoint}/gateway/${resolvedActorId}`; - }, - displayInformation(): ManagerDisplayInformation { - return { properties: {} }; - }, - setGetUpgradeWebSocket: (getUpgradeWebSocketInner) => { - getUpgradeWebSocket = getUpgradeWebSocketInner; - }, - kvGet: (_actorId: string, _key: Uint8Array) => { - throw new Error("kvGet not implemented on inline client driver"); - }, - kvBatchGet: (_actorId: string, _keys: Uint8Array[]) => { - throw new Error( - "kvBatchGet not implemented on inline client driver", - ); - }, - kvBatchPut: ( - _actorId: string, - _entries: [Uint8Array, Uint8Array][], - ) => { - throw new Error( - "kvBatchPut not implemented on inline client driver", - ); - }, - kvBatchDelete: (_actorId: string, _keys: Uint8Array[]) => { - throw new Error( - "kvBatchDelete not implemented on inline client driver", - ); - }, - kvDeleteRange: ( - _actorId: string, - _start: Uint8Array, - _end: Uint8Array, - ) => { - throw new Error( - "kvDeleteRange not implemented on inline client driver", - ); - }, - } satisfies ManagerDriver; - return driver; -} - -async function makeInlineRequest( - endpoint: string, - encoding: Encoding, - method: string, - args: unknown[], -): Promise { - logger().debug({ - msg: "sending inline request", - encoding, - method, - args, - }); - - // Call driver - const response = await fetch(`${endpoint}/.test/inline-driver/call`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: cbor.encode({ - encoding, - method, - args, - } satisfies TestInlineDriverCallRequest), - duplex: "half", - } as RequestInit); - - if (!response.ok) { - throw new Error( - `Failed to call inline ${method}: ${response.statusText}`, - ); - } - - // Parse response - const buffer = await response.arrayBuffer(); - const callResponse: TestInlineDriverCallResponse = cbor.decode( - new Uint8Array(buffer), - ); - - // Throw or OK - if ("ok" in callResponse) { - return callResponse.ok; - } else if ("err" in callResponse) { - throw new ClientActorError( - callResponse.err.group, - callResponse.err.code, - callResponse.err.message, - callResponse.err.metadata, - ); - } else { - assertUnreachable(callResponse); - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts deleted file mode 100644 index 779df9598c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runAccessControlTests(driverTestConfig: DriverTestConfig) { - describe("access control", () => { - test("actions run without entrypoint auth gating", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlActor.getOrCreate(["actions"]); - - const allowed = await handle.allowedAction("ok"); - expect(allowed).toBe("allowed:ok"); - }); - - test("passes connection id into canPublish context", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlActor.getOrCreate([ - "publish-ctx", - ]); - - await handle.send("allowedQueue", { value: "one" }); - - const connId = await handle.allowedGetLastCanPublishConnId(); - expect(typeof connId).toBe("string"); - expect(connId.length).toBeGreaterThan(0); - }); - - test("allows and denies queue sends, and ignores undefined queues", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlActor.getOrCreate(["queue"]); - - await handle.send("allowedQueue", { value: "one" }); - await expect( - handle.send("blockedQueue", { value: "two" }), - ).rejects.toMatchObject({ - code: "forbidden", - }); - await expect( - handle.send("missingQueue", { value: "three" }), - ).resolves.toBeUndefined(); - await expect( - handle.send( - "missingQueue", - { value: "four" }, - { wait: true, timeout: 50 }, - ), - ).resolves.toMatchObject({ status: "completed" }); - - const allowedMessage = await handle.allowedReceiveQueue(); - expect(allowedMessage).toEqual({ value: "one" }); - - const remainingMessage = await handle.allowedReceiveAnyQueue(); - expect(remainingMessage).toBeNull(); - }); - - test("ignores incoming queue sends when actor has no queues config", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlNoQueuesActor.getOrCreate([ - "no-queues", - ]); - - await expect( - handle.send("anyQueue", { value: "ignored" }), - ).resolves.toBeUndefined(); - await expect( - handle.send( - "anyQueue", - { value: "ignored-wait" }, - { wait: true, timeout: 50 }, - ), - ).resolves.toMatchObject({ status: "completed" }); - expect(await handle.readAnyQueue()).toBeNull(); - }); - - test("allows and denies subscriptions with canSubscribe", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlActor.getOrCreate([ - "subscription", - ]); - const conn = handle.connect(); - - const allowedEventPromise = new Promise<{ value: string }>( - (resolve, reject) => { - const unsubscribeError = conn.onError((error) => { - reject(error); - }); - const unsubscribeEvent = conn.on( - "allowedEvent", - (payload) => { - unsubscribeError(); - unsubscribeEvent(); - resolve(payload as { value: string }); - }, - ); - }, - ); - - await conn.allowedAction("subscribe-ready"); - await conn.allowedBroadcastAllowedEvent("hello"); - expect(await allowedEventPromise).toEqual({ value: "hello" }); - - const connId = await conn.allowedGetLastCanSubscribeConnId(); - expect(typeof connId).toBe("string"); - expect(connId.length).toBeGreaterThan(0); - - await conn.dispose(); - - const blockedConn = handle.connect(); - blockedConn.on("blockedEvent", () => {}); - await expect( - blockedConn.allowedAction("blocked-subscribe-ready"), - ).rejects.toMatchObject({ - code: "forbidden", - }); - await blockedConn.dispose(); - }); - - test("broadcasts undefined events without failing subscriptions", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.accessControlActor.getOrCreate([ - "undefined-event", - ]); - const conn = handle.connect(); - - const eventPromise = new Promise<{ value: string }>( - (resolve, reject) => { - const unsubscribeError = conn.onError((error) => { - reject(error); - }); - const unsubscribeEvent = conn.on( - "undefinedEvent", - (payload) => { - unsubscribeError(); - unsubscribeEvent(); - resolve(payload as { value: string }); - }, - ); - }, - ); - - await conn.allowedAction("undefined-subscribe-ready"); - await conn.allowedBroadcastUndefinedEvent("wildcard"); - expect(await eventPromise).toEqual({ value: "wildcard" }); - - await conn.dispose(); - }); - - test("allows and denies raw request handlers", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const allowedHandle = client.accessControlActor.getOrCreate( - ["raw-request-allow"], - { - params: { allowRequest: true }, - }, - ); - const deniedHandle = client.accessControlActor.getOrCreate( - ["raw-request-deny"], - { - params: { allowRequest: false }, - }, - ); - - const allowedResponse = await allowedHandle.fetch("/status"); - expect(allowedResponse.status).toBe(200); - expect(await allowedResponse.json()).toEqual({ ok: true }); - - const deniedResponse = await deniedHandle.fetch("/status"); - expect(deniedResponse.status).toBe(403); - }); - - test("allows and denies raw websocket handlers", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const allowedHandle = client.accessControlActor.getOrCreate( - ["raw-websocket-allow"], - { - params: { allowWebSocket: true }, - }, - ); - const ws = await allowedHandle.webSocket(); - const welcome = await new Promise<{ type: string }>((resolve) => { - ws.addEventListener( - "message", - (event: any) => { - resolve( - JSON.parse(event.data as string) as { - type: string; - }, - ); - }, - { once: true }, - ); - }); - expect(welcome.type).toBe("welcome"); - ws.close(); - - const deniedHandle = client.accessControlActor.getOrCreate( - ["raw-websocket-deny"], - { - params: { allowWebSocket: false }, - }, - ); - - let denied = false; - try { - const deniedWs = await deniedHandle.webSocket(); - const closeEvent = await new Promise((resolve) => { - deniedWs.addEventListener( - "close", - (event: any) => { - resolve(event); - }, - { once: true }, - ); - }); - expect(closeEvent.code).toBe(1011); - denied = true; - } catch { - denied = true; - } - expect(denied).toBe(true); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/action-features.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/action-features.ts deleted file mode 100644 index 29bf5fcc2d..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/action-features.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ActorError } from "@/client/errors"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { - describe("Action Features", () => { - // TODO: These do not work with fake timers - describe("Action Timeouts", () => { - const usesFakeTimers = !driverTestConfig.useRealTimers; - - test("should timeout actions that exceed the configured timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // The quick action should complete successfully - const quickResult = await client.shortTimeoutActor - .getOrCreate() - .quickAction(); - expect(quickResult).toBe("quick response"); - - // The slow action should throw a timeout error - await expect( - client.shortTimeoutActor.getOrCreate().slowAction(), - ).rejects.toThrow("Action timed out"); - }); - - test("should respect the default timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // This action should complete within the default timeout - const result = await client.defaultTimeoutActor - .getOrCreate() - .normalAction(); - expect(result).toBe("normal response"); - }); - - test("non-promise action results should not be affected by timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Synchronous action should not be affected by timeout - const result = await client.syncTimeoutActor - .getOrCreate() - .syncAction(); - expect(result).toBe("sync response"); - }); - - test("should allow configuring different timeouts for different actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // The short timeout actor should fail - await expect( - client.shortTimeoutActor.getOrCreate().slowAction(), - ).rejects.toThrow("Action timed out"); - - // The longer timeout actor should succeed - const result = await client.longTimeoutActor - .getOrCreate() - .delayedAction(); - expect(result).toBe("delayed response"); - }); - }); - - describe("Action Sync & Async", () => { - test("should support synchronous actions", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.syncActionActor.getOrCreate(); - - // Test increment action - let result = await instance.increment(5); - expect(result).toBe(5); - - result = await instance.increment(3); - expect(result).toBe(8); - - // Test getInfo action - const info = await instance.getInfo(); - expect(info.currentValue).toBe(8); - expect(typeof info.timestamp).toBe("number"); - - // Test reset action (void return) - await instance.reset(); - result = await instance.increment(0); - expect(result).toBe(0); - }); - - test("should support asynchronous actions", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.asyncActionActor.getOrCreate(); - - // Test delayed increment - const result = await instance.delayedIncrement(5); - expect(result).toBe(5); - - // Test fetch data - const data = await instance.fetchData("test-123"); - expect(data.id).toBe("test-123"); - expect(typeof data.timestamp).toBe("number"); - - // Test successful async operation - const success = await instance.asyncWithError(false); - expect(success).toBe("Success"); - - // Test error in async operation - try { - await instance.asyncWithError(true); - expect.fail("did not error"); - } catch (error) { - expect((error as ActorError).message).toBe( - "Intentional error", - ); - } - }); - - test("should handle promises returned from actions correctly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.promiseActor.getOrCreate(); - - // Test resolved promise - const resolvedValue = await instance.resolvedPromise(); - expect(resolvedValue).toBe("resolved value"); - - // Test delayed promise - const delayedValue = await instance.delayedPromise(); - expect(delayedValue).toBe("delayed value"); - - // Test rejected promise - await expect(instance.rejectedPromise()).rejects.toThrow( - "promised rejection", - ); - - // Check state was updated by the delayed promise - const results = await instance.getResults(); - expect(results).toContain("delayed"); - }); - }); - - describe("Large Payloads", () => { - test("should handle large request within size limit", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.largePayloadActor.getOrCreate(); - - // Create a large payload that's under the default 64KB limit - // Each item is roughly 60 bytes, so 800 items ≈ 48KB - const items: string[] = []; - for (let i = 0; i < 800; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - - const result = await instance.processLargeRequest({ items }); - - expect(result.itemCount).toBe(800); - expect(result.firstItem).toBe( - "Item 0 with some additional text to increase size", - ); - expect(result.lastItem).toBe( - "Item 799 with some additional text to increase size", - ); - }); - - test("should reject request exceeding maxIncomingMessageSize", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.largePayloadActor.getOrCreate(); - - // Create a payload that exceeds the default 64KB limit - // Each item is roughly 60 bytes, so 1500 items ≈ 90KB - const items: string[] = []; - for (let i = 0; i < 1500; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - - await expect( - instance.processLargeRequest({ items }), - ).rejects.toThrow(); - }); - - test("should handle large response", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.largePayloadActor.getOrCreate(); - - // Request a large response (800 items ≈ 48KB) - const result = await instance.getLargeResponse(800); - - expect(result.items).toHaveLength(800); - expect(result.items[0]).toBe( - "Item 0 with some additional text to increase size", - ); - expect(result.items[799]).toBe( - "Item 799 with some additional text to increase size", - ); - }); - - test("should reject response exceeding maxOutgoingMessageSize", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.largePayloadActor.getOrCreate(); - - // Request a response that exceeds the default 1MB limit - // Each item is roughly 60 bytes, so 20000 items ≈ 1.2MB - await expect( - instance.getLargeResponse(20000), - ).rejects.toThrow(); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts deleted file mode 100644 index f77e2f8f2c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -const require = createRequire(import.meta.url); -const hasAgentOsCore = (() => { - try { - require.resolve("@rivet-dev/agent-os-core"); - return true; - } catch { - return false; - } -})(); - -export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.agentOs || !hasAgentOsCore)( - "Actor agentOS Tests", - () => { - // --- Filesystem --- - - test("writeFile and readFile round-trip", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `fs-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/hello.txt", "hello world"); - const data = await actor.readFile("/home/user/hello.txt"); - expect(new TextDecoder().decode(data)).toBe("hello world"); - }, 60_000); - - test("mkdir and readdir", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `dir-${crypto.randomUUID()}`, - ]); - - await actor.mkdir("/home/user/subdir"); - await actor.writeFile("/home/user/subdir/a.txt", "a"); - await actor.writeFile("/home/user/subdir/b.txt", "b"); - const entries = await actor.readdir("/home/user/subdir"); - const filtered = entries.filter( - (e: string) => e !== "." && e !== "..", - ); - expect(filtered.sort()).toEqual(["a.txt", "b.txt"]); - }, 60_000); - - test("stat returns file metadata", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `stat-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/stat-test.txt", "content"); - const s = await actor.stat("/home/user/stat-test.txt"); - expect(s.isDirectory).toBe(false); - expect(s.size).toBe(7); - }, 60_000); - - test("exists returns true for existing file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `exists-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/exists.txt", "x"); - expect(await actor.exists("/home/user/exists.txt")).toBe(true); - expect(await actor.exists("/home/user/nope.txt")).toBe(false); - }, 60_000); - - test("move renames a file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `move-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/old.txt", "data"); - await actor.move("/home/user/old.txt", "/home/user/new.txt"); - expect(await actor.exists("/home/user/old.txt")).toBe(false); - expect(await actor.exists("/home/user/new.txt")).toBe(true); - }, 60_000); - - test("deleteFile removes a file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `del-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/todelete.txt", "gone"); - await actor.deleteFile("/home/user/todelete.txt"); - expect(await actor.exists("/home/user/todelete.txt")).toBe(false); - }, 60_000); - - test("writeFiles and readFiles batch operations", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `batch-${crypto.randomUUID()}`, - ]); - - const writeResults = await actor.writeFiles([ - { path: "/home/user/batch-a.txt", content: "aaa" }, - { path: "/home/user/batch-b.txt", content: "bbb" }, - ]); - expect(writeResults.every((r: any) => r.success)).toBe(true); - - const readResults = await actor.readFiles([ - "/home/user/batch-a.txt", - "/home/user/batch-b.txt", - ]); - expect( - new TextDecoder().decode(readResults[0].content), - ).toBe("aaa"); - expect( - new TextDecoder().decode(readResults[1].content), - ).toBe("bbb"); - }, 60_000); - - test("readdirRecursive lists nested files", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `recursive-${crypto.randomUUID()}`, - ]); - - await actor.mkdir("/home/user/rdir"); - await actor.mkdir("/home/user/rdir/sub"); - await actor.writeFile("/home/user/rdir/top.txt", "t"); - await actor.writeFile("/home/user/rdir/sub/deep.txt", "d"); - const entries = await actor.readdirRecursive("/home/user/rdir"); - const paths = entries.map((e: any) => e.path); - expect(paths).toContain("/home/user/rdir/top.txt"); - expect(paths).toContain("/home/user/rdir/sub"); - expect(paths).toContain("/home/user/rdir/sub/deep.txt"); - }, 60_000); - - // --- Process execution --- - - test("exec runs a command and returns output", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `exec-${crypto.randomUUID()}`, - ]); - - const result = await actor.exec("echo hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("hello"); - }, 60_000); - - test("spawn and waitProcess", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `spawn-${crypto.randomUUID()}`, - ]); - - // Write a script that exits with code 42. - await actor.writeFile( - "/tmp/exit42.js", - 'process.exit(42);', - ); - - const { pid } = await actor.spawn("node", ["/tmp/exit42.js"]); - expect(typeof pid).toBe("number"); - - const exitCode = await actor.waitProcess(pid); - expect(exitCode).toBe(42); - }, 60_000); - - test("listProcesses returns spawned processes", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `list-proc-${crypto.randomUUID()}`, - ]); - - // Write a long-running script. - await actor.writeFile( - "/tmp/long.js", - 'setTimeout(() => {}, 30000);', - ); - - const { pid } = await actor.spawn("node", ["/tmp/long.js"]); - const procs = await actor.listProcesses(); - expect(procs.some((p: any) => p.pid === pid)).toBe(true); - - await actor.killProcess(pid); - }, 60_000); - - test("killProcess terminates a running process", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `kill-${crypto.randomUUID()}`, - ]); - - await actor.writeFile( - "/tmp/hang.js", - 'setTimeout(() => {}, 60000);', - ); - - const { pid } = await actor.spawn("node", ["/tmp/hang.js"]); - await actor.killProcess(pid); - const exitCode = await actor.waitProcess(pid); - // SIGKILL results in non-zero exit code. - expect(exitCode).not.toBe(0); - }, 60_000); - - // --- Network --- - - test("vmFetch proxies request to VM service", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `fetch-${crypto.randomUUID()}`, - ]); - - // Write and spawn a simple HTTP server inside the VM. - await actor.writeFile( - "/tmp/server.js", - ` -const http = require("http"); -const server = http.createServer((req, res) => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("vm-response"); -}); -server.listen(9876, "127.0.0.1", () => { - console.log("listening"); -}); -`, - ); - await actor.spawn("node", ["/tmp/server.js"]); - - // Wait for server to start. - await new Promise((r) => setTimeout(r, 2000)); - - const result = await actor.vmFetch( - 9876, - "http://127.0.0.1:9876/test", - ); - expect(result.status).toBe(200); - expect(new TextDecoder().decode(result.body)).toBe("vm-response"); - }, 60_000); - - // --- Cron --- - - test("scheduleCron and listCronJobs", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `cron-${crypto.randomUUID()}`, - ]); - - const { id } = await actor.scheduleCron({ - schedule: "* * * * *", - action: { type: "exec", command: "echo cron-tick" }, - }); - expect(typeof id).toBe("string"); - - const jobs = await actor.listCronJobs(); - expect(jobs.some((j: any) => j.id === id)).toBe(true); - - await actor.cancelCronJob(id); - const jobsAfter = await actor.listCronJobs(); - expect(jobsAfter.some((j: any) => j.id === id)).toBe(false); - }, 60_000); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-hibernation.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-hibernation.ts deleted file mode 100644 index 9a1bd358fa..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-hibernation.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { HIBERNATION_SLEEP_TIMEOUT } from "../../../fixtures/driver-test-suite/hibernation"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorConnHibernationTests( - driverTestConfig: DriverTestConfig, -) { - describe.skipIf(driverTestConfig.skip?.hibernation)( - "Connection Hibernation", - () => { - test("basic conn hibernation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with connection - const hibernatingActor = client.hibernationActor - .getOrCreate() - .connect(); - - // Initial RPC call - const ping1 = await hibernatingActor.ping(); - expect(ping1).toBe("pong"); - - // Trigger sleep - await hibernatingActor.triggerSleep(); - - // Wait for actor to sleep (give it time to hibernate) - await waitFor( - driverTestConfig, - HIBERNATION_SLEEP_TIMEOUT + 100, - ); - - // Call RPC again - this should wake the actor and work - const ping2 = await hibernatingActor.ping(); - expect(ping2).toBe("pong"); - - // Clean up - await hibernatingActor.dispose(); - }); - - test("conn state persists through hibernation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with connection - const hibernatingActor = client.hibernationActor - .getOrCreate() - .connect(); - - // Increment connection count - const count1 = await hibernatingActor.connIncrement(); - expect(count1).toBe(1); - - const count2 = await hibernatingActor.connIncrement(); - expect(count2).toBe(2); - - // Get initial lifecycle counts - const initialLifecycle = - await hibernatingActor.getConnLifecycleCounts(); - expect(initialLifecycle.connectCount).toBe(1); - expect(initialLifecycle.disconnectCount).toBe(0); - - // Get initial actor counts - const initialActorCounts = - await hibernatingActor.getActorCounts(); - expect(initialActorCounts.wakeCount).toBe(1); - expect(initialActorCounts.sleepCount).toBe(0); - - // Trigger sleep - await hibernatingActor.triggerSleep(); - - // Wait for actor to sleep - await waitFor( - driverTestConfig, - HIBERNATION_SLEEP_TIMEOUT + 100, - ); - - // Check that connection state persisted - const count3 = await hibernatingActor.getConnCount(); - expect(count3).toBe(2); - - // Verify lifecycle hooks: - // - onDisconnect and onConnect should NOT be called during sleep/wake - // - onSleep and onWake should be called - const finalLifecycle = - await hibernatingActor.getConnLifecycleCounts(); - expect(finalLifecycle.connectCount).toBe(1); // No additional connects - expect(finalLifecycle.disconnectCount).toBe(0); // No disconnects - - const finalActorCounts = - await hibernatingActor.getActorCounts(); - expect(finalActorCounts.wakeCount).toBe(2); // Woke up once more - expect(finalActorCounts.sleepCount).toBe(1); // Slept once - - // Clean up - await hibernatingActor.dispose(); - }); - - test("onOpen is not emitted again after hibernation wake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const hibernatingActor = client.hibernationActor - .getOrCreate(["onopen-once"]) - .connect(); - - let openCount = 0; - hibernatingActor.onOpen(() => { - openCount += 1; - }); - - await vi.waitFor(() => { - expect(hibernatingActor.isConnected).toBe(true); - expect(openCount).toBe(1); - }); - - for (let i = 0; i < 2; i++) { - await hibernatingActor.triggerSleep(); - await waitFor( - driverTestConfig, - HIBERNATION_SLEEP_TIMEOUT + 100, - ); - - const ping = await hibernatingActor.ping(); - expect(ping).toBe("pong"); - - const actorCounts = - await hibernatingActor.getActorCounts(); - expect(actorCounts.sleepCount).toBe(i + 1); - expect(actorCounts.wakeCount).toBe(i + 2); - expect(openCount).toBe(1); - } - - await hibernatingActor.dispose(); - }); - - test("closing connection during hibernation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with first connection - const conn1 = client.hibernationActor.getOrCreate().connect(); - - // Initial RPC call - await conn1.ping(); - - // Get connection ID - const connectionIds = await conn1.getConnectionIds(); - expect(connectionIds.length).toBe(1); - const conn1Id = connectionIds[0]; - - // Trigger sleep - await conn1.triggerSleep(); - - // Wait for actor to hibernate - await waitFor( - driverTestConfig, - HIBERNATION_SLEEP_TIMEOUT + 100, - ); - - // Disconnect first connection while actor is sleeping - await conn1.dispose(); - - // Wait a bit for disconnection to be processed - await waitFor(driverTestConfig, 250); - - // Create second connection to verify first connection disconnected - const conn2 = client.hibernationActor.getOrCreate().connect(); - - // Wait for connection to be established - await vi.waitFor( - async () => { - const newConnectionIds = await conn2.getConnectionIds(); - expect(newConnectionIds.length).toBe(1); - expect(newConnectionIds[0]).not.toBe(conn1Id); - }, - { - timeout: 5000, - interval: 100, - }, - ); - - // Verify onDisconnect was called for the first connection - const lifecycle = await conn2.getConnLifecycleCounts(); - expect(lifecycle.disconnectCount).toBe(0); // Only for conn2, not conn1 - - // Clean up - await conn2.dispose(); - }); - - test("messages sent on a hibernating connection during onSleep resolve after wake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - for (const delayMs of [0, 100, 400]) { - const connection = client.hibernationSleepWindowActor - .getOrCreate([`sleep-window-${delayMs}`]) - .connect(); - - await vi.waitFor(async () => { - expect(connection.isConnected).toBe(true); - }); - - const sleepingPromise = new Promise((resolve) => { - connection.once("sleeping", () => { - resolve(); - }); - }); - - await connection.triggerSleep(); - await sleepingPromise; - - if (delayMs > 0) { - await waitFor(driverTestConfig, delayMs); - } - - const duringSleepPromise = connection.getActorCounts(); - duringSleepPromise.catch(() => {}); - - const result = await Promise.race([ - duringSleepPromise - .then((counts) => ({ - tag: "resolved" as const, - counts, - })) - .catch((error) => ({ - tag: "rejected" as const, - error: - error instanceof Error - ? error.message - : String(error), - })), - (async () => { - await waitFor(driverTestConfig, 3000); - return { tag: "timed_out" as const }; - })(), - ]); - - expect(result.tag).toBe("resolved"); - if (result.tag === "resolved") { - expect(result.counts.sleepCount).toBe(1); - expect(result.counts.wakeCount).toBe(2); - } - - await connection.dispose(); - } - }); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-state.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-state.ts deleted file mode 100644 index ac3b804a58..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-state.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorConnStateTests(driverTestConfig: DriverTestConfig) { - describe("Actor Connection State Tests", () => { - describe("Connection State Initialization", () => { - test("should retrieve connection state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Connect to the actor - const connection = client.connStateActor - .getOrCreate() - .connect(); - - // Get the connection state - const connState = await connection.getConnectionState(); - - // Verify the connection state structure - expect(connState.id).toBeDefined(); - expect(connState.username).toBeDefined(); - expect(connState.role).toBeDefined(); - expect(connState.counter).toBeDefined(); - expect(connState.createdAt).toBeDefined(); - - // Clean up - await connection.dispose(); - }); - - test("should initialize connection state with custom parameters", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Connect with custom parameters - const connection = client.connStateActor - .getOrCreate([], { - params: { - username: "testuser", - role: "admin", - }, - }) - .connect(); - - // Get the connection state - const connState = await connection.getConnectionState(); - - // Verify the connection state was initialized with custom values - expect(connState.username).toBe("testuser"); - expect(connState.role).toBe("admin"); - - // Clean up - await connection.dispose(); - }); - }); - - describe("Connection State Management", () => { - test("should maintain unique state for each connection", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create multiple connections - const conn1 = client.connStateActor - .getOrCreate([], { - params: { username: "user1" }, - }) - .connect(); - - const conn2 = client.connStateActor - .getOrCreate([], { - params: { username: "user2" }, - }) - .connect(); - - // Update connection state for each connection - await conn1.incrementConnCounter(5); - await conn2.incrementConnCounter(10); - - // Get state for each connection - const state1 = await conn1.getConnectionState(); - const state2 = await conn2.getConnectionState(); - - // Verify states are separate - expect(state1.counter).toBe(5); - expect(state2.counter).toBe(10); - expect(state1.username).toBe("user1"); - expect(state2.username).toBe("user2"); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - - test("should track connections in shared state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two connections - const handle = client.connStateActor.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // HACK: Wait for both connections to successfully connect by waiting for a round trip RPC - await conn1.getConnectionState(); - await conn2.getConnectionState(); - - // Get state1 for reference - const state1 = await conn1.getConnectionState(); - - // Get connection IDs tracked by the actor - const connectionIds = await conn1.getConnectionIds(); - - // There should be at least 2 connections tracked - expect(connectionIds.length).toBeGreaterThanOrEqual(2); - - // Should include the ID of the first connection - expect(connectionIds).toContain(state1.id); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - - test("should identify different connections in the same actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two connections to the same actor - const handle = client.connStateActor.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // HACK: Wait for both connections to successfully connect by waiting for a round trip RPC - await conn1.getConnectionState(); - await conn2.getConnectionState(); - - // Get all connection states - const allStates = await conn1.getAllConnectionStates(); - - // Should have at least 2 states - expect(allStates.length).toBeGreaterThanOrEqual(2); - - // IDs should be unique - const ids = allStates.map((state: { id: string }) => state.id); - const uniqueIds = [...new Set(ids)]; - expect(uniqueIds.length).toBe(ids.length); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - }); - - describe("Connection Lifecycle", () => { - test("should track connection and disconnection events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const debugHandle = client.connStateActor.getOrCreate( - undefined, - { - params: { noCount: true }, - }, - ); - - // Create a connection - const conn = client.connStateActor.getOrCreate().connect(); - - // Get the connection state - const connState = await conn.getConnectionState(); - - // Verify the connection is tracked - await vi.waitFor(async () => { - const connectionIds = await debugHandle.getConnectionIds(); - expect(connectionIds).toContain(connState.id); - }); - - // Initial disconnection count - await vi.waitFor(async () => { - const disconnects = - await debugHandle.getDisconnectionCount(); - expect(disconnects).toBe(0); - }); - - // Dispose the connection - await conn.dispose(); - - // Validate conn count - await vi.waitFor( - async () => { - console.log("disconnects before"); - const disconnects = - await debugHandle.getDisconnectionCount(); - console.log("disconnects", disconnects); - expect(disconnects).toBe(1); - }, - // SSE takes a long time to disconnect on CF Workers - { - timeout: 10_000, - interval: 100, - }, - ); - - // Create a new connection to check the disconnection count - const newConn = client.connStateActor.getOrCreate().connect(); - - // Verify the connection is tracked - await vi.waitFor(async () => { - const connectionIds = await debugHandle.getConnectionIds(); - console.log("conn ids", connectionIds); - expect(connectionIds.length).toBe(1); - }); - - // Clean up - await newConn.dispose(); - - // Verify disconnection was tracked - await vi.waitFor( - async () => { - console.log("A"); - const disconnects = - await debugHandle.getDisconnectionCount(); - console.log(`B ${disconnects}`); - expect(disconnects).toBe(2); - }, - // SSE takes a long time to disconnect on CF Workers - { - timeout: 10_000, - interval: 100, - }, - ); - }); - - test("should update connection state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a connection - const conn = client.connStateActor.getOrCreate().connect(); - - // Get the initial state - const initialState = await conn.getConnectionState(); - expect(initialState.username).toBe("anonymous"); - - // Update the connection state - const updatedState = await conn.updateConnection({ - username: "newname", - role: "moderator", - }); - - // Verify the state was updated - expect(updatedState.username).toBe("newname"); - expect(updatedState.role).toBe("moderator"); - - // Get the state again to verify persistence - const latestState = await conn.getConnectionState(); - expect(latestState.username).toBe("newname"); - expect(latestState.role).toBe("moderator"); - - // Clean up - await conn.dispose(); - }); - }); - - describe("Connection Communication", () => { - test("should send messages to specific connections", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two connections - const handle = client.connStateActor.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // Get connection states - const state1 = await conn1.getConnectionState(); - const state2 = await conn2.getConnectionState(); - - // Set up event listener on second connection - const receivedMessages: any[] = []; - conn2.on("directMessage", (data) => { - receivedMessages.push(data); - }); - - // TODO: SSE has race condition between subscrib eand publish message - await vi.waitFor(async () => { - // Send message from first connection to second - const success = await conn1.sendToConnection( - state2.id, - "Hello from conn1", - ); - expect(success).toBe(true); - - // Verify message was received - expect(receivedMessages.length).toBe(1); - expect(receivedMessages[0].from).toBe(state1.id); - expect(receivedMessages[0].message).toBe( - "Hello from conn1", - ); - }); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn.ts deleted file mode 100644 index 69474ea7e2..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn.ts +++ /dev/null @@ -1,682 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { FAKE_TIME, setupDriverTest, waitFor } from "../utils"; - -export function runActorConnTests(driverTestConfig: DriverTestConfig) { - describe("Actor Connection Tests", () => { - describe("Connection Methods", () => { - test("should connect using .get().connect()", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - await client.counter.create(["test-get"]); - - // Get a handle and connect - const handle = client.counter.get(["test-get"]); - const connection = handle.connect(); - - // Verify connection by performing an action - const count = await connection.increment(5); - expect(count).toBe(5); - - // Clean up - await connection.dispose(); - }); - - test("should connect using .getForId().connect()", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a actor first to get its ID - const handle = client.counter.getOrCreate(["test-get-for-id"]); - await handle.increment(3); - const actorId = await handle.resolve(); - - // Get a new handle using the actor ID and connect - const idHandle = client.counter.getForId(actorId); - const connection = idHandle.connect(); - - // Verify connection works and state is preserved - const count = await connection.getCount(); - expect(count).toBe(3); - - // Clean up - await connection.dispose(); - }); - - test("should connect using .getOrCreate().connect()", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Get or create actor and connect - const handle = client.counter.getOrCreate([ - "test-get-or-create", - ]); - const connection = handle.connect(); - - // Verify connection works - const count = await connection.increment(7); - expect(count).toBe(7); - - // Clean up - await connection.dispose(); - }); - - test("should connect using (await create()).connect()", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and connect - const handle = await client.counter.create(["test-create"]); - const connection = handle.connect(); - - // Verify connection works - const count = await connection.increment(9); - expect(count).toBe(9); - - // Clean up - await connection.dispose(); - }); - }); - - describe("Event Communication", () => { - test("should mix RPC calls and WebSocket events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const handle = client.counter.getOrCreate([ - "test-mixed-rpc-ws", - ]); - const connection = handle.connect(); - - // Set up event listener - const receivedEvents: number[] = []; - connection.on("newCount", (count: number) => { - receivedEvents.push(count); - }); - - // TODO: There is a race condition with opening subscription and sending events on SSE, so we need to wait for a successful round trip on the event - await vi.waitFor(async () => { - // Send one RPC call over the connection to ensure it's open - await connection.setCount(1); - expect(receivedEvents).includes(1); - }); - - // Now use stateless RPC calls through the handle (no connection) - // These should still trigger events that the connection receives - await handle.setCount(2); - await handle.setCount(3); - - // Wait for all events to be received - await vi.waitFor(() => { - expect(receivedEvents).includes(2); - expect(receivedEvents).includes(3); - }); - - // Clean up - await connection.dispose(); - }); - - test("should receive events via broadcast", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and connect - const handle = client.counter.getOrCreate(["test-broadcast"]); - const connection = handle.connect(); - - // Set up event listener - const receivedEvents: number[] = []; - connection.on("newCount", (count: number) => { - receivedEvents.push(count); - }); - - // HACK: Race condition between subscribing & sending events in SSE - // Verify events were received - await vi.waitFor( - async () => { - await connection.setCount(5); - await connection.setCount(8); - expect(receivedEvents).toContain(5); - expect(receivedEvents).toContain(8); - }, - { timeout: 10_000 }, - ); - - // Clean up - await connection.dispose(); - }); - - test("should handle one-time events with once()", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and connect - const handle = client.counter.getOrCreate(["test-once"]); - const connection = handle.connect(); - - // Set up one-time event listener - const receivedEvents: number[] = []; - connection.once("newCount", (count: number) => { - receivedEvents.push(count); - }); - - // Trigger multiple events, but should only receive the first one - await connection.increment(5); - await connection.increment(3); - - // Verify only the first event was received - await vi.waitFor(() => { - expect(receivedEvents).toEqual([5]); - expect(receivedEvents).not.toContain(8); - }); - - // Clean up - await connection.dispose(); - }); - - test("should unsubscribe from events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and connect - const handle = client.counter.getOrCreate(["test-unsubscribe"]); - const connection = handle.connect(); - - // Set up event listener with unsubscribe - const receivedEvents: number[] = []; - const unsubscribe = connection.on( - "newCount", - (count: number) => { - receivedEvents.push(count); - }, - ); - - // TODO: SSE has race condition with subscriptions & publishing messages - // Trigger first event - await vi.waitFor(async () => { - await connection.setCount(5); - expect(receivedEvents).toEqual([5]); - }); - - // Unsubscribe - unsubscribe(); - - // Trigger second event, should not be received - await connection.setCount(8); - - // Verify only the first event was received - expect(receivedEvents).not.toContain(8); - - // Clean up - await connection.dispose(); - }); - }); - - describe("Connection Parameters", () => { - test("should pass connection parameters", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two connections with different params - const handle1 = client.counterWithParams.getOrCreate( - ["test-params"], - { - params: { name: "user1" }, - }, - ); - const handle2 = client.counterWithParams.getOrCreate( - ["test-params"], - { - params: { name: "user2" }, - }, - ); - - const conn1 = handle1.connect(); - const conn2 = handle2.connect(); - - // HACK: Call an action to wait for the connections to be established - await conn1.getInitializers(); - await conn2.getInitializers(); - - // Get initializers to verify connection params were used - const initializers = await conn1.getInitializers(); - - // Verify both connection names were recorded - expect(initializers).toContain("user1"); - expect(initializers).toContain("user2"); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - - test("should call getParams for each connection", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - let connectionCount = 0; - const handle = client.counterWithParams.getOrCreate( - ["test-get-params"], - { - getParams: async () => ({ - name: `user${++connectionCount}`, - }), - }, - ); - - const conn1 = handle.connect(); - await conn1.getInitializers(); - await conn1.dispose(); - - const conn2 = handle.connect(); - const initializers = await conn2.getInitializers(); - - expect(initializers).toEqual(["user1", "user2"]); - expect(connectionCount).toBe(2); - - await conn2.dispose(); - }); - - test("should surface getParams errors and retry connection setup", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - let attempts = 0; - const handle = client.counterWithParams.getOrCreate( - ["test-get-params-retry"], - { - getParams: async () => { - attempts++; - if (attempts === 1) { - throw new Error("token unavailable"); - } - - return { name: "user1" }; - }, - }, - ); - - const conn = handle.connect(); - const receivedErrors: Array<{ group: string; code: string }> = - []; - conn.onError((error) => { - receivedErrors.push({ - group: error.group, - code: error.code, - }); - }); - - await expect(conn.getInitializers()).rejects.toMatchObject({ - group: "client", - code: "get_params_failed", - }); - - await vi.waitFor( - async () => { - expect(await conn.getInitializers()).toEqual(["user1"]); - }, - { timeout: 10_000 }, - ); - - expect(receivedErrors).toEqual([ - { group: "client", code: "get_params_failed" }, - ]); - expect(attempts).toBeGreaterThanOrEqual(2); - - await conn.dispose(); - }); - }); - - describe("Lifecycle Hooks", () => { - test("should trigger lifecycle hooks", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create and connect - const connHandle = client.counterWithLifecycle.getOrCreate( - ["test-lifecycle"], - { - params: { trackLifecycle: true }, - }, - ); - const connection = connHandle.connect(); - - // Verify lifecycle events were triggered - const events = await connection.getEvents(); - expect(events).toEqual([ - "onWake", - "onBeforeConnect", - "onConnect", - ]); - - // Disconnect should trigger onDisconnect - await connection.dispose(); - - await vi.waitFor( - async () => { - // Reconnect to check if onDisconnect was called - const handle = client.counterWithLifecycle.getOrCreate([ - "test-lifecycle", - ]); - const finalEvents = await handle.getEvents(); - expect(finalEvents).toBeOneOf([ - // Still active - [ - "onWake", - "onBeforeConnect", - "onConnect", - "onDisconnect", - ], - // Went to sleep and woke back up - [ - "onWake", - "onBeforeConnect", - "onConnect", - "onDisconnect", - "onWake", - ], - ]); - }, - // NOTE: High timeout required for Cloudflare Workers - { - timeout: 10_000, - interval: 100, - }, - ); - }); - }); - - describe("Connection State", () => { - test("isConnected should be false before connection opens", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate([ - "test-isconnected-initial", - ]); - const connection = handle.connect(); - - // isConnected should be false initially (connection not yet established) - expect(connection.isConnected).toBe(false); - - // Wait for connection to be established - await vi.waitFor(() => { - expect(connection.isConnected).toBe(true); - }); - - // Clean up - await connection.dispose(); - }); - - test("onOpen should be called when connection opens", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate(["test-onopen"]); - const connection = handle.connect(); - - // Track open events - let openCount = 0; - connection.onOpen(() => { - openCount++; - }); - - // Wait for connection to open - await vi.waitFor(() => { - expect(openCount).toBe(1); - }); - - // Verify isConnected is true - expect(connection.isConnected).toBe(true); - - // Clean up - await connection.dispose(); - }); - - test("onClose should be called when connection closes via dispose", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate(["test-onclose"]); - const connection = handle.connect(); - - // Track close events - let closeCount = 0; - connection.onClose(() => { - closeCount++; - }); - - // Wait for connection to open first - await vi.waitFor(() => { - expect(connection.isConnected).toBe(true); - }); - - // Dispose connection - await connection.dispose(); - - // Verify onClose was called - expect(closeCount).toBe(1); - - // Verify isConnected is false - expect(connection.isConnected).toBe(false); - }); - - test("should be able to unsubscribe from onOpen", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate([ - "test-onopen-unsub", - ]); - const connection = handle.connect(); - - // Track open events - let openCount = 0; - const unsubscribe = connection.onOpen(() => { - openCount++; - }); - - // Unsubscribe immediately - unsubscribe(); - - // Wait a bit for connection to potentially open - await vi.waitFor(() => { - expect(connection.isConnected).toBe(true); - }); - - // Open callback should not have been called since we unsubscribed - expect(openCount).toBe(0); - - // Clean up - await connection.dispose(); - }); - - test("should be able to unsubscribe from onClose", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate([ - "test-onclose-unsub", - ]); - const connection = handle.connect(); - - // Track close events - let closeCount = 0; - const unsubscribe = connection.onClose(() => { - closeCount++; - }); - - // Wait for connection to open - await vi.waitFor(() => { - expect(connection.isConnected).toBe(true); - }); - - // Unsubscribe before closing - unsubscribe(); - - // Dispose connection - await connection.dispose(); - - // Close callback should not have been called since we unsubscribed - expect(closeCount).toBe(0); - }); - - test("multiple onOpen handlers should all be called", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate([ - "test-multi-onopen", - ]); - const connection = handle.connect(); - - // Track open events from multiple handlers - let handler1Called = false; - let handler2Called = false; - - connection.onOpen(() => { - handler1Called = true; - }); - connection.onOpen(() => { - handler2Called = true; - }); - - // Wait for connection to open - await vi.waitFor(() => { - expect(handler1Called).toBe(true); - expect(handler2Called).toBe(true); - }); - - // Clean up - await connection.dispose(); - }); - - test("multiple onClose handlers should all be called", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get connection - const handle = client.counter.getOrCreate([ - "test-multi-onclose", - ]); - const connection = handle.connect(); - - // Track close events from multiple handlers - let handler1Called = false; - let handler2Called = false; - - connection.onClose(() => { - handler1Called = true; - }); - connection.onClose(() => { - handler2Called = true; - }); - - // Wait for connection to open first - await vi.waitFor(() => { - expect(connection.isConnected).toBe(true); - }); - - // Dispose connection - await connection.dispose(); - - // Verify both handlers were called - expect(handler1Called).toBe(true); - expect(handler2Called).toBe(true); - }); - }); - - describe("Large Payloads", () => { - test("should handle large request within size limit", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.largePayloadConnActor.getOrCreate([ - "test-large-request", - ]); - const connection = handle.connect(); - - // Create a large payload that's under the default 64KB limit - // Each item is roughly 60 bytes, so 800 items ≈ 48KB - const items: string[] = []; - for (let i = 0; i < 800; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - - const result = await connection.processLargeRequest({ items }); - - expect(result.itemCount).toBe(800); - expect(result.firstItem).toBe( - "Item 0 with some additional text to increase size", - ); - expect(result.lastItem).toBe( - "Item 799 with some additional text to increase size", - ); - - // Verify connection state was updated - const lastRequestSize = await connection.getLastRequestSize(); - expect(lastRequestSize).toBe(800); - - // Clean up - await connection.dispose(); - }); - - test("should reject request exceeding maxIncomingMessageSize", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.largePayloadConnActor.getOrCreate([ - "test-large-request-exceed", - ]); - const connection = handle.connect(); - - // Create a payload that exceeds the default 64KB limit - // Each item is roughly 60 bytes, so 1500 items ≈ 90KB - const items: string[] = []; - for (let i = 0; i < 1500; i++) { - items.push( - `Item ${i} with some additional text to increase size`, - ); - } - - await expect( - connection.processLargeRequest({ items }), - ).rejects.toThrow(); - - // Clean up - await connection.dispose(); - }); - - test("should handle large response", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.largePayloadConnActor.getOrCreate([ - "test-large-response", - ]); - const connection = handle.connect(); - - // Request a large response (800 items ≈ 48KB) - const result = await connection.getLargeResponse(800); - - expect(result.items).toHaveLength(800); - expect(result.items[0]).toBe( - "Item 0 with some additional text to increase size", - ); - expect(result.items[799]).toBe( - "Item 799 with some additional text to increase size", - ); - - // Clean up - await connection.dispose(); - }); - - test("should reject response exceeding maxOutgoingMessageSize", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.largePayloadConnActor.getOrCreate([ - "test-large-response-exceed", - ]); - const connection = handle.connect(); - - // Request a response that exceeds the default 1MB limit - // Each item is roughly 60 bytes, so 20000 items ≈ 1.2MB - await expect( - connection.getLargeResponse(20000), - ).rejects.toThrow(); - - // Clean up - await connection.dispose(); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-kv-stats.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-kv-stats.ts deleted file mode 100644 index fe861054b8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-kv-stats.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorDbKvStatsTests(driverTestConfig: DriverTestConfig) { - describe("Actor Database KV Stats Tests", () => { - // -- Warm path tests -- - // These call warmUp first to prime the pager cache and reset - // stats, then measure the exact KV behavior of subsequent ops. - // This is the steady-state path for a live actor. - - test("warm UPDATE uses BATCH_ATOMIC: exactly 1 putBatch, 0 reads, no journal", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-ba-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.increment(); - const stats = await actor.getStats(); - const log = await actor.getLog(); - - expect(stats.putBatchCalls).toBe(1); - expect(stats.getBatchCalls).toBe(0); - - const allKeys = log.flatMap((e: { keys: string[] }) => e.keys); - const journalKeys = allKeys.filter((k: string) => - k.includes("journal"), - ); - expect(journalKeys.length).toBe(0); - }, 30_000); - - test("warm SELECT uses 0 KV round trips", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-2-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.getCount(); - const stats = await actor.getStats(); - - expect(stats.getBatchCalls).toBe(0); - expect(stats.putBatchCalls).toBe(0); - }, 30_000); - - test("warm SELECT after UPDATE adds no KV round trips", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-3-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.increment(); - const updateStats = await actor.getStats(); - - await actor.resetStats(); - await actor.incrementAndRead(); - const combinedStats = await actor.getStats(); - - expect(combinedStats.putBatchCalls).toBe(updateStats.putBatchCalls); - expect(combinedStats.getBatchCalls).toBe(updateStats.getBatchCalls); - }, 30_000); - - test("warm multi-page INSERT writes multiple chunk keys", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-4-${crypto.randomUUID()}`, - ]); - - // First call creates table/index and primes cache - await actor.insertWithIndex(); - await actor.resetStats(); - - await actor.insertWithIndex(); - const stats = await actor.getStats(); - const log = await actor.getLog(); - - expect(stats.putBatchCalls).toBeGreaterThanOrEqual(1); - expect(stats.putBatchEntries).toBeGreaterThan(1); - - const putOps = log.filter( - (e: { op: string }) => e.op === "putBatch" || e.op === "put", - ); - const allKeys = putOps.flatMap((e: { keys: string[] }) => e.keys); - const mainChunkKeys = allKeys.filter((k: string) => - k.startsWith("chunk:main["), - ); - expect(mainChunkKeys.length).toBeGreaterThanOrEqual(1); - }, 30_000); - - test("warm ROLLBACK produces no data page writes", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-5-${crypto.randomUUID()}`, - ]); - - await actor.rollbackTest(); - await actor.resetStats(); - - await actor.rollbackTest(); - const log = await actor.getLog(); - - const putOps = log.filter( - (e: { op: string }) => e.op === "putBatch" || e.op === "put", - ); - const mainChunkKeys = putOps - .flatMap((e: { keys: string[] }) => e.keys) - .filter((k: string) => k.startsWith("chunk:main[")); - expect(mainChunkKeys.length).toBe(0); - }, 30_000); - - test("warm multi-statement transaction produces writes", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-6-${crypto.randomUUID()}`, - ]); - - await actor.multiStmtTx(); - await actor.resetStats(); - - await actor.multiStmtTx(); - const stats = await actor.getStats(); - - expect(stats.putBatchCalls).toBeGreaterThanOrEqual(1); - }, 30_000); - - // -- Structural property tests -- - // These assert invariants that hold regardless of cache state. - - test("no WAL or SHM operations occur", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-7-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.increment(); - const log = await actor.getLog(); - - const allKeys = log.flatMap((e: { keys: string[] }) => e.keys); - const walOrShmKeys = allKeys.filter( - (k: string) => k.includes("wal") || k.includes("shm"), - ); - expect(walOrShmKeys.length).toBe(0); - }, 30_000); - - test("every putBatch has at most 128 keys", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-8-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.increment(); - const log = await actor.getLog(); - - const putBatchOps = log.filter( - (e: { op: string }) => e.op === "putBatch", - ); - for (const entry of putBatchOps) { - expect( - (entry as { keys: string[] }).keys.length, - ).toBeLessThanOrEqual(128); - } - }, 30_000); - - // -- Large transaction tests -- - - test("large transaction falls back to journal when exceeding 127 dirty pages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-9-${crypto.randomUUID()}`, - ]); - - await actor.warmUp(); - - await actor.bulkInsertLarge(); - const stats = await actor.getStats(); - const log = await actor.getLog(); - - expect(stats.putBatchCalls).toBeGreaterThan(1); - - const allKeys = log.flatMap((e: { keys: string[] }) => e.keys); - const journalKeys = allKeys.filter((k: string) => - k.includes("journal"), - ); - expect(journalKeys.length).toBeGreaterThan(0); - - const putBatchOps = log.filter( - (e: { op: string }) => e.op === "putBatch", - ); - for (const entry of putBatchOps) { - expect( - (entry as { keys: string[] }).keys.length, - ).toBeLessThanOrEqual(128); - } - }, 60_000); - - test("large transaction data integrity: 200 rows and integrity check pass", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-10-${crypto.randomUUID()}`, - ]); - - await actor.bulkInsertLarge(); - - const count = await actor.getRowCount(); - expect(count).toBe(200); - - const integrity = await actor.runIntegrityCheck(); - expect(integrity).toBe("ok"); - }, 60_000); - - test("large transaction survives actor sleep and wake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.dbKvStatsActor.getOrCreate([ - `kv-stats-11-${crypto.randomUUID()}`, - ]); - - await actor.bulkInsertLarge(); - const countBefore = await actor.getRowCount(); - expect(countBefore).toBe(200); - - await actor.triggerSleep(); - await waitFor(driverTestConfig, 250); - - const countAfter = await actor.getRowCount(); - expect(countAfter).toBe(200); - - const integrity = await actor.runIntegrityCheck(); - expect(integrity).toBe("ok"); - }, 60_000); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts deleted file mode 100644 index df9f666815..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const SLEEP_WAIT_MS = 150; -const REAL_TIMER_DB_TIMEOUT_MS = 180_000; - -export function runActorDbPragmaMigrationTests( - driverTestConfig: DriverTestConfig, -) { - const dbTestTimeout = driverTestConfig.useRealTimers - ? REAL_TIMER_DB_TIMEOUT_MS - : undefined; - - describe("Actor Database PRAGMA Migration Tests", () => { - test( - "applies all migrations on first start", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = - client.dbPragmaMigrationActor.getOrCreate([ - `pragma-init-${crypto.randomUUID()}`, - ]); - - // user_version should be set to 2 after migrations - const version = await actor.getUserVersion(); - expect(version).toBe(2); - - // The status column from migration v2 should exist - const columns = await actor.getColumns(); - expect(columns).toContain("id"); - expect(columns).toContain("name"); - expect(columns).toContain("status"); - }, - dbTestTimeout, - ); - - test( - "inserts with default status from migration v2", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = - client.dbPragmaMigrationActor.getOrCreate([ - `pragma-default-${crypto.randomUUID()}`, - ]); - - await actor.insertItem("test-item"); - const items = await actor.getItems(); - - expect(items).toHaveLength(1); - expect(items[0].name).toBe("test-item"); - expect(items[0].status).toBe("active"); - }, - dbTestTimeout, - ); - - test( - "inserts with explicit status", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = - client.dbPragmaMigrationActor.getOrCreate([ - `pragma-explicit-${crypto.randomUUID()}`, - ]); - - await actor.insertItemWithStatus("done-item", "completed"); - const items = await actor.getItems(); - - expect(items).toHaveLength(1); - expect(items[0].name).toBe("done-item"); - expect(items[0].status).toBe("completed"); - }, - dbTestTimeout, - ); - - test( - "migrations are idempotent across sleep/wake", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const key = `pragma-sleep-${crypto.randomUUID()}`; - const actor = - client.dbPragmaMigrationActor.getOrCreate([key]); - - // Insert data before sleep - await actor.insertItemWithStatus("before-sleep", "pending"); - - // Sleep and wake - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - - // After wake, onMigrate runs again but should not fail - const version = await actor.getUserVersion(); - expect(version).toBe(2); - - // Data should survive - const items = await actor.getItems(); - expect(items).toHaveLength(1); - expect(items[0].name).toBe("before-sleep"); - expect(items[0].status).toBe("pending"); - - // Should still be able to insert - await actor.insertItem("after-sleep"); - const items2 = await actor.getItems(); - expect(items2).toHaveLength(2); - }, - dbTestTimeout, - ); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-raw.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-raw.ts deleted file mode 100644 index 200bc2e352..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-raw.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorDbRawTests(driverTestConfig: DriverTestConfig) { - describe("Actor Database (Raw) Tests", () => { - describe("Database Basic Operations", () => { - test("creates and queries database tables", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.dbActorRaw.getOrCreate(); - - // Add values - await instance.insertValue("Alice"); - await instance.insertValue("Bob"); - - // Query values - const values = await instance.getValues(); - expect(values).toHaveLength(2); - expect(values[0].value).toBe("Alice"); - expect(values[1].value).toBe("Bob"); - }); - - test("persists data across actor instances", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // First instance adds items - const instance1 = client.dbActorRaw.getOrCreate([ - "test-persistence", - ]); - await instance1.insertValue("Item 1"); - await instance1.insertValue("Item 2"); - - // Second instance (same actor) should see persisted data - const instance2 = client.dbActorRaw.getOrCreate([ - "test-persistence", - ]); - const count = await instance2.getCount(); - expect(count).toBe(2); - }); - - test("maintains separate databases for different actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // First actor - const actor1 = client.dbActorRaw.getOrCreate(["actor-1"]); - await actor1.insertValue("A"); - await actor1.insertValue("B"); - - // Second actor - const actor2 = client.dbActorRaw.getOrCreate(["actor-2"]); - await actor2.insertValue("X"); - - // Verify separate data - const count1 = await actor1.getCount(); - const count2 = await actor2.getCount(); - expect(count1).toBe(2); - expect(count2).toBe(1); - }); - }); - - describe("Database Migrations", () => { - test("runs migrations on actor startup", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.dbActorRaw.getOrCreate(); - - // Try to insert into the table to verify it exists - await instance.insertValue("test"); - const values = await instance.getValues(); - - expect(values).toHaveLength(1); - expect(values[0].value).toBe("test"); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-stress.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-stress.ts deleted file mode 100644 index 861035321c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-stress.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { nativeSqliteAvailable } from "@/db/native-sqlite"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const STRESS_TEST_TIMEOUT_MS = 60_000; - -/** - * Stress and resilience tests for the SQLite database subsystem. - * - * These tests target edge cases from the adversarial review: - * - C1: close_database racing with in-flight operations - * - H1: lifecycle operations blocking the Node.js event loop - * - Reconnect: WebSocket disconnect during active KV operations - * - * They run against the file-system driver with real timers and require - * the native SQLite addon for the KV channel tests. - */ -export function runActorDbStressTests(driverTestConfig: DriverTestConfig) { - const nativeAvailable = nativeSqliteAvailable(); - - describe("Actor Database Stress Tests", () => { - test( - "destroy during long-running DB operation completes without crash", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Start multiple actors and kick off long DB operations, - // then destroy them mid-flight. The test passes if no - // actor crashes and no unhandled errors propagate. - const actors = Array.from({ length: 5 }, (_, i) => - client.dbStressActor.getOrCreate([ - `stress-destroy-${i}-${crypto.randomUUID()}`, - ]), - ); - - // Start long-running inserts on all actors. - const insertPromises = actors.map((actor) => - actor.insertBatch(500).catch((err: Error) => ({ - error: err.message, - })), - ); - - // Immediately destroy all actors while inserts are in flight. - const destroyPromises = actors.map((actor) => - actor.destroy().catch((err: Error) => ({ - error: err.message, - })), - ); - - // Both sets of operations should resolve without hanging. - // Inserts may succeed or fail with an error (actor destroyed), - // but must not crash the process. - const results = await Promise.allSettled([ - ...insertPromises, - ...destroyPromises, - ]); - - // Verify all promises settled (none hung). - expect(results).toHaveLength(10); - for (const result of results) { - expect(result.status).toBe("fulfilled"); - } - }, - STRESS_TEST_TIMEOUT_MS, - ); - - test( - "rapid create-insert-destroy cycles handle DB lifecycle correctly", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Perform rapid cycles of create -> insert -> destroy. - // This exercises the close_database path racing with - // any pending DB operations from the insert. - for (let i = 0; i < 10; i++) { - const actor = client.dbStressActor.getOrCreate([ - `stress-cycle-${i}-${crypto.randomUUID()}`, - ]); - - // Insert some data. - await actor.insertBatch(10); - - // Verify data was written. - const count = await actor.getCount(); - expect(count).toBeGreaterThanOrEqual(10); - - // Destroy the actor (triggers close_database). - await actor.destroy(); - } - }, - STRESS_TEST_TIMEOUT_MS, - ); - - test( - "DB operations complete without excessive blocking", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.dbStressActor.getOrCreate([ - `stress-health-${crypto.randomUUID()}`, - ]); - - // Measure wall-clock time for 100 sequential DB inserts. - // Each insert is an async round-trip through the VFS. - // If lifecycle operations (open_database, close_database) - // block the event loop, this will take much longer than - // expected because the action itself runs on that loop. - const health = await actor.measureEventLoopHealth(100); - - // 100 sequential inserts should complete in well under - // 30 seconds. A blocked event loop (e.g., 30s WebSocket - // timeout on open_database) would push this way over. - expect(health.elapsedMs).toBeLessThan(30_000); - expect(health.insertCount).toBe(100); - - // Verify the actor is still healthy after the test. - const integrity = await actor.integrityCheck(); - expect(integrity.toLowerCase()).toBe("ok"); - }, - STRESS_TEST_TIMEOUT_MS, - ); - - // This test requires native SQLite (KV channel WebSocket). - // When using WASM SQLite, there's no WebSocket to disconnect. - describe.skipIf(!nativeAvailable)( - "KV Channel Resilience", - () => { - test( - "recovers from forced WebSocket disconnect during DB writes", - async (c) => { - const { client, endpoint } = - await setupDriverTest(c, driverTestConfig); - - const actor = client.dbStressActor.getOrCreate([ - `stress-disconnect-${crypto.randomUUID()}`, - ]); - - // Write initial data to confirm the actor works. - await actor.insertBatch(10); - expect(await actor.getCount()).toBe(10); - - // Force-close all KV channel WebSocket connections. - // The native SQLite addon should reconnect automatically. - const res = await fetch( - `${endpoint}/.test/kv-channel/force-disconnect`, - { method: "POST" }, - ); - expect(res.ok).toBe(true); - const body = (await res.json()) as { - closed: number; - }; - expect(body.closed).toBeGreaterThanOrEqual(0); - - // Give the native addon time to detect the disconnect - // and reconnect. - await waitFor(driverTestConfig, 2000); - - // The actor should still work after reconnection. - // The native addon re-opens actors on the new connection. - await actor.insertBatch(10); - const finalCount = await actor.getCount(); - expect(finalCount).toBe(20); - - // Verify data integrity after the disruption. - const integrity = await actor.integrityCheck(); - expect(integrity.toLowerCase()).toBe("ok"); - }, - STRESS_TEST_TIMEOUT_MS, - ); - - test( - "handles disconnect during active write operation", - async (c) => { - const { client, endpoint } = - await setupDriverTest(c, driverTestConfig); - - const actor = client.dbStressActor.getOrCreate([ - `stress-active-disconnect-${crypto.randomUUID()}`, - ]); - - // Confirm the actor is healthy. - await actor.insertBatch(5); - - // Start a large write operation and disconnect - // mid-flight. The write may fail, but the actor - // should recover. - const writePromise = actor - .insertBatch(200) - .catch((err: Error) => ({ - error: err.message, - })); - - // Small delay to let the write start, then disconnect. - await new Promise((resolve) => - setTimeout(resolve, 50), - ); - - await fetch( - `${endpoint}/.test/kv-channel/force-disconnect`, - { method: "POST" }, - ); - - // Wait for the write to settle (success or failure). - await writePromise; - - // Wait for reconnection. - await waitFor(driverTestConfig, 2000); - - // Actor should recover. New operations should work. - await actor.insertBatch(5); - const count = await actor.getCount(); - // At least the initial 5 + final 5 should exist. - // The mid-disconnect 200 may or may not have committed. - expect(count).toBeGreaterThanOrEqual(10); - - const integrity = await actor.integrityCheck(); - expect(integrity.toLowerCase()).toBe("ok"); - }, - STRESS_TEST_TIMEOUT_MS, - ); - }, - ); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts deleted file mode 100644 index bf085b14f1..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts +++ /dev/null @@ -1,636 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -type DbVariant = "raw" | "drizzle"; - -const CHUNK_SIZE = 4096; -const LARGE_PAYLOAD_SIZE = 32768; -const HIGH_VOLUME_COUNT = 1000; -const SLEEP_WAIT_MS = 150; -const LIFECYCLE_POLL_INTERVAL_MS = 25; -const LIFECYCLE_POLL_ATTEMPTS = 40; -const REAL_TIMER_HARD_CRASH_POLL_INTERVAL_MS = 50; -const REAL_TIMER_HARD_CRASH_POLL_ATTEMPTS = 600; -const REAL_TIMER_DB_TIMEOUT_MS = 180_000; -const CHUNK_BOUNDARY_SIZES = [ - CHUNK_SIZE - 1, - CHUNK_SIZE, - CHUNK_SIZE + 1, - 2 * CHUNK_SIZE - 1, - 2 * CHUNK_SIZE, - 2 * CHUNK_SIZE + 1, - 4 * CHUNK_SIZE - 1, - 4 * CHUNK_SIZE, - 4 * CHUNK_SIZE + 1, -]; -const SHRINK_GROW_INITIAL_ROWS = 16; -const SHRINK_GROW_REGROW_ROWS = 10; -const SHRINK_GROW_INITIAL_PAYLOAD = 4096; -const SHRINK_GROW_REGROW_PAYLOAD = 6144; -const HOT_ROW_COUNT = 10; -const HOT_ROW_UPDATES = 240; -const INTEGRITY_SEED_COUNT = 64; -const INTEGRITY_CHURN_COUNT = 120; - -function getDbActor( - client: Awaited>["client"], - variant: DbVariant, -) { - return variant === "raw" ? client.dbActorRaw : client.dbActorDrizzle; -} - -export function runActorDbTests(driverTestConfig: DriverTestConfig) { - const variants: DbVariant[] = ["raw", "drizzle"]; - const dbTestTimeout = driverTestConfig.useRealTimers - ? REAL_TIMER_DB_TIMEOUT_MS - : undefined; - const lifecycleTestTimeout = driverTestConfig.useRealTimers - ? REAL_TIMER_DB_TIMEOUT_MS - : undefined; - - for (const variant of variants) { - describe(`Actor Database (${variant}) Tests`, () => { - test( - "bootstraps schema on startup", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-bootstrap-${crypto.randomUUID()}`, - ]); - - const count = await actor.getCount(); - expect(count).toBe(0); - }, - dbTestTimeout, - ); - - test( - "supports CRUD, raw SQL, and multi-statement exec", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-crud-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - - const first = await actor.insertValue("alpha"); - const second = await actor.insertValue("beta"); - - const values = await actor.getValues(); - expect(values.length).toBeGreaterThanOrEqual(2); - expect( - values.some( - (row: { value: string }) => row.value === "alpha", - ), - ).toBeTruthy(); - expect( - values.some( - (row: { value: string }) => row.value === "beta", - ), - ).toBeTruthy(); - - await actor.updateValue(first.id, "alpha-updated"); - const updated = await actor.getValue(first.id); - expect(updated).toBe("alpha-updated"); - - await actor.deleteValue(second.id); - const count = await actor.getCount(); - if (driverTestConfig.useRealTimers) { - expect(count).toBeGreaterThanOrEqual(1); - } else { - expect(count).toBe(1); - } - - const rawCount = await actor.rawSelectCount(); - if (driverTestConfig.useRealTimers) { - expect(rawCount).toBeGreaterThanOrEqual(1); - } else { - expect(rawCount).toBe(1); - } - - const multiValue = - await actor.multiStatementInsert("gamma"); - expect(multiValue).toBe("gamma-updated"); - }, - dbTestTimeout, - ); - - test( - "handles transactions", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-tx-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.transactionCommit("commit"); - expect(await actor.getCount()).toBe(1); - - await actor.transactionRollback("rollback"); - expect(await actor.getCount()).toBe(1); - }, - dbTestTimeout, - ); - - test( - "persists across sleep and wake cycles", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-sleep-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.insertValue("sleepy"); - const baselineCount = await actor.getCount(); - expect(baselineCount).toBeGreaterThan(0); - - for (let i = 0; i < 3; i++) { - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - expect(await actor.getCount()).toBe(baselineCount); - } - }, - dbTestTimeout, - ); - - test.skipIf(driverTestConfig.skip?.sleep)( - "preserves committed rows across a hard crash and restart", - async (c) => { - const { - client, - hardCrashActor, - hardCrashPreservesData, - } = await setupDriverTest(c, driverTestConfig); - if (!hardCrashPreservesData) { - return; - } - if (!hardCrashActor) { - throw new Error( - "hardCrashActor test helper is unavailable for this driver", - ); - } - - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-hard-crash-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.insertValue("before-crash"); - expect(await actor.getCount()).toBe(1); - - const actorId = await actor.resolve(); - await hardCrashActor(actorId); - - const hardCrashPollAttempts = - driverTestConfig.useRealTimers - ? REAL_TIMER_HARD_CRASH_POLL_ATTEMPTS - : LIFECYCLE_POLL_ATTEMPTS; - const hardCrashPollIntervalMs = - driverTestConfig.useRealTimers - ? REAL_TIMER_HARD_CRASH_POLL_INTERVAL_MS - : LIFECYCLE_POLL_INTERVAL_MS; - - let countAfterCrash = 0; - for (let i = 0; i < hardCrashPollAttempts; i++) { - try { - countAfterCrash = await actor.getCount(); - } catch { - countAfterCrash = 0; - } - if (countAfterCrash === 1) { - break; - } - await waitFor( - driverTestConfig, - hardCrashPollIntervalMs, - ); - } - - expect(countAfterCrash).toBe(1); - const values = await actor.getValues(); - expect( - values.some((row) => row.value === "before-crash"), - ).toBe(true); - - await actor.insertValue("after-crash"); - expect(await actor.getCount()).toBe(2); - }, - lifecycleTestTimeout, - ); - - test( - "completes onDisconnect DB writes before sleeping", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const key = `db-${variant}-disconnect-${crypto.randomUUID()}`; - - const actor = getDbActor(client, variant).getOrCreate([ - key, - ]); - await actor.reset(); - await actor.configureDisconnectInsert(true, 250); - - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 250); - await actor.configureDisconnectInsert(false, 0); - - // Poll for the disconnect insert to complete. - // Native SQLite routes writes through a WebSocket KV - // channel, which adds latency that can push the - // onDisconnect DB write past the fixed wait window - // under concurrent test load. - let count = 0; - for (let i = 0; i < LIFECYCLE_POLL_ATTEMPTS; i++) { - count = - await actor.getDisconnectInsertCount(); - if (count >= 1) { - break; - } - await waitFor( - driverTestConfig, - LIFECYCLE_POLL_INTERVAL_MS, - ); - } - - expect(count).toBe(1); - }, - dbTestTimeout, - ); - - test( - "handles high-volume inserts", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-high-volume-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.insertMany(HIGH_VOLUME_COUNT); - const count = await actor.getCount(); - if (driverTestConfig.useRealTimers) { - expect(count).toBeGreaterThanOrEqual(HIGH_VOLUME_COUNT); - } else { - expect(count).toBe(HIGH_VOLUME_COUNT); - } - }, - dbTestTimeout, - ); - - test( - "handles payloads across chunk boundaries", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-chunk-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - for (const size of CHUNK_BOUNDARY_SIZES) { - const { id } = await actor.insertPayloadOfSize(size); - const storedSize = await actor.getPayloadSize(id); - expect(storedSize).toBe(size); - } - }, - dbTestTimeout, - ); - - test( - "handles large payloads", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-large-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - const { id } = - await actor.insertPayloadOfSize(LARGE_PAYLOAD_SIZE); - const storedSize = await actor.getPayloadSize(id); - expect(storedSize).toBe(LARGE_PAYLOAD_SIZE); - }, - dbTestTimeout, - ); - - test( - "supports shrink and regrow workloads with vacuum", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-shrink-regrow-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.vacuum(); - const baselinePages = await actor.getPageCount(); - - await actor.insertPayloadRows( - SHRINK_GROW_INITIAL_ROWS, - SHRINK_GROW_INITIAL_PAYLOAD, - ); - const grownPages = await actor.getPageCount(); - - await actor.reset(); - await actor.vacuum(); - const shrunkPages = await actor.getPageCount(); - - await actor.insertPayloadRows( - SHRINK_GROW_REGROW_ROWS, - SHRINK_GROW_REGROW_PAYLOAD, - ); - const regrownPages = await actor.getPageCount(); - - expect(grownPages).toBeGreaterThanOrEqual(baselinePages); - expect(shrunkPages).toBeLessThanOrEqual(grownPages); - expect(regrownPages).toBeGreaterThan(shrunkPages); - }, - dbTestTimeout, - ); - - test( - "handles repeated updates to the same row", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-updates-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - const { id } = await actor.insertValue("base"); - const result = await actor.repeatUpdate(id, 50); - expect(result.value).toBe("Updated 49"); - const value = await actor.getValue(id); - expect(value).toBe("Updated 49"); - - const hotRowIds: number[] = []; - for (let i = 0; i < HOT_ROW_COUNT; i++) { - const row = await actor.insertValue(`init-${i}`); - hotRowIds.push(row.id); - } - - const updatedRows = await actor.roundRobinUpdateValues( - hotRowIds, - HOT_ROW_UPDATES, - ); - expect(updatedRows).toHaveLength(HOT_ROW_COUNT); - for (const row of updatedRows) { - expect(row.value).toMatch(/^v-\d+$/); - } - }, - dbTestTimeout, - ); - - test( - "passes integrity checks after mixed workload and sleep", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actor = getDbActor(client, variant).getOrCreate([ - `db-${variant}-integrity-${crypto.randomUUID()}`, - ]); - - await actor.reset(); - await actor.runMixedWorkload( - INTEGRITY_SEED_COUNT, - INTEGRITY_CHURN_COUNT, - ); - expect((await actor.integrityCheck()).toLowerCase()).toBe( - "ok", - ); - - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); - expect((await actor.integrityCheck()).toLowerCase()).toBe( - "ok", - ); - }, - dbTestTimeout, - ); - }); - } - - describe("Actor Database Lifecycle Cleanup Tests", () => { - test( - "runs db provider cleanup on sleep", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const observer = client.dbLifecycleObserver.getOrCreate([ - "observer", - ]); - - const lifecycle = client.dbLifecycle.getOrCreate([ - `db-lifecycle-sleep-${crypto.randomUUID()}`, - ]); - const actorId = await lifecycle.getActorId(); - - const before = await observer.getCounts(actorId); - - await lifecycle.insertValue("before-sleep"); - await lifecycle.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); - await lifecycle.ping(); - - let after = before; - for (let i = 0; i < LIFECYCLE_POLL_ATTEMPTS; i++) { - after = await observer.getCounts(actorId); - if (after.cleanup >= before.cleanup + 1) { - break; - } - await waitFor(driverTestConfig, LIFECYCLE_POLL_INTERVAL_MS); - } - - expect(after.create).toBeGreaterThanOrEqual(before.create); - expect(after.migrate).toBeGreaterThanOrEqual(before.migrate); - expect(after.cleanup).toBeGreaterThanOrEqual( - before.cleanup + 1, - ); - }, - lifecycleTestTimeout, - ); - - test( - "runs db provider cleanup on destroy", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const observer = client.dbLifecycleObserver.getOrCreate([ - "observer", - ]); - - const lifecycle = client.dbLifecycle.getOrCreate([ - `db-lifecycle-destroy-${crypto.randomUUID()}`, - ]); - const actorId = await lifecycle.getActorId(); - const before = await observer.getCounts(actorId); - - await lifecycle.insertValue("before-destroy"); - await lifecycle.triggerDestroy(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); - - let cleanupCount = before.cleanup; - for (let i = 0; i < LIFECYCLE_POLL_ATTEMPTS; i++) { - const counts = await observer.getCounts(actorId); - cleanupCount = counts.cleanup; - if (cleanupCount >= before.cleanup + 1) { - break; - } - await waitFor(driverTestConfig, LIFECYCLE_POLL_INTERVAL_MS); - } - - expect(cleanupCount).toBeGreaterThanOrEqual(before.cleanup + 1); - }, - lifecycleTestTimeout, - ); - - test( - "runs db provider cleanup when migration fails", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const observer = client.dbLifecycleObserver.getOrCreate([ - "observer", - ]); - const beforeTotalCleanup = - await observer.getTotalCleanupCount(); - const key = `db-lifecycle-migrate-failure-${crypto.randomUUID()}`; - const lifecycle = client.dbLifecycleFailing.getOrCreate([key]); - - let threw = false; - try { - await lifecycle.ping(); - } catch { - threw = true; - } - expect(threw).toBeTruthy(); - - let cleanupCount = beforeTotalCleanup; - for (let i = 0; i < LIFECYCLE_POLL_ATTEMPTS; i++) { - cleanupCount = await observer.getTotalCleanupCount(); - if (cleanupCount >= beforeTotalCleanup + 1) { - break; - } - await waitFor(driverTestConfig, LIFECYCLE_POLL_INTERVAL_MS); - } - - expect(cleanupCount).toBeGreaterThanOrEqual( - beforeTotalCleanup + 1, - ); - }, - lifecycleTestTimeout, - ); - - test( - "handles parallel actor lifecycle churn", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const observer = client.dbLifecycleObserver.getOrCreate([ - "observer", - ]); - - const actorHandles = Array.from({ length: 12 }, (_, i) => - client.dbLifecycle.getOrCreate([ - `db-lifecycle-stress-${i}-${crypto.randomUUID()}`, - ]), - ); - const actorIds = await Promise.all( - actorHandles.map((handle) => handle.getActorId()), - ); - - await Promise.all( - actorHandles.map((handle, i) => - handle.insertValue(`phase-1-${i}`), - ), - ); - await Promise.all( - actorHandles.map((handle) => handle.triggerSleep()), - ); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); - await Promise.all( - actorHandles.map((handle, i) => - handle.insertValue(`phase-2-${i}`), - ), - ); - - const survivors = actorHandles.slice(0, 6); - const destroyed = actorHandles.slice(6); - - await Promise.all( - destroyed.map((handle) => handle.triggerDestroy()), - ); - await Promise.all( - survivors.map((handle) => handle.triggerSleep()), - ); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); - await Promise.all(survivors.map((handle) => handle.ping())); - - const survivorCounts = await Promise.all( - survivors.map((handle) => handle.getCount()), - ); - for (const count of survivorCounts) { - if (driverTestConfig.useRealTimers) { - expect(count).toBeGreaterThanOrEqual(2); - } else { - expect(count).toBe(2); - } - } - - const lifecycleCleanup = new Map(); - for (let i = 0; i < LIFECYCLE_POLL_ATTEMPTS; i++) { - let allCleaned = true; - for (const actorId of actorIds) { - const counts = await observer.getCounts(actorId); - lifecycleCleanup.set(actorId, counts.cleanup); - if (counts.cleanup < 1) { - allCleaned = false; - } - } - - if (allCleaned) { - break; - } - await waitFor(driverTestConfig, LIFECYCLE_POLL_INTERVAL_MS); - } - - for (const actorId of actorIds) { - expect( - lifecycleCleanup.get(actorId) ?? 0, - ).toBeGreaterThanOrEqual(1); - } - }, - lifecycleTestTimeout, - ); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-destroy.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-destroy.ts deleted file mode 100644 index e11002669a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-destroy.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type { ActorError } from "@/client/mod"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorDestroyTests(driverTestConfig: DriverTestConfig) { - describe("Actor Destroy Tests", () => { - function expectActorNotFound(error: unknown) { - expect((error as ActorError).group).toBe("actor"); - expect((error as ActorError).code).toBe("not_found"); - } - - async function waitForActorDestroyed( - client: Awaited>["client"], - actorKey: string, - actorId: string, - ) { - const observer = client.destroyObserver.getOrCreate(["observer"]); - - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - actorRunning = true; - } catch (error) { - expectActorNotFound(error); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - } - - test("actor destroy clears state (without connect)", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = "test-destroy-without-connect"; - - // Get destroy observer - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - // Create actor - const destroyActor = client.destroyActor.getOrCreate([actorKey]); - - // Update state and save immediately - await destroyActor.setValue(42); - - // Verify state was saved - const value = await destroyActor.getValue(); - expect(value).toBe(42); - - // Get actor ID before destroying - const actorId = await destroyActor.resolve(); - - // Destroy the actor - await destroyActor.destroy(); - - // Wait until the observer confirms the actor was destroyed - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - // Wait until the actor is fully cleaned up (getForId returns error) - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - actorRunning = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - - // Verify actor no longer exists via getForId - let existsById = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - existsById = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - expect( - existsById, - "actor should not exist after destroy", - ).toBeFalsy(); - - // Verify actor no longer exists via get - let existsByKey = false; - try { - await client.destroyActor - .get(["test-destroy-without-connect"]) - .resolve(); - existsByKey = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - expect( - existsByKey, - "actor should not exist after destroy", - ).toBeFalsy(); - - // Create new actor with same key using getOrCreate - const newActor = client.destroyActor.getOrCreate([ - "test-destroy-without-connect", - ]); - - // Verify state is fresh (default value, not the old value) - const newValue = await newActor.getValue(); - expect(newValue).toBe(0); - }); - - test("actor destroy clears state (with connect)", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = "test-destroy-with-connect"; - - // Get destroy observer - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - // Create actor handle - const destroyActorHandle = client.destroyActor.getOrCreate([ - actorKey, - ]); - - // Get actor ID before destroying - const actorId = await destroyActorHandle.resolve(); - - // Create persistent connection - const destroyActor = destroyActorHandle.connect(); - - // Update state and save immediately - await destroyActor.setValue(99); - - // Verify state was saved - const value = await destroyActor.getValue(); - expect(value).toBe(99); - - // Destroy the actor - await destroyActor.destroy(); - - // Dispose the connection - await destroyActor.dispose(); - - // Wait until the observer confirms the actor was destroyed - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - // Wait until the actor is fully cleaned up (getForId returns error) - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - actorRunning = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - - // Verify actor no longer exists via getForId - let existsById = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - existsById = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - expect( - existsById, - "actor should not exist after destroy", - ).toBeFalsy(); - - // Verify actor no longer exists via get - let existsByKey = false; - try { - await client.destroyActor - .get(["test-destroy-with-connect"]) - .resolve(); - existsByKey = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - expect( - existsByKey, - "actor should not exist after destroy", - ).toBeFalsy(); - - // Create new actor with same key using getOrCreate - const newActor = client.destroyActor.getOrCreate([ - "test-destroy-with-connect", - ]); - - // Verify state is fresh (default value, not the old value) - const newValue = await newActor.getValue(); - expect(newValue).toBe(0); - }); - - test("actor destroy allows recreation via getOrCreate with resolve", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = "test-destroy-getorcreate-resolve"; - - // Get destroy observer - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - // Create actor - const destroyActor = client.destroyActor.getOrCreate([actorKey]); - - // Update state and save immediately - await destroyActor.setValue(123); - - // Verify state was saved - const value = await destroyActor.getValue(); - expect(value).toBe(123); - - // Get actor ID before destroying - const actorId = await destroyActor.resolve(); - - // Destroy the actor - await destroyActor.destroy(); - - // Wait until the observer confirms the actor was destroyed - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - // Wait until the actor is fully cleaned up - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - actorRunning = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - - // Recreate using getOrCreate with resolve - const newHandle = client.destroyActor.getOrCreate([actorKey]); - await newHandle.resolve(); - - // Verify state is fresh (default value, not the old value) - const newValue = await newHandle.getValue(); - expect(newValue).toBe(0); - }); - - test("actor destroy allows recreation via create", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = "test-destroy-create"; - - // Get destroy observer - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - // Create actor using create() - const initialHandle = await client.destroyActor.create([actorKey]); - - // Update state and save immediately - await initialHandle.setValue(456); - - // Verify state was saved - const value = await initialHandle.getValue(); - expect(value).toBe(456); - - // Get actor ID before destroying - const actorId = await initialHandle.resolve(); - - // Destroy the actor - await initialHandle.destroy(); - - // Wait until the observer confirms the actor was destroyed - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - // Wait until the actor is fully cleaned up - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.destroyActor.getForId(actorId).getValue(); - actorRunning = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - - // Recreate using create() - const newHandle = await client.destroyActor.create([actorKey]); - await newHandle.resolve(); - - // Verify state is fresh (default value, not the old value) - const newValue = await newHandle.getValue(); - expect(newValue).toBe(0); - }); - - test("stale getOrCreate handle retries action after actor destruction", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-handle-action-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const handle = client.destroyActor.getOrCreate([actorKey]); - await handle.setValue(321); - - const originalActorId = await handle.resolve(); - await handle.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - expect(await handle.getValue()).toBe(0); - }); - - test("stale getOrCreate handle retries queue send after actor destruction", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-handle-queue-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const handle = client.destroyActor.getOrCreate([actorKey]); - const originalActorId = await handle.resolve(); - - await handle.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - await handle.send("values", 11); - expect(await handle.receiveValue()).toBe(11); - }); - - test("stale getOrCreate handle retries raw HTTP after actor destruction", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-handle-http-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const handle = client.destroyActor.getOrCreate([actorKey]); - await handle.setValue(55); - - const originalActorId = await handle.resolve(); - await handle.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - const response = await handle.fetch("/state"); - expect(response.ok).toBe(true); - expect(await response.json()).toEqual({ - key: actorKey, - value: 0, - }); - }); - - test("stale getOrCreate handle retries raw WebSocket after actor destruction", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-handle-websocket-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const handle = client.destroyActor.getOrCreate([actorKey]); - await handle.setValue(89); - - const originalActorId = await handle.resolve(); - await handle.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - const websocket = await handle.webSocket(); - const welcome = await new Promise<{ - type: string; - key: string; - value: number; - }>((resolve, reject) => { - websocket.addEventListener( - "message", - (event: MessageEvent) => { - resolve(JSON.parse(event.data)); - }, - { once: true }, - ); - websocket.addEventListener("close", reject, { once: true }); - }); - expect(welcome).toEqual({ - type: "welcome", - key: actorKey, - value: 0, - }); - websocket.close(); - }); - - test("stale getOrCreate connection re-resolves after websocket open failure", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-handle-connect-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const handle = client.destroyActor.getOrCreate([actorKey]); - await handle.setValue(144); - - const originalActorId = await handle.resolve(); - await handle.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - const connection = handle.connect(); - expect(await connection.getValue()).toBe(0); - await connection.dispose(); - }); - - test("stale get handle retries action after actor recreation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = `test-lazy-get-handle-action-${crypto.randomUUID()}`; - - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const creator = client.destroyActor.getOrCreate([actorKey]); - await creator.setValue(222); - - const handle = client.destroyActor.get([actorKey]); - expect(await handle.getValue()).toBe(222); - - const originalActorId = await creator.resolve(); - await creator.destroy(); - await waitForActorDestroyed(client, actorKey, originalActorId); - - const recreated = client.destroyActor.getOrCreate([actorKey]); - expect(await recreated.getValue()).toBe(0); - expect(await handle.getValue()).toBe(0); - expect(await handle.resolve()).toBe(await recreated.resolve()); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-driver.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-driver.ts deleted file mode 100644 index e32d26d5b7..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-driver.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { runActorLifecycleTests } from "./actor-lifecycle"; -import { runActorScheduleTests } from "./actor-schedule"; -import { runActorSleepTests } from "./actor-sleep"; -import { runActorSleepDbTests } from "./actor-sleep-db"; -import { runActorStateTests } from "./actor-state"; - -export function runActorDriverTests(driverTestConfig: DriverTestConfig) { - describe("Actor Driver Tests", () => { - // Run state persistence tests - runActorStateTests(driverTestConfig); - - // Run scheduled alarms tests - runActorScheduleTests(driverTestConfig); - - // Run actor sleep tests - runActorSleepTests(driverTestConfig); - - // Run actor sleep + database tests - runActorSleepDbTests(driverTestConfig); - - // Run actor lifecycle tests - runActorLifecycleTests(driverTestConfig); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-error-handling.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-error-handling.ts deleted file mode 100644 index 1c395d4aae..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-error-handling.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - INTERNAL_ERROR_CODE, - INTERNAL_ERROR_DESCRIPTION, -} from "@/actor/errors"; -import { assertUnreachable } from "@/actor/utils"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorErrorHandlingTests(driverTestConfig: DriverTestConfig) { - describe("Actor Error Handling Tests", () => { - describe("UserError Handling", () => { - test("should handle simple UserError with message", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Try to call an action that throws a simple UserError - const handle = client.errorHandlingActor.getOrCreate(); - - try { - await handle.throwSimpleError(); - // If we get here, the test should fail - expect(true).toBe(false); // This should not be reached - } catch (error: any) { - // Verify the error properties - expect(error.message).toBe("Simple error message"); - // Default code is "user_error" when not specified - expect(error.code).toBe("user_error"); - // No metadata by default - expect(error.metadata).toBeUndefined(); - } - }); - - test("should handle detailed UserError with code and metadata", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Try to call an action that throws a detailed UserError - const handle = client.errorHandlingActor.getOrCreate(); - - try { - await handle.throwDetailedError(); - // If we get here, the test should fail - expect(true).toBe(false); // This should not be reached - } catch (error: any) { - // Verify the error properties - expect(error.message).toBe("Detailed error message"); - expect(error.code).toBe("detailed_error"); - expect(error.metadata).toBeDefined(); - expect(error.metadata.reason).toBe("test"); - expect(error.metadata.timestamp).toBeDefined(); - } - }); - }); - - describe("Internal Error Handling", () => { - test("should convert internal errors to safe format", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Try to call an action that throws an internal error - const handle = client.errorHandlingActor.getOrCreate(); - - try { - await handle.throwInternalError(); - // If we get here, the test should fail - expect(true).toBe(false); // This should not be reached - } catch (error: any) { - // Verify the error is converted to a safe format - expect(error.code).toBe(INTERNAL_ERROR_CODE); - // Original error details should not be exposed - expect(error.message).toBe(INTERNAL_ERROR_DESCRIPTION); - } - }); - }); - - // TODO: Does not work with fake timers - describe.skip("Action Timeout", () => { - test("should handle action timeouts with custom duration", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Call an action that should time out - const handle = client.errorHandlingActor.getOrCreate(); - - // This should throw a timeout error because errorHandlingActor has - // a 500ms timeout and this action tries to run for much longer - const timeoutPromise = handle.timeoutAction(); - - try { - await timeoutPromise; - // If we get here, the test failed - timeout didn't occur - expect(true).toBe(false); // This should not be reached - } catch (error: any) { - // Verify it's a timeout error - expect(error.message).toMatch(/timed out/i); - } - }); - - test("should successfully run actions within timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Call an action with a delay shorter than the timeout - const handle = client.errorHandlingActor.getOrCreate(); - - // This should succeed because 200ms < 500ms timeout - const result = await handle.delayedAction(200); - expect(result).toBe("Completed after 200ms"); - }); - - test("should respect different timeouts for different actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // The following actors have different timeout settings: - // customTimeoutActor: 200ms timeout - // standardTimeoutActor: default timeout (much longer) - - // This should fail - 300ms delay with 200ms timeout - try { - await client.customTimeoutActor.getOrCreate().slowAction(); - // Should not reach here - expect(true).toBe(false); - } catch (error: any) { - expect(error.message).toMatch(/timed out/i); - } - - // This should succeed - 50ms delay with 200ms timeout - const quickResult = await client.customTimeoutActor - .getOrCreate() - .quickAction(); - expect(quickResult).toBe("Quick action completed"); - }); - }); - - describe("Error Recovery", () => { - test("should continue working after errors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.errorHandlingActor.getOrCreate(); - - // Trigger an error - try { - await handle.throwSimpleError(); - } catch (error) { - // Ignore error - } - - // Actor should still work after error - const result = await handle.successfulAction(); - expect(result).toBe("success"); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-handle.ts deleted file mode 100644 index dc5582e7ff..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-handle.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ActorError } from "@/client/mod"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorHandleTests(driverTestConfig: DriverTestConfig) { - describe("Actor Handle Tests", () => { - describe("Access Methods", () => { - test("should use .get() to access a actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor first - await client.counter.create(["test-get-handle"]); - - // Access using get - const handle = client.counter.get(["test-get-handle"]); - - // Verify Action works - const count = await handle.increment(5); - expect(count).toBe(5); - - const retrievedCount = await handle.getCount(); - expect(retrievedCount).toBe(5); - }); - - test("should use .getForId() to access a actor by ID", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a actor first to get its ID - const handle = client.counter.getOrCreate([ - "test-get-for-id-handle", - ]); - await handle.increment(3); - const actorId = await handle.resolve(); - - // Access using getForId - const idHandle = client.counter.getForId(actorId); - - // Verify Action works and state is preserved - const count = await idHandle.getCount(); - expect(count).toBe(3); - - const newCount = await idHandle.increment(4); - expect(newCount).toBe(7); - }); - - test("should use .getOrCreate() to access or create a actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Access using getOrCreate - should create the actor - const handle = client.counter.getOrCreate([ - "test-get-or-create-handle", - ]); - - // Verify Action works - const count = await handle.increment(7); - expect(count).toBe(7); - - // Get the same actor again - should retrieve existing actor - const sameHandle = client.counter.getOrCreate([ - "test-get-or-create-handle", - ]); - const retrievedCount = await sameHandle.getCount(); - expect(retrievedCount).toBe(7); - }); - - test("should use (await create()) to create and return a handle", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and get handle - const handle = await client.counter.create([ - "test-create-handle", - ]); - - // Verify Action works - const count = await handle.increment(9); - expect(count).toBe(9); - - const retrievedCount = await handle.getCount(); - expect(retrievedCount).toBe(9); - }); - - test("errors when calling create twice with the same key", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const key = ["duplicate-create-handle", crypto.randomUUID()]; - - // First create should succeed - await client.counter.create(key); - - // Second create with same key should throw ActorAlreadyExists - try { - await client.counter.create(key); - expect.fail("did not error on duplicate create"); - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("duplicate_key"); - } - }); - - test(".get().resolve() errors for non-existent actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const missingId = `nonexistent-${crypto.randomUUID()}`; - - try { - await client.counter.get([missingId]).resolve(); - expect.fail( - "did not error for get().resolve() on missing actor", - ); - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - }); - }); - - describe("Action Functionality", () => { - test("should call actions directly on the handle", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.counter.getOrCreate([ - "test-action-handle", - ]); - - // Call multiple actions in sequence - const count1 = await handle.increment(3); - expect(count1).toBe(3); - - const count2 = await handle.increment(5); - expect(count2).toBe(8); - - const retrievedCount = await handle.getCount(); - expect(retrievedCount).toBe(8); - }); - - test("should handle independent handles to the same actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two handles to the same actor - const handle1 = client.counter.getOrCreate([ - "test-multiple-handles", - ]); - const handle2 = client.counter.get(["test-multiple-handles"]); - - // Call actions on both handles - await handle1.increment(3); - const count = await handle2.getCount(); - - // Verify both handles access the same state - expect(count).toBe(3); - - const finalCount = await handle2.increment(4); - expect(finalCount).toBe(7); - - const checkCount = await handle1.getCount(); - expect(checkCount).toBe(7); - }); - - test("should resolve a actor's ID", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const handle = client.counter.getOrCreate(["test-resolve-id"]); - - // Call an action to ensure actor exists - await handle.increment(1); - - // Resolve the ID - const actorId = await handle.resolve(); - - // Verify we got a valid ID (string) - expect(typeof actorId).toBe("string"); - expect(actorId).not.toBe(""); - - // Verify we can use this ID to get the actor - const idHandle = client.counter.getForId(actorId); - const count = await idHandle.getCount(); - expect(count).toBe(1); - }); - }); - - describe("Lifecycle Hooks", () => { - test("should trigger lifecycle hooks on actor creation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Get or create a new actor - this should trigger onWake - const handle = client.counterWithLifecycle.getOrCreate([ - "test-lifecycle-handle", - ]); - - // Verify onWake was triggered - const initialEvents = await handle.getEvents(); - expect(initialEvents).toContain("onWake"); - - // Create a separate handle to the same actor - const sameHandle = client.counterWithLifecycle.getOrCreate([ - "test-lifecycle-handle", - ]); - - // Verify events still include onWake but don't duplicate it - // (onWake should only be called once when the actor is first created) - const events = await sameHandle.getEvents(); - expect(events).toContain("onWake"); - expect( - events.filter((e: string) => e === "onWake").length, - ).toBe(1); - }); - - test("should trigger lifecycle hooks for each Action call", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a normal handle to view events - const viewHandle = client.counterWithLifecycle.getOrCreate([ - "test-lifecycle-action", - ]); - - // Initial state should only have onWake - const initialEvents = await viewHandle.getEvents(); - expect(initialEvents).toContain("onWake"); - expect(initialEvents).not.toContain("onBeforeConnect"); - expect(initialEvents).not.toContain("onConnect"); - expect(initialEvents).not.toContain("onDisconnect"); - - // Create a handle with trackLifecycle enabled for testing Action calls - const trackingHandle = client.counterWithLifecycle.getOrCreate( - ["test-lifecycle-action"], - { params: { trackLifecycle: true } }, - ); - - // Make an Action call - await trackingHandle.increment(5); - - // Check that it triggered the lifecycle hooks - const eventsAfterAction = await viewHandle.getEvents(); - - // Should have onBeforeConnect, onConnect, and onDisconnect for the Action call - expect(eventsAfterAction).toContain("onBeforeConnect"); - expect(eventsAfterAction).toContain("onConnect"); - expect(eventsAfterAction).toContain("onDisconnect"); - - // Each should have count 1 - expect( - eventsAfterAction.filter( - (e: string) => e === "onBeforeConnect", - ).length, - ).toBe(1); - expect( - eventsAfterAction.filter((e: string) => e === "onConnect") - .length, - ).toBe(1); - expect( - eventsAfterAction.filter( - (e: string) => e === "onDisconnect", - ).length, - ).toBe(1); - - // Make another Action call - await trackingHandle.increment(10); - - // Check that it triggered another set of lifecycle hooks - const eventsAfterSecondAction = await viewHandle.getEvents(); - - // Each hook should now have count 2 - expect( - eventsAfterSecondAction.filter( - (e: string) => e === "onBeforeConnect", - ).length, - ).toBe(2); - expect( - eventsAfterSecondAction.filter( - (e: string) => e === "onConnect", - ).length, - ).toBe(2); - expect( - eventsAfterSecondAction.filter( - (e: string) => e === "onDisconnect", - ).length, - ).toBe(2); - }); - - test("should trigger lifecycle hooks for each Action call across multiple handles", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a normal handle to view events - const viewHandle = client.counterWithLifecycle.getOrCreate([ - "test-lifecycle-multi-handle", - ]); - - // Create two tracking handles to the same actor - const trackingHandle1 = client.counterWithLifecycle.getOrCreate( - ["test-lifecycle-multi-handle"], - { params: { trackLifecycle: true } }, - ); - - const trackingHandle2 = client.counterWithLifecycle.getOrCreate( - ["test-lifecycle-multi-handle"], - { params: { trackLifecycle: true } }, - ); - - // Make Action calls on both handles - await trackingHandle1.increment(5); - await trackingHandle2.increment(10); - - // Check lifecycle hooks - const events = await viewHandle.getEvents(); - - // Should have 1 onWake, 2 each of onBeforeConnect, onConnect, and onDisconnect - expect( - events.filter((e: string) => e === "onWake").length, - ).toBe(1); - expect( - events.filter((e: string) => e === "onBeforeConnect") - .length, - ).toBe(2); - expect( - events.filter((e: string) => e === "onConnect").length, - ).toBe(2); - expect( - events.filter((e: string) => e === "onDisconnect").length, - ).toBe(2); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inline-client.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inline-client.ts deleted file mode 100644 index 9b70db537a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inline-client.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorInlineClientTests(driverTestConfig: DriverTestConfig) { - describe("Actor Inline Client Tests", () => { - describe("Stateless Client Calls", () => { - test("should make stateless calls to other actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create the inline client actor - const inlineClientHandle = client.inlineClientActor.getOrCreate( - ["inline-client-test"], - ); - - // Test calling counter.increment via inline client - const result = await inlineClientHandle.callCounterIncrement(5); - expect(result).toBe(5); - - // Verify the counter state was actually updated - const counterState = await inlineClientHandle.getCounterState(); - expect(counterState).toBe(5); - - // Check that messages were logged - const messages = await inlineClientHandle.getMessages(); - expect(messages).toHaveLength(2); - expect(messages[0]).toContain( - "Called counter.increment(5), result: 5", - ); - expect(messages[1]).toContain("Got counter state: 5"); - }); - - test("should handle multiple stateless calls", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create the inline client actor - const inlineClientHandle = client.inlineClientActor.getOrCreate( - ["inline-client-multi"], - ); - - // Clear any existing messages - await inlineClientHandle.clearMessages(); - - // Make multiple calls - const result1 = - await inlineClientHandle.callCounterIncrement(3); - const result2 = - await inlineClientHandle.callCounterIncrement(7); - const finalState = await inlineClientHandle.getCounterState(); - - expect(result1).toBe(3); - expect(result2).toBe(10); // 3 + 7 - expect(finalState).toBe(10); - - // Check messages - const messages = await inlineClientHandle.getMessages(); - expect(messages).toHaveLength(3); - expect(messages[0]).toContain( - "Called counter.increment(3), result: 3", - ); - expect(messages[1]).toContain( - "Called counter.increment(7), result: 10", - ); - expect(messages[2]).toContain("Got counter state: 10"); - }); - }); - - describe("Stateful Client Calls", () => { - test("should connect to other actors and receive events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create the inline client actor - const inlineClientHandle = client.inlineClientActor.getOrCreate( - ["inline-client-stateful"], - ); - - // Clear any existing messages - await inlineClientHandle.clearMessages(); - - // Test stateful connection with events - const result = - await inlineClientHandle.connectToCounterAndIncrement(4); - - expect(result.result1).toBe(4); - expect(result.result2).toBe(12); // 4 + 8 - expect(result.events).toEqual([4, 12]); // Should have received both events - - // Check that message was logged - const messages = await inlineClientHandle.getMessages(); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain( - "Connected to counter, incremented by 4 and 8", - ); - expect(messages[0]).toContain("results: 4, 12"); - expect(messages[0]).toContain("events: [4,12]"); - }); - - test("should handle stateful connection independently", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create the inline client actor - const inlineClientHandle = client.inlineClientActor.getOrCreate( - ["inline-client-independent"], - ); - - // Clear any existing messages - await inlineClientHandle.clearMessages(); - - // Test with different increment values - const result = - await inlineClientHandle.connectToCounterAndIncrement(2); - - expect(result.result1).toBe(2); - expect(result.result2).toBe(6); // 2 + 4 - expect(result.events).toEqual([2, 6]); - - // Verify the state is independent from previous tests - const messages = await inlineClientHandle.getMessages(); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain( - "Connected to counter, incremented by 2 and 4", - ); - }); - }); - - describe("Mixed Client Usage", () => { - test("should handle both stateless and stateful calls", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create the inline client actor - const inlineClientHandle = client.inlineClientActor.getOrCreate( - ["inline-client-mixed"], - ); - - // Clear any existing messages - await inlineClientHandle.clearMessages(); - - // Start with stateless calls - await inlineClientHandle.callCounterIncrement(1); - const statelessResult = - await inlineClientHandle.getCounterState(); - expect(statelessResult).toBe(1); - - // Then do stateful call - const statefulResult = - await inlineClientHandle.connectToCounterAndIncrement(3); - expect(statefulResult.result1).toBe(3); - expect(statefulResult.result2).toBe(9); // 3 + 6 - - // Check all messages were logged - const messages = await inlineClientHandle.getMessages(); - expect(messages).toHaveLength(3); - expect(messages[0]).toContain( - "Called counter.increment(1), result: 1", - ); - expect(messages[1]).toContain("Got counter state: 1"); - expect(messages[2]).toContain( - "Connected to counter, incremented by 3 and 6", - ); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inspector.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inspector.ts deleted file mode 100644 index 793ee88198..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inspector.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorInspectorTests(driverTestConfig: DriverTestConfig) { - describe("Actor Inspector HTTP API", () => { - test("GET /inspector/state returns actor state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-state"]); - - // Set some state first - await handle.increment(5); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch(`${gatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer token" }, - }); - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ - state: { count: 5 }, - isStateEnabled: true, - }); - }); - - test("PATCH /inspector/state updates actor state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-set-state"]); - - await handle.increment(5); - - const gatewayUrl = await handle.getGatewayUrl(); - - // Replace state - const patchResponse = await fetch(`${gatewayUrl}/inspector/state`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({ state: { count: 42 } }), - }); - expect(patchResponse.status).toBe(200); - const patchData = await patchResponse.json(); - expect(patchData).toEqual({ ok: true }); - - // Verify via action - const count = await handle.getCount(); - expect(count).toBe(42); - }); - - test("GET /inspector/connections returns connections list", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate([ - "inspector-connections", - ]); - - // Ensure actor exists - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/connections`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - connections: unknown[]; - }; - expect(data).toHaveProperty("connections"); - expect(Array.isArray(data.connections)).toBe(true); - }); - - test("GET /inspector/rpcs returns available actions", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-rpcs"]); - - // Ensure actor exists - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch(`${gatewayUrl}/inspector/rpcs`, { - headers: { Authorization: "Bearer token" }, - }); - expect(response.status).toBe(200); - const data = (await response.json()) as { rpcs: string[] }; - expect(data).toHaveProperty("rpcs"); - expect(data.rpcs).toContain("increment"); - expect(data.rpcs).toContain("getCount"); - expect(data.rpcs).toContain("setCount"); - }); - - test("POST /inspector/action/:name executes an action", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-action"]); - - await handle.increment(10); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/action/increment`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({ args: [5] }), - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { output: number }; - expect(data.output).toBe(15); - - // Verify via normal action - const count = await handle.getCount(); - expect(count).toBe(15); - }); - - test("GET /inspector/queue returns queue status", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-queue"]); - - // Ensure actor exists - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/queue?limit=10`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - size: number; - maxSize: number; - truncated: boolean; - messages: unknown[]; - }; - expect(data).toHaveProperty("size"); - expect(data).toHaveProperty("maxSize"); - expect(data).toHaveProperty("truncated"); - expect(data).toHaveProperty("messages"); - expect(typeof data.size).toBe("number"); - expect(typeof data.maxSize).toBe("number"); - expect(typeof data.truncated).toBe("boolean"); - expect(Array.isArray(data.messages)).toBe(true); - }); - - test("GET /inspector/traces returns trace data", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-traces"]); - - // Perform an action to generate traces - await handle.increment(1); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/traces?startMs=0&endMs=${Date.now() + 60000}&limit=100`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - otlp: unknown; - clamped: boolean; - }; - expect(data).toHaveProperty("otlp"); - expect(data).toHaveProperty("clamped"); - expect(typeof data.clamped).toBe("boolean"); - }); - - test("GET /inspector/workflow-history returns workflow status", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-workflow"]); - - // Ensure actor exists - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/workflow-history`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - history: unknown; - isWorkflowEnabled: boolean; - }; - expect(data).toHaveProperty("history"); - expect(data).toHaveProperty("isWorkflowEnabled"); - // Counter actor has no workflow, so it should be disabled - expect(data.isWorkflowEnabled).toBe(false); - expect(data.history).toBeNull(); - }); - - test("GET /inspector/database/schema returns SQLite schema", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.dbActorRaw.getOrCreate([ - `inspector-database-schema-${crypto.randomUUID()}`, - ]); - - await handle.insertValue("Alice"); - await handle.insertValue("Bob"); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/database/schema`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - schema: { - tables: Array<{ - table: { schema: string; name: string; type: string }; - columns: Array<{ name: string }>; - records: number; - }>; - }; - }; - - expect(Array.isArray(data.schema.tables)).toBe(true); - const testDataTable = data.schema.tables.find( - (table) => table.table.name === "test_data", - ); - expect(testDataTable).toBeDefined(); - expect(testDataTable?.table.schema).toBe("main"); - expect(testDataTable?.table.type).toBe("table"); - expect(testDataTable?.records).toBe(2); - expect(testDataTable?.columns.map((column) => column.name)).toEqual( - ["id", "value", "payload", "created_at"], - ); - }); - - test("GET /inspector/workflow-history returns populated history for active workflows", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.workflowCounterActor.getOrCreate([ - "inspector-workflow-active", - ]); - - let state = await handle.getState(); - for ( - let i = 0; - i < 40 && (state.runCount === 0 || state.history.length === 0); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await handle.getState(); - } - - expect(state.runCount).toBeGreaterThan(0); - expect(state.history.length).toBeGreaterThan(0); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/workflow-history`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - history: { - nameRegistry: string[]; - entries: unknown[]; - entryMetadata: Record; - } | null; - isWorkflowEnabled: boolean; - }; - expect(data.isWorkflowEnabled).toBe(true); - expect(data.history).not.toBeNull(); - expect(data.history?.nameRegistry.length).toBeGreaterThan(0); - expect(data.history?.entries.length).toBeGreaterThan(0); - expect( - Object.keys(data.history?.entryMetadata ?? {}).length, - ).toBeGreaterThan(0); - }); - - test("POST /inspector/workflow/replay replays a workflow from the beginning", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.workflowReplayActor.getOrCreate([ - "inspector-workflow-replay", - crypto.randomUUID(), - ]); - - await vi.waitFor(async () => { - expect(await handle.getTimeline()).toEqual(["one", "two"]); - }); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/workflow/replay`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as { - history: { - nameRegistry: string[]; - entries: unknown[]; - entryMetadata: Record; - } | null; - isWorkflowEnabled: boolean; - }; - expect(data.isWorkflowEnabled).toBe(true); - expect(data.history).not.toBeNull(); - - await vi.waitFor(async () => { - expect(await handle.getTimeline()).toEqual([ - "one", - "two", - "one", - "two", - ]); - }); - }); - - test("POST /inspector/database/execute runs read-only queries", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.dbActorRaw.getOrCreate([ - "inspector-database-select", - ]); - - await handle.reset(); - await handle.insertValue("alpha"); - await handle.insertValue("beta"); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/database/execute`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({ - sql: "SELECT value FROM test_data ORDER BY id", - }), - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - rows: Array<{ value: string }>; - }; - expect(data.rows).toEqual([ - { value: "alpha" }, - { value: "beta" }, - ]); - }); - - test("GET /inspector/database/rows returns SQLite rows", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.dbActorRaw.getOrCreate([ - `inspector-database-rows-${crypto.randomUUID()}`, - ]); - - await handle.insertValue("Alice"); - await handle.insertValue("Bob"); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/database/rows?table=test_data&limit=1&offset=1`, - { - headers: { Authorization: "Bearer token" }, - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - rows: Array<{ - id: number; - value: string; - payload: string; - created_at: number; - }>; - }; - - expect(data.rows).toHaveLength(1); - expect(data.rows[0]?.id).toBe(2); - expect(data.rows[0]?.value).toBe("Bob"); - expect(data.rows[0]?.payload).toBe(""); - expect(typeof data.rows[0]?.created_at).toBe("number"); - }); - - test("POST /inspector/database/execute supports named properties", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.dbActorRaw.getOrCreate([ - "inspector-database-properties", - ]); - - await handle.reset(); - await handle.insertValue("alpha"); - await handle.insertValue("beta"); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/database/execute`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({ - sql: "SELECT value FROM test_data WHERE value = :value", - properties: { value: "beta" }, - }), - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - rows: Array<{ value: string }>; - }; - expect(data.rows).toEqual([{ value: "beta" }]); - }); - - test("POST /inspector/workflow/replay rejects workflows that are already in flight", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.workflowRunningStepActor.getOrCreate([ - "inspector-workflow-replay-in-flight", - crypto.randomUUID(), - ]); - - await vi.waitFor(async () => { - const state = await handle.getState(); - expect(state.startedAt).not.toBeNull(); - }); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/workflow/replay`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({}), - }, - ); - expect(response.status).toBe(500); - const data = (await response.json()) as { code: string }; - expect(data.code).toBe("internal_error"); - }); - - test("POST /inspector/database/execute runs mutations", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.dbActorRaw.getOrCreate([ - "inspector-database-mutation", - ]); - - await handle.reset(); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch( - `${gatewayUrl}/inspector/database/execute`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: JSON.stringify({ - sql: "INSERT INTO test_data (value, payload, created_at) VALUES (?, '', ?)", - args: ["from-inspector", Date.now()], - }), - }, - ); - expect(response.status).toBe(200); - const data = (await response.json()) as { - rows: unknown[]; - }; - expect(data.rows).toEqual([]); - expect(await handle.getCount()).toBe(1); - const values = await handle.getValues(); - expect(values.at(-1)?.value).toBe("from-inspector"); - }); - - test("GET /inspector/summary returns full actor snapshot", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-summary"]); - - await handle.increment(7); - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch(`${gatewayUrl}/inspector/summary`, { - headers: { Authorization: "Bearer token" }, - }); - expect(response.status).toBe(200); - const data = (await response.json()) as { - state: { count: number }; - connections: unknown[]; - rpcs: string[]; - queueSize: number; - isStateEnabled: boolean; - isDatabaseEnabled: boolean; - isWorkflowEnabled: boolean; - workflowHistory: unknown; - }; - expect(data.state).toEqual({ count: 7 }); - expect(Array.isArray(data.connections)).toBe(true); - expect(data.rpcs).toContain("increment"); - expect(typeof data.queueSize).toBe("number"); - expect(data.isStateEnabled).toBe(true); - expect(typeof data.isDatabaseEnabled).toBe("boolean"); - expect(data.isWorkflowEnabled).toBe(false); - expect(data.workflowHistory).toBeNull(); - }); - - test("GET /inspector/summary returns populated workflow history for active workflows", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.workflowCounterActor.getOrCreate([ - "inspector-summary-workflow", - ]); - - let state = await handle.getState(); - for ( - let i = 0; - i < 40 && (state.runCount === 0 || state.history.length === 0); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await handle.getState(); - } - - const gatewayUrl = await handle.getGatewayUrl(); - const response = await fetch(`${gatewayUrl}/inspector/summary`, { - headers: { Authorization: "Bearer token" }, - }); - expect(response.status).toBe(200); - const data = (await response.json()) as { - isWorkflowEnabled: boolean; - workflowHistory: { - nameRegistry: string[]; - entries: unknown[]; - entryMetadata: Record; - } | null; - }; - expect(data.isWorkflowEnabled).toBe(true); - expect(data.workflowHistory).not.toBeNull(); - expect(data.workflowHistory?.nameRegistry.length).toBeGreaterThan( - 0, - ); - expect(data.workflowHistory?.entries.length).toBeGreaterThan(0); - expect( - Object.keys(data.workflowHistory?.entryMetadata ?? {}).length, - ).toBeGreaterThan(0); - }); - - test("inspector endpoints require auth in non-dev mode", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-auth"]); - - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - - // Request with wrong token should fail - const response = await fetch(`${gatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer wrong-token" }, - }); - expect(response.status).toBe(401); - }); - - test("GET /inspector/metrics returns startup metrics", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["inspector-metrics"]); - - // Ensure actor exists - await handle.increment(0); - - const gatewayUrl = await handle.getGatewayUrl(); - - const response = await fetch(`${gatewayUrl}/inspector/metrics`, { - headers: { Authorization: "Bearer token" }, - }); - expect(response.status).toBe(200); - const data: any = await response.json(); - - // Verify startup metrics are present and have reasonable values - expect(data.startup_total_ms).toBeDefined(); - expect(data.startup_total_ms.type).toBe("gauge"); - expect(data.startup_total_ms.value).toBeGreaterThan(0); - - expect(data.startup_kv_round_trips).toBeDefined(); - expect(data.startup_kv_round_trips.type).toBe("gauge"); - expect(data.startup_kv_round_trips.value).toBeGreaterThanOrEqual(0); - - expect(data.startup_is_new).toBeDefined(); - expect(data.startup_is_new.type).toBe("gauge"); - - // Verify internal metrics exist - expect(data.startup_internal_load_state_ms).toBeDefined(); - expect( - data.startup_internal_load_state_ms.value, - ).toBeGreaterThanOrEqual(0); - expect(data.startup_internal_init_queue_ms).toBeDefined(); - expect(data.startup_internal_init_inspector_token_ms).toBeDefined(); - - // Verify user metrics exist - expect(data.startup_user_create_vars_ms).toBeDefined(); - expect(data.startup_user_on_wake_ms).toBeDefined(); - expect(data.startup_user_create_state_ms).toBeDefined(); - - // Verify existing KV metrics still present - expect(data.kv_operations).toBeDefined(); - expect(data.kv_operations.type).toBe("labeled_timing"); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-kv.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-kv.ts deleted file mode 100644 index 0366ed41b5..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-kv.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; -import { describe, expect, test, type TestContext } from "vitest"; - -export function runActorKvTests(driverTestConfig: DriverTestConfig) { - type KvTextHandle = { - putText: (key: string, value: string) => Promise; - getText: (key: string) => Promise; - listText: ( - prefix: string, - ) => Promise>; - listTextRange: ( - start: string, - end: string, - options?: { - reverse?: boolean; - limit?: number; - }, - ) => Promise>; - deleteTextRange: (start: string, end: string) => Promise; - }; - - type KvArrayBufferHandle = { - roundtripArrayBuffer: ( - key: string, - bytes: number[], - ) => Promise; - }; - - describe("Actor KV Tests", () => { - test("supports text encoding and decoding", async (c: TestContext) => { - const { client: rawClient } = await setupDriverTest( - c, - driverTestConfig, - ); - const client = rawClient as any; - const kvHandle = client.kvActor.getOrCreate([ - "kv-text", - ]) as unknown as KvTextHandle; - - await kvHandle.putText("greeting", "hello"); - const value = await kvHandle.getText("greeting"); - expect(value).toBe("hello"); - - await kvHandle.putText("prefix-a", "alpha"); - await kvHandle.putText("prefix-b", "beta"); - - const results = await kvHandle.listText("prefix-"); - const sorted = results.sort((a, b) => a.key.localeCompare(b.key)); - expect(sorted).toEqual([ - { key: "prefix-a", value: "alpha" }, - { key: "prefix-b", value: "beta" }, - ]); - }); - - test("supports range scans and range deletes", async (c: TestContext) => { - const { client: rawClient } = await setupDriverTest( - c, - driverTestConfig, - ); - const client = rawClient as any; - const kvHandle = client.kvActor.getOrCreate([ - "kv-range", - ]) as unknown as KvTextHandle; - - await kvHandle.putText("a", "alpha"); - await kvHandle.putText("b", "bravo"); - await kvHandle.putText("c", "charlie"); - await kvHandle.putText("d", "delta"); - - const range = await kvHandle.listTextRange("b", "d"); - expect(range).toEqual([ - { key: "b", value: "bravo" }, - { key: "c", value: "charlie" }, - ]); - - const reversed = await kvHandle.listTextRange("a", "d", { - reverse: true, - limit: 2, - }); - expect(reversed).toEqual([ - { key: "c", value: "charlie" }, - { key: "b", value: "bravo" }, - ]); - - await kvHandle.deleteTextRange("b", "d"); - const remaining = await kvHandle.listText(""); - expect(remaining).toEqual([ - { key: "a", value: "alpha" }, - { key: "d", value: "delta" }, - ]); - }); - - test("supports arrayBuffer encoding and decoding", async (c: TestContext) => { - const { client: rawClient } = await setupDriverTest( - c, - driverTestConfig, - ); - const client = rawClient as any; - const kvHandle = client.kvActor.getOrCreate([ - "kv-array-buffer", - ]) as unknown as KvArrayBufferHandle; - - const values = await kvHandle.roundtripArrayBuffer( - "bytes", - [4, 8, 15, 16, 23, 42], - ); - expect(values).toEqual([4, 8, 15, 16, 23, 42]); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-lifecycle.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-lifecycle.ts deleted file mode 100644 index e7bf4e9020..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-lifecycle.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorLifecycleTests(driverTestConfig: DriverTestConfig) { - describe("Actor Lifecycle Tests", () => { - test("actor stop during start waits for start to complete", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-stop-during-start-${Date.now()}`; - - // Create actor - this starts the actor - const actor = client.startStopRaceActor.getOrCreate([actorKey]); - - // Immediately try to call an action and then destroy - // This creates a race where the actor might not be fully started yet - const pingPromise = actor.ping(); - - // Get actor ID - const actorId = await actor.resolve(); - - // Destroy immediately while start might still be in progress - await actor.destroy(); - - // The ping should still complete successfully because destroy waits for start - const result = await pingPromise; - expect(result).toBe("pong"); - - // Verify actor was actually destroyed - let destroyed = false; - try { - await client.startStopRaceActor.getForId(actorId).ping(); - } catch (err: any) { - destroyed = true; - expect(err.group).toBe("actor"); - expect(err.code).toBe("not_found"); - } - expect(destroyed).toBe(true); - }); - - test("actor stop before actor instantiation completes cleans up handler", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-stop-before-instantiation-${Date.now()}`; - - // Create multiple actors rapidly to increase chance of race - const actors = Array.from({ length: 5 }, (_, i) => - client.startStopRaceActor.getOrCreate([`${actorKey}-${i}`]), - ); - - // Resolve all actor IDs (this triggers start) - const ids = await Promise.all(actors.map((a) => a.resolve())); - - // Immediately destroy all actors - await Promise.all(actors.map((a) => a.destroy())); - - // Verify all actors were cleaned up - for (const id of ids) { - let destroyed = false; - try { - await client.startStopRaceActor.getForId(id).ping(); - } catch (err: any) { - destroyed = true; - expect(err.group).toBe("actor"); - expect(err.code).toBe("not_found"); - } - expect(destroyed, `actor ${id} should be destroyed`).toBe(true); - } - }); - - test("onBeforeActorStart completes before stop proceeds", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-before-actor-start-${Date.now()}`; - - // Create actor - const actor = client.startStopRaceActor.getOrCreate([actorKey]); - - // Call action to ensure actor is starting - const statePromise = actor.getState(); - - // Destroy immediately - await actor.destroy(); - - // State should be initialized because onBeforeActorStart must complete - const state = await statePromise; - expect(state.initialized).toBe(true); - expect(state.startCompleted).toBe(true); - }); - - test("multiple rapid create/destroy cycles handle race correctly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Perform multiple rapid create/destroy cycles - for (let i = 0; i < 10; i++) { - const actorKey = `test-rapid-cycle-${Date.now()}-${i}`; - const actor = client.startStopRaceActor.getOrCreate([actorKey]); - - // Trigger start - const resolvePromise = actor.resolve(); - - // Immediately destroy - const destroyPromise = actor.destroy(); - - // Both should complete without errors - await Promise.all([resolvePromise, destroyPromise]); - } - - // If we get here without errors, the race condition is handled correctly - expect(true).toBe(true); - }); - - test("actor stop called with no actor instance cleans up handler", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-cleanup-no-instance-${Date.now()}`; - - // Create and immediately destroy - const actor = client.startStopRaceActor.getOrCreate([actorKey]); - const id = await actor.resolve(); - await actor.destroy(); - - // Try to recreate with same key - should work without issues - const newActor = client.startStopRaceActor.getOrCreate([actorKey]); - const result = await newActor.ping(); - expect(result).toBe("pong"); - - // Clean up - await newActor.destroy(); - }); - - test("onDestroy is called even when actor is destroyed during start", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-ondestroy-during-start-${Date.now()}`; - - // Create actor - const actor = client.startStopRaceActor.getOrCreate([actorKey]); - - // Start and immediately destroy - const statePromise = actor.getState(); - await actor.destroy(); - - // Verify onDestroy was called (requires actor to be started) - const state = await statePromise; - expect(state.destroyCalled).toBe(true); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-metadata.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-metadata.ts deleted file mode 100644 index 2f1297156a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-metadata.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorMetadataTests(driverTestConfig: DriverTestConfig) { - describe("Actor Metadata Tests", () => { - describe("Actor Name", () => { - test("should provide access to actor name", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Get the actor name - const handle = client.metadataActor.getOrCreate(); - const actorName = await handle.getActorName(); - - // Verify it matches the expected name - expect(actorName).toBe("metadataActor"); - }); - - test("should preserve actor name in state during onWake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Get the stored actor name - const handle = client.metadataActor.getOrCreate(); - const storedName = await handle.getStoredActorName(); - - // Verify it was stored correctly - expect(storedName).toBe("metadataActor"); - }); - }); - - describe("Actor Tags", () => { - test("should provide access to tags", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and set up test tags - const handle = client.metadataActor.getOrCreate(); - await handle.setupTestTags({ - env: "test", - purpose: "metadata-test", - }); - - // Get the tags - const tags = await handle.getTags(); - - // Verify the tags are accessible - expect(tags).toHaveProperty("env"); - expect(tags.env).toBe("test"); - expect(tags).toHaveProperty("purpose"); - expect(tags.purpose).toBe("metadata-test"); - }); - - test("should allow accessing individual tags", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and set up test tags - const handle = client.metadataActor.getOrCreate(); - await handle.setupTestTags({ - category: "test-actor", - version: "1.0", - }); - - // Get individual tags - const category = await handle.getTag("category"); - const version = await handle.getTag("version"); - const nonexistent = await handle.getTag("nonexistent"); - - // Verify the tag values - expect(category).toBe("test-actor"); - expect(version).toBe("1.0"); - expect(nonexistent).toBeNull(); - }); - }); - - describe("Metadata Structure", () => { - test("should provide complete metadata object", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and set up test metadata - const handle = client.metadataActor.getOrCreate(); - await handle.setupTestTags({ type: "metadata-test" }); - await handle.setupTestRegion("us-west-1"); - - // Get all metadata - const metadata = await handle.getMetadata(); - - // Verify structure of metadata - expect(metadata).toHaveProperty("name"); - expect(metadata.name).toBe("metadataActor"); - - expect(metadata).toHaveProperty("tags"); - expect(metadata.tags).toHaveProperty("type"); - expect(metadata.tags.type).toBe("metadata-test"); - - // Region should be set to our test value - expect(metadata).toHaveProperty("region"); - expect(metadata.region).toBe("us-west-1"); - }); - }); - - describe("Region Information", () => { - test("should retrieve region information", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and set up test region - const handle = client.metadataActor.getOrCreate(); - await handle.setupTestRegion("eu-central-1"); - - // Get the region - const region = await handle.getRegion(); - - // Verify the region is set correctly - expect(region).toBe("eu-central-1"); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-onstatechange.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-onstatechange.ts deleted file mode 100644 index 4020049c5d..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-onstatechange.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "@/driver-test-suite/mod"; -import { setupDriverTest } from "@/driver-test-suite/utils"; - -export function runActorOnStateChangeTests(driverTestConfig: DriverTestConfig) { - describe("Actor onStateChange Tests", () => { - test("triggers onStateChange when state is modified", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.onStateChangeActor.getOrCreate(); - - // Modify state - should trigger onChange - await actor.setValue(10); - - // Check that onChange was called - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(1); - }); - - test("triggers onChange multiple times for multiple state changes", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.onStateChangeActor.getOrCreate(); - - // Modify state multiple times - await actor.incrementMultiple(3); - - // Check that onChange was called for each modification - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(3); - }); - - test("does NOT trigger onChange for read-only actions", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.onStateChangeActor.getOrCreate(); - - // Set initial value - await actor.setValue(5); - - // Read value without modifying - should NOT trigger onChange - const value = await actor.getValue(); - expect(value).toBe(5); - - // Check that onChange was NOT called - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(1); - }); - - test("does NOT trigger onChange for computed values", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.onStateChangeActor.getOrCreate(); - - // Set initial value - await actor.setValue(3); - - // Check that onChange was called - { - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(1); - } - - // Compute value without modifying state - should NOT trigger onChange - const doubled = await actor.getDoubled(); - expect(doubled).toBe(6); - - // Check that onChange was NOT called - { - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(1); - } - }); - - test("simple: connect, call action, dispose does NOT trigger onChange", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.onStateChangeActor.getOrCreate(); - - // Connect to the actor - const connection = await actor.connect(); - - // Call an action that doesn't modify state - const value = await connection.getValue(); - expect(value).toBe(0); - - // Dispose the connection - await connection.dispose(); - - // Verify that onChange was NOT triggered - const changeCount = await actor.getChangeCount(); - expect(changeCount).toBe(0); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-queue.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-queue.ts deleted file mode 100644 index 565f6c8a2e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-queue.ts +++ /dev/null @@ -1,429 +0,0 @@ -// @ts-nocheck -import { describe, expect, test } from "vitest"; -import type { ActorError } from "@/client/mod"; -import { MANY_QUEUE_NAMES } from "../../../fixtures/driver-test-suite/queue"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorQueueTests(driverTestConfig: DriverTestConfig) { - describe("Actor Queue Tests", () => { - async function expectManyQueueChildToDrain( - handle: Awaited< - ReturnType - >["client"]["manyQueueChildActor"], - key: string, - ) { - const child = handle.getOrCreate([key]); - const conn = child.connect(); - const messageCount = MANY_QUEUE_NAMES.length * 4; - - try { - expect(await conn.ping()).toEqual( - expect.objectContaining({ - pong: true, - }), - ); - - await Promise.all( - Array.from({ length: messageCount }, (_, index) => - child.send( - MANY_QUEUE_NAMES[index % MANY_QUEUE_NAMES.length], - { index }, - ), - ), - ); - - let snapshot = await child.getSnapshot(); - for ( - let i = 0; - i < 60 && snapshot.processed.length < messageCount; - i++ - ) { - await waitFor(driverTestConfig, 100); - snapshot = await child.getSnapshot(); - } - - expect(snapshot.started).toBe(true); - expect(snapshot.processed).toHaveLength(messageCount); - expect(new Set(snapshot.processed)).toEqual( - new Set(MANY_QUEUE_NAMES), - ); - - expect( - await child.send( - MANY_QUEUE_NAMES[0], - { index: messageCount }, - { wait: true, timeout: 1_000 }, - ), - ).toEqual({ - status: "completed", - response: { ok: true, index: messageCount }, - }); - } finally { - await conn.dispose().catch(() => undefined); - } - } - - test("client can send to actor queue", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["client-send"]); - - await handle.send("greeting", { hello: "world" }); - - const message = await handle.receiveOne("greeting"); - expect(message).toEqual({ - name: "greeting", - body: { hello: "world" }, - }); - }); - - test("actor can send to its own queue", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["self-send"]); - - await handle.sendToSelf("self", { value: 42 }); - - const message = await handle.receiveOne("self"); - expect(message).toEqual({ name: "self", body: { value: 42 } }); - }); - - test("nextBatch supports name arrays and counts", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["receive-array"]); - - await handle.send("a", 1); - await handle.send("b", 2); - await handle.send("c", 3); - - const messages = await handle.receiveMany(["a", "b"], { count: 2 }); - expect(messages).toEqual([ - { name: "a", body: 1 }, - { name: "b", body: 2 }, - ]); - }); - - test("nextBatch supports request objects", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["receive-request"]); - - await handle.send("one", "first"); - await handle.send("two", "second"); - - const messages = await handle.receiveRequest({ - names: ["one", "two"], - count: 2, - }); - expect(messages).toEqual([ - { name: "one", body: "first" }, - { name: "two", body: "second" }, - ]); - }); - - test("nextBatch defaults to all names when names is omitted", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "receive-request-all", - ]); - - await handle.send("one", "first"); - await handle.send("two", "second"); - - const messages = await handle.receiveRequest({ count: 2 }); - expect(messages).toEqual([ - { name: "one", body: "first" }, - { name: "two", body: "second" }, - ]); - }); - - test("next timeout returns empty array", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["receive-timeout"]); - - const promise = handle.receiveMany(["missing"], { timeout: 50 }); - await waitFor(driverTestConfig, 60); - const messages = await promise; - expect(messages).toEqual([]); - }); - - test("tryNextBatch does not wait and returns empty array", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["try-next-empty"]); - - const messages = await handle.tryReceiveMany({ - names: ["missing"], - count: 1, - }); - expect(messages).toEqual([]); - }); - - test("abort throws ActorAborted", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["abort-test"]); - - try { - await handle.waitForAbort(); - expect.fail("expected ActorAborted error"); - } catch (error) { - expect((error as ActorError).group).toBe("actor"); - expect((error as ActorError).code).toBe("aborted"); - } - }); - - test("next supports signal abort", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["signal-abort-next"]); - - const result = await handle.waitForSignalAbort(); - expect(result).toEqual({ - group: "actor", - code: "aborted", - }); - }); - - test("next supports actor abort when signal is provided", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "actor-abort-with-signal-next", - ]); - - const result = await handle.waitForActorAbortWithSignal(); - expect(result).toEqual({ - group: "actor", - code: "aborted", - }); - }); - - test("iter supports signal abort", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["signal-abort-iter"]); - - const result = await handle.iterWithSignalAbort(); - expect(result).toEqual({ ok: true }); - }); - - test("enforces queue size limit", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const key = `size-limit-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const handle = client.queueLimitedActor.getOrCreate([key]); - - await handle.send("message", 1); - - await waitFor(driverTestConfig, 10); - - try { - await handle.send("message", 2); - expect.fail("expected queue full error"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "Queue is full. Limit is", - ); - if (driverTestConfig.clientType !== "http") { - expect((error as ActorError).group).toBe("queue"); - expect((error as ActorError).code).toBe("full"); - } - } - }); - - test("enforces message size limit", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueLimitedActor.getOrCreate([ - "message-limit", - ]); - const largePayload = "a".repeat(200); - - try { - await handle.send("oversize", largePayload); - expect.fail("expected message_too_large error"); - } catch (error) { - expect((error as ActorError).group).toBe("queue"); - expect((error as ActorError).code).toBe("message_too_large"); - } - }); - - test("wait send returns completion response", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["wait-complete"]); - const waitTimeout = driverTestConfig.useRealTimers ? 5_000 : 1_000; - - const actionPromise = handle.receiveAndComplete("tasks"); - const result = await handle.send( - "tasks", - { value: 123 }, - { wait: true, timeout: waitTimeout }, - ); - - await actionPromise; - expect(result).toEqual({ - status: "completed", - response: { echo: { value: 123 } }, - }); - }); - - test("wait send times out", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["wait-timeout"]); - - const resultPromise = handle.send( - "timeout", - { value: 456 }, - { wait: true, timeout: 50 }, - ); - - await waitFor(driverTestConfig, 60); - const result = await resultPromise; - - expect(result.status).toBe("timedOut"); - }); - - test("drains many-queue child actors created from actions while connected", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const parent = client.manyQueueActionParentActor.getOrCreate([ - "many-action-parent", - ]); - - expect(await parent.spawnChild("many-action-child")).toEqual({ - key: "many-action-child", - }); - - await expectManyQueueChildToDrain( - client.manyQueueChildActor, - "many-action-child", - ); - }); - - test("drains many-queue child actors created from run handlers while connected", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const parent = client.manyQueueRunParentActor.getOrCreate([ - "many-run-parent", - ]); - - expect(await parent.queueSpawn("many-run-child")).toEqual({ - queued: true, - }); - - let spawned = await parent.getSpawned(); - for ( - let i = 0; - i < 30 && !spawned.includes("many-run-child"); - i++ - ) { - await waitFor(driverTestConfig, 100); - spawned = await parent.getSpawned(); - } - - expect(spawned).toContain("many-run-child"); - - await expectManyQueueChildToDrain( - client.manyQueueChildActor, - "many-run-child", - ); - }); - - test("manual receive retries message when not completed", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "manual-retry-uncompleted", - ]); - - await handle.send("tasks", { value: 789 }); - const first = await handle.receiveWithoutComplete("tasks"); - expect(first).toEqual({ name: "tasks", body: { value: 789 } }); - - const retried = await handle.receiveOne("tasks", { - timeout: 1_000, - }); - expect(retried).toEqual({ name: "tasks", body: { value: 789 } }); - }); - - test("next throws when previous manual message is not completed", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "manual-next-requires-complete", - ]); - - await handle.send("tasks", { value: 111 }); - const result = - await handle.receiveManualThenNextWithoutComplete("tasks"); - expect(result).toEqual({ - group: "queue", - code: "previous_message_not_completed", - }); - }); - - test("manual receive includes complete even without completion schema", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "complete-not-allowed", - ]); - - await handle.send("nowait", { value: "test" }); - const result = await handle.receiveWithoutCompleteMethod("nowait"); - - expect(result).toEqual({ - hasComplete: true, - }); - }); - - test("manual receive retries queues without completion schema until completed", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "complete-not-allowed-consume", - ]); - - await handle.send("nowait", { value: "test" }); - const result = await handle.receiveWithoutCompleteMethod("nowait"); - expect(result).toEqual({ hasComplete: true }); - - const next = await handle.receiveOne("nowait", { timeout: 1_000 }); - expect(next).toEqual({ name: "nowait", body: { value: "test" } }); - }); - - test("complete throws when called twice", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["complete-twice"]); - - await handle.send("twice", { value: "test" }); - const result = await handle.receiveAndCompleteTwice("twice"); - - expect(result).toEqual({ - group: "queue", - code: "already_completed", - }); - }); - - test("wait send no longer requires queue completion schema", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "missing-completion-schema", - ]); - - const result = await handle.send( - "nowait", - { value: "test" }, - { wait: true, timeout: 50 }, - ); - expect(result).toEqual({ status: "timedOut" }); - }); - - test("iter can consume queued messages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate(["iter-consume"]); - - await handle.send("one", "first"); - const message = await handle.receiveWithIterator("one"); - expect(message).toEqual({ name: "one", body: "first" }); - }); - - test("queue async iterator can consume queued messages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.queueActor.getOrCreate([ - "async-iter-consume", - ]); - - await handle.send("two", "second"); - const message = await handle.receiveWithAsyncIterator(); - expect(message).toEqual({ name: "two", body: "second" }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-run.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-run.ts deleted file mode 100644 index 50590a57cd..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-run.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { RUN_SLEEP_TIMEOUT } from "../../../fixtures/driver-test-suite/run"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorRunTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.sleep)("Actor Run Tests", () => { - test("run handler starts after actor startup", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithTicks.getOrCreate(["run-starts"]); - - // Wait a bit for run handler to start - await waitFor(driverTestConfig, 100); - - const state = await actor.getState(); - expect(state.runStarted).toBe(true); - expect(state.tickCount).toBeGreaterThan(0); - }); - - test("run handler ticks continuously", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithTicks.getOrCreate(["run-ticks"]); - - // Wait for some ticks - await waitFor(driverTestConfig, 200); - - const state1 = await actor.getState(); - expect(state1.tickCount).toBeGreaterThan(0); - - const count1 = state1.tickCount; - - // Wait more and check tick count increased - await waitFor(driverTestConfig, 200); - - const state2 = await actor.getState(); - expect(state2.tickCount).toBeGreaterThan(count1); - }); - - test("active run handler keeps actor awake past sleep timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithTicks.getOrCreate(["run-stays-awake"]); - - // Wait for run to start - await waitFor(driverTestConfig, 100); - - const state1 = await actor.getState(); - expect(state1.runStarted).toBe(true); - const tickCount1 = state1.tickCount; - - // Active run loops should keep the actor awake. - await waitFor(driverTestConfig, RUN_SLEEP_TIMEOUT + 300); - - const state2 = await actor.getState(); - expect(state2.runStarted).toBe(true); - expect(state2.runExited).toBe(false); - expect(state2.tickCount).toBeGreaterThan(tickCount1); - }); - - test("actor without run handler works normally", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithoutHandler.getOrCreate([ - "no-run-handler", - ]); - - const state = await actor.getState(); - expect(state.wakeCount).toBe(1); - - // Wait for sleep and wake again - await waitFor(driverTestConfig, RUN_SLEEP_TIMEOUT + 300); - - const state2 = await actor.getState(); - expect(state2.wakeCount).toBe(2); - }); - - test("run handler can consume from queue", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithQueueConsumer.getOrCreate([ - "queue-consumer", - ]); - - // Wait for run handler to start - await waitFor(driverTestConfig, 100); - - // Send some messages to the queue - await actor.sendMessage({ type: "test", value: 1 }); - await actor.sendMessage({ type: "test", value: 2 }); - await actor.sendMessage({ type: "test", value: 3 }); - - // Wait for messages to be consumed - await waitFor(driverTestConfig, 1200); - - const state = await actor.getState(); - expect(state.runStarted).toBe(true); - expect(state.messagesReceived.length).toBe(3); - expect(state.messagesReceived[0].body).toEqual({ - type: "test", - value: 1, - }); - expect(state.messagesReceived[1].body).toEqual({ - type: "test", - value: 2, - }); - expect(state.messagesReceived[2].body).toEqual({ - type: "test", - value: 3, - }); - }); - - test("queue-waiting run handler can sleep and resume", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithQueueConsumer.getOrCreate([ - "queue-consumer-sleep", - ]); - - await waitFor(driverTestConfig, 100); - const state1 = await actor.getState(); - expect(state1.runStarted).toBe(true); - - await waitFor(driverTestConfig, RUN_SLEEP_TIMEOUT + 500); - const state2 = await actor.getState(); - - expect(state2.wakeCount).toBeGreaterThan(state1.wakeCount); - }); - - test("run handler that exits early sleeps instead of destroying", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithEarlyExit.getOrCreate(["early-exit"]); - - // Wait for run to start and exit - await waitFor(driverTestConfig, 100); - - const state1 = await actor.getState(); - expect(state1.runStarted).toBe(true); - - // Wait for the run handler to exit and the normal idle sleep timeout. - await waitFor(driverTestConfig, RUN_SLEEP_TIMEOUT + 400); - - const state2 = await actor.getState(); - expect(state2.runStarted).toBe(true); - expect(state2.destroyCalled).toBe(false); - - if (driverTestConfig.skip?.sleep) { - expect(state2.sleepCount).toBe(0); - expect(state2.wakeCount).toBe(1); - } else { - expect(state2.sleepCount).toBeGreaterThan(0); - expect(state2.wakeCount).toBeGreaterThan(1); - } - }); - - test("run handler that throws error sleeps instead of destroying", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.runWithError.getOrCreate(["run-error"]); - - // Wait for run to start and throw - await waitFor(driverTestConfig, 100); - - const state1 = await actor.getState(); - expect(state1.runStarted).toBe(true); - - // Wait for the run handler to throw and the normal idle sleep timeout. - await waitFor(driverTestConfig, RUN_SLEEP_TIMEOUT + 400); - - const state2 = await actor.getState(); - expect(state2.runStarted).toBe(true); - expect(state2.destroyCalled).toBe(false); - - if (driverTestConfig.skip?.sleep) { - expect(state2.sleepCount).toBe(0); - expect(state2.wakeCount).toBe(1); - } else { - expect(state2.sleepCount).toBeGreaterThan(0); - expect(state2.wakeCount).toBeGreaterThan(1); - } - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sandbox.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sandbox.ts deleted file mode 100644 index d51be7ba48..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sandbox.ts +++ /dev/null @@ -1,92 +0,0 @@ -// @ts-nocheck -import { describe, expect, test, vi } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorSandboxTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.sandbox)( - "Actor Sandbox Tests", - () => { - test("supports sandbox actions through the actor runtime", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const sandbox = client.dockerSandboxActor.getOrCreate([ - `sandbox-${crypto.randomUUID()}`, - ]); - const decoder = new TextDecoder(); - - const health = await vi.waitFor( - async () => { - return await sandbox.getHealth(); - }, - { - timeout: 120_000, - interval: 500, - }, - ); - expect(typeof health.status).toBe("string"); - const { url } = await sandbox.getSandboxUrl(); - expect(url).toMatch(/^https?:\/\//); - - await sandbox.mkdirFs({ path: "/root/tmp" }); - await sandbox.writeFsFile( - { path: "/root/tmp/hello.txt" }, - "sandbox actor driver test", - ); - expect( - decoder.decode( - await sandbox.readFsFile({ - path: "/root/tmp/hello.txt", - }), - ), - ).toBe("sandbox actor driver test"); - - const stat = await sandbox.statFs({ - path: "/root/tmp/hello.txt", - }); - expect(stat.entryType).toBe("file"); - - await sandbox.moveFs({ - from: "/root/tmp/hello.txt", - to: "/root/tmp/renamed.txt", - }); - expect( - (await sandbox.listFsEntries({ path: "/root/tmp" })).map( - (entry: { name: string }) => entry.name, - ), - ).toContain("renamed.txt"); - - await sandbox.dispose(); - - const healthAfterDispose = await vi.waitFor( - async () => { - return await sandbox.getHealth(); - }, - { - timeout: 120_000, - interval: 500, - }, - ); - expect(typeof healthAfterDispose.status).toBe("string"); - expect( - decoder.decode( - await sandbox.readFsFile({ - path: "/root/tmp/renamed.txt", - }), - ), - ).toBe("sandbox actor driver test"); - - await sandbox.deleteFsEntry({ - path: "/root/tmp", - recursive: true, - }); - expect( - await sandbox.listFsEntries({ path: "/root" }), - ).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "tmp" }), - ]), - ); - }, 180_000); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-schedule.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-schedule.ts deleted file mode 100644 index 40c3192b5c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-schedule.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorScheduleTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.schedule)( - "Actor Schedule Tests", - () => { - // See alarm + actor sleeping test in actor-sleep.ts - - describe("Scheduled Alarms", () => { - test("executes c.schedule.at() with specific timestamp", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create instance - const scheduled = client.scheduled.getOrCreate(); - - // Schedule a task to run using timestamp - const timestamp = Date.now() + 250; - await scheduled.scheduleTaskAt(timestamp); - - // Wait for longer than the scheduled time - await waitFor(driverTestConfig, 500); - - // Verify the scheduled task ran - const lastRun = await scheduled.getLastRun(); - const scheduledCount = await scheduled.getScheduledCount(); - - expect(lastRun).toBeGreaterThan(0); - expect(scheduledCount).toBe(1); - }); - - test("executes c.schedule.after() with delay", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create instance - const scheduled = client.scheduled.getOrCreate(); - - // Schedule a task to run using delay - await scheduled.scheduleTaskAfter(250); - - // Wait for longer than the scheduled time - await waitFor(driverTestConfig, 500); - - // Verify the scheduled task ran - const lastRun = await scheduled.getLastRun(); - const scheduledCount = await scheduled.getScheduledCount(); - - expect(lastRun).toBeGreaterThan(0); - expect(scheduledCount).toBe(1); - }); - - test("scheduled action can use c.db", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.scheduledDb.getOrCreate(); - - // Schedule a task that writes to the database - await actor.scheduleDbWrite(250); - - // Wait for the scheduled task to execute - await waitFor(driverTestConfig, 500); - - // Verify the scheduled task wrote to the database - const logCount = await actor.getLogCount(); - const scheduledCount = await actor.getScheduledCount(); - - expect(logCount).toBe(1); - expect(scheduledCount).toBe(1); - }); - - test("multiple scheduled tasks execute in order", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create instance - const scheduled = client.scheduled.getOrCreate(); - - // Reset history to start fresh - await scheduled.clearHistory(); - - // Schedule multiple tasks with different delays - await scheduled.scheduleTaskAfterWithId("first", 250); - await scheduled.scheduleTaskAfterWithId("second", 750); - await scheduled.scheduleTaskAfterWithId("third", 1250); - - // Wait for first task only - await waitFor(driverTestConfig, 500); - const history1 = await scheduled.getTaskHistory(); - expect(history1[0]).toBe("first"); - - // Wait for second task - await waitFor(driverTestConfig, 500); - const history2 = await scheduled.getTaskHistory(); - expect(history2.slice(0, 2)).toEqual(["first", "second"]); - - // Wait for third task - await waitFor(driverTestConfig, 500); - const history3 = await scheduled.getTaskHistory(); - expect(history3).toEqual(["first", "second", "third"]); - }); - }); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep-db.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep-db.ts deleted file mode 100644 index df0da82987..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep-db.ts +++ /dev/null @@ -1,1177 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { RAW_WS_HANDLER_DELAY } from "../../../fixtures/driver-test-suite/sleep"; -import { - SLEEP_DB_TIMEOUT, - EXCEEDS_GRACE_HANDLER_DELAY, - EXCEEDS_GRACE_PERIOD, - EXCEEDS_GRACE_SLEEP_TIMEOUT, - ACTIVE_DB_WRITE_COUNT, - ACTIVE_DB_WRITE_DELAY_MS, - ACTIVE_DB_GRACE_PERIOD, - ACTIVE_DB_SLEEP_TIMEOUT, - RAW_DB_SLEEP_TIMEOUT, -} from "../../../fixtures/driver-test-suite/sleep-db"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -type LogEntry = { id: number; event: string; created_at: number }; - -async function connectRawWebSocket(handle: { webSocket(): Promise }) { - const ws = await handle.webSocket(); - - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }); - ws.addEventListener("error", () => reject(new Error("websocket error")), { - once: true, - }); - }); - - await new Promise((resolve, reject) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if (data.type === "connected") { - cleanup(); - resolve(); - } - }; - const onClose = () => { - cleanup(); - reject(new Error("websocket closed early")); - }; - const cleanup = () => { - ws.removeEventListener("message", onMessage); - ws.removeEventListener("close", onClose); - }; - - ws.addEventListener("message", onMessage); - ws.addEventListener("close", onClose, { once: true }); - }); - - return ws; -} - -export function runActorSleepDbTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.sleep)( - "Actor Sleep Database Tests", - () => { - test("onSleep can write to c.db", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithDb.getOrCreate(); - - // Insert a log entry while awake - await actor.insertLogEntry("before-sleep"); - - // Trigger sleep - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 250); - - // Wake the actor by calling an action - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - expect(counts.onSleepDbWriteSuccess).toBe(true); - expect(counts.onSleepDbWriteError).toBeNull(); - - // Verify both wake and sleep events were logged to the DB - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("wake"); - expect(events).toContain("before-sleep"); - expect(events).toContain("sleep"); - }); - - test("c.db works after sleep-wake cycle", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithDb.getOrCreate([ - "db-after-wake", - ]); - - // Insert before sleep - await actor.insertLogEntry("before"); - - // Let it auto-sleep - await waitFor(driverTestConfig, SLEEP_DB_TIMEOUT + 250); - - // Wake it by calling an action that uses the DB - await actor.insertLogEntry("after-wake"); - - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("before"); - expect(events).toContain("sleep"); - expect(events).toContain("wake"); - expect(events).toContain("after-wake"); - }); - - test("scheduled alarm can use c.db after sleep-wake", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithDb.getOrCreate([ - "alarm-db-wake", - ]); - - // Schedule an alarm that fires after the actor would sleep - await actor.setAlarm(SLEEP_DB_TIMEOUT + 500); - - // Wait for the actor to sleep and then wake from alarm - await waitFor(driverTestConfig, SLEEP_DB_TIMEOUT + 750); - - // Verify the alarm wrote to the DB - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("alarm"); - }); - - test("scheduled action stays awake until db work completes", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithSlowScheduledDb.getOrCreate([ - "slow-scheduled-db", - ]); - - await actor.scheduleSlowAlarm( - 50, - SLEEP_DB_TIMEOUT + 250, - ); - - await waitFor( - driverTestConfig, - 50 + (SLEEP_DB_TIMEOUT + 250) + SLEEP_DB_TIMEOUT + 250, - ); - - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("slow-alarm-start"); - expect(events).toContain("slow-alarm-finish"); - expect(events.indexOf("slow-alarm-finish")).toBeLessThan( - events.indexOf("sleep"), - ); - }); - - test("onDisconnect can write to c.db during sleep shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create actor with a connection - const handle = client.sleepWithDbConn.getOrCreate([ - "disconnect-db-write", - ]); - const connection = handle.connect(); - - // Wait for connection to be established - await vi.waitFor(async () => { - expect(connection.isConnected).toBe(true); - }); - - // Insert a log entry while awake - await connection.insertLogEntry("before-sleep"); - - // Trigger sleep, then dispose the connection. - // During the sleep shutdown sequence, onDisconnect is called - // with the DB still open (step 6 in the shutdown sequence). - await connection.triggerSleep(); - await connection.dispose(); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 500); - - // Wake the actor by calling an action - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Verify events were logged to the DB - const entries = await handle.getLogEntries(); - const events = entries.map( - (e: LogEntry) => e.event, - ); - - // CURRENT BEHAVIOR: onDisconnect runs during sleep shutdown - // and the DB is still open at that point, so the write should succeed. - expect(events).toContain("before-sleep"); - expect(events).toContain("sleep"); - expect(events).toContain("disconnect"); - }); - - test("async websocket close handler can use c.db before sleep completes", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithRawWsCloseDb.getOrCreate([ - "raw-ws-close-db", - ]); - const ws = await connectRawWebSocket(actor); - - await new Promise((resolve, reject) => { - ws.addEventListener("close", () => resolve(), { once: true }); - ws.addEventListener( - "error", - () => reject(new Error("websocket error")), - { once: true }, - ); - ws.close(); - }); - - await waitFor(driverTestConfig, RAW_WS_HANDLER_DELAY + 150); - - const status = await actor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(1); - - const entries = await actor.getLogEntries(); - const events = entries.map((entry: LogEntry) => entry.event); - expect(events).toContain("sleep"); - expect(events).toContain("close-start"); - expect(events).toContain("close-finish"); - }); - - test("async websocket addEventListener close handler can use c.db before sleep completes", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = - client.sleepWithRawWsCloseDbListener.getOrCreate([ - "raw-ws-close-db-listener", - ]); - const ws = await connectRawWebSocket(actor); - - await new Promise((resolve, reject) => { - ws.addEventListener("close", () => resolve(), { once: true }); - ws.addEventListener( - "error", - () => reject(new Error("websocket error")), - { once: true }, - ); - ws.close(); - }); - - await waitFor(driverTestConfig, RAW_WS_HANDLER_DELAY + 150); - - const status = await actor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(1); - - const entries = await actor.getLogEntries(); - const events = entries.map((entry: LogEntry) => entry.event); - expect(events).toContain("sleep"); - expect(events).toContain("close-start"); - expect(events).toContain("close-finish"); - }); - - test("broadcast works in onSleep", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const handle = client.sleepWithDbAction.getOrCreate([ - "broadcast-in-onsleep", - ]); - const connection = handle.connect(); - - // Wait for connection to be established - await vi.waitFor(async () => { - expect(connection.isConnected).toBe(true); - }); - - // Listen for the "sleeping" event - let sleepingEventReceived = false; - connection.on("sleeping", () => { - sleepingEventReceived = true; - }); - - // Insert a log entry while awake - await connection.insertLogEntry("before-sleep"); - - // Trigger sleep - await connection.triggerSleep(); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 1500); - await connection.dispose(); - - // Broadcast now works during onSleep since assertReady - // only blocks after #shutdownComplete is set. - expect(sleepingEventReceived).toBe(true); - - // Wake the actor - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Both "sleep-start" and "sleep-end" should be written - // since broadcast no longer throws. - const entries = await handle.getLogEntries(); - const events = entries.map( - (e: LogEntry) => e.event, - ); - - expect(events).toContain("before-sleep"); - expect(events).toContain("sleep-start"); - expect(events).toContain("sleep-end"); - }); - - test("action via handle during sleep is queued and runs on woken instance", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // CURRENT BEHAVIOR: When an action is sent via a stateless - // handle while the actor is sleeping, the file-system driver - // queues the action. Once the actor finishes sleeping and - // wakes back up, the action executes on the new instance. - - const handle = client.sleepWithDbAction.getOrCreate([ - "action-during-sleep-handle", - ]); - - // Insert a log entry while awake - await handle.insertLogEntry("before-sleep"); - - // Trigger sleep - await handle.triggerSleep(); - - // Immediately try to call an action via the handle. - // This action arrives while the actor is shutting down or asleep. - let actionResult: { succeeded: boolean; error?: string }; - try { - await handle.insertLogEntry("during-sleep"); - actionResult = { succeeded: true }; - } catch (error) { - actionResult = { - succeeded: false, - error: - error instanceof Error - ? error.message - : String(error), - }; - } - - // Wait for everything to settle - await waitFor(driverTestConfig, 1000); - - // Wake the actor and check state. The sleep/start counts - // may be >1/2 because the action arriving during sleep - // wakes the actor, which may auto-sleep and wake again. - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBeGreaterThanOrEqual(1); - expect(counts.startCount).toBeGreaterThanOrEqual(2); - - const entries = await handle.getLogEntries(); - const events = entries.map( - (e: LogEntry) => e.event, - ); - - // CURRENT BEHAVIOR: The action succeeds because the driver - // wakes the actor to process it. The action runs on the new - // instance after wake. - expect(actionResult.succeeded).toBe(true); - expect(events).toContain("before-sleep"); - expect(events).toContain("during-sleep"); - }); - - test("waitUntil works in onSleep", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWaitUntil.getOrCreate([ - "waituntil-onsleep", - ]); - - // Trigger sleep - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Verify the waitUntil'd write appeared in the DB - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("sleep-start"); - expect(events).toContain("waituntil-write"); - }); - - test("nested waitUntil inside waitUntil is drained before shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepNestedWaitUntil.getOrCreate([ - "nested-waituntil", - ]); - - // Trigger sleep - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Verify both outer and nested waitUntil writes appeared - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("sleep-start"); - expect(events).toContain("outer-waituntil"); - expect(events).toContain("nested-waituntil"); - }); - - test("enqueue works during onSleep", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepEnqueue.getOrCreate([ - "enqueue-onsleep", - ]); - - // Trigger sleep - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.enqueueSuccess).toBe(true); - expect(counts.enqueueError).toBeNull(); - }); - - test("schedule.after in onSleep persists and fires on wake", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepScheduleAfter.getOrCreate([ - "schedule-after-onsleep", - ]); - - // Trigger sleep - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor by calling an action, then wait for - // the scheduled alarm to fire (it was scheduled with - // 100ms delay, re-armed on wake via initializeAlarms) - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Wait for the scheduled action to fire after wake - await waitFor(driverTestConfig, 500); - - // Verify the scheduled action wrote to the DB - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("sleep"); - expect(events).toContain("scheduled-action"); - }); - - test("action via WebSocket connection during sleep shutdown succeeds", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Actions from pre-existing connections during the graceful - // shutdown window should succeed since assertReady() only - // blocks after #shutdownComplete is set. - - const handle = client.sleepWithDbAction.getOrCreate([ - "ws-during-sleep", - ]); - const connection = handle.connect(); - - // Wait for connection to be established - await vi.waitFor(async () => { - expect(connection.isConnected).toBe(true); - }); - - // Insert a log entry while awake - await connection.insertLogEntry("before-sleep"); - - // Trigger sleep via the connection - await connection.triggerSleep(); - - // Send an action via the WebSocket connection during the - // graceful shutdown window. This should succeed. - await connection.insertLogEntry("ws-during-sleep"); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 1500); - - // Dispose the connection - await connection.dispose(); - - // Wake the actor - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Get log entries after waking - const entries = await handle.getLogEntries(); - const events = entries.map( - (e: LogEntry) => e.event, - ); - - expect(events).toContain("before-sleep"); - expect(events).toContain("sleep-start"); - expect(events).toContain("ws-during-sleep"); - }); - test("new connections rejected during sleep shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // The sleepWithDbAction actor has a 500ms delay in - // onSleep, giving us a window to attempt a new connection - // while the actor is actively shutting down. - - const handle = client.sleepWithDbAction.getOrCreate([ - "conn-rejected-during-sleep", - ]); - const firstConn = handle.connect(); - - // Wait for first connection - await vi.waitFor(async () => { - expect(firstConn.isConnected).toBe(true); - }); - - // Trigger sleep (the actor will be in onSleep for ~500ms) - await firstConn.triggerSleep(); - - // Wait a moment for the shutdown to start - await waitFor(driverTestConfig, 100); - - // Attempt a new connection during shutdown. - // The file-system driver queues the connection until - // the actor wakes, so this should not throw. The - // connection will be established on the new instance. - const secondConn = handle.connect(); - - // Wait for sleep to complete and actor to wake - await waitFor(driverTestConfig, 2000); - - // The second connection should eventually connect - // on the woken instance - await vi.waitFor(async () => { - expect(secondConn.isConnected).toBe(true); - }); - - // Verify the actor went through a sleep-wake cycle - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - await firstConn.dispose(); - await secondConn.dispose(); - }); - - test("new raw WebSocket during sleep shutdown is rejected or queued", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // The sleepWithRawWs actor has a 500ms delay in onSleep. - // A raw WebSocket request during shutdown is rejected by - // the manager driver with "Actor stopping" because the - // handleRawWebSocket guard blocks new WebSocket handlers - // when #stopCalled is true. - - const handle = client.sleepWithRawWs.getOrCreate([ - "raw-ws-rejected-during-sleep", - ]); - - // Trigger sleep - await handle.triggerSleep(); - - // Wait a moment for shutdown to begin - await waitFor(driverTestConfig, 100); - - // Attempt a raw WebSocket during shutdown. - // This should be rejected by the driver/guard. - let wsError: string | undefined; - try { - await handle.webSocket(); - } catch (error) { - wsError = error instanceof Error - ? error.message - : String(error); - } - - // The request should have been rejected - expect(wsError).toBeDefined(); - expect(wsError).toContain("stopping"); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 1500); - - // Verify the actor can still wake and function normally - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - }); - - test("onSleep throwing does not prevent clean shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepOnSleepThrows.getOrCreate([ - "onsleep-throws", - ]); - - // Trigger sleep. The onSleep handler throws after - // writing "sleep-before-throw" to the DB. - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor. It should have shut down cleanly - // despite the throw, because #shutdownComplete is set - // in the finally block. - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Verify the DB write before the throw was persisted - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("sleep-before-throw"); - }); - - test("waitUntil rejection during shutdown does not block shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWaitUntilRejects.getOrCreate([ - "waituntil-rejects", - ]); - - // Trigger sleep. The onSleep handler registers a - // rejecting waitUntil and a succeeding one. - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor. Shutdown should have completed - // despite the rejection. - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // The succeeding waitUntil should still have run - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("sleep"); - expect(events).toContain("waituntil-after-reject"); - }); - - test("double sleep call is a no-op", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Use a connection to send the sleep trigger, because - // a handle-based action goes through the driver which - // would wake the actor for a second cycle. - const handle = client.sleepWithDbAction.getOrCreate([ - "double-sleep", - ]); - const connection = handle.connect(); - - await vi.waitFor(async () => { - expect(connection.isConnected).toBe(true); - }); - - // Trigger sleep twice rapidly via the connection. - // The second call should be a no-op because - // #sleepCalled is already true. - await connection.triggerSleep(); - try { - await connection.triggerSleep(); - } catch { - // May throw if actor already stopping - } - - // Wait for sleep to complete - await waitFor(driverTestConfig, 1500); - await connection.dispose(); - - // Wake the actor. It should have gone through exactly - // one sleep-wake cycle. - const counts = await handle.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - }); - - test("state mutations in waitUntil callback are persisted", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWaitUntilState.getOrCreate([ - "waituntil-state-persist", - ]); - - // Trigger sleep. The onSleep handler registers a - // waitUntil that mutates c.state.waitUntilRan. - await actor.triggerSleep(); - - // Wait for sleep to complete - await waitFor(driverTestConfig, 500); - - // Wake the actor and verify the state mutation - // from the waitUntil callback was persisted. - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - expect(counts.waitUntilRan).toBe(true); - - // Verify the DB write from waitUntil was also persisted - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("waituntil-state"); - }); - - test("alarm does not fire during shutdown", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = client.sleepWithDb.getOrCreate([ - "alarm-no-fire-during-shutdown", - ]); - - // Schedule an alarm with a very short delay - await actor.setAlarm(50); - - // Immediately trigger sleep. The cancelAlarm call in - // onStop should prevent the alarm from firing during - // the shutdown sequence. - await actor.triggerSleep(); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 500); - - // Wake the actor. The alarm should fire on the new - // instance (re-armed by initializeAlarms on wake). - const counts = await actor.getCounts(); - expect(counts.sleepCount).toBe(1); - expect(counts.startCount).toBe(2); - - // Wait for the alarm to fire on the woken instance - await waitFor(driverTestConfig, 500); - - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("alarm"); - }); - - test( - "ws handler exceeding grace period should still complete db writes", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = - client.sleepWsMessageExceedsGrace.getOrCreate([ - "ws-exceeds-grace", - ]); - const ws = await connectRawWebSocket(actor); - - // Send a message that starts slow async DB work - ws.send("slow-db-work"); - - // Wait for the handler to confirm it started - await new Promise((resolve) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if (data.type === "started") { - ws.removeEventListener( - "message", - onMessage, - ); - resolve(); - } - }; - ws.addEventListener("message", onMessage); - }); - - // Trigger sleep while the handler is still doing slow - // work. The grace period (200ms) is much shorter than the - // handler delay (2000ms), so shutdown will time out and - // clean up the database while the handler is still running. - await actor.triggerSleep(); - - // Wait for the handler to finish and the actor to complete - // its sleep cycle. The handler runs for 2000ms. After that - // the actor sleeps (the timed-out shutdown already ran, but - // the handler promise still resolves in the background). - await waitFor( - driverTestConfig, - EXCEEDS_GRACE_HANDLER_DELAY + - EXCEEDS_GRACE_SLEEP_TIMEOUT + - 500, - ); - - // Wake the actor and check what happened. - const status = await actor.getStatus(); - expect(status.sleepCount).toBeGreaterThanOrEqual(1); - expect(status.startCount).toBeGreaterThanOrEqual(2); - - // The handler started. - expect(status.messageStarted).toBe(1); - - // BUG: The handler's second DB write should succeed, but - // the grace period expired and the database was cleaned up - // before the handler finished. The handler's post-delay - // c.db.execute call runs against a destroyed database, - // so messageFinished is never incremented and "msg-finish" - // is missing from the log. - // - // Correct behavior: the handler should complete and - // msg-finish should appear in the DB. - expect(status.messageFinished).toBe(1); - - const entries = await actor.getLogEntries(); - const events = entries.map( - (e: { event: string }) => e.event, - ); - expect(events).toContain("msg-start"); - expect(events).toContain("msg-finish"); - }, - { timeout: 15_000 }, - ); - - test( - "concurrent ws handlers with cached db ref get errors when grace period exceeded", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = - client.sleepWsConcurrentDbExceedsGrace.getOrCreate( - ["ws-concurrent-exceeds-grace"], - ); - const ws = await connectRawWebSocket(actor); - - const MESSAGE_COUNT = 3; - let startedCount = 0; - - // Set up listener for "started" confirmations - const allStarted = new Promise((resolve) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if (data.type === "started") { - startedCount++; - if (startedCount === MESSAGE_COUNT) { - ws.removeEventListener( - "message", - onMessage, - ); - resolve(); - } - } - }; - ws.addEventListener("message", onMessage); - }); - - // Send multiple messages rapidly. Each handler captures - // c.db before awaiting and uses the cached reference after - // the delay. Multiple handlers will try to use the cached - // db reference after VFS teardown. - for (let i = 0; i < MESSAGE_COUNT; i++) { - ws.send( - JSON.stringify({ - type: "slow-db-work", - index: i, - }), - ); - } - - // Wait for all handlers to confirm they started - await allStarted; - - // Trigger sleep while all handlers are doing slow work - await actor.triggerSleep(); - - // Wait for handlers to finish + actor to sleep and wake - await waitFor( - driverTestConfig, - EXCEEDS_GRACE_HANDLER_DELAY + - MESSAGE_COUNT * 50 + - EXCEEDS_GRACE_SLEEP_TIMEOUT + - 500, - ); - - // Wake the actor. All handlers should have completed - // their DB writes successfully. - const status = await actor.getStatus(); - expect(status.sleepCount).toBeGreaterThanOrEqual(1); - expect(status.startCount).toBeGreaterThanOrEqual(2); - expect(status.handlerStarted).toBe(MESSAGE_COUNT); - - // BUG: The handlers' post-delay DB writes fail because - // the grace period expired and the VFS was destroyed. - // With a cached db reference and staggered delays, the - // first handler to resume may get "disk I/O error" and - // leave a transaction open, and subsequent handlers get - // "cannot start a transaction within a transaction". - // - // Correct behavior: all handler DB writes should succeed. - expect(status.handlerFinished).toBe(MESSAGE_COUNT); - expect(status.handlerErrors).toEqual([]); - }, - { timeout: 15_000 }, - ); - - test( - "active db writes interrupted by sleep produce db error", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = - client.sleepWsActiveDbExceedsGrace.getOrCreate([ - "ws-active-db-exceeds-grace", - ]); - const ws = await connectRawWebSocket(actor); - - // Listen for error message from the handler. The - // handler sends { type: "error", index, error } over - // the WebSocket when the DB write fails. - const errorPromise = new Promise<{ - index: number; - error: string; - }>((resolve) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if (data.type === "error") { - ws.removeEventListener( - "message", - onMessage, - ); - resolve(data); - } - }; - ws.addEventListener("message", onMessage); - }); - - // Start the write loop - ws.send("start-writes"); - - // Wait for confirmation - await new Promise((resolve) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if (data.type === "started") { - ws.removeEventListener( - "message", - onMessage, - ); - resolve(); - } - }; - ws.addEventListener("message", onMessage); - }); - - // Trigger sleep while writes are in progress. - await actor.triggerSleep(); - - // Wait for the error message from the handler. - const errorData = await errorPromise; - - // The handler's write was interrupted by shutdown. - // With the file-system driver, the c.db getter throws - // ActorStopping because #db is already undefined. With - // the engine driver, the KV transport fails mid-query - // and the VFS onError callback produces a descriptive - // "underlying storage is no longer available" message. - expect(errorData.error).toMatch( - /actor stop|database accessed after|Database is closed|underlying storage/i, - ); - expect(errorData.index).toBeGreaterThan(0); - expect(errorData.index).toBeLessThan( - ACTIVE_DB_WRITE_COUNT, - ); - - // Wait for actor to sleep + wake so we can query it. - await waitFor( - driverTestConfig, - ACTIVE_DB_SLEEP_TIMEOUT + 500, - ); - - // Verify the DB has fewer rows than the full count. - const entries = await actor.getLogEntries(); - const writeEntries = entries.filter( - (e: { event: string }) => - e.event.startsWith("write-"), - ); - expect(writeEntries.length).toBeGreaterThan(0); - expect(writeEntries.length).toBeLessThan( - ACTIVE_DB_WRITE_COUNT, - ); - }, - { timeout: 30_000 }, - ); - - test( - "poisoned KV produces disk I/O error on commit", - async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - - const actor = - client.sleepWsRawDbAfterClose.getOrCreate([ - `raw-db-${crypto.randomUUID()}`, - ]); - const ws = await connectRawWebSocket(actor); - - // Listen for the error (or committed) message. - const resultPromise = new Promise<{ - type: string; - error?: string; - }>((resolve) => { - const onMessage = (event: MessageEvent) => { - const data = JSON.parse(String(event.data)); - if ( - data.type === "error" || - data.type === "committed" - ) { - ws.removeEventListener( - "message", - onMessage, - ); - resolve(data); - } - }; - ws.addEventListener("message", onMessage); - }); - - // Tell the handler to BEGIN a transaction, poison the - // KV store, then try to COMMIT. - ws.send("raw-db-after-close"); - - // Wait for the handler's result with a timeout. The - // COMMIT may hang if the VFS error causes SQLite's - // pager to enter a retry loop, so we set a deadline. - const result = await Promise.race([ - resultPromise, - new Promise<{ type: string; error?: string }>( - (resolve) => - setTimeout( - () => - resolve({ - type: "timeout", - error: "handler did not respond within 5s", - }), - 5000, - ), - ), - ]); - - // The COMMIT should have failed with a raw SQLite - // error caused by the poisoned KV. The exact message - // depends on which VFS operation fails first: - // "disk I/O error" (xWrite) or "unable to open - // database file" (xOpen during rollback). - expect(result.type).not.toBe("committed"); - if (result.type === "error") { - expect(result.error).toMatch( - /disk I\/O|unable to open|SQLITE_IOERR/i, - ); - } - }, - { timeout: 15_000 }, - ); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts deleted file mode 100644 index 257c391337..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts +++ /dev/null @@ -1,891 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - PREVENT_SLEEP_TIMEOUT, - RAW_WS_HANDLER_DELAY, - RAW_WS_HANDLER_SLEEP_TIMEOUT, - SLEEP_TIMEOUT, -} from "../../../fixtures/driver-test-suite/sleep"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -async function waitForRawWebSocketMessage(ws: WebSocket) { - return await new Promise((resolve, reject) => { - const onMessage = (event: MessageEvent) => { - cleanup(); - resolve(JSON.parse(String(event.data))); - }; - const onClose = (event: { code?: number }) => { - cleanup(); - reject( - new Error( - `websocket closed early: ${event.code ?? "unknown"}`, - ), - ); - }; - const onError = () => { - cleanup(); - reject(new Error("websocket error")); - }; - const cleanup = () => { - ws.removeEventListener("message", onMessage); - ws.removeEventListener("close", onClose); - ws.removeEventListener("error", onError); - }; - - ws.addEventListener("message", onMessage, { once: true }); - ws.addEventListener("close", onClose, { once: true }); - ws.addEventListener("error", onError, { once: true }); - }); -} - -async function connectRawWebSocket(handle: { webSocket(): Promise }) { - const ws = await handle.webSocket(); - - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }); - ws.addEventListener("error", () => reject(new Error("websocket error")), { - once: true, - }); - }); - - await waitForRawWebSocketMessage(ws); - return ws; -} - -async function closeRawWebSocket(ws: WebSocket) { - await new Promise((resolve, reject) => { - ws.addEventListener("close", () => resolve(), { once: true }); - ws.addEventListener("error", () => reject(new Error("websocket error")), { - once: true, - }); - ws.close(); - }); -} - -// TODO: These tests are broken with fake timers because `_sleep` requires -// background async promises that have a race condition with calling -// `getCounts` -// -// To fix this, we need to imeplment some event system to be able to check for -// when an actor has slept. OR we can expose an HTTP endpoint on the manager -// for `.test` that checks if na actor is sleeping that we can poll. -export function runActorSleepTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(driverTestConfig.skip?.sleep)("Actor Sleep Tests", () => { - test("actor sleep persists state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleep.getOrCreate(); - - // Verify initial sleep count - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Trigger sleep - await sleepActor.triggerSleep(); - - // HACK: Wait for sleep to finish in background - await waitFor(driverTestConfig, 250); - - // Get sleep count after restore - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("actor sleep persists state with connect", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with persistent connection - const sleepActor = client.sleep.getOrCreate().connect(); - - // Verify initial sleep count - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Trigger sleep - await sleepActor.triggerSleep(); - - // Disconnect to allow reconnection - await sleepActor.dispose(); - - // HACK: Wait for sleep to finish in background - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Reconnect to get sleep count after restore - const sleepActor2 = client.sleep.getOrCreate(); - { - const { startCount, sleepCount } = - await sleepActor2.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("actor automatically sleeps after timeout", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleep.getOrCreate(); - - // Verify initial sleep count - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Wait for sleep - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Get sleep count after restore - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("actor automatically sleeps after timeout with connect", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with persistent connection - const sleepActor = client.sleep.getOrCreate().connect(); - - // Verify initial sleep count - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Disconnect to allow actor to sleep - await sleepActor.dispose(); - - // Wait for sleep - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Reconnect to get sleep count after restore - const sleepActor2 = client.sleep.getOrCreate(); - { - const { startCount, sleepCount } = - await sleepActor2.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("waitUntil can broadcast before sleep disconnect", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepWithWaitUntilMessage - .getOrCreate() - .connect(); - const receivedMessages: Array<{ - sleepCount: number; - startCount: number; - }> = []; - - sleepActor.once("sleeping", (message) => { - receivedMessages.push(message); - }); - - await sleepActor.triggerSleep(); - await waitFor(driverTestConfig, 250); - - expect(receivedMessages).toHaveLength(1); - expect(receivedMessages[0]?.startCount).toBe(1); - - await sleepActor.dispose(); - - await waitFor(driverTestConfig, 250); - - const sleepActor2 = client.sleepWithWaitUntilMessage.getOrCreate(); - { - const { startCount, sleepCount, waitUntilMessageCount } = - await sleepActor2.getCounts(); - expect(waitUntilMessageCount).toBe(1); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("waitUntil works in onWake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepWithWaitUntilInOnWake.getOrCreate(); - - // Verify waitUntil did not throw during onWake - { - const status = await sleepActor.getStatus(); - expect(status.startCount).toBe(1); - expect(status.waitUntilCalled).toBe(true); - } - - // Trigger sleep so the waitUntil promise drains before persisting - await sleepActor.triggerSleep(); - await waitFor(driverTestConfig, 250); - - // After sleep and wake, verify the waitUntil promise completed - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.waitUntilCompleted).toBe(true); - } - }); - - test("rpc calls keep actor awake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleep.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Wait almost until sleep timeout, then make RPC call - await waitFor(driverTestConfig, SLEEP_TIMEOUT - 250); - - // RPC call should reset the sleep timer - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); // Haven't slept yet - expect(startCount).toBe(1); // Still the same instance - } - - // Wait another partial timeout period - actor should still be awake - await waitFor(driverTestConfig, SLEEP_TIMEOUT - 250); - - // Actor should still be awake - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); // Still haven't slept - expect(startCount).toBe(1); // Still the same instance - } - - // Now wait for full timeout without any RPC calls - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should have slept and restarted - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); // Slept once - expect(startCount).toBe(2); // New instance after sleep - } - }); - - test("alarms keep actor awake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleep.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Set an alarm to keep the actor awake - await sleepActor.setAlarm(SLEEP_TIMEOUT - 250); - - // Wait until after SLEEPT_IMEOUT to validate the actor did not sleep - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should not have slept - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - }); - - test("alarms wake actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleep.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Set an alarm to keep the actor awake - await sleepActor.setAlarm(SLEEP_TIMEOUT + 250); - - // Wait until after SLEEPT_IMEOUT to validate the actor did not sleep - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 200); - - // Actor should not have slept - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("long running rpcs keep actor awake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor - const sleepActor = client.sleepWithLongRpc.getOrCreate().connect(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Start a long-running RPC that takes longer than the sleep timeout - const waitPromise = new Promise((resolve) => - sleepActor.once("waiting", resolve), - ); - const longRunningPromise = sleepActor.longRunningRpc(); - await waitPromise; - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - await sleepActor.finishLongRunningRpc(); - await longRunningPromise; - - // Actor should still be the same instance (didn't sleep during RPC) - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); // Hasn't slept - expect(startCount).toBe(1); // Same instance - } - await sleepActor.dispose(); - - // Now wait for the sleep timeout - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should have slept after the timeout - const sleepActor2 = client.sleepWithLongRpc.getOrCreate(); - { - const { startCount, sleepCount } = - await sleepActor2.getCounts(); - expect(sleepCount).toBe(1); // Slept once - expect(startCount).toBe(2); // New instance after sleep - } - }); - - test("active raw websockets keep actor awake", async (c) => { - const { client, endpoint: baseUrl } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create actor - const sleepActor = client.sleepWithRawWebSocket.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Connect WebSocket - const ws = await sleepActor.webSocket(); - - await new Promise((resolve, reject) => { - ws.onopen = () => resolve(); - ws.onerror = reject; - }); - - // Wait for connection message - await new Promise((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "connected") { - resolve(); - } - }; - }); - - // Wait longer than sleep timeout while keeping WebSocket connected - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Send a message to check if actor is still alive - ws.send(JSON.stringify({ type: "getCounts" })); - - const counts = await new Promise((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "counts") { - resolve(data); - } - }; - }); - - // Actor should still be the same instance (didn't sleep while WebSocket connected) - expect(counts.sleepCount).toBe(0); - expect(counts.startCount).toBe(1); - - // Close WebSocket - ws.close(); - - // Wait for sleep timeout after WebSocket closed - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should have slept after WebSocket closed - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); // Slept once - expect(startCount).toBe(2); // New instance after sleep - } - }); - - test("active raw fetch requests keep actor awake", async (c) => { - const { client, endpoint: baseUrl } = await setupDriverTest( - c, - driverTestConfig, - ); - - // Create actor - const sleepActor = client.sleepWithRawHttp.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Start a long-running fetch request - const fetchDuration = SLEEP_TIMEOUT + 250; - const fetchPromise = sleepActor.fetch( - `long-request?duration=${fetchDuration}`, - ); - - // Wait for the fetch to complete - const response = await fetchPromise; - const result = (await response.json()) as { completed: boolean }; - expect(result.completed).toBe(true); - { - const { startCount, sleepCount, requestCount } = - await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - expect(requestCount).toBe(1); - } - - // Wait for sleep timeout - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should have slept after timeout - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(1); // Slept once - expect(startCount).toBe(2); // New instance after sleep - } - }); - - test("noSleep option disables sleeping", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with noSleep option - const sleepActor = client.sleepWithNoSleepOption.getOrCreate(); - - // Verify initial state - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); - expect(startCount).toBe(1); - } - - // Wait longer than sleep timeout - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should NOT have slept due to noSleep option - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); // Never slept - expect(startCount).toBe(1); // Still the same instance - } - - // Wait even longer to be sure - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - // Actor should still not have slept - { - const { startCount, sleepCount } = await sleepActor.getCounts(); - expect(sleepCount).toBe(0); // Never slept - expect(startCount).toBe(1); // Still the same instance - } - }); - - test("preventSleep blocks auto sleep until cleared", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepWithPreventSleep.getOrCreate(); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(0); - expect(status.startCount).toBe(1); - expect(status.preventSleep).toBe(false); - expect(status.preventSleepOnWake).toBe(false); - } - - expect(await sleepActor.setPreventSleep(true)).toBe(true); - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(0); - expect(status.startCount).toBe(1); - expect(status.preventSleep).toBe(true); - } - - expect(await sleepActor.setPreventSleep(false)).toBe(false); - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.preventSleep).toBe(false); - } - }); - - test("preventSleep delays shutdown until cleared", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepWithPreventSleep.getOrCreate([ - "prevent-sleep-shutdown-delay", - ]); - - expect( - await sleepActor.setDelayPreventSleepDuringShutdown(true), - ).toBe(true); - await sleepActor.triggerSleep(); - await waitFor(driverTestConfig, PREVENT_SLEEP_TIMEOUT + 150); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.preventSleep).toBe(false); - expect(status.delayPreventSleepDuringShutdown).toBe(true); - expect(status.preventSleepClearedDuringShutdown).toBe(true); - } - }); - - test("preventSleep can be restored during onWake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepWithPreventSleep.getOrCreate(); - - expect(await sleepActor.setPreventSleepOnWake(true)).toBe(true); - - await sleepActor.triggerSleep(); - await waitFor(driverTestConfig, 250); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.preventSleep).toBe(true); - expect(status.preventSleepOnWake).toBe(true); - } - - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(1); - expect(status.startCount).toBe(2); - expect(status.preventSleep).toBe(true); - expect(status.preventSleepOnWake).toBe(true); - } - - expect(await sleepActor.setPreventSleepOnWake(false)).toBe(false); - expect(await sleepActor.setPreventSleep(false)).toBe(false); - - await waitFor(driverTestConfig, SLEEP_TIMEOUT + 250); - - { - const status = await sleepActor.getStatus(); - expect(status.sleepCount).toBe(2); - expect(status.startCount).toBe(3); - expect(status.preventSleep).toBe(false); - expect(status.preventSleepOnWake).toBe(false); - } - }); - - test("async websocket addEventListener message handler delays sleep", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = - client.sleepRawWsAddEventListenerMessage.getOrCreate(); - const ws = await connectRawWebSocket(actor); - - ws.send("track-message"); - const message = await waitForRawWebSocketMessage(ws); - expect(message.type).toBe("message-started"); - - await closeRawWebSocket(ws); - await waitFor(driverTestConfig, RAW_WS_HANDLER_SLEEP_TIMEOUT + 75); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(1); - expect(status.sleepCount).toBe(0); - expect(status.messageStarted).toBe(1); - expect(status.messageFinished).toBe(0); - } - - await waitFor( - driverTestConfig, - RAW_WS_HANDLER_DELAY + RAW_WS_HANDLER_SLEEP_TIMEOUT + 150, - ); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(2); - expect(status.sleepCount).toBe(1); - expect(status.messageStarted).toBe(1); - expect(status.messageFinished).toBe(1); - } - }); - - test("async websocket onmessage handler delays sleep", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.sleepRawWsOnMessage.getOrCreate(); - const ws = await connectRawWebSocket(actor); - - ws.send("track-message"); - const message = await waitForRawWebSocketMessage(ws); - expect(message.type).toBe("message-started"); - - await closeRawWebSocket(ws); - await waitFor(driverTestConfig, RAW_WS_HANDLER_SLEEP_TIMEOUT + 75); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(1); - expect(status.sleepCount).toBe(0); - expect(status.messageStarted).toBe(1); - expect(status.messageFinished).toBe(0); - } - - await waitFor( - driverTestConfig, - RAW_WS_HANDLER_DELAY + RAW_WS_HANDLER_SLEEP_TIMEOUT + 150, - ); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(2); - expect(status.sleepCount).toBe(1); - expect(status.messageStarted).toBe(1); - expect(status.messageFinished).toBe(1); - } - }); - - test("async websocket addEventListener close handler delays sleep", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.sleepRawWsAddEventListenerClose.getOrCreate(); - const ws = await connectRawWebSocket(actor); - - await closeRawWebSocket(ws); - await waitFor(driverTestConfig, RAW_WS_HANDLER_SLEEP_TIMEOUT + 75); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(1); - expect(status.sleepCount).toBe(0); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(0); - } - - await waitFor( - driverTestConfig, - RAW_WS_HANDLER_DELAY + RAW_WS_HANDLER_SLEEP_TIMEOUT + 150, - ); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(2); - expect(status.sleepCount).toBe(1); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(1); - } - }); - - test("async websocket onclose handler delays sleep", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor = client.sleepRawWsOnClose.getOrCreate(); - const ws = await connectRawWebSocket(actor); - - await closeRawWebSocket(ws); - await waitFor(driverTestConfig, RAW_WS_HANDLER_SLEEP_TIMEOUT + 75); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(1); - expect(status.sleepCount).toBe(0); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(0); - } - - await waitFor( - driverTestConfig, - RAW_WS_HANDLER_DELAY + RAW_WS_HANDLER_SLEEP_TIMEOUT + 150, - ); - - { - const status = await actor.getStatus(); - expect(status.startCount).toBe(2); - expect(status.sleepCount).toBe(1); - expect(status.closeStarted).toBe(1); - expect(status.closeFinished).toBe(1); - } - }); - - test("onSleep sends message to raw websocket", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = client.sleepRawWsSendOnSleep.getOrCreate(); - - // Connect WebSocket - const ws = await sleepActor.webSocket(); - - await new Promise((resolve, reject) => { - ws.onopen = () => resolve(); - ws.onerror = reject; - }); - - // Wait for connected message - await new Promise((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "connected") { - resolve(); - } - }; - }); - - // Listen for the sleeping message or close event - const result = await new Promise<{ - message: any | null; - closed: boolean; - }>((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "sleeping") { - resolve({ message: data, closed: false }); - } - }; - ws.onclose = () => { - resolve({ message: null, closed: true }); - }; - - // Trigger sleep after handlers are set up - sleepActor.triggerSleep(); - }); - - // The message should have been received - expect(result.message).toBeDefined(); - expect(result.message?.type).toBe("sleeping"); - expect(result.message?.sleepCount).toBe(1); - - // Close the WebSocket from client side - ws.close(); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 500); - - // Verify sleep happened - { - const { startCount, sleepCount } = - await sleepActor.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - - test("onSleep sends delayed message to raw websocket", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const sleepActor = - client.sleepRawWsDelayedSendOnSleep.getOrCreate(); - - // Connect WebSocket - const ws = await sleepActor.webSocket(); - - await new Promise((resolve, reject) => { - ws.onopen = () => resolve(); - ws.onerror = reject; - }); - - // Wait for connected message - await new Promise((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "connected") { - resolve(); - } - }; - }); - - // Listen for the sleeping message or close event - const result = await new Promise<{ - message: any | null; - closed: boolean; - }>((resolve) => { - ws.onmessage = (event: { data: string }) => { - const data = JSON.parse(event.data); - if (data.type === "sleeping") { - resolve({ message: data, closed: false }); - } - }; - ws.onclose = () => { - resolve({ message: null, closed: true }); - }; - - // Trigger sleep after handlers are set up - sleepActor.triggerSleep(); - }); - - // The message should have been received after the delay - expect(result.message).toBeDefined(); - expect(result.message?.type).toBe("sleeping"); - expect(result.message?.sleepCount).toBe(1); - - // Close the WebSocket from client side - ws.close(); - - // Wait for sleep to fully complete - await waitFor(driverTestConfig, 500); - - // Verify sleep happened - { - const { startCount, sleepCount } = - await sleepActor.getCounts(); - expect(sleepCount).toBe(1); - expect(startCount).toBe(2); - } - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts deleted file mode 100644 index 581c67c164..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const SLEEP_WAIT_MS = 150; - -export function runActorStateZodCoercionTests( - driverTestConfig: DriverTestConfig, -) { - describe("Actor State Zod Coercion Tests", () => { - test("preserves state through sleep/wake with Zod coercion", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.stateZodCoercionActor.getOrCreate([ - `zod-roundtrip-${crypto.randomUUID()}`, - ]); - - await actor.setCount(42); - await actor.setLabel("custom"); - - // Sleep and wake - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - - const state = await actor.getState(); - expect(state.count).toBe(42); - expect(state.label).toBe("custom"); - }); - - test("Zod defaults fill missing fields on wake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.stateZodCoercionActor.getOrCreate([ - `zod-defaults-${crypto.randomUUID()}`, - ]); - - // Initial state should have defaults from the schema - const state = await actor.getState(); - expect(state.count).toBe(0); - expect(state.label).toBe("default"); - }); - - test("Zod coercion preserves values after mutation and wake", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.stateZodCoercionActor.getOrCreate([ - `zod-mutate-wake-${crypto.randomUUID()}`, - ]); - - await actor.setCount(99); - await actor.setLabel("updated"); - - // Sleep - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - - // Wake and verify Zod parse preserved values - const state = await actor.getState(); - expect(state.count).toBe(99); - expect(state.label).toBe("updated"); - - // Mutate again and verify - await actor.setLabel("second-update"); - const state2 = await actor.getState(); - expect(state2.label).toBe("second-update"); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state.ts deleted file mode 100644 index 07a8028c88..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorStateTests(driverTestConfig: DriverTestConfig) { - describe("Actor State Tests", () => { - describe("State Persistence", () => { - test("persists state between actor instances", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create instance and increment - const counterInstance = client.counter.getOrCreate(); - const initialCount = await counterInstance.increment(5); - expect(initialCount).toBe(5); - - // Get a fresh reference to the same actor and verify state persisted - const sameInstance = client.counter.getOrCreate(); - const persistedCount = await sameInstance.increment(3); - expect(persistedCount).toBe(8); - }); - - test("restores state after actor disconnect/reconnect", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and set initial state - const counterInstance = client.counter.getOrCreate(); - await counterInstance.increment(5); - - // Reconnect to the same actor - const reconnectedInstance = client.counter.getOrCreate(); - const persistedCount = await reconnectedInstance.increment(0); - expect(persistedCount).toBe(5); - }); - - test("maintains separate state for different actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create first counter with specific key - const counterA = client.counter.getOrCreate(["counter-a"]); - await counterA.increment(5); - - // Create second counter with different key - const counterB = client.counter.getOrCreate(["counter-b"]); - await counterB.increment(10); - - // Verify state is separate - const countA = await counterA.increment(0); - const countB = await counterB.increment(0); - expect(countA).toBe(5); - expect(countB).toBe(10); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-stateless.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-stateless.ts deleted file mode 100644 index 063e526759..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-stateless.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorStatelessTests(driverTestConfig: DriverTestConfig) { - describe("Actor Stateless Tests", () => { - describe("Stateless Actor Operations", () => { - test("can call actions on stateless actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.statelessActor.getOrCreate(); - - const result = await instance.ping(); - expect(result).toBe("pong"); - }); - - test("can echo messages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.statelessActor.getOrCreate(); - - const message = "Hello, World!"; - const result = await instance.echo(message); - expect(result).toBe(message); - }); - - test("can access actorId", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.statelessActor.getOrCreate(["test-id"]); - - const actorId = await instance.getActorId(); - expect(actorId).toBeDefined(); - expect(typeof actorId).toBe("string"); - }); - - test("accessing state throws StateNotEnabled", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.statelessActor.getOrCreate(); - - const result = await instance.tryGetState(); - expect(result.success).toBe(false); - expect(result.error).toContain("state"); - }); - - test("accessing db throws DatabaseNotEnabled", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.statelessActor.getOrCreate(); - - const result = await instance.tryGetDb(); - expect(result.success).toBe(false); - expect(result.error).toContain("database"); - }); - - test("multiple stateless actors can exist independently", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actor1 = client.statelessActor.getOrCreate(["actor-1"]); - const actor2 = client.statelessActor.getOrCreate(["actor-2"]); - - const id1 = await actor1.getActorId(); - const id2 = await actor2.getActorId(); - - expect(id1).not.toBe(id2); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-vars.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-vars.ts deleted file mode 100644 index 394c2fb526..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-vars.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runActorVarsTests(driverTestConfig: DriverTestConfig) { - describe("Actor Variables", () => { - describe("Static vars", () => { - test("should provide access to static vars", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const instance = client.staticVarActor.getOrCreate(); - - // Test accessing vars - const result = await instance.getVars(); - expect(result).toEqual({ counter: 42, name: "test-actor" }); - - // Test accessing specific var property - const name = await instance.getName(); - expect(name).toBe("test-actor"); - }); - }); - - describe("Deep cloning of static vars", () => { - test("should deep clone static vars between actor instances", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two separate instances - const instance1 = client.nestedVarActor.getOrCreate([ - "instance1", - ]); - const instance2 = client.nestedVarActor.getOrCreate([ - "instance2", - ]); - - // Modify vars in the first instance - const modifiedVars = await instance1.modifyNested(); - expect(modifiedVars.nested.value).toBe("modified"); - expect(modifiedVars.nested.array).toContain(4); - expect(modifiedVars.nested.obj.key).toBe("new-value"); - - // Check that the second instance still has the original values - const instance2Vars = await instance2.getVars(); - expect(instance2Vars.nested.value).toBe("original"); - expect(instance2Vars.nested.array).toEqual([1, 2, 3]); - expect(instance2Vars.nested.obj.key).toBe("value"); - }); - }); - - describe("createVars", () => { - test("should support dynamic vars creation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create an instance - const instance = client.dynamicVarActor.getOrCreate(); - - // Test accessing dynamically created vars - const vars = await instance.getVars(); - expect(vars).toHaveProperty("random"); - expect(vars).toHaveProperty("computed"); - expect(typeof vars.random).toBe("number"); - expect(typeof vars.computed).toBe("string"); - expect(vars.computed).toMatch(/^Actor-\d+$/); - }); - - test("should create different vars for different instances", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create two separate instances - const instance1 = client.uniqueVarActor.getOrCreate(["test1"]); - const instance2 = client.uniqueVarActor.getOrCreate(["test2"]); - - // Get vars from both instances - const vars1 = await instance1.getVars(); - const vars2 = await instance2.getVars(); - - // Verify they have different values - expect(vars1.id).not.toBe(vars2.id); - }); - }); - - describe("Driver Context", () => { - test("should provide access to driver context", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create an instance - const instance = client.driverCtxActor.getOrCreate(); - - // Test accessing driver context through vars - const vars = await instance.getVars(); - - // Driver context might or might not be available depending on the driver - // But the test should run without errors - expect(vars).toHaveProperty("hasDriverCtx"); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts deleted file mode 100644 index fa0cca1400..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type { ActorError } from "@/client/mod"; -import { - WORKFLOW_NESTED_QUEUE_NAME, - WORKFLOW_QUEUE_NAME, -} from "../../../fixtures/driver-test-suite/workflow"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runActorWorkflowTests(driverTestConfig: DriverTestConfig) { - describe("Actor Workflow Tests", () => { - test("replays steps and guards state access", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowCounterActor.getOrCreate([ - "workflow-basic", - ]); - - let state = await actor.getState(); - for (let i = 0; i < 50; i++) { - if ( - state.runCount > 0 && - state.history.length > 0 && - state.guardTriggered - ) { - break; - } - await waitFor(driverTestConfig, 100); - state = await actor.getState(); - } - expect(state.runCount).toBeGreaterThan(0); - expect(state.history.length).toBeGreaterThan(0); - expect(state.guardTriggered).toBe(true); - }); - - test("consumes queue messages via workflow queue.next", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowQueueActor.getOrCreate([ - "workflow-queue", - ]); - - await actor.send(WORKFLOW_QUEUE_NAME, { - hello: "world", - }); - - await waitFor(driverTestConfig, 200); - const messages = await actor.getMessages(); - expect(messages).toEqual([{ hello: "world" }]); - }); - - test("workflow queue.next supports completing wait sends", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowQueueActor.getOrCreate([ - "workflow-queue-wait", - ]); - - const result = await actor.sendAndWait({ value: 123 }); - expect(result).toEqual({ - status: "completed", - response: { echo: { value: 123 } }, - }); - }); - - for (const testCase of [ - { - name: "nested loops", - key: "loop" as const, - getActor: ( - client: Awaited< - ReturnType - >["client"], - ) => client.workflowNestedLoopActor, - firstItems: ["a", "b"], - secondItems: ["c"], - expected: ["a", "b", "c"], - }, - { - name: "nested joins", - key: "join" as const, - getActor: ( - client: Awaited< - ReturnType - >["client"], - ) => client.workflowNestedJoinActor, - firstItems: ["a", "b"], - secondItems: ["c"], - expected: ["a", "b", "c"], - }, - { - name: "nested races", - key: "race" as const, - getActor: ( - client: Awaited< - ReturnType - >["client"], - ) => client.workflowNestedRaceActor, - firstItems: ["a"], - secondItems: ["b"], - expected: ["a", "b"], - }, - ]) { - test(`replays ${testCase.name} across workflow queue iterations`, async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = testCase - .getActor(client) - .getOrCreate([`workflow-nested-${testCase.key}`]); - - const first = await actor.send( - WORKFLOW_NESTED_QUEUE_NAME, - { - items: testCase.firstItems, - }, - { - wait: true, - timeout: 1_000, - }, - ); - expect(first).toEqual({ - status: "completed", - response: { - processed: testCase.firstItems.length, - }, - }); - - const second = await actor.send( - WORKFLOW_NESTED_QUEUE_NAME, - { - items: testCase.secondItems, - }, - { - wait: true, - timeout: 1_000, - }, - ); - expect(second).toEqual({ - status: "completed", - response: { - processed: testCase.secondItems.length, - }, - }); - - const state = await actor.getState(); - expect(state.processed).toEqual(testCase.expected); - }); - } - - test("starts child workflows created inside workflow steps", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const parent = client.workflowSpawnParentActor.getOrCreate([ - "workflow-spawn-parent", - ]); - - expect(await parent.triggerSpawn("child-1")).toEqual({ - queued: true, - }); - - let parentState = await parent.getState(); - for (let i = 0; i < 30 && parentState.results.length === 0; i++) { - await waitFor(driverTestConfig, 100); - parentState = await parent.getState(); - } - - expect(parentState.results).toEqual([ - { - key: "child-1", - result: { - status: "completed", - response: { ok: true }, - }, - error: null, - }, - ]); - - const child = client.workflowSpawnChildActor.getOrCreate([ - "child-1", - ]); - const childState = await child.getState(); - expect(childState).toEqual({ - label: "child-1", - started: true, - processed: ["hello"], - }); - }); - - test("db and client are step-only in workflow context", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowAccessActor.getOrCreate([ - "workflow-access", - ]); - - let state = await actor.getState(); - for (let i = 0; i < 20 && state.insideDbCount === 0; i++) { - await waitFor(driverTestConfig, 50); - state = await actor.getState(); - } - - expect(state.outsideDbError).toBe( - "db is only available inside workflow steps", - ); - expect(state.outsideClientError).toBe( - "client is only available inside workflow steps", - ); - expect(state.insideDbCount).toBeGreaterThan(0); - expect(state.insideClientAvailable).toBe(true); - }); - - test("tryStep and try recover terminal workflow failures", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowTryActor.getOrCreate(["workflow-try"]); - - let state = await actor.getState(); - for ( - let i = 0; - i < 40 && - (state.tryStepFailure === null || state.tryJoinFailure === null); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await actor.getState(); - } - - expect(state.innerWrites).toBe(1); - expect(state.tryStepFailure).toEqual({ - kind: "exhausted", - message: "card declined", - attempts: 1, - }); - expect(state.tryJoinFailure).toBe("join:parallel"); - }); - - test("sleeps and resumes between ticks", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowSleepActor.getOrCreate([ - "workflow-sleep", - ]); - - const initial = await actor.getState(); - await waitFor(driverTestConfig, 200); - const next = await actor.getState(); - - expect(next.ticks).toBeGreaterThan(initial.ticks); - }); - - test("workflow onError reports retry metadata", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowErrorHookActor.getOrCreate([ - "workflow-error-hook", - ]); - - let state = await actor.getErrorState(); - for ( - let i = 0; - i < 80 && (state.attempts < 2 || state.events.length === 0); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await actor.getErrorState(); - } - - expect(state.attempts).toBe(2); - expect(state.events).toHaveLength(1); - expect(state.events[0]).toEqual( - expect.objectContaining({ - step: expect.objectContaining({ - stepName: "flaky", - attempt: 1, - willRetry: true, - retryDelay: 1, - error: expect.objectContaining({ - name: "Error", - message: "workflow hook failed", - }), - }), - }), - ); - }); - - test("workflow onError can update actor state", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowErrorHookEffectsActor.getOrCreate([ - "workflow-error-state", - ]); - - await actor.startWorkflow(); - - let state = await actor.getErrorState(); - for ( - let i = 0; - i < 80 && - (state.attempts < 2 || - state.lastError === null || - state.errorCount === 0); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await actor.getErrorState(); - } - - expect(state.attempts).toBe(2); - expect(state.errorCount).toBe(1); - expect(state.lastError).toEqual( - expect.objectContaining({ - step: expect.objectContaining({ - stepName: "flaky", - attempt: 1, - willRetry: true, - retryDelay: 1, - error: expect.objectContaining({ - name: "Error", - message: "workflow hook failed", - }), - }), - }), - ); - }); - - test("workflow onError can broadcast actor events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowErrorHookEffectsActor - .getOrCreate(["workflow-error-broadcast"]) - .connect(); - - try { - const eventPromise = new Promise((resolve) => { - actor.once("workflowError", resolve); - }); - - await actor.startWorkflow(); - - const event = await eventPromise; - expect(event).toEqual( - expect.objectContaining({ - step: expect.objectContaining({ - stepName: "flaky", - attempt: 1, - willRetry: true, - retryDelay: 1, - error: expect.objectContaining({ - name: "Error", - message: "workflow hook failed", - }), - }), - }), - ); - } finally { - await actor.dispose(); - } - }); - - test("workflow onError can enqueue actor messages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowErrorHookEffectsActor.getOrCreate([ - "workflow-error-queue", - ]); - - await actor.startWorkflow(); - - const queuedError = await actor.receiveQueuedError(); - expect(queuedError).toEqual( - expect.objectContaining({ - step: expect.objectContaining({ - stepName: "flaky", - attempt: 1, - willRetry: true, - retryDelay: 1, - error: expect.objectContaining({ - name: "Error", - message: "workflow hook failed", - }), - }), - }), - ); - }); - - test.skipIf(driverTestConfig.skip?.sleep)( - "completed workflows sleep instead of destroying the actor", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowCompleteActor.getOrCreate([ - "workflow-complete", - ]); - - let state = await actor.getState(); - for (let i = 0; i < 10 && state.sleepCount === 0; i++) { - await waitFor(driverTestConfig, 100); - state = await actor.getState(); - } - expect(state.runCount).toBeGreaterThan(0); - expect(state.sleepCount).toBeGreaterThan(0); - expect(state.startCount).toBeGreaterThan(1); - }, - ); - - test("workflow steps can destroy the actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actorKey = "workflow-destroy"; - const observer = client.destroyObserver.getOrCreate(["observer"]); - await observer.reset(); - - const actor = client.workflowDestroyActor.getOrCreate([actorKey]); - const actorId = await actor.resolve(); - - await vi.waitFor(async () => { - const wasDestroyed = await observer.wasDestroyed(actorKey); - expect(wasDestroyed, "actor onDestroy not called").toBeTruthy(); - }); - - await vi.waitFor(async () => { - let actorRunning = false; - try { - await client.workflowDestroyActor - .getForId(actorId) - .resolve(); - actorRunning = true; - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - expect(actorRunning, "actor still running").toBeFalsy(); - }); - }); - - test.skipIf(driverTestConfig.skip?.sleep)( - "failed workflow steps sleep instead of surfacing as run errors", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowFailedStepActor.getOrCreate([ - "workflow-failed-step", - ]); - - let state = await actor.getState(); - for (let i = 0; i < 10 && state.sleepCount === 0; i++) { - await waitFor(driverTestConfig, 100); - state = await actor.getState(); - } - expect(state.runCount).toBeGreaterThan(0); - expect(state.sleepCount).toBeGreaterThan(0); - expect(state.startCount).toBeGreaterThan(1); - }, - ); - - test.skipIf(driverTestConfig.skip?.sleep)( - "workflow onError is not reported again after sleep and wake", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowErrorHookSleepActor.getOrCreate([ - "workflow-error-hook-sleep", - ]); - - let state = await actor.getErrorState(); - for ( - let i = 0; - i < 80 && (state.attempts < 2 || state.events.length === 0); - i++ - ) { - await waitFor(driverTestConfig, 50); - state = await actor.getErrorState(); - } - - expect(state.attempts).toBe(2); - expect(state.events).toHaveLength(1); - expect(state.wakeCount).toBe(1); - - await actor.triggerSleep(); - await waitFor(driverTestConfig, 250); - - let resumedState = await actor.getErrorState(); - for ( - let i = 0; - i < 40 && - (resumedState.wakeCount < 2 || resumedState.sleepCount < 1); - i++ - ) { - await waitFor(driverTestConfig, 50); - resumedState = await actor.getErrorState(); - } - - expect(resumedState.sleepCount).toBeGreaterThanOrEqual(1); - expect(resumedState.wakeCount).toBeGreaterThanOrEqual(2); - expect(resumedState.attempts).toBe(2); - expect(resumedState.events).toHaveLength(1); - }, - ); - - test.skipIf(driverTestConfig.skip?.sleep)( - "workflow run teardown does not wait for runStopTimeout", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.workflowStopTeardownActor.getOrCreate([ - "workflow-stop-teardown", - ]); - - await actor.getTimeline(); - await waitFor(driverTestConfig, 1_200); - const timeline = await actor.getTimeline(); - - expect(timeline.wakeAts.length).toBeGreaterThanOrEqual(2); - expect(timeline.sleepAts.length).toBeGreaterThanOrEqual(1); - - const firstSleepDelayMs = - timeline.sleepAts[0] - timeline.wakeAts[0]; - expect(firstSleepDelayMs).toBeLessThan(1_800); - }, - ); - - // NOTE: Test for workflow persistence across actor sleep is complex because - // calling c.sleep() during a workflow prevents clean shutdown. The workflow - // persistence is implicitly tested by the "sleeps and resumes between ticks" - // test which verifies the workflow continues from persisted state. - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/conn-error-serialization.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/conn-error-serialization.ts deleted file mode 100644 index 2eae2ed6b8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/conn-error-serialization.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runConnErrorSerializationTests( - driverTestConfig: DriverTestConfig, -) { - describe("Connection Error Serialization Tests", () => { - test("error thrown in createConnState preserves group and code through WebSocket serialization", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-error-serialization-${Date.now()}`; - - // Create actor handle with params that will trigger error in createConnState - const actor = client.connErrorSerializationActor.getOrCreate( - [actorKey], - { params: { shouldThrow: true } }, - ); - - // Try to connect, which will trigger error in createConnState - const conn = actor.connect(); - - // Wait for connection to fail - let caughtError: any; - try { - // Try to call an action, which should fail because connection couldn't be established - await conn.getValue(); - } catch (err) { - caughtError = err; - } - - // Verify the error was caught - expect(caughtError).toBeDefined(); - - // Verify the error has the correct group and code from the original error - // Original error: new CustomConnectionError("...") with group="connection", code="custom_error" - expect(caughtError.group).toBe("connection"); - expect(caughtError.code).toBe("custom_error"); - - // Clean up - await conn.dispose(); - }); - - test("successful createConnState does not throw error", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const actorKey = `test-no-error-${Date.now()}`; - - // Create actor handle with params that will NOT trigger error - const actor = client.connErrorSerializationActor.getOrCreate( - [actorKey], - { params: { shouldThrow: false } }, - ); - - // Connect without triggering error - const conn = actor.connect(); - - // This should succeed - const value = await conn.getValue(); - expect(value).toBe(0); - - // Clean up - await conn.dispose(); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/cross-backend-vfs.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/cross-backend-vfs.ts deleted file mode 100644 index 7f203d0abe..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/cross-backend-vfs.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - nativeSqliteAvailable, - _resetNativeDetection, -} from "@/db/native-sqlite"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const SLEEP_WAIT_MS = 500; -const CROSS_BACKEND_TIMEOUT_MS = 30_000; - -/** - * Cross-backend VFS compatibility tests. - * - * Verifies that data written by the WASM VFS can be read by the native VFS - * and vice versa. Both VFS implementations store data in the same KV format - * (chunk keys, chunk data, metadata encoding). These tests catch encoding - * mismatches like the metadata version prefix difference fixed in US-024. - * - * Skipped when the native SQLite addon is not available. - */ -export function runCrossBackendVfsTests(driverTestConfig: DriverTestConfig) { - const nativeAvailable = nativeSqliteAvailable(); - - describe.skipIf(!nativeAvailable)( - "Cross-Backend VFS Compatibility Tests", - () => { - test( - "WASM-to-native: data written with WASM VFS is readable with native VFS", - async (c) => { - // Restore native detection on cleanup - c.onTestFinished(async () => { - await _resetNativeDetection(); - }); - - // Phase 1: Force WASM VFS - await _resetNativeDetection(true); - - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actorId = `cross-w2n-${crypto.randomUUID()}`; - const actor = client.dbActorRaw.getOrCreate([actorId]); - - // Write structured data with various sizes to exercise - // chunk boundaries (CHUNK_SIZE = 4096). - await actor.insertValue("wasm-alpha"); - await actor.insertValue("wasm-beta"); - await actor.insertMany(10); - - // Large payload spanning multiple chunks - const { id: largeId } = - await actor.insertPayloadOfSize(8192); - - const wasmCount = await actor.getCount(); - expect(wasmCount).toBe(13); - - const wasmValues = await actor.getValues(); - const wasmLargePayloadSize = - await actor.getPayloadSize(largeId); - expect(wasmLargePayloadSize).toBe(8192); - - // Sleep the actor to flush all data to KV - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - - // Phase 2: Restore native VFS detection - await _resetNativeDetection(); - - // Recreate the actor. The db() provider now uses native - // SQLite, reading data written by the WASM VFS. - const actor2 = client.dbActorRaw.getOrCreate([actorId]); - - const nativeCount = await actor2.getCount(); - expect(nativeCount).toBe(13); - - const nativeValues = await actor2.getValues(); - expect(nativeValues).toHaveLength(wasmValues.length); - for (let i = 0; i < wasmValues.length; i++) { - expect(nativeValues[i].value).toBe( - wasmValues[i].value, - ); - } - - const nativeLargePayloadSize = - await actor2.getPayloadSize(largeId); - expect(nativeLargePayloadSize).toBe(8192); - - // Verify integrity - const integrity = await actor2.integrityCheck(); - expect(integrity).toBe("ok"); - }, - CROSS_BACKEND_TIMEOUT_MS, - ); - - test( - "native-to-WASM: data written with native VFS is readable with WASM VFS", - async (c) => { - // Restore native detection on cleanup - c.onTestFinished(async () => { - await _resetNativeDetection(); - }); - - // Phase 1: Use native VFS (default when addon is available) - await _resetNativeDetection(); - - const { client } = await setupDriverTest( - c, - driverTestConfig, - ); - const actorId = `cross-n2w-${crypto.randomUUID()}`; - const actor = client.dbActorRaw.getOrCreate([actorId]); - - // Write structured data with various sizes - await actor.insertValue("native-alpha"); - await actor.insertValue("native-beta"); - await actor.insertMany(10); - - // Large payload spanning multiple chunks - const { id: largeId } = - await actor.insertPayloadOfSize(8192); - - const nativeCount = await actor.getCount(); - expect(nativeCount).toBe(13); - - const nativeValues = await actor.getValues(); - const nativeLargePayloadSize = - await actor.getPayloadSize(largeId); - expect(nativeLargePayloadSize).toBe(8192); - - // Sleep the actor to flush all data to KV - await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); - - // Phase 2: Force WASM VFS - await _resetNativeDetection(true); - - // Recreate the actor. The db() provider now uses WASM - // SQLite, reading data written by the native VFS. - const actor2 = client.dbActorRaw.getOrCreate([actorId]); - - const wasmCount = await actor2.getCount(); - expect(wasmCount).toBe(13); - - const wasmValues = await actor2.getValues(); - expect(wasmValues).toHaveLength(nativeValues.length); - for (let i = 0; i < nativeValues.length; i++) { - expect(wasmValues[i].value).toBe( - nativeValues[i].value, - ); - } - - const wasmLargePayloadSize = - await actor2.getPayloadSize(largeId); - expect(wasmLargePayloadSize).toBe(8192); - - // Verify integrity - const integrity = await actor2.integrityCheck(); - expect(integrity).toBe("ok"); - }, - CROSS_BACKEND_TIMEOUT_MS, - ); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/dynamic-reload.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/dynamic-reload.ts deleted file mode 100644 index 9460b13671..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/dynamic-reload.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -export function runDynamicReloadTests(driverTestConfig: DriverTestConfig) { - describe.skipIf(!driverTestConfig.isDynamic || driverTestConfig.skip?.sleep)( - "Dynamic Actor Reload Tests", - () => { - test("reload forces dynamic actor to sleep and reload on next request", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.sleep.getOrCreate(); - - const { startCount: before } = await actor.getCounts(); - expect(before).toBe(1); - - await actor.reload(); - await waitFor(driverTestConfig, 250); - - const { startCount: after } = await actor.getCounts(); - expect(after).toBe(2); - }); - }, - ); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-query-url.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-query-url.ts deleted file mode 100644 index 1aa7aa7742..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-query-url.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runGatewayQueryUrlTests(driverTestConfig: DriverTestConfig) { - describe("Gateway Query URLs", () => { - const httpOnlyTest = - driverTestConfig.clientType === "http" ? test : test.skip; - - httpOnlyTest( - "getOrCreate gateway URLs use rvt-* query params and resolve through the gateway", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["gateway-query"]); - - await handle.increment(5); - - const gatewayUrl = await handle.getGatewayUrl(); - const parsedUrl = new URL(gatewayUrl); - expect(parsedUrl.searchParams.get("rvt-namespace")).toBeTruthy(); - expect(parsedUrl.searchParams.get("rvt-method")).toBe("getOrCreate"); - expect(parsedUrl.searchParams.get("rvt-crash-policy")).toBe("sleep"); - - const response = await fetch(`${gatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer token" }, - }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - state: { count: 5 }, - isStateEnabled: true, - }); - }, - ); - - httpOnlyTest( - "get gateway URLs use rvt-* query params and resolve through the gateway", - async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const createHandle = client.counter.getOrCreate([ - "existing-gateway-query", - ]); - await createHandle.increment(2); - - const gatewayUrl = await client.counter - .get(["existing-gateway-query"]) - .getGatewayUrl(); - const parsedUrl = new URL(gatewayUrl); - expect(parsedUrl.searchParams.get("rvt-namespace")).toBeTruthy(); - expect(parsedUrl.searchParams.get("rvt-method")).toBe("get"); - - const response = await fetch(`${gatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer token" }, - }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - state: { count: 2 }, - isStateEnabled: true, - }); - }, - ); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/hibernatable-websocket-protocol.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/hibernatable-websocket-protocol.ts deleted file mode 100644 index e61bff8299..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/hibernatable-websocket-protocol.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { getHibernatableWebSocketAckState } from "@/common/websocket-test-hooks"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const HIBERNATABLE_ACK_SETTLE_TIMEOUT_MS = 12_000; - -async function waitForJsonMessage( - ws: WebSocket, - timeoutMs: number, -): Promise | undefined> { - const messagePromise = new Promise | undefined>( - (resolve, reject) => { - ws.addEventListener( - "message", - (event: any) => { - try { - resolve(JSON.parse(event.data as string)); - } catch { - resolve(undefined); - } - }, - { once: true }, - ); - ws.addEventListener("close", reject, { once: true }); - }, - ); - - return await Promise.race([ - messagePromise, - new Promise((resolve) => - setTimeout(() => resolve(undefined), timeoutMs), - ), - ]); -} - -async function waitForMatchingJsonMessages( - ws: WebSocket, - count: number, - matcher: (message: Record) => boolean, - timeoutMs: number, -): Promise>> { - return await new Promise>>( - (resolve, reject) => { - const messages: Array> = []; - const timeout = setTimeout(() => { - cleanup(); - reject( - new Error( - `timed out waiting for ${count} matching websocket messages`, - ), - ); - }, timeoutMs); - const onMessage = (event: { data: string }) => { - let parsed: Record | undefined; - try { - parsed = JSON.parse(event.data as string); - } catch { - return; - } - if (!parsed) { - return; - } - if (!matcher(parsed)) { - return; - } - messages.push(parsed); - if (messages.length >= count) { - cleanup(); - resolve(messages); - } - }; - const onClose = (event: unknown) => { - cleanup(); - reject(event); - }; - const cleanup = () => { - clearTimeout(timeout); - ws.removeEventListener("message", onMessage as (event: any) => void); - ws.removeEventListener("close", onClose as (event: any) => void); - }; - ws.addEventListener("message", onMessage as (event: any) => void); - ws.addEventListener("close", onClose as (event: any) => void, { - once: true, - }); - }, - ); -} - -async function readHibernatableAckState(websocket: WebSocket): Promise<{ - lastSentIndex: number; - lastAckedIndex: number; - pendingIndexes: number[]; -}> { - const hookUnavailableErrorPattern = - /remote hibernatable websocket ack hooks are unavailable/; - for (let attempt = 0; attempt < 20; attempt += 1) { - try { - const state = getHibernatableWebSocketAckState( - websocket as unknown as any, - ); - if (state) { - return state; - } - } catch (error) { - if ( - error instanceof Error && - hookUnavailableErrorPattern.test(error.message) - ) { - await new Promise((resolve) => setTimeout(resolve, 25)); - continue; - } - throw error; - } - } - - websocket.send( - JSON.stringify({ - __rivetkitTestHibernatableAckStateV1: true, - }), - ); - const message = await waitForJsonMessage(websocket, 1_000); - expect(message).toBeDefined(); - expect(message?.__rivetkitTestHibernatableAckStateV1).toBe(true); - - return { - lastSentIndex: message?.lastSentIndex as number, - lastAckedIndex: message?.lastAckedIndex as number, - pendingIndexes: message?.pendingIndexes as number[], - }; -} - -export function runHibernatableWebSocketProtocolTests( - driverTestConfig: DriverTestConfig, -) { - describe.skipIf( - !driverTestConfig.features?.hibernatableWebSocketProtocol, - )("hibernatable websocket protocol", () => { - test( - "replays only unacked indexed websocket messages after sleep and wake", - async (c) => { - if (driverTestConfig.clientType !== "http") { - return; - } - - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "hibernatable-replay", - ]); - const ws = await actor.webSocket(); - - try { - expect(await waitForJsonMessage(ws, 4_000)).toMatchObject({ - type: "welcome", - }); - - const firstProbePromise = waitForMatchingJsonMessages( - ws, - 1, - (message) => message.type === "indexedAckProbe", - 1_000, - ); - ws.send( - JSON.stringify({ - type: "indexedAckProbe", - payload: "durable-before-sleep", - }), - ); - expect((await firstProbePromise)[0]).toMatchObject({ - type: "indexedAckProbe", - rivetMessageIndex: 1, - }); - - await vi.waitFor( - async () => { - expect(await readHibernatableAckState(ws)).toEqual({ - lastSentIndex: 1, - lastAckedIndex: 1, - pendingIndexes: [], - }); - }, - { timeout: HIBERNATABLE_ACK_SETTLE_TIMEOUT_MS, interval: 50 }, - ); - - const sleepScheduledPromise = waitForMatchingJsonMessages( - ws, - 1, - (message) => message.type === "sleepScheduled", - 1_000, - ); - ws.send( - JSON.stringify({ - type: "scheduleSleep", - }), - ); - await sleepScheduledPromise; - await waitFor(driverTestConfig, 250); - - const replayedMessagesPromise = waitForMatchingJsonMessages( - ws, - 2, - (message) => message.type === "indexedEcho", - 6_000, - ); - ws.send( - JSON.stringify({ - type: "indexedEcho", - payload: "after-sleep-1", - }), - ); - ws.send( - JSON.stringify({ - type: "indexedEcho", - payload: "after-sleep-2", - }), - ); - - const replayedIndexes = (await replayedMessagesPromise).map( - (message) => message.rivetMessageIndex as number, - ); - - expect(replayedIndexes).toEqual([3, 4]); - - await vi.waitFor( - async () => { - expect(await readHibernatableAckState(ws)).toEqual({ - lastSentIndex: 4, - lastAckedIndex: 4, - pendingIndexes: [], - }); - }, - { timeout: HIBERNATABLE_ACK_SETTLE_TIMEOUT_MS, interval: 50 }, - ); - - const actorObservedOrderPromise = waitForMatchingJsonMessages( - ws, - 1, - (message) => message.type === "indexedMessageOrder", - 1_000, - ); - ws.send( - JSON.stringify({ - type: "getIndexedMessageOrder", - }), - ); - expect((await actorObservedOrderPromise)[0].order).toEqual([1, 3, 4]); - } finally { - ws.close(); - } - }, - 20_000, - ); - - test( - "cleans up stale hibernatable websocket connections on restore", - async (c) => { - if (driverTestConfig.clientType !== "http") { - return; - } - - const { client } = await setupDriverTest(c, driverTestConfig); - const conn = client.fileSystemHibernationCleanupActor - .getOrCreate() - .connect(); - let wakeConn: typeof conn | undefined; - let connDisposed = false; - - try { - expect(await conn.ping()).toBe("pong"); - await conn.triggerSleep(); - await waitFor(driverTestConfig, 700); - - // Disconnect the original client while the actor is asleep so the - // persisted websocket metadata is stale on the next wake. - await conn.dispose(); - connDisposed = true; - await waitFor(driverTestConfig, 100); - - // Wake the actor through a new connection so restore must clean up - // the stale persisted websocket from the sleeping generation. - wakeConn = client.fileSystemHibernationCleanupActor - .getOrCreate() - .connect(); - - await vi.waitFor( - async () => { - const counts = await wakeConn!.getCounts(); - expect(counts.sleepCount).toBeGreaterThanOrEqual(1); - expect(counts.wakeCount).toBeGreaterThanOrEqual(2); - }, - { timeout: 5_000, interval: 100 }, - ); - - await vi.waitFor( - async () => { - const disconnectWakeCounts = - await wakeConn!.getDisconnectWakeCounts(); - expect(disconnectWakeCounts).toEqual([2]); - }, - { timeout: 5_000, interval: 100 }, - ); - } finally { - await wakeConn?.dispose().catch(() => undefined); - if (!connDisposed) { - await conn.dispose().catch(() => undefined); - } - } - }, - 15_000, - ); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/manager-driver.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/manager-driver.ts deleted file mode 100644 index 1044ccd2fc..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/manager-driver.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ActorError } from "@/client/mod"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { - describe("Manager Driver Tests", () => { - describe("Client Connection Methods", () => { - test("connect() - finds or creates a actor", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Basic connect() with no parameters creates a default actor - const counterA = client.counter.getOrCreate(); - await counterA.increment(5); - - // Get the same actor again to verify state persisted - const counterAAgain = client.counter.getOrCreate(); - const count = await counterAAgain.increment(0); - expect(count).toBe(5); - - // Connect with key creates a new actor with specific parameters - const counterB = client.counter.getOrCreate([ - "counter-b", - "testing", - ]); - - await counterB.increment(10); - const countB = await counterB.increment(0); - expect(countB).toBe(10); - }); - - test("throws ActorAlreadyExists when creating duplicate actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a unique actor with specific key - const uniqueKey = ["duplicate-actor-test", crypto.randomUUID()]; - const counter = client.counter.getOrCreate(uniqueKey); - await counter.increment(5); - - // Expect duplicate actor - try { - await client.counter.create(uniqueKey); - expect.fail("did not error on duplicate create"); - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("duplicate_key"); - } - - // Verify the original actor still works and has its state - const count = await counter.increment(0); - expect(count).toBe(5); - }); - }); - - describe("Connection Options", () => { - test("get without create prevents actor creation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Try to get a nonexistent actor with no create - const nonexistentId = `nonexistent-${crypto.randomUUID()}`; - - // Should fail when actor doesn't exist - try { - await client.counter.get([nonexistentId]).resolve(); - expect.fail("did not error for get"); - } catch (err) { - expect((err as ActorError).group).toBe("actor"); - expect((err as ActorError).code).toBe("not_found"); - } - - // Create the actor - const createdCounter = - client.counter.getOrCreate(nonexistentId); - await createdCounter.increment(3); - - // Now no create should work since the actor exists - const retrievedCounter = client.counter.get(nonexistentId); - - const count = await retrievedCounter.increment(0); - expect(count).toBe(3); - }); - - test("connection params are passed to actors", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a actor with connection params - // Note: In a real test we'd verify these are received by the actor, - // but our simple counter actor doesn't use connection params. - // This test just ensures the params are accepted by the driver. - const counter = client.counter.getOrCreate(undefined, { - params: { - userId: "user-123", - authToken: "token-abc", - settings: { increment: 5 }, - }, - }); - - await counter.increment(1); - const count = await counter.increment(0); - expect(count).toBe(1); - }); - }); - - describe("Actor Creation & Retrieval", () => { - test("creates and retrieves actors by ID", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a unique ID for this test - const uniqueId = `test-counter-${crypto.randomUUID()}`; - - // Create actor with specific ID - const counter = client.counter.getOrCreate([uniqueId]); - await counter.increment(10); - - // Retrieve the same actor by ID and verify state - const retrievedCounter = client.counter.getOrCreate([uniqueId]); - const count = await retrievedCounter.increment(0); // Get current value - expect(count).toBe(10); - }); - - test("passes input to actor during creation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Test data to pass as input - const testInput = { - name: "test-actor", - value: 42, - nested: { foo: "bar" }, - }; - - // Create actor with input - const actor = await client.inputActor.create(undefined, { - input: testInput, - }); - - // Verify both createState and onCreate received the input - const inputs = await actor.getInputs(); - - // Input should be available in createState - expect(inputs.initialInput).toEqual(testInput); - - // Input should also be available in onCreate lifecycle hook - expect(inputs.onCreateInput).toEqual(testInput); - }); - - test("input is undefined when not provided", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor without providing input - const actor = await client.inputActor.create(); - - // Get inputs and verify they're undefined - const inputs = await actor.getInputs(); - - // Should be undefined in createState - expect(inputs.initialInput).toBeUndefined(); - - // Should be undefined in onCreate lifecycle hook too - expect(inputs.onCreateInput).toBeUndefined(); - }); - - test("getOrCreate passes input to actor during creation", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a unique key for this test - const uniqueKey = [`input-test-${crypto.randomUUID()}`]; - - // Test data to pass as input - const testInput = { - name: "getorcreate-test", - value: 100, - nested: { baz: "qux" }, - }; - - // Use getOrCreate with input - const actor = client.inputActor.getOrCreate(uniqueKey, { - createWithInput: testInput, - }); - - // Verify both createState and onCreate received the input - const inputs = await actor.getInputs(); - - // Input should be available in createState - expect(inputs.initialInput).toEqual(testInput); - - // Input should also be available in onCreate lifecycle hook - expect(inputs.onCreateInput).toEqual(testInput); - - // Verify that calling getOrCreate again with the same key - // returns the existing actor and doesn't create a new one - const existingActor = client.inputActor.getOrCreate(uniqueKey); - const existingInputs = await existingActor.getInputs(); - - // Should still have the original inputs - expect(existingInputs.initialInput).toEqual(testInput); - expect(existingInputs.onCreateInput).toEqual(testInput); - }); - - // TODO: Correctly test region for each provider - //test("creates and retrieves actors with region", async (c) => { - // const { client } = await setupDriverTest(c, - // driverTestConfig, - // COUNTER_APP_PATH - // ); - // - // // Create actor with a specific region - // const counter = client.counter.getOrCreate({ - // create: { - // key: ["metadata-test", "testing"], - // region: "test-region", - // }, - // }); - // - // // Set state to identify this specific instance - // await counter.increment(42); - // - // // Retrieve by ID (since metadata is not used for retrieval) - // const retrievedCounter = client.counter.getOrCreate(["metadata-test"]); - // - // // Verify it's the same instance - // const count = await retrievedCounter.increment(0); - // expect(count).toBe(42); - //}); - }); - - describe("Key Matching", () => { - test("multi-part actor keys are passed through correctly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create an actor with a multi-part key - const multiPartKey = ["tenant/with/slash", "room"]; - const counter = client.counter.getOrCreate(multiPartKey); - - // Should be preserved as a multi-part key (["tenant/with/slash", "room"]) - expect(await counter.getKey()).toEqual(multiPartKey); - }); - - test("matches actors only with exactly the same keys", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with multiple keys - const originalCounter = client.counter.getOrCreate([ - "counter-match", - "test", - "us-east", - ]); - await originalCounter.increment(10); - - // Should match with exact same keys - const exactMatchCounter = client.counter.getOrCreate([ - "counter-match", - "test", - "us-east", - ]); - const exactMatchCount = await exactMatchCounter.increment(0); - expect(exactMatchCount).toBe(10); - - // Should NOT match with subset of keys - should create new actor - const subsetMatchCounter = client.counter.getOrCreate([ - "counter-match", - "test", - ]); - const subsetMatchCount = await subsetMatchCounter.increment(0); - expect(subsetMatchCount).toBe(0); // Should be a new counter with 0 - - // Should NOT match with just one key - should create new actor - const singleKeyCounter = client.counter.getOrCreate([ - "counter-match", - ]); - const singleKeyCount = await singleKeyCounter.increment(0); - expect(singleKeyCount).toBe(0); // Should be a new counter with 0 - }); - - test("string key matches array with single string key", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with string key - const stringKeyCounter = - client.counter.getOrCreate("string-key-test"); - await stringKeyCounter.increment(7); - - // Should match with equivalent array key - const arrayKeyCounter = client.counter.getOrCreate([ - "string-key-test", - ]); - const count = await arrayKeyCounter.increment(0); - expect(count).toBe(7); - }); - - test("undefined key matches empty array key and no key", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with undefined key - const undefinedKeyCounter = - client.counter.getOrCreate(undefined); - await undefinedKeyCounter.increment(12); - - // Should match with empty array key - const emptyArrayKeyCounter = client.counter.getOrCreate([]); - const emptyArrayCount = await emptyArrayKeyCounter.increment(0); - expect(emptyArrayCount).toBe(12); - - // Should match with no key - const noKeyCounter = client.counter.getOrCreate(); - const noKeyCount = await noKeyCounter.increment(0); - expect(noKeyCount).toBe(12); - }); - - test("no keys does not match actors with keys", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create counter with keys - const keyedCounter = client.counter.getOrCreate([ - "counter-with-keys", - "special", - ]); - await keyedCounter.increment(15); - - // Should not match when searching with no keys - const noKeysCounter = client.counter.getOrCreate(); - const count = await noKeysCounter.increment(10); - expect(count).toBe(10); - }); - - test("actors with keys match actors with no keys", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create a counter with no keys - const noKeysCounter = client.counter.getOrCreate(); - await noKeysCounter.increment(25); - - // Get counter with keys - should create a new one - const keyedCounter = client.counter.getOrCreate([ - "new-counter", - "prod", - ]); - const keyedCount = await keyedCounter.increment(0); - - // Should be a new counter, not the one created above - expect(keyedCount).toBe(0); - }); - }); - - describe("Multiple Actor Instances", () => { - // TODO: This test is flakey https://github.com/rivet-dev/rivetkit/issues/873 - test("creates multiple actor instances of the same type", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create multiple instances with different IDs - const instance1 = client.counter.getOrCreate(["multi-1"]); - const instance2 = client.counter.getOrCreate(["multi-2"]); - const instance3 = client.counter.getOrCreate(["multi-3"]); - - // Set different states - await instance1.increment(1); - await instance2.increment(2); - await instance3.increment(3); - - // Retrieve all instances again - const retrieved1 = client.counter.getOrCreate(["multi-1"]); - const retrieved2 = client.counter.getOrCreate(["multi-2"]); - const retrieved3 = client.counter.getOrCreate(["multi-3"]); - - // Verify separate state - expect(await retrieved1.increment(0)).toBe(1); - expect(await retrieved2.increment(0)).toBe(2); - expect(await retrieved3.increment(0)).toBe(3); - }); - - test("handles default instance with no explicit ID", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Get default instance (no ID specified) - const defaultCounter = client.counter.getOrCreate(); - - // Set state - await defaultCounter.increment(5); - - // Get default instance again - const sameDefaultCounter = client.counter.getOrCreate(); - - // Verify state is maintained - const count = await sameDefaultCounter.increment(0); - expect(count).toBe(5); - }); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-direct-registry.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-direct-registry.ts deleted file mode 100644 index 206b8f0e52..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-direct-registry.ts +++ /dev/null @@ -1,227 +0,0 @@ -// TODO: re-expose this once we can have actor queries on the gateway -// import { describe, expect, test } from "vitest"; -// import { -// HEADER_ACTOR_QUERY, -// HEADER_CONN_PARAMS, -// } from "@/actor/router-endpoints"; -// import type { ActorQuery } from "@/manager/protocol/query"; -// import type { DriverTestConfig } from "../mod"; -// import { setupDriverTest } from "../utils"; -// -// export function runRawHttpDirectRegistryTests( -// driverTestConfig: DriverTestConfig, -// ) { -// describe("raw http - direct registry access", () => { -// test("should handle direct fetch requests to registry with proper headers", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// // Build the actor query -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-test"], -// }, -// }; -// -// // Make a direct fetch request to the registry -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/hello`, -// { -// method: "GET", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// }, -// }, -// ); -// -// expect(response.ok).toBe(true); -// expect(response.status).toBe(200); -// const data = await response.json(); -// expect(data).toEqual({ message: "Hello from actor!" }); -// }); -// -// test("should handle POST requests with body to registry", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-post-test"], -// }, -// }; -// -// const testData = { test: "direct", number: 456 }; -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/echo`, -// { -// method: "POST", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// "Content-Type": "application/json", -// }, -// body: JSON.stringify(testData), -// }, -// ); -// -// expect(response.ok).toBe(true); -// expect(response.status).toBe(200); -// const data = await response.json(); -// expect(data).toEqual(testData); -// }); -// -// test("should pass custom headers through to actor", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-headers-test"], -// }, -// }; -// -// const customHeaders = { -// "X-Custom-Header": "direct-test-value", -// "X-Another-Header": "another-direct-value", -// }; -// -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/headers`, -// { -// method: "GET", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// ...customHeaders, -// }, -// }, -// ); -// -// expect(response.ok).toBe(true); -// const headers = (await response.json()) as Record; -// expect(headers["x-custom-header"]).toBe("direct-test-value"); -// expect(headers["x-another-header"]).toBe("another-direct-value"); -// }); -// -// test("should handle connection parameters for authentication", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-auth-test"], -// }, -// }; -// -// const connParams = { token: "test-auth-token", userId: "user123" }; -// -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/hello`, -// { -// method: "GET", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// [HEADER_CONN_PARAMS]: JSON.stringify(connParams), -// }, -// }, -// ); -// -// expect(response.ok).toBe(true); -// const data = await response.json(); -// expect(data).toEqual({ message: "Hello from actor!" }); -// }); -// -// test("should return 404 for actors without onRequest handler", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpNoHandlerActor", -// key: ["direct-no-handler"], -// }, -// }; -// -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/anything`, -// { -// method: "GET", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// }, -// }, -// ); -// -// expect(response.ok).toBe(false); -// expect(response.status).toBe(404); -// }); -// -// test("should handle different HTTP methods", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-methods-test"], -// }, -// }; -// -// // Test various HTTP methods -// const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; -// -// for (const method of methods) { -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/echo`, -// { -// method, -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// ...(method !== "GET" -// ? { "Content-Type": "application/json" } -// : {}), -// }, -// body: ["POST", "PUT", "PATCH"].includes(method) -// ? JSON.stringify({ method }) -// : undefined, -// }, -// ); -// -// // Echo endpoint only handles POST, others should fall through to 404 -// if (method === "POST") { -// expect(response.ok).toBe(true); -// const data = await response.json(); -// expect(data).toEqual({ method }); -// } else { -// expect(response.status).toBe(404); -// } -// } -// }); -// -// test("should handle binary data", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawHttpActor", -// key: ["direct-binary-test"], -// }, -// }; -// -// // Send binary data -// const binaryData = new Uint8Array([1, 2, 3, 4, 5]); -// const response = await fetch( -// `${endpoint}/registry/actors/request/api/echo`, -// { -// method: "POST", -// headers: { -// [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), -// "Content-Type": "application/octet-stream", -// }, -// body: binaryData, -// }, -// ); -// -// expect(response.ok).toBe(true); -// const responseBuffer = await response.arrayBuffer(); -// const responseArray = new Uint8Array(responseBuffer); -// expect(Array.from(responseArray)).toEqual([1, 2, 3, 4, 5]); -// }); -// }); -// } diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-request-properties.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-request-properties.ts deleted file mode 100644 index 15e963ec90..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-request-properties.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { z } from "zod/v4"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runRawHttpRequestPropertiesTests( - driverTestConfig: DriverTestConfig, -) { - describe("raw http request properties", () => { - test("should pass all Request properties correctly to onRequest", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test basic request properties - const response = await actor.fetch("test/path?foo=bar&baz=qux", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Custom-Header": "custom-value", - Authorization: "Bearer test-token", - }, - body: JSON.stringify({ test: "data" }), - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - - // Verify URL properties - expect(data.url).toContain("/test/path?foo=bar&baz=qux"); - expect(data.pathname).toBe("/test/path"); - expect(data.search).toBe("?foo=bar&baz=qux"); - expect(data.searchParams).toEqual({ - foo: "bar", - baz: "qux", - }); - - // Verify method - expect(data.method).toBe("POST"); - - // Verify headers - expect(data.headers["content-type"]).toBe("application/json"); - expect(data.headers["x-custom-header"]).toBe("custom-value"); - expect(data.headers["authorization"]).toBe("Bearer test-token"); - - // Verify body - expect(data.body).toEqual({ test: "data" }); - }); - - test("should handle GET requests with no body", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const response = await actor.fetch("test/get", { - method: "GET", - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - - expect(data.method).toBe("GET"); - expect(data.body).toBeNull(); - }); - - test("should handle different content types", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test form data - const formData = new URLSearchParams(); - formData.append("field1", "value1"); - formData.append("field2", "value2"); - - const formResponse = await actor.fetch("test/form", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), - }); - - expect(formResponse.ok).toBe(true); - const formResult = (await formResponse.json()) as any; - expect(formResult.headers["content-type"]).toBe( - "application/x-www-form-urlencoded", - ); - expect(formResult.bodyText).toBe("field1=value1&field2=value2"); - - // Test plain text - const textResponse = await actor.fetch("test/text", { - method: "POST", - headers: { - "Content-Type": "text/plain", - }, - body: "Hello, World!", - }); - - expect(textResponse.ok).toBe(true); - const textResult = (await textResponse.json()) as any; - expect(textResult.headers["content-type"]).toBe("text/plain"); - expect(textResult.bodyText).toBe("Hello, World!"); - }); - - test("should preserve all header casing and values", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const response = await actor.fetch("test/headers", { - headers: { - Accept: "application/json", - "Accept-Language": "en-US,en;q=0.9", - "Cache-Control": "no-cache", - "User-Agent": "RivetKit-Test/1.0", - "X-Request-ID": "12345", - }, - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - - // Headers should be normalized to lowercase - expect(data.headers["accept"]).toBe("application/json"); - expect(data.headers["accept-language"]).toBe("en-US,en;q=0.9"); - expect(data.headers["cache-control"]).toBe("no-cache"); - // User-Agent might be overwritten by the HTTP client, so just check it exists - expect(data.headers["user-agent"]).toBeTruthy(); - expect(data.headers["x-request-id"]).toBe("12345"); - }); - - test("should handle empty and special URL paths", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test root path - const rootResponse = await actor.fetch(""); - expect(rootResponse.ok).toBe(true); - const rootData = (await rootResponse.json()) as any; - expect(rootData.pathname).toBe("/"); - - // Test path with special characters - const specialResponse = await actor.fetch( - "test/path%20with%20spaces/and%2Fslashes", - ); - expect(specialResponse.ok).toBe(true); - const specialData = (await specialResponse.json()) as any; - // Note: The URL path may or may not be decoded depending on the HTTP client/server - // Just verify it contains the expected segments - expect(specialData.pathname).toMatch( - /path.*with.*spaces.*and.*slashes/, - ); - - // Test path with fragment (should be ignored in server-side) - const fragmentResponse = await actor.fetch("test/path#fragment"); - expect(fragmentResponse.ok).toBe(true); - const fragmentData = (await fragmentResponse.json()) as any; - expect(fragmentData.pathname).toBe("/test/path"); - expect(fragmentData.hash).toBe(""); // Fragments are not sent to server - }); - - test("should handle request properties for all HTTP methods", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const methods = [ - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", - "HEAD", - "OPTIONS", - ]; - - for (const method of methods) { - const response = await actor.fetch( - `test/${method.toLowerCase()}`, - { - method, - // Only include body for methods that support it - body: ["POST", "PUT", "PATCH"].includes(method) - ? JSON.stringify({ method }) - : undefined, - }, - ); - - // HEAD responses have no body - if (method === "HEAD") { - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toBe(""); - } else if (method === "OPTIONS") { - expect(response.status).toBe(204); - const text = await response.text(); - expect(text).toBe(""); - } else { - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.method).toBe(method); - } - } - }); - - test("should handle complex query parameters", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test multiple values for same key - const response = await actor.fetch( - "test?key=value1&key=value2&array[]=1&array[]=2&nested[prop]=val", - ); - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - - // Note: URLSearchParams only keeps the last value for duplicate keys - expect(data.searchParams.key).toBe("value2"); - expect(data.searchParams["array[]"]).toBe("2"); - expect(data.searchParams["nested[prop]"]).toBe("val"); - }); - - test("should handle multipart form data", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Create multipart boundary - const boundary = "----RivetKitBoundary"; - const body = [ - `------${boundary}`, - 'Content-Disposition: form-data; name="field1"', - "", - "value1", - `------${boundary}`, - 'Content-Disposition: form-data; name="field2"', - "", - "value2", - `------${boundary}--`, - ].join("\r\n"); - - const response = await actor.fetch("test/multipart", { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=----${boundary}`, - }, - body: body, - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.headers["content-type"]).toContain( - "multipart/form-data", - ); - expect(data.bodyText).toContain("field1"); - expect(data.bodyText).toContain("value1"); - }); - - test("should handle very long URLs", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Create a very long query string - const longValue = "x".repeat(1000); - const response = await actor.fetch(`test/long?param=${longValue}`); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.searchParams.param).toBe(longValue); - expect(data.search.length).toBeGreaterThan(1000); - }); - - test.skip("should handle large request bodies", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Create a large JSON body (1MB+) - const largeArray = new Array(10000).fill({ - id: 1, - name: "Test", - description: "This is a test object with some data", - }); - - const response = await actor.fetch("test/large", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(largeArray), - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.body).toHaveLength(10000); - }); - - test("should handle missing content-type header", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const response = await actor.fetch("test/no-content-type", { - method: "POST", - body: "plain text without content-type", - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.bodyText).toBe("plain text without content-type"); - }); - - test("should handle empty request body", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const response = await actor.fetch("test/empty", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: "", - }); - - expect(response.ok).toBe(true); - // TODO: This is inconsistent between engine & file system driver - // const data = (await response.json()) as any; - // expect(data.body).toBeNull(); - // expect(data.bodyText).toBe(""); - }); - - test("should handle custom HTTP methods", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test a custom method (though most HTTP clients may not support this) - try { - const response = await actor.fetch("test/custom", { - method: "CUSTOM", - }); - - // If the request succeeds, verify the method - if (response.ok) { - const data = (await response.json()) as any; - expect(data.method).toBe("CUSTOM"); - } - } catch (error) { - // Some HTTP clients may reject custom methods - // This is expected behavior - } - }); - - test("should handle cookies in headers", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - const response = await actor.fetch("test/cookies", { - headers: { - Cookie: "session=abc123; user=test; preferences=dark_mode", - }, - }); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - expect(data.headers.cookie).toBe( - "session=abc123; user=test; preferences=dark_mode", - ); - }); - - test("should handle URL encoding properly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Test various encoded characters - const response = await actor.fetch( - "test/encoded?special=%20%21%40%23%24%25%5E%26&unicode=%E2%9C%93&email=test%40example.com", - ); - - expect(response.ok).toBe(true); - const data = (await response.json()) as any; - - // Verify URL decoding - expect(data.searchParams.special).toBe(" !@#$%^&"); - expect(data.searchParams.unicode).toBe("✓"); - expect(data.searchParams.email).toBe("test@example.com"); - }); - - test("should handle concurrent requests maintaining separate contexts", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpRequestPropertiesActor.getOrCreate([ - "test", - ]); - - // Send multiple concurrent requests with different data - const requests = [ - actor.fetch("test/concurrent?id=1", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: 1 }), - }), - actor.fetch("test/concurrent?id=2", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: 2 }), - }), - actor.fetch("test/concurrent?id=3", { - method: "DELETE", - }), - ]; - - const responses = await Promise.all(requests); - const results = (await Promise.all( - responses.map((r) => r.json()), - )) as any[]; - - // Verify each request maintained its own context - expect(results[0].searchParams.id).toBe("1"); - expect(results[0].method).toBe("POST"); - expect(results[0].body).toEqual({ request: 1 }); - - expect(results[1].searchParams.id).toBe("2"); - expect(results[1].method).toBe("PUT"); - expect(results[1].body).toEqual({ request: 2 }); - - expect(results[2].searchParams.id).toBe("3"); - expect(results[2].method).toBe("DELETE"); - expect(results[2].body).toBeNull(); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http.ts deleted file mode 100644 index 62ec39e661..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runRawHttpTests(driverTestConfig: DriverTestConfig) { - describe("raw http", () => { - test("should handle raw HTTP GET requests", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["test"]); - - // Test the hello endpoint - const helloResponse = await actor.fetch("api/hello"); - expect(helloResponse.ok).toBe(true); - const helloData = await helloResponse.json(); - expect(helloData).toEqual({ message: "Hello from actor!" }); - }); - - test("should handle raw HTTP POST requests with echo", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["test"]); - - const testData = { test: "data", number: 123 }; - const echoResponse = await actor.fetch("api/echo", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(testData), - }); - - expect(echoResponse.ok).toBe(true); - const echoData = await echoResponse.json(); - expect(echoData).toEqual(testData); - }); - - test("should track state across raw HTTP requests", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["state-test"]); - - // Make a few requests - await actor.fetch("api/hello"); - await actor.fetch("api/hello"); - await actor.fetch("api/state"); - - // Check the state endpoint - const stateResponse = await actor.fetch("api/state"); - expect(stateResponse.ok).toBe(true); - const stateData = (await stateResponse.json()) as { - requestCount: number; - }; - expect(stateData.requestCount).toBe(4); // 4 total requests - }); - - test("should pass headers correctly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["headers-test"]); - - const customHeaders = { - "X-Custom-Header": "test-value", - "X-Another-Header": "another-value", - }; - - const response = await actor.fetch("api/headers", { - headers: customHeaders, - }); - - expect(response.ok).toBe(true); - const headers = (await response.json()) as Record; - expect(headers["x-custom-header"]).toBe("test-value"); - expect(headers["x-another-header"]).toBe("another-value"); - }); - - test("should return 404 for unhandled paths", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["404-test"]); - - const response = await actor.fetch("api/nonexistent"); - expect(response.ok).toBe(false); - expect(response.status).toBe(404); - }); - - test("should return 404 when no onRequest handler defined", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpNoHandlerActor.getOrCreate([ - "no-handler", - ]); - - const response = await actor.fetch("api/anything"); - expect(response.ok).toBe(false); - expect(response.status).toBe(404); - - // No actions available without onRequest handler - }); - - test("should return 500 error when onRequest returns void", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpVoidReturnActor.getOrCreate([ - "void-return", - ]); - - const response = await actor.fetch("api/anything"); - expect(response.ok).toBe(false); - expect(response.status).toBe(500); - - // Check error message - response might be CBOR encoded - try { - const errorData = (await response.json()) as { - message: string; - }; - expect(errorData.message).toContain( - "onRequest handler must return a Response", - ); - } catch { - // If JSON parsing fails, just check that we got a 500 error - // The error details are already validated by the status code - } - - // No actions available when onRequest returns void - }); - - test("should handle different HTTP methods", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["methods-test"]); - - // Test various HTTP methods - const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; - - for (const method of methods) { - const response = await actor.fetch("api/echo", { - method, - body: ["POST", "PUT", "PATCH"].includes(method) - ? JSON.stringify({ method }) - : undefined, - }); - - // Echo endpoint only handles POST, others should fall through to 404 - if (method === "POST") { - expect(response.ok).toBe(true); - const data = await response.json(); - expect(data).toEqual({ method }); - } else if (method === "GET") { - // GET to echo should return 404 - expect(response.status).toBe(404); - } else { - // Other methods with body should also return 404 - expect(response.status).toBe(404); - } - } - }); - - test("should handle binary data", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["binary-test"]); - - // Send binary data - const binaryData = new Uint8Array([1, 2, 3, 4, 5]); - const response = await actor.fetch("api/echo", { - method: "POST", - headers: { - "Content-Type": "application/octet-stream", - }, - body: binaryData, - }); - - expect(response.ok).toBe(true); - const responseBuffer = await response.arrayBuffer(); - const responseArray = new Uint8Array(responseBuffer); - expect(Array.from(responseArray)).toEqual([1, 2, 3, 4, 5]); - }); - - test("should work with Hono router using createVars", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpHonoActor.getOrCreate(["hono-test"]); - - // Test root endpoint - const rootResponse = await actor.fetch("/"); - expect(rootResponse.ok).toBe(true); - const rootData = await rootResponse.json(); - expect(rootData).toEqual({ message: "Welcome to Hono actor!" }); - - // Test GET all users - const usersResponse = await actor.fetch("/users"); - expect(usersResponse.ok).toBe(true); - const users = await usersResponse.json(); - expect(users).toEqual([ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, - ]); - - // Test GET single user - const userResponse = await actor.fetch("/users/1"); - expect(userResponse.ok).toBe(true); - const user = await userResponse.json(); - expect(user).toEqual({ id: 1, name: "Alice" }); - - // Test POST new user - const newUser = { name: "Charlie" }; - const createResponse = await actor.fetch("/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newUser), - }); - expect(createResponse.ok).toBe(true); - expect(createResponse.status).toBe(201); - const createdUser = await createResponse.json(); - expect(createdUser).toEqual({ id: 3, name: "Charlie" }); - - // Test PUT update user - const updateData = { name: "Alice Updated" }; - const updateResponse = await actor.fetch("/users/1", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updateData), - }); - expect(updateResponse.ok).toBe(true); - const updatedUser = await updateResponse.json(); - expect(updatedUser).toEqual({ id: 1, name: "Alice Updated" }); - - // Test DELETE user - const deleteResponse = await actor.fetch("/users/2", { - method: "DELETE", - }); - expect(deleteResponse.ok).toBe(true); - const deleteResult = await deleteResponse.json(); - expect(deleteResult).toEqual({ message: "User 2 deleted" }); - - // Test 404 for non-existent route - const notFoundResponse = await actor.fetch("/api/unknown"); - expect(notFoundResponse.ok).toBe(false); - expect(notFoundResponse.status).toBe(404); - - // No actions available on Hono actor - }); - - test("should handle paths with and without leading slashes", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["path-test"]); - - // Test path without leading slash - const responseWithoutSlash = await actor.fetch("api/hello"); - expect(responseWithoutSlash.ok).toBe(true); - const dataWithoutSlash = await responseWithoutSlash.json(); - expect(dataWithoutSlash).toEqual({ message: "Hello from actor!" }); - - // Test path with leading slash - const responseWithSlash = await actor.fetch("/api/hello"); - expect(responseWithSlash.ok).toBe(true); - const dataWithSlash = await responseWithSlash.json(); - expect(dataWithSlash).toEqual({ message: "Hello from actor!" }); - - // Both should work the same way - }); - - test("should not create double slashes in request URLs", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - // Create a special actor that logs the request URL - const actor = client.rawHttpHonoActor.getOrCreate(["url-test"]); - - // Test with leading slash - this was causing double slashes - const response = await actor.fetch("/users"); - expect(response.ok).toBe(true); - - // The Hono router should receive a clean path without double slashes - // If there were double slashes, Hono would not match the route correctly - const data = await response.json(); - expect(data).toEqual([ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, - ]); - }); - - test("should handle forwarded requests correctly without double slashes", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpHonoActor.getOrCreate(["forward-test"]); - - // Simulate what the example does - pass path as string and Request as init - const truncatedPath = "/users"; - const url = new URL(truncatedPath, "http://example.com"); - const newRequest = new Request(url, { - method: "GET", - }); - - // This simulates calling actor.fetch(truncatedPath, newRequest) - // which was causing double slashes in the example - const response = await actor.fetch( - truncatedPath, - newRequest as any, - ); - expect(response.ok).toBe(true); - const users = await response.json(); - expect(users).toEqual([ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, - ]); - }); - - test("example fix: should properly forward requests using just Request object", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpHonoActor.getOrCreate(["forward-fix"]); - - // The correct way - just pass the Request object - const truncatedPath = "/users/1"; - const url = new URL(truncatedPath, "http://example.com"); - const newRequest = new Request(url, { - method: "GET", - }); - - // Correct usage - just pass the Request - const response = await actor.fetch(newRequest); - expect(response.ok).toBe(true); - const user = await response.json(); - expect(user).toEqual({ id: 1, name: "Alice" }); - }); - - test("should support standard fetch API with URL and Request objects", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawHttpActor.getOrCreate(["fetch-api-test"]); - - // Test with URL object - const url = new URL("/api/echo", "http://example.com"); - const urlResponse = await actor.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from: "URL object" }), - }); - expect(urlResponse.ok).toBe(true); - const urlData = await urlResponse.json(); - expect(urlData).toEqual({ from: "URL object" }); - - // Test with Request object - const request = new Request("http://example.com/api/echo", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from: "Request object" }), - }); - const requestResponse = await actor.fetch(request); - expect(requestResponse.ok).toBe(true); - const requestData = await requestResponse.json(); - expect(requestData).toEqual({ from: "Request object" }); - - // Test with Request object and additional init params - const request2 = new Request("http://example.com/api/headers", { - method: "GET", - headers: { "X-Original": "request-header" }, - }); - const overrideResponse = await actor.fetch(request2, { - headers: { "X-Override": "init-header" }, - }); - expect(overrideResponse.ok).toBe(true); - const headers = (await overrideResponse.json()) as Record< - string, - string - >; - expect(headers["x-override"]).toBe("init-header"); - // Original headers should be present too - expect(headers["x-original"]).toBe("request-header"); - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket-direct-registry.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket-direct-registry.ts deleted file mode 100644 index 0c29f70cf0..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket-direct-registry.ts +++ /dev/null @@ -1,393 +0,0 @@ -// TODO: re-expose this once we can have actor queries on the gateway -// import { describe, expect, test } from "vitest"; -// import { importWebSocket } from "@/common/websocket"; -// import type { ActorQuery } from "@/manager/protocol/query"; -// import type { DriverTestConfig } from "../mod"; -// import { setupDriverTest } from "../utils"; -// -// export function runRawWebSocketDirectRegistryTests( -// driverTestConfig: DriverTestConfig, -// ) { -// describe("raw websocket - direct registry access", () => { -// test("should establish vanilla WebSocket connection with proper subprotocols", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// // Build the actor query -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-test"], -// }, -// }; -// -// // Encode query as WebSocket subprotocol -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// -// // Build WebSocket URL (convert http to ws) -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// // Create WebSocket connection with subprotocol -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => { -// resolve(); -// }); -// ws.addEventListener("error", reject); -// ws.addEventListener("close", reject); -// }); -// -// // Should receive welcome message -// const welcomeMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(welcomeMessage.type).toBe("welcome"); -// expect(welcomeMessage.connectionCount).toBe(1); -// -// ws.close(); -// }); -// -// test("should echo messages with vanilla WebSocket", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-echo"], -// }, -// }; -// -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => resolve(), { once: true }); -// ws.addEventListener("close", reject); -// }); -// -// // Skip welcome message -// await new Promise((resolve, reject) => { -// ws.addEventListener("message", () => resolve(), { once: true }); -// ws.addEventListener("close", reject); -// }); -// -// // Send and receive echo -// const testMessage = { test: "vanilla", timestamp: Date.now() }; -// ws.send(JSON.stringify(testMessage)); -// -// const echoMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(echoMessage).toEqual(testMessage); -// -// ws.close(); -// }); -// -// test("should handle connection parameters for authentication", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-auth"], -// }, -// }; -// -// const connParams = { token: "ws-auth-token", userId: "ws-user123" }; -// -// // Encode both query and connection params as subprotocols -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// const connParamsProtocol = `conn_params.${encodeURIComponent(JSON.stringify(connParams))}`; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// connParamsProtocol, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => { -// resolve(); -// }); -// ws.addEventListener("error", reject); -// ws.addEventListener("close", reject); -// }); -// -// // Connection should succeed with auth params -// const welcomeMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(welcomeMessage.type).toBe("welcome"); -// -// ws.close(); -// }); -// -// test("should handle custom user protocols alongside rivetkit protocols", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-protocols"], -// }, -// }; -// -// // Include user-defined protocols -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// const userProtocol1 = "chat-v1"; -// const userProtocol2 = "custom-protocol"; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// userProtocol1, -// userProtocol2, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => { -// resolve(); -// }); -// ws.addEventListener("error", reject); -// ws.addEventListener("close", reject); -// }); -// -// // Should connect successfully with custom protocols -// const welcomeMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(welcomeMessage.type).toBe("welcome"); -// -// ws.close(); -// }); -// -// test("should handle different paths for WebSocket routes", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-paths"], -// }, -// }; -// -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// -// // Test different paths -// const paths = ["chat/room1", "updates/feed", "stream/events"]; -// -// for (const path of paths) { -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/${path}`; -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => { -// resolve(); -// }); -// ws.addEventListener("error", reject); -// }); -// -// // Should receive welcome message with the path -// const welcomeMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(welcomeMessage.type).toBe("welcome"); -// -// ws.close(); -// } -// }); -// -// test("should return error for actors without onWebSocket handler", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketNoHandlerActor", -// key: ["vanilla-no-handler"], -// }, -// }; -// -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// -// // Should fail to connect -// await new Promise((resolve) => { -// ws.addEventListener("error", () => resolve(), { once: true }); -// ws.addEventListener("close", () => resolve(), { once: true }); -// }); -// -// expect(ws.readyState).toBe(ws.CLOSED || 3); // WebSocket.CLOSED -// }); -// -// test("should handle binary data over vanilla WebSocket", async (c) => { -// const { endpoint } = await setupDriverTest(c, driverTestConfig); -// const WebSocket = await importWebSocket(); -// -// const actorQuery: ActorQuery = { -// getOrCreateForKey: { -// name: "rawWebSocketActor", -// key: ["vanilla-binary"], -// }, -// }; -// -// const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; -// -// const wsEndpoint = endpoint -// .replace(/^http:/, "ws:") -// .replace(/^https:/, "wss:"); -// const wsUrl = `${wsEndpoint}/registry/actors/websocket/`; -// -// const ws = new WebSocket(wsUrl, [ -// queryProtocol, -// // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts -// "rivetkit", -// ]) as any; -// ws.binaryType = "arraybuffer"; -// -// await new Promise((resolve, reject) => { -// ws.addEventListener("open", () => resolve(), { once: true }); -// ws.addEventListener("close", reject); -// }); -// -// // Skip welcome message -// await new Promise((resolve, reject) => { -// ws.addEventListener("message", () => resolve(), { once: true }); -// ws.addEventListener("close", reject); -// }); -// -// // Send binary data -// const binaryData = new Uint8Array([1, 2, 3, 4, 5]); -// ws.send(binaryData.buffer); -// -// // Receive echoed binary data -// const echoedData = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// // The actor echoes binary data back as-is -// resolve(event.data as ArrayBuffer); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// // Verify the echoed data matches what we sent -// const echoedArray = new Uint8Array(echoedData); -// expect(Array.from(echoedArray)).toEqual([1, 2, 3, 4, 5]); -// -// // Now test JSON echo -// ws.send(JSON.stringify({ type: "binary-test", size: binaryData.length })); -// -// const echoMessage = await new Promise((resolve, reject) => { -// ws.addEventListener( -// "message", -// (event: any) => { -// resolve(JSON.parse(event.data as string)); -// }, -// { once: true }, -// ); -// ws.addEventListener("close", reject); -// }); -// -// expect(echoMessage.type).toBe("binary-test"); -// expect(echoMessage.size).toBe(5); -// -// ws.close(); -// }); -// }); -// } diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket.ts deleted file mode 100644 index e6e462e3f3..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket.ts +++ /dev/null @@ -1,844 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { HIBERNATABLE_WEBSOCKET_BUFFERED_MESSAGE_SIZE_THRESHOLD } from "@/actor/conn/hibernatable-websocket-ack-state"; -import { getHibernatableWebSocketAckState } from "@/common/websocket-test-hooks"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -const HIBERNATABLE_ACK_SETTLE_TIMEOUT_MS = 12_000; - -async function waitForJsonMessage( - ws: WebSocket, - timeoutMs: number, -): Promise | undefined> { - const messagePromise = new Promise | undefined>( - (resolve, reject) => { - ws.addEventListener( - "message", - (event: any) => { - try { - resolve(JSON.parse(event.data as string)); - } catch { - resolve(undefined); - } - }, - { once: true }, - ); - ws.addEventListener("close", reject, { once: true }); - }, - ); - - return await Promise.race([ - messagePromise, - new Promise((resolve) => - setTimeout(() => resolve(undefined), timeoutMs), - ), - ]); -} - -async function waitForMatchingJsonMessages( - ws: WebSocket, - count: number, - matcher: (message: Record) => boolean, - timeoutMs: number, -): Promise>> { - return await new Promise>>( - (resolve, reject) => { - const messages: Array> = []; - const timeout = setTimeout(() => { - cleanup(); - reject( - new Error( - `timed out waiting for ${count} matching websocket messages`, - ), - ); - }, timeoutMs); - const onMessage = (event: { data: string }) => { - let parsed: Record | undefined; - try { - parsed = JSON.parse(event.data as string); - } catch { - return; - } - if (!parsed) { - return; - } - if (!matcher(parsed)) { - return; - } - messages.push(parsed); - if (messages.length >= count) { - cleanup(); - resolve(messages); - } - }; - const onClose = (event: unknown) => { - cleanup(); - reject(event); - }; - const cleanup = () => { - clearTimeout(timeout); - ws.removeEventListener( - "message", - onMessage as (event: any) => void, - ); - ws.removeEventListener( - "close", - onClose as (event: any) => void, - ); - }; - ws.addEventListener("message", onMessage as (event: any) => void); - ws.addEventListener("close", onClose as (event: any) => void, { - once: true, - }); - }, - ); -} - -export function runRawWebSocketTests(driverTestConfig: DriverTestConfig) { - describe("raw websocket", () => { - test("should establish raw WebSocket connection", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate(["basic"]); - - const ws = await actor.webSocket(); - - // The WebSocket should already be open since openWebSocket waits for openPromise - // But we still need to ensure any buffered events are processed - await new Promise((resolve) => { - // If already open, resolve immediately - if (ws.readyState === WebSocket.OPEN) { - resolve(); - } else { - // Otherwise wait for open event - ws.addEventListener( - "open", - () => { - resolve(); - }, - { once: true }, - ); - } - }); - - // Should receive welcome message - const welcomeMessage = await new Promise((resolve, reject) => { - ws.addEventListener( - "message", - (event: any) => { - resolve(JSON.parse(event.data as string)); - }, - { once: true }, - ); - ws.addEventListener("close", reject); - }); - - expect(welcomeMessage.type).toBe("welcome"); - expect(welcomeMessage.connectionCount).toBe(1); - - ws.close(); - }); - - test("should echo messages", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate(["echo"]); - - const ws = await actor.webSocket(); - - // Check if WebSocket is already open - if (ws.readyState !== WebSocket.OPEN) { - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { - once: true, - }); - ws.addEventListener("close", reject); - }); - } - - // Skip welcome message - await new Promise((resolve, reject) => { - ws.addEventListener("message", () => resolve(), { once: true }); - ws.addEventListener("close", reject); - }); - - // Send and receive echo - const testMessage = { test: "data", timestamp: Date.now() }; - ws.send(JSON.stringify(testMessage)); - - const echoMessage = await new Promise((resolve, reject) => { - ws.addEventListener( - "message", - (event: any) => { - resolve(JSON.parse(event.data as string)); - }, - { once: true }, - ); - ws.addEventListener("close", reject); - }); - - expect(echoMessage).toEqual(testMessage); - - ws.close(); - }); - - test("should handle ping/pong protocol", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate(["ping"]); - - const ws = await actor.webSocket(); - - // Check if WebSocket is already open - if (ws.readyState !== WebSocket.OPEN) { - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { - once: true, - }); - ws.addEventListener("close", reject); - }); - } - - // Skip welcome message - await new Promise((resolve, reject) => { - ws.addEventListener("message", () => resolve(), { once: true }); - ws.addEventListener("close", reject); - }); - - // Send ping - ws.send(JSON.stringify({ type: "ping" })); - - const pongMessage = await new Promise((resolve, reject) => { - ws.addEventListener("message", (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type === "pong") { - resolve(data); - } - }); - ws.addEventListener("close", reject); - }); - - expect(pongMessage.type).toBe("pong"); - expect(pongMessage.timestamp).toBeDefined(); - - ws.close(); - }); - - test("should track stats across connections", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor1 = client.rawWebSocketActor.getOrCreate(["stats"]); - - // Create first connection to ensure actor exists - const ws1 = await actor1.webSocket(); - const ws1MessagePromise = new Promise((resolve, reject) => { - ws1.addEventListener("message", () => resolve(), { - once: true, - }); - ws1.addEventListener("close", reject); - }); - - // Wait for first connection to establish before getting the actor - await ws1MessagePromise; - - // Now get reference to same actor - const actor2 = client.rawWebSocketActor.get(["stats"]); - const ws2 = await actor2.webSocket(); - const ws2MessagePromise = new Promise((resolve, reject) => { - ws2.addEventListener("message", () => resolve(), { - once: true, - }); - ws2.addEventListener("close", reject); - }); - - // Wait for welcome messages - await Promise.all([ws1MessagePromise, ws2MessagePromise]); - - // Send some messages - const pingPromise = new Promise((resolve, reject) => { - ws2.addEventListener("message", (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type === "pong") { - resolve(undefined); - } - }); - ws2.addEventListener("close", reject); - }); - ws1.send(JSON.stringify({ data: "test1" })); - ws1.send(JSON.stringify({ data: "test3" })); - ws2.send(JSON.stringify({ type: "ping" })); - await pingPromise; - - // Get stats - const statsPromise = new Promise((resolve, reject) => { - ws1.addEventListener("message", (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type === "stats") { - resolve(data); - } - }); - ws1.addEventListener("close", reject); - }); - ws1.send(JSON.stringify({ type: "getStats" })); - const stats = await statsPromise; - expect(stats.connectionCount).toBe(2); - expect(stats.messageCount).toBe(4); - - // Verify via action - const actionStats = await actor1.getStats(); - expect(actionStats.connectionCount).toBe(2); - expect(actionStats.messageCount).toBe(4); - - ws1.close(); - ws2.close(); - }); - - test("should handle binary data", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketBinaryActor.getOrCreate([ - "binary", - ]); - - const ws = await actor.webSocket(); - - // Check if WebSocket is already open - if (ws.readyState !== WebSocket.OPEN) { - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { - once: true, - }); - ws.addEventListener("close", reject); - }); - } - - // Helper to receive and convert binary message - const receiveBinaryMessage = async (): Promise => { - const response = await new Promise( - (resolve, reject) => { - ws.addEventListener( - "message", - (event: any) => { - resolve(event.data); - }, - { once: true }, - ); - ws.addEventListener("close", reject); - }, - ); - - // Convert Blob to ArrayBuffer if needed - const buffer = - response instanceof Blob - ? await response.arrayBuffer() - : response; - - return new Uint8Array(buffer); - }; - - // Test 1: Small binary data - const smallData = new Uint8Array([1, 2, 3, 4, 5]); - ws.send(smallData); - const smallReversed = await receiveBinaryMessage(); - expect(Array.from(smallReversed)).toEqual([5, 4, 3, 2, 1]); - - // Test 2: Large binary data (1KB) - const largeData = new Uint8Array(1024); - for (let i = 0; i < largeData.length; i++) { - largeData[i] = i % 256; - } - ws.send(largeData); - const largeReversed = await receiveBinaryMessage(); - - // Verify it's reversed correctly - for (let i = 0; i < largeData.length; i++) { - expect(largeReversed[i]).toBe( - largeData[largeData.length - 1 - i], - ); - } - - ws.close(); - }); - - test("should work with custom paths", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate(["paths"]); - - const ws = await actor.webSocket("custom/path"); - - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => { - resolve(); - }); - ws.addEventListener("error", reject); - ws.addEventListener("close", reject); - }); - - // Should still work - const welcomeMessage = await new Promise((resolve) => { - ws.addEventListener( - "message", - (event: any) => { - resolve(JSON.parse(event.data as string)); - }, - { once: true }, - ); - }); - - expect(welcomeMessage.type).toBe("welcome"); - - ws.close(); - }); - - test("should handle connection close properly", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate(["close-test"]); - - const ws = await actor.webSocket(); - - // Check if WebSocket is already open - if (ws.readyState !== WebSocket.OPEN) { - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { - once: true, - }); - ws.addEventListener("close", reject); - }); - } - - // Get initial stats - const initialStats = await actor.getStats(); - expect(initialStats.connectionCount).toBe(1); - - // Wait for close event on client side - const closePromise = new Promise((resolve) => { - ws.addEventListener("close", () => resolve(), { once: true }); - }); - - // Close connection - ws.close(); - await closePromise; - - // Poll getStats until connection count is 0 - let finalStats: any; - for (let i = 0; i < 20; i++) { - finalStats = await actor.getStats(); - if (finalStats.connectionCount === 0) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - // Check stats after close - expect(finalStats?.connectionCount).toBe(0); - }); - - test("should properly handle onWebSocket open and close events", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "open-close-test", - ]); - - // Create first connection - const ws1 = await actor.webSocket(); - - // Wait for open event - await new Promise((resolve, reject) => { - ws1.addEventListener("open", () => resolve(), { once: true }); - ws1.addEventListener("close", reject); - }); - - // Wait for welcome message which confirms onWebSocket was called - const welcome1 = await new Promise((resolve, reject) => { - ws1.addEventListener( - "message", - (event: any) => { - resolve(JSON.parse(event.data as string)); - }, - { once: true }, - ); - ws1.addEventListener("close", reject); - }); - - expect(welcome1.type).toBe("welcome"); - expect(welcome1.connectionCount).toBe(1); - - // Create second connection to same actor - const ws2 = await actor.webSocket(); - - await new Promise((resolve, reject) => { - ws2.addEventListener("open", () => resolve(), { once: true }); - ws2.addEventListener("close", reject); - }); - - const welcome2 = await new Promise((resolve, reject) => { - ws2.addEventListener( - "message", - (event: any) => { - resolve(JSON.parse(event.data as string)); - }, - { once: true }, - ); - ws2.addEventListener("close", reject); - }); - - expect(welcome2.type).toBe("welcome"); - expect(welcome2.connectionCount).toBe(2); - - // Verify stats - const midStats = await actor.getStats(); - expect(midStats.connectionCount).toBe(2); - - // Close first connection - ws1.close(); - await new Promise((resolve) => { - ws1.addEventListener("close", () => resolve(), { once: true }); - }); - - // Poll getStats until connection count decreases to 1 - let afterFirstClose: any; - for (let i = 0; i < 20; i++) { - afterFirstClose = await actor.getStats(); - if (afterFirstClose.connectionCount === 1) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - // Verify connection count decreased - expect(afterFirstClose?.connectionCount).toBe(1); - - // Close second connection - ws2.close(); - await new Promise((resolve) => { - ws2.addEventListener("close", () => resolve(), { once: true }); - }); - - // Poll getStats until connection count is 0 - let finalStats: any; - for (let i = 0; i < 20; i++) { - finalStats = await actor.getStats(); - if (finalStats.connectionCount === 0) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - // Verify final state - expect(finalStats?.connectionCount).toBe(0); - }); - - test("should handle query parameters in websocket paths", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "query-params", - ]); - - // Test WebSocket with query parameters - const ws = await actor.webSocket( - "api/v1/stream?token=abc123&user=test", - ); - - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }); - ws.addEventListener("error", reject); - }); - - const requestInfoPromise = new Promise((resolve, reject) => { - ws.addEventListener("message", (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type === "requestInfo") { - resolve(data); - } - }); - ws.addEventListener("close", reject); - }); - - // Send request to get the request info - ws.send(JSON.stringify({ type: "getRequestInfo" })); - - const requestInfo = await requestInfoPromise; - - // Verify the path and query parameters were preserved - expect(requestInfo.url).toContain("api/v1/stream"); - expect(requestInfo.url).toContain("token=abc123"); - expect(requestInfo.url).toContain("user=test"); - - ws.close(); - }); - - test("should handle query parameters on base websocket path (no subpath)", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "base-path-query-params", - ]); - - // Test WebSocket with ONLY query parameters on the base path - // This tests the case where path is "/websocket?foo=bar" without trailing slash - const ws = await actor.webSocket("?token=secret&session=123"); - - await new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }); - ws.addEventListener("error", reject); - ws.addEventListener("close", (evt: any) => { - reject( - new Error( - `WebSocket closed: code=${evt.code} reason=${evt.reason}`, - ), - ); - }); - }); - - const requestInfoPromise = new Promise((resolve, reject) => { - ws.addEventListener("message", (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type === "requestInfo") { - resolve(data); - } - }); - ws.addEventListener("close", reject); - }); - - // Send request to get the request info - ws.send(JSON.stringify({ type: "getRequestInfo" })); - - const requestInfo = await requestInfoPromise; - - // Verify query parameters were preserved even on base websocket path - expect(requestInfo.url).toContain("token=secret"); - expect(requestInfo.url).toContain("session=123"); - - ws.close(); - }); - - test("should preserve indexed websocket message ordering", async (c) => { - if (driverTestConfig.clientType !== "http") { - return; - } - - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "indexed-ordering", - ]); - const ws = await actor.webSocket(); - - try { - const welcome = await waitForJsonMessage(ws, 2000); - if (!welcome || welcome.type !== "welcome") { - // Some dynamic inline transports do not currently surface this path reliably. - return; - } - - const orderedResponsesPromise = new Promise( - (resolve, reject) => { - const indexes: number[] = []; - const handler = (event: any) => { - const data = JSON.parse(event.data as string); - if (data.type !== "indexedEcho") { - return; - } - indexes.push(data.rivetMessageIndex); - if (indexes.length === 3) { - ws.removeEventListener("message", handler); - resolve(indexes); - } - }; - ws.addEventListener("message", handler); - ws.addEventListener("close", reject); - }, - ); - - ws.send( - JSON.stringify({ - type: "indexedEcho", - payload: "first", - }), - ); - ws.send( - JSON.stringify({ - type: "indexedEcho", - payload: "second", - }), - ); - ws.send( - JSON.stringify({ - type: "indexedEcho", - payload: "third", - }), - ); - - const observedOrder = await Promise.race([ - orderedResponsesPromise, - new Promise((resolve) => - setTimeout(() => resolve(undefined), 1500), - ), - ]); - if (!observedOrder) { - return; - } - expect(observedOrder).toHaveLength(3); - const actorObservedOrderPromise = waitForMatchingJsonMessages( - ws, - 1, - (message) => message.type === "indexedMessageOrder", - 1_000, - ); - ws.send( - JSON.stringify({ - type: "getIndexedMessageOrder", - }), - ); - const actorObservedOrder = (await actorObservedOrderPromise)[0] - .order as Array; - expect(actorObservedOrder).toHaveLength(3); - const numericOrder = actorObservedOrder.filter( - (value): value is number => Number.isInteger(value), - ); - if (numericOrder.length === 3) { - expect(numericOrder[1]).toBeGreaterThan(numericOrder[0]); - expect(numericOrder[2]).toBeGreaterThan(numericOrder[1]); - } - } finally { - ws.close(); - } - }); - - describe.skipIf( - !driverTestConfig.features?.hibernatableWebSocketProtocol, - )("hibernatable websocket ack", () => { - test("acks indexed raw websocket messages without extra actor writes", async (c) => { - if (driverTestConfig.clientType !== "http") { - return; - } - - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "hibernatable-ack", - ]); - const ws = await actor.webSocket(); - - try { - const welcome = await waitForJsonMessage(ws, 4000); - expect(welcome).toMatchObject({ - type: "welcome", - }); - - ws.send( - JSON.stringify({ - type: "indexedAckProbe", - payload: "ack-me", - }), - ); - expect(await waitForJsonMessage(ws, 1000)).toMatchObject({ - type: "indexedAckProbe", - rivetMessageIndex: 1, - payloadSize: 6, - }); - - await vi.waitFor( - async () => { - expect(await readHibernatableAckState(ws)).toEqual({ - lastSentIndex: 1, - lastAckedIndex: 1, - pendingIndexes: [], - }); - }, - { - timeout: HIBERNATABLE_ACK_SETTLE_TIMEOUT_MS, - interval: 50, - }, - ); - } finally { - ws.close(); - } - }); - - test("acks buffered indexed raw websocket messages immediately at the threshold", async (c) => { - if (driverTestConfig.clientType !== "http") { - return; - } - - const { client } = await setupDriverTest(c, driverTestConfig); - const actor = client.rawWebSocketActor.getOrCreate([ - "hibernatable-threshold", - ]); - const ws = await actor.webSocket(); - - try { - const welcome = await waitForJsonMessage(ws, 4000); - expect(welcome).toMatchObject({ - type: "welcome", - }); - - ws.send( - JSON.stringify({ - type: "indexedAckProbe", - payload: "x".repeat( - HIBERNATABLE_WEBSOCKET_BUFFERED_MESSAGE_SIZE_THRESHOLD + - 8_000, - ), - }), - ); - expect(await waitForJsonMessage(ws, 1000)).toMatchObject({ - type: "indexedAckProbe", - rivetMessageIndex: 1, - payloadSize: - HIBERNATABLE_WEBSOCKET_BUFFERED_MESSAGE_SIZE_THRESHOLD + - 8_000, - }); - - await vi.waitFor( - async () => { - expect(await readHibernatableAckState(ws)).toEqual({ - lastSentIndex: 1, - lastAckedIndex: 1, - pendingIndexes: [], - }); - }, - { timeout: 1_000, interval: 25 }, - ); - } finally { - ws.close(); - } - }); - }); - }); -} - -async function readHibernatableAckState(websocket: WebSocket): Promise<{ - lastSentIndex: number; - lastAckedIndex: number; - pendingIndexes: number[]; -}> { - const hookUnavailableErrorPattern = - /remote hibernatable websocket ack hooks are unavailable/; - for (let attempt = 0; attempt < 20; attempt += 1) { - try { - const directState = getHibernatableWebSocketAckState( - websocket as unknown as any, - ); - if (directState) { - return directState; - } - } catch (error) { - if ( - error instanceof Error && - hookUnavailableErrorPattern.test(error.message) - ) { - await new Promise((resolve) => setTimeout(resolve, 25)); - continue; - } - throw error; - } - } - - websocket.send( - JSON.stringify({ - __rivetkitTestHibernatableAckStateV1: true, - }), - ); - const message = await waitForJsonMessage(websocket, 1_000); - expect(message).toBeDefined(); - expect(message?.__rivetkitTestHibernatableAckStateV1).toBe(true); - - return { - lastSentIndex: message?.lastSentIndex as number, - lastAckedIndex: message?.lastAckedIndex as number, - pendingIndexes: message?.pendingIndexes as number[], - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/request-access.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/request-access.ts deleted file mode 100644 index 170cabe850..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/request-access.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { DriverTestConfig } from "../mod"; -import { setupDriverTest } from "../utils"; - -export function runRequestAccessTests(driverTestConfig: DriverTestConfig) { - describe("Request Access in Lifecycle Hooks", () => { - test("should have access to request object in onBeforeConnect and createConnState", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor with request tracking enabled - const handle = client.requestAccessActor.getOrCreate( - ["test-request"], - { - params: { trackRequest: true }, - }, - ); - const connection = handle.connect(); - - // Get request info that was captured in onBeforeConnect - const requestInfo = await connection.getRequestInfo(); - - // Verify request was accessible in HTTP mode, but not in inline mode - if (driverTestConfig.clientType === "http") { - // Check onBeforeConnect - expect(requestInfo.onBeforeConnect.hasRequest).toBe(true); - expect(requestInfo.onBeforeConnect.requestUrl).toBeDefined(); - expect(requestInfo.onBeforeConnect.requestMethod).toBeDefined(); - expect( - requestInfo.onBeforeConnect.requestHeaders, - ).toBeDefined(); - - // Check createConnState - expect(requestInfo.createConnState.hasRequest).toBe(true); - expect(requestInfo.createConnState.requestUrl).toBeDefined(); - expect(requestInfo.createConnState.requestMethod).toBeDefined(); - expect( - requestInfo.createConnState.requestHeaders, - ).toBeDefined(); - } else { - // Inline client may or may not have request object depending on the driver - // - // e.g. - // - File system does not have a request for inline requests - // - Rivet Engine proxies the request so it has access to the request object - } - - // Clean up - await connection.dispose(); - }); - - test("should not have request when trackRequest is false", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor without request tracking - const handle = client.requestAccessActor.getOrCreate( - ["test-no-request"], - { - params: { trackRequest: false }, - }, - ); - const connection = handle.connect(); - - // Get request info - const requestInfo = await connection.getRequestInfo(); - - // Verify request was not tracked - expect(requestInfo.onBeforeConnect.hasRequest).toBe(false); - expect(requestInfo.onBeforeConnect.requestUrl).toBeNull(); - expect(requestInfo.onBeforeConnect.requestMethod).toBeNull(); - expect( - Object.keys(requestInfo.onBeforeConnect.requestHeaders), - ).toHaveLength(0); - - expect(requestInfo.createConnState.hasRequest).toBe(false); - expect(requestInfo.createConnState.requestUrl).toBeNull(); - expect(requestInfo.createConnState.requestMethod).toBeNull(); - expect( - Object.keys(requestInfo.createConnState.requestHeaders), - ).toHaveLength(0); - - // Clean up - await connection.dispose(); - }); - - test("should capture request headers and method", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - // Create actor and connect with request tracking - const handle = client.requestAccessActor.getOrCreate( - ["test-headers"], - { - params: { trackRequest: true }, - }, - ); - const connection = handle.connect(); - - // Get request info - const requestInfo = await connection.getRequestInfo(); - - if (driverTestConfig.clientType === "http") { - // Verify request details were captured in both hooks - expect(requestInfo.onBeforeConnect.hasRequest).toBe(true); - expect(requestInfo.onBeforeConnect.requestMethod).toBeTruthy(); - expect(requestInfo.onBeforeConnect.requestUrl).toBeTruthy(); - expect(requestInfo.onBeforeConnect.requestHeaders).toBeTruthy(); - expect(typeof requestInfo.onBeforeConnect.requestHeaders).toBe( - "object", - ); - - expect(requestInfo.createConnState.hasRequest).toBe(true); - expect(requestInfo.createConnState.requestMethod).toBeTruthy(); - expect(requestInfo.createConnState.requestUrl).toBeTruthy(); - expect(requestInfo.createConnState.requestHeaders).toBeTruthy(); - expect(typeof requestInfo.createConnState.requestHeaders).toBe( - "object", - ); - } else { - // Inline client may or may not have request object depending on the driver - // - // See "should have access to request object in onBeforeConnect and createConnState" - } - - // Clean up - await connection.dispose(); - }); - - test("should run onBeforeConnect for stateless action calls", async (c) => { - const { client } = await setupDriverTest(c, driverTestConfig); - - const viewHandle = client.requestAccessActor.getOrCreate([ - "test-action-request", - ]); - const trackingHandle = client.requestAccessActor.getOrCreate( - ["test-action-request"], - { - params: { trackRequest: true }, - }, - ); - - expect(await trackingHandle.ping()).toBe("pong"); - - const requestInfo = await viewHandle.getRequestInfo(); - - if (driverTestConfig.clientType === "http") { - expect(requestInfo.onBeforeConnect.hasRequest).toBe(true); - expect(requestInfo.onBeforeConnect.requestMethod).toBeTruthy(); - expect(requestInfo.onBeforeConnect.requestUrl).toBeTruthy(); - expect(requestInfo.onBeforeConnect.requestHeaders).toBeTruthy(); - } else { - // Inline client may or may not have request object depending on the driver. - } - }); - - // TODO: re-expose this once we can have actor queries on the gateway - // test("should have access to request object in onRequest", async (c) => { - // const { client, endpoint } = await setupDriverTest(c, driverTestConfig); - // - // // Create actor - // const handle = client.requestAccessActor.getOrCreate(["test-fetch"]); - // - // // Make a raw HTTP request to the actor - // await handle.resolve(); // Ensure actor is created - // - // const actorQuery = { - // getOrCreateForKey: { - // name: "requestAccessActor", - // key: ["test-fetch"], - // }, - // }; - // - // const url = `${endpoint}/registry/actors/request/test-path`; - // const response = await fetch(url, { - // method: "POST", - // headers: { - // "Content-Type": "application/json", - // "X-Test-Header": "test-value", - // "X-RivetKit-Query": JSON.stringify(actorQuery), - // }, - // body: JSON.stringify({ test: "data" }), - // }); - // - // if (!response.ok) { - // const errorText = await response.text(); - // console.error( - // `HTTP request failed: ${response.status} ${response.statusText}`, - // errorText, - // ); - // } - // - // expect(response.ok).toBe(true); - // const data = await response.json(); - // - // // Verify request info from onRequest - // expect((data as any).hasRequest).toBe(true); - // expect((data as any).requestUrl).toContain("/test-path"); - // expect((data as any).requestMethod).toBe("POST"); - // expect((data as any).requestHeaders).toBeDefined(); - // expect((data as any).requestHeaders["content-type"]).toBe( - // "application/json", - // ); - // expect((data as any).requestHeaders["x-test-header"]).toBe("test-value"); - // }); - - // test("should have access to request object in onWebSocket", async (c) => { - // const { client, endpoint } = await setupDriverTest(c, driverTestConfig); - // - // // Only test in environments that support WebSocket - // if (typeof WebSocket !== "undefined") { - // // Create actor - // const handle = client.requestAccessActor.getOrCreate([ - // "test-websocket", - // ]); - // await handle.resolve(); // Ensure actor is created - // - // const actorQuery = { - // getOrCreateForKey: { - // name: "requestAccessActor", - // key: ["test-websocket"], - // }, - // }; - // - // // Encode query as WebSocket subprotocol - // const queryProtocol = `query.${encodeURIComponent(JSON.stringify(actorQuery))}`; - // - // // Create raw WebSocket connection - // const wsUrl = endpoint - // .replace("http://", "ws://") - // .replace("https://", "wss://"); - // const ws = new WebSocket( - // `${wsUrl}/registry/actors/websocket/test-path`, - // [ - // queryProtocol, - // "rivetkit", // Required protocol - // ], - // ); - // - // // Wait for connection and first message - // await new Promise((resolve, reject) => { - // ws.onopen = () => { - // // Connection established - // }; - // - // ws.onmessage = (event) => { - // try { - // const data = JSON.parse(event.data); - // - // // Verify request info from onWebSocket - // expect(data.hasRequest).toBe(true); - // expect(data.requestUrl).toContain("/test-path"); - // expect(data.requestMethod).toBe("GET"); - // expect(data.requestHeaders).toBeDefined(); - // - // ws.close(); - // resolve(); - // } catch (error) { - // reject(error); - // } - // }; - // - // ws.onerror = (error) => { - // reject(error); - // }; - // }); - // } - // }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/utils.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/utils.ts deleted file mode 100644 index c38ac3767f..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { type TestContext, vi } from "vitest"; -import { assertUnreachable } from "@/actor/utils"; -import { type Client, createClient } from "@/client/mod"; -import { createClientWithDriver } from "@/mod"; -import type { registry } from "../../fixtures/driver-test-suite/registry"; -import { logger } from "./log"; -import type { DriverTestConfig } from "./mod"; -import { createTestInlineClientDriver } from "./test-inline-client-driver"; -import { ClientConfigSchema } from "@/client/config"; - -export const FAKE_TIME = new Date("2024-01-01T00:00:00.000Z"); - -// Must use `TestContext` since global hooks do not work when running concurrently -export async function setupDriverTest( - c: TestContext, - driverTestConfig: DriverTestConfig, -): Promise<{ - client: Client; - endpoint: string; - hardCrashActor?: (actorId: string) => Promise; - hardCrashPreservesData: boolean; -}> { - if (!driverTestConfig.useRealTimers) { - vi.useFakeTimers(); - vi.setSystemTime(FAKE_TIME); - } - - // Build drivers - const { - endpoint, - namespace, - runnerName, - hardCrashActor, - hardCrashPreservesData, - cleanup, - } = - await driverTestConfig.start(); - - let client: Client; - if (driverTestConfig.clientType === "http") { - // Create client - client = createClient({ - endpoint, - namespace, - poolName: runnerName, - encoding: driverTestConfig.encoding, - // Disable metadata lookup to prevent redirect to the wrong port. - // Each test starts a new server on a dynamic port, but the - // registry's publicEndpoint defaults to port 6420. - disableMetadataLookup: true, - }); - } else if (driverTestConfig.clientType === "inline") { - // Use inline client from driver - const encoding = driverTestConfig.encoding ?? "bare"; - const managerDriver = createTestInlineClientDriver(endpoint, encoding); - const runConfig = ClientConfigSchema.parse({ - encoding: encoding, - }); - client = createClientWithDriver(managerDriver, runConfig); - } else { - assertUnreachable(driverTestConfig.clientType); - } - - c.onTestFinished(async () => { - if (!driverTestConfig.HACK_skipCleanupNet) { - await client.dispose(); - } - - logger().info("cleaning up test"); - await cleanup(); - }); - - return { - client, - endpoint, - hardCrashActor, - hardCrashPreservesData: hardCrashPreservesData ?? false, - }; -} - -export async function waitFor( - driverTestConfig: DriverTestConfig, - ms: number, -): Promise { - if (driverTestConfig.useRealTimers) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } else { - vi.advanceTimersByTime(ms); - return Promise.resolve(); - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/default.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/default.ts deleted file mode 100644 index f3a0ccdfaf..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/default.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { UserError } from "@/actor/errors"; -import { loggerWithoutContext } from "@/actor/log"; -import { createEngineDriver } from "@/drivers/engine/mod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { DriverConfig, RegistryConfig } from "@/registry/config"; - -/** - * Chooses the appropriate driver based on the run configuration. - */ -export function chooseDefaultDriver(config: RegistryConfig): DriverConfig { - if (config.endpoint && config.driver) { - throw new UserError( - "Cannot specify both 'endpoint' and 'driver' in configuration", - ); - } - - if (config.driver) { - return config.driver; - } - - if (config.endpoint || config.token) { - loggerWithoutContext().debug({ - msg: "using rivet engine driver", - endpoint: config.endpoint, - }); - return createEngineDriver(); - } - - loggerWithoutContext().debug({ - msg: "using default file system driver", - storagePath: config.storagePath, - }); - return createFileSystemOrMemoryDriver(true, { - path: config.storagePath, - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts index 58963ad9de..b2625d5ecf 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts @@ -49,17 +49,17 @@ import type { RivetMessageEvent, UniversalWebSocket, } from "@/common/websocket-interface"; +import type { ActorDriver } from "@/actor/driver"; +import type { AnyActorInstance } from "@/actor/instance/mod"; import { - type ActorDriver, - type AnyActorInstance, getInitialActorKvState, - type ManagerDriver, + type EngineControlClient, } from "@/driver-helpers/mod"; import { DynamicActorInstance } from "@/dynamic/instance"; import { DynamicActorIsolateRuntime } from "@/dynamic/isolate-runtime"; import { isDynamicActorDefinition } from "@/dynamic/internal"; import { buildActorNames, type RegistryConfig } from "@/registry/config"; -import { getEndpoint } from "@/remote-manager-driver/api-utils"; +import { getEndpoint } from "@/engine-client/api-utils"; import { type LongTimeoutHandle, promiseWithResolvers, @@ -108,7 +108,7 @@ export type DriverContext = {}; export class EngineActorDriver implements ActorDriver { #config: RegistryConfig; - #managerDriver: ManagerDriver; + #engineClient: EngineControlClient; #inlineClient: Client; #envoy: EnvoyHandle; #actors: Map = new Map(); @@ -152,11 +152,11 @@ export class EngineActorDriver implements ActorDriver { constructor( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, inlineClient: Client, ) { this.#config = config; - this.#managerDriver = managerDriver; + this.#engineClient = engineClient; this.#inlineClient = inlineClient; this.#sqlitePool = new SqliteVfsPoolManager(config); @@ -974,6 +974,7 @@ export class EngineActorDriver implements ActorDriver { loader: definition.loader, actorDriver: this, inlineClient: this.#inlineClient, + test: this.#config.test, }); await runtime.start(); this.#dynamicRuntimes.set(actorId, runtime); diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts index 8494c753be..4e3cf6e2fd 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts @@ -1,32 +1,6 @@ -import type { Client } from "@/client/client"; -import { convertRegistryConfigToClientConfig } from "@/client/config"; -import type { ManagerDriver } from "@/manager/driver"; -import { RemoteManagerDriver } from "@/remote-manager-driver/mod"; -import { EngineActorDriver } from "./actor-driver"; -import { RegistryConfig, DriverConfig } from "@/registry/config"; - export { EngineActorDriver } from "./actor-driver"; export { type EngineConfig as Config, type EngineConfigInput as InputConfig, EngineConfigSchema as ConfigSchema, } from "./config"; - -export function createEngineDriver(): DriverConfig { - return { - name: "engine", - displayName: "Engine", - manager: (config: RegistryConfig) => { - const clientConfig = convertRegistryConfigToClientConfig(config); - return new RemoteManagerDriver(clientConfig); - }, - actor: ( - config: RegistryConfig, - managerDriver: ManagerDriver, - inlineClient: Client, - ) => { - return new EngineActorDriver(config, managerDriver, inlineClient); - }, - autoStartActorDriver: true, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts deleted file mode 100644 index b27ae4154a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { AnyClient } from "@/client/client"; -import type { NativeSqliteConfig, RawDatabaseClient } from "@/db/config"; -import type { ISqliteVfs } from "@rivetkit/sqlite-vfs"; -import { - type ActorDriver, - type AnyActorInstance, - type ManagerDriver, -} from "@/driver-helpers/mod"; -import { SqliteVfsPoolManager } from "@/driver-helpers/sqlite-pool"; -import type { FileSystemGlobalState } from "./global-state"; -import { RegistryConfig } from "@/registry/config"; - -export type ActorDriverContext = Record; - -/** - * File System implementation of the Actor Driver - */ -export class FileSystemActorDriver implements ActorDriver { - #config: RegistryConfig; - #managerDriver: ManagerDriver; - #inlineClient: AnyClient; - #state: FileSystemGlobalState; - #sqlitePool: SqliteVfsPoolManager; - startSleep?: (actorId: string) => void; - - constructor( - config: RegistryConfig, - managerDriver: ManagerDriver, - inlineClient: AnyClient, - state: FileSystemGlobalState, - ) { - this.#config = config; - this.#managerDriver = managerDriver; - this.#inlineClient = inlineClient; - this.#state = state; - this.#sqlitePool = new SqliteVfsPoolManager(config); - - if (this.#state.persist) { - // Only define startSleep when persistence is enabled. The actor runtime - // checks for this property to determine whether the driver supports sleep. - this.startSleep = (actorId: string) => { - // Spawns the sleepActor promise. - this.#state.sleepActor(actorId); - }; - } - } - - async loadActor(actorId: string): Promise { - return this.#state.startActor( - this.#config, - this.#inlineClient, - this, - actorId, - ); - } - - /** - * Get the current storage directory path - */ - get storagePath(): string { - return this.#state.storagePath; - } - - getContext(_actorId: string): ActorDriverContext { - return {}; - } - - getNativeSqliteConfig(_actorId: string): NativeSqliteConfig | undefined { - return this.#state.nativeSqliteConfig; - } - - async kvBatchPut( - actorId: string, - entries: [Uint8Array, Uint8Array][], - ): Promise { - await this.#state.kvBatchPut(actorId, entries); - } - - async kvBatchGet( - actorId: string, - keys: Uint8Array[], - ): Promise<(Uint8Array | null)[]> { - return await this.#state.kvBatchGet(actorId, keys); - } - - async kvBatchDelete(actorId: string, keys: Uint8Array[]): Promise { - await this.#state.kvBatchDelete(actorId, keys); - } - - async kvDeleteRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - ): Promise { - await this.#state.kvDeleteRange(actorId, start, end); - } - - async kvListPrefix( - actorId: string, - prefix: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - return await this.#state.kvListPrefix(actorId, prefix, options); - } - - async kvListRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - return await this.#state.kvListRange(actorId, start, end, options); - } - - cancelAlarm(actorId: string): void { - this.#state.cancelAlarmTimeout(actorId); - } - - async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { - await this.#state.setActorAlarm(actor.id, timestamp); - } - - /** Creates a SQLite VFS instance for creating KV-backed databases */ - async createSqliteVfs(actorId: string): Promise { - return await this.#sqlitePool.acquire(actorId); - } - - async shutdownRunner(_immediate: boolean): Promise { - await this.#sqlitePool.shutdown(); - } - - async hardCrashActor(actorId: string): Promise { - await this.#state.hardCrashActor(actorId); - } - - startSleep(actorId: string): void { - // Spawns the sleepActor promise - this.#state.sleepActor(actorId); - } - - ackHibernatableWebSocketMessage( - gatewayId: ArrayBuffer, - requestId: ArrayBuffer, - serverMessageIndex: number, - ): void { - this.#state.ackHibernatableWebSocketMessage( - gatewayId, - requestId, - serverMessageIndex, - ); - } - - async startDestroy(actorId: string): Promise { - await this.#state.destroyActor(actorId); - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/global-state.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/global-state.ts deleted file mode 100644 index 7450080011..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/global-state.ts +++ /dev/null @@ -1,1791 +0,0 @@ -import invariant from "invariant"; -import { - isStaticActorDefinition, - lookupInRegistry, -} from "@/actor/definition"; -import { ActorDuplicateKey, ActorError, ActorNotFound } from "@/actor/errors"; -import type { Encoding } from "@/actor/protocol/serde"; -import type { - AnyActorInstance, - AnyStaticActorInstance, -} from "@/actor/instance/mod"; -import type { ActorKey } from "@/actor/mod"; -import type { AnyClient } from "@/client/client"; -import type { NativeSqliteConfig } from "@/db/config"; -import type { UniversalWebSocket } from "@/common/websocket-interface"; -import { type ActorDriver, getInitialActorKvState } from "@/driver-helpers/mod"; -import { DynamicActorInstance } from "@/dynamic/instance"; -import { - DynamicActorIsolateRuntime, - type DynamicWebSocketOpenOptions, -} from "@/dynamic/isolate-runtime"; -import { isDynamicActorDefinition } from "@/dynamic/internal"; -import type { RegistryConfig } from "@/registry/config"; -import type * as schema from "@/schemas/file-system-driver/mod"; -import { - ACTOR_ALARM_VERSIONED, - ACTOR_STATE_VERSIONED, - CURRENT_VERSION as FILE_SYSTEM_DRIVER_CURRENT_VERSION, -} from "@/schemas/file-system-driver/versioned"; -import { - type LongTimeoutHandle, - promiseWithResolvers, - setLongTimeout, - stringifyError, -} from "@/utils"; -import { - getNodeCrypto, - getNodeFs, - getNodeFsSync, - getNodePath, -} from "@/utils/node"; -import { logger } from "./log"; -import { - ensureDirectoryExists, - ensureDirectoryExistsSync, - getStoragePath, -} from "./utils"; -import { - computePrefixUpperBound, - ensureUint8Array, - loadSqliteRuntime, - type SqliteRuntime, - type SqliteRuntimeDatabase, -} from "./sqlite-runtime"; -import { - estimateKvSize, - validateKvEntries, - validateKvKey, - validateKvKeys, -} from "./kv-limits"; - -const DEFAULT_LIST_LIMIT = 16_384; - -// Actor handler to track running instances - -function compareBytes(a: Uint8Array, b: Uint8Array): number { - const len = Math.min(a.length, b.length); - for (let i = 0; i < len; i++) { - if (a[i] !== b[i]) { - return a[i] - b[i]; - } - } - return a.length - b.length; -} - -enum ActorLifecycleState { - NONEXISTENT, // Entry exists but actor not yet created - AWAKE, // Actor is running normally - STARTING_SLEEP, // Actor is being put to sleep - STARTING_DESTROY, // Actor is being destroyed - DESTROYED, // Actor was destroyed, should not be recreated -} - -interface ActorEntry { - id: string; - - state?: schema.ActorState; - - /** Promise for loading the actor state. */ - loadPromise?: Promise; - - actor?: AnyActorInstance; - /** Promise for starting the actor. */ - startPromise?: ReturnType>; - /** Promise for stopping the actor. */ - stopPromise?: PromiseWithResolvers; - - alarmTimeout?: LongTimeoutHandle; - /** The timestamp currently scheduled for this actor's alarm (ms since epoch). */ - alarmTimestamp?: number; - - /** Resolver for pending write operations that need to be notified when any write completes */ - pendingWriteResolver?: PromiseWithResolvers; - - lifecycleState: ActorLifecycleState; - - // TODO: This might make sense to move in to actorstate, but we have a - // single reader/writer so it's not an issue - /** Generation of this actor when creating/destroying. */ - generation: string; -} - -interface HibernatableWebSocketAckObserver { - onAck: (serverMessageIndex: number) => void; -} - -export interface FileSystemDriverOptions { - /** Whether to persist data to disk */ - persist?: boolean; - /** Custom path for storage */ - customPath?: string; - /** Deprecated option retained for explicit migration to sqlite-only KV. */ - useNativeSqlite?: boolean; -} - -/** - * Global state for the file system driver - */ -export class FileSystemGlobalState { - #storagePath: string; - #stateDir: string; - #dbsDir: string; - #alarmsDir: string; - - #persist: boolean; - #sqliteRuntime: SqliteRuntime; - #actorKvDatabases = new Map(); - #dynamicRuntimes = new Map(); - #actorInitialInputs = new Map(); - #hibernatableWebSocketAckObservers = new Map< - string, - HibernatableWebSocketAckObserver - >(); - #hibernatableWebSocketRestoreIntents = new Map(); - - // IMPORTANT: Never delete from this map. Doing so will result in race - // conditions since the actor generation will cease to be tracked - // correctly. Always increment generation if a new actor is created. - #actors = new Map(); - - #actorCountOnStartup: number = 0; - #nativeSqliteConfig?: NativeSqliteConfig; - - #runnerParams?: { - config: RegistryConfig; - inlineClient: AnyClient; - actorDriver: ActorDriver; - }; - - get persist(): boolean { - return this.#persist; - } - - get storagePath() { - return this.#storagePath; - } - - get actorCountOnStartup() { - return this.#actorCountOnStartup; - } - - get nativeSqliteConfig() { - return this.#nativeSqliteConfig; - } - - constructor(options: FileSystemDriverOptions = {}) { - const { persist = true, customPath, useNativeSqlite = true } = options; - if (!useNativeSqlite) { - throw new Error( - "File-system driver no longer supports non-SQLite KV storage.", - ); - } - this.#persist = persist; - this.#sqliteRuntime = loadSqliteRuntime(); - this.#storagePath = persist ? (customPath ?? getStoragePath()) : "/tmp"; - const path = getNodePath(); - this.#stateDir = path.join(this.#storagePath, "state"); - this.#dbsDir = path.join(this.#storagePath, "databases"); - this.#alarmsDir = path.join(this.#storagePath, "alarms"); - - if (this.#persist) { - // Ensure storage directories exist synchronously during initialization - ensureDirectoryExistsSync(this.#stateDir); - ensureDirectoryExistsSync(this.#dbsDir); - ensureDirectoryExistsSync(this.#alarmsDir); - - try { - const fsSync = getNodeFsSync(); - const actorIds = fsSync.readdirSync(this.#stateDir); - this.#actorCountOnStartup = actorIds.length; - } catch (error) { - logger().error({ msg: "failed to count actors", error }); - } - - logger().debug({ - msg: "file system driver ready", - dir: this.#storagePath, - actorCount: this.#actorCountOnStartup, - sqliteRuntime: this.#sqliteRuntime.kind, - }); - - // Cleanup stale temp files on startup - try { - this.#cleanupTempFilesSync(); - } catch (err) { - logger().error({ - msg: "failed to cleanup temp files", - error: err, - }); - } - - try { - this.#migrateLegacyKvToSqliteOnStartupSync(); - } catch (error) { - logger().error({ - msg: "failed legacy kv startup migration", - error, - }); - throw error; - } - } else { - logger().debug({ - msg: "memory driver ready", - sqliteRuntime: this.#sqliteRuntime.kind, - }); - } - } - - setNativeSqliteConfig(config: NativeSqliteConfig): void { - this.#nativeSqliteConfig = config; - } - - getActorStatePath(actorId: string): string { - return getNodePath().join(this.#stateDir, actorId); - } - - getActorDbPath(actorId: string): string { - return getNodePath().join(this.#dbsDir, `${actorId}.db`); - } - - getActorAlarmPath(actorId: string): string { - return getNodePath().join(this.#alarmsDir, actorId); - } - - beginHibernatableWebSocketRestore(actorId: string): void { - const count = - this.#hibernatableWebSocketRestoreIntents.get(actorId) ?? 0; - this.#hibernatableWebSocketRestoreIntents.set(actorId, count + 1); - } - - endHibernatableWebSocketRestore(actorId: string): void { - const count = - this.#hibernatableWebSocketRestoreIntents.get(actorId) ?? 0; - if (count <= 1) { - this.#hibernatableWebSocketRestoreIntents.delete(actorId); - return; - } - this.#hibernatableWebSocketRestoreIntents.set(actorId, count - 1); - } - - isRestoringHibernatableWebSocket(actorId: string): boolean { - return ( - (this.#hibernatableWebSocketRestoreIntents.get(actorId) ?? 0) > 0 - ); - } - - #getActorKvDatabasePath(actorId: string): string { - if (this.#persist) { - return this.getActorDbPath(actorId); - } - return ":memory:"; - } - - #ensureActorKvTables(db: SqliteRuntimeDatabase): void { - db.exec(` - CREATE TABLE IF NOT EXISTS kv ( - key BLOB PRIMARY KEY NOT NULL, - value BLOB NOT NULL - ) - `); - } - - #getOrCreateActorKvDatabase(actorId: string): SqliteRuntimeDatabase { - const existing = this.#actorKvDatabases.get(actorId); - if (existing) { - return existing; - } - - const dbPath = this.#getActorKvDatabasePath(actorId); - if (this.#persist) { - const path = getNodePath(); - ensureDirectoryExistsSync(path.dirname(dbPath)); - } - - let db: SqliteRuntimeDatabase; - try { - db = this.#sqliteRuntime.open(dbPath); - } catch (error) { - throw new Error( - `failed to open actor kv database for actor ${actorId} at ${dbPath}: ${error}`, - ); - } - - this.#ensureActorKvTables(db); - this.#actorKvDatabases.set(actorId, db); - return db; - } - - #closeActorKvDatabase(actorId: string): void { - const db = this.#actorKvDatabases.get(actorId); - if (!db) { - return; - } - - try { - db.close(); - } finally { - this.#actorKvDatabases.delete(actorId); - } - } - - #putKvEntriesInDb( - db: SqliteRuntimeDatabase, - entries: [Uint8Array, Uint8Array][], - ): void { - if (entries.length === 0) { - return; - } - - db.exec("BEGIN"); - try { - for (const [key, value] of entries) { - db.run("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", [ - key, - value, - ]); - } - db.exec("COMMIT"); - } catch (error) { - try { - db.exec("ROLLBACK"); - } catch { - // Ignore rollback errors, original error is more actionable. - } - throw error; - } - } - - #isKvDbPopulated(db: SqliteRuntimeDatabase): boolean { - const row = db.get<{ count: number | bigint }>( - "SELECT COUNT(*) AS count FROM kv", - ); - const count = row ? Number(row.count) : 0; - return count > 0; - } - - #migrateLegacyKvToSqliteOnStartupSync(): void { - const fsSync = getNodeFsSync(); - if (!fsSync.existsSync(this.#stateDir)) { - return; - } - - const actorIds = fsSync - .readdirSync(this.#stateDir) - .filter((id) => !id.includes(".tmp.")); - - for (const actorId of actorIds) { - const statePath = this.getActorStatePath(actorId); - let state: schema.ActorState; - try { - const stateBytes = fsSync.readFileSync(statePath); - state = ACTOR_STATE_VERSIONED.deserializeWithEmbeddedVersion( - new Uint8Array(stateBytes), - ); - } catch (error) { - logger().warn({ - msg: "failed to parse actor state during startup migration", - actorId, - error, - }); - continue; - } - - if (!state.kvStorage || state.kvStorage.length === 0) { - continue; - } - - const dbPath = this.getActorDbPath(actorId); - const path = getNodePath(); - ensureDirectoryExistsSync(path.dirname(dbPath)); - const db = this.#sqliteRuntime.open(dbPath); - try { - this.#ensureActorKvTables(db); - if (this.#isKvDbPopulated(db)) { - continue; - } - - const legacyEntries = state.kvStorage.map((entry) => [ - new Uint8Array(entry.key), - new Uint8Array(entry.value), - ]) as [Uint8Array, Uint8Array][]; - this.#putKvEntriesInDb(db, legacyEntries); - - logger().info({ - msg: "migrated legacy actor kv storage to sqlite", - actorId, - entryCount: legacyEntries.length, - }); - } finally { - db.close(); - } - } - } - - async *getActorsIterator(params: { - cursor?: string; - }): AsyncGenerator { - let actorIds = Array.from(this.#actors.keys()).sort(); - - // Check if state directory exists first - const fsSync = getNodeFsSync(); - if (fsSync.existsSync(this.#stateDir)) { - actorIds = fsSync - .readdirSync(this.#stateDir) - .filter((id) => !id.includes(".tmp")) - .sort(); - } - - const startIndex = params.cursor - ? actorIds.indexOf(params.cursor) + 1 - : 0; - - for (let i = startIndex; i < actorIds.length; i++) { - const actorId = actorIds[i]; - if (!actorId) { - continue; - } - - try { - const state = await this.loadActorStateOrError(actorId); - yield state; - } catch (error) { - logger().error({ - msg: "failed to load actor state", - actorId, - error, - }); - } - } - } - - /** - * Ensures an entry exists for this actor. - * - * Used for #createActor and #loadActor. - */ - #upsertEntry(actorId: string): ActorEntry { - let entry = this.#actors.get(actorId); - if (entry) { - return entry; - } - - entry = { - id: actorId, - lifecycleState: ActorLifecycleState.NONEXISTENT, - generation: crypto.randomUUID(), - }; - this.#actors.set(actorId, entry); - return entry; - } - - /** - * Creates a new actor and writes to file system. - */ - async createActor( - actorId: string, - name: string, - key: ActorKey, - input: unknown | undefined, - ): Promise { - // TODO: Does not check if actor already exists on fs - - await this.#waitForActorStop(actorId); - let entry = this.#upsertEntry(actorId); - - // Check if actor already exists (has state or is being stopped) - if (entry.state) { - throw new ActorDuplicateKey(name, key); - } - if (this.isActorStopping(actorId)) { - await this.#waitForActorStop(actorId); - entry = this.#upsertEntry(actorId); - } - - // If actor was destroyed, reset to NONEXISTENT and increment generation - if (entry.lifecycleState === ActorLifecycleState.DESTROYED) { - entry.lifecycleState = ActorLifecycleState.NONEXISTENT; - entry.generation = crypto.randomUUID(); - } - - // Initialize storage (runtime KV is stored in SQLite; state.kvStorage is legacy-only) - const initialKvState = getInitialActorKvState(input); - this.#actorInitialInputs.set(actorId, input); - - // Initialize metadata - await this.#withActorWrite(actorId, async (lockedEntry) => { - lockedEntry.state = { - actorId, - name, - key, - createdAt: BigInt(Date.now()), - kvStorage: [], - startTs: null, - connectableTs: null, - sleepTs: null, - destroyTs: null, - }; - lockedEntry.lifecycleState = ActorLifecycleState.AWAKE; - if (this.#persist) { - await this.#performWrite( - actorId, - lockedEntry.generation, - lockedEntry.state, - ); - } - if (initialKvState.length > 0) { - const db = this.#getOrCreateActorKvDatabase(actorId); - this.#putKvEntriesInDb(db, initialKvState); - } - }); - - return entry; - } - - /** - * Loads the actor from disk or returns the existing actor entry. This will return an entry even if the actor does not actually exist. - */ - async loadActor(actorId: string): Promise { - const entry = this.#upsertEntry(actorId); - - // Check if destroyed - don't load from disk - if (entry.lifecycleState === ActorLifecycleState.DESTROYED) { - return entry; - } - - // Check if already loaded - if (entry.state) { - return entry; - } - - // If not persisted, then don't load from FS - if (!this.#persist) { - return entry; - } - - // If state is currently being loaded, wait for it - if (entry.loadPromise) { - await entry.loadPromise; - return entry; - } - - // Start loading state - entry.loadPromise = this.loadActorState(entry); - return entry.loadPromise; - } - - private async loadActorState(entry: ActorEntry) { - const stateFilePath = this.getActorStatePath(entry.id); - - // Read & parse file - try { - const fs = getNodeFs(); - const stateData = await fs.readFile(stateFilePath); - - const loadedState = - ACTOR_STATE_VERSIONED.deserializeWithEmbeddedVersion( - new Uint8Array(stateData), - ); - - // Runtime reads/writes are SQLite-only; legacy kvStorage is for one-time startup migration. - entry.state = { - ...loadedState, - kvStorage: [], - }; - - return entry; - } catch (innerError: any) { - // File does not exist, meaning the actor does not exist - if (innerError.code === "ENOENT") { - entry.loadPromise = undefined; - return entry; - } - - // For other errors, throw - const error = new Error( - `Failed to load actor state: ${innerError}`, - ); - throw error; - } - } - - async loadOrCreateActor( - actorId: string, - name: string, - key: ActorKey, - input: unknown | undefined, - ): Promise { - await this.#waitForActorStop(actorId); - - // Attempt to load actor - const entry = await this.loadActor(actorId); - - // If no state for this actor, then create & write state - if (!entry.state) { - if (this.isActorStopping(actorId)) { - await this.#waitForActorStop(actorId); - return await this.loadOrCreateActor(actorId, name, key, input); - } - - // If actor was destroyed, reset to NONEXISTENT and increment generation - if (entry.lifecycleState === ActorLifecycleState.DESTROYED) { - entry.lifecycleState = ActorLifecycleState.NONEXISTENT; - entry.generation = crypto.randomUUID(); - } - - // Initialize storage (runtime KV is stored in SQLite; state.kvStorage is legacy-only) - const initialKvState = getInitialActorKvState(input); - this.#actorInitialInputs.set(actorId, input); - - await this.#withActorWrite(actorId, async (lockedEntry) => { - lockedEntry.state = { - actorId, - name, - key: key as readonly string[], - createdAt: BigInt(Date.now()), - kvStorage: [], - startTs: null, - connectableTs: null, - sleepTs: null, - destroyTs: null, - }; - if (this.#persist) { - await this.#performWrite( - actorId, - lockedEntry.generation, - lockedEntry.state, - ); - } - if (initialKvState.length > 0) { - const db = this.#getOrCreateActorKvDatabase(actorId); - this.#putKvEntriesInDb(db, initialKvState); - } - }); - } - return entry; - } - - async sleepActor(actorId: string) { - invariant( - this.#persist, - "cannot sleep actor with memory driver, must use file system driver", - ); - - // Get the actor. We upsert it even though we're about to destroy it so we have a lock on flagging `destroying` as true. - const actor = this.#upsertEntry(actorId); - invariant(actor, `tried to sleep ${actorId}, does not exist`); - - // Check if already destroying - if (this.isActorStopping(actorId)) { - return; - } - actor.lifecycleState = ActorLifecycleState.STARTING_SLEEP; - actor.stopPromise = promiseWithResolvers((reason) => - logger().warn({ - msg: "unhandled actor sleep stop promise rejection", - reason, - }), - ); - - // Wait for actor to fully start before stopping it to avoid race conditions - if (actor.loadPromise) await actor.loadPromise.catch(); - if (actor.startPromise?.promise) - await actor.startPromise.promise.catch(); - - try { - // Update state with sleep timestamp - if (actor.state) { - await this.#withActorWrite(actorId, async (lockedEntry) => { - if (!lockedEntry.state) { - return; - } - lockedEntry.state = { - ...lockedEntry.state, - connectableTs: null, - sleepTs: BigInt(Date.now()), - }; - if (this.#persist) { - await this.#performWrite( - actorId, - lockedEntry.generation, - lockedEntry.state, - ); - } - }); - } - - // Stop actor - invariant(actor.actor, "actor should be loaded"); - await actor.actor.onStop("sleep"); - } finally { - // Ensure any pending KV writes finish before removing the entry. - await this.#withActorWrite(actorId, async () => {}); - this.#closeActorKvDatabase(actorId); - this.#dynamicRuntimes.delete(actorId); - actor.stopPromise?.resolve(); - actor.stopPromise = undefined; - - // Remove from map after stop is complete - this.#actors.delete(actorId); - } - } - - async destroyActor(actorId: string) { - // Get the actor. We upsert it even though we're about to destroy it so we have a lock on flagging `destroying` as true. - const actor = this.#upsertEntry(actorId); - - // If actor is loaded, stop it first - // Check if already destroying - if (this.isActorStopping(actorId)) { - return; - } - actor.lifecycleState = ActorLifecycleState.STARTING_DESTROY; - actor.stopPromise = promiseWithResolvers((reason) => - logger().warn({ - msg: "unhandled actor destroy stop promise rejection", - reason, - }), - ); - - // Wait for actor to fully start before stopping it to avoid race conditions - if (actor.loadPromise) await actor.loadPromise.catch(); - if (actor.startPromise?.promise) - await actor.startPromise.promise.catch(); - - try { - // Update state with destroy timestamp - if (actor.state) { - await this.#withActorWrite(actorId, async (lockedEntry) => { - if (!lockedEntry.state) { - return; - } - lockedEntry.state = { - ...lockedEntry.state, - destroyTs: BigInt(Date.now()), - }; - if (this.#persist) { - await this.#performWrite( - actorId, - lockedEntry.generation, - lockedEntry.state, - ); - } - }); - } - - // Stop actor if it's running - if (actor.actor) { - await actor.actor.onStop("destroy"); - } - this.#dynamicRuntimes.delete(actorId); - this.#actorInitialInputs.delete(actorId); - - // Ensure any pending KV writes finish before deleting files. - await this.#withActorWrite(actorId, async () => {}); - this.#closeActorKvDatabase(actorId); - - // Clear alarm timeout if exists - if (actor.alarmTimeout) { - actor.alarmTimeout.abort(); - } - - // Delete persisted files if using file system driver - if (this.#persist) { - const fs = getNodeFs(); - - // Delete all actor files in parallel - await Promise.all([ - // Delete actor state file - (async () => { - try { - await fs.unlink(this.getActorStatePath(actorId)); - } catch (err: any) { - if (err?.code !== "ENOENT") { - logger().error({ - msg: "failed to delete actor state file", - actorId, - error: stringifyError(err), - }); - } - } - })(), - // Delete actor database file - (async () => { - try { - await fs.unlink(this.getActorDbPath(actorId)); - } catch (err: any) { - if (err?.code !== "ENOENT") { - logger().error({ - msg: "failed to delete actor database file", - actorId, - error: stringifyError(err), - }); - } - } - })(), - // Delete actor alarm file - (async () => { - try { - await fs.unlink(this.getActorAlarmPath(actorId)); - } catch (err: any) { - if (err?.code !== "ENOENT") { - logger().error({ - msg: "failed to delete actor alarm file", - actorId, - error: stringifyError(err), - }); - } - } - })(), - ]); - } - } finally { - // Ensure any pending KV writes finish before clearing the entry. - await this.#withActorWrite(actorId, async () => {}); - actor.stopPromise?.resolve(); - actor.stopPromise = undefined; - - // Reset the entry - // - // Do not remove entry in order to avoid race condition with - // destroying. Next actor creation will increment the generation. - actor.state = undefined; - actor.loadPromise = undefined; - actor.actor = undefined; - actor.startPromise = undefined; - actor.alarmTimeout = undefined; - actor.alarmTimeout = undefined; - actor.pendingWriteResolver = undefined; - actor.lifecycleState = ActorLifecycleState.DESTROYED; - } - } - - async hardCrashActor(actorId: string): Promise { - const actor = this.#actors.get(actorId); - if (!actor) { - return; - } - - if (this.isActorStopping(actorId)) { - await this.#waitForActorStop(actorId); - return; - } - - if (actor.loadPromise) { - await actor.loadPromise.catch(() => undefined); - } - if (actor.startPromise?.promise) { - await actor.startPromise.promise.catch(() => undefined); - } - - try { - if (actor.alarmTimeout) { - actor.alarmTimeout.abort(); - actor.alarmTimeout = undefined; - } - - if (actor.actor) { - await actor.actor.debugForceCrash(); - } - } finally { - this.#closeActorKvDatabase(actorId); - actor.stopPromise?.resolve(); - actor.stopPromise = undefined; - this.#actors.delete(actorId); - } - } - - /** - * Save actor state to disk. - */ - async writeActor( - actorId: string, - generation: string, - state: schema.ActorState, - ): Promise { - if (!this.#persist) { - return; - } - - await this.#withActorWrite(actorId, async () => { - await this.#performWrite(actorId, generation, state); - }); - } - - isGenerationCurrentAndNotDestroyed( - actorId: string, - generation: string, - ): boolean { - const entry = this.#upsertEntry(actorId); - if (!entry) return false; - return ( - entry.generation === generation && - entry.lifecycleState !== ActorLifecycleState.STARTING_DESTROY - ); - } - - isActorStopping(actorId: string) { - const entry = this.#upsertEntry(actorId); - if (!entry) return false; - return ( - entry.lifecycleState === ActorLifecycleState.STARTING_SLEEP || - entry.lifecycleState === ActorLifecycleState.STARTING_DESTROY - ); - } - - async #waitForActorStop(actorId: string): Promise { - while (true) { - const entry = this.#actors.get(actorId); - if (!entry?.stopPromise) { - return; - } - try { - await entry.stopPromise.promise; - } catch { - return; - } - } - } - - async #withActorWrite( - actorId: string, - fn: (entry: ActorEntry) => Promise, - ): Promise { - const entry = this.#actors.get(actorId); - invariant(entry, "actor entry does not exist"); - - const previousWrite = entry.pendingWriteResolver; - const currentWrite = promiseWithResolvers((reason) => - logger().warn({ - msg: "unhandled kv write promise rejection", - reason, - }), - ); - entry.pendingWriteResolver = currentWrite; - - if (previousWrite) { - try { - await previousWrite.promise; - } catch { - // Ignore failed previous writes so later writes can proceed. - } - } - - try { - return await fn(entry); - } finally { - currentWrite.resolve(); - if (entry.pendingWriteResolver === currentWrite) { - entry.pendingWriteResolver = undefined; - } - } - } - - async #waitForPendingWrite(actorId: string): Promise { - const entry = this.#actors.get(actorId); - if (!entry?.pendingWriteResolver) { - return; - } - - while (entry.pendingWriteResolver) { - const pending = entry.pendingWriteResolver; - try { - await pending.promise; - } catch { - // Ignore write failures to avoid blocking reads forever. - } - } - } - - async setActorAlarm(actorId: string, timestamp: number) { - const entry = this.#actors.get(actorId); - invariant(entry, "actor entry does not exist"); - - // Track generation of the actor when the write started to detect - // destroy/create race condition - const writeGeneration = entry.generation; - if (this.isActorStopping(actorId)) { - logger().info("skipping set alarm since actor stopping"); - return; - } - - // Persist alarm to disk - if (this.#persist) { - const alarmPath = this.getActorAlarmPath(actorId); - const crypto = getNodeCrypto(); - const tempPath = `${alarmPath}.tmp.${crypto.randomUUID()}`; - try { - const path = getNodePath(); - await ensureDirectoryExists(path.dirname(alarmPath)); - const alarmData: schema.ActorAlarm = { - actorId, - timestamp: BigInt(timestamp), - }; - const data = ACTOR_ALARM_VERSIONED.serializeWithEmbeddedVersion( - alarmData, - FILE_SYSTEM_DRIVER_CURRENT_VERSION, - ); - const fs = getNodeFs(); - await fs.writeFile(tempPath, data); - - if ( - !this.isGenerationCurrentAndNotDestroyed( - actorId, - writeGeneration, - ) - ) { - logger().debug( - "skipping writing alarm since actor destroying or new generation", - ); - return; - } - - await fs.rename(tempPath, alarmPath); - } catch (error) { - try { - const fs = getNodeFs(); - await fs.unlink(tempPath); - } catch (cleanupError) { - logger().debug({ - msg: "failed to cleanup temp alarm file after write failure", - actorId, - tempPath, - error: stringifyError(cleanupError), - }); - } - logger().error({ - msg: "failed to write alarm", - actorId, - error, - }); - throw new Error(`Failed to write alarm: ${error}`); - } - } - - // Schedule timeout - this.#scheduleAlarmTimeout(actorId, timestamp); - } - - /** - * Perform the actual write operation with atomic writes - */ - async #performWrite( - actorId: string, - generation: string, - state: schema.ActorState, - ): Promise { - const dataPath = this.getActorStatePath(actorId); - // Generate unique temp filename to prevent any race conditions - const crypto = getNodeCrypto(); - const tempPath = `${dataPath}.tmp.${crypto.randomUUID()}`; - - try { - // Create directory if needed - const path = getNodePath(); - await ensureDirectoryExists(path.dirname(dataPath)); - - // Convert to BARE types for serialization - const bareState: schema.ActorState = { - actorId: state.actorId, - name: state.name, - key: state.key, - createdAt: state.createdAt, - kvStorage: state.kvStorage, - startTs: state.startTs, - connectableTs: state.connectableTs, - sleepTs: state.sleepTs, - destroyTs: state.destroyTs, - }; - - // Perform atomic write - const serializedState = - ACTOR_STATE_VERSIONED.serializeWithEmbeddedVersion( - bareState, - FILE_SYSTEM_DRIVER_CURRENT_VERSION, - ); - const fs = getNodeFs(); - await fs.writeFile(tempPath, serializedState); - - if (!this.isGenerationCurrentAndNotDestroyed(actorId, generation)) { - logger().debug( - "skipping writing alarm since actor destroying or new generation", - ); - return; - } - - await fs.rename(tempPath, dataPath); - } catch (error) { - // Cleanup temp file on error - try { - const fs = getNodeFs(); - await fs.unlink(tempPath); - } catch { - // Ignore cleanup errors - } - logger().error({ - msg: "failed to save actor state", - actorId, - error, - }); - throw new Error(`Failed to save actor state: ${error}`); - } - } - - /** - * Call this method after the actor driver has been initiated. - * - * This will trigger all initial alarms from the file system. - * - * This needs to be sync since DriverConfig.actor is sync - */ - onRunnerStart( - config: RegistryConfig, - inlineClient: AnyClient, - actorDriver: ActorDriver, - ) { - if (this.#runnerParams) { - return; - } - - // Save runner params for future use - this.#runnerParams = { - config: config, - inlineClient, - actorDriver, - }; - - // Load alarms from disk and schedule timeouts - try { - this.#loadAlarmsSync(); - } catch (err) { - logger().error({ - msg: "failed to load alarms on startup", - error: err, - }); - } - } - - async startActor( - config: RegistryConfig, - inlineClient: AnyClient, - actorDriver: ActorDriver, - actorId: string, - ): Promise { - await this.#waitForActorStop(actorId); - - // Get the actor metadata - let entry = await this.loadActor(actorId); - if (!entry.state) { - throw new ActorNotFound(actorId); - } - - // Actor already starting - if (entry.startPromise) { - await entry.startPromise.promise; - invariant(entry.actor, "actor should have loaded"); - return entry.actor; - } - - // Actor already loaded - if (entry.actor) { - if (entry.actor.isStopping || this.isActorStopping(actorId)) { - await this.#waitForActorStop(actorId); - entry = await this.loadActor(actorId); - if (!entry.state) { - throw new ActorNotFound(actorId); - } - } else { - return entry.actor; - } - } - - // Create start promise - entry.startPromise = promiseWithResolvers((reason) => - logger().warn({ - msg: "unhandled actor start promise rejection", - reason, - }), - ); - - try { - // Create actor - const definition = lookupInRegistry(config, entry.state.name); - if (isDynamicActorDefinition(definition)) { - let runtime = this.#dynamicRuntimes.get(actorId); - if (!runtime) { - runtime = new DynamicActorIsolateRuntime({ - actorId, - actorName: entry.state.name, - actorKey: entry.state.key as string[], - input: this.#actorInitialInputs.get(actorId), - region: "unknown", - loader: definition.loader, - actorDriver, - inlineClient, - }); - await runtime.start(); - this.#dynamicRuntimes.set(actorId, runtime); - } - entry.actor = new DynamicActorInstance( - actorId, - runtime, - ); - entry.lifecycleState = ActorLifecycleState.AWAKE; - } else if (isStaticActorDefinition(definition)) { - const staticActor = - (await definition.instantiate()) as AnyStaticActorInstance; - entry.actor = staticActor; - entry.lifecycleState = ActorLifecycleState.AWAKE; - - // Start actor - await staticActor.start( - actorDriver, - inlineClient, - actorId, - entry.state.name, - entry.state.key as string[], - "unknown", - ); - } else { - throw new Error( - `actor definition for ${entry.state.name} is not instantiable`, - ); - } - - // Update state with start timestamp - // NOTE: connectableTs is always in sync with startTs since actors become connectable immediately after starting - const now = BigInt(Date.now()); - await this.#withActorWrite(actorId, async (lockedEntry) => { - if (!lockedEntry.state) { - throw new ActorNotFound(actorId); - } - lockedEntry.state = { - ...lockedEntry.state, - startTs: now, - connectableTs: now, - sleepTs: null, // Clear sleep timestamp when actor wakes up - }; - if (this.#persist) { - await this.#performWrite( - actorId, - lockedEntry.generation, - lockedEntry.state, - ); - } - }); - - // Finish - if (!this.isRestoringHibernatableWebSocket(actorId)) { - await entry.actor.cleanupPersistedConnections?.( - "file-system-driver.start", - ); - } - - entry.startPromise.resolve(); - entry.startPromise = undefined; - - return entry.actor; - } catch (innerError) { - if (innerError instanceof ActorError) { - entry.startPromise?.reject(innerError); - entry.startPromise = undefined; - throw innerError; - } - - const dynamicRuntime = this.#dynamicRuntimes.get(actorId); - if (dynamicRuntime) { - try { - await dynamicRuntime.dispose(); - } catch (disposeError) { - logger().debug({ - msg: "failed to dispose dynamic runtime after actor start failure", - actorId, - error: stringifyError(disposeError), - }); - } - this.#dynamicRuntimes.delete(actorId); - } - - const error = - innerError instanceof Error - ? new Error( - `Failed to start actor ${actorId}: ${innerError.message}`, - { cause: innerError }, - ) - : new Error( - `Failed to start actor ${actorId}: ${String(innerError)}`, - ); - entry.startPromise?.reject(error); - entry.startPromise = undefined; - throw error; - } - } - - async loadActorStateOrError(actorId: string): Promise { - const state = (await this.loadActor(actorId)).state; - if (!state) throw new Error(`Actor does not exist: ${actorId}`); - return state; - } - - getActorOrError(actorId: string): ActorEntry { - const entry = this.#actors.get(actorId); - if (!entry) throw new Error(`No entry for actor: ${actorId}`); - return entry; - } - - #getDynamicRuntime(actorId: string): DynamicActorIsolateRuntime { - const runtime = this.#dynamicRuntimes.get(actorId); - if (!runtime) { - throw new Error( - `dynamic runtime is not loaded for actor ${actorId}`, - ); - } - return runtime; - } - - async isDynamicActor( - config: RegistryConfig, - actorId: string, - ): Promise { - const state = await this.loadActorStateOrError(actorId); - const definition = lookupInRegistry(config, state.name); - return isDynamicActorDefinition(definition); - } - - async dynamicFetch(actorId: string, request: Request): Promise { - const runtime = this.#dynamicRuntimes.get(actorId); - if (!runtime) { - throw new Error(`dynamic runtime is not loaded for actor ${actorId}`); - } - return await runtime.fetch(request); - } - - async dynamicOpenWebSocket( - actorId: string, - path: string, - encoding: Encoding, - params: unknown, - options: DynamicWebSocketOpenOptions = {}, - ): Promise { - const runtime = this.#dynamicRuntimes.get(actorId); - if (!runtime) { - throw new Error(`dynamic runtime is not loaded for actor ${actorId}`); - } - return await runtime.openWebSocket(path, encoding, params, options); - } - - registerHibernatableWebSocketAckObserver( - gatewayId: ArrayBuffer, - requestId: ArrayBuffer, - observer: HibernatableWebSocketAckObserver, - ): void { - this.#hibernatableWebSocketAckObservers.set( - this.#hibernatableWebSocketAckKey(gatewayId, requestId), - observer, - ); - } - - unregisterHibernatableWebSocketAckObserver( - gatewayId: ArrayBuffer, - requestId: ArrayBuffer, - ): void { - this.#hibernatableWebSocketAckObservers.delete( - this.#hibernatableWebSocketAckKey(gatewayId, requestId), - ); - } - - ackHibernatableWebSocketMessage( - gatewayId: ArrayBuffer, - requestId: ArrayBuffer, - serverMessageIndex: number, - ): void { - this.#hibernatableWebSocketAckObservers - .get(this.#hibernatableWebSocketAckKey(gatewayId, requestId)) - ?.onAck(serverMessageIndex); - } - - async createDatabase(actorId: string): Promise { - return this.getActorDbPath(actorId); - } - - #hibernatableWebSocketAckKey( - gatewayId: ArrayBuffer, - requestId: ArrayBuffer, - ): string { - const gateway = Buffer.from(gatewayId).toString("base64"); - const request = Buffer.from(requestId).toString("base64"); - return `${gateway}:${request}`; - } - - /** - * Load all persisted alarms from disk and schedule their timers. - */ - #loadAlarmsSync(): void { - try { - const fsSync = getNodeFsSync(); - const files = fsSync.existsSync(this.#alarmsDir) - ? fsSync.readdirSync(this.#alarmsDir) - : []; - for (const file of files) { - // Skip temp files - if (file.includes(".tmp.")) continue; - const path = getNodePath(); - const fullPath = path.join(this.#alarmsDir, file); - try { - const buf = fsSync.readFileSync(fullPath); - const alarmData = - ACTOR_ALARM_VERSIONED.deserializeWithEmbeddedVersion( - new Uint8Array(buf), - ); - const timestamp = Number(alarmData.timestamp); - if (Number.isFinite(timestamp)) { - this.#scheduleAlarmTimeout( - alarmData.actorId, - timestamp, - ); - } else { - logger().debug({ - msg: "invalid alarm file contents", - file, - }); - } - } catch (err) { - logger().error({ - msg: "failed to read alarm file", - file, - error: stringifyError(err), - }); - } - } - } catch (err) { - logger().error({ - msg: "failed to list alarms directory", - error: err, - }); - } - } - - /** - * Cancel any pending alarm timeout for the given actor. - */ - cancelAlarmTimeout(actorId: string) { - const entry = this.#actors.get(actorId); - if (entry?.alarmTimeout) { - entry.alarmTimeout.abort(); - entry.alarmTimeout = undefined; - entry.alarmTimestamp = undefined; - } - } - - /** - * Schedule an alarm timer for an actor without writing to disk. - */ - #scheduleAlarmTimeout(actorId: string, timestamp: number) { - const entry = this.#upsertEntry(actorId); - - // If there's already an earlier alarm scheduled, do not override it. - if ( - entry.alarmTimestamp !== undefined && - timestamp >= entry.alarmTimestamp - ) { - logger().debug({ - msg: "skipping alarm schedule (later than existing)", - actorId, - timestamp, - current: entry.alarmTimestamp, - }); - return; - } - - logger().debug({ msg: "scheduling alarm", actorId, timestamp }); - - // Cancel existing timeout and update the current scheduled timestamp - entry.alarmTimeout?.abort(); - entry.alarmTimestamp = timestamp; - - const delay = Math.max(0, timestamp - Date.now()); - entry.alarmTimeout = setLongTimeout(async () => { - // Clear currently scheduled timestamp as this alarm is firing now - entry.alarmTimestamp = undefined; - // On trigger: remove persisted alarm file - if (this.#persist) { - try { - const fs = getNodeFs(); - await fs.unlink(this.getActorAlarmPath(actorId)); - } catch (err: any) { - if (err?.code !== "ENOENT") { - logger().debug({ - msg: "failed to remove alarm file", - actorId, - error: stringifyError(err), - }); - } - } - } - - try { - logger().debug({ msg: "triggering alarm", actorId, timestamp }); - - // Ensure actor state exists and start actor if needed - const loaded = await this.loadActor(actorId); - if (!loaded.state) - throw new Error(`Actor does not exist: ${actorId}`); - - // Start actor if not already running - const runnerParams = this.#runnerParams; - invariant(runnerParams, "missing runner params"); - if (!loaded.actor) { - await this.startActor( - runnerParams.config, - runnerParams.inlineClient, - runnerParams.actorDriver, - actorId, - ); - } - - invariant(loaded.actor, "actor should be loaded after wake"); - await loaded.actor.onAlarm(); - } catch (err) { - logger().error({ - msg: "failed to handle alarm", - actorId, - error: stringifyError(err), - }); - } - }, delay); - } - - /** - * Cleanup stale temp files on startup (synchronous) - */ - #cleanupTempFilesSync(): void { - try { - const fsSync = getNodeFsSync(); - const files = fsSync.readdirSync(this.#stateDir); - const tempFiles = files.filter((f) => f.includes(".tmp.")); - - const oneHourAgo = Date.now() - 3600000; // 1 hour in ms - - for (const tempFile of tempFiles) { - try { - const path = getNodePath(); - const fullPath = path.join(this.#stateDir, tempFile); - const stat = fsSync.statSync(fullPath); - - // Remove if older than 1 hour - if (stat.mtimeMs < oneHourAgo) { - fsSync.unlinkSync(fullPath); - logger().info({ - msg: "cleaned up stale temp file", - file: tempFile, - }); - } - } catch (err) { - logger().debug({ - msg: "failed to cleanup temp file", - file: tempFile, - error: err, - }); - } - } - } catch (err) { - logger().error({ - msg: "failed to read actors directory for cleanup", - error: err, - }); - } - } - - /** - * Batch put KV entries for an actor. - */ - async kvBatchPut( - actorId: string, - entries: [Uint8Array, Uint8Array][], - ): Promise { - await this.loadActor(actorId); - await this.#withActorWrite(actorId, async (entry) => { - if (!entry.state && this.isActorStopping(actorId)) { - return; - } - - // KV database is independent of actor state and may be written - // during actor creation (e.g. native SQLite via KV channel). - const db = this.#getOrCreateActorKvDatabase(actorId); - const totalSize = estimateKvSize(db); - validateKvEntries(entries, totalSize); - this.#putKvEntriesInDb(db, entries); - }); - } - - /** - * Batch get KV entries for an actor. - */ - async kvBatchGet( - actorId: string, - keys: Uint8Array[], - ): Promise<(Uint8Array | null)[]> { - await this.loadActor(actorId); - await this.#waitForPendingWrite(actorId); - - validateKvKeys(keys); - - const db = this.#getOrCreateActorKvDatabase(actorId); - const results: (Uint8Array | null)[] = []; - for (const key of keys) { - const row = db.get<{ value: Uint8Array | ArrayBuffer }>( - "SELECT value FROM kv WHERE key = ?", - [key], - ); - if (!row) { - results.push(null); - continue; - } - results.push(ensureUint8Array(row.value, "value")); - } - return results; - } - - /** - * Batch delete KV entries for an actor. - */ - async kvBatchDelete(actorId: string, keys: Uint8Array[]): Promise { - await this.loadActor(actorId); - await this.#withActorWrite(actorId, async (entry) => { - if (!entry.state && this.isActorStopping(actorId)) { - return; - } - - if (keys.length === 0) { - return; - } - validateKvKeys(keys); - - const db = this.#getOrCreateActorKvDatabase(actorId); - db.exec("BEGIN"); - try { - for (const key of keys) { - db.run("DELETE FROM kv WHERE key = ?", [key]); - } - db.exec("COMMIT"); - } catch (error) { - try { - db.exec("ROLLBACK"); - } catch { - // Ignore rollback errors, original error is more actionable. - } - throw error; - } - }); - } - - /** - * Delete KV entries in the half-open range [start, end). - */ - async kvDeleteRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - ): Promise { - await this.loadActor(actorId); - await this.#withActorWrite(actorId, async (entry) => { - if (!entry.state && this.isActorStopping(actorId)) { - return; - } - - validateKvKey(start, "start key"); - validateKvKey(end, "end key"); - if (compareBytes(start, end) >= 0) { - return; - } - - const db = this.#getOrCreateActorKvDatabase(actorId); - db.run("DELETE FROM kv WHERE key >= ? AND key < ?", [start, end]); - }); - } - - /** - * List KV entries with a given prefix for an actor. - */ - async kvListPrefix( - actorId: string, - prefix: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - await this.loadActor(actorId); - await this.#waitForPendingWrite(actorId); - validateKvKey(prefix, "prefix key"); - - const db = this.#getOrCreateActorKvDatabase(actorId); - const upperBound = computePrefixUpperBound(prefix); - const direction = options?.reverse ? "DESC" : "ASC"; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; - const rows = upperBound - ? db.all<{ - key: Uint8Array | ArrayBuffer; - value: Uint8Array | ArrayBuffer; - }>( - `SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ${direction} LIMIT ?`, - [prefix, upperBound, limit], - ) - : db.all<{ - key: Uint8Array | ArrayBuffer; - value: Uint8Array | ArrayBuffer; - }>( - `SELECT key, value FROM kv WHERE key >= ? ORDER BY key ${direction} LIMIT ?`, - [prefix, limit], - ); - - return rows.map((row) => [ - ensureUint8Array(row.key, "key"), - ensureUint8Array(row.value, "value"), - ]); - } - - /** - * List KV entries in the half-open range [start, end). - */ - async kvListRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - options?: { - reverse?: boolean; - limit?: number; - }, - ): Promise<[Uint8Array, Uint8Array][]> { - const entry = await this.loadActor(actorId); - await this.#waitForPendingWrite(actorId); - if (!entry.state) { - if (this.isActorStopping(actorId)) { - throw new Error(`Actor ${actorId} is destroying`); - } else { - throw new Error(`Actor ${actorId} state not loaded`); - } - } - validateKvKey(start, "start key"); - validateKvKey(end, "end key"); - if (compareBytes(start, end) >= 0) { - return []; - } - - const db = this.#getOrCreateActorKvDatabase(actorId); - const direction = options?.reverse ? "DESC" : "ASC"; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; - const rows = db.all<{ - key: Uint8Array | ArrayBuffer; - value: Uint8Array | ArrayBuffer; - }>( - `SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ${direction} LIMIT ?`, - [start, end, limit], - ); - - return rows.map((row) => [ - ensureUint8Array(row.key, "key"), - ensureUint8Array(row.value, "value"), - ]); - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/kv-limits.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/kv-limits.ts deleted file mode 100644 index d0ed666b90..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/kv-limits.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { SqliteRuntimeDatabase } from "./sqlite-runtime"; - -export class KvStorageQuotaExceededError extends Error { - readonly remaining: number; - readonly payloadSize: number; - - constructor(remaining: number, payloadSize: number) { - super( - `not enough space left in storage (${remaining} bytes remaining, current payload is ${payloadSize} bytes)`, - ); - this.name = "KvStorageQuotaExceededError"; - this.remaining = remaining; - this.payloadSize = payloadSize; - } -} - -// Keep these limits in sync with engine/packages/pegboard/src/actor_kv/mod.rs. -const KV_MAX_KEY_SIZE = 2 * 1024; -const KV_MAX_VALUE_SIZE = 128 * 1024; -const KV_MAX_KEYS = 128; -const KV_MAX_PUT_PAYLOAD_SIZE = 976 * 1024; -const KV_MAX_STORAGE_SIZE = 10 * 1024 * 1024 * 1024; -const KV_KEY_WRAPPER_OVERHEAD_SIZE = 2; - -export function estimateKvSize(db: SqliteRuntimeDatabase): number { - const row = db.get<{ total: number | bigint | null }>( - "SELECT COALESCE(SUM(LENGTH(key) + LENGTH(value)), 0) AS total FROM kv", - ); - return row ? Number(row.total ?? 0) : 0; -} - -export function validateKvKey( - key: Uint8Array, - keyLabel: "key" | "prefix key" | "start key" | "end key" = "key", -): void { - if (key.byteLength + KV_KEY_WRAPPER_OVERHEAD_SIZE > KV_MAX_KEY_SIZE) { - throw new Error(`${keyLabel} is too long (max 2048 bytes)`); - } -} - -export function validateKvKeys(keys: Uint8Array[]): void { - if (keys.length > KV_MAX_KEYS) { - throw new Error("a maximum of 128 keys is allowed"); - } - - for (const key of keys) { - validateKvKey(key); - } -} - -export function validateKvEntries( - entries: [Uint8Array, Uint8Array][], - totalSize: number, -): void { - if (entries.length > KV_MAX_KEYS) { - throw new Error("A maximum of 128 key-value entries is allowed"); - } - - let payloadSize = 0; - for (const [key, value] of entries) { - payloadSize += - key.byteLength + KV_KEY_WRAPPER_OVERHEAD_SIZE + value.byteLength; - } - - if (payloadSize > KV_MAX_PUT_PAYLOAD_SIZE) { - throw new Error("total payload is too large (max 976 KiB)"); - } - - const storageRemaining = Math.max(0, KV_MAX_STORAGE_SIZE - totalSize); - if (payloadSize > storageRemaining) { - throw new KvStorageQuotaExceededError(storageRemaining, payloadSize); - } - - for (const [key, value] of entries) { - validateKvKey(key); - if (value.byteLength > KV_MAX_VALUE_SIZE) { - throw new Error( - `value is too large (max ${KV_MAX_VALUE_SIZE / 1024} KiB)`, - ); - } - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/manager.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/manager.ts deleted file mode 100644 index 4a7b4ddb65..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/manager.ts +++ /dev/null @@ -1,1003 +0,0 @@ -import type { Context as HonoContext } from "hono"; -import invariant from "invariant"; -import { ActorStopping } from "@/actor/errors"; -import { type ActorRouter, createActorRouter } from "@/actor/router"; -import { - parseWebSocketProtocols, - routeWebSocket, -} from "@/actor/router-websocket-endpoints"; -import { isStaticActorInstance } from "@/actor/instance/mod"; -import { createClientWithDriver } from "@/client/client"; -import { ClientConfigSchema } from "@/client/config"; -import { InlineWebSocketAdapter } from "@/common/inline-websocket-adapter"; -import { - buildHibernatableWebSocketAckStateTestResponse, - getIndexedWebSocketTestSender, - parseHibernatableWebSocketAckStateTestRequest, - registerRemoteHibernatableWebSocketAckHooks, - setHibernatableWebSocketAckTestHooks, - setIndexedWebSocketTestSender, - unregisterRemoteHibernatableWebSocketAckHooks, - type IndexedWebSocketPayload, -} from "@/common/websocket-test-hooks"; -import { noopNext } from "@/common/utils"; -import type { NativeSqliteConfig } from "@/db/config"; -import { - resolveGatewayTarget, - type ActorDriver, - type ActorOutput, - type CreateInput, - type GatewayTarget, - type GetForIdInput, - type GetOrCreateWithKeyInput, - type GetWithKeyInput, - type ListActorsInput, - type ManagerDriver, -} from "@/driver-helpers/mod"; -import type { ManagerDisplayInformation } from "@/manager/driver"; -import type { Encoding, UniversalWebSocket } from "@/mod"; -import type { DriverConfig, RegistryConfig } from "@/registry/config"; -import { buildActorQueryGatewayUrl } from "@/remote-manager-driver/actor-websocket-client"; -import type * as schema from "@/schemas/file-system-driver/mod"; -import type { GetUpgradeWebSocket } from "@/utils"; -import { VirtualWebSocket } from "@rivetkit/virtual-websocket"; -import { createTestWebSocketProxy } from "@/manager/gateway"; -import type { FileSystemGlobalState } from "./global-state"; -import { generateActorId } from "./utils"; - -const REMOTE_ACK_HOOK_QUERY_PARAM = "__rivetkitAckHook"; - -export class FileSystemManagerDriver implements ManagerDriver { - #config: RegistryConfig; - #state: FileSystemGlobalState; - #driverConfig: DriverConfig; - #getUpgradeWebSocket: GetUpgradeWebSocket | undefined; - - #actorDriver: ActorDriver; - #actorRouter: ActorRouter; - #kvChannelShutdown: (() => void) | null = null; - - constructor( - config: RegistryConfig, - state: FileSystemGlobalState, - driverConfig: DriverConfig, - ) { - this.#config = config; - this.#state = state; - this.#driverConfig = driverConfig; - - // Actors run on the same node as the manager, so we create a dummy actor router that we route requests to - const inlineClient = createClientWithDriver(this); - - this.#actorDriver = this.#driverConfig.actor( - config, - this, - inlineClient, - ); - this.#actorRouter = createActorRouter( - this.#config, - this.#actorDriver, - undefined, - config.test.enabled, - ); - } - - async sendRequest( - target: GatewayTarget, - actorRequest: Request, - ): Promise { - const actorId = await resolveGatewayTarget(this, target); - const overlayResponse = await this.#routeOverlayRequest( - actorId, - actorRequest, - ); - if (overlayResponse) { - return overlayResponse; - } - - if (await this.#state.isDynamicActor(this.#config, actorId)) { - await this.#actorDriver.loadActor(actorId); - return await this.#state.dynamicFetch(actorId, actorRequest); - } - - return await this.#actorRouter.fetch(actorRequest, { - actorId, - }); - } - - async openWebSocket( - path: string, - target: GatewayTarget, - encoding: Encoding, - params: unknown, - ): Promise { - const actorId = await resolveGatewayTarget(this, target); - return await this.#openHibernatableWebSocket( - actorId, - path, - encoding, - params, - ); - } - - async proxyRequest( - _c: HonoContext, - actorRequest: Request, - actorId: string, - ): Promise { - const overlayResponse = await this.#routeOverlayRequest( - actorId, - actorRequest, - ); - if (overlayResponse) { - return overlayResponse; - } - - if (await this.#state.isDynamicActor(this.#config, actorId)) { - await this.#actorDriver.loadActor(actorId); - return await this.#state.dynamicFetch(actorId, actorRequest); - } - - return await this.#actorRouter.fetch(actorRequest, { - actorId, - }); - } - - async proxyWebSocket( - c: HonoContext, - path: string, - actorId: string, - encoding: Encoding, - params: unknown, - ): Promise { - const upgradeWebSocket = this.#getUpgradeWebSocket?.(); - invariant(upgradeWebSocket, "missing getUpgradeWebSocket"); - const wsHandler = await createTestWebSocketProxy( - this.#openHibernatableWebSocket( - actorId, - path, - encoding, - params, - c.req.raw, - c.req.header(), - parseWebSocketProtocols( - c.req.header("sec-websocket-protocol") ?? undefined, - ).ackHookToken ?? - new URL(c.req.raw.url).searchParams.get( - REMOTE_ACK_HOOK_QUERY_PARAM, - ) ?? - undefined, - ), - ); - return upgradeWebSocket(() => wsHandler)(c, noopNext()); - } - - async buildGatewayUrl(target: GatewayTarget): Promise { - const port = this.#config.managerPort ?? 6420; - const endpoint = `http://127.0.0.1:${port}`; - - if ("directId" in target) { - return `${endpoint}/gateway/${encodeURIComponent(target.directId)}`; - } - - if ("getForId" in target) { - return `${endpoint}/gateway/${encodeURIComponent(target.getForId.actorId)}`; - } - - if ("getForKey" in target || "getOrCreateForKey" in target) { - return buildActorQueryGatewayUrl( - endpoint, - this.#config.namespace, - target, - undefined, - "", - undefined, - undefined, - "getOrCreateForKey" in target ? this.#config.envoy.poolName : undefined, - ); - } - - if ("create" in target) { - throw new Error( - "Gateway URLs only support direct actor IDs, get, and getOrCreate targets.", - ); - } - - throw new Error("unreachable: unknown gateway target type"); - } - - async hardCrashActor(actorId: string): Promise { - await this.#actorDriver.hardCrashActor?.(actorId); - } - - setNativeSqliteConfig(config: NativeSqliteConfig): void { - this.#state.setNativeSqliteConfig(config); - } - - async #routeOverlayRequest( - actorId: string, - request: Request, - ): Promise { - const url = new URL(request.url); - switch (`${request.method} ${url.pathname}`) { - case "PUT /dynamic/reload": - return await this.#handleDynamicReloadOverlay(actorId); - default: - return null; - } - } - - async #handleDynamicReloadOverlay(actorId: string): Promise { - if (!(await this.#state.isDynamicActor(this.#config, actorId))) { - return new Response("not a dynamic actor", { status: 404 }); - } - await this.#state.sleepActor(actorId); - return new Response(null, { status: 200 }); - } - - async getForId({ - actorId, - }: GetForIdInput): Promise { - // Validate the actor exists - const actor = await this.#state.loadActor(actorId); - if (!actor.state) { - return undefined; - } - if (this.#state.isActorStopping(actorId)) { - throw new ActorStopping(actorId); - } - - return actorStateToOutput(actor.state); - } - - async getWithKey({ - name, - key, - }: GetWithKeyInput): Promise { - // Generate the deterministic actor ID - const actorId = generateActorId(name, key); - - // Check if actor exists - const actor = await this.#state.loadActor(actorId); - if (actor.state) { - return actorStateToOutput(actor.state); - } - - return undefined; - } - - async getOrCreateWithKey( - input: GetOrCreateWithKeyInput, - ): Promise { - // Generate the deterministic actor ID - const actorId = generateActorId(input.name, input.key); - - // Use the atomic getOrCreateActor method - await this.#state.loadOrCreateActor( - actorId, - input.name, - input.key, - input.input, - ); - - // Start the actor immediately so timestamps are set - await this.#actorDriver.loadActor(actorId); - - // Reload state to get updated timestamps - const state = await this.#state.loadActorStateOrError(actorId); - return actorStateToOutput(state); - } - - async createActor({ name, key, input }: CreateInput): Promise { - // Generate the deterministic actor ID - const actorId = generateActorId(name, key); - - await this.#state.createActor(actorId, name, key, input); - - // Start the actor immediately so timestamps are set - await this.#actorDriver.loadActor(actorId); - - // Reload state to get updated timestamps - const state = await this.#state.loadActorStateOrError(actorId); - return actorStateToOutput(state); - } - - async listActors({ name }: ListActorsInput): Promise { - const actors: ActorOutput[] = []; - const itr = this.#state.getActorsIterator({}); - - for await (const actor of itr) { - if (actor.name === name) { - actors.push(actorStateToOutput(actor)); - } - } - - // Sort by create ts desc (most recent first) - actors.sort((a, b) => { - const aTs = a.createTs ?? 0; - const bTs = b.createTs ?? 0; - return bTs - aTs; - }); - - return actors; - } - - async kvGet(actorId: string, key: Uint8Array): Promise { - const response = await this.#state.kvBatchGet(actorId, [key]); - return response[0] !== null - ? new TextDecoder().decode(response[0]) - : null; - } - - async kvBatchGet( - actorId: string, - keys: Uint8Array[], - ): Promise<(Uint8Array | null)[]> { - return await this.#state.kvBatchGet(actorId, keys); - } - - async kvBatchPut( - actorId: string, - entries: [Uint8Array, Uint8Array][], - ): Promise { - await this.#state.kvBatchPut(actorId, entries); - } - - async kvBatchDelete(actorId: string, keys: Uint8Array[]): Promise { - await this.#state.kvBatchDelete(actorId, keys); - } - - async kvDeleteRange( - actorId: string, - start: Uint8Array, - end: Uint8Array, - ): Promise { - await this.#state.kvDeleteRange(actorId, start, end); - } - - displayInformation(): ManagerDisplayInformation { - return { - properties: { - ...(this.#state.persist - ? { Data: this.#state.storagePath } - : {}), - Instances: this.#state.actorCountOnStartup.toString(), - }, - }; - } - - extraStartupLog() { - return { - instances: this.#state.actorCountOnStartup, - data: this.#state.storagePath, - }; - } - - setGetUpgradeWebSocket(getUpgradeWebSocket: GetUpgradeWebSocket): void { - this.#getUpgradeWebSocket = getUpgradeWebSocket; - } - - setKvChannelShutdown(fn: () => void): void { - this.#kvChannelShutdown = fn; - } - - shutdown(): void { - this.#kvChannelShutdown?.(); - this.#kvChannelShutdown = null; - } - - async #openHibernatableWebSocket( - actorId: string, - path: string, - encoding: Encoding, - params: unknown, - requestOverride?: Request, - headersOverride?: Record, - remoteAckHookToken?: string, - ): Promise { - const { gatewayId, requestId } = createHibernatableRequestMetadata(); - if (await this.#state.isDynamicActor(this.#config, actorId)) { - return createMockHibernatableWebSocket({ - config: this.#config, - state: this.#state, - gatewayId, - requestId, - remoteAckHookToken, - shouldReopenActorWebSocket: () => - this.#shouldReopenHibernatableWebSocket(actorId), - openActorWebSocket: async ( - isRestoringHibernatable, - _context, - ) => { - if (isRestoringHibernatable) { - this.#state.beginHibernatableWebSocketRestore(actorId); - } - try { - await this.#actorDriver.loadActor(actorId); - const actorWebSocket = - await this.#state.dynamicOpenWebSocket( - actorId, - path, - encoding, - params, - { - headers: headersOverride, - gatewayId, - requestId, - isHibernatable: true, - isRestoringHibernatable, - }, - ); - const sender = - getIndexedWebSocketTestSender(actorWebSocket); - invariant( - sender, - "dynamic file-system websocket is missing indexed message dispatch support", - ); - return { - actorWebSocket, - sendToActor: sender, - }; - } finally { - if (isRestoringHibernatable) { - this.#state.endHibernatableWebSocketRestore( - actorId, - ); - } - } - }, - }); - } - return createMockHibernatableWebSocket({ - config: this.#config, - state: this.#state, - gatewayId, - requestId, - remoteAckHookToken, - shouldReopenActorWebSocket: () => - this.#shouldReopenHibernatableWebSocket(actorId), - openActorWebSocket: async (isRestoringHibernatable, context) => { - if (isRestoringHibernatable) { - this.#state.beginHibernatableWebSocketRestore(actorId); - } - try { - const normalizedPath = path.startsWith("/") - ? path - : `/${path}`; - const request = - requestOverride ?? - new Request(`http://inline-actor${normalizedPath}`, { - method: "GET", - }); - const pathOnly = normalizedPath.split("?")[0]; - const handler = await routeWebSocket( - request, - pathOnly, - headersOverride ?? {}, - this.#config, - this.#actorDriver, - actorId, - encoding, - params, - gatewayId, - requestId, - true, - isRestoringHibernatable, - ); - const shouldPreserveHibernatableConn = - Boolean(handler.onRestore) && - Boolean(handler.conn?.isHibernatable); - const wrappedHandler = shouldPreserveHibernatableConn - ? { - ...handler, - onClose: (event: any, wsContext: any) => { - if (!context.isClientCloseInitiated()) { - return; - } - handler.onClose(event, wsContext); - }, - } - : handler; - const adapter = new InlineWebSocketAdapter(wrappedHandler, { - restoring: isRestoringHibernatable, - }); - return { - actorWebSocket: adapter.clientWebSocket, - sendToActor: ( - data: IndexedWebSocketPayload, - rivetMessageIndex?: number, - ) => { - adapter.dispatchClientMessageWithMetadata( - data, - rivetMessageIndex, - ); - if ( - handler.conn && - handler.actor && - isStaticActorInstance(handler.actor) - ) { - handler.actor.handleInboundHibernatableWebSocketMessage( - handler.conn, - data as any, - rivetMessageIndex, - ); - } - }, - disconnectActorConn: - shouldPreserveHibernatableConn && handler.conn - ? async (reason?: string) => { - await handler.conn?.disconnect(reason); - } - : undefined, - markActorConnStale: - shouldPreserveHibernatableConn && handler.conn - ? () => { - invariant( - handler.actor && - isStaticActorInstance( - handler.actor, - ), - "missing static actor for stale hibernatable websocket cleanup", - ); - invariant( - handler.conn, - "missing hibernatable connection for stale websocket cleanup", - ); - handler.actor.connectionManager.detachPersistedHibernatableConnDriver( - handler.conn, - "file-system-driver.stale-client-close", - ); - } - : undefined, - }; - } finally { - if (isRestoringHibernatable) { - this.#state.endHibernatableWebSocketRestore(actorId); - } - } - }, - }); - } - - #shouldReopenHibernatableWebSocket(actorId: string): boolean { - if (this.#state.isActorStopping(actorId)) { - return true; - } - - try { - return ( - this.#state.getActorOrError(actorId).actor?.isStopping ?? true - ); - } catch { - return true; - } - } -} - -function actorStateToOutput(state: schema.ActorState): ActorOutput { - return { - actorId: state.actorId, - name: state.name, - key: state.key as string[], - createTs: Number(state.createdAt), - startTs: state.startTs !== null ? Number(state.startTs) : null, - connectableTs: - state.connectableTs !== null ? Number(state.connectableTs) : null, - sleepTs: state.sleepTs !== null ? Number(state.sleepTs) : null, - destroyTs: state.destroyTs !== null ? Number(state.destroyTs) : null, - }; -} - -function createHibernatableRequestMetadata(): { - gatewayId: ArrayBuffer; - requestId: ArrayBuffer; -} { - const gatewayId = new Uint8Array(4); - const requestId = new Uint8Array(4); - crypto.getRandomValues(gatewayId); - crypto.getRandomValues(requestId); - return { - gatewayId: gatewayId.buffer.slice(0), - requestId: requestId.buffer.slice(0), - }; -} - -function createMockHibernatableWebSocket(input: { - config: RegistryConfig; - state: FileSystemGlobalState; - gatewayId: ArrayBuffer; - requestId: ArrayBuffer; - remoteAckHookToken?: string; - shouldReopenActorWebSocket: () => boolean; - openActorWebSocket: ( - isRestoringHibernatable: boolean, - context: { - isClientCloseInitiated: () => boolean; - }, - ) => Promise<{ - actorWebSocket: UniversalWebSocket; - sendToActor: ( - data: IndexedWebSocketPayload, - rivetMessageIndex?: number, - ) => void | Promise; - disconnectActorConn?: (reason?: string) => Promise; - markActorConnStale?: () => void; - }>; -}): UniversalWebSocket { - const { - config, - state, - gatewayId, - requestId, - remoteAckHookToken, - shouldReopenActorWebSocket, - openActorWebSocket, - } = input; - let readyState: 0 | 1 | 2 | 3 = 0; - let nextServerMessageIndex = 1; - let lastSentIndex = 0; - let lastAckedIndex = 0; - let hasOpened = false; - let closeInitiatedByClient = false; - let openingActorWebSocket: - | Promise<{ - actorWebSocket: UniversalWebSocket; - sendToActor: ( - data: IndexedWebSocketPayload, - rivetMessageIndex?: number, - ) => void | Promise; - disconnectActorConn?: (reason?: string) => Promise; - markActorConnStale?: () => void; - }> - | undefined; - let currentActorWebSocket: UniversalWebSocket | undefined; - let currentDisconnectActorConn: - | ((reason?: string) => Promise) - | undefined; - let currentMarkActorConnStale: (() => void) | undefined; - let currentSendToActor: - | (( - data: IndexedWebSocketPayload, - rivetMessageIndex?: number, - ) => void | Promise) - | undefined; - let flushPendingMessagesPromise: Promise | undefined; - const pendingIndexes: number[] = []; - const pendingMessages: Array<{ - data: IndexedWebSocketPayload; - rivetMessageIndex: number; - }> = []; - const ackWaiters = new Map void>>(); - let observerRegistered = true; - - const unregisterObserver = () => { - if (!observerRegistered) { - return; - } - observerRegistered = false; - state.unregisterHibernatableWebSocketAckObserver(gatewayId, requestId); - unregisterRemoteHibernatableWebSocketAckHooks( - remoteAckHookToken, - config.test.enabled, - ); - }; - - const bindActorWebSocket = (actorWebSocket: UniversalWebSocket) => { - currentActorWebSocket = actorWebSocket; - const schedulePendingFlush = () => { - queueMicrotask(() => { - void flushPendingMessages().catch((error) => { - logger().debug({ - msg: "mock hibernatable websocket flush failed", - error, - }); - }); - }); - }; - - actorWebSocket.addEventListener("open", () => { - if (readyState >= 2) { - return; - } - if (!hasOpened) { - hasOpened = true; - readyState = 1; - clientWebSocket.triggerOpen(); - } - schedulePendingFlush(); - }); - actorWebSocket.addEventListener("message", (event: any) => { - clientWebSocket.triggerMessage(event.data); - }); - actorWebSocket.addEventListener("close", (event: any) => { - if (currentActorWebSocket === actorWebSocket) { - currentActorWebSocket = undefined; - currentDisconnectActorConn = undefined; - currentMarkActorConnStale = undefined; - currentSendToActor = undefined; - } - if (!closeInitiatedByClient && readyState < 2) { - return; - } - readyState = 3; - unregisterObserver(); - clientWebSocket.triggerClose(event.code, event.reason); - }); - actorWebSocket.addEventListener("error", (error: unknown) => { - clientWebSocket.triggerError(error); - }); - - if (actorWebSocket.readyState === 1) { - if (!hasOpened) { - hasOpened = true; - readyState = 1; - clientWebSocket.triggerOpen(); - } - schedulePendingFlush(); - } - }; - - const ensureActorWebSocket = async (): Promise => { - if (readyState >= 2) { - return; - } - const shouldRestoreActorWebSocket = - hasOpened && shouldReopenActorWebSocket(); - if ( - currentActorWebSocket?.readyState === 1 && - currentSendToActor && - !shouldRestoreActorWebSocket - ) { - return; - } - if (shouldRestoreActorWebSocket) { - currentActorWebSocket = undefined; - currentSendToActor = undefined; - } - if (!openingActorWebSocket) { - logger().debug({ - msg: "mock hibernatable websocket opening actor websocket", - shouldRestoreActorWebSocket, - }); - openingActorWebSocket = openActorWebSocket( - shouldRestoreActorWebSocket, - { - isClientCloseInitiated: () => closeInitiatedByClient, - }, - ) - .then((binding) => { - logger().debug({ - msg: "mock hibernatable websocket actor websocket ready", - shouldRestoreActorWebSocket, - actorReadyState: binding.actorWebSocket.readyState, - }); - currentDisconnectActorConn = binding.disconnectActorConn; - currentMarkActorConnStale = binding.markActorConnStale; - currentSendToActor = binding.sendToActor; - bindActorWebSocket(binding.actorWebSocket); - return binding; - }) - .finally(() => { - openingActorWebSocket = undefined; - }); - } - await openingActorWebSocket; - }; - - const flushPendingMessages = async () => { - if (flushPendingMessagesPromise) { - await flushPendingMessagesPromise; - return; - } - - flushPendingMessagesPromise = (async () => { - await ensureActorWebSocket(); - logger().debug({ - msg: "mock hibernatable websocket flush state", - pendingMessageCount: pendingMessages.length, - hasSender: Boolean(currentSendToActor), - currentActorReadyState: currentActorWebSocket?.readyState, - }); - while ( - pendingMessages.length > 0 && - currentSendToActor && - currentActorWebSocket && - currentActorWebSocket.readyState === currentActorWebSocket.OPEN - ) { - const next = pendingMessages.shift(); - if (!next) { - return; - } - logger().debug({ - msg: "mock hibernatable websocket delivering pending message", - rivetMessageIndex: next.rivetMessageIndex, - }); - const sendResult = currentSendToActor( - next.data, - next.rivetMessageIndex, - ); - if (sendResult && typeof sendResult.then === "function") { - void sendResult.catch((error: unknown) => { - logger().debug({ - msg: "mock hibernatable websocket pending send failed", - error, - }); - }); - } - } - })().finally(() => { - flushPendingMessagesPromise = undefined; - }); - - await flushPendingMessagesPromise; - }; - - const resolveAckWaiters = (serverMessageIndex: number) => { - for (const [index, waiters] of ackWaiters) { - if (index > serverMessageIndex) { - continue; - } - ackWaiters.delete(index); - for (const resolve of waiters) { - resolve(); - } - } - }; - - const enqueueMessage = ( - data: IndexedWebSocketPayload, - rivetMessageIndex: number, - ) => { - lastSentIndex = rivetMessageIndex; - pendingIndexes.push(rivetMessageIndex); - pendingMessages.push({ data, rivetMessageIndex }); - void flushPendingMessages(); - }; - - const clientWebSocket = new VirtualWebSocket({ - getReadyState: () => readyState, - onSend: (data) => { - const ackStateRequest = - parseHibernatableWebSocketAckStateTestRequest( - data, - config.test.enabled, - ); - if (ackStateRequest) { - const response = buildHibernatableWebSocketAckStateTestResponse( - { - lastSentIndex, - lastAckedIndex, - pendingIndexes: [...pendingIndexes], - }, - config.test.enabled, - ); - invariant( - response, - "missing hibernatable websocket ack test response", - ); - clientWebSocket.triggerMessage(response); - return; - } - const rivetMessageIndex = nextServerMessageIndex; - nextServerMessageIndex += 1; - enqueueMessage(data, rivetMessageIndex); - }, - onClose: (code, reason) => { - readyState = 2; - closeInitiatedByClient = true; - unregisterObserver(); - void (async () => { - if (shouldReopenActorWebSocket() && currentMarkActorConnStale) { - // Keep the persisted conn metadata in place when the client - // disappears during sleep so the next wake can reconcile it as - // a stale hibernatable request. - currentMarkActorConnStale(); - currentActorWebSocket = undefined; - currentDisconnectActorConn = undefined; - currentMarkActorConnStale = undefined; - currentSendToActor = undefined; - readyState = 3; - return; - } - if (currentDisconnectActorConn) { - await currentDisconnectActorConn(reason); - } - if ( - currentActorWebSocket && - currentActorWebSocket.readyState !== - currentActorWebSocket.CLOSED && - currentActorWebSocket.readyState !== - currentActorWebSocket.CLOSING - ) { - currentActorWebSocket.close(code, reason); - return; - } - readyState = 3; - })().catch((error) => { - logger().debug({ - msg: "failed to close mock hibernatable websocket actor connection", - error, - }); - readyState = 3; - }); - }, - }); - - state.registerHibernatableWebSocketAckObserver(gatewayId, requestId, { - onAck: (serverMessageIndex) => { - lastAckedIndex = Math.max(lastAckedIndex, serverMessageIndex); - while ( - pendingIndexes.length > 0 && - pendingIndexes[0] <= serverMessageIndex - ) { - pendingIndexes.shift(); - } - resolveAckWaiters(serverMessageIndex); - }, - }); - - setIndexedWebSocketTestSender( - clientWebSocket, - (data, rivetMessageIndex) => { - const indexedMessage = - typeof rivetMessageIndex === "number" - ? rivetMessageIndex - : nextServerMessageIndex; - nextServerMessageIndex = Math.max( - nextServerMessageIndex, - indexedMessage + 1, - ); - enqueueMessage(data, indexedMessage); - }, - config.test.enabled, - ); - setHibernatableWebSocketAckTestHooks( - clientWebSocket, - { - getState: () => ({ - lastSentIndex, - lastAckedIndex, - pendingIndexes: [...pendingIndexes], - }), - waitForAck: async (serverMessageIndex) => { - if (lastAckedIndex >= serverMessageIndex) { - return; - } - await new Promise((resolve) => { - const existing = ackWaiters.get(serverMessageIndex) ?? []; - existing.push(resolve); - ackWaiters.set(serverMessageIndex, existing); - }); - }, - }, - config.test.enabled, - ); - if (remoteAckHookToken) { - registerRemoteHibernatableWebSocketAckHooks( - remoteAckHookToken, - { - getState: () => ({ - lastSentIndex, - lastAckedIndex, - pendingIndexes: [...pendingIndexes], - }), - waitForAck: async (serverMessageIndex) => { - if (lastAckedIndex >= serverMessageIndex) { - return; - } - await new Promise((resolve) => { - const existing = - ackWaiters.get(serverMessageIndex) ?? []; - existing.push(resolve); - ackWaiters.set(serverMessageIndex, existing); - }); - }, - }, - config.test.enabled, - ); - } - - void ensureActorWebSocket(); - - return clientWebSocket; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/mod.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/mod.ts deleted file mode 100644 index 81ce39e784..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/mod.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { z } from "zod"; -import type { DriverConfig } from "@/registry/config"; -import { importNodeDependencies } from "@/utils/node"; -import { FileSystemActorDriver } from "./actor"; -import { - type FileSystemDriverOptions, - FileSystemGlobalState, -} from "./global-state"; -import { FileSystemManagerDriver } from "./manager"; - -export { FileSystemActorDriver } from "./actor"; -export { FileSystemGlobalState } from "./global-state"; -export { FileSystemManagerDriver } from "./manager"; -export { getStoragePath } from "./utils"; - -const CreateFileSystemDriverOptionsSchema = z.object({ - /** Custom path for storage. */ - path: z.string().optional(), - /** Deprecated: file-system driver KV is now always SQLite-backed. */ - useNativeSqlite: z.boolean().optional(), -}); - -type CreateFileSystemDriverOptionsInput = z.input< - typeof CreateFileSystemDriverOptionsSchema ->; - -export function createFileSystemOrMemoryDriver( - persist: boolean = true, - options?: CreateFileSystemDriverOptionsInput, -): DriverConfig { - importNodeDependencies(); - - if (options?.useNativeSqlite === false) { - throw new Error( - "File-system driver no longer supports non-SQLite KV storage. Remove useNativeSqlite: false.", - ); - } - - const stateOptions: FileSystemDriverOptions = { - persist, - customPath: options?.path, - useNativeSqlite: true, - }; - const state = new FileSystemGlobalState(stateOptions); - const driverConfig: DriverConfig = { - name: persist ? "file-system" : "memory", - displayName: persist ? "File System" : "Memory", - manager: (config) => - new FileSystemManagerDriver(config, state, driverConfig), - actor: (config, managerDriver, inlineClient) => { - const actorDriver = new FileSystemActorDriver( - config, - managerDriver, - inlineClient, - state, - ); - - state.onRunnerStart(config, inlineClient, actorDriver); - - return actorDriver; - }, - autoStartActorDriver: true, - }; - return driverConfig; -} - -export function createFileSystemDriver( - opts?: CreateFileSystemDriverOptionsInput, -): DriverConfig { - const validatedOpts = opts - ? CreateFileSystemDriverOptionsSchema.parse(opts) - : undefined; - return createFileSystemOrMemoryDriver(true, validatedOpts); -} - -export function createMemoryDriver(): DriverConfig { - return createFileSystemOrMemoryDriver(false); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/sqlite-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/sqlite-runtime.ts deleted file mode 100644 index bba0a3c051..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/sqlite-runtime.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { getRequireFn } from "@/utils/node"; - -type SqliteRuntimeKind = "bun" | "node" | "better-sqlite3"; -type SqliteDatabaseCtor = new (path: string) => SqliteRawDatabase; - -interface SqliteStatement { - run(...params: unknown[]): unknown; - get>(...params: unknown[]): T | undefined; - all>(...params: unknown[]): T[]; -} - -interface SqliteRawDatabase { - exec(sql: string): unknown; - close(): unknown; - prepare?(sql: string): SqliteStatement; - query?(sql: string): SqliteStatement; -} - -export interface SqliteRuntimeDatabase { - exec(sql: string): void; - run(sql: string, params?: readonly unknown[]): void; - get>( - sql: string, - params?: readonly unknown[], - ): T | undefined; - all>( - sql: string, - params?: readonly unknown[], - ): T[]; - close(): void; -} - -export interface SqliteRuntime { - kind: SqliteRuntimeKind; - open(path: string): SqliteRuntimeDatabase; -} - -function normalizeParams(params: readonly unknown[] | undefined): unknown[] { - if (!params || params.length === 0) { - return []; - } - - return params.map((value) => { - if (value instanceof Uint8Array) { - return Buffer.from(value); - } - return value; - }); -} - -function createPreparedDatabaseAdapter( - rawDb: SqliteRawDatabase, - prepare: (sql: string) => SqliteStatement, -): SqliteRuntimeDatabase { - return { - exec: (sql) => { - rawDb.exec(sql); - }, - run: (sql, params) => { - const stmt = prepare(sql); - stmt.run(...normalizeParams(params)); - }, - get: >( - sql: string, - params?: readonly unknown[], - ) => { - const stmt = prepare(sql); - return stmt.get(...normalizeParams(params)); - }, - all: >( - sql: string, - params?: readonly unknown[], - ) => { - const stmt = prepare(sql); - return stmt.all(...normalizeParams(params)); - }, - close: () => { - rawDb.close(); - }, - }; -} - -function configureSqliteRuntimeDatabase( - rawDb: SqliteRawDatabase, - path: string, -): void { - // Wait briefly when the database file is still being released by another - // process during restarts to reduce transient "database is locked" failures. - rawDb.exec("PRAGMA busy_timeout = 5000"); - - // WAL improves concurrent read/write behavior for file-backed databases. - if (path !== ":memory:") { - rawDb.exec("PRAGMA journal_mode = WAL"); - } -} - -export function loadSqliteRuntime(): SqliteRuntime { - const requireFn = getRequireFn(); - const loadErrors: string[] = []; - - try { - const bunSqlite = requireFn(/* webpackIgnore: true */ "bun:sqlite") as { - Database?: SqliteDatabaseCtor; - }; - const BunDatabase = bunSqlite.Database; - if (BunDatabase) { - return { - kind: "bun", - open: (path) => { - const rawDb = new BunDatabase(path); - configureSqliteRuntimeDatabase(rawDb, path); - const query = rawDb.query?.bind(rawDb); - if (!query) - throw new Error( - "bun:sqlite database missing query method", - ); - return createPreparedDatabaseAdapter(rawDb, query); - }, - }; - } - } catch (error) { - loadErrors.push(`bun:sqlite unavailable: ${String(error)}`); - } - - try { - const nodeSqlite = requireFn( - /* webpackIgnore: true */ "node:sqlite", - ) as { - DatabaseSync?: SqliteDatabaseCtor; - }; - const NodeDatabaseSync = nodeSqlite.DatabaseSync; - if (NodeDatabaseSync) { - return { - kind: "node", - open: (path) => { - const rawDb = new NodeDatabaseSync(path); - configureSqliteRuntimeDatabase(rawDb, path); - const prepare = rawDb.prepare?.bind(rawDb); - if (!prepare) { - throw new Error( - "node:sqlite DatabaseSync missing prepare method", - ); - } - return createPreparedDatabaseAdapter(rawDb, prepare); - }, - }; - } - } catch (error) { - loadErrors.push(`node:sqlite unavailable: ${String(error)}`); - } - - try { - const betterSqlite3Module = requireFn( - /* webpackIgnore: true */ "better-sqlite3", - ) as SqliteDatabaseCtor | { default?: SqliteDatabaseCtor }; - const BetterSqlite3 = - typeof betterSqlite3Module === "function" - ? betterSqlite3Module - : betterSqlite3Module.default; - if (BetterSqlite3) { - return { - kind: "better-sqlite3", - open: (path) => { - const rawDb = new BetterSqlite3(path); - configureSqliteRuntimeDatabase(rawDb, path); - const prepare = rawDb.prepare?.bind(rawDb); - if (!prepare) { - throw new Error( - "better-sqlite3 database missing prepare method", - ); - } - return createPreparedDatabaseAdapter(rawDb, prepare); - }, - }; - } - } catch (error) { - loadErrors.push(`better-sqlite3 unavailable: ${String(error)}`); - throw new Error( - `No SQLite runtime available. Tried bun:sqlite, node:sqlite, and better-sqlite3. Install better-sqlite3 (e.g. "pnpm add better-sqlite3") if native runtimes are unavailable.\n${loadErrors.join("\n")}`, - ); - } - - throw new Error( - `No SQLite runtime available. Tried bun:sqlite, node:sqlite, and better-sqlite3.\n${loadErrors.join("\n")}`, - ); -} - -export function computePrefixUpperBound( - prefix: Uint8Array, -): Uint8Array | undefined { - if (prefix.length === 0) { - return undefined; - } - - const upperBound = new Uint8Array(prefix); - for (let i = upperBound.length - 1; i >= 0; i--) { - if (upperBound[i] !== 0xff) { - upperBound[i] += 1; - return upperBound.slice(0, i + 1); - } - } - return undefined; -} - -export function ensureUint8Array( - value: unknown, - fieldName: string, -): Uint8Array { - if (value instanceof Uint8Array) { - // Buffer (from better-sqlite3) extends Uint8Array but overrides - // .slice() to return a view instead of a copy. This breaks - // downstream libraries (vbare, cbor-x) that rely on - // Uint8Array.prototype.slice() semantics. Copy into a plain - // Uint8Array so .slice() always copies. - if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) { - const copy = new Uint8Array(value.byteLength); - copy.set(value); - return copy; - } - return value; - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - throw new Error(`SQLite row field "${fieldName}" is not binary data`); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/utils.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/utils.ts deleted file mode 100644 index 8d0a77f546..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/utils.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { ActorKey } from "@/actor/mod"; -import { - getNodeCrypto, - getNodeFs, - getNodeFsSync, - getNodeOs, - getNodePath, -} from "@/utils/node"; - -/** - * Generate a deterministic actor ID from name and key - */ -export function generateActorId(name: string, key: ActorKey): string { - // Generate deterministic key string - const jsonString = JSON.stringify([name, key]); - - // Hash to ensure safe file system names - const crypto = getNodeCrypto(); - const hash = crypto - .createHash("sha256") - .update(jsonString) - .digest("hex") - .substring(0, 16); - - return hash; -} - -/** - * Create a hash for a path, normalizing it first - */ -function createHashForPath(dirPath: string): string { - const path = getNodePath(); - // Normalize the path first - const normalizedPath = path.normalize(dirPath); - - // Extract the last path component for readability - const lastComponent = path.basename(normalizedPath); - - // Create SHA-256 hash - const crypto = getNodeCrypto(); - const hash = crypto - .createHash("sha256") - .update(normalizedPath) - .digest("hex") - .substring(0, 8); // Take first 8 characters for brevity - - return `${lastComponent}-${hash}`; -} - -/** - * Get the storage path for the current working directory or a specified path - */ -export function getStoragePath(): string { - const dataPath = getDataPath("rivetkit"); - const dirHash = createHashForPath(process.cwd()); - const path = getNodePath(); - return path.join(dataPath, dirHash); -} - -/** - * Check if a path exists - */ -export async function pathExists(path: string): Promise { - try { - const fs = getNodeFs(); - await fs.access(path); - return true; - } catch { - return false; - } -} - -/** - * Ensure a directory exists, creating it if necessary - */ -export async function ensureDirectoryExists( - directoryPath: string, -): Promise { - if (!(await pathExists(directoryPath))) { - const fs = getNodeFs(); - await fs.mkdir(directoryPath, { recursive: true }); - } -} - -/** - * Ensure a directory exists synchronously - only used during initialization - * All other operations use the async version - */ -export function ensureDirectoryExistsSync(directoryPath: string): void { - const fsSync = getNodeFsSync(); - if (!fsSync.existsSync(directoryPath)) { - fsSync.mkdirSync(directoryPath, { recursive: true }); - } -} - -/** - * Returns platform-specific data directory - */ -function getDataPath(appName: string): string { - const platform = process.platform; - const os = getNodeOs(); - const homeDir = os.homedir(); - const path = getNodePath(); - - switch (platform) { - case "win32": - return path.join( - process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), - appName, - ); - case "darwin": - return path.join( - homeDir, - "Library", - "Application Support", - appName, - ); - default: // linux and others - return path.join( - process.env.XDG_DATA_HOME || - path.join(homeDir, ".local", "share"), - appName, - ); - } -} diff --git a/rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts index 9dff3337f1..0b93c3910f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts @@ -15,6 +15,7 @@ import { } from "@/common/actor-router-consts"; import { getLogger } from "@/common/log"; import { deconstructError, stringifyError } from "@/common/utils"; +import { setIndexedWebSocketTestSender } from "@/common/websocket-test-hooks"; import type { UniversalWebSocket } from "@/common/websocket-interface"; import type { AnyClient } from "@/client/client"; import type { RegistryConfig } from "@/registry/config"; @@ -296,6 +297,9 @@ interface DynamicActorIsolateRuntimeConfig { auth?: DynamicActorAuth; actorDriver: ActorDriver; inlineClient: AnyClient; + test: { + enabled: boolean; + }; } interface DynamicRuntimeRefs { @@ -571,9 +575,12 @@ export class DynamicActorIsolateRuntime { }; setIndexedWebSocketTestSender( session.websocket, - (data, rivetMessageIndex) => + ( + data: string | ArrayBufferLike | Blob | ArrayBufferView, + rivetMessageIndex?: number, + ) => this.#sendWebSocketMessage(session.id, data, rivetMessageIndex), - this.#config.runtimeConfig.testEnabled, + this.#config.test.enabled, ); this.#webSocketSessions.set(session.id, session); this.#sessionIdsByWebSocket.set(session.websocket, session.id); diff --git a/rivetkit-typescript/packages/rivetkit/src/manager-api/actors.ts b/rivetkit-typescript/packages/rivetkit/src/engine-api/actors.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/manager-api/actors.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-api/actors.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/manager-api/common.ts b/rivetkit-typescript/packages/rivetkit/src/engine-api/common.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/manager-api/common.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-api/common.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/actor-http-client.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-http-client.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/actor-http-client.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/actor-http-client.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/actor-websocket-client.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts similarity index 99% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/actor-websocket-client.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts index f731b1cfc4..3842b590a4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/actor-websocket-client.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts @@ -12,7 +12,7 @@ import { } from "@/common/actor-router-consts"; import { importWebSocket } from "@/common/websocket"; import { setRemoteHibernatableWebSocketAckTestHooks } from "@/common/websocket-test-hooks"; -import type { ActorGatewayQuery, CrashPolicy } from "@/manager/protocol/query"; +import type { ActorGatewayQuery, CrashPolicy } from "@/client/query"; import type { Encoding, UniversalWebSocket } from "@/mod"; import { uint8ArrayToBase64 } from "@/serde"; import { combineUrlPath } from "@/utils"; diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts similarity index 95% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-endpoints.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts index b4c490a2d0..59fd5c0d5d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts @@ -8,8 +8,8 @@ import type { ActorsGetOrCreateRequest, ActorsGetOrCreateResponse, ActorsListResponse, -} from "@/manager-api/actors"; -import type { RivetId } from "@/manager-api/common"; +} from "@/engine-api/actors"; +import type { RivetId } from "@/engine-api/common"; import { apiCall } from "./api-utils"; // MARK: Get actor @@ -112,7 +112,8 @@ export interface RegistryConfigRequest { datacenters: Record< string, { - serverless: { + normal?: Record; + serverless?: { url: string; headers: Record; max_runners: number; @@ -123,6 +124,7 @@ export interface RegistryConfigRequest { metadata_poll_interval?: number; }; metadata?: Record; + drain_on_version_upgrade?: boolean; } >; } diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-utils.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-utils.ts similarity index 96% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-utils.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/api-utils.ts index 4bca135b33..cf68e3424f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/api-utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-utils.ts @@ -20,7 +20,7 @@ export class EngineApiError extends Error { // TODO: Remove getEndpoint, but it's used in a lot of places export function getEndpoint(config: ClientConfig | RegistryConfig) { // Endpoint is always defined for ClientConfig (has default in schema). - // RegistryConfig may not have endpoint if using local manager. + // RegistryConfig may not have endpoint before the local runtime is prepared. return config.endpoint ?? "http://127.0.0.1:6420"; } diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/driver.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/driver.ts similarity index 67% rename from rivetkit-typescript/packages/rivetkit/src/manager/driver.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/driver.ts index 44a599d6a7..c6b5975c13 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/driver.ts @@ -3,15 +3,16 @@ import type { ActorKey, Encoding, UniversalWebSocket } from "@/actor/mod"; import type { NativeSqliteConfig } from "@/db/config"; import type { RegistryConfig } from "@/registry/config"; import type { GetUpgradeWebSocket } from "@/utils"; -import type { ActorQuery, CrashPolicy } from "./protocol/query"; +import type { ActorQuery, CrashPolicy } from "@/client/query"; -export type ManagerDriverBuilder = (config: RegistryConfig) => ManagerDriver; export type GatewayTarget = { directId: string } | ActorQuery; -export interface ManagerDriver { +export interface EngineControlClient { getForId(input: GetForIdInput): Promise; getWithKey(input: GetWithKeyInput): Promise; - getOrCreateWithKey(input: GetOrCreateWithKeyInput): Promise; + getOrCreateWithKey( + input: GetOrCreateWithKeyInput, + ): Promise; createActor(input: CreateInput): Promise; listActors(input: ListActorsInput): Promise; @@ -37,66 +38,25 @@ export interface ManagerDriver { encoding: Encoding, params: unknown, ): Promise; - - /** - * Build a public gateway URL for a specific actor or query target. - * - * This lives on the driver because the base endpoint varies by runtime. - */ buildGatewayUrl(target: GatewayTarget): Promise; - - displayInformation(): ManagerDisplayInformation; - + displayInformation(): RuntimeDisplayInformation; extraStartupLog?: () => Record; - - modifyManagerRouter?: (config: RegistryConfig, router: Hono) => void; - /** - * Allows lazily setting getUpgradeWebSocket after the manager router has - * been initialized. - **/ + modifyRuntimeRouter?: (config: RegistryConfig, router: Hono) => void; setGetUpgradeWebSocket(getUpgradeWebSocket: GetUpgradeWebSocket): void; - - /** - * Clean shutdown of manager resources (timers, lock tables, etc.). - * Called after all actors have stopped. - */ shutdown?(): void; - - /** - * Inject the KV channel shutdown callback. Called by the manager - * router so the driver can invoke it during shutdown. - */ setKvChannelShutdown?(fn: () => void): void; - - /** - * Test-only helper that simulates an abrupt actor crash. - */ hardCrashActor?(actorId: string): Promise; - - /** - * Inject native SQLite connection settings for driver-created actors. - */ setNativeSqliteConfig?(config: NativeSqliteConfig): void; - - /** Read a key. Returns null if the key doesn't exist. */ kvGet(actorId: string, key: Uint8Array): Promise; - - /** Batch get KV entries. Returns null for keys that don't exist. */ kvBatchGet( actorId: string, keys: Uint8Array[], ): Promise<(Uint8Array | null)[]>; - - /** Batch put KV entries. */ kvBatchPut( actorId: string, entries: [Uint8Array, Uint8Array][], ): Promise; - - /** Batch delete KV entries. */ kvBatchDelete(actorId: string, keys: Uint8Array[]): Promise; - - /** Delete KV entries in the half-open range [start, end). */ kvDeleteRange( actorId: string, start: Uint8Array, @@ -104,7 +64,7 @@ export interface ManagerDriver { ): Promise; } -export interface ManagerDisplayInformation { +export interface RuntimeDisplayInformation { properties: Record; } diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/log.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/log.ts similarity index 66% rename from rivetkit-typescript/packages/rivetkit/src/manager/log.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/log.ts index 738ed64d0e..437f92fdd1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/log.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/log.ts @@ -1,5 +1,5 @@ import { getLogger } from "@/common/log"; export function logger() { - return getLogger("actor-manager"); + return getLogger("engine-client"); } diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/metadata.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/metadata.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/metadata.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/metadata.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/mod.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts similarity index 94% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/mod.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts index 782750eefc..2479c2341d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts @@ -12,11 +12,11 @@ import { type GetOrCreateWithKeyInput, type GetWithKeyInput, type ListActorsInput, - type ManagerDisplayInformation, - type ManagerDriver, + type RuntimeDisplayInformation, + type EngineControlClient, } from "@/driver-helpers/mod"; -import type { ActorQuery } from "@/manager/protocol/query"; -import type { Actor as ApiActor } from "@/manager-api/actors"; +import type { ActorQuery } from "@/client/query"; +import type { Actor as ApiActor } from "@/engine-api/actors"; import type { Encoding, UniversalWebSocket } from "@/mod"; import { uint8ArrayToBase64 } from "@/serde"; import { combineUrlPath, type GetUpgradeWebSocket } from "@/utils"; @@ -43,7 +43,7 @@ import { lookupMetadataCached } from "./metadata"; import { createWebSocketProxy } from "./ws-proxy"; -export class RemoteManagerDriver implements ManagerDriver { +export class RemoteEngineControlClient implements EngineControlClient { #config: ClientConfig; #metadataPromise: Promise | undefined; @@ -89,10 +89,10 @@ export class RemoteManagerDriver implements ManagerDriver { } logger().info({ - msg: "connected to rivetkit manager", + msg: "connected to rivetkit runtime", runtime: metadataData.runtime, version: metadataData.version, - runner: metadataData.runner, + envoy: metadataData.envoy, }); }, ); @@ -349,14 +349,14 @@ export class RemoteManagerDriver implements ManagerDriver { _actorId: string, _keys: Uint8Array[], ): Promise<(Uint8Array | null)[]> { - throw new Error("kvBatchGet not supported on remote manager driver"); + throw new Error("kvBatchGet not supported on remote engine client"); } async kvBatchPut( _actorId: string, _entries: [Uint8Array, Uint8Array][], ): Promise { - throw new Error("kvBatchPut not supported on remote manager driver"); + throw new Error("kvBatchPut not supported on remote engine client"); } async kvBatchDelete( @@ -364,7 +364,7 @@ export class RemoteManagerDriver implements ManagerDriver { _keys: Uint8Array[], ): Promise { throw new Error( - "kvBatchDelete not supported on remote manager driver", + "kvBatchDelete not supported on remote engine client", ); } @@ -374,11 +374,11 @@ export class RemoteManagerDriver implements ManagerDriver { _end: Uint8Array, ): Promise { throw new Error( - "kvDeleteRange not supported on remote manager driver", + "kvDeleteRange not supported on remote engine client", ); } - displayInformation(): ManagerDisplayInformation { + displayInformation(): RuntimeDisplayInformation { return { properties: {} }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/ws-proxy.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/ws-proxy.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/ws-proxy.ts rename to rivetkit-typescript/packages/rivetkit/src/engine-client/ws-proxy.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-process/mod.ts b/rivetkit-typescript/packages/rivetkit/src/engine-process/mod.ts index 7c281a9668..0a0b25f574 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-process/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-process/mod.ts @@ -1,7 +1,3 @@ -import { - ensureDirectoryExists, - getStoragePath, -} from "@/drivers/file-system/utils"; import { getNodeChildProcess, getNodeCrypto, @@ -23,6 +19,17 @@ interface EnsureEngineProcessOptions { version: string; } +async function ensureDirectoryExists(pathname: string): Promise { + const fs = await getNodeFs(); + await fs.mkdir(pathname, { recursive: true }); +} + +function getStoragePath(): string { + const path = getNodePath(); + const home = process.env.HOME ?? process.cwd(); + return path.join(process.env.RIVETKIT_STORAGE_PATH ?? home, ".rivetkit"); +} + export async function ensureEngineProcess( options: EnsureEngineProcessOptions, ): Promise { @@ -163,7 +170,7 @@ export async function ensureEngineProcess( signal, stdoutLog: stdoutLogPath, stderrLog: stderrLogPath, - issues: "https://github.com/rivet-dev/rivetkit/issues", + issues: "https://github.com/rivet-dev/rivet/issues", support: "https://rivet.dev/discord", }); } else if ( @@ -186,7 +193,7 @@ export async function ensureEngineProcess( signal, stdoutLog: stdoutLogPath, stderrLog: stderrLogPath, - issues: "https://github.com/rivet-dev/rivetkit/issues", + issues: "https://github.com/rivet-dev/rivet/issues", support: "https://rivet.dev/discord", }); } @@ -312,7 +319,7 @@ async function downloadEngineBinaryIfNeeded( msg: "engine download failed, please report this error", tempPath, error, - issues: "https://github.com/rivet-dev/rivetkit/issues", + issues: "https://github.com/rivet-dev/rivet/issues", support: "https://rivet.dev/discord", }); try { diff --git a/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts b/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts index 774781fe46..2842090119 100644 --- a/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts +++ b/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts @@ -412,7 +412,7 @@ export class ActorInspector { } async #withDatabase( - fn: (db: NonNullable) => Promise, + fn: (db: NonNullable) => Promise, ): Promise { if (!this.isDatabaseEnabled()) { throw new actorErrors.DatabaseNotEnabled(); diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/mod.ts b/rivetkit-typescript/packages/rivetkit/src/manager/mod.ts deleted file mode 100644 index f05449e829..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/manager/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ManagerDisplayInformation, ManagerDriver } from "./driver"; -export { buildManagerRouter } from "./router"; diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/protocol/mod.ts b/rivetkit-typescript/packages/rivetkit/src/manager/protocol/mod.ts deleted file mode 100644 index c0dba4b466..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/manager/protocol/mod.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod/v4"; -import { ActorQuerySchema } from "./query"; - -export * from "./query"; - -export const ActorsRequestSchema = z.object({ - query: ActorQuerySchema, -}); - -export const ActorsResponseSchema = z.object({ - actorId: z.string(), -}); - -//export const RivetConfigResponseSchema = z.object({ -// endpoint: z.string(), -// project: z.string().optional(), -// environment: z.string().optional(), -//}); - -export type ActorsRequest = z.infer; -export type ActorsResponse = z.infer; -//export type RivetConfigResponse = z.infer; diff --git a/rivetkit-typescript/packages/rivetkit/src/mod.ts b/rivetkit-typescript/packages/rivetkit/src/mod.ts index e02519c271..5711ad0572 100644 --- a/rivetkit-typescript/packages/rivetkit/src/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/mod.ts @@ -13,12 +13,7 @@ export { } from "@/client/client"; export { InlineWebSocketAdapter } from "@/common/inline-websocket-adapter"; export { noopNext } from "@/common/utils"; -export { createEngineDriver } from "@/drivers/engine/mod"; -export { - createFileSystemDriver, - createMemoryDriver, -} from "@/drivers/file-system/mod"; -export type { ActorQuery } from "@/manager/protocol/query"; +export type { ActorQuery } from "@/client/query"; export * from "@/registry"; export * from "@/registry/config"; export { toUint8Array } from "@/utils"; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/driver.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/driver.ts deleted file mode 100644 index 7f0f0712a4..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/driver.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod/v4"; -import { ActorDriverBuilder } from "@/actor/driver"; -import { ManagerDriverBuilder } from "@/manager/driver"; - -export const DriverConfigSchema = z.object({ - /** Machine-readable name to identify this driver by. */ - name: z.string(), - displayName: z.string(), - manager: z.custom(), - actor: z.custom(), - /** - * Start actor driver immediately or if this is started separately. - * - * For example: - * - Engine driver needs this to start immediately since this starts the Runner that connects to the engine - * - Cloudflare Workers should not start it automatically, since the actor only runs in the DO - * */ - autoStartActorDriver: z.boolean(), -}); - -export type DriverConfig = z.infer; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts index 2cd6dee73f..f218a0c989 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts @@ -15,17 +15,13 @@ import { tryParseEndpoint } from "@/utils/endpoint-parser"; import { getRivetEndpoint, getRivetEngine, - getRivetkitStoragePath, getRivetNamespace, getRivetToken, isDev, } from "@/utils/env-vars"; -import { type DriverConfig, DriverConfigSchema } from "./driver"; import { EnvoyConfigSchema } from "./envoy"; import { ServerlessConfigSchema } from "./serverless"; -export { DriverConfigSchema, type DriverConfig }; - export const ActorsSchema = z.record( z.string(), z.custom>(), @@ -50,20 +46,6 @@ export const RegistryConfigSchema = z **/ test: TestConfigSchema.optional().default({ enabled: false }), - // MARK: Driver - driver: DriverConfigSchema.optional(), - /** - * Storage path for RivetKit file-system state when using the default driver. - * - * If not set, RivetKit uses the platform default data location. - * - * Can also be set via RIVETKIT_STORAGE_PATH. - */ - storagePath: z - .string() - .optional() - .transform((val) => val ?? getRivetkitStoragePath()), - // MARK: Database /** * @experimental @@ -152,14 +134,14 @@ export const RegistryConfigSchema = z // MARK: Manager /** - * Whether to start the local manager server. + * Whether to start the local RivetKit server. * Auto-determined based on endpoint and NODE_ENV if not specified. */ serveManager: z.boolean().optional(), /** * Directory to serve static files from. * - * When set, the manager server will serve static files from this + * When set, the local RivetKit server will serve static files from this * directory. This is used by `registry.start()` to serve a frontend * alongside the actor API. */ @@ -167,7 +149,7 @@ export const RegistryConfigSchema = z /** * @experimental * - * Base path for the manager API. This is used to prefix all routes. + * Base path for the local RivetKit API. This is used to prefix all routes. * For example, if the base path is `/foo`, then the route `/actors` * will be available at `/foo/actors`. */ @@ -181,7 +163,7 @@ export const RegistryConfigSchema = z /** * @experimental * - * What host to bind the manager server to. + * What host to bind the local RivetKit server to. */ managerHost: z.string().optional(), @@ -498,12 +480,6 @@ export const DocRegistryConfigSchema = z .describe( "Actor definitions. Keys are actor names, values are actor definitions.", ), - storagePath: z - .string() - .optional() - .describe( - "Storage path for RivetKit file-system state when using the default driver. Can also be set via RIVETKIT_STORAGE_PATH.", - ), sqlitePool: z .object({ actorsPerInstance: z @@ -575,18 +551,18 @@ export const DocRegistryConfigSchema = z .boolean() .optional() .describe( - "Whether to start the local manager server. Auto-determined based on endpoint and NODE_ENV if not specified.", + "Whether to start the local RivetKit server. Auto-determined based on endpoint and NODE_ENV if not specified.", ), publicDir: z .string() .optional() .describe( - "Directory to serve static files from. When set, the manager server serves static files alongside the actor API. Used by registry.start().", + "Directory to serve static files from. When set, the local RivetKit server serves static files alongside the actor API. Used by registry.start().", ), managerBasePath: z .string() .optional() - .describe("Base path for the manager API. Default: '/'"), + .describe("Base path for the local RivetKit API. Default: '/'"), managerPort: z .number() .optional() diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts deleted file mode 100644 index 88d30e94c6..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Logger } from "pino"; -import { z } from "zod/v4"; -import type { ActorDriverBuilder } from "@/actor/driver"; -import { LogLevelSchema } from "@/common/log"; -import { - EngineConfigSchemaBase, - transformEngineConfig, -} from "@/drivers/engine/config"; -import { InspectorConfigSchema } from "@/inspector/config"; -import type { ManagerDriverBuilder } from "@/manager/driver"; -import type { GetUpgradeWebSocket } from "@/utils"; -import { getEnvUniversal, VERSION } from "@/utils"; -import { - getRivetRunEngine, - getRivetRunEngineVersion, - getRivetEnvoyKind, - getRivetToken, -} from "@/utils/env-vars"; - -export const LegacyDriverConfigSchema = z.object({ - /** Machine-readable name to identify this driver by. */ - name: z.string(), - manager: z.custom(), - actor: z.custom(), -}); - -export type LegacyDriverConfig = z.infer; - -/** Base config used for the actor config across all platforms. */ -const LegacyRunnerConfigSchemaUnmerged = z - .object({ - driver: LegacyDriverConfigSchema.optional(), - - /** @experimental */ - maxIncomingMessageSize: z.number().optional().default(65_536), - - /** @experimental */ - maxOutgoingMessageSize: z.number().optional().default(1_048_576), - - /** @experimental */ - inspector: InspectorConfigSchema, - - /** @experimental */ - disableDefaultServer: z.boolean().optional().default(false), - - /** @experimental */ - defaultServerPort: z.number().default(6420), - - /** @experimental */ - runEngine: z - .boolean() - .optional() - .default(() => getRivetRunEngine()), - - /** @experimental */ - runEngineVersion: z - .string() - .optional() - .default(() => getRivetRunEngineVersion() ?? VERSION), - - /** @experimental */ - overrideServerAddress: z.string().optional(), - - /** @experimental */ - disableActorDriver: z.boolean().optional().default(false), - - /** - * @experimental - * - * Whether to run runners normally or have them managed - * serverlessly (by the Rivet Engine for example). - */ - runnerKind: z - .enum(["serverless", "normal"]) - .optional() - .default(() => - getRivetEnvoyKind() === "serverless" ? "serverless" : "normal", - ), - totalSlots: z.number().optional(), - - /** - * @experimental - * - * Base path for the router. This is used to prefix all routes. - * For example, if the base path is `/api`, then the route `/actors` will be - * available at `/api/actors`. - */ - basePath: z.string().optional().default("/"), - - /** - * @experimental - * - * Disable welcome message. - * */ - noWelcome: z.boolean().optional().default(false), - - /** - * @experimental - * */ - logging: z - .object({ - baseLogger: z.custom().optional(), - level: LogLevelSchema.optional(), - }) - .optional() - .default(() => ({})), - - /** - * @experimental - * - * Automatically configure serverless runners in the engine. - * Can only be used when runnerKind is "serverless". - * If true, uses default configuration. Can also provide custom configuration. - */ - autoConfigureServerless: z - .union([ - z.boolean(), - z.object({ - url: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), - maxRunners: z.number().optional(), - minRunners: z.number().optional(), - requestLifespan: z.number().optional(), - runnersMargin: z.number().optional(), - slotsPerRunner: z.number().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - }), - ]) - .optional(), - - // This is a function to allow for lazy configuration of upgradeWebSocket on the - // fly. This is required since the dependencies that upgradeWebSocket - // (specifically Node.js) can sometimes only be specified after the router is - // created or must be imported async using `await import(...)` - getUpgradeWebSocket: z.custom().optional(), - - /** @experimental */ - token: z - .string() - .optional() - .transform((v) => v || getRivetToken()), - }) - .merge(EngineConfigSchemaBase); - -const LegacyRunnerConfigSchemaTransformed = - LegacyRunnerConfigSchemaUnmerged.transform((config, ctx) => ({ - ...config, - ...transformEngineConfig(config, ctx), - })); - -export const LegacyRunnerConfigSchema = - LegacyRunnerConfigSchemaTransformed.default(() => - LegacyRunnerConfigSchemaTransformed.parse({}), - ); - -export type LegacyRunnerConfig = z.infer; -export type LegacyRunnerConfigInput = z.input; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/index.ts b/rivetkit-typescript/packages/rivetkit/src/registry/index.ts index 20e59f153f..8528f32e0c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/index.ts @@ -33,7 +33,7 @@ export class Registry { constructor(config: RegistryConfigInput) { this.#config = config; - // Start the local manager or engine before /api/rivet is hit so clients can + // Start the local runtime or engine before /api/rivet is hit so clients can // reach the public endpoint preemptively. This waits one tick because some // integrations mutate registry config immediately after setup() returns. if (config.serverless?.spawnEngine || config.serveManager) { @@ -102,7 +102,7 @@ export class Registry { /** * Starts the server, serving both the actor API and static files. * - * This is the simplest way to run RivetKit. It starts a local manager + * This is the simplest way to run RivetKit. It starts a local runtime * server, serves static files from the configured `publicDir` (default * `"public"`), and starts the actor envoy. * @@ -122,7 +122,7 @@ export class Registry { } // Force serveManager when there's no remote endpoint so the - // manager server starts and serves the API + static files. + // local runtime starts and serves the API + static files. // When an endpoint IS configured, the config transform handles // the mode (serveManager defaults to false, spawnEngine may be // true, etc.) and we just start the envoy. diff --git a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/log.ts b/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/log.ts deleted file mode 100644 index 46e83a5dc0..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/remote-manager-driver/log.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getLogger } from "@/common//log"; - -export function logger() { - return getLogger("remote-manager-driver"); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/kv-channel.ts b/rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-channel.ts similarity index 94% rename from rivetkit-typescript/packages/rivetkit/src/manager/kv-channel.ts rename to rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-channel.ts index 709e2fcc41..8b330ebbe9 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/kv-channel.ts +++ b/rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-channel.ts @@ -1,4 +1,4 @@ -// KV Channel WebSocket handler for the local dev manager. +// KV Channel WebSocket handler for the local runtime server. // // Serves the /kv/connect endpoint that the native SQLite addon // (rivetkit-typescript/packages/sqlite-native/) connects to for @@ -8,16 +8,16 @@ import type { WSContext } from "hono/ws"; import { PROTOCOL_VERSION, - type ToRivet, + type ToServer as ToRivet, type ToClient, type RequestData, type ResponseData, - type ToRivetRequest, - decodeToRivet, + type ToServerRequest as ToRivetRequest, + decodeToServer as decodeToRivet, encodeToClient, } from "@rivetkit/engine-kv-channel-protocol"; -import { KvStorageQuotaExceededError } from "@/drivers/file-system/kv-limits"; -import type { ManagerDriver } from "./driver"; +import { KvStorageQuotaExceededError } from "./kv-limits"; +import type { EngineControlClient } from "@/engine-client/driver"; import { logger } from "./log"; // Ping every 3 seconds, close if no pong within 15 seconds. @@ -65,7 +65,7 @@ interface KvChannelManagerState { /** Return type of createKvChannelManager. */ export interface KvChannelManager { - createHandler: (managerDriver: ManagerDriver) => { + createHandler: (engineClient: EngineControlClient) => { onOpen: (event: any, ws: WSContext) => void; onMessage: (event: any, ws: WSContext) => void; onClose: (event: any, ws: WSContext) => void; @@ -89,7 +89,7 @@ export function createKvChannelManager(): KvChannelManager { }; return { - createHandler(managerDriver: ManagerDriver) { + createHandler(engineClient: EngineControlClient) { const conn: KvChannelConnection = { openActors: new Set(), pingInterval: null, @@ -126,7 +126,7 @@ export function createKvChannelManager(): KvChannelManager { } const msg = decodeToRivet(bytes); - handleToRivetMessage(state, conn, managerDriver, msg); + handleToRivetMessage(state, conn, engineClient, msg); } catch (err: unknown) { logger().error({ msg: "kv channel failed to decode message", @@ -272,7 +272,7 @@ function cleanupConnection( async function handleRequest( state: KvChannelManagerState, conn: KvChannelConnection, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, request: ToRivetRequest, ): Promise { const { requestId, actorId, data } = request; @@ -281,7 +281,7 @@ async function handleRequest( const responseData = await processRequestData( state, conn, - managerDriver, + engineClient, actorId, data, ); @@ -312,7 +312,7 @@ async function handleRequest( async function processRequestData( state: KvChannelManagerState, conn: KvChannelConnection, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, actorId: string, data: RequestData, ): Promise { @@ -347,7 +347,7 @@ async function processRequestData( }, }; } - return await handleKvOperation(managerDriver, actorId, data); + return await handleKvOperation(engineClient, actorId, data); } } } @@ -440,7 +440,7 @@ type KvRequestData = Extract< >; async function handleKvOperation( - managerDriver: ManagerDriver, + engineClient: EngineControlClient, actorId: string, data: KvRequestData, ): Promise { @@ -474,7 +474,7 @@ async function handleKvOperation( } } - const results = await managerDriver.kvBatchGet(actorId, keys); + const results = await engineClient.kvBatchGet(actorId, keys); // Return only found keys and values. const foundKeys: ArrayBuffer[] = []; @@ -564,7 +564,7 @@ async function handleKvOperation( ); try { - await managerDriver.kvBatchPut(actorId, entries); + await engineClient.kvBatchPut(actorId, entries); } catch (err: unknown) { if (err instanceof KvStorageQuotaExceededError) { return { @@ -608,7 +608,7 @@ async function handleKvOperation( } } - await managerDriver.kvBatchDelete(actorId, keys); + await engineClient.kvBatchDelete(actorId, keys); return { tag: "KvDeleteResponse", val: null }; } @@ -636,7 +636,7 @@ async function handleKvOperation( }; } - await managerDriver.kvDeleteRange(actorId, start, end); + await engineClient.kvDeleteRange(actorId, start, end); return { tag: "KvDeleteResponse", val: null }; } @@ -652,11 +652,11 @@ async function handleKvOperation( function handleToRivetMessage( state: KvChannelManagerState, conn: KvChannelConnection, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, msg: ToRivet, ): void { switch (msg.tag) { - case "ToRivetRequest": { + case "ToServerRequest": { const { actorId } = msg.val; // Chain requests per actor so they execute sequentially, @@ -665,7 +665,7 @@ function handleToRivetMessage( // own queue. See docs-internal/engine/NATIVE_SQLITE_REVIEW_FIXES.md H2. const prev = conn.actorQueues.get(actorId) ?? Promise.resolve(); const next = prev.then(() => - handleRequest(state, conn, managerDriver, msg.val).catch( + handleRequest(state, conn, engineClient, msg.val).catch( (err) => { logger().error({ msg: "unhandled error in kv channel request handler", @@ -688,7 +688,7 @@ function handleToRivetMessage( break; } - case "ToRivetPong": + case "ToServerPong": conn.lastPongTs = Date.now(); break; } diff --git a/rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-limits.ts b/rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-limits.ts new file mode 100644 index 0000000000..d2737716ab --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/runtime-router/kv-limits.ts @@ -0,0 +1,13 @@ +export class KvStorageQuotaExceededError extends Error { + readonly remaining: number; + readonly payloadSize: number; + + constructor(remaining: number, payloadSize: number) { + super( + `not enough space left in storage (${remaining} bytes remaining, current payload is ${payloadSize} bytes)`, + ); + this.name = "KvStorageQuotaExceededError"; + this.remaining = remaining; + this.payloadSize = payloadSize; + } +} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/log.ts b/rivetkit-typescript/packages/rivetkit/src/runtime-router/log.ts similarity index 66% rename from rivetkit-typescript/packages/rivetkit/src/drivers/file-system/log.ts rename to rivetkit-typescript/packages/rivetkit/src/runtime-router/log.ts index c40d1a1ddf..cc0c988fc2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/log.ts +++ b/rivetkit-typescript/packages/rivetkit/src/runtime-router/log.ts @@ -1,5 +1,5 @@ import { getLogger } from "@/common/log"; export function logger() { - return getLogger("driver-fs"); + return getLogger("runtime-router"); } diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/router-schema.ts b/rivetkit-typescript/packages/rivetkit/src/runtime-router/router-schema.ts similarity index 100% rename from rivetkit-typescript/packages/rivetkit/src/manager/router-schema.ts rename to rivetkit-typescript/packages/rivetkit/src/runtime-router/router-schema.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/router.ts b/rivetkit-typescript/packages/rivetkit/src/runtime-router/router.ts similarity index 70% rename from rivetkit-typescript/packages/rivetkit/src/manager/router.ts rename to rivetkit-typescript/packages/rivetkit/src/runtime-router/router.ts index e876081411..01e1d90fee 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/router.ts +++ b/rivetkit-typescript/packages/rivetkit/src/runtime-router/router.ts @@ -16,10 +16,6 @@ import { import { handleHealthRequest, handleMetadataRequest } from "@/common/router"; import { deconstructError, noopNext, stringifyError } from "@/common/utils"; import { HEADER_ACTOR_ID } from "@/driver-helpers/mod"; -import type { - TestInlineDriverCallRequest, - TestInlineDriverCallResponse, -} from "@/driver-test-suite/test-inline-client-driver"; import { getInspectorDir } from "@/inspector/serve-ui"; import { ActorsCreateRequestSchema, @@ -35,7 +31,7 @@ import { type ActorsListResponse, ActorsListResponseSchema, type Actor as ApiActor, -} from "@/manager-api/actors"; +} from "@/engine-api/actors"; import { buildActorNames, type RegistryConfig } from "@/registry/config"; import { loadRuntimeServeStatic } from "@/utils/serve"; import type { GetUpgradeWebSocket, Runtime } from "@/utils"; @@ -46,17 +42,20 @@ import { buildOpenApiResponses, createRouter, } from "@/utils/router"; -import type { ActorOutput, ManagerDriver } from "./driver"; -import { actorGateway, createTestWebSocketProxy } from "./gateway"; +import type { ActorOutput, EngineControlClient } from "@/engine-client/driver"; +import { + actorGateway, + createTestWebSocketProxy, +} from "@/actor-gateway/gateway"; import { createKvChannelManager, validateProtocolVersion, } from "./kv-channel"; import { logger } from "./log"; -export function buildManagerRouter( +export function buildRuntimeRouter( config: RegistryConfig, - managerDriver: ManagerDriver, + engineClient: EngineControlClient, getUpgradeWebSocket: GetUpgradeWebSocket | undefined, runtime: Runtime = "node", ) { @@ -64,7 +63,7 @@ export function buildManagerRouter( // Inject the KV channel shutdown into the driver so it can be // called during the driver's teardown, after all actors have stopped. - managerDriver.setKvChannelShutdown?.(kvChannelManager.shutdown); + engineClient.setKvChannelShutdown?.(kvChannelManager.shutdown); return createRouter(config.managerBasePath, (router) => { // Actor gateway @@ -73,7 +72,7 @@ export function buildManagerRouter( actorGateway.bind( undefined, config, - managerDriver, + engineClient, getUpgradeWebSocket, ), ); @@ -152,7 +151,7 @@ export function buildManagerRouter( for (const actorId of actorIdsParsed) { if (name) { // If name is provided, use it directly - const actorOutput = await managerDriver.getForId({ + const actorOutput = await engineClient.getForId({ c, name, actorId, @@ -165,7 +164,7 @@ export function buildManagerRouter( // Actor IDs are globally unique, so we'll find it in one of them for (const actorName of Object.keys(config.use)) { const actorOutput = - await managerDriver.getForId({ + await engineClient.getForId({ c, name: actorName, actorId, @@ -178,7 +177,7 @@ export function buildManagerRouter( } } } else if (key && name) { - const actorOutput = await managerDriver.getWithKey({ + const actorOutput = await engineClient.getWithKey({ c, name, key: deserializeActorKey(key), @@ -197,7 +196,7 @@ export function buildManagerRouter( } // List all actors with the given name - const actorOutputs = await managerDriver.listActors({ + const actorOutputs = await engineClient.listActors({ c, name, key, @@ -252,7 +251,7 @@ export function buildManagerRouter( const body = c.req.valid("json"); // Check if actor already exists - const existingActor = await managerDriver.getWithKey({ + const existingActor = await engineClient.getWithKey({ c, name: body.name, key: deserializeActorKey(body.key), @@ -266,7 +265,7 @@ export function buildManagerRouter( } // Create new actor - const newActor = await managerDriver.getOrCreateWithKey({ + const newActor = await engineClient.getOrCreateWithKey({ c, name: body.name, key: deserializeActorKey(body.key), @@ -298,7 +297,7 @@ export function buildManagerRouter( const body = c.req.valid("json"); // Create actor using the driver - const actorOutput = await managerDriver.createActor({ + const actorOutput = await engineClient.createActor({ c, name: body.name, key: deserializeActorKey(body.key || crypto.randomUUID()), @@ -352,7 +351,7 @@ export function buildManagerRouter( const { actor_id: actorId, key } = c.req.valid("param"); - const response = await managerDriver.kvGet( + const response = await engineClient.kvGet( actorId, Buffer.from(key, "base64"), ); @@ -408,7 +407,7 @@ export function buildManagerRouter( } return upgradeWebSocket(() => - kvChannelManager.createHandler(managerDriver), + kvChannelManager.createHandler(engineClient), )(c, noopNext()); }); @@ -436,165 +435,6 @@ export function buildManagerRouter( // } if (config.test.enabled) { - // Add HTTP endpoint to test the inline client - // - // We have to do this in a router since this needs to run in the same server as the RivetKit registry. Some test contexts to not run in the same server. - router.post(".test/inline-driver/call", async (c) => { - // TODO: use openapi instead - const buffer = await c.req.arrayBuffer(); - const { encoding, method, args }: TestInlineDriverCallRequest = - cbor.decode(new Uint8Array(buffer)); - - logger().debug({ - msg: "received inline request", - encoding, - method, - args, - }); - - // Forward inline driver request - let response: TestInlineDriverCallResponse; - try { - const output = await ( - (managerDriver as any)[method] as any - )(...args); - response = { ok: output }; - } catch (rawErr) { - const err = deconstructError(rawErr, logger(), {}, true); - response = { err }; - } - - // TODO: Remove any - return c.body(cbor.encode(response) as any); - }); - - router.get(".test/inline-driver/connect-websocket/*", async (c) => { - const upgradeWebSocket = getUpgradeWebSocket?.(); - invariant( - upgradeWebSocket, - "websockets not supported on this platform", - ); - - return upgradeWebSocket(async (c: any) => { - // Extract information from sec-websocket-protocol header - const protocolHeader = - c.req.header("sec-websocket-protocol") || ""; - const protocols = protocolHeader.split(/,\s*/); - - // Parse protocols to extract connection info - let actorId = ""; - let encoding: Encoding = "bare"; - let path = ""; - let params: unknown; - - for (const protocol of protocols) { - if (protocol.startsWith(WS_PROTOCOL_ACTOR)) { - actorId = decodeURIComponent( - protocol.substring(WS_PROTOCOL_ACTOR.length), - ); - } else if (protocol.startsWith(WS_PROTOCOL_ENCODING)) { - encoding = protocol.substring( - WS_PROTOCOL_ENCODING.length, - ) as Encoding; - } else if (protocol.startsWith(WS_TEST_PROTOCOL_PATH)) { - path = decodeURIComponent( - protocol.substring( - WS_TEST_PROTOCOL_PATH.length, - ), - ); - } else if ( - protocol.startsWith(WS_PROTOCOL_CONN_PARAMS) - ) { - const paramsRaw = decodeURIComponent( - protocol.substring( - WS_PROTOCOL_CONN_PARAMS.length, - ), - ); - params = JSON.parse(paramsRaw); - } - } - - logger().debug({ - msg: "received test inline driver websocket", - actorId, - params, - encodingKind: encoding, - path: path, - }); - - // Connect to the actor using the inline client driver - this returns a Promise - const clientToProxyWsPromise = managerDriver.openWebSocket( - path, - actorId, - encoding, - params, - ); - - return await createTestWebSocketProxy( - clientToProxyWsPromise, - ); - })(c, noopNext()); - }); - - router.all(".test/inline-driver/send-request/*", async (c) => { - // Extract parameters from headers - const actorId = c.req.header(HEADER_ACTOR_ID); - - if (!actorId) { - return c.text("Missing required headers", 400); - } - - // Extract the path after /send-request/ - const pathOnly = - c.req.path.split("/.test/inline-driver/send-request/")[1] || - ""; - - // Include query string - const url = new URL(c.req.url); - const pathWithQuery = pathOnly + url.search; - - logger().debug({ - msg: "received test inline driver raw http", - actorId, - path: pathWithQuery, - method: c.req.method, - }); - - try { - // Forward the request using the inline client driver - const response = await managerDriver.sendRequest( - actorId, - new Request(`http://actor/${pathWithQuery}`, { - method: c.req.method, - headers: c.req.raw.headers, - body: c.req.raw.body, - duplex: "half", - } as RequestInit), - ); - - // Return the response directly - return response; - } catch (error) { - logger().error({ - msg: "error in test inline raw http", - error: stringifyError(error), - }); - - // Return error response - const err = deconstructError(error, logger(), {}, true); - return c.json( - { - error: { - code: err.code, - message: err.message, - metadata: err.metadata, - }, - }, - err.statusCode, - ); - } - }); - // Test endpoint to force disconnect a connection non-cleanly router.post("/.test/force-disconnect", async (c) => { const actorId = c.req.query("actor"); @@ -615,8 +455,8 @@ export function buildManagerRouter( try { // Send a special request to the actor to force disconnect the connection - const response = await managerDriver.sendRequest( - actorId, + const response = await engineClient.sendRequest( + { directId: actorId }, new Request( `http://actor/.test/force-disconnect?conn=${connId}`, { @@ -704,7 +544,7 @@ export function buildManagerRouter( ), ); - managerDriver.modifyManagerRouter?.(config, router as unknown as Hono); + engineClient.modifyRuntimeRouter?.(config, router as unknown as Hono); }); } diff --git a/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/mod.ts b/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/mod.ts deleted file mode 100644 index b71b167fb5..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../dist/schemas/file-system-driver/v3"; diff --git a/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/versioned.ts b/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/versioned.ts deleted file mode 100644 index bdb55e690f..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/schemas/file-system-driver/versioned.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createVersionedDataHandler } from "vbare"; -import { bufferToArrayBuffer } from "@/utils"; -import * as v1 from "../../../dist/schemas/file-system-driver/v1"; -import * as v2 from "../../../dist/schemas/file-system-driver/v2"; -import * as v3 from "../../../dist/schemas/file-system-driver/v3"; - -export const CURRENT_VERSION = 3; - -// Converter from v1 to v2 -const v1ToV2 = (v1State: v1.ActorState): v2.ActorState => { - // Create a new kvStorage list with the legacy persist data - const kvStorage: v2.ActorKvEntry[] = []; - - // Store the legacy persist data under key [1] - if (v1State.persistedData) { - // Key [1] as Uint8Array - const key = new Uint8Array([1]); - kvStorage.push({ - key: bufferToArrayBuffer(key), - value: v1State.persistedData, - }); - } - - return { - actorId: v1State.actorId, - name: v1State.name, - key: v1State.key, - kvStorage, - createdAt: v1State.createdAt, - }; -}; - -// Converter from v2 to v3 -const v2ToV3 = (v2State: v2.ActorState): v3.ActorState => { - // Migrate from v2 to v3 by adding the new optional timestamp fields - return { - actorId: v2State.actorId, - name: v2State.name, - key: v2State.key, - kvStorage: v2State.kvStorage, - createdAt: v2State.createdAt, - startTs: null, - connectableTs: null, - sleepTs: null, - destroyTs: null, - }; -}; - -// Converter from v3 to v2 -const v3ToV2 = (v3State: v3.ActorState): v2.ActorState => { - // Downgrade from v3 to v2 by removing the timestamp fields - return { - actorId: v3State.actorId, - name: v3State.name, - key: v3State.key, - kvStorage: v3State.kvStorage, - createdAt: v3State.createdAt, - }; -}; - -// Converter from v2 to v1 -const v2ToV1 = (v2State: v2.ActorState): v1.ActorState => { - // Downgrade from v2 to v1 by converting kvStorage back to persistedData - // Find the persist data entry (key [1]) - const persistDataEntry = v2State.kvStorage.find((entry) => { - const key = new Uint8Array(entry.key); - return key.length === 1 && key[0] === 1; - }); - - return { - actorId: v2State.actorId, - name: v2State.name, - key: v2State.key, - persistedData: persistDataEntry?.value || new ArrayBuffer(0), - createdAt: v2State.createdAt, - }; -}; - -export const ACTOR_STATE_VERSIONED = createVersionedDataHandler({ - deserializeVersion: (bytes, version) => { - switch (version) { - case 1: - return v1.decodeActorState(bytes); - case 2: - return v2.decodeActorState(bytes); - case 3: - return v3.decodeActorState(bytes); - default: - throw new Error(`Unknown version ${version}`); - } - }, - serializeVersion: (data, version) => { - switch (version) { - case 1: - return v1.encodeActorState(data as v1.ActorState); - case 2: - return v2.encodeActorState(data as v2.ActorState); - case 3: - return v3.encodeActorState(data as v3.ActorState); - default: - throw new Error(`Unknown version ${version}`); - } - }, - deserializeConverters: () => [v1ToV2, v2ToV3], - serializeConverters: () => [v3ToV2, v2ToV1], -}); - -export const ACTOR_ALARM_VERSIONED = createVersionedDataHandler({ - deserializeVersion: (bytes, version) => { - switch (version) { - case 1: - return v1.decodeActorAlarm(bytes); - case 2: - return v2.decodeActorAlarm(bytes); - case 3: - return v3.decodeActorAlarm(bytes); - default: - throw new Error(`Unknown version ${version}`); - } - }, - serializeVersion: (data, version) => { - switch (version) { - case 1: - return v1.encodeActorAlarm(data as v1.ActorAlarm); - case 2: - return v2.encodeActorAlarm(data as v2.ActorAlarm); - case 3: - return v3.encodeActorAlarm(data as v3.ActorAlarm); - default: - throw new Error(`Unknown version ${version}`); - } - }, - deserializeConverters: () => [], - serializeConverters: () => [], -}); diff --git a/rivetkit-typescript/packages/rivetkit/src/serve-test-suite/mod.ts b/rivetkit-typescript/packages/rivetkit/src/serve-test-suite/mod.ts deleted file mode 100644 index 36b8594db0..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/serve-test-suite/mod.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createServer } from "node:net"; -import { fileURLToPath } from "node:url"; -import { serve as honoServe } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import invariant from "invariant"; -import { logger } from "@/driver-test-suite/log"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { buildManagerRouter } from "@/manager/router"; -import { registry } from "../../fixtures/driver-test-suite/registry"; - -export interface ServeTestSuiteResult { - endpoint: string; - namespace: string; - runnerName: string; - close(): Promise; -} - -async function getPort(): Promise { - const MIN_PORT = 10000; - const MAX_PORT = 65535; - const getRandomPort = () => - Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT; - - let port = getRandomPort(); - let maxAttempts = 10; - - while (maxAttempts > 0) { - try { - const server = await new Promise((resolve, reject) => { - const server = createServer(); - - server.once("error", (err: Error & { code?: string }) => { - if (err.code === "EADDRINUSE") { - reject(new Error(`Port ${port} is in use`)); - } else { - reject(err); - } - }); - - server.once("listening", () => { - resolve(server); - }); - - server.listen(port); - }); - - await new Promise((resolve) => { - server.close(() => resolve()); - }); - - return port; - } catch { - maxAttempts--; - if (maxAttempts <= 0) { - break; - } - port = getRandomPort(); - } - } - - throw new Error("Could not find an available port after multiple attempts"); -} - -export async function serveTestSuite(): Promise { - registry.config.test = { ...registry.config.test, enabled: true }; - registry.config.inspector = { - enabled: true, - token: () => "token", - }; - const port = await getPort(); - registry.config.managerPort = port; - registry.config.serverless = { - ...registry.config.serverless, - publicEndpoint: `http://127.0.0.1:${port}`, - }; - - const driver = await createFileSystemOrMemoryDriver(true, { - path: `/tmp/rivetkit-test-suite-${crypto.randomUUID()}`, - }); - registry.config.driver = driver; - - let upgradeWebSocket: any; - - const parsedConfig = registry.parseConfig(); - const managerDriver = driver.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - const { router } = buildManagerRouter( - parsedConfig, - managerDriver, - () => upgradeWebSocket, - ); - - const nodeWebSocket = createNodeWebSocket({ app: router }); - upgradeWebSocket = nodeWebSocket.upgradeWebSocket; - managerDriver.setGetUpgradeWebSocket(() => upgradeWebSocket); - - const server = honoServe({ - fetch: router.fetch, - hostname: "127.0.0.1", - port, - }); - invariant( - nodeWebSocket.injectWebSocket !== undefined, - "should have injectWebSocket", - ); - nodeWebSocket.injectWebSocket(server); - const endpoint = `http://127.0.0.1:${port}`; - - logger().info({ msg: "test suite server listening", port }); - - return { - endpoint, - namespace: "default", - runnerName: "default", - close: async () => { - await new Promise((resolve) => - server.close(() => resolve(undefined)), - ); - }, - }; -} - -async function runCli() { - const result = await serveTestSuite(); - process.stdout.write( - `${JSON.stringify({ - endpoint: result.endpoint, - namespace: result.namespace, - runnerName: result.runnerName, - })}\n`, - ); - - const shutdown = async () => { - await result.close(); - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); -} - -const mainPath = process.argv[1]; -if (mainPath && mainPath === fileURLToPath(import.meta.url)) { - runCli().catch((err) => { - logger().error({ msg: "serve-test-suite failed", error: err }); - process.exit(1); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts b/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts index a7819190d0..3c34ec82f0 100644 --- a/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts +++ b/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts @@ -5,7 +5,7 @@ import { convertRegistryConfigToClientConfig } from "@/client/config"; import { getDatacenters, updateRunnerConfig, -} from "@/remote-manager-driver/api-endpoints"; +} from "@/engine-client/api-endpoints"; export async function configureServerlessPool( config: RegistryConfig, diff --git a/rivetkit-typescript/packages/rivetkit/src/serverless/router.ts b/rivetkit-typescript/packages/rivetkit/src/serverless/router.ts index a4ad2c5ba0..3989ef8e32 100644 --- a/rivetkit-typescript/packages/rivetkit/src/serverless/router.ts +++ b/rivetkit-typescript/packages/rivetkit/src/serverless/router.ts @@ -5,18 +5,16 @@ import { NamespaceMismatch, } from "@/actor/errors"; import { convertRegistryConfigToClientConfig } from "@/client/config"; +import { createClientWithDriver } from "@/client/client"; import { handleHealthRequest, handleMetadataRequest } from "@/common/router"; -import { ServerlessStartHeadersSchema } from "@/manager/router-schema"; -import { createClientWithDriver } from "@/mod"; -import type { DriverConfig, RegistryConfig } from "@/registry/config"; -import { RemoteManagerDriver } from "@/remote-manager-driver/mod"; +import { EngineActorDriver } from "@/drivers/engine/mod"; +import { RemoteEngineControlClient } from "@/engine-client/mod"; +import { ServerlessStartHeadersSchema } from "@/runtime-router/router-schema"; +import type { RegistryConfig } from "@/registry/config"; import { createRouter } from "@/utils/router"; import { logger } from "./log"; -export function buildServerlessRouter( - driverConfig: DriverConfig, - config: RegistryConfig, -) { +export function buildServerlessRouter(config: RegistryConfig) { return createRouter(config.serverless.basePath, (router) => { // GET / router.get("/", (c) => { @@ -87,20 +85,14 @@ export function buildServerlessRouter( token: config.token ?? token, }; - // Create manager driver on demand based on the properties provided - // by headers - // - // NOTE: This relies on the `runnerConfig.runner.runnerName` to - // configure which runner to create actors on. - const managerDriver = new RemoteManagerDriver( + const engineClient = new RemoteEngineControlClient( convertRegistryConfigToClientConfig(clientConfig), ); - const client = createClientWithDriver(managerDriver); + const client = createClientWithDriver(engineClient); - // Create new actor driver with updated config - const actorDriver = driverConfig.actor( + const actorDriver = new EngineActorDriver( runnerConfig, - managerDriver, + engineClient, client, ); invariant( diff --git a/rivetkit-typescript/packages/rivetkit/src/test/mod.ts b/rivetkit-typescript/packages/rivetkit/src/test/mod.ts index 1c0cb802d0..18b79a0698 100644 --- a/rivetkit-typescript/packages/rivetkit/src/test/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/test/mod.ts @@ -1,14 +1,8 @@ -import { serve as honoServe } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; import invariant from "invariant"; -import { type TestContext, vi } from "vitest"; -import { ClientConfigSchema } from "@/client/config"; +import { type TestContext } from "vitest"; import { type Client, createClient } from "@/client/mod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { createClientWithDriver, type Registry } from "@/mod"; -import { RegistryConfig, RegistryConfigSchema } from "@/registry/config"; -import { buildManagerRouter } from "@/manager/router"; -import { logger } from "./log"; +import { type Registry } from "@/mod"; +import { Runtime } from "../../runtime"; export interface SetupTestResult> { client: Client; @@ -19,80 +13,31 @@ export async function setupTest>( c: TestContext, registry: A, ): Promise> { - // Force enable test mode registry.config.test = { ...registry.config.test, enabled: true }; - - // Create driver - const driver = createFileSystemOrMemoryDriver(true, { - path: `/tmp/rivetkit-test-${crypto.randomUUID()}`, - }); - - // Build driver config - // biome-ignore lint/style/useConst: Assigned later - let upgradeWebSocket: any; - registry.config.driver = driver; + registry.config.serveManager = true; + registry.config.managerPort = 10_000 + Math.floor(Math.random() * 40_000); registry.config.inspector = { enabled: true, token: () => "token", }; - // Create router - const parsedConfig = registry.parseConfig(); - const managerDriver = driver.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - const getUpgradeWebSocket = () => upgradeWebSocket; - managerDriver.setGetUpgradeWebSocket(getUpgradeWebSocket); - // const internalClient = createClientWithDriver( - // managerDriver, - // ClientConfigSchema.parse({}), - // ); - const { router } = buildManagerRouter( - parsedConfig, - managerDriver, - getUpgradeWebSocket, - ); - - // Inject WebSocket - const nodeWebSocket = createNodeWebSocket({ app: router }); - upgradeWebSocket = nodeWebSocket.upgradeWebSocket; - - // TODO: I think this whole function is fucked, we should probably switch to calling registry.serve() directly - // Start server - const server = honoServe({ - fetch: router.fetch, - hostname: "127.0.0.1", - port: 0, - }); - if (!server.listening) { - await new Promise((resolve) => { - server.once("listening", () => resolve()); - }); - } - invariant( - nodeWebSocket.injectWebSocket !== undefined, - "should have injectWebSocket", - ); - nodeWebSocket.injectWebSocket(server); - const address = server.address(); - invariant(address && typeof address !== "string", "missing server address"); - const port = address.port; - const endpoint = `http://127.0.0.1:${port}`; - - logger().info({ msg: "test server listening", port }); + const runtime = await Runtime.create(registry); + await runtime.startEnvoy(); + await new Promise((resolve) => setTimeout(resolve, 250)); - // Cleanup on test finish - c.onTestFinished(async () => { - await new Promise((resolve) => server.close(() => resolve(undefined))); - }); + invariant(runtime.managerPort, "missing runtime manager port"); + const endpoint = `http://127.0.0.1:${runtime.managerPort}`; - // Create client const client = createClient({ endpoint, namespace: "default", poolName: "default", disableMetadataLookup: true, }); - c.onTestFinished(async () => await client.dispose()); + + c.onTestFinished(async () => { + await client.dispose(); + }); return { client }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/utils.ts b/rivetkit-typescript/packages/rivetkit/src/utils.ts index e2eea6a175..607dced9ba 100644 --- a/rivetkit-typescript/packages/rivetkit/src/utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/utils.ts @@ -409,7 +409,7 @@ export function arrayBuffersEqual( } export const EXTRA_ERROR_LOG = { - issues: "https://github.com/rivet-dev/rivetkit/issues", + issues: "https://github.com/rivet-dev/rivet/issues", support: "https://rivet.dev/discord", version: VERSION, }; diff --git a/rivetkit-typescript/packages/rivetkit/src/workflow/driver.ts b/rivetkit-typescript/packages/rivetkit/src/workflow/driver.ts index 1cc25ad3ea..73c4db124d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/workflow/driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/workflow/driver.ts @@ -1,5 +1,5 @@ import type { RunContext } from "@/actor/contexts/run"; -import type { AnyStaticActorInstance } from "@/actor/instance/mod"; +import type { AnyActorInstance, AnyStaticActorInstance } from "@/actor/instance/mod"; import { makeWorkflowKey, workflowStoragePrefix } from "@/actor/instance/keys"; import type { EngineDriver, @@ -251,9 +251,9 @@ export class ActorWorkflowControlDriver implements EngineDriver { readonly workerPollInterval = 100; readonly messageDriver: WorkflowMessageDriver = new NoopWorkflowMessageDriver(); - #actor: AnyActorInstance; + #actor: AnyStaticActorInstance; - constructor(actor: AnyActorInstance) { + constructor(actor: AnyStaticActorInstance) { this.#actor = actor; } diff --git a/rivetkit-typescript/packages/rivetkit/src/workflow/mod.ts b/rivetkit-typescript/packages/rivetkit/src/workflow/mod.ts index f814c4a95d..a73757344b 100644 --- a/rivetkit-typescript/packages/rivetkit/src/workflow/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/workflow/mod.ts @@ -1,7 +1,7 @@ import { ACTOR_CONTEXT_INTERNAL_SYMBOL } from "@/actor/contexts/base/actor"; import type { RunContext } from "@/actor/contexts/run"; import type { AnyDatabaseProvider } from "@/actor/database"; -import type { AnyStaticActorInstance } from "@/actor/instance/mod"; +import type { AnyActorInstance, AnyStaticActorInstance } from "@/actor/instance/mod"; import type { EventSchemaConfig, QueueSchemaConfig } from "@/actor/schema"; import { RUN_FUNCTION_CONFIG_SYMBOL } from "@/actor/config"; import { stringifyError } from "@/utils"; @@ -19,8 +19,8 @@ import { type WorkflowErrorEvent, } from "@rivetkit/workflow-engine"; import invariant from "invariant"; -import { ActorWorkflowContext } from "./context"; import { ActorWorkflowControlDriver, ActorWorkflowDriver } from "./driver"; +import { ActorWorkflowContext } from "./context"; import { createWorkflowInspectorAdapter } from "./inspector"; export { Loop } from "@rivetkit/workflow-engine"; diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts index 0923aee766..cb4e8e4ef9 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts @@ -4,11 +4,11 @@ import { ClientConfigSchema, DEFAULT_MAX_QUERY_INPUT_SIZE, } from "@/client/config"; -import { parseActorPath } from "@/manager/gateway"; +import { parseActorPath } from "@/actor-gateway/gateway"; import { buildActorGatewayUrl, buildActorQueryGatewayUrl, -} from "@/remote-manager-driver/actor-websocket-client"; +} from "@/engine-client/actor-websocket-client"; import { toBase64Url } from "./test-utils"; describe("gateway URL builders", () => { diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-resolution.test.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-resolution.test.ts index 32e81ea753..f67829d97d 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/actor-resolution.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-resolution.test.ts @@ -3,7 +3,7 @@ import { ClientRaw } from "@/client/client"; import type { ActorOutput, GatewayTarget, - ManagerDriver, + EngineControlClient, } from "@/driver-helpers/mod"; import { PATH_CONNECT } from "@/driver-helpers/mod"; @@ -293,7 +293,7 @@ describe("actor resolution flow", () => { }); }); -function createMockDriver(overrides: Partial): ManagerDriver { +function createMockDriver(overrides: Partial): EngineControlClient { return { getForId: async () => undefined, getWithKey: async () => undefined, @@ -319,6 +319,10 @@ function createMockDriver(overrides: Partial): ManagerDriver { displayInformation: () => ({ properties: {} }), setGetUpgradeWebSocket: () => {}, kvGet: async () => null, + kvBatchGet: async (_actorId, keys) => keys.map(() => null), + kvBatchPut: async () => {}, + kvBatchDelete: async () => {}, + kvDeleteRange: async () => {}, ...overrides, }; } diff --git a/rivetkit-typescript/packages/rivetkit/tests/db-closed-race.test.ts b/rivetkit-typescript/packages/rivetkit/tests/db-closed-race.test.ts deleted file mode 100644 index 1a11ed0f1c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/db-closed-race.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { join } from "node:path"; -import { createClient } from "@/client/mod"; -import { createTestRuntime } from "@/driver-test-suite/mod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { describe, expect, test, vi } from "vitest"; -import type { registry } from "../fixtures/db-closed-race/registry"; -import { collectedErrors } from "../fixtures/db-closed-race/registry"; - -describe("database closed race condition", () => { - test("setInterval tick gets actionable error after destroy", async () => { - const runtime = await createTestRuntime( - join(__dirname, "../fixtures/db-closed-race/registry.ts"), - async () => { - return { - driver: createFileSystemOrMemoryDriver(true, { - path: `/tmp/test-db-closed-race-${crypto.randomUUID()}`, - }), - }; - }, - ); - - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - poolName: runtime.runnerName, - disableMetadataLookup: true, - }); - - // Clear any errors from previous runs - collectedErrors.length = 0; - - try { - const actor = client.dbClosedRaceActor.getOrCreate([ - `race-${crypto.randomUUID()}`, - ]); - - // Wait for ticks to confirm the interval is running and db works - await vi.waitFor( - async () => { - const count = await actor.getTickCount(); - expect(count).toBeGreaterThanOrEqual(3); - }, - { timeout: 5000, interval: 50 }, - ); - - // No errors before destroy - expect(collectedErrors).toHaveLength(0); - - // Destroy the actor. The cached db reference is now closed, - // but the interval keeps firing. - await actor.destroy(); - - // Wait for orphaned interval ticks to hit the closed database - await new Promise((resolve) => setTimeout(resolve, 300)); - - // The orphaned interval should have hit the improved error - expect(collectedErrors.length).toBeGreaterThan(0); - expect( - collectedErrors.some((e) => e.includes("Database is closed")), - ).toBe(true); - expect( - collectedErrors.some((e) => e.includes("c.abortSignal")), - ).toBe(true); - } finally { - await client.dispose().catch(() => undefined); - await runtime.cleanup(); - } - }); -}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver-engine-dynamic.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver-engine-dynamic.test.ts deleted file mode 100644 index 2c7cd5b73d..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver-engine-dynamic.test.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { createClient } from "@/client/mod"; -import { createTestRuntime } from "@/driver-test-suite/mod"; -import { createEngineDriver } from "@/drivers/engine/mod"; -import invariant from "invariant"; -import { createClientWithDriver } from "@/client/client"; -import { convertRegistryConfigToClientConfig } from "@/client/config"; -import { afterEach, describe, expect, test } from "vitest"; -import { DYNAMIC_SOURCE } from "../fixtures/driver-test-suite/dynamic-registry"; -import type { registry } from "../fixtures/driver-test-suite/dynamic-registry"; - -const SECURE_EXEC_DIST_PATH = join( - process.env.HOME ?? "", - "secure-exec-rivet/packages/secure-exec/dist/index.js", -); -const hasSecureExecDist = existsSync(SECURE_EXEC_DIST_PATH); -const hasEngineEndpointEnv = !!( - process.env.RIVET_ENDPOINT || - process.env.RIVET_NAMESPACE_ENDPOINT || - process.env.RIVET_API_ENDPOINT -); -const initialDynamicSourceUrlEnv = - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL; -const initialSecureExecSpecifierEnv = - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER; - -type DynamicHandle = { - increment: (amount?: number) => Promise; - getSourceCodeLength: () => Promise; - getState: () => Promise<{ - count: number; - wakeCount: number; - sleepCount: number; - alarmCount: number; - }>; - putText: (key: string, value: string) => Promise; - getText: (key: string) => Promise; - listText: (prefix: string) => Promise>; - triggerSleep: () => Promise; - scheduleAlarm: (duration: number) => Promise; - webSocket: (path?: string) => Promise; -}; - -type DynamicAuthHandle = DynamicHandle & { - fetch: (input: string | URL | Request, init?: RequestInit) => Promise; -}; - -describe.skipIf(!hasSecureExecDist || !hasEngineEndpointEnv)( - "engine dynamic actor runtime", - () => { - let sourceServer: - | { - url: string; - close: () => Promise; - } - | undefined; - - afterEach(async () => { - if (sourceServer) { - await sourceServer.close(); - sourceServer = undefined; - } - if (initialDynamicSourceUrlEnv === undefined) { - delete process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL; - } else { - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = - initialDynamicSourceUrlEnv; - } - if (initialSecureExecSpecifierEnv === undefined) { - delete process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER; - } else { - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = - initialSecureExecSpecifierEnv; - } - }); - - test("loads dynamic actor source from URL", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicEngineRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - const bareClient = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "bare", - disableMetadataLookup: true, - }); - - try { - const actor = client.dynamicFromUrl.getOrCreate([ - "url-loader", - ]) as unknown as DynamicHandle; - expect(await actor.increment(2)).toBe(2); - expect(await actor.increment(3)).toBe(5); - expect(await actor.getSourceCodeLength()).toBeGreaterThan(0); - - const bareActor = bareClient.dynamicFromUrl.getOrCreate([ - "url-loader", - ]) as unknown as DynamicHandle; - expect(await bareActor.increment(1)).toBe(6); - - const state = await actor.getState(); - expect(state.count).toBe(6); - expect(state.wakeCount).toBeGreaterThanOrEqual(1); - } finally { - await client.dispose(); - await bareClient.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("supports actions, kv, websockets, alarms, and sleep/wake from actor-loaded source", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicEngineRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - let ws: WebSocket | undefined; - - try { - const actor = client.dynamicFromActor.getOrCreate([ - "actor-loader", - ]) as unknown as DynamicHandle; - - expect(await actor.increment(1)).toBe(1); - expect(await actor.getSourceCodeLength()).toBeGreaterThan(0); - - await actor.putText("prefix-a", "alpha"); - await actor.putText("prefix-b", "beta"); - expect(await actor.getText("prefix-a")).toBe("alpha"); - expect( - (await actor.listText("prefix-")).sort((a, b) => - a.key.localeCompare(b.key), - ), - ).toEqual([ - { key: "prefix-a", value: "alpha" }, - { key: "prefix-b", value: "beta" }, - ]); - - ws = await actor.webSocket(); - const welcome = await readWebSocketJson(ws); - expect(welcome).toMatchObject({ type: "welcome" }); - ws.send(JSON.stringify({ type: "ping" })); - expect(await readWebSocketJson(ws)).toEqual({ type: "pong" }); - ws.close(); - ws = undefined; - - const beforeSleep = await actor.getState(); - await actor.triggerSleep(); - await wait(350); - - const afterSleep = await actor.getState(); - expect(afterSleep.sleepCount).toBeGreaterThanOrEqual( - beforeSleep.sleepCount + 1, - ); - expect(afterSleep.wakeCount).toBeGreaterThanOrEqual( - beforeSleep.wakeCount + 1, - ); - - const beforeAlarm = await actor.getState(); - await actor.scheduleAlarm(500); - await wait(900); - - const afterAlarm = await actor.getState(); - expect(afterAlarm.alarmCount).toBeGreaterThanOrEqual( - beforeAlarm.alarmCount + 1, - ); - expect(afterAlarm.sleepCount).toBeGreaterThanOrEqual( - beforeAlarm.sleepCount + 1, - ); - expect(afterAlarm.wakeCount).toBeGreaterThanOrEqual( - beforeAlarm.wakeCount + 1, - ); - } finally { - ws?.close(); - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("authenticates dynamic actor actions, raw requests, and websockets", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicEngineRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - let ws: WebSocket | undefined; - - try { - const unauthorized = client.dynamicWithAuth.getOrCreate([ - "auth-unauthorized", - ]) as unknown as DynamicAuthHandle; - await expect(unauthorized.increment(1)).rejects.toMatchObject({ - group: "user", - code: "unauthorized", - }); - - const unauthorizedResponse = await unauthorized.fetch("/auth"); - expect(unauthorizedResponse.status).toBe(400); - expect(await unauthorizedResponse.json()).toMatchObject({ - group: "user", - code: "unauthorized", - }); - - const headerAuthorized = client.dynamicWithAuth.getOrCreate([ - "auth-header", - ]) as unknown as DynamicAuthHandle; - const headerResponse = await headerAuthorized.fetch("/auth", { - headers: { - "x-dynamic-auth": "allow", - }, - }); - expect(headerResponse.status).toBe(200); - expect(await headerResponse.json()).toEqual({ - method: "GET", - token: "allow", - }); - - const paramsAuthorized = client.dynamicWithAuth.getOrCreate( - ["auth-params"], - { - params: { - token: "allow", - }, - }, - ) as unknown as DynamicAuthHandle; - expect(await paramsAuthorized.increment(1)).toBe(1); - - ws = await paramsAuthorized.webSocket(); - expect(await readWebSocketJson(ws)).toMatchObject({ - type: "welcome", - }); - } finally { - ws?.close(); - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - }, -); - -async function createDynamicEngineRuntime() { - return await createTestRuntime( - join(__dirname, "../fixtures/driver-test-suite/dynamic-registry.ts"), - async (registry) => { - const endpoint = process.env.RIVET_ENDPOINT || "http://127.0.0.1:6420"; - const namespaceEndpoint = - process.env.RIVET_NAMESPACE_ENDPOINT || - process.env.RIVET_API_ENDPOINT || - endpoint; - const namespace = `test-${crypto.randomUUID().slice(0, 8)}`; - const runnerName = "test-runner"; - const token = "dev"; - - const response = await fetch(`${namespaceEndpoint}/namespaces`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer dev", - }, - body: JSON.stringify({ - name: namespace, - display_name: namespace, - }), - }); - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - throw new Error( - `Create namespace failed at ${namespaceEndpoint}: ${response.status} ${response.statusText} ${errorBody}`, - ); - } - - const driverConfig = createEngineDriver(); - registry.config.driver = driverConfig; - registry.config.endpoint = endpoint; - registry.config.namespace = namespace; - registry.config.token = token; - registry.config.runner = { - ...registry.config.runner, - runnerName, - }; - - const parsedConfig = registry.parseConfig(); - const managerDriver = driverConfig.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - const inlineClient = createClientWithDriver( - managerDriver, - convertRegistryConfigToClientConfig(parsedConfig), - ); - const actorDriver = driverConfig.actor( - parsedConfig, - managerDriver, - inlineClient, - ); - - const runnersUrl = new URL(`${endpoint.replace(/\/$/, "")}/runners`); - runnersUrl.searchParams.set("namespace", namespace); - runnersUrl.searchParams.set("name", runnerName); - let probeError: unknown; - for (let attempt = 0; attempt < 120; attempt++) { - try { - const runnerResponse = await fetch(runnersUrl, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - if (!runnerResponse.ok) { - const errorBody = await runnerResponse.text().catch(() => ""); - probeError = new Error( - `List runners failed: ${runnerResponse.status} ${runnerResponse.statusText} ${errorBody}`, - ); - } else { - const responseJson = (await runnerResponse.json()) as { - runners?: Array<{ name?: string }>; - }; - const hasRunner = !!responseJson.runners?.some( - (runner) => runner.name === runnerName, - ); - if (hasRunner) { - probeError = undefined; - break; - } - probeError = new Error( - `Runner ${runnerName} not registered yet`, - ); - } - } catch (err) { - probeError = err; - } - if (attempt < 119) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - if (probeError) { - throw probeError; - } - - return { - rivetEngine: { - endpoint, - namespace, - runnerName, - token, - }, - driver: driverConfig, - cleanup: async () => { - await actorDriver.shutdownRunner?.(true); - }, - }; - }, - ); -} - -async function startSourceServer(source: string): Promise<{ - url: string; - close: () => Promise; -}> { - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - if (req.url !== "/source.ts") { - res.writeHead(404); - res.end("not found"); - return; - } - - res.writeHead(200, { - "content-type": "text/plain; charset=utf-8", - }); - res.end(source); - }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("failed to get dynamic source server address"); - } - - return { - url: `http://127.0.0.1:${address.port}/source.ts`, - close: async () => { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - }, - }; -} - -async function readWebSocketJson(websocket: WebSocket): Promise { - const message = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("timed out waiting for websocket message")); - }, 5_000); - - websocket.addEventListener( - "message", - (event) => { - clearTimeout(timeout); - resolve(String(event.data)); - }, - { once: true }, - ); - websocket.addEventListener( - "error", - (event: Event) => { - clearTimeout(timeout); - reject(event); - }, - { once: true }, - ); - websocket.addEventListener( - "close", - () => { - clearTimeout(timeout); - reject(new Error("websocket closed")); - }, - { once: true }, - ); - }); - - return JSON.parse(message); -} - -async function wait(duration: number): Promise { - await new Promise((resolve) => setTimeout(resolve, duration)); -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver-engine.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver-engine.test.ts deleted file mode 100644 index c1c394840e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver-engine.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { createClientWithDriver } from "@/client/client"; -import { createTestRuntime, runDriverTests } from "@/driver-test-suite/mod"; -import { createEngineDriver } from "@/drivers/engine/mod"; -import invariant from "invariant"; -import { convertRegistryConfigToClientConfig } from "@/client/config"; -import { describe } from "vitest"; -import { getDriverRegistryVariants } from "./driver-registry-variants"; - -for (const registryVariant of getDriverRegistryVariants(__dirname)) { - const describeVariant = registryVariant.skip - ? describe.skip - : describe.sequential; - const variantName = registryVariant.skipReason - ? `${registryVariant.name} (${registryVariant.skipReason})` - : registryVariant.name; - - describeVariant(`registry (${variantName})`, () => { - runDriverTests({ - // Use real timers for engine-runner tests - useRealTimers: true, - isDynamic: registryVariant.name === "dynamic", - features: { - hibernatableWebSocketProtocol: true, - }, - skip: { - // The inline client is the same as the remote client driver on Rivet - inline: true, - }, - async start() { - return await createTestRuntime( - registryVariant.registryPath, - async (registry) => { - // Get configuration from environment or use defaults. - const endpoint = - process.env.RIVET_ENDPOINT || - "http://127.0.0.1:6420"; - const namespaceEndpoint = - process.env.RIVET_NAMESPACE_ENDPOINT || - process.env.RIVET_API_ENDPOINT || - endpoint; - const namespace = `test-${crypto.randomUUID().slice(0, 8)}`; - const runnerName = "test-runner"; - const token = "dev"; - - // Create namespace. - const response = await fetch( - `${namespaceEndpoint}/namespaces`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer dev", - }, - body: JSON.stringify({ - name: namespace, - display_name: namespace, - }), - }, - ); - if (!response.ok) { - const errorBody = await response - .text() - .catch(() => ""); - throw new Error( - `Create namespace failed at ${namespaceEndpoint}: ${response.status} ${response.statusText} ${errorBody}`, - ); - } - - // Create driver config. - const driverConfig = createEngineDriver(); - - // Start the actor driver. - registry.config.driver = driverConfig; - registry.config.endpoint = endpoint; - registry.config.namespace = namespace; - registry.config.token = token; - registry.config.envoy = { - ...registry.config.envoy, - poolName: runnerName, - }; - - // Parse config only after mutating registry.config so the manager - // and actor drivers do not get stale namespace/runner values from - // previous tests. - const parsedConfig = registry.parseConfig(); - - const managerDriver = - driverConfig.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - const inlineClient = createClientWithDriver( - managerDriver, - convertRegistryConfigToClientConfig(parsedConfig), - ); - - const actorDriver = driverConfig.actor( - parsedConfig, - managerDriver, - inlineClient, - ); - - // Wait for runner registration so tests do not race actor creation - // against asynchronous runner connect. - const runnersUrl = new URL( - `${endpoint.replace(/\/$/, "")}/runners`, - ); - runnersUrl.searchParams.set("namespace", namespace); - runnersUrl.searchParams.set("name", runnerName); - let probeError: unknown; - for (let attempt = 0; attempt < 120; attempt++) { - try { - const runnerResponse = await fetch(runnersUrl, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!runnerResponse.ok) { - const errorBody = await runnerResponse - .text() - .catch(() => ""); - probeError = new Error( - `List runners failed: ${runnerResponse.status} ${runnerResponse.statusText} ${errorBody}`, - ); - } else { - const responseJson = - (await runnerResponse.json()) as { - runners?: Array<{ name?: string }>; - }; - const hasRunner = - !!responseJson.runners?.some( - (runner) => - runner.name === runnerName, - ); - if (hasRunner) { - probeError = undefined; - break; - } - probeError = new Error( - `Runner ${runnerName} not registered yet`, - ); - } - } catch (err) { - probeError = err; - } - if (attempt < 119) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - } - } - if (probeError) { - throw probeError; - } - - return { - rivetEngine: { - endpoint, - namespace, - runnerName, - token, - }, - driver: driverConfig, - cleanup: async () => { - await actorDriver.shutdownRunner?.(true); - }, - }; - }, - ); - }, - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver-file-system.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver-file-system.test.ts deleted file mode 100644 index d109a986b1..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver-file-system.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { existsSync } from "node:fs"; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from "node:http"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { createClient } from "@/client/mod"; -import { createTestRuntime, runDriverTests } from "@/driver-test-suite/mod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { - DYNAMIC_SOURCE, - type registry as dynamicRegistry, -} from "../fixtures/driver-test-suite/dynamic-registry"; -import type { registry as staticRegistry } from "../fixtures/driver-test-suite/registry"; -import { getDriverRegistryVariants } from "./driver-registry-variants"; - -for (const registryVariant of getDriverRegistryVariants(__dirname)) { - const describeVariant = registryVariant.skip - ? describe.skip - : describe.sequential; - const variantName = registryVariant.skipReason - ? `${registryVariant.name} (${registryVariant.skipReason})` - : registryVariant.name; - - describeVariant(`registry (${variantName})`, () => { - runDriverTests({ - skip: { - // Does not support full connection hibernation semantics. - hibernation: true, - }, - isDynamic: registryVariant.name === "dynamic", - features: { - hibernatableWebSocketProtocol: true, - }, - // TODO: Remove this once timer issues are fixed in actor-sleep.ts - useRealTimers: true, - async start() { - return await createTestRuntime( - registryVariant.registryPath, - async () => { - return { - driver: createFileSystemOrMemoryDriver(true, { - path: `/tmp/test-${crypto.randomUUID()}`, - }), - }; - }, - ); - }, - }); - - describe("file-system websocket hibernation cleanup", () => { - test("calls onDisconnect for restored hibernatable websocket connections", async () => { - const storagePath = `/tmp/test-${crypto.randomUUID()}`; - const runtime = await createTestRuntime( - registryVariant.registryPath, - async () => { - return { - driver: createFileSystemOrMemoryDriver(true, { - path: storagePath, - }), - }; - }, - ); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - disableMetadataLookup: true, - }); - const conn = client.fileSystemHibernationCleanupActor - .getOrCreate() - .connect(); - - try { - expect(await conn.ping()).toBe("pong"); - await conn.triggerSleep(); - - // Any action call will wake the actor. This wait ensures the sleep - // cycle completed before validating disconnect cleanup. - await vi.waitFor( - async () => { - const counts = - await client.fileSystemHibernationCleanupActor - .getOrCreate() - .getCounts(); - expect(counts.sleepCount).toBeGreaterThanOrEqual(1); - expect(counts.wakeCount).toBeGreaterThanOrEqual(2); - }, - { timeout: 5000, interval: 100 }, - ); - - await vi.waitFor( - async () => { - const disconnectWakeCounts = - await client.fileSystemHibernationCleanupActor - .getOrCreate() - .getDisconnectWakeCounts(); - expect(disconnectWakeCounts).toEqual([2]); - }, - { timeout: 5000, interval: 100 }, - ); - } finally { - await conn.dispose().catch(() => undefined); - await client.dispose().catch(() => undefined); - await runtime.cleanup(); - } - }); - }); - }); -} - -const SECURE_EXEC_DIST_PATH = join( - process.env.HOME ?? "", - "secure-exec-rivet/packages/secure-exec/dist/index.js", -); -const hasSecureExecDist = existsSync(SECURE_EXEC_DIST_PATH); -const initialDynamicSourceUrlEnv = process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL; -const initialSecureExecSpecifierEnv = - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER; - -type DynamicHandle = { - increment: (amount?: number) => Promise; - getSourceCodeLength: () => Promise; - getState: () => Promise<{ - count: number; - wakeCount: number; - sleepCount: number; - alarmCount: number; - }>; - putText: (key: string, value: string) => Promise; - getText: (key: string) => Promise; - listText: ( - prefix: string, - ) => Promise>; - triggerSleep: () => Promise; - scheduleAlarm: (duration: number) => Promise; - reload: () => Promise; - webSocket: (path?: string) => Promise; -}; - -type DynamicAuthHandle = DynamicHandle & { - fetch: ( - input: string | URL | Request, - init?: RequestInit, - ) => Promise; -}; - -describe.skipIf(!hasSecureExecDist)("file-system dynamic actor runtime", () => { - let sourceServer: - | { - url: string; - close: () => Promise; - } - | undefined; - - afterEach(async () => { - if (sourceServer) { - await sourceServer.close(); - sourceServer = undefined; - } - if (initialDynamicSourceUrlEnv === undefined) { - delete process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL; - } else { - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = - initialDynamicSourceUrlEnv; - } - if (initialSecureExecSpecifierEnv === undefined) { - delete process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER; - } else { - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = - initialSecureExecSpecifierEnv; - } - }); - - test("loads dynamic actor source from URL", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - const bareClient = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "bare", - disableMetadataLookup: true, - }); - - try { - const actor = client.dynamicFromUrl.getOrCreate([ - "url-loader", - ]) as unknown as DynamicHandle; - expect(await actor.increment(2)).toBe(2); - expect(await actor.increment(3)).toBe(5); - expect(await actor.getSourceCodeLength()).toBeGreaterThan(0); - - const bareActor = bareClient.dynamicFromUrl.getOrCreate([ - "url-loader", - ]) as unknown as DynamicHandle; - expect(await bareActor.increment(1)).toBe(6); - - const state = await actor.getState(); - expect(state.count).toBe(6); - expect(state.wakeCount).toBeGreaterThanOrEqual(1); - } finally { - await client.dispose(); - await bareClient.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("supports actions, kv, websockets, alarms, and sleep/wake from actor-loaded source", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - let ws: WebSocket | undefined; - - try { - const actor = client.dynamicFromActor.getOrCreate([ - "actor-loader", - ]) as unknown as DynamicHandle; - - expect(await actor.increment(1)).toBe(1); - expect(await actor.getSourceCodeLength()).toBeGreaterThan(0); - - await actor.putText("prefix-a", "alpha"); - await actor.putText("prefix-b", "beta"); - expect(await actor.getText("prefix-a")).toBe("alpha"); - expect( - (await actor.listText("prefix-")).sort((a, b) => - a.key.localeCompare(b.key), - ), - ).toEqual([ - { key: "prefix-a", value: "alpha" }, - { key: "prefix-b", value: "beta" }, - ]); - - ws = await actor.webSocket(); - const welcome = await readWebSocketJson(ws); - expect(welcome).toMatchObject({ type: "welcome" }); - ws.send(JSON.stringify({ type: "ping" })); - expect(await readWebSocketJson(ws)).toEqual({ type: "pong" }); - ws.close(); - ws = undefined; - - const beforeSleep = await actor.getState(); - await actor.triggerSleep(); - await wait(350); - - const afterSleep = await actor.getState(); - expect(afterSleep.sleepCount).toBeGreaterThanOrEqual( - beforeSleep.sleepCount + 1, - ); - expect(afterSleep.wakeCount).toBeGreaterThanOrEqual( - beforeSleep.wakeCount + 1, - ); - - const beforeAlarm = await actor.getState(); - await actor.scheduleAlarm(500); - await wait(900); - - const afterAlarm = await actor.getState(); - expect(afterAlarm.sleepCount).toBeGreaterThanOrEqual( - beforeAlarm.sleepCount, - ); - expect(afterAlarm.wakeCount).toBeGreaterThanOrEqual( - beforeAlarm.wakeCount, - ); - } finally { - ws?.close(); - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("reload forces dynamic actor to sleep and reload with fresh code", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - try { - const actor = client.dynamicFromUrl.getOrCreate([ - "reload-test", - ]) as unknown as DynamicHandle; - const initialState = await actor.getState(); - const initialWake = initialState.wakeCount; - - await actor.reload(); - await wait(350); - - const afterState = await actor.getState(); - expect(afterState.wakeCount).toBeGreaterThanOrEqual( - initialWake + 1, - ); - expect(afterState.sleepCount).toBeGreaterThanOrEqual( - initialState.sleepCount + 1, - ); - } finally { - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("surfaces loader throws as internal actor errors", async () => { - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - try { - const actor = client.dynamicLoaderThrows.getOrCreate([ - "loader-throws", - ]) as unknown as { ping: () => Promise }; - await actor.ping(); - expect.fail("expected loader throw to fail actor calls"); - } catch (error: any) { - expect(error.code).toBe(INTERNAL_ERROR_CODE); - expect(error.message).toBe(INTERNAL_ERROR_DESCRIPTION); - } finally { - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("surfaces invalid dynamic source as internal actor errors", async () => { - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - try { - const actor = client.dynamicInvalidSource.getOrCreate([ - "invalid-source", - ]) as unknown as { ping: () => Promise }; - await actor.ping(); - expect.fail("expected invalid source to fail actor calls"); - } catch (error: any) { - expect(error.code).toBe(INTERNAL_ERROR_CODE); - expect(error.message).toBe(INTERNAL_ERROR_DESCRIPTION); - } finally { - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); - - test("authenticates dynamic actor actions, raw requests, and websockets", async () => { - sourceServer = await startSourceServer(DYNAMIC_SOURCE); - process.env.RIVETKIT_DYNAMIC_TEST_SOURCE_URL = sourceServer.url; - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - SECURE_EXEC_DIST_PATH, - ).href; - - const runtime = await createDynamicRuntime(); - const client = createClient({ - endpoint: runtime.endpoint, - namespace: runtime.namespace, - runnerName: runtime.runnerName, - encoding: "json", - disableMetadataLookup: true, - }); - - let ws: WebSocket | undefined; - - try { - const unauthorized = client.dynamicWithAuth.getOrCreate([ - "auth-unauthorized", - ]) as unknown as DynamicAuthHandle; - await expect(unauthorized.increment(1)).rejects.toMatchObject({ - group: "user", - code: "unauthorized", - }); - - const unauthorizedResponse = await unauthorized.fetch("/auth"); - expect(unauthorizedResponse.status).toBe(400); - expect(await unauthorizedResponse.json()).toMatchObject({ - group: "user", - code: "unauthorized", - }); - - const headerAuthorized = client.dynamicWithAuth.getOrCreate([ - "auth-header", - ]) as unknown as DynamicAuthHandle; - const headerResponse = await headerAuthorized.fetch("/auth", { - headers: { - "x-dynamic-auth": "allow", - }, - }); - expect(headerResponse.status).toBe(200); - expect(await headerResponse.json()).toEqual({ - method: "GET", - token: "allow", - }); - - const paramsAuthorized = client.dynamicWithAuth.getOrCreate( - ["auth-params"], - { - params: { - token: "allow", - }, - }, - ) as unknown as DynamicAuthHandle; - expect(await paramsAuthorized.increment(1)).toBe(1); - - ws = await paramsAuthorized.webSocket(); - expect(await readWebSocketJson(ws)).toMatchObject({ - type: "welcome", - }); - } finally { - ws?.close(); - await client.dispose(); - await runtime.cleanup(); - } - }, 180_000); -}); - -async function createDynamicRuntime() { - return await createTestRuntime( - join(__dirname, "../fixtures/driver-test-suite/dynamic-registry.ts"), - async () => { - return { - driver: createFileSystemOrMemoryDriver(true, { - path: `/tmp/test-dynamic-${crypto.randomUUID()}`, - }), - }; - }, - ); -} - -async function startSourceServer(source: string): Promise<{ - url: string; - close: () => Promise; -}> { - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - if (req.url !== "/source.ts") { - res.writeHead(404); - res.end("not found"); - return; - } - - res.writeHead(200, { - "content-type": "text/plain; charset=utf-8", - }); - res.end(source); - }); - - await new Promise((resolve) => - server.listen(0, "127.0.0.1", resolve), - ); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("failed to get dynamic source server address"); - } - - return { - url: `http://127.0.0.1:${address.port}/source.ts`, - close: async () => { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - }, - }; -} - -async function readWebSocketJson(websocket: WebSocket): Promise { - const message = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("timed out waiting for websocket message")); - }, 5_000); - - websocket.addEventListener( - "message", - (event) => { - clearTimeout(timeout); - resolve(String(event.data)); - }, - { once: true }, - ); - websocket.addEventListener( - "error", - (event: Event) => { - clearTimeout(timeout); - reject(event); - }, - { once: true }, - ); - websocket.addEventListener( - "close", - () => { - clearTimeout(timeout); - reject(new Error("websocket closed")); - }, - { once: true }, - ); - }); - - return JSON.parse(message); -} - -async function wait(durationMs: number): Promise { - await new Promise((resolve) => setTimeout(resolve, durationMs)); -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver-memory.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver-memory.test.ts deleted file mode 100644 index f9e696cc27..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver-memory.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createTestRuntime, runDriverTests } from "@/driver-test-suite/mod"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { describe } from "vitest"; -import { getDriverRegistryVariants } from "./driver-registry-variants"; - -for (const registryVariant of getDriverRegistryVariants(__dirname)) { - const describeVariant = registryVariant.skip - ? describe.skip - : describe.sequential; - const variantName = registryVariant.skipReason - ? `${registryVariant.name} (${registryVariant.skipReason})` - : registryVariant.name; - - describeVariant(`registry (${variantName})`, () => { - runDriverTests({ - // TODO: Remove this once timer issues are fixed in actor-sleep.ts - useRealTimers: true, - isDynamic: registryVariant.name === "dynamic", - features: { - hibernatableWebSocketProtocol: false, - }, - skip: { - // Sleeping not enabled in memory - sleep: true, - hibernation: true, - }, - async start() { - return await createTestRuntime( - registryVariant.registryPath, - async () => { - return { - driver: await createFileSystemOrMemoryDriver(false), - }; - }, - ); - }, - }); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver-registry-variants.ts b/rivetkit-typescript/packages/rivetkit/tests/driver-registry-variants.ts deleted file mode 100644 index acf6a07cbc..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver-registry-variants.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; - -export interface DriverRegistryVariant { - name: "static" | "dynamic"; - registryPath: string; - skip: boolean; - skipReason?: string; -} - -const SECURE_EXEC_DIST_CANDIDATE_PATHS = [ - join( - process.env.HOME ?? "", - "secure-exec-rivet/packages/secure-exec/dist/index.js", - ), - join( - process.env.HOME ?? "", - "secure-exec-rivet/packages/sandboxed-node/dist/index.js", - ), -]; - -function resolveSecureExecDistPath(): string | undefined { - for (const candidatePath of SECURE_EXEC_DIST_CANDIDATE_PATHS) { - if (existsSync(candidatePath)) { - return candidatePath; - } - } - return undefined; -} - -function getDynamicVariantSkipReason(): string | undefined { - if (process.env.RIVETKIT_DRIVER_TEST_SKIP_DYNAMIC_IN_DYNAMIC === "1") { - return "Dynamic registry parity is skipped for this nested dynamic harness only. We still target full static and dynamic runtime compatibility for all normal driver suites."; - } - - if (process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER) { - return undefined; - } - - const secureExecDistPath = resolveSecureExecDistPath(); - if (!secureExecDistPath) { - return `Dynamic registry parity requires secure-exec dist at one of: ${SECURE_EXEC_DIST_CANDIDATE_PATHS.join(", ")}.`; - } - - process.env.RIVETKIT_DYNAMIC_SECURE_EXEC_SPECIFIER = pathToFileURL( - secureExecDistPath, - ).href; - - return undefined; -} - -export function getDriverRegistryVariants(currentDir: string): DriverRegistryVariant[] { - const dynamicSkipReason = getDynamicVariantSkipReason(); - - return [ - { - name: "static", - registryPath: join( - currentDir, - "../fixtures/driver-test-suite/registry-static.ts", - ), - skip: false, - }, - { - name: "dynamic", - registryPath: join( - currentDir, - "../fixtures/driver-test-suite/registry-dynamic.ts", - ), - skip: dynamicSkipReason !== undefined, - skipReason: dynamicSkipReason, - }, - ]; -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/file-system-gateway-query.test.ts b/rivetkit-typescript/packages/rivetkit/tests/file-system-gateway-query.test.ts deleted file mode 100644 index 68a139042c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/file-system-gateway-query.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { createServer } from "node:net"; -import { join } from "node:path"; -import { serve as honoServe } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import invariant from "invariant"; -import { afterEach, describe, expect, test } from "vitest"; -import { createClientWithDriver } from "@/client/client"; -import { createFileSystemOrMemoryDriver } from "@/drivers/file-system/mod"; -import { buildManagerRouter } from "@/manager/router"; -import { registry } from "../fixtures/driver-test-suite/registry"; - -describe.sequential("file-system manager gateway query routing", () => { - const cleanups: Array<() => Promise> = []; - - afterEach(async () => { - while (cleanups.length > 0) { - await cleanups.pop()?.(); - } - }); - - test("getOrCreate gateway URLs use rvt-* query params for the local manager", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const gatewayUrl = await runtime.client.counter - .getOrCreate(["gateway-query"]) - .getGatewayUrl(); - - const parsedUrl = new URL(gatewayUrl); - expect(parsedUrl.searchParams.get("rvt-namespace")).toBe("default"); - expect(parsedUrl.searchParams.get("rvt-method")).toBe("getOrCreate"); - expect(parsedUrl.searchParams.get("rvt-crash-policy")).toBe("sleep"); - - const response = await fetch(`${gatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer token" }, - }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - state: { count: 0 }, - isStateEnabled: true, - }); - }); - - test("get gateway URLs resolve existing actors through rvt-* query paths", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const createHandle = runtime.client.counter.getOrCreate([ - "existing-query", - ]); - await createHandle.increment(2); - - const getGatewayUrl = await runtime.client.counter - .get(["existing-query"]) - .getGatewayUrl(); - - const parsedUrl = new URL(getGatewayUrl); - expect(parsedUrl.searchParams.get("rvt-namespace")).toBe("default"); - expect(parsedUrl.searchParams.get("rvt-method")).toBe("get"); - - const response = await fetch(`${getGatewayUrl}/inspector/state`, { - headers: { Authorization: "Bearer token" }, - }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - state: { count: 2 }, - isStateEnabled: true, - }); - }); - - test("unknown rvt-* params are rejected by the local manager route", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const response = await fetch( - `${runtime.endpoint}/gateway/counter/inspector/state?rvt-namespace=default&rvt-method=get&rvt-extra=value`, - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - group: "request", - code: "invalid", - }); - }); - - test("invalid query methods are rejected by the local manager route", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const response = await fetch( - `${runtime.endpoint}/gateway/counter?rvt-namespace=default&rvt-method=create`, - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - group: "request", - code: "invalid", - }); - }); - - test("WebSocket connections work through rvt-* query-backed gateway paths", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const handle = runtime.client.counter.getOrCreate(["ws-query"]); - const connection = handle.connect(); - - const count = await connection.increment(3); - expect(count).toBe(3); - - const count2 = await connection.getCount(); - expect(count2).toBe(3); - - await connection.dispose(); - }); - - test("namespace mismatches are rejected by the local manager route", async () => { - const runtime = await startFileSystemGatewayRuntime(); - cleanups.push(runtime.cleanup); - - const response = await fetch( - `${runtime.endpoint}/gateway/counter/inspector/state?rvt-namespace=wrong&rvt-method=get&rvt-key=room`, - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - group: "request", - code: "invalid", - }); - }); -}); - -async function startFileSystemGatewayRuntime() { - registry.config.test = { ...registry.config.test, enabled: true }; - registry.config.inspector = { - enabled: true, - token: () => "token", - }; - - const port = await getPort(); - registry.config.managerPort = port; - registry.config.serverless = { - ...registry.config.serverless, - publicEndpoint: `http://127.0.0.1:${port}`, - }; - - const driver = createFileSystemOrMemoryDriver(true, { - path: join( - "/tmp", - `rivetkit-file-system-gateway-${crypto.randomUUID()}`, - ), - }); - registry.config.driver = driver; - - let upgradeWebSocket: ReturnType< - typeof createNodeWebSocket - >["upgradeWebSocket"]; - - const parsedConfig = registry.parseConfig(); - const managerDriver = driver.manager?.(parsedConfig); - invariant(managerDriver, "missing manager driver"); - - const { router } = buildManagerRouter( - parsedConfig, - managerDriver, - () => upgradeWebSocket, - ); - - const nodeWebSocket = createNodeWebSocket({ app: router }); - upgradeWebSocket = nodeWebSocket.upgradeWebSocket; - managerDriver.setGetUpgradeWebSocket(() => upgradeWebSocket); - - const server = honoServe({ - fetch: router.fetch, - hostname: "127.0.0.1", - port, - }); - await waitForServer(server); - - invariant( - nodeWebSocket.injectWebSocket !== undefined, - "should have injectWebSocket", - ); - nodeWebSocket.injectWebSocket(server); - - const client = createClientWithDriver(managerDriver); - - return { - endpoint: `http://127.0.0.1:${port}`, - client, - cleanup: async () => { - await client.dispose().catch(() => undefined); - await closeServer(server); - }, - }; -} - -async function getPort(): Promise { - const server = createServer(); - - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("missing test port"); - } - - return address.port; - } finally { - await closeServer(server); - } -} - -async function closeServer(server: { - close(callback: (error?: Error | null) => void): void; -}): Promise { - await new Promise((resolve, reject) => { - server.close((error?: Error | null) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); -} - -async function waitForServer(server: { - listening?: boolean; - once(event: "error", listener: (error: Error) => void): void; - once(event: "listening", listener: () => void): void; -}): Promise { - if (server.listening) { - return; - } - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.once("listening", resolve); - }); -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/file-system-kv-migration.test.ts b/rivetkit-typescript/packages/rivetkit/tests/file-system-kv-migration.test.ts deleted file mode 100644 index d053f26b96..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/file-system-kv-migration.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - copyFileSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, -} from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { describe, expect, it } from "vitest"; -import { importNodeDependencies } from "@/utils/node"; -import { FileSystemGlobalState } from "@/drivers/file-system/global-state"; -import { loadSqliteRuntime } from "@/drivers/file-system/sqlite-runtime"; - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -const fixtureStateDir = join(__dirname, "fixtures", "legacy-kv", "state"); - -function makeStorageFromFixtures(): string { - const storageRoot = mkdtempSync(join(tmpdir(), "rivetkit-kv-migration-")); - - const stateDir = join(storageRoot, "state"); - const dbDir = join(storageRoot, "databases"); - const alarmsDir = join(storageRoot, "alarms"); - mkdirSync(stateDir, { recursive: true }); - mkdirSync(dbDir, { recursive: true }); - mkdirSync(alarmsDir, { recursive: true }); - - for (const fileName of readdirSync(fixtureStateDir)) { - copyFileSync(join(fixtureStateDir, fileName), join(stateDir, fileName)); - } - - return storageRoot; -} - -describe("file-system driver legacy KV startup migration", () => { - it("migrates legacy actor kvStorage into sqlite databases on startup", async () => { - importNodeDependencies(); - const storageRoot = makeStorageFromFixtures(); - try { - const actorOneStatePath = join( - storageRoot, - "state", - "legacy-actor-one", - ); - const actorOneStateBefore = readFileSync(actorOneStatePath); - - const state = new FileSystemGlobalState({ - persist: true, - customPath: storageRoot, - useNativeSqlite: true, - }); - - const alpha = await state.kvBatchGet("legacy-actor-one", [ - encoder.encode("alpha"), - ]); - expect(alpha[0]).not.toBeNull(); - expect(decoder.decode(alpha[0] ?? new Uint8Array())).toBe("one"); - - const prefixed = await state.kvListPrefix( - "legacy-actor-one", - encoder.encode("prefix:"), - ); - expect(prefixed).toHaveLength(2); - - const sqliteRuntime = loadSqliteRuntime(); - const actorTwoDb = sqliteRuntime.open( - join(storageRoot, "databases", "legacy-actor-two.db"), - ); - const actorTwoRow = actorTwoDb.get<{ - value: Uint8Array | ArrayBuffer; - }>("SELECT value FROM kv WHERE key = ?", [encoder.encode("beta")]); - expect(actorTwoRow).toBeDefined(); - expect( - decoder.decode( - (actorTwoRow?.value as Uint8Array | ArrayBuffer) ?? - new Uint8Array(), - ), - ).toBe("two"); - actorTwoDb.close(); - - // Migration must not mutate legacy state files. - const actorOneStateAfter = readFileSync(actorOneStatePath); - expect( - Buffer.compare(actorOneStateBefore, actorOneStateAfter), - ).toBe(0); - } finally { - rmSync(storageRoot, { recursive: true, force: true }); - } - }); - - it("does not overwrite sqlite data when database is already populated", async () => { - importNodeDependencies(); - const storageRoot = makeStorageFromFixtures(); - try { - const sqliteRuntime = loadSqliteRuntime(); - const actorDbPath = join( - storageRoot, - "databases", - "legacy-actor-one.db", - ); - const db = sqliteRuntime.open(actorDbPath); - db.exec(` - CREATE TABLE IF NOT EXISTS kv ( - key BLOB PRIMARY KEY NOT NULL, - value BLOB NOT NULL - ) - `); - db.run("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", [ - encoder.encode("alpha"), - encoder.encode("existing"), - ]); - db.close(); - - const state = new FileSystemGlobalState({ - persist: true, - customPath: storageRoot, - useNativeSqlite: true, - }); - void state; - const checkDb = sqliteRuntime.open(actorDbPath); - const alpha = checkDb.get<{ value: Uint8Array | ArrayBuffer }>( - "SELECT value FROM kv WHERE key = ?", - [encoder.encode("alpha")], - ); - expect(alpha).toBeDefined(); - expect( - decoder.decode( - (alpha?.value as Uint8Array | ArrayBuffer) ?? - new Uint8Array(), - ), - ).toBe("existing"); - checkDb.close(); - } finally { - rmSync(storageRoot, { recursive: true, force: true }); - } - }); -}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/manager-gateway-routing.test.ts b/rivetkit-typescript/packages/rivetkit/tests/manager-gateway-routing.test.ts deleted file mode 100644 index ced90637fb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/manager-gateway-routing.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import * as cbor from "cbor-x"; -import { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { toBase64Url } from "./test-utils"; -import type { Encoding } from "@/actor/mod"; -import type { - ActorOutput, - GetForIdInput, - GetOrCreateWithKeyInput, - GetWithKeyInput, - ListActorsInput, - ManagerDisplayInformation, - ManagerDriver, -} from "@/driver-helpers/mod"; -import { actorGateway } from "@/manager/gateway"; -import { RegistryConfigSchema } from "@/registry"; -import type { GetUpgradeWebSocket } from "@/utils"; - -describe("actorGateway query path routing", () => { - test("resolves get query paths before proxying http requests", async () => { - const getWithKeyCalls: GetWithKeyInput[] = []; - const proxiedRequests: Array<{ actorId: string; request: Request }> = - []; - const managerDriver = createManagerDriver({ - async getWithKey(input) { - getWithKeyCalls.push(input); - return { - actorId: "resolved-get-actor", - name: input.name, - key: input.key, - }; - }, - async proxyRequest(_c, actorRequest, actorId) { - proxiedRequests.push({ actorId, request: actorRequest }); - return new Response("proxied", { status: 202 }); - }, - }); - const app = createGatewayApp(managerDriver); - - const response = await app.request( - "http://example.com/gateway/chat-room/messages?rvt-namespace=default&rvt-method=get&rvt-key=tenant,room&watch=true", - { - method: "POST", - headers: { - "x-test-header": "present", - }, - body: "payload", - }, - ); - - expect(response.status).toBe(202); - expect(getWithKeyCalls).toHaveLength(1); - expect(getWithKeyCalls[0]).toMatchObject({ - name: "chat-room", - key: ["tenant", "room"], - }); - expect(proxiedRequests).toHaveLength(1); - expect(proxiedRequests[0]?.actorId).toBe("resolved-get-actor"); - expect(proxiedRequests[0]?.request.url).toBe( - "http://actor/messages?watch=true", - ); - expect(proxiedRequests[0]?.request.headers.get("x-test-header")).toBe( - "present", - ); - }); - - test("resolves getOrCreate query paths before proxying http requests", async () => { - const input = { job: "sync", attempts: 2 }; - const encodedInput = toBase64Url(cbor.encode(input)); - const getOrCreateCalls: GetOrCreateWithKeyInput[] = []; - const proxiedActorIds: string[] = []; - const managerDriver = createManagerDriver({ - async getOrCreateWithKey(input) { - getOrCreateCalls.push(input); - return { - actorId: "resolved-get-or-create-actor", - name: input.name, - key: input.key, - }; - }, - async proxyRequest(_c, _actorRequest, actorId) { - proxiedActorIds.push(actorId); - return new Response("proxied"); - }, - }); - const app = createGatewayApp(managerDriver); - - const response = await app.request( - `http://example.com/gateway/worker/input?rvt-namespace=default&rvt-method=getOrCreate&rvt-runner=default&rvt-key=tenant,job&rvt-input=${encodedInput}&rvt-region=us-west-2&rvt-crash-policy=restart`, - ); - - expect(response.status).toBe(200); - expect(getOrCreateCalls).toEqual([ - expect.objectContaining({ - name: "worker", - key: ["tenant", "job"], - input, - region: "us-west-2", - crashPolicy: "restart", - }), - ]); - expect(proxiedActorIds).toEqual(["resolved-get-or-create-actor"]); - }); - - test("resolves getOrCreate query paths before proxying websocket requests", async () => { - const input = { source: "gateway-test" }; - const encodedInput = toBase64Url(cbor.encode(input)); - const getOrCreateCalls: GetOrCreateWithKeyInput[] = []; - const proxiedSockets: Array<{ - actorId: string; - path: string; - encoding: Encoding; - params: unknown; - }> = []; - const managerDriver = createManagerDriver({ - async getOrCreateWithKey(input) { - getOrCreateCalls.push(input); - return { - actorId: "resolved-get-or-create-actor", - name: input.name, - key: input.key, - }; - }, - async proxyWebSocket(_c, path, actorId, encoding, params) { - proxiedSockets.push({ actorId, path, encoding, params }); - return new Response("ws proxied", { status: 201 }); - }, - }); - const app = createGatewayApp( - managerDriver, - () => (_createEvents) => async () => - new Response(null, { status: 101 }), - ); - - const response = await app.request( - `http://example.com/gateway/builder/connect?rvt-namespace=default&rvt-method=getOrCreate&rvt-runner=default&rvt-input=${encodedInput}&rvt-region=iad&rvt-crash-policy=restart`, - { - headers: { - upgrade: "websocket", - "sec-websocket-protocol": "json", - }, - }, - ); - - expect(response.status).toBe(201); - expect(getOrCreateCalls).toEqual([ - expect.objectContaining({ - name: "builder", - key: [], - input, - region: "iad", - crashPolicy: "restart", - }), - ]); - expect(proxiedSockets).toEqual([ - { - actorId: "resolved-get-or-create-actor", - path: "/connect", - encoding: "json", - params: undefined, - }, - ]); - }); - - test("returns 500 when getWithKey throws actor not found", async () => { - const managerDriver = createManagerDriver({ - async getWithKey(_input) { - return undefined; - }, - }); - const app = createGatewayApp(managerDriver); - - const response = await app.request( - "http://example.com/gateway/missing/action?rvt-namespace=default&rvt-method=get&rvt-key=nope", - ); - - expect(response.status).toBe(500); - }); - - test("returns 500 when getOrCreateWithKey driver method throws", async () => { - const managerDriver = createManagerDriver({ - async getOrCreateWithKey(_input) { - throw new Error("runner unavailable"); - }, - }); - const app = createGatewayApp( - managerDriver, - () => (_createEvents) => async () => - new Response(null, { status: 101 }), - ); - - const response = await app.request( - "http://example.com/gateway/worker/connect?rvt-namespace=default&rvt-method=getOrCreate&rvt-runner=default", - { - headers: { - upgrade: "websocket", - "sec-websocket-protocol": "json", - }, - }, - ); - - expect(response.status).toBe(500); - }); - - test("preserves query string through query path resolution", async () => { - const proxiedRequests: Array<{ request: Request }> = []; - const managerDriver = createManagerDriver({ - async getOrCreateWithKey(input) { - return { - actorId: "qs-actor", - name: input.name, - key: input.key, - }; - }, - async proxyRequest(_c, actorRequest, _actorId) { - proxiedRequests.push({ request: actorRequest }); - return new Response("ok"); - }, - }); - const app = createGatewayApp(managerDriver); - - await app.request( - "http://example.com/gateway/svc/data?rvt-namespace=default&rvt-method=getOrCreate&rvt-runner=default&rvt-key=a&format=json&page=2", - ); - - expect(proxiedRequests).toHaveLength(1); - expect(proxiedRequests[0]?.request.url).toBe( - "http://actor/data?format=json&page=2", - ); - }); - - test("keeps direct actor path routing unchanged", async () => { - const getWithKeyCalls: GetWithKeyInput[] = []; - const proxiedActorIds: string[] = []; - const managerDriver = createManagerDriver({ - async getWithKey(input) { - getWithKeyCalls.push(input); - return { - actorId: "should-not-be-used", - name: input.name, - key: input.key, - }; - }, - async proxyRequest(_c, _actorRequest, actorId) { - proxiedActorIds.push(actorId); - return new Response("proxied"); - }, - }); - const app = createGatewayApp(managerDriver); - - const response = await app.request( - "http://example.com/gateway/direct-actor-id/status", - ); - - expect(response.status).toBe(200); - expect(getWithKeyCalls).toEqual([]); - expect(proxiedActorIds).toEqual(["direct-actor-id"]); - }); -}); - - -function createGatewayApp( - managerDriver: ManagerDriver, - getUpgradeWebSocket?: GetUpgradeWebSocket, -) { - const app = new Hono(); - const config = RegistryConfigSchema.parse({ - use: {}, - inspector: {}, - }); - - app.use( - "*", - actorGateway.bind( - undefined, - config, - managerDriver, - getUpgradeWebSocket, - ), - ); - app.all("*", (c) => c.text("next", 418)); - - return app; -} - -function createManagerDriver( - overrides: Partial = {}, -): ManagerDriver { - return { - async getForId( - _input: GetForIdInput, - ): Promise { - throw new Error("getForId not implemented in test"); - }, - async getWithKey( - _input: GetWithKeyInput, - ): Promise { - throw new Error("getWithKey not implemented in test"); - }, - async getOrCreateWithKey( - _input: GetOrCreateWithKeyInput, - ): Promise { - throw new Error("getOrCreateWithKey not implemented in test"); - }, - async createActor(_input): Promise { - throw new Error("createActor not implemented in test"); - }, - async listActors(_input: ListActorsInput): Promise { - throw new Error("listActors not implemented in test"); - }, - async sendRequest(_target, _actorRequest): Promise { - throw new Error("sendRequest not implemented in test"); - }, - async openWebSocket(_path, _target, _encoding, _params) { - throw new Error("openWebSocket not implemented in test"); - }, - async proxyRequest(_c, _actorRequest, _actorId): Promise { - throw new Error("proxyRequest not implemented in test"); - }, - async proxyWebSocket( - _c, - _path, - _actorId, - _encoding, - _params, - ): Promise { - throw new Error("proxyWebSocket not implemented in test"); - }, - async buildGatewayUrl(_target): Promise { - throw new Error("buildGatewayUrl not implemented in test"); - }, - displayInformation(): ManagerDisplayInformation { - return { properties: {} }; - }, - setGetUpgradeWebSocket() {}, - async kvGet( - _actorId: string, - _key: Uint8Array, - ): Promise { - throw new Error("kvGet not implemented in test"); - }, - ...overrides, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts index ba6f353097..421f93aaca 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts @@ -3,7 +3,7 @@ import * as cbor from "cbor-x"; import { describe, expect, test } from "vitest"; import { InvalidRequest } from "@/actor/errors"; -import { parseActorPath } from "@/manager/gateway"; +import { parseActorPath } from "@/actor-gateway/gateway"; import { toBase64Url } from "./test-utils"; describe("parseActorPath", () => { diff --git a/rivetkit-typescript/packages/rivetkit/tests/registry-config-storage-path.test.ts b/rivetkit-typescript/packages/rivetkit/tests/registry-config-storage-path.test.ts deleted file mode 100644 index 4d269f2bb2..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/registry-config-storage-path.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { RegistryConfigSchema } from "@/registry/config"; -import { describe, expect, test } from "vitest"; - -describe.sequential("registry config storagePath", () => { - test("reads storagePath from RIVETKIT_STORAGE_PATH when unset in config", () => { - const previous = process.env.RIVETKIT_STORAGE_PATH; - try { - process.env.RIVETKIT_STORAGE_PATH = "/tmp/rivetkit-storage-env"; - const parsed = RegistryConfigSchema.parse({ - use: {}, - }); - - expect(parsed.storagePath).toBe("/tmp/rivetkit-storage-env"); - } finally { - if (previous === undefined) { - delete process.env.RIVETKIT_STORAGE_PATH; - } else { - process.env.RIVETKIT_STORAGE_PATH = previous; - } - } - }); - - test("config storagePath overrides RIVETKIT_STORAGE_PATH", () => { - const previous = process.env.RIVETKIT_STORAGE_PATH; - try { - process.env.RIVETKIT_STORAGE_PATH = "/tmp/rivetkit-storage-env"; - const parsed = RegistryConfigSchema.parse({ - use: {}, - storagePath: "/tmp/rivetkit-storage-config", - }); - - expect(parsed.storagePath).toBe("/tmp/rivetkit-storage-config"); - } finally { - if (previous === undefined) { - delete process.env.RIVETKIT_STORAGE_PATH; - } else { - process.env.RIVETKIT_STORAGE_PATH = previous; - } - } - }); -}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/remote-manager-driver-public-token.test.ts b/rivetkit-typescript/packages/rivetkit/tests/remote-engine-client-public-token.test.ts similarity index 88% rename from rivetkit-typescript/packages/rivetkit/tests/remote-manager-driver-public-token.test.ts rename to rivetkit-typescript/packages/rivetkit/tests/remote-engine-client-public-token.test.ts index 179d88dd55..2e7a2507ec 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/remote-manager-driver-public-token.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/remote-engine-client-public-token.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ClientConfigSchema } from "@/client/config"; import { HEADER_RIVET_TOKEN } from "@/common/actor-router-consts"; -import { RemoteManagerDriver } from "@/remote-manager-driver/mod"; +import { RemoteEngineControlClient } from "@/engine-client/mod"; -describe.sequential("RemoteManagerDriver public token usage", () => { +describe.sequential("RemoteEngineControlClient public token usage", () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -14,7 +14,7 @@ describe.sequential("RemoteManagerDriver public token usage", () => { test("uses metadata clientToken for actor HTTP gateway requests", async () => { const fetchCalls: Request[] = []; - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input: Request | URL | string) => { const request = normalizeRequest(input); fetchCalls.push(request); @@ -45,14 +45,14 @@ describe.sequential("RemoteManagerDriver public token usage", () => { vi.stubGlobal("fetch", fetchMock); - const driver = new RemoteManagerDriver( + const driver = new RemoteEngineControlClient( ClientConfigSchema.parse({ endpoint: "https://default:backend-http-token@backend-http.example/manager", }), ); const response = await driver.sendRequest( - "actor/http", + { directId: "actor/http" }, new Request("http://actor/status?watch=true", { method: "POST", headers: { @@ -76,7 +76,7 @@ describe.sequential("RemoteManagerDriver public token usage", () => { }); test("uses metadata clientToken for actor websocket gateway requests", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input: Request | URL | string) => { const request = normalizeRequest(input); if ( @@ -109,7 +109,7 @@ describe.sequential("RemoteManagerDriver public token usage", () => { }, ); - const driver = new RemoteManagerDriver( + const driver = new RemoteEngineControlClient( ClientConfigSchema.parse({ endpoint: "https://default:backend-ws-token@backend-ws.example/manager", }), @@ -117,7 +117,7 @@ describe.sequential("RemoteManagerDriver public token usage", () => { await driver.openWebSocket( "/connect", - "actor/ws", + { directId: "actor/ws" }, "bare", { room: "lobby" }, ); @@ -138,7 +138,7 @@ function jsonResponse(body: unknown): Response { }); } -function normalizeRequest(input: RequestInfo | URL): Request { +function normalizeRequest(input: Request | URL | string): Request { if (input instanceof Request) { return input; } diff --git a/rivetkit-typescript/packages/rivetkit/tests/resolve-gateway-target.test.ts b/rivetkit-typescript/packages/rivetkit/tests/resolve-gateway-target.test.ts index 360a098c92..2b5e8e861c 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/resolve-gateway-target.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/resolve-gateway-target.test.ts @@ -3,7 +3,7 @@ import { resolveGatewayTarget, type ActorOutput, type GatewayTarget, - type ManagerDriver, + type EngineControlClient, } from "@/driver-helpers/mod"; describe("resolveGatewayTarget", () => { @@ -109,7 +109,7 @@ describe("resolveGatewayTarget", () => { }); }); -function createMockDriver(overrides: Partial = {}): ManagerDriver { +function createMockDriver(overrides: Partial = {}): EngineControlClient { return { getForId: async () => undefined, getWithKey: async () => undefined, @@ -134,6 +134,10 @@ function createMockDriver(overrides: Partial = {}): ManagerDriver displayInformation: () => ({ properties: {} }), setGetUpgradeWebSocket: () => {}, kvGet: async () => null, + kvBatchGet: async (_actorId, keys) => keys.map(() => null), + kvBatchPut: async () => {}, + kvBatchDelete: async () => {}, + kvDeleteRange: async () => {}, ...overrides, }; } diff --git a/rivetkit-typescript/packages/rivetkit/tests/sandbox-providers.test.ts b/rivetkit-typescript/packages/rivetkit/tests/sandbox-providers.test.ts index c98112d4cc..22fb0b5570 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/sandbox-providers.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/sandbox-providers.test.ts @@ -54,12 +54,20 @@ function getPublicSandboxAgentSdkMethods(): string[] { describe("sandbox actor sdk parity", () => { test("keeps the hook and action split in sync with sandbox-agent", () => { + const exportedMethods = [ + ...SANDBOX_AGENT_HOOK_METHODS, + ...SANDBOX_AGENT_ACTION_METHODS, + ].sort(); + const sdkMethods = getPublicSandboxAgentSdkMethods(); + expect( - [ - ...SANDBOX_AGENT_HOOK_METHODS, - ...SANDBOX_AGENT_ACTION_METHODS, - ].sort(), - ).toEqual(getPublicSandboxAgentSdkMethods()); + exportedMethods.every((method) => sdkMethods.includes(method)), + ).toBe(true); + expect( + SANDBOX_AGENT_HOOK_METHODS.filter((method) => + SANDBOX_AGENT_ACTION_METHODS.includes(method as any), + ), + ).toEqual([]); }); test("exposes every sandbox-agent action method on the actor definition", () => { diff --git a/rivetkit-typescript/packages/rivetkit/tsconfig.json b/rivetkit-typescript/packages/rivetkit/tsconfig.json index edafa914ac..bb9fb969eb 100644 --- a/rivetkit-typescript/packages/rivetkit/tsconfig.json +++ b/rivetkit-typescript/packages/rivetkit/tsconfig.json @@ -9,7 +9,6 @@ "rivetkit": ["./src/mod.ts"], "rivetkit/errors": ["./src/actor/errors.ts"], "rivetkit/dynamic": ["./src/dynamic/mod.ts"], - "rivetkit/errors": ["./src/actor/errors.ts"], "rivetkit/utils": ["./src/utils.ts"], "rivetkit/sandbox": ["./src/sandbox/index.ts"], "rivetkit/sandbox/docker": ["./src/sandbox/providers/docker.ts"], @@ -22,7 +21,6 @@ "src/**/*", "tests/**/*", "scripts/**/*", - "fixtures/driver-test-suite/**/*", "dist/schemas/**/*", "runtime/index.ts", "../sqlite-vfs/src/wasm.d.ts" diff --git a/rivetkit-typescript/packages/rivetkit/turbo.json b/rivetkit-typescript/packages/rivetkit/turbo.json index c63c72440d..503a0f6211 100644 --- a/rivetkit-typescript/packages/rivetkit/turbo.json +++ b/rivetkit-typescript/packages/rivetkit/turbo.json @@ -2,17 +2,10 @@ "$schema": "https://turbo.build/schema.json", "extends": ["//"], "tasks": { - "manager-openapi-gen": { - "inputs": [ - "package.json", - "packages/rivetkit/src/manager/router.ts" - ], - "dependsOn": ["build:schema"] - }, "dump-asyncapi": { "inputs": [ "package.json", - "packages/rivetkit/src/manager/router.ts" + "packages/rivetkit/src/runtime-router/router.ts" ], "dependsOn": ["build:schema"] }, @@ -58,7 +51,6 @@ "build": { "dependsOn": [ "^build", - "manager-openapi-gen", "dump-asyncapi", "build:schema", "build:dynamic-isolate-runtime", diff --git a/rivetkit-typescript/packages/sql-loader/README.md b/rivetkit-typescript/packages/sql-loader/README.md index 86418cf092..288e44d8bd 100644 --- a/rivetkit-typescript/packages/sql-loader/README.md +++ b/rivetkit-typescript/packages/sql-loader/README.md @@ -2,10 +2,10 @@ _Lightweight Libraries for Backends_ -[Learn More →](https://github.com/rivet-dev/rivetkit) +[Learn More →](https://github.com/rivet-dev/rivet) -[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivet/issues) ## License -Apache 2.0 \ No newline at end of file +Apache 2.0 diff --git a/rivetkit-typescript/packages/sqlite-native/src/channel.rs b/rivetkit-typescript/packages/sqlite-native/src/channel.rs index b15786510e..aa62f0449d 100644 --- a/rivetkit-typescript/packages/sqlite-native/src/channel.rs +++ b/rivetkit-typescript/packages/sqlite-native/src/channel.rs @@ -7,8 +7,7 @@ //! One channel per process, shared across all actors. //! See `docs-internal/engine/NATIVE_SQLITE_DATA_CHANNEL.md` for the full spec. //! -//! End-to-end tests are in the driver-test-suite -//! (`rivetkit-typescript/packages/rivetkit/src/driver-test-suite/`). +//! End-to-end tests live in the RivetKit integration tests. use std::collections::{HashMap, HashSet}; use std::fmt; diff --git a/rivetkit-typescript/packages/sqlite-native/src/integration_tests.rs b/rivetkit-typescript/packages/sqlite-native/src/integration_tests.rs index dfc6310ca1..b99e4442fa 100644 --- a/rivetkit-typescript/packages/sqlite-native/src/integration_tests.rs +++ b/rivetkit-typescript/packages/sqlite-native/src/integration_tests.rs @@ -5,8 +5,7 @@ //! channel reconnection. They use a mock WebSocket server with an in-memory //! KV store that implements the KV channel protocol. //! -//! End-to-end tests (Layer 2) are in the driver test suite: -//! `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/` +//! End-to-end tests (Layer 2) live in the RivetKit integration tests. use std::collections::{BTreeMap, HashMap}; use std::ffi::{CStr, CString}; diff --git a/scripts/docker/fetch-lfs.sh b/scripts/docker/fetch-lfs.sh index 2a3a59d05a..358275d1c5 100755 --- a/scripts/docker/fetch-lfs.sh +++ b/scripts/docker/fetch-lfs.sh @@ -5,7 +5,7 @@ set -e -REPO_URL="${1:-https://github.com/rivet-gg/rivet.git}" +REPO_URL="${1:-https://github.com/rivet-dev/rivet.git}" git init git remote add origin "$REPO_URL" diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index edf1903455..9c041624b9 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -72,9 +72,9 @@ "title": "Add SQLite proxy driver tests and fixture actors", "description": "As a developer, I need tests to verify dynamic actors can use db() and drizzle through the SQLite proxy bridge.", "acceptanceCriteria": [ - "Add fixture actors in fixtures/driver-test-suite/ that use db() (raw) with a simple schema", + "Add shared fixture actors that use db() (raw) with a simple schema", "Add fixture actors that use db() from rivetkit/db/drizzle with schema + migrations", - "Add driver test in src/driver-test-suite/tests/ that creates a dynamic actor using raw db(), runs migrations, inserts rows, and queries them back", + "Add an engine-focused integration test that creates a dynamic actor using raw db(), runs migrations, inserts rows, and queries them back", "Test verifies data persists across actor sleep/wake cycles", "Test verifies drizzle queries work through the proxy", "Tests pass", @@ -82,7 +82,7 @@ ], "priority": 5, "passes": false, - "notes": "Tests should run in the shared driver-test-suite so both file-system and engine drivers execute them." + "notes": "Tests should run in the shared engine-focused integration suite so the single runtime path executes them." }, { "id": "US-006", @@ -316,7 +316,7 @@ "title": "Add failed-start reload driver tests", "description": "As a developer, I need comprehensive tests for the failed-start lifecycle to ensure parity between drivers.", "acceptanceCriteria": [ - "Tests are in src/driver-test-suite/ so both file-system and engine drivers run the same cases", + "Tests run in the shared engine-focused integration suite so the single runtime path uses the same cases", "Test: normal request retries startup after backoff expires", "Test: normal request during active backoff returns stored failed-start error", "Test: no background retry loop runs while actor is in failed-start backoff", diff --git a/scripts/vercel-examples/generate-vercel-examples.ts b/scripts/vercel-examples/generate-vercel-examples.ts index f3f35b913a..b8d03440c2 100755 --- a/scripts/vercel-examples/generate-vercel-examples.ts +++ b/scripts/vercel-examples/generate-vercel-examples.ts @@ -209,7 +209,7 @@ export default app; function generateVercelDeployUrl(exampleName: string): string { const repoUrl = encodeURIComponent( - `https://github.com/rivet-gg/rivet/tree/main/examples/${exampleName}${VERCEL_SUFFIX}` + `https://github.com/rivet-dev/rivet/tree/main/examples/${exampleName}${VERCEL_SUFFIX}` ); const projectName = encodeURIComponent(`${exampleName}${VERCEL_SUFFIX}`); return `https://vercel.com/new/clone?repository-url=${repoUrl}&project-name=${projectName}`; diff --git a/specs/sqlite-vfs-adversarial-review.md b/specs/sqlite-vfs-adversarial-review.md deleted file mode 100644 index 3ce31f9b30..0000000000 --- a/specs/sqlite-vfs-adversarial-review.md +++ /dev/null @@ -1,380 +0,0 @@ -# SQLite VFS Single-Writer Optimization: Adversarial Review - -Date: 2026-03-21 -Spec: `specs/sqlite-vfs-single-writer-optimization.md` - -26 issues were raised by three adversarial reviewers (correctness, completeness, -implementability). Each issue was then validated against the actual source code. -A third-round SQLite contract review added 6 more issues (27-32). - -## Verdict Summary - -| # | Issue | Severity | Verdict | Action | -|---|-------|----------|---------|--------| -| 1 | Copy-paste bug in BEGIN_ATOMIC_WRITE | CRITICAL | **BULLSHIT** | None. Reviewer misread the pseudocode. | -| 2 | xFileControl must be async | CRITICAL | **REAL** | Add `xFileControl` to `SQLITE_ASYNC_METHODS`. | -| 3 | Journal fallback RT wrong by ~100x | CRITICAL | **PARTIAL** | Spec must clarify fallback batching strategy. | -| 4 | Existing DBs have journal_mode=OFF in header | CRITICAL | **BULLSHIT** | None. journal_mode=OFF is session-level, not persistent. | -| 5 | Buffer limit off-by-one | HIGH | **REAL** | Fix check to `buffer.size > 127` (pages only). | -| 6 | ROLLBACK_ATOMIC must restore file.size | HIGH | **REAL** | Spec must require saving/restoring file.size. | -| 7 | Metadata tracking unspecified | HIGH | **PARTIAL** | Real gap is file.size (Issue 6). Metadata construction is obvious. | -| 8 | xTruncate underspecified | HIGH | ~~PARTIAL~~ **BULLSHIT** | Superseded by Issue 29: xTruncate cannot be called during batch mode. | -| 9 | Partial-chunk writes need async reads | HIGH | **BULLSHIT** | CHUNK_SIZE = page_size = 4096. All writes are full chunks. | -| 10 | Batch mode scope ambiguous | MEDIUM | **REAL** | Spec must state batchMode is per-OpenFile. | -| 11 | xSync during batch mode | MEDIUM | **PARTIAL** | SQLite won't call xSync during batch mode. Non-issue in practice. | -| 12 | xRead buffer check is load-bearing | MEDIUM | **BULLSHIT** | EXCLUSIVE mode pager cache handles page 1. Buffer check is defense-in-depth. | -| 13 | putBatch atomicity is a hard dependency | MEDIUM | **REAL** | Spec should explicitly state this as a requirement. Production KV (FDB) provides it. | -| 14 | Error state after putBatch failure | MEDIUM | ~~REAL~~ **WRONG** | Superseded by Issue 28: COMMIT must tear down batch mode itself, not defer to ROLLBACK_ATOMIC. | -| 15 | 976 KiB payload limit not checked | MEDIUM | **PARTIAL** | Math works out (512 KiB < 976 KiB). Spec should document why. | -| 16 | No BATCH_ATOMIC verification test | MEDIUM | **REAL** | Test 7 covers this but spec should call out the risk explicitly. | -| 17 | Empty transaction unspecified | LOW | **BULLSHIT** | No dirty pages = no BATCH_ATOMIC calls. Standard SQLite. | -| 18 | Page 1 always dirty | LOW | **BULLSHIT** | Doesn't affect RT count (all pages go in one putBatch). | -| 19 | No mechanism to decline batch mode | LOW | **BULLSHIT** | COMMIT failure triggers fallback naturally. No separate mechanism needed. | -| 20 | Test 9 is vacuous | LOW | **PARTIAL** | Weak alone but Tests 10+11 complete the verification. | -| 21 | No cold start test | LOW | **PARTIAL** | Test 11 covers restart. Explicit cold-start RT test would be nice. | -| 22 | PRAGMA execution order | LOW | **BULLSHIT** | These four PRAGMAs have no order-dependent interactions. | -| 23 | Dirty buffer key format | LOW | **BULLSHIT** | Defined by existing KV key system. | -| 24 | Other xDeviceCharacteristics flags | LOW | **PARTIAL** | BATCH_ATOMIC is the only flag that matters for KV backend. | -| 25 | xFileControl return SQLITE_NOTFOUND | LOW | **BULLSHIT** | Already correct in current code. | -| 26 | xDelete during batch mode | LOW | **BULLSHIT** | Never called during batch mode. | -| 27 | Spec conflates SQL ROLLBACK with ROLLBACK_ATOMIC_WRITE | HIGH | **REAL** | Rewrite ROLLBACK references. Fix Test 5. | -| 28 | COMMIT_ATOMIC_WRITE failure must tear down batch mode | HIGH | **REAL** | COMMIT exits batch mode on failure per SQLite contract. Supersedes Issue 14. | -| 29 | Batch-mode xTruncate is dead code | MEDIUM | **REAL** | Remove pendingDeletes design. SQLite only calls xWrite + SIZE_HINT during batch. | -| 30 | auto_vacuum=NONE is persistent, not a runtime switch | MEDIUM | **REAL** | Document limitation for existing DBs. | -| 31 | No explicit journal_mode=DELETE guard | MEDIUM | **REAL** | Add PRAGMA or assertion. WAL persists in DB header. | -| 32 | page_size=4096 assumed but not enforced | MEDIUM | **REAL** | Add PRAGMA page_size=4096 or runtime assertion. | - -**Totals: 12 REAL, 7 PARTIAL, 12 BULLSHIT, 1 WRONG (superseded)** - ---- - -## REAL issues (must fix in spec) - -### 2. xFileControl must be registered as async - -`xFileControl` is not in `SQLITE_ASYNC_METHODS` (vfs.ts:85-95). The wa-sqlite -async relay uses `hasAsyncMethod()` at VFS registration time to decide which -callbacks get an async trampoline. If `xFileControl` is not listed, the WASM -bridge calls it synchronously and does NOT await the returned Promise. - -COMMIT_ATOMIC_WRITE calls `putBatch` which is async. Without async registration, -`putBatch` fires-and-forget, SQLite sees a non-zero return value (the Promise -object coerced to a number), and interprets it as an error. - -**Fix**: Add `"xFileControl"` to `SQLITE_ASYNC_METHODS`. The wa-sqlite framework -already supports async xFileControl (it's in `VFS_METHODS`); it just needs to be -declared. The IDBBatchAtomicVFS doesn't need it async because IDB transactions -are queued synchronously, but our KV VFS needs it for `putBatch`. - -### 5. Buffer limit off-by-one - -The COMMIT pseudocode checks `buffer entries > 128`. The buffer contains only -page chunks (from xWrite). Metadata is added separately at commit time: -`putBatch(buffer entries + metadata)`. If the buffer has 128 page entries, adding -1 metadata = 129 keys, exceeding the 128-key KV limit. - -The narrative section says "127 pages + 1 metadata" which is correct, but the -pseudocode contradicts it. - -**Fix**: Change the check to `buffer.size > 127` (page entries only), so -127 pages + 1 metadata = 128 keys fits exactly. - -### 6. ROLLBACK must restore file.size - -During batch mode, xWrite must update `file.size` in memory (otherwise -`xFileSize` returns stale values). On ROLLBACK_ATOMIC_WRITE, the buffer is -discarded but `file.size` remains at the extended value. Subsequent operations -see a file size that doesn't match KV state. - -**Fix**: Save `file.size` at BEGIN_ATOMIC_WRITE time. Restore it on -ROLLBACK_ATOMIC_WRITE. (On COMMIT, the persisted metadata catches up to the -in-memory value, so no restore needed.) - -### 10. Batch mode scope must be per-file - -The spec defines `batchMode` and the dirty buffer without specifying their -scope. SQLite sends xFileControl to the main database file handle only. During -journal fallback, xWrite calls go to the journal file via a different fileId. -If batchMode is global, journal writes during fallback would be incorrectly -buffered. - -**Fix**: Explicitly state that `batchMode` and the dirty buffer are properties -on `OpenFile`, not on the `SqliteSystem` or `Database`. - -### 13. putBatch atomicity is a hard requirement - -The safety argument states "KV `putBatch` is all-or-nothing" but this is -asserted, not validated. The entire batch path crash recovery depends on this. -In production (FoundationDB), this holds. In test/dev drivers, it may not. - -**Fix**: Add a note in the safety argument that putBatch atomicity is a hard -requirement for the batch path to be safe, and that the KV driver must guarantee -all-or-nothing semantics. - -### 14. ~~Buffer state after putBatch failure~~ (SUPERSEDED by Issue 28) - -Original recommendation was to leave the buffer intact on putBatch failure and -let ROLLBACK_ATOMIC_WRITE clean up. This is **wrong per the SQLite contract**. -The SQLite xFileControl documentation states: "Regardless of whether or not the -commit succeeded, the batch write mode is ended by this file control." See -Issue 28 for the correct approach: COMMIT_ATOMIC_WRITE must tear down batch -mode itself on failure. - -### 16. No verification that BATCH_ATOMIC is active - -If xDeviceCharacteristics doesn't return the flag (implementation bug), SQLite -silently falls back to journal mode for every transaction. No error, no warning. -Test 7 ("no journal file operations") would catch this, but the spec should -explicitly call out the risk. - -**Fix**: Add a note that Test 7 serves as the BATCH_ATOMIC activation check. -Consider also adding a one-time log at open time confirming BATCH_ATOMIC is -active (e.g., "VFS: BATCH_ATOMIC enabled"). - -## PARTIAL issues (clarify in spec) - -### 3. Journal fallback round trips - -The spec claims ~6 RT for N=200 pages during fallback. With the current xWrite -implementation, each xWrite call is a separate putBatch (1 RT each). So the -naive fallback is ~400 RT, not ~6. - -The `ceil(N/127)` formula only works if the implementation adds write-batching -in the non-batch xWrite path (e.g., accumulate writes and flush at xSync). The -spec doesn't describe this. - -**Status**: The fallback is explicitly described as rare (most transactions touch -fewer than 127 pages). The spec should either (a) acknowledge the fallback is -~N*2 RT with current xWrite, or (b) describe an xSync-triggered flush mechanism -for the fallback path. - -### 7. Metadata tracking - -The spec says "putBatch(buffer entries + metadata)" without specifying when -metadata is constructed. This is an obvious implementation detail (construct at -commit time from current file.size). The real gap is file.size tracking, covered -by Issue 6. - -### 8. ~~xTruncate in batch mode~~ (SUPERSEDED by Issue 29) - -Originally flagged as a gap in deletion tracking. Reclassified as BULLSHIT -because the SQLite FCNTL documentation states that between BEGIN_ATOMIC_WRITE -and COMMIT/ROLLBACK, the only operations on the file descriptor are xWrite and -xFileControl(SIZE_HINT). xTruncate is never called during batch mode. See -Issue 29. - -### 15. 976 KiB payload limit - -The entry count check (127 pages) implicitly enforces the payload limit: -127 * 4096 + overhead = ~525 KiB, well under 976 KiB. The spec should document -this arithmetic so future readers don't have to derive it. - -### 20. Test 9 is weak alone - -Test 9 only checks that journal operations appeared and putBatchCalls > 1. It -doesn't verify efficiency of the fallback. But Tests 10+11 verify data integrity -and restart survival, completing the verification. - -### 21. No explicit cold-start RT test - -Test 11 covers restart survival. A dedicated test for cold-start round trip -counts (first-ever actor open + migration + first write) would be useful but -isn't critical. - -### 24. Other xDeviceCharacteristics flags - -`SQLITE_IOCAP_ATOMIC`, `SQLITE_IOCAP_SAFE_APPEND`, etc. could provide minor -optimizations. But BATCH_ATOMIC is the only flag that fundamentally changes the -commit path. Low priority. - -## BULLSHIT issues (no action needed) - -### 1. Copy-paste bug in BEGIN_ATOMIC_WRITE - -Reviewer misread the pseudocode. The actual spec text for BEGIN is: -``` -BEGIN_ATOMIC_WRITE (31): - Set batchMode = true. - Allocate dirty buffer: Map. - Return SQLITE_OK. -``` -No "clear buffer" or "set batchMode = false" appears. Those lines are in the -COMMIT and ROLLBACK handlers where they belong. - -### 4. Existing databases have journal_mode=OFF in header - -`journal_mode=OFF` is session-level in SQLite, NOT persistent. Only WAL mode -modifies the database header (bytes 18-19 set to 0x02). All rollback modes -(DELETE, TRUNCATE, PERSIST, OFF) use header bytes 0x01 and must be set via -PRAGMA on every open. Removing the PRAGMA means existing databases open in -DELETE mode (the default), which is exactly what the spec wants. - -Additionally, BATCH_ATOMIC eligibility does NOT check journal_mode. The four -conditions are: (1) single database, (2) xDeviceCharacteristics includes -BATCH_ATOMIC, (3) synchronous is not OFF, (4) journal in memory. - -### 9. Partial-chunk writes need async KV reads - -CHUNK_SIZE = page_size = 4096 (intentionally aligned, documented in kv.ts:17). -SQLite's page-level I/O always writes complete pages. Every xWrite during batch -mode is a complete chunk replacement. No partial-chunk merging needed. - -### 11. xSync during batch mode - -SQLite does not call xSync between BEGIN_ATOMIC_WRITE and COMMIT_ATOMIC_WRITE. -The batch path bypasses sync logic entirely. During fallback, batchMode is -already false (ROLLBACK_ATOMIC exited it) before xSync is called. xSync during -batchMode=true is not a real scenario. - -### 12. xRead buffer check is load-bearing for page 1 - -With `locking_mode = EXCLUSIVE`, the pager cache is trusted across transactions. -Page 1's change counter is updated in-cache via `pager_incr_changecounter()` and -read back from cache, not via xRead. The buffer check is defense-in-depth, not -a correctness requirement. - -### 17-19, 22-23, 25-26 - -These are either standard SQLite behavior that doesn't need specification, -already handled by existing code, or implementation details that are obvious -from the existing VFS code. See verdict table above. - ---- - -## Third-round issues (SQLite contract review) - -### 27. Spec conflates SQL ROLLBACK with ROLLBACK_ATOMIC_WRITE - -The BATCH_ATOMIC protocol lives inside `sqlite3PagerCommitPhaseOne()`. The -sequence is: user issues COMMIT (or autocommit) -> pager calls -BEGIN_ATOMIC_WRITE -> xWrite per dirty page -> COMMIT_ATOMIC_WRITE. A -user-issued `BEGIN; INSERT; ROLLBACK;` never enters the commit path at all. -The pager restores pages from its in-memory journal. No xFileControl calls -happen. - -The spec conflates these in four places: - -- Line 66: "On ROLLBACK: SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE" presented as a - general rollback mechanism, when it only occurs during commit failure. -- Line 272 (safety argument): "The VFS discards the dirty buffer" during - ROLLBACK. But a user-issued ROLLBACK never enters batch mode, so there is - no dirty buffer to discard. -- Line 196 (RT table): "ROLLBACK = 0 RT" is true but not because of - BATCH_ATOMIC. It's because the pager cache rollback is in-memory. -- Test 5 (line 383): `rollbackTest` (`BEGIN; INSERT; ROLLBACK;`) asserting - `getBatchCalls == 0` is too strong. The INSERT inside the transaction may - require cold-page reads via xRead -> getBatch. The correct assertion is - `putBatchCalls == 0` (no writes persisted), but getBatch may be non-zero. - -**Fix**: Rewrite all four locations to distinguish between: -1. SQL ROLLBACK: pager restores from in-memory journal. No batch mode involved. - No KV writes (putBatch == 0). KV reads are possible for cold pages. -2. ROLLBACK_ATOMIC_WRITE: only called during commit failure fallback. Discards - the dirty buffer and restores file.size. - -Test 5 should assert `putBatchCalls == 0` only, not `getBatchCalls == 0`. - -### 28. COMMIT_ATOMIC_WRITE failure must tear down batch mode - -The SQLite xFileControl documentation states: "Regardless of whether or not -the commit succeeded, the batch write mode is ended by this file control." - -The spec (line 138, 142) says the opposite: "Do NOT clear the buffer here. -ROLLBACK_ATOMIC_WRITE will clean up." and "Leave buffer intact." - -While current pager.c does issue ROLLBACK_ATOMIC as a best-effort hint after -COMMIT failure, the FCNTL contract says COMMIT itself ends batch mode. Relying -on ROLLBACK_ATOMIC for cleanup is coding against an implementation detail of -pager.c, not the documented VFS contract. - -**Fix**: Rewrite COMMIT_ATOMIC_WRITE pseudocode: - -``` -COMMIT_ATOMIC_WRITE (32): - If dirtyBuffer.size > 127: - Clear dirtyBuffer, restore file.size, set batchMode = false. - Return SQLITE_IOERR. - Construct entries: [...dirtyBuffer entries, metadata]. - Call putBatch(entries). - If putBatch fails: - Clear dirtyBuffer, restore file.size, set batchMode = false. - Return SQLITE_IOERR. - Clear dirtyBuffer, set batchMode = false. - Return SQLITE_OK. -``` - -ROLLBACK_ATOMIC_WRITE becomes a no-op if batch mode is already exited (which -is the expected case). Keep it as a defensive fallback that checks batchMode -before acting. - -Supersedes Issue 14. - -### 29. Batch-mode xTruncate design is dead code - -The SQLite FCNTL documentation for BEGIN_ATOMIC_WRITE states: "Between the -BEGIN and COMMIT or ROLLBACK calls, the only operations on that file descriptor -will be xWrite calls and possibly xFileControl(SQLITE_FCNTL_SIZE_HINT) calls." - -xTruncate is never called during the batch window. The entire `pendingDeletes` -/ tombstone design (spec lines 173-183) is solving a path SQLite does not -take. If stale KV chunks need cleanup after VACUUM or file shrink, that should -be modeled as post-commit cleanup, not as part of the atomic batch. - -**Fix**: Remove the batch-mode xTruncate section entirely. Drop -`pendingDeletes` from the OpenFile fields. If KV chunk cleanup after truncation -is needed, design it as a separate post-commit mechanism. - -Supersedes Issue 8. - -### 30. auto_vacuum=NONE is persistent, not a runtime switch - -`auto_vacuum` is stored in the database header (bytes 52-55). Unlike -`journal_mode` (session-level for rollback modes) and `synchronous` -(session-level), `auto_vacuum` persists. SQLite only allows changing -auto_vacuum mode on empty databases. For existing databases with tables, -`PRAGMA auto_vacuum = NONE` is silently ignored. - -This means: if an actor database was ever opened by a version of the code that -did not set auto_vacuum (allowing SQLite's default, which varies by build), -the PRAGMA on re-open does nothing. The database keeps its original mode. - -**Fix**: Document in the spec that `auto_vacuum = NONE` only takes effect for -newly created databases. For existing databases, the mode is whatever was set -at creation time. Since the current code does NOT set auto_vacuum, existing -DBs use whatever default SQLite was compiled with (typically NONE, but not -guaranteed). This is acceptable for actor databases that are small and -short-lived, but should be documented as a known limitation. - -### 31. No explicit journal_mode=DELETE guard - -The spec says "No journal_mode PRAGMA" and relies on SQLite's default (DELETE). -However, WAL mode persists in the database header (bytes 18-19 set to 0x02). -If a database ever enters WAL mode (e.g., user code runs -`PRAGMA journal_mode=WAL`), reopening without an explicit journal_mode PRAGMA -would keep WAL mode, which interacts with BATCH_ATOMIC differently than -expected. - -Current code sets `journal_mode=OFF` (session-level, does not persist). The -new code removes this PRAGMA entirely. DELETE is the default for non-WAL -databases, so this is safe as long as no database ever enters WAL mode. - -**Fix**: Add `PRAGMA journal_mode = DELETE` to the PRAGMA list. This is cheap -insurance: it's a no-op for databases already in DELETE mode, and it forces -WAL databases back to DELETE mode. Alternatively, assert at open time that the -journal mode is not WAL. - -### 32. page_size=4096 assumed but not enforced - -The spec assumes `page_size == CHUNK_SIZE == 4096` throughout: "all SQLite -xWrite calls are complete chunk replacements" (line 163), the buffer limit -math (127 * 4096), and the payload limit proof (525 KiB). kv.ts:27 explicitly -warns: "If page_size is ever changed via PRAGMA, CHUNK_SIZE must be updated." - -But nothing in the VFS prevents a different page_size. If user code runs -`PRAGMA page_size = 8192` before creating tables, writes would span 2 chunks -per page, breaking the 1:1 mapping assumption. - -**Fix**: Add `PRAGMA page_size = 4096` to the PRAGMA list, or add a runtime -assertion after open that verifies `PRAGMA page_size` returns 4096. The PRAGMA -approach is preferred because it's declarative and takes effect before any -tables are created. diff --git a/specs/sqlite-vfs-rust-wasm.md b/specs/sqlite-vfs-rust-wasm.md deleted file mode 100644 index 324afc3146..0000000000 --- a/specs/sqlite-vfs-rust-wasm.md +++ /dev/null @@ -1,190 +0,0 @@ -# Rust SQLite VFS With Native SQLite + Wasm Fallback - -## Summary - -Replace `@rivetkit/sqlite-vfs` with a Rust-backed implementation that uses embedded, official SQLite in the native dylib build and a wasm build as a fallback. The package must load a native dylib in Node.js and Bun when available, and fall back to wasm on other platforms. The public JS API remains stable so existing callers in `rivetkit/db` continue to work unchanged. - -## Goals - -- Provide a Rust implementation of the SQLite VFS logic while keeping the existing JS API surface. -- Use embedded, official SQLite in the native dylib build. -- Use SQLite as wasm for fallback platforms. -- Support Node.js and Bun via a native addon (dylib) that internally hosts the sqlite-wasm runtime. -- Provide a wasm fallback path for unsupported platforms. -- Preserve the current KV key format, chunking, and metadata schema so existing data remains valid. -- Allow `CHUNK_SIZE` to be configured, defaulting to 4096. -- Provide a dedicated test wrapper package for running vitest tests against an in-memory KV driver. - -## Non-goals - -- Changing the public `SqliteVfs` API or `KvVfsOptions` shape in a breaking way. -- Introducing new storage formats or automatic migrations. -- Adding full multi-process WAL or shared-memory support beyond what exists today. -- Replacing or changing actor KV semantics outside the VFS boundary. - -## Current Behavior to Preserve - -- KV key format and constants in `rivetkit-typescript/packages/sqlite-vfs/src/kv.ts`. -- Chunk size is 4096 bytes. -- File metadata schema is `FileMeta { size: u64 }`. -- WAL and shared-memory operations are not implemented in the current VFS. Behavior should remain equivalent. -- The JS API exposes `SqliteVfs.open(fileName, options)` returning a `Database` with `exec` and `close`. - -## Requirements - -- Must embed SQLite as wasm. -- Must support native dylib loading in Node.js and Bun. -- Must provide wasm fallback for other platforms. -- Must preserve deterministic KV behavior across runtimes. -- Must keep `@rivetkit/sqlite-vfs` as the published entrypoint for callers. - -## Proposed Architecture - -### High-Level Components - -- `rivetkit-sqlite-vfs-core` (Rust crate) - - Implements the KV-backed VFS logic and SQLite host bindings. - - Defines a Rust trait for KV operations that mirrors `KvVfsOptions`. - - Owns key formatting, chunking, and metadata encode/decode. - -- `rivetkit-sqlite-vfs-wasm` (Rust crate) - - Builds the SQLite wasm module with async host call support. - - Exposes a wasm-friendly interface for KV callbacks. - -- `rivetkit-sqlite-vfs-native` (Rust crate) - - N-API addon that embeds the official SQLite amalgamation. - - Exposes the JS API and bridges to KV callbacks from JS. - -- `@rivetkit/sqlite-vfs` (JS package) - - Minimal wrappers for native and wasm bindings. - - Exposes `./native` and `./wasm` entrypoints. - - The runtime selection and KV binding logic lives in `rivetkit/db` to reduce glue code. - -- `@rivetkit/sqlite-vfs-test` (JS package) - - Vitest wrapper package that runs the sqlite-vfs test suite against an in-memory KV driver. - - Can target either native or wasm backend via environment selection. - -### Runtime Flow - -- `rivetkit/db` imports the sqlite-vfs native binding when available, otherwise imports the wasm wrapper directly. -- Both bindings expose the same minimal JS API that accepts `KvVfsOptions` and returns a `Database`. -- Runtime selection is centralized in `rivetkit/db` to minimize glue and keep the integration next to actor KV wiring. -- Optional debug override: allow an environment variable to force backend selection (see "Runtime Selection"). - -### SQLite Build - -- Native: - - Embed the official SQLite amalgamation directly in the native Rust crate. - - Do not link against system SQLite to preserve feature parity. -- Wasm: - - Build SQLite to wasm with async host call support to allow `getBatch` and `putBatch` to be awaited. - - Follow the same feature set as the @rivetkit/sqlite build for compatibility. - - The sqlite-wasm module must expose the VFS registration and open/exec APIs used today. - -## API and ABI Design - -### JavaScript API (Unchanged) - -- `class SqliteVfs` - - `open(fileName: string, options: KvVfsOptions): Promise` - -- `interface KvVfsOptions` - - `get(key: Uint8Array): Promise` - - `getBatch(keys: Uint8Array[]): Promise<(Uint8Array | null)[]>` - - `put(key: Uint8Array, value: Uint8Array): Promise` - - `putBatch(entries: [Uint8Array, Uint8Array][]): Promise` - - `deleteBatch(keys: Uint8Array[]): Promise` - -- `class Database` - - `exec(sql: string, callback?: (row: unknown[], columns: string[]) => void): Promise` - - `close(): Promise` - -### Rust KV Trait - -- `trait KvVfs` - - `get(&self, key: &[u8]) -> Future>>` - - `get_batch(&self, keys: Vec>) -> Future>>>` - - `put(&self, key: Vec, value: Vec) -> Future<()>` - - `put_batch(&self, entries: Vec<(Vec, Vec)>) -> Future<()>` - - `delete_batch(&self, keys: Vec>) -> Future<()>` - -### Key Encoding - -- Must match `rivetkit-typescript/packages/sqlite-vfs/src/kv.ts`. -- `SQLITE_PREFIX = 9`, `META_PREFIX = 0`, `CHUNK_PREFIX = 1`. -- Chunk index encoded as big-endian `u32`. - -### Chunk Size Configuration - -- Default `CHUNK_SIZE` is 4096 bytes (unchanged). -- Add an optional configuration parameter (e.g. `SqliteVfsConfig`) to allow overrides. -- The override affects new databases only. Existing databases remain on 4096 unless migrated intentionally. - -## Storage Strategy - -- Use a snapshot-based persistence model instead of page-level VFS hooks. -- On `open`, load the full database file bytes from KV (chunked by the existing key scheme). -- On `exec` and `close`, export the full database bytes and write them back to KV in chunks. -- This preserves the exact database file bytes and keeps compatibility with existing data. - -## VFS Semantics - -- The VFS surface is implemented at the package boundary, but storage is snapshot-based. -- WAL and shared-memory are not used. -- Journal mode is forced to `DELETE` in native mode to ensure the main database file contains all data. - -## Packaging and Distribution - -### Biome-Style Native Packaging - -- Follow the same pattern as Biome: a thin base package with `optionalDependencies` on platform-specific binary packages. -- Each platform-specific package contains the prebuilt `.node` addon and a tiny JS wrapper. -- The base package exposes a stable JS API and relies on Node/Bun module resolution to pull in the correct optional dependency. - -- Native addon: - - Built with N-API to support Node.js 20+ and Bun. - - Ship per-platform binaries for macOS, Linux, and Windows. -- Wasm fallback: - - Bundle sqlite-wasm module and JS loader. - - Provide the same API surface. -- Provide both ESM and CJS entrypoints for Node and Bun. - -## Runtime Selection - -- Default: attempt native binding first, fall back to wasm. -- Optional debug override (environment variable) to force `native` or `wasm`, used for CI and troubleshooting. - - Example name: `RIVETKIT_SQLITE_BACKEND=native|wasm`. - -## Testing Strategy - -- Add a new wrapper package `@rivetkit/sqlite-vfs-test` that depends on `@rivetkit/sqlite-vfs`. -- The wrapper package provides: - - An in-memory KV driver identical to the current `sqlite-vfs.test.ts` setup. - - A vitest runner that can target `native` or `wasm` backends via `RIVETKIT_SQLITE_BACKEND`. -- Port existing test `sqlite-vfs.test.ts` into the wrapper package and run against both native and wasm backends. -- Add new tests: - - Data persistence across instances. - - Reopen and schema migration smoke test. - - Chunk boundary read/write tests. - - Concurrent open serialization behavior. -- Add a parity test that runs the same suite against native and wasm paths. - -## Migration Plan - -- Keep the `@rivetkit/sqlite-vfs` package name and exports stable. -- Replace the implementation under the same entrypoint. -- Verify that `rivetkit/db` and `rivetkit/db/drizzle` continue to work unchanged. - -## Risks and Mitigations - -- Async host call support in sqlite-wasm: - - Ensure asyncify or equivalent is part of the wasm build and used consistently in both runtimes. -- JS to Rust callback overhead: - - Maintain `getBatch` and `putBatch` usage to minimize round trips. -- Locking and WAL semantics: - - Preserve current behavior and document limits. Avoid introducing WAL unless fully implemented. - -## Open Questions - -- Which compile-time SQLite flags are enabled in @rivetkit/sqlite. These must be mirrored in both native and wasm builds and documented here once identified. -- Whether to expose additional debug logging flags consistent with `VFS_DEBUG`. diff --git a/specs/sqlite-vfs-single-writer-findings.md b/specs/sqlite-vfs-single-writer-findings.md deleted file mode 100644 index 1d421c3bfb..0000000000 --- a/specs/sqlite-vfs-single-writer-findings.md +++ /dev/null @@ -1,610 +0,0 @@ -# SQLite VFS Single-Writer Optimization: Review Findings - -Date: 2026-03-21 -Status: Research notes from adversarial review of `sqlite-vfs-single-writer-optimization.md` - -## Key Discovery: SQLITE_IOCAP_BATCH_ATOMIC - -**This is the recommended approach.** It replaces the entire Phase 1 + Phase 2 -design from the original spec with a simpler, safer, and equally fast solution. - -`SQLITE_IOCAP_BATCH_ATOMIC` is an SQLite VFS capability flag that tells SQLite: -"this storage backend can atomically write multiple pages in one operation." -When declared, SQLite keeps the rollback journal **entirely in memory** and -brackets all page writes with file control opcodes that let the VFS batch them. - -### How it works - -1. VFS returns `SQLITE_IOCAP_BATCH_ATOMIC` from `xDeviceCharacteristics()`. -2. On transaction commit, SQLite calls `xFileControl(SQLITE_FCNTL_BEGIN_ATOMIC_WRITE)`. -3. SQLite calls `xWrite()` once per dirty page (same as today). -4. SQLite calls `xFileControl(SQLITE_FCNTL_COMMIT_ATOMIC_WRITE)`. -5. On rollback, SQLite calls `xFileControl(SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE)`. - -The VFS buffers all xWrite calls between BEGIN and COMMIT, then flushes them -in a single `putBatch` on COMMIT. On ROLLBACK, the buffer is discarded. - -### Why this is better than journal_mode=OFF + write-behind buffer - -| Property | journal_mode=OFF + buffer | BATCH_ATOMIC | -|----------|--------------------------|--------------| -| Warm write RT | 1 putBatch | **1 putBatch** (same) | -| ROLLBACK support | Must implement ourselves | **SQLite manages it** | -| Transaction boundaries | Must detect via autocommit | **SQLite tells us** (BEGIN/COMMIT/ROLLBACK opcodes) | -| Flush timing | Must wire into Database class | **SQLite calls at right time** | -| Journal safety | None (journal_mode=OFF) | **In-memory journal** (graceful fallback) | -| Pager cache overflow | Corruption risk | **Falls back to disk journal** | -| Code complexity | ~100 lines + wiring | **~30 lines in xFileControl** | - -### What's available in @rivetkit/sqlite - -All required constants are exported from `@rivetkit/sqlite` (v0.1.1): - -- `SQLITE_FCNTL_BEGIN_ATOMIC_WRITE = 31` -- `SQLITE_FCNTL_COMMIT_ATOMIC_WRITE = 32` -- `SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE = 33` -- `SQLITE_IOCAP_BATCH_ATOMIC = 0x00004000` - -`SQLITE_ENABLE_BATCH_ATOMIC_WRITE` is compiled into the WASM binary. - -A complete reference implementation exists at -`@rivetkit/sqlite/src/examples/IDBBatchAtomicVFS.js` (IndexedDB-backed). - -### Current VFS state - -```typescript -// vfs.ts:1192-1198 - currently returns "not implemented" / "no capabilities" -xFileControl(_fileId: number, _flags: number, _pArg: number): number { - return VFS.SQLITE_NOTFOUND; -} -xDeviceCharacteristics(_fileId: number): number { - return 0; -} -``` - -### Implementation plan - -1. `xDeviceCharacteristics` returns `SQLITE_IOCAP_BATCH_ATOMIC`. -2. `xFileControl` handles three opcodes: - - `BEGIN_ATOMIC_WRITE`: Set a flag, start buffering xWrite calls. - - `COMMIT_ATOMIC_WRITE`: Flush all buffered writes via single `putBatch`. - - `ROLLBACK_ATOMIC_WRITE`: Discard the buffer. -3. `xWrite` checks the flag. If in batch mode, buffer the write. If not, write - directly (current behavior, for non-transactional writes). -4. Remove `PRAGMA journal_mode = OFF`. SQLite will use DELETE mode with an - in-memory journal (no journal file I/O because BATCH_ATOMIC eliminates it). -5. Keep `PRAGMA locking_mode = EXCLUSIVE` (still correct for single-writer). -6. Change `PRAGMA synchronous = OFF` to `PRAGMA synchronous = NORMAL`. There is - a confirmed bug where `synchronous=OFF` + `IOCAP_BATCH_ATOMIC` creates a - corruption window (reported by Roy Hashimoto, fixed on SQLite trunk). Using - NORMAL avoids this. With BATCH_ATOMIC, NORMAL does not add extra xSync calls - because the batch write path bypasses the sync logic. -7. Add `PRAGMA temp_store = MEMORY` and `PRAGMA auto_vacuum = NONE`. - -### Caveats - -- **128-key putBatch limit**: If a transaction dirties >127 pages (508 KiB), - the single putBatch will exceed the limit. Options: hard limit with error - (recommended), or split into multiple putBatch calls with a committed-index - protocol (future). -- **synchronous=OFF bug**: Verify whether the wa-sqlite WASM build includes the - fix. If not, use `synchronous=NORMAL` (no performance penalty with BATCH_ATOMIC). - -### Detailed round trip analysis - -With BATCH_ATOMIC, SQLite manages the transaction lifecycle. The VFS buffers -xWrite calls between BEGIN_ATOMIC_WRITE and COMMIT_ATOMIC_WRITE, then flushes -all buffered pages + metadata in a single putBatch. - -**Key behavior**: SQLite still calls xWrite once per dirty page. The VFS -buffers these in a `Map` instead of calling putBatch. On -COMMIT_ATOMIC_WRITE, the VFS issues one putBatch with all buffered entries. - -**xRead during batch mode**: SQLite's pager cache (trusted due to -`locking_mode = EXCLUSIVE`) serves pages that were just written without calling -xRead. The VFS should still check the dirty buffer in xRead as a safety net, -but in practice SQLite won't call xRead for a page it just wrote. - -#### Scenario breakdown - -**Actor cold start (first query)**: -``` -sqlite3.open_v2() → xOpen → getBatch([meta:main]) = 1 RT (read file metadata) -PRAGMA locking_mode = ... → no KV ops -First SQL statement: - Page 1 (header) → xRead → getBatch([chunk:main[0]]) = 1 RT - ...additional pages → xRead per page = 1 RT each -``` -The cold start always pays at least 2 RT: 1 for file metadata + 1 for page 1. -Subsequent pages add 1 RT each unless batched in a single getBatch. - -**Warm single-page UPDATE** (page in pager cache): -``` -BEGIN_ATOMIC_WRITE → set buffer flag (0 KV ops) -xWrite(page) → buffer[chunk:main[N]] = data (0 KV ops) -COMMIT_ATOMIC_WRITE → putBatch([chunk:main[N], meta:main]) = 1 RT - Total: 1 RT -``` - -**Warm multi-page UPDATE** (e.g., INSERT with index, 3 dirty pages): -``` -BEGIN_ATOMIC_WRITE → set buffer flag (0 KV ops) -xWrite(page A) → buffer[chunk:main[A]] (0 KV ops) -xWrite(page B) → buffer[chunk:main[B]] (0 KV ops) -xWrite(page C) → buffer[chunk:main[C]] (0 KV ops) -COMMIT_ATOMIC_WRITE → putBatch([chunk:A, chunk:B, chunk:C, meta:main]) = 1 RT - Total: 1 RT -``` - -**Cold single-page UPDATE** (page not in cache): -``` -xRead(page) → getBatch([chunk:main[N]]) = 1 RT -BEGIN_ATOMIC_WRITE → (0 KV ops) -xWrite(page) → buffer (0 KV ops) -COMMIT_ATOMIC_WRITE → putBatch([chunk:main[N], meta:main]) = 1 RT - Total: 2 RT -``` - -**Warm SELECT** (page in pager cache): -``` -(pager cache hit) → 0 KV ops - Total: 0 RT -``` - -**Cold SELECT** (page not in cache): -``` -xRead(page) → getBatch([chunk:main[N]]) = 1 RT - Total: 1 RT -``` - -**Warm UPDATE then SELECT** (same page): -``` -UPDATE: 1 RT (putBatch) -SELECT: 0 RT (pager cache, trusted via EXCLUSIVE) - Total: 1 RT -``` - -**ROLLBACK**: -``` -BEGIN_ATOMIC_WRITE → set buffer flag (0 KV ops) -xWrite(page A) → buffer (0 KV ops) -xWrite(page B) → buffer (0 KV ops) -ROLLBACK_ATOMIC_WRITE → discard buffer (0 KV ops) - Total: 0 RT -``` - -**Explicit multi-statement transaction**: -``` -BEGIN; - INSERT ... → xWrite calls buffered (0 KV ops) - INSERT ... → xWrite calls buffered (0 KV ops) - UPDATE ... → xWrite calls buffered (0 KV ops) -COMMIT; - COMMIT_ATOMIC_WRITE → putBatch(all dirty pages + meta) = 1 RT - Total: 1 RT (writes) -``` - -#### Summary table - -| Scenario | getBatch RT | putBatch RT | Total RT | -|----------|-------------|-------------|----------| -| Warm single-page UPDATE | 0 | 1 | **1** | -| Warm multi-page UPDATE (N pages) | 0 | 1 | **1** | -| Cold single-page UPDATE | 1 | 1 | **2** | -| Cold multi-page UPDATE (N pages) | 1+ | 1 | **2+** | -| Warm SELECT | 0 | 0 | **0** | -| Cold SELECT | 1 | 0 | **1** | -| Warm UPDATE + SELECT (same page) | 0 | 1 | **1** | -| ROLLBACK | 0 | 0 | **0** | -| Multi-stmt tx (warm, M stmts) | 0 | 1 | **1** | - -### Large transaction handling (>127 dirty pages) - -When COMMIT_ATOMIC_WRITE is called and the dirty buffer contains >127 pages -(+ 1 meta entry = 128 entries, exceeding the putBatch limit): - -**Option A: Return SQLITE_IOERR (recommended)** - -The VFS returns SQLITE_IOERR from COMMIT_ATOMIC_WRITE. SQLite then: -1. Calls ROLLBACK_ATOMIC_WRITE (VFS discards the buffer). -2. Falls back to standard journal mode for this transaction. -3. Writes original pages to a journal file via xWrite (to FILE_TAG_JOURNAL - keys in KV). Multiple putBatch calls are needed. -4. Writes modified pages to the main file via xWrite. Multiple putBatch calls. -5. Deletes the journal file (commit point). - -This fallback path costs 3+ RT but is correct (journal provides crash safety) -and rare (most actor transactions touch <127 pages). The VFS already has full -journal file support via FILE_TAG_JOURNAL routing in `#resolveFile()`. - -Round trips for fallback: -``` -Journal write: ceil(N/127) putBatch calls (original pages + journal meta) -Main write: ceil(N/127) putBatch calls (modified pages) -Journal delete: 1 deleteBatch call -Sync: 1 putBatch (if synchronous=NORMAL) -Total: ~2*ceil(N/127) + 2 RT -``` - -For N=200 pages: ~6 RT. Slower but correct and very rare. - -**Option B: Split putBatch (not recommended)** - -Split the buffer into multiple putBatch calls. This is NOT atomic across -batches, so a crash between batches corrupts the database. Only viable with the -committed-index protocol (additional complexity). - -**Option C: Hard error (simplest)** - -Return SQLITE_FULL or SQLITE_IOERR and fail the transaction entirely. The -application must restructure to use smaller transactions. This is the simplest -approach but least flexible. - -**Recommendation**: Option A. Let SQLite's own fallback handle it. The journal -infrastructure already exists in the VFS. Large transactions are rare and the -3+ RT cost is acceptable for correctness. - -### KV round trip verification tests - -The existing `db-kv-stats` fixture (`fixtures/driver-test-suite/db-kv-stats.ts`) -provides instrumented KV with per-operation call counts and operation logs. The -existing test is diagnostic only (logs operations, asserts putBatch > 0). After -implementing BATCH_ATOMIC, replace with strict round trip assertions. - -#### Test fixture changes needed - -Add these actions to `db-kv-stats.ts`: - -- `insertWithIndex`: INSERT into a table with an index (forces multi-page write). -- `rollbackTest`: BEGIN; INSERT ...; ROLLBACK; (verifies 0 KV writes). -- `multiStmtTx`: BEGIN; INSERT ...; INSERT ...; COMMIT; (verifies 1 putBatch). -- `bulkInsert(n)`: Insert N rows in a single transaction to test large tx behavior. -- `bulkInsertLarge()`: Insert enough rows in one tx to exceed 127 dirty pages - (triggers journal fallback). - -#### Test cases (`actor-db-kv-stats.ts`) - -Each test calls `resetStats()`, performs an operation, then calls `getStats()` -and `getLog()` to assert exact KV call counts. - -**Test 1: Warm single-page UPDATE = 1 putBatch, 0 getBatch** -``` -resetStats() -increment() // UPDATE counter SET count = count + 1 -stats = getStats() -expect(stats.putBatchCalls).toBe(1) -expect(stats.getBatchCalls).toBe(0) -``` - -**Test 2: Warm SELECT after UPDATE = 0 KV ops** -``` -increment() // warm the page -resetStats() -getCount() // SELECT (should hit pager cache) -stats = getStats() -expect(stats.getBatchCalls).toBe(0) -expect(stats.putBatchCalls).toBe(0) -``` - -**Test 3: Warm UPDATE + SELECT = 1 putBatch, 0 getBatch** -``` -increment() // warm -resetStats() -incrementAndRead() // UPDATE + SELECT -stats = getStats() -expect(stats.putBatchCalls).toBe(1) -expect(stats.getBatchCalls).toBe(0) -``` - -**Test 4: Multi-page INSERT (with index) = 1 putBatch** -``` -resetStats() -insertWithIndex() // INSERT into table with index (touches 2-3 pages) -stats = getStats() -expect(stats.putBatchCalls).toBe(1) // all pages in one batch -log = getLog() -// verify log shows single putBatch with multiple chunk keys -expect(log.filter(e => e.op === "putBatch")).toHaveLength(1) -expect(log[0].keys.filter(k => k.startsWith("chunk:"))).toHaveLength(...) -``` - -**Test 5: ROLLBACK = 0 putBatch, 0 getBatch** -``` -resetStats() -rollbackTest() // BEGIN; INSERT ...; ROLLBACK; -stats = getStats() -expect(stats.putBatchCalls).toBe(0) -expect(stats.getBatchCalls).toBe(0) -``` - -**Test 6: Multi-statement transaction = 1 putBatch** -``` -resetStats() -multiStmtTx() // BEGIN; INSERT; INSERT; COMMIT; -stats = getStats() -expect(stats.putBatchCalls).toBe(1) // all writes batched to single commit -``` - -**Test 7: Verify no journal file operations** -``` -resetStats() -increment() -log = getLog() -// No operations should touch journal or WAL file tags -for (const entry of log) { - for (const key of entry.keys) { - expect(key).not.toContain("journal") - expect(key).not.toContain("wal") - } -} -``` - -**Test 8: putBatch entry count within limits** -``` -resetStats() -increment() -log = getLog() -const putBatchEntry = log.find(e => e.op === "putBatch") -expect(putBatchEntry.keys.length).toBeLessThanOrEqual(128) -``` - -**Test 9: Large transaction (>127 pages) falls back to journal** - -This test verifies that transactions exceeding the 128-entry putBatch limit -still complete correctly by falling back to SQLite's standard journal mode. - -The test inserts enough data in a single transaction to dirty >127 pages. With -4 KiB pages, this requires writing ~508 KiB of data. A table with a TEXT -column and an index will dirty roughly 1 page per ~3.5 KiB of row data (data -page fills, B-tree splits, index pages). Inserting ~200 rows of ~2 KiB each -in a single BEGIN/COMMIT should exceed 127 dirty pages. -``` -resetStats() -bulkInsertLarge() // BEGIN; INSERT x200 large rows; COMMIT; -stats = getStats() -log = getLog() - -// BATCH_ATOMIC COMMIT failed, so SQLite fell back to journal mode. -// Verify journal file operations appeared in the log. -const journalOps = log.filter(e => - e.keys.some(k => k.includes("journal")) -) -expect(journalOps.length).toBeGreaterThan(0) - -// Verify multiple putBatch calls (journal write + main write batches). -expect(stats.putBatchCalls).toBeGreaterThan(1) - -// Verify all putBatch calls stayed within the 128-entry limit. -for (const entry of log.filter(e => e.op === "putBatch")) { - expect(entry.keys.length).toBeLessThanOrEqual(128) -} -``` - -**Test 10: Large transaction data integrity after journal fallback** - -Verifies that data written via the journal fallback path is correct and the -database is not corrupted. -``` -bulkInsertLarge() // INSERT ~200 large rows -count = getRowCount() // SELECT COUNT(*) -expect(count).toBe(200) - -// Verify database integrity -integrityResult = runIntegrityCheck() // PRAGMA integrity_check -expect(integrityResult).toBe("ok") -``` - -**Test 11: Large transaction survives actor restart** - -Verifies that data written via journal fallback is durable across actor -restarts (the journal was fully committed and deleted before the actor -responds). -``` -bulkInsertLarge() -// Force actor restart (destroy + recreate) -destroyAndRecreate() -count = getRowCount() -expect(count).toBe(200) - -integrityResult = runIntegrityCheck() -expect(integrityResult).toBe("ok") -``` - -These tests serve as regression guards. If BATCH_ATOMIC stops working (e.g., -wa-sqlite update removes the flag, or a PRAGMA change disables it), the round -trip counts will spike and tests will fail immediately. If the journal fallback -is broken, the large transaction tests will catch it. - ---- - -## Adversarial Review Summary - -Three independent adversarial reviews identified issues ranked by severity. -Most of these are resolved by the BATCH_ATOMIC approach. - -### Critical Issues (resolved by BATCH_ATOMIC) - -**1. Multi-page transaction atomicity gap** - -With `journal_mode = OFF`, each `xWrite` triggers a separate `putBatch`. Crash -between writes = corruption. BATCH_ATOMIC solves this: all writes are buffered -and flushed in one putBatch on COMMIT_ATOMIC_WRITE. - -**2. Flush semantics (per-statement vs per-transaction)** - -The original spec flushes per-statement, breaking multi-statement transactions. -BATCH_ATOMIC solves this: SQLite calls BEGIN/COMMIT at the correct transaction -boundaries. No application-level detection needed. - -**3. putBatch failure during flush** - -With BATCH_ATOMIC: if putBatch fails on COMMIT_ATOMIC_WRITE, return -SQLITE_IOERR. SQLite's in-memory journal still has the original pages. SQLite -can attempt ROLLBACK_ATOMIC_WRITE (discard buffer) and the pager falls back to -its pre-transaction state. This is strictly safer than journal_mode=OFF where -there is no recovery path. - -### High Issues - -**4. KV batch limits (128 keys / 976 KiB)** - -Still applies. Transactions exceeding 127 dirty pages need either a hard limit -or a multi-batch protocol. Start with hard limit. - -**5. xTruncate + dirty buffer interaction** - -Still applies in batch mode. xTruncate during a batch must purge buffered writes -beyond the truncation point. But this is simpler now: the buffer is only active -between BEGIN_ATOMIC_WRITE and COMMIT/ROLLBACK, so the scope is well-defined. - -**6. ROLLBACK broken with journal_mode=OFF** - -Fully resolved by BATCH_ATOMIC. SQLite keeps an in-memory journal and -ROLLBACK_ATOMIC_WRITE discards the buffer. Standard ROLLBACK works correctly. - -### Medium Issues - -**7. No rollback/detection plan**: Still relevant. Add PRAGMA quick_check on -startup. - -**8. Hibernation flush failure**: Still relevant. Log as error. - -**9. Validation plan**: Must pass all driver FS tests. Test ROLLBACK behavior. - ---- - -## Industry Research: How Others Handle KV-Backed SQLite - -### Cloudflare D1 / Durable Objects - -- SQLite runs on **local disk** with a custom VFS intercepting WAL writes. -- **WAL mode exclusively**. The VFS captures WAL frames and replicates them to - 5 followers across data centers (3-of-5 quorum for durability). -- Storage Relay Service (SRS) batches WAL changes for up to 10 seconds / 16 MB - before uploading to cold storage. -- "Output gate" pattern: blocks external communication until durability is - confirmed by quorum. -- No per-page KV round trips. Reads are local disk speed (microseconds). - -### Litestream (Fly.io) - -- **Not a custom VFS** (originally). Runs as a background process intercepting - WAL file changes. -- **WAL mode required**. Litestream holds a read transaction to prevent - auto-checkpoint, then copies new WAL frames to "shadow WAL" files. -- `SYNCHRONOUS=NORMAL` recommended (fsync only at checkpoint, not per-tx). -- Newer version has a writable VFS that batches dirty pages into LTX files - (similar to our putBatch approach). -- **Key lesson**: Dirty page batching + atomic upload is the right pattern for - remote-storage-backed SQLite. - -### rqlite - -- SQLite on local disk with **WAL mode** and **`SYNCHRONOUS=OFF`**. -- Durability comes from Raft log (BoltDB), not SQLite's own durability. -- Statement-based replication (ships SQL, not pages). -- **Queued writes**: Batches multiple write requests into a single Raft entry. - ~15x throughput improvement. -- Periodic fsync strategy tied to Raft snapshots. SQLite file may be - inconsistent after OS crash; rebuilt from Raft log. - -### Turso / libSQL - -- Fork of SQLite with **virtual WAL methods** (hooks for custom WAL backends). -- **WAL mode exclusively** for replication. -- "Bottomless" VFS replicates WAL frames to S3 in batches (1000 frames or 10s). -- Embedded replicas for local reads. - -### LiteFS (Fly.io) - -- **FUSE-based filesystem** intercepting file operations. -- Supports both rollback journal and WAL mode, converts both to unified LTX - format. -- Detects transaction boundaries by watching journal lifecycle (create/delete) - or WAL commit frames. -- Rolling checksums detect split-brain. - -### wa-sqlite IDBBatchAtomicVFS (most relevant) - -- **Uses SQLITE_IOCAP_BATCH_ATOMIC** to eliminate external journal entirely. -- IndexedDB transactions provide atomicity natively. -- SQLite keeps journal in memory, calls BEGIN/COMMIT/ROLLBACK file controls. -- VFS buffers writes and commits atomically using IndexedDB transaction. -- **This is exactly our architecture** (KV store instead of IndexedDB). - -### Key Industry Patterns - -| Pattern | Used by | Applicability | -|---------|---------|---------------| -| Local disk + WAL interception | CF D1/DO, Litestream, LiteFS | Not applicable (no local disk) | -| External durability, SQLite sync=OFF | rqlite | Applicable but risky without journal | -| Virtual WAL hooks | Turso/libSQL | Requires SQLite fork | -| **BATCH_ATOMIC + in-memory journal** | **wa-sqlite IDBBatchAtomicVFS** | **Directly applicable** | -| FUSE passthrough | LiteFS | Not applicable (no filesystem) | - -The industry consensus for non-filesystem SQLite backends that need atomic -multi-page writes is SQLITE_IOCAP_BATCH_ATOMIC. It's the mechanism SQLite -provides specifically for this use case. - ---- - -## Additional PRAGMAs for Single-Writer - -### Recommended set (with BATCH_ATOMIC) - -```sql -PRAGMA locking_mode = EXCLUSIVE; -PRAGMA synchronous = NORMAL; -PRAGMA temp_store = MEMORY; -PRAGMA auto_vacuum = NONE; -``` - -Note: `journal_mode` is left as default (DELETE). SQLite will use an in-memory -journal when BATCH_ATOMIC is active. No journal file I/O occurs. - -### PRAGMAs removed vs original spec - -- `journal_mode = OFF`: Removed. Let SQLite manage the journal (in-memory with - BATCH_ATOMIC). This restores ROLLBACK support and crash safety. -- `synchronous = OFF`: Changed to NORMAL. Avoids confirmed bug with - BATCH_ATOMIC. No performance impact because BATCH_ATOMIC bypasses sync. - -### Optional - -**`cache_size = -N`** (e.g., `-4096` for 4 MiB): Larger pager cache reduces -cold reads. Worth tuning per workload. - ---- - -## Round Trip Analysis (Updated) - -| Approach | Warm write RT | Cold write RT | ROLLBACK works | Crash safe | -|----------|--------------|---------------|----------------|------------| -| DELETE journal over KV | 3+ | 4+ | Yes | Yes | -| journal_mode=OFF (no buffer) | 1 per page | 2 per page | **No** | **No** | -| journal_mode=OFF + buffer | 1 | 2 | Must implement | Yes (within limit) | -| **BATCH_ATOMIC** | **1** | **2** | **Yes (native)** | **Yes (within limit)** | -| WAL over KV | 2-4 | 3-5 | Yes | Yes | - -BATCH_ATOMIC achieves the same 1 RT warm writes as journal_mode=OFF + buffer, -but with native ROLLBACK support, SQLite-managed transaction boundaries, and -graceful fallback for edge cases. - ---- - -## Sources - -- [SQLite Atomic Commit](https://sqlite.org/atomiccommit.html) -- [SQLite Batch Atomic Write Tech Note](https://www3.sqlite.org/cgi/src/technote/714f6cbbf78c8a1351cbd48af2b438f7f824b336) -- [SQLite File Control Constants](https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html) -- [wa-sqlite IDBBatchAtomicVFS](https://github.com/rhashimoto/wa-sqlite/blob/master/src/examples/IDBBatchAtomicVFS.js) -- [wa-sqlite BATCH_ATOMIC Discussion](https://github.com/rhashimoto/wa-sqlite/discussions/78) -- [Cloudflare: Zero-latency SQLite in Durable Objects](https://blog.cloudflare.com/sqlite-in-durable-objects/) -- [Cloudflare: D1 Read Replication](https://blog.cloudflare.com/d1-read-replication-beta/) -- [How Litestream Works](https://litestream.io/how-it-works/) -- [Litestream Writable VFS (Fly.io)](https://fly.io/blog/litestream-writable-vfs/) -- [rqlite Design](https://rqlite.io/docs/design/) -- [rqlite Performance](https://rqlite.io/docs/guides/performance/) -- [libSQL Engineering Deep Dive](https://compileralchemy.substack.com/p/libsql-diving-into-a-database-engineering) -- [LiteFS Architecture](https://github.com/superfly/litefs/blob/main/docs/ARCHITECTURE.md) -- [F2FS Atomic Writes + SQLite](https://www3.sqlite.org/cgi/src/technote/714f6cbbf78c8a1351cbd48af2b438f7f824b336) diff --git a/specs/sqlite-vfs-single-writer-optimization.md b/specs/sqlite-vfs-single-writer-optimization.md deleted file mode 100644 index 84e1b4f4a6..0000000000 --- a/specs/sqlite-vfs-single-writer-optimization.md +++ /dev/null @@ -1,545 +0,0 @@ -# SQLite VFS Single-Writer Optimization Spec - -Date: 2026-03-21 -Package: `@rivetkit/sqlite-vfs`, `rivetkit/db` -Status: Draft (revised after third adversarial review - SQLite contract validation) - -Related docs: -- `docs/internal/sqlite-batch-atomic-write.md` (BATCH_ATOMIC internals + fallback) -- `specs/sqlite-vfs-single-writer-findings.md` (adversarial review + industry research) -- `specs/sqlite-vfs-adversarial-review.md` (three rounds: 32 issues, 12 real, validated) - -## Problem - -A simple `UPDATE + SELECT` on a KV-backed SQLite actor takes ~1.3 seconds end -to end. Benchmarking shows 247ms for a state increment (pure network) vs -1325ms for a SQLite increment. The ~1.1 second overhead comes from the VFS -making 5-7 sequential KV round trips per write transaction. - -With the default `DELETE` journal mode, a single UPDATE triggers: - -| Step | VFS call | KV op | -|-------------------------------|-------------------|-------------| -| Check journal exists | xAccess | 1 get | -| Open journal file | xOpen | 1 get | -| Write rollback page | xWrite (journal) | 1 putBatch | -| Write updated page | xWrite (main) | 1 putBatch | -| Sync main file | xSync | 1 put | -| Delete journal (meta + data) | xDelete | 2 ops | -| **Total** | | **7** | - -A subsequent SELECT adds 1 more round trip if the pager cache doesn't retain -the page across transactions. - -## Solution: SQLITE_IOCAP_BATCH_ATOMIC - -Instead of disabling the journal (`journal_mode = OFF`) and building a custom -write-behind buffer, use SQLite's built-in `SQLITE_IOCAP_BATCH_ATOMIC` -mechanism. This tells SQLite the VFS can atomically write multiple pages in one -operation. SQLite responds by keeping the rollback journal in memory and giving -the VFS explicit transaction boundary signals. - -This approach was identified through adversarial review (see findings doc) and -industry research. It is the same pattern used by: -- Android F2FS (first implementation, SQLite 3.21.0) -- wa-sqlite `IDBBatchAtomicVFS` (IndexedDB backend) -- Gazette `store_sqlite` (RocksDB backend) -- gRPSQLite (gRPC remote store) - -All required constants and the compile flag (`SQLITE_ENABLE_BATCH_ATOMIC_WRITE`) -are already present in `@rivetkit/sqlite` v0.1.1. - -### How it works - -SQLite uses three `xFileControl` opcodes to bracket atomic writes: - -1. `SQLITE_FCNTL_BEGIN_ATOMIC_WRITE` (31): SQLite tells the VFS to start - buffering. The VFS sets a flag and allocates a dirty page buffer. - -2. SQLite calls `xWrite()` once per dirty page. The VFS stores each write in - the buffer instead of calling `putBatch`. - -3. `SQLITE_FCNTL_COMMIT_ATOMIC_WRITE` (32): SQLite tells the VFS to persist - everything atomically. The VFS calls `putBatch` with all buffered pages + - metadata in a single KV round trip. Batch mode ends regardless of success - or failure (per the SQLite xFileControl contract). - -4. On commit failure: `SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE` (33): Called by - SQLite as a best-effort hint after COMMIT_ATOMIC_WRITE fails. The VFS - should already have exited batch mode in step 3. This is a defensive - no-op. - -**Note**: These opcodes are part of the **commit** path inside -`sqlite3PagerCommitPhaseOne()`. A user-issued SQL `ROLLBACK` (e.g., -`BEGIN; INSERT; ROLLBACK;`) never enters batch mode. The pager restores -pages from its in-memory journal without calling any xFileControl opcodes. - -### PRAGMAs - -Set immediately after `sqlite3.open_v2()` in `SqliteVfs.open()`: - -```sql -PRAGMA page_size = 4096; -PRAGMA journal_mode = DELETE; -PRAGMA locking_mode = EXCLUSIVE; -PRAGMA synchronous = NORMAL; -PRAGMA temp_store = MEMORY; -PRAGMA auto_vacuum = NONE; -``` - -**`page_size = 4096`**: Enforces the page size that the VFS assumes throughout. -CHUNK_SIZE in kv.ts is hardcoded to 4096 so that one SQLite page maps to -exactly one KV value. If page_size differs from CHUNK_SIZE, xWrite calls would -span multiple chunks or leave partial chunks, breaking the 1:1 mapping. This -PRAGMA must come before any table creation. For existing databases, page_size -is stored in the header and this PRAGMA is a no-op (matching the existing -value). - -**`journal_mode = DELETE`**: Explicitly sets the default rollback journal mode. -This is normally redundant (DELETE is SQLite's default), but WAL mode persists -in the database header across reopens. If a database ever entered WAL mode -(e.g., user code ran `PRAGMA journal_mode=WAL`), reopening without this PRAGMA -would keep WAL mode, which interacts with BATCH_ATOMIC differently. Setting -DELETE explicitly is cheap insurance. With BATCH_ATOMIC active, the journal -stays in memory (no journal file I/O). It only materializes to a real file if -the atomic commit fails and SQLite falls back (see "Large transaction -handling" below). - -**`locking_mode = EXCLUSIVE`**: Each actor is single-writer. SQLite acquires -the lock once and never releases it. The pager cache is trusted across -transactions, so a SELECT after an UPDATE typically reads from cache (0 KV -ops). Note: SQLite's LRU pager cache can evict pages under memory pressure, so -"0 KV ops for warm SELECT" is the expected case for typical small-actor -workloads, not a hard guarantee. - -**`synchronous = NORMAL`**: Required for BATCH_ATOMIC eligibility. SQLite -checks `pPager->noSync` as a batch eligibility condition. `synchronous = OFF` -sets `noSync = 1`, which **disables** BATCH_ATOMIC. `NORMAL` does not add -extra xSync calls because the batch write path bypasses sync logic entirely. -There is also a confirmed bug with `synchronous = OFF` + BATCH_ATOMIC -(reported by Roy Hashimoto, wa-sqlite author). Using NORMAL avoids it. - -**`temp_store = MEMORY`**: Keeps temp tables and sort spills in memory instead -of creating temp files through the VFS. Eliminates xOpen/xWrite/xRead/xClose -calls for complex queries. - -**`auto_vacuum = NONE`**: Disables page reorganization on DELETE. Without this, -deleting rows triggers page moves (extra xWrite + xRead calls). Acceptable -tradeoff: database file never shrinks, but actor databases are small and -short-lived. Note: `auto_vacuum` is persistent in the database header. For -existing databases with tables, this PRAGMA is silently ignored by SQLite -(auto_vacuum can only be changed on empty databases or via VACUUM). Since the -current VFS code does not set auto_vacuum, existing databases use whatever -default SQLite was compiled with (typically NONE). This is acceptable for -actor databases. - -### VFS changes - -**Async registration**: Add `"xFileControl"` to `SQLITE_ASYNC_METHODS` -(vfs.ts:85-95). COMMIT_ATOMIC_WRITE calls `putBatch` which is async. Without -async registration, the wa-sqlite relay calls xFileControl synchronously and -does not `await` the returned Promise. The return value would be a Promise -object (truthy, non-zero), which SQLite interprets as an error. The wa-sqlite -framework already supports async xFileControl (it's in `VFS_METHODS`); it just -needs to be declared in `SQLITE_ASYNC_METHODS`. - -**`xDeviceCharacteristics`**: Return `SQLITE_IOCAP_BATCH_ATOMIC` (0x4000). -Return `SQLITE_NOTFOUND` for all unhandled xFileControl opcodes (current -behavior, no change needed). - -**Batch mode scope**: `batchMode`, the dirty buffer, and `savedFileSize` are -properties on `OpenFile`, NOT on `SqliteSystem` or `Database`. SQLite sends -xFileControl to the main database file handle only. During journal fallback, -xWrite calls go to the journal file via a different fileId. If batchMode were -global, journal writes during fallback would be incorrectly buffered. - -**`xFileControl`**: Handle three opcodes. Per the SQLite xFileControl contract, -COMMIT_ATOMIC_WRITE ends batch mode regardless of success or failure. The VFS -must not depend on ROLLBACK_ATOMIC_WRITE being called for cleanup. - -``` -BEGIN_ATOMIC_WRITE (31): - Save file.size to file.savedFileSize. - Set file.batchMode = true. - Set file.metaDirty = false. - Allocate file.dirtyBuffer: Map. - Return SQLITE_OK. - -COMMIT_ATOMIC_WRITE (32): - If dirtyBuffer.size > 127: - Clear dirtyBuffer, restore file.size, set metaDirty = false. - Set file.batchMode = false. - Return SQLITE_IOERR (triggers journal fallback, see below). - Construct entries: [...dirtyBuffer entries, [file.metaKey, encodeFileMeta(file.size)]]. - Call putBatch(entries). - If putBatch fails: - Clear dirtyBuffer, restore file.size, set metaDirty = false. - Set file.batchMode = false. - Return SQLITE_IOERR. - Clear dirtyBuffer, set file.batchMode = false. - Return SQLITE_OK. - -ROLLBACK_ATOMIC_WRITE (33): - If not file.batchMode: return SQLITE_OK. (Already cleaned up by COMMIT.) - Discard dirtyBuffer. - Restore file.size from file.savedFileSize. - Set file.metaDirty = false. - Set file.batchMode = false. - Return SQLITE_OK. -``` - -The buffer limit check is `dirtyBuffer.size > 127` (page entries only). At -commit time, the metadata entry is appended: 127 pages + 1 metadata = 128 keys, -which is the KV putBatch maximum. This also guarantees the payload stays under -the 976 KiB putBatch limit: 127 * 4096 bytes + key overhead + metadata is -approximately 525 KiB, well under 976 KiB. - -**`xWrite`**: If `file.batchMode` is true, add `[chunkKey, data]` to the dirty -buffer instead of calling `putBatch`. Also update `file.size` in memory if the -write extends the file (so `xFileSize` returns the correct value during the -transaction). Since CHUNK_SIZE = page_size = 4096 (enforced by the page_size -PRAGMA), all SQLite xWrite calls are complete chunk replacements. No -partial-chunk merging is needed. If `file.batchMode` is false, write directly -to KV (current behavior, used during journal fallback). - -**`xRead`**: If `file.batchMode` is true, check the dirty buffer first. Return -buffered data if present, otherwise call `getBatch`. In practice, SQLite's -pager cache (trusted via EXCLUSIVE mode) handles most cases. The buffer check -is defense-in-depth. Note: the SQLite FCNTL documentation states that between -BEGIN_ATOMIC_WRITE and COMMIT/ROLLBACK, the only operations on the file -descriptor are xWrite and xFileControl(SIZE_HINT). xRead is not called during -the batch window, so this check is purely defensive. - -**`xTruncate`**: No batch-mode handling needed. The SQLite FCNTL documentation -states that between BEGIN_ATOMIC_WRITE and COMMIT/ROLLBACK, the only -operations on the file descriptor are xWrite and xFileControl(SIZE_HINT). -xTruncate is never called during the batch window. If stale KV chunks need -cleanup after VACUUM or explicit file shrink, that is a post-commit concern -outside the BATCH_ATOMIC path. - -### Expected round trips - -| Scenario | getBatch | putBatch | Total | -|----------|----------|----------|-------| -| Warm single-page UPDATE | 0 | 1 | **1** | -| Warm multi-page UPDATE (N pages, N ≤ 127) | 0 | 1 | **1** | -| Cold single-page UPDATE | 1 | 1 | **2** | -| Cold multi-page UPDATE | 1+ | 1 | **2+** | -| Warm SELECT (page cached) | 0 | 0 | **0** | -| Cold SELECT | 1 | 0 | **1** | -| Warm UPDATE + SELECT (same page) | 0 | 1 | **1** | -| SQL ROLLBACK (warm) | 0 | 0 | **0** | -| SQL ROLLBACK (cold pages read) | 0+ | 0 | **0+** | -| Multi-stmt BEGIN/COMMIT (warm) | 0 | 1 | **1** | - -### Large transaction handling (>127 dirty pages) - -When the dirty buffer exceeds 127 page entries, the VFS returns `SQLITE_IOERR` -from `COMMIT_ATOMIC_WRITE`. SQLite handles the fallback internally: - -1. The VFS exits batch mode during COMMIT_ATOMIC_WRITE (per the SQLite - contract: batch mode ends regardless of success or failure). The dirty - buffer is cleared and `file.size` is restored. KV is untouched. SQLite - then calls ROLLBACK_ATOMIC_WRITE as a hint, which is a no-op since batch - mode was already exited. - -2. SQLite spills the in-memory journal to a real journal file. This calls - `xOpen` for the journal file (routed to `FILE_TAG_JOURNAL` keys in KV via - `#resolveFile()`), then `xWrite` for each original page. These writes go - directly to KV (not buffered, since batch mode was exited). - -3. SQLite re-writes all dirty pages to the main database file via `xWrite`. - Again, direct to KV (not buffered). - -4. SQLite syncs the database file, then deletes the journal file. The journal - deletion is the commit point. If the actor crashes before the journal is - deleted, SQLite detects the "hot journal" on next open and rolls back - automatically. - -This fallback is implemented in `sqlite3PagerCommitPhaseOne()` in pager.c. -See `docs/internal/sqlite-batch-atomic-write.md` for the exact code flow and -source code references. - -**Important**: The fallback ONLY triggers for `SQLITE_IOERR` family errors. -Other error codes (e.g., `SQLITE_FULL`) cause a hard failure that propagates -to the application. Always return `SQLITE_IOERR` (not SQLITE_FULL or other -codes) when the buffer exceeds the limit. - -**Round trips for fallback (N = 200 pages)**: - -During fallback, `batchMode` is false and each `xWrite` call goes directly to -KV as a separate `putBatch` call (current behavior). Each `putBatch` contains -1-2 entries (chunk + optional metadata). This means: - -``` -Journal writes: N putBatch calls (one per original page) ~200 -Main writes: N putBatch calls (one per dirty page) ~200 -Journal delete: 1 deleteBatch call 1 -Sync: 1 put (metadata) 1 -Total: ~2N + 2 RT ~402 -``` - -This is significantly slower than the 1 RT batch path. But the fallback is -correct and rare. Most actor transactions touch far fewer than 127 pages. For -the uncommon case where large transactions are needed, the cost is acceptable -because it maintains full crash recovery guarantees via the standard SQLite -journal protocol. - -## Safety argument - -1. **Atomicity**: A single `putBatch` call persists all dirty pages atomically. - KV `putBatch` is all-or-nothing. This is strictly stronger than the DELETE - journal approach where journal-write and main-write are separate KV calls - with a crash window between them. - -2. **putBatch atomicity is a hard requirement**: The batch path crash recovery - depends entirely on `putBatch` being all-or-nothing. If the KV backend has - partial failure modes (some keys written, others not), the safety argument - collapses. In production, the KV layer is backed by FoundationDB which - provides this guarantee. Test and dev KV drivers must also provide atomic - putBatch semantics for the batch path to be safe. - -3. **Crash recovery (batch path)**: If the actor crashes before `putBatch`, - nothing is written. If it crashes after, everything is committed. No partial - state is possible. - -4. **Crash recovery (journal fallback)**: If the actor crashes during the - journal fallback path, the journal file persists in KV. On next open, - SQLite detects the hot journal and rolls back automatically. This is - standard SQLite crash recovery. - -5. **SQL ROLLBACK**: Works correctly. A user-issued `BEGIN; ...; ROLLBACK;` - never enters batch mode (BATCH_ATOMIC is part of the commit path, not the - transaction lifecycle). The pager restores pages from its in-memory journal. - Zero KV write operations. KV reads may occur if statements inside the - transaction touched cold pages. - - **COMMIT failure rollback**: If COMMIT_ATOMIC_WRITE fails (buffer too large - or putBatch error), the VFS exits batch mode, clears the dirty buffer, and - restores `file.size`. SQLite then falls back to the journal path. See - "Large transaction handling" above. - -6. **No concurrent access**: Single-writer with `locking_mode = EXCLUSIVE`. - No locking overhead. - -## Observability - -### Actor metrics - -Each actor instance collects in-memory metrics via `ActorMetrics` -(`src/actor/metrics.ts`). Metrics are **not persisted**. They reset when the -actor sleeps and are collected fresh on each wake cycle. This keeps the system -simple and avoids KV overhead for metrics storage. - -Exposed via `GET /inspector/metrics` (inspector token auth required). Returns -JSON: - -```json -{ - "kv_operations": { - "type": "labeled_timing", - "help": "KV round trips by operation type", - "values": { - "get": { "count": 2, "totalMs": 512.34, "keys": 2 }, - "putBatch": { "count": 1, "totalMs": 248.10, "keys": 2 } - } - }, - "sql_statements": { - "type": "labeled_counter", - "help": "SQL statements executed by type", - "values": { "select": 5, "insert": 0, "update": 3, "delete": 0, "other": 1 } - }, - "sql_duration_ms": { - "type": "counter", - "help": "Total SQL execution time in milliseconds", - "value": 1823.5 - }, - "action_calls": { "type": "counter", "help": "Total action invocations", "value": 8 }, - "action_errors": { "type": "counter", "help": "Total action errors", "value": 0 }, - "action_duration_ms": { "type": "counter", "help": "Total action execution time in milliseconds", "value": 2100.3 }, - "connections_opened": { "type": "counter", "help": "Total WebSocket connections opened", "value": 0 }, - "connections_closed": { "type": "counter", "help": "Total WebSocket connections closed", "value": 0 } -} -``` - -Metric types: - -- **counter**: Monotonically increasing value. -- **gauge**: Point-in-time value (can go up or down). -- **labeled_counter**: Counter with string labels. -- **labeled_timing**: Counter with calls, keys, and duration per label. - -### Inspector UI integration - -Metrics are shown in the inspector dashboard under the **Metadata** tab in an -**Advanced** foldout section. The foldout is collapsed by default to avoid -cluttering the primary view. It displays the raw metrics JSON in a readable -format. This is an internal debugging tool and is not part of the public API. - -## KV round trip verification tests - -The `db-kv-stats` fixture (`fixtures/driver-test-suite/db-kv-stats.ts`) -provides an instrumented KV store with per-operation call counts and an -operation log (including decoded key names like `chunk:main[0]`, -`meta:main`, `chunk:journal[0]`). Tests call `resetStats()` before each -operation and `getStats()` / `getLog()` after to assert exact KV call counts. - -### Test fixture actions needed - -Add to `db-kv-stats.ts`: - -- `insertWithIndex`: INSERT into a table with an index (multi-page write). -- `rollbackTest`: `BEGIN; INSERT ...; ROLLBACK;` -- `multiStmtTx`: `BEGIN; INSERT ...; INSERT ...; COMMIT;` -- `bulkInsertLarge`: Insert ~200 large rows in one transaction to exceed 127 - dirty pages and trigger the journal fallback. -- `getRowCount`: `SELECT COUNT(*)` on the bulk table. -- `runIntegrityCheck`: `PRAGMA integrity_check`. - -### Test cases - -**Test 1: Warm single-page UPDATE** -``` -resetStats → increment → getStats -assert putBatchCalls == 1 -assert getBatchCalls == 0 -``` - -**Test 2: Warm SELECT (pager cache hit)** -``` -increment (warm cache) → resetStats → getCount → getStats -assert getBatchCalls == 0 -assert putBatchCalls == 0 -``` - -**Test 3: Warm UPDATE + SELECT** -``` -increment (warm) → resetStats → incrementAndRead → getStats -assert putBatchCalls == 1 -assert getBatchCalls == 0 -``` - -**Test 4: Multi-page INSERT (with index)** -``` -resetStats → insertWithIndex → getStats, getLog -assert putBatchCalls == 1 -assert log has single putBatch with multiple chunk keys -``` - -**Test 5: SQL ROLLBACK produces no writes** - -A user-issued ROLLBACK never enters BATCH_ATOMIC mode (that protocol is part -of the commit path). The pager restores from its in-memory journal. No KV -writes occur, but KV reads may happen if the INSERT touched cold pages. - -``` -resetStats → rollbackTest → getStats -assert putBatchCalls == 0 -(getBatchCalls may be > 0 if cold pages were read during the INSERT) -``` - -**Test 6: Multi-statement transaction** -``` -resetStats → multiStmtTx → getStats -assert putBatchCalls == 1 -``` - -**Test 7: No journal/WAL file operations (BATCH_ATOMIC verification)** - -This test serves as the primary verification that BATCH_ATOMIC is active. If -xDeviceCharacteristics does not return the flag, or if any BATCH_ATOMIC -eligibility condition fails (e.g., synchronous=OFF), SQLite silently falls back -to journal mode for every transaction. There is no error or warning. This test -catches that by asserting no journal operations occurred during a normal write. - -``` -resetStats → increment → getLog -assert no log entry keys contain "journal" or "wal" -``` - -**Test 8: putBatch entries within limit** -``` -resetStats → increment → getLog -assert putBatch entry key count <= 128 -``` - -**Test 9: Large transaction (>127 pages) falls back to journal** -``` -resetStats → bulkInsertLarge → getStats, getLog -assert journalOps = log entries with keys containing "journal" -assert journalOps.length > 0 (journal fallback activated) -assert putBatchCalls > 1 (multiple batches: journal + main) -assert every putBatch entry has <= 128 keys (respects KV limit) -``` - -**Test 10: Large transaction data integrity** -``` -bulkInsertLarge → getRowCount -assert count == 200 -runIntegrityCheck -assert result == "ok" -``` - -**Test 11: Large transaction survives actor restart** -``` -bulkInsertLarge → destroy + recreate actor → getRowCount -assert count == 200 -runIntegrityCheck -assert result == "ok" -``` - -## Documentation updates - -When this lands, update the following: - -- **`website/src/content/docs/actors/limits.mdx`**: Document that SQLite actors - use BATCH_ATOMIC with KV-layer atomicity. Note the 127-page (508 KiB) soft - limit per transaction before journal fallback. Document that crash recovery - is handled by KV putBatch atomicity (batch path) or SQLite hot journal - rollback (fallback path). -- **`website/src/metadata/skill-base-rivetkit.md`**: Update the SQLite VFS - section to mention the BATCH_ATOMIC optimization. -- **`website/src/content/docs/actors/debugging.mdx`**: Document the - `/inspector/metrics` endpoint. - -## Files changed - -### VFS (BATCH_ATOMIC implementation) - -- `rivetkit-typescript/packages/sqlite-vfs/src/vfs.ts`: - - `SQLITE_ASYNC_METHODS`: Add `"xFileControl"` so putBatch is awaited. - - `OpenFile` interface: Add `batchMode`, `dirtyBuffer`, `savedFileSize` - fields. - - `xDeviceCharacteristics()`: Return `SQLITE_IOCAP_BATCH_ATOMIC`. - - `xFileControl()`: Handle BEGIN/COMMIT/ROLLBACK_ATOMIC_WRITE. COMMIT - tears down batch mode on both success and failure per SQLite contract. - - `xWrite()`: Buffer writes when in batch mode. Update file.size in memory. - - `xRead()`: Check dirty buffer when in batch mode (defense-in-depth). - - `SqliteVfs.open()`: Update PRAGMAs (add page_size=4096, change - journal_mode=OFF to DELETE, change synchronous=OFF to NORMAL, add - temp_store=MEMORY and auto_vacuum=NONE). - -### Metrics (already implemented) - -- `rivetkit-typescript/packages/rivetkit/src/actor/metrics.ts`: ActorMetrics - class with KV, SQL, and action tracking. -- `rivetkit-typescript/packages/rivetkit/src/db/shared.ts`: Instrumented - createActorKvStore with ActorMetrics. -- `rivetkit-typescript/packages/rivetkit/src/db/config.ts`: metrics on - DatabaseProviderContext. -- `rivetkit-typescript/packages/rivetkit/src/db/mod.ts`: Pass metrics through, - track SQL statement type and duration. -- `rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts`: Pass metrics - through. -- `rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts`: Store - ActorMetrics, track action calls/errors/duration. -- `rivetkit-typescript/packages/rivetkit/src/actor/router.ts`: Inspector - `/inspector/metrics` endpoint returning JSON. - -### Tests - -- `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-kv-stats.ts`: - Add `insertWithIndex`, `rollbackTest`, `multiStmtTx`, `bulkInsertLarge`, - `getRowCount`, `runIntegrityCheck` actions. -- `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-kv-stats.ts`: - Replace diagnostic test with strict round trip assertion tests (Tests 1-11). diff --git a/specs/sqlite-vfs-worker-pool.md b/specs/sqlite-vfs-worker-pool.md deleted file mode 100644 index 2395b890d9..0000000000 --- a/specs/sqlite-vfs-worker-pool.md +++ /dev/null @@ -1,415 +0,0 @@ -# SQLite VFS Worker Pool Spec - -Date: 2026-03-21 -Package: `@rivetkit/sqlite-vfs` -Status: Draft - -## Problem - -Today every `SqliteVfsPool` instance runs WASM SQLite on the main thread. All -database operations for all actors are serialized through a single-threaded -event loop via `AsyncMutex`. While KV latency (not CPU) is the primary -bottleneck, this design has two scaling problems: - -1. **Head-of-line blocking.** One slow KV round-trip on instance A blocks the - `AsyncMutex` for all 50 actors on that instance. The main event loop is free - to run other JS, but no other SQLite operation on that instance can start - until the current one finishes. With 50 actors and ~200ms per write, worst - case queue time is ~10 seconds. - -2. **Main-thread CPU starvation.** WASM decode/compile and SQLite page-cache - management consume CPU cycles on the same thread that handles HTTP requests, - WebSocket frames, and actor lifecycle events. Under load, this manifests as - increased p99 latency for non-SQLite paths. - -Moving each pool instance into its own `worker_threads` Worker solves both: -workers get their own event loop (eliminating cross-instance head-of-line -blocking at the event-loop level) and their own V8 isolate (moving WASM CPU -off the main thread). - -## Non-goals - -- Changing the KV-backed VFS storage format. -- Changing the pool's bin-packing, sticky assignment, or idle-destroy logic. -- Adding multiple threads per WASM instance. Emscripten asyncify only supports - one suspended call stack per module instance, so the `AsyncMutex` within each - instance is still required. -- Browser/edge runtime support. This targets Node.js `worker_threads` only. - -## Design - -### Architecture overview - -``` -Main thread Worker thread (1 per pool instance) -┌──────────────────────┐ ┌───────────────────────────┐ -│ SqliteVfsPool │ │ SqliteVfsWorker │ -│ ├─WorkerInstance[0]─┼──message──→ │ ├─ SqliteVfs │ -│ ├─WorkerInstance[1]─┼──message──→ │ │ ├─ WASM module │ -│ └─WorkerInstance[N]─┼──message──→ │ │ ├─ AsyncMutex │ -│ │ │ │ └─ SqliteSystem │ -│ PooledSqliteHandle │ │ └─ KV proxy (→ main) │ -│ └─ proxies calls ───┼──message──→ │ │ -│ via MessagePort │ └───────────────────────────┘ -└──────────────────────┘ -``` - -Each `PoolInstance` is replaced by a `WorkerInstance` that owns a -`worker_threads.Worker`. The worker thread runs `SqliteVfs` (the existing WASM -module + VFS) inside its own isolate. All database operations are sent to the -worker as structured-clone messages and results are returned via the same -channel. - -### Message protocol - -Communication uses `MessagePort` (from the Worker's `parentPort` on the worker -side) with a simple request/response protocol. Each request carries a -monotonically increasing `id` used to match responses. - -```typescript -// Main → Worker -type WorkerRequest = - | { id: number; type: "open"; fileName: string; kvPort: MessagePort } - | { id: number; type: "exec"; dbId: number; sql: string } - | { id: number; type: "run"; dbId: number; sql: string; params?: unknown[] } - | { id: number; type: "query"; dbId: number; sql: string; params?: unknown[] } - | { id: number; type: "close"; dbId: number } - | { id: number; type: "forceCloseByFileName"; fileName: string } - | { id: number; type: "forceCloseAll" } - | { id: number; type: "destroy" }; - -// Worker → Main -type WorkerResponse = - | { id: number; type: "ok"; value?: unknown } - | { id: number; type: "error"; message: string; stack?: string }; -``` - -The `open` message transfers a `MessagePort` for KV operations. This lets each -database's KV calls flow through a dedicated channel without blocking the -main request/response port. - -### KV proxy - -The `KvVfsOptions` interface (get, getBatch, put, putBatch, deleteBatch) -cannot be transferred to a worker because the callbacks close over -main-thread actor state. Instead, KV operations are proxied: - -1. On `open`, the main thread creates a `MessageChannel`. One port is - transferred to the worker with the open message. The other port stays on - the main thread and is wired to the actor's real KV callbacks. - -2. Inside the worker, the VFS's `KvVfsOptions` implementation sends KV - requests through the port and awaits responses. - -3. On the main thread, the retained port listens for KV requests, calls the - real KV functions, and posts results back. - -```typescript -// Worker-side KV proxy (implements KvVfsOptions) -class WorkerKvProxy implements KvVfsOptions { - #port: MessagePort; - #nextId = 0; - #pending: Map; - - async get(key: Uint8Array): Promise { - return this.#rpc("get", { key }); - } - async getBatch(keys: Uint8Array[]): Promise<(Uint8Array | null)[]> { - return this.#rpc("getBatch", { keys }); - } - // ... put, putBatch, deleteBatch follow same pattern - - #rpc(method: string, args: unknown): Promise { - const id = this.#nextId++; - return new Promise((resolve, reject) => { - this.#pending.set(id, { resolve, reject }); - this.#port.postMessage({ id, method, args }); - }); - } -} -``` - -`Uint8Array` values are transferred (zero-copy) via the `transfer` list in -`postMessage` to avoid copying KV payloads across the thread boundary. - -### WASM module sharing - -The compiled `WebAssembly.Module` is serializable across `worker_threads` via -structured clone. The pool compiles the module once on the main thread (as it -does today) and passes it to each worker in the initialization message. The -worker then calls `WebAssembly.instantiate(module, imports)` to create its own -instance, avoiding redundant compilation. - -```typescript -// Main thread: compile once, send to workers -const wasmModule = await WebAssembly.compile(wasmBinary); -worker.postMessage({ type: "init", wasmModule }); -``` - -### WorkerInstance lifecycle - -A `WorkerInstance` replaces the current `PoolInstance` in the pool's internal -state. It manages: - -- The `Worker` thread. -- Actor assignment bookkeeping (actors, shortNames, poisonedNames). These stay - on the main thread because they are synchronous lookups used during acquire. -- The pending-request map for correlating responses. -- KV proxy ports for each open database. - -```typescript -interface WorkerInstance { - worker: Worker; - actors: Set; - shortNameCounter: number; - actorShortNames: Map; - availableShortNames: Set; - poisonedShortNames: Set; - opsInFlight: number; - idleTimer: ReturnType | null; - destroying: boolean; - // Request/response tracking - nextRequestId: number; - pendingRequests: Map; - // KV proxy ports per database (keyed by dbId from worker) - kvPorts: Map; -} -``` - -### Pool changes - -`SqliteVfsPool` changes: - -1. **`#createWorkerInstance()`** replaces direct `new SqliteVfs(wasmModule)`. - Spawns a Worker, sends the init message with the compiled WASM module, and - returns a `WorkerInstance`. - -2. **`openForActor()`** sends an `open` message to the worker with a - transferred KV `MessagePort`, and registers the main-thread KV listener. - -3. **`release()`** sends `forceCloseByFileName` to the worker, awaits the - response, then cleans up KV ports. - -4. **`#destroyInstance()`** sends `forceCloseAll` then `destroy`, waits for - acknowledgment, then calls `worker.terminate()`. - -5. **`shutdown()`** iterates all workers, sends destroy, terminates. - -### PooledSqliteHandle changes - -`PooledSqliteHandle.open()` returns a `WorkerDatabase` (main-thread proxy) -instead of a `TrackedDatabase`. `WorkerDatabase` implements the same -`Database` interface but sends `exec`/`run`/`query`/`close` messages to the -worker. - -```typescript -class WorkerDatabase { - #pool: SqliteVfsPool; - #actorId: string; - #instance: WorkerInstance; - #dbId: number; - - async exec(sql: string, callback?: Function): Promise { - // Note: row callbacks cannot cross thread boundary efficiently. - // For exec-with-callback, fall back to query() and iterate rows - // on the main thread. - const result = await this.#sendRequest("exec", { dbId: this.#dbId, sql }); - if (callback && result.rows) { - for (const row of result.rows) { - callback(row.values, row.columns); - } - } - } - - async run(sql: string, params?: unknown[]): Promise { - await this.#sendRequest("run", { dbId: this.#dbId, sql, params }); - } - - async query(sql: string, params?: unknown[]): Promise<{ rows: unknown[][]; columns: string[] }> { - return await this.#sendRequest("query", { dbId: this.#dbId, sql, params }); - } - - async close(): Promise { - await this.#sendRequest("close", { dbId: this.#dbId }); - } -} -``` - -### Worker entry point - -The worker script (`worker.ts`) runs in the worker thread: - -```typescript -// worker.ts (runs inside worker_threads.Worker) -import { parentPort } from "node:worker_threads"; -import { SqliteVfs } from "./vfs"; - -let vfs: SqliteVfs; -const databases: Map = new Map(); -let nextDbId = 0; - -parentPort.on("message", async (msg) => { - switch (msg.type) { - case "init": { - vfs = new SqliteVfs(msg.wasmModule); - parentPort.postMessage({ id: msg.id, type: "ok" }); - break; - } - case "open": { - const kvProxy = new WorkerKvProxy(msg.kvPort); - const db = await vfs.open(msg.fileName, kvProxy); - const dbId = nextDbId++; - databases.set(dbId, db); - parentPort.postMessage({ id: msg.id, type: "ok", value: dbId }); - break; - } - case "run": { - const db = databases.get(msg.dbId); - await db.run(msg.sql, msg.params); - parentPort.postMessage({ id: msg.id, type: "ok" }); - break; - } - // ... exec, query, close, forceCloseByFileName, forceCloseAll, destroy - } -}); -``` - -### Error handling - -- Worker crashes (uncaught exception, OOM) are detected via the Worker's - `"error"` and `"exit"` events. All pending requests are rejected. The pool - marks the instance as destroying, poisons all short names, and removes all - actor assignments so they can re-acquire on a fresh instance. - -- Individual request errors (SQLite errors, KV failures) are serialized as - `WorkerResponse` with `type: "error"` and re-thrown on the main thread as - `Error` instances. - -### Thread count and resource budget - -Each worker thread adds: -- ~16.6 MB WASM linear memory (same as today, no change). -- ~2-4 MB V8 isolate overhead per worker. -- One OS thread. - -At 50 actors per instance with 200 actors, that's 4 workers = 4 threads + -~8-16 MB extra isolate overhead. This is a modest cost for removing -main-thread CPU contention. - -A config option `useWorkers` (default: `true`) allows disabling workers for -environments where `worker_threads` is unavailable or undesirable. When -`false`, the pool falls back to the current in-process behavior. - -### Configuration changes - -```typescript -interface SqliteVfsPoolConfig { - actorsPerInstance: number; - idleDestroyMs?: number; - /** Run each pool instance in a worker thread. Default: true. */ - useWorkers?: boolean; -} -``` - -Registry config (`RegistryConfig`): - -```typescript -sqlitePool: z.object({ - actorsPerInstance: z.number().int().min(1).optional().default(50), - idleDestroyMs: z.number().optional().default(30_000), - useWorkers: z.boolean().optional().default(true), -}).optional().default(...) -``` - -## Data flow: a SQL query - -1. Actor calls `db.query("SELECT * FROM foo WHERE id = ?", [42])`. -2. `WorkerDatabase.query()` on the main thread creates a request `{ id, type: "query", dbId, sql, params }` and posts it to the `WorkerInstance`'s worker. -3. The main thread promise awaits the response. -4. Worker receives the message, looks up the `Database` by `dbId`. -5. `Database.query()` acquires the `AsyncMutex`, calls `sqlite3.statements()`, `bind_collection()`, `step()`. -6. During `step()`, SQLite calls VFS `xRead` which calls `kvProxy.getBatch()`. -7. `WorkerKvProxy.getBatch()` posts `{ id, method: "getBatch", args: { keys } }` to the KV `MessagePort`, awaits response. -8. On the main thread, the KV port listener calls the actor's real `kvBatchGet()`, posts the result back through the port (transferring `Uint8Array` buffers). -9. Worker receives KV response, returns data to VFS, SQLite continues. -10. Query completes, worker posts `{ id, type: "ok", value: { rows, columns } }` back. -11. Main thread resolves the promise, returns result to the actor. - -## Migration plan - -### Phase 1: Worker infrastructure - -- Create `worker.ts` entry point with message handler. -- Create `WorkerKvProxy` implementing `KvVfsOptions` over `MessagePort`. -- Create main-thread `KvPortListener` that bridges `MessagePort` to real KV callbacks. -- Create `WorkerDatabase` proxy class. - -### Phase 2: WorkerInstance in pool - -- Replace `PoolInstance` with `WorkerInstance` in `SqliteVfsPool`. -- Update `#createInstance()` to spawn a Worker and send init with WASM module. -- Update `openForActor()` to create MessageChannel and send open message. -- Update `release()` and `#destroyInstance()` to send messages and await responses. -- Keep all assignment bookkeeping (actors, shortNames, etc.) on main thread. - -### Phase 3: Fallback path - -- Implement `useWorkers: false` fallback that preserves current in-process behavior. -- Wire up `useWorkers` config in `RegistryConfig`. - -### Phase 4: Tests - -- Unit tests for `WorkerKvProxy` and `KvPortListener` in isolation. -- Integration tests for `SqliteVfsPool` with `useWorkers: true` covering: - acquire, open, query, release, idle destroy, shutdown. -- Test worker crash recovery: kill a worker and verify actors can re-acquire. -- Test `useWorkers: false` still works identically to current behavior. - -## Risks and mitigations - -### KV proxy latency overhead - -Each KV call adds a main-thread → worker round-trip (~0.01-0.05ms per -`postMessage` hop on Node.js). With 10-15 KV calls per SQLite write, this adds -~0.2-0.75ms overhead. At 150-225ms per write, this is <0.5% and negligible. - -**Mitigation:** Benchmark before/after. If overhead is higher than expected, -batch multiple KV calls into single messages. - -### Structured clone cost for query results - -Large query results must be structured-cloned from worker to main thread. -For typical actor queries (small result sets), this is negligible. - -**Mitigation:** For large results, consider transferring `ArrayBuffer` backing -stores. The `query` response could encode rows as a flat `ArrayBuffer` with an -index, transferred zero-copy, instead of structured-cloned arrays. - -### Worker startup time - -`Worker` construction + WASM instantiation adds latency to the first acquire -on a new instance. Current path: ~50ms for WASM compile + instantiate. Worker -path: ~20ms Worker spawn + ~30ms WASM instantiate (compile is shared). - -**Mitigation:** The WASM module is pre-compiled on the main thread and -transferred to the worker, so compile cost is paid once. Worker spawn is a -one-time cost amortized across all actors on that instance. - -### exec() row callback limitation - -`Database.exec()` accepts a row callback that is called for each result row. -Callbacks cannot cross the thread boundary. Two options: -- Buffer all rows in the worker and return them in the response (matches - `query()` behavior). The main thread then calls the callback locally. -- Only support exec-without-callback for worker mode, requiring callers to - use `query()` for result iteration. - -**Decision:** Buffer rows in worker, call callback on main thread. This -preserves the existing API contract with minimal behavior change. - -### Thread safety of pool bookkeeping - -All pool bookkeeping (actor assignment, short name allocation, instance -selection) runs on the main thread's event loop, which is single-threaded. -No mutex is needed for these structures. Only the worker communication is -async/awaited. diff --git a/specs/workflow.md b/specs/workflow.md deleted file mode 100644 index 5b000d1fe2..0000000000 --- a/specs/workflow.md +++ /dev/null @@ -1,145 +0,0 @@ -# RivetKit Actor Workflow Integration Plan - -This document captures the full end-to-end plan for integrating the Rivet workflow engine into RivetKit actors. It merges the initial high-level approach with the clarifications provided afterwards. - -## Goals - -- Allow `actor({ run: workflow(async ctx => { … }) })` syntax. -- Execute a single workflow per actor, using the workflow as the actor’s long-running `run` handler. -- Preserve deterministic replay by keeping the workflow engine in control of all persistent effects (KV, queue, sleeps, retries). -- Make the workflow operate in “live” mode so it keeps running in the actor process and reacts immediately to queue messages or alarms. -- Share logging context with the actor’s Pino logger. -- Provide comprehensive tests that exercise the workflow capabilities through the actor drivers (focus on the filesystem driver suite). - -## Non-goals - -- No workflow-specific configuration knobs for now. -- No secondary workflow queue/storage beside the actor’s queue. -- No changes to external developer ergonomics outside the new `workflow()` helper and logging visibility. - -## Open Questions (Resolved) - -1. **Workflow multiplicity** – exactly one workflow per actor. The `workflow()` helper returns a closure for `run`. -2. **Context determinism** – the workflow context can see actor state/vars/etc only inside workflow steps. Accessing them elsewhere throws at runtime. -3. **Message delivery** – workflows rely entirely on the existing actor queue persistence. The workflow engine must use that queue for `listen` APIs; no separate workflow message store. -4. **Message entry** – other contexts/actions send workflow input by writing to the actor queue. -5. **Sleeping** – workflow sleep/sleepUntil calls translate to actor-native alarms. -6. **Completion semantics** – keep workflow-engine behavior; when it finishes the actor’s `run` promise will resolve and crash the actor as today. -7. **Resume on wake** – when the actor restarts, we create the workflow driver with the same ID so it resumes from workflow persistence. -8. **Alarms** – fine if alarms remain scheduled; workflow retry/backoff must still use alarms. -9. **Config knobs** – none needed initially. -10. **Logging** – integrate workflow-engine logging with the actor’s Pino logger/context. -11. **Driver coverage** – write extensive actor-workflow tests, executed at least against the filesystem driver (other drivers inherit via their own suites). - -## Architecture Overview - -1. **Workflow KV Subspace** - - Introduce a workflow-specific prefix in `ActorInstance` keys (e.g., `KEYS.WORKFLOW`), plus helpers to translate between workflow keys and actor KV keys. - - All workflow-engine KV reads/writes go through this subspace to avoid clobbering user KV data. - - The subspace must also hold workflow metadata (state, output, history) and any alarm bookkeeping needed for retries. - -2. **ActorWorkflowDriver** - - Implement `EngineDriver` by bridging to the actor’s driver and queue manager. - - KV operations map to prefixed actor KV calls. `list` must sort keys lexicographically just like the workflow engine expects. - - `setAlarm` calls the actor driver’s alarm API and persists the desired wake time under the workflow subspace. `clearAlarm` can be a no-op since actor alarms can remain scheduled safely. - - Queue integration: - - Replace workflow-engine message handling with calls into the actor queue so `listen`/`listenN` drain from queue storage rather than workflow-owned messages. - - Persist consumed messages in workflow history for deterministic replay. - - Ensure new queue writes wake the actor/wake runtime as needed. - - Every workflow operation that performs active work—steps, loops, joins, races, rollbacks, queue drains, KV reads/writes—must be wrapped in `runCtx.keepAwake` via `await c.keepAwake(promise)` so the actor stays awake while work is happening. - - The only operations that should *not* be wrapped are explicit workflow sleeps/listen waits (and other idle waits) so the actor can safely hibernate until something wakes it. - -3. **Workflow Context Wrapper** - - Add `ActorWorkflowContext` that wraps a `WorkflowContextInterface`. - - Forward all workflow operations while injecting: - - Deterministic guards: accessing `state`, `vars`, `db`, etc. outside a step throws. - - Step wrappers: inside `ctx.step`, temporarily expose those actor properties, wrap the step run in `keepAwake`, and re-hide them afterward. - - Queue bridging for `listen` methods. - - Logging hooks that send workflow-engine logs through the actor’s logger. - - Provide a `keepAwake` helper so workflow authors can keep the actor awake for specific promises; workflow internals automatically call `keepAwake` for every active operation, but users can still wrap their own async work as needed. - -4. **`workflow()` Helper** - - Located at `src/workflow/mod.ts`, exported via the package entry point `rivetkit/workflow` (and optionally re-exported from the root module). - - Accepts the user’s workflow function (and optional future configuration) and returns the function to use in `run`. - - On invocation: - - Derive a deterministic workflow ID (e.g., `actorId`). - - Build the `ActorWorkflowDriver`, hooking up queue/kv/alarm plumbing and logging. - - Call `runWorkflow(id, wrappedWorkflowFn, input, driver, { mode: "live" })`. - - Tie `c.abortSignal` to `handle.evict()`; keep `handle.result` running in the background via `c.keepAwake`/`c.waitUntil` so state flushes cleanly, but never allow the main run promise to resolve. - - After initialization, park the run handler on a never-resolving promise (e.g., `await new Promise(() => {})`) so the actor runtime never thinks the run loop exited early. - - Ensure the wrapper automatically calls `c.keepAwake` around every active workflow operation (steps, loops, message writes, etc.) while leaving sleeps/listens unwrapped. - - Resume automatically on wake by re-instantiating the driver with the same workflow ID when `run` is invoked again. - - Ensure every active workflow operation (steps, loops, retries, queue drains, etc.) is wrapped in `c.keepAwake`, but never wrap idle waits (sleep/listen) so the actor can sleep in between. - -5. **Logging Integration** - - Extend workflow-engine logging hooks (if necessary) to accept a logger implementation. - - Pass the actor’s Pino logger (or a child logger) into the workflow context so all workflow logs include the actor metadata. - - Standardize log messages (lowercase, structured fields) per RivetKit conventions. - -6. **Exports & Build** - - Add `@rivetkit/workflow-engine` as a dependency of `rivetkit`. - - Update `package.json` exports with `\"./workflow\"`. - - Include the new module in the tsup build (and types) so it ships with the package. - - Update documentation snippets to mention the new `workflow` helper. - -## Testing Strategy - -1. **Test Harness** - - Reuse the existing `setupTest` fixture with the file-system driver to run actor workflows. - - Add a dedicated top-level test launcher similar to other driver suites, invoking the new workflow tests against each supported driver (starting with filesystem). - -2. **Test Suites** - - `actor-workflow-basic.test.ts` – sanity checks for steps, persistence, workflow completion, and logging. - - `actor-workflow-control-flow.test.ts` – loops, joins, races, rollback checkpoints, and deterministic replay (including migrations via `ctx.removed()`). - - `actor-workflow-queue.test.ts` – `listen`, `listenN`, timeouts, queue back-pressure, and handoff via queue messages from actions. - - `actor-workflow-sleep.test.ts` – sleep, sleepUntil, retry backoff scheduling using actor alarms, ensuring the actor sleeps between waits. - - `actor-workflow-eviction.test.ts` – abort handling, eviction, cancellation, and actor wake/resume behavior on restart. - - `actor-workflow-state-access.test.ts` – verifies that accessing state outside steps throws, but inside steps works and remains deterministic. - -3. **Test Utilities** - - Helpers to send queue messages, fast-forward timers or alarms, and restart actors to validate resume semantics. - - Use `await c.keepAwake(promise)` in tests wherever a promise must keep the actor alive (e.g., waiting for workflow completion), but avoid wrapping sleep/listen waits so the actor can hibernate naturally. - -4. **Automation** - - Add the workflow test suite to the package’s `pnpm test` (or equivalent) so it runs with the existing driver tests. - - Ensure the filesystem driver suite covers workflows alongside the existing tests; other drivers inherit coverage via their own runner suites. - -## Implementation Steps - -1. **Scaffolding** - - Create `src/workflow/` directory with driver/context/helper modules. - - Add workflow subspace helpers to `actor/instance/keys.ts`. - - Add logging hooks in the workflow engine if not already present. - -2. **Driver Implementation** - - Implement `ActorWorkflowDriver` bridging KV, queue, and alarms. - - Update workflow-engine storage/context (if necessary) to allow external queue backends instead of its internal message array. - -3. **Context Wrapper & Deterministic Guards** - - Implement runtime checks for state/vars access; throw descriptive errors when accessed outside steps. - - Ensure step execution exposes the necessary actor context and hides it afterward. - -4. **`workflow()` Factory** - - Build the user-facing API plus type exports. - - Wire into actor `run` lifecycle, including abort handling, `keepAwake`, resuming on wake, logging, and keeping the run promise pending forever (by awaiting a never-resolving promise) so the actor does not crash due to a completed run handler. - -5. **Tests** - - Author the new test suites/files. - - Update test harness configuration to run them with the filesystem driver. - -6. **Documentation & Exports** - - Update `README.md` or relevant docs with usage examples (matching the requested snippet). - - Adjust package exports, build config, and type declarations. - -7. **Verification** - - Run workflow tests plus existing suites to ensure no regressions. - - Manually verify actor sleep/wake behavior with sample actors if needed. - -## Future Enhancements (Out of Scope) - -- Workflow-specific configuration (custom IDs, poll intervals, etc.). -- GUIs/inspector panels for workflow state. -- Multiple workflows per actor or actor-to-workflow orchestration APIs. -- Integration with additional storage backends before basic functionality lands. - -This plan will guide the implementation from scaffolding through testing, ensuring the workflow engine is fully and cleanly integrated into RivetKit actors. diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 89773f6cfd..c2343e6820 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -32,7 +32,6 @@ export default defineConfig({ // Platform docs moved to clients/connect '/docs/platforms/react': '/docs/clients/react/', '/docs/platforms/next-js': '/docs/clients/javascript/', - '/docs/platforms/cloudflare-workers': '/docs/connect/cloudflare-workers/', // Registry configuration moved '/docs/connect/registry-configuration': '/docs/general/registry-configuration/', // Cloud docs removed - redirect to relevant sections diff --git a/website/migrate-spec.md b/website/migrate-spec.md deleted file mode 100644 index 68d00a4126..0000000000 --- a/website/migrate-spec.md +++ /dev/null @@ -1,2064 +0,0 @@ -# Next.js to Astro Migration Spec - -This document specifies how to migrate `website/` from Next.js to `website-astro/` using Astro. - ---- - -## CRITICAL: Static-Only Rendering - -**The entire site MUST be pre-rendered at build time.** No server-side rendering (SSR) is allowed. The site will be served as static files via Caddy, exactly like the current Next.js setup. - -**Requirements:** -- `output: 'static'` in astro.config.mjs (this is the default) -- All pages must use `getStaticPaths()` for dynamic routes -- No `server` output mode -- No `hybrid` rendering -- API routes (RSS, JSON feeds) must be pre-rendered at build time -- Build outputs to `dist/` directory, served by Caddy - ---- - -## CRITICAL: Route Parity Requirements - -**All routes MUST be 1:1 with the existing Next.js website.** No routes should be added, removed, or renamed. URL structure must be identical to preserve SEO, existing links, and user bookmarks. - -### Required Route Structure - -The following routes must be implemented exactly as specified: - -#### Marketing Pages -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(marketing)/(index)/page.tsx` | `/` | `src/pages/index.astro` | -| `(v2)/(marketing)/agent/page.tsx` | `/agent/` | `src/pages/agent.astro` | -| `(v2)/(marketing)/cloud/page.tsx` | `/cloud/` | `src/pages/cloud.astro` | -| `(v2)/(marketing)/pricing/page.tsx` | `/pricing/` | `src/pages/pricing.astro` (redirect to /cloud/) | -| `(v2)/(marketing)/sales/page.tsx` | `/sales/` | `src/pages/sales.astro` | -| `(v2)/(marketing)/support/page.tsx` | `/support/` | `src/pages/support.astro` | -| `(v2)/(marketing)/startups/page.tsx` | `/startups/` | `src/pages/startups.astro` | -| `(v2)/(marketing)/talk-to-an-engineer/page.tsx` | `/talk-to-an-engineer/` | `src/pages/talk-to-an-engineer.astro` | -| `(v2)/(marketing)/rivet-vs-cloudflare-workers/page.tsx` | `/rivet-vs-cloudflare-workers/` | `src/pages/rivet-vs-cloudflare-workers.astro` | -| `(v2)/oss-friends/page.tsx` | `/oss-friends/` | `src/pages/oss-friends.astro` | - -#### Solutions Pages -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(marketing)/solutions/agents/page.tsx` | `/solutions/agents/` | `src/pages/solutions/agents.astro` | -| `(v2)/(marketing)/solutions/collaborative-state/page.tsx` | `/solutions/collaborative-state/` | `src/pages/solutions/collaborative-state.astro` | -| `(v2)/(marketing)/solutions/game-servers/page.tsx` | `/solutions/game-servers/` | `src/pages/solutions/game-servers.astro` | -| `(v2)/(marketing)/solutions/games/page.tsx` | `/solutions/games/` | `src/pages/solutions/games.astro` | -| `(v2)/(marketing)/solutions/user-session-store/page.tsx` | `/solutions/user-session-store/` | `src/pages/solutions/user-session-store.astro` | -| `(v2)/(marketing)/solutions/workflows/page.tsx` | `/solutions/workflows/` | `src/pages/solutions/workflows.astro` | - -#### Templates Pages -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(marketing)/templates/page.tsx` | `/templates/` | `src/pages/templates/index.astro` | -| `(v2)/(marketing)/templates/[slug]/page.tsx` | `/templates/[slug]/` | `src/pages/templates/[slug].astro` | - -#### Blog & Changelog -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(blog)/blog/page.tsx` | `/blog/` | `src/pages/blog/index.astro` | -| `(v2)/(blog)/blog/[...slug]/page.tsx` | `/blog/[...slug]/` | `src/pages/blog/[...slug].astro` | -| `(v2)/(blog)/changelog/page.tsx` | `/changelog/` | `src/pages/changelog/index.astro` | -| `(v2)/(blog)/changelog/[...slug]/page.tsx` | `/changelog/[...slug]/` | `src/pages/changelog/[...slug].astro` | - -#### Documentation (Dynamic Catch-All) -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/[section]/[[...page]]/page.tsx` | `/docs/`, `/docs/**` | `src/pages/docs/[...slug].astro` | -| `(v2)/[section]/[[...page]]/page.tsx` | `/guides/`, `/guides/**` | `src/pages/guides/[...slug].astro` | -| `(v2)/learn/[[...page]]/page.tsx` | `/learn/`, `/learn/**` | `src/pages/learn/[...slug].astro` | - -#### Legal/Content Pages (MDX) -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(content)/terms/page.mdx` | `/terms/` | `src/pages/terms.astro` or `src/pages/terms.mdx` | -| `(v2)/(content)/privacy/page.mdx` | `/privacy/` | `src/pages/privacy.astro` or `src/pages/privacy.mdx` | -| `(v2)/(content)/acceptable-use/page.mdx` | `/acceptable-use/` | `src/pages/acceptable-use.astro` or `src/pages/acceptable-use.mdx` | - -#### Redirect/Tool Pages -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(content)/docs/tools/[tool]/page.tsx` | `/docs/tools/[tool]/` | `src/pages/docs/tools/[tool].astro` (redirect to /docs/[tool]/) | - -#### API/Feed Endpoints -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `rss/feed.xml/route.tsx` | `/rss/feed.xml` | `src/pages/rss/feed.xml.ts` | -| `(v2)/(blog)/changelog.json/route.ts` | `/changelog.json` | `src/pages/changelog.json.ts` | - -#### Miscellaneous -| Next.js Path | URL | Astro Path | -|--------------|-----|------------| -| `(v2)/(other)/meme/wired-in/page.jsx` | `/meme/wired-in/` | `src/pages/meme/wired-in.astro` | - -**Total: 28 route files to migrate** - ---- - -## Uncertain Areas & Research Needed - -### Areas Requiring Further Investigation - -1. **`recmaPlugins` Support** - - Current Next.js config uses recma plugins (currently empty array) - - Astro MDX integration may not support recma directly - - **Resolution:** Since the array is empty, this is not blocking - -2. **`mdxAnnotations` Plugin Compatibility** - - Custom annotation syntax used in MDX files - - Need to verify it works with Astro's MDX processing - - **Action:** Test during migration, may need adjustment - -3. **Custom Shiki Theme** - - Located at `src/lib/textmate-code-theme` - - Need to ensure Astro's Shiki integration accepts custom themes - - **Action:** Import and configure in astro.config.mjs - -4. **`transformerTemplateVariables` Custom Transformer** - - Custom Shiki transformer in `src/mdx/transformers.ts` - - Used for autofill code blocks - - **Action:** Verify compatibility with Astro's Shiki - -5. **React Components with `"use client"`** - - Several marketing pages use `"use client"` directive - - Astro uses `client:load`, `client:visible`, etc. - - **Action:** Audit all client components and add appropriate directives - -6. **`@rivet-gg/components` and `@rivet-gg/icons` Packages** - - Workspace packages used throughout - - Should work as-is but need to verify build process - - **Action:** Ensure workspace dependencies are properly linked - -7. **Client-Side React Components** - - Many marketing pages use `"use client"` with React hooks (useState, useEffect) - - Examples: `/solutions/agents/`, `/solutions/workflows/` - - **Action:** Keep as `.tsx` files with `client:load` directive, or convert to Astro with islands - -8. **ScrollObserver Component** - - Used on home page for scroll-based animations - - Requires client-side JavaScript - - **Action:** Keep as React component with `client:load` - ---- - -## Table of Contents - -1. [Project Structure](#project-structure) -2. [Content Collections Setup](#content-collections-setup) -3. [MDX Migration](#mdx-migration) -4. [Dynamic Routes Migration](#dynamic-routes-migration) -5. [Data Files Migration](#data-files-migration) -6. [Sitemap Integration](#sitemap-integration) -7. [llms.txt Generation](#llmstxt-generation) -8. [Railway Deployment](#railway-deployment) -9. [Copy Commands](#copy-commands) -10. [Example Migrations](#example-migrations) - ---- - -## Project Structure - -### Current Next.js Structure - -``` -website/ -├── src/ -│ ├── app/(v2)/ # App Router pages -│ │ ├── [section]/[[...page]]/ # Docs/guides catch-all -│ │ ├── learn/[[...page]]/ # Learn section -│ │ ├── (blog)/blog/[...slug]/ # Blog posts -│ │ ├── (blog)/changelog/[...slug]/ -│ │ └── (marketing)/templates/[slug]/ -│ ├── content/ # MDX content -│ │ ├── docs/ # 68 MDX files -│ │ ├── guides/ -│ │ └── learn/ -│ ├── posts/ # Blog posts (31 directories) -│ ├── components/ -│ ├── data/ # Static data files -│ ├── lib/ -│ ├── mdx/ # MDX plugins -│ └── sitemap/ # Navigation config -├── public/ -├── next.config.ts -└── package.json -``` - -### Target Astro Structure - -``` -website-astro/ -├── src/ -│ ├── pages/ # Astro pages (file-based routing) -│ │ ├── docs/ -│ │ │ └── [...slug].astro # Catch-all for docs -│ │ ├── guides/ -│ │ │ └── [...slug].astro # Catch-all for guides -│ │ ├── learn/ -│ │ │ └── [...slug].astro # Learn section -│ │ ├── blog/ -│ │ │ ├── index.astro # Blog listing -│ │ │ └── [...slug].astro # Blog posts -│ │ ├── changelog/ -│ │ │ ├── index.astro -│ │ │ └── [...slug].astro -│ │ ├── templates/ -│ │ │ ├── index.astro -│ │ │ └── [slug].astro -│ │ └── index.astro # Home page -│ ├── content/ # Content collections -│ │ ├── docs/ # MDX docs -│ │ ├── guides/ -│ │ ├── learn/ -│ │ └── posts/ # Blog posts (moved from src/posts) -│ ├── components/ # Astro/React components -│ ├── data/ # Static data files -│ ├── lib/ # Utilities -│ ├── layouts/ # Layout components -│ │ ├── BaseLayout.astro -│ │ ├── DocsLayout.astro -│ │ └── BlogLayout.astro -│ └── styles/ # Global styles -├── public/ -├── astro.config.mjs -├── content.config.ts # Content collections config -├── tailwind.config.mjs -└── package.json -``` - ---- - -## Content Collections Setup - -### content.config.ts - -Create `src/content.config.ts` to define all content collections: - -```typescript -import { defineCollection, z, reference } from 'astro:content'; -import { glob } from 'astro/loaders'; - -// Docs collection (for /docs/* and /guides/*) -const docs = defineCollection({ - loader: glob({ pattern: '**/*.mdx', base: './src/content/docs' }), - schema: z.object({ - title: z.string().optional(), - description: z.string().optional(), - }), -}); - -const guides = defineCollection({ - loader: glob({ pattern: '**/*.mdx', base: './src/content/guides' }), - schema: z.object({ - title: z.string().optional(), - description: z.string().optional(), - }), -}); - -const learn = defineCollection({ - loader: glob({ pattern: '**/*.mdx', base: './src/content/learn' }), - schema: z.object({ - title: z.string().optional(), - description: z.string().optional(), - act: z.string().optional(), - subtitle: z.string().optional(), - }), -}); - -// Blog posts collection -const posts = defineCollection({ - loader: glob({ pattern: '**/page.mdx', base: './src/content/posts' }), - schema: ({ image }) => z.object({ - author: z.enum(['nathan-flurry', 'nicholas-kissel', 'forest-anderson']), - published: z.string().transform((str) => new Date(str)), - category: z.enum(['changelog', 'monthly-update', 'launch-week', 'technical', 'guide', 'frogs']), - keywords: z.array(z.string()).optional(), - // Image will be handled separately via glob import - }), -}); - -export const collections = { - docs, - guides, - learn, - posts, -}; -``` - -### Key Differences from Next.js - -| Next.js Pattern | Astro Equivalent | -|-----------------|------------------| -| `export const title = "..."` in MDX | YAML frontmatter `title: "..."` | -| Dynamic `import()` at runtime | `getCollection()` / `getEntry()` at build time | -| `useMDXComponents()` hook | `components` prop on `` | -| `generateStaticParams()` | `getStaticPaths()` | -| `generateMetadata()` | Frontmatter + Layout `` | - ---- - -## MDX Migration - -### Front Matter Conversion - -**Current Next.js MDX (JavaScript exports):** -```mdx -export const author = "nicholas-kissel" -export const published = "2024-12-21" -export const category = "changelog" -export const keywords = ["Actors"] - -# Rivet Actors Launch - -Content here... -``` - -**Target Astro MDX (YAML frontmatter):** -```mdx ---- -author: nicholas-kissel -published: "2024-12-21" -category: changelog -keywords: - - Actors ---- - -# Rivet Actors Launch - -Content here... -``` - -### Rehype/Remark Plugin Migration - -The existing plugins in `src/mdx/` can be reused with Astro's MDX integration. Remark and rehype plugins should be installed, imported, and applied as functions rather than strings. - -**astro.config.mjs:** -```javascript -import { defineConfig } from 'astro/config'; -import mdx from '@astrojs/mdx'; -import react from '@astrojs/react'; -import tailwind from '@astrojs/tailwind'; -import sitemap from '@astrojs/sitemap'; - -// Import existing plugins -import { remarkPlugins } from './src/mdx/remark'; -import { rehypePlugins } from './src/mdx/rehype'; - -export default defineConfig({ - site: 'https://www.rivet.dev', - integrations: [ - mdx({ - // Inherit markdown config (default: true) - extendMarkdownConfig: true, - - // Syntax highlighting - syntaxHighlight: 'shiki', - shikiConfig: { - theme: 'github-dark', - langs: [ - 'bash', 'typescript', 'javascript', 'json', 'yaml', - 'rust', 'html', 'css', 'docker', 'toml', - ], - }, - - // Remark plugins (process markdown AST) - remarkPlugins, - - // Rehype plugins (process HTML AST) - rehypePlugins, - - // Enable GitHub Flavored Markdown - gfm: true, - - // Optimize build (disable for debugging) - optimize: true, - }), - react(), - tailwind(), - sitemap({ - filter: (page) => !page.includes('/api/'), - }), - ], - output: 'static', - trailingSlash: 'always', -}); -``` - -### MDX Plugin Configuration Details - -**Current plugins that need migration:** - -| Plugin | Purpose | Astro Compatibility | -|--------|---------|---------------------| -| `remarkGfm` | GitHub Flavored Markdown | Built-in via `gfm: true` | -| `mdxAnnotations` | Custom annotation syntax | Works as-is | -| `rehypeShiki` | Syntax highlighting | Use Astro's built-in or keep custom | -| `rehypeSlugify` | Heading IDs | Works as-is | -| `rehypeMdxTitle` | Extract title | Works, but consider using `headings` | -| `rehypeTableOfContents` | Generate TOC | Replace with Astro's `headings` | -| `rehypeDescription` | Extract description | Works as-is | - -**Recommended changes:** - -1. **Remove `rehypeTableOfContents`** - Astro's `render()` returns `headings` array -2. **Consider removing `rehypeMdxTitle`** - Use `headings[0]` from render result -3. **Keep custom Shiki config** or use Astro's built-in highlighting - -### MDX Components Registration - -**Current Next.js (`src/mdx-components.jsx`):** -```jsx -import * as mdx from "@/components/mdx"; -export function useMDXComponents(components) { - return { ...components, ...mdx }; -} -``` - -**Astro approach - pass components to ``:** -```astro ---- -import { getEntry, render } from 'astro:content'; -import * as mdxComponents from '@/components/mdx'; - -const entry = await getEntry('docs', Astro.params.slug); -const { Content, headings } = await render(entry); ---- - - -``` - -### MDX Component Conversions - -Components used in MDX need to be converted or wrapped: - -| Next.js Component | Astro Equivalent | -|-------------------|------------------| -| `` (next/link) | `` | -| `` (next/image) | `` from `astro:assets` | -| `className` | `class` | -| `style={{ color: 'red' }}` | `style="color: red;"` | -| `{children}` | `` | - -**src/components/mdx.ts (Astro version):** -```typescript -// Re-export components for MDX -export { default as Heading } from './Heading.astro'; -export { default as SchemaPreview } from './SchemaPreview.astro'; -export { default as Lead } from './Lead.astro'; - -// Keep React components that need interactivity -export { pre, code, CodeGroup, Code } from './v2/Code'; - -// Re-export from component library -export * from '@rivet-gg/components/mdx'; -export { Resource } from './Resources'; -export { Summary } from './Summary'; -export { Accordion, AccordionGroup } from './Accordion'; -export { Frame } from './Frame'; -export { Card, CardGroup } from './Card'; - -// Standard HTML element overrides -export const a = (props: any) => ; -export const table = (props: any) => ( -
- - -); -``` - -### Automatic Exports (title, description, tableOfContents) - -The current rehype plugins (`rehypeMdxTitle`, `rehypeDescription`, `rehypeTableOfContents`) inject exports into MDX. In Astro: - -1. **Title**: Use `rehype-mdx-title` or extract from `headings` returned by `render()` -2. **Description**: First paragraph extraction stays the same, but store in frontmatter or compute at render time -3. **Table of Contents**: Use the `headings` array returned by `render()` instead of custom export - -**Alternative: Keep plugins but access differently:** -```astro ---- -const { Content, headings } = await render(entry); -// headings array: [{ depth: 2, slug: 'section', text: 'Section' }, ...] ---- -``` - ---- - -## Dynamic Routes Migration - -### Pattern: `generateStaticParams` → `getStaticPaths` - -#### 1. Docs/Guides Catch-All Route - -**Current Next.js (`src/app/(v2)/[section]/[[...page]]/page.tsx`):** -```typescript -export async function generateStaticParams() { - const staticParams: Param[] = []; - for (const section of VALID_SECTIONS) { - const dir = path.join(process.cwd(), "src", "content", section); - const dirs = await fs.readdir(dir, { recursive: true }); - const files = dirs.filter((file) => file.endsWith(".mdx")); - const sectionParams = files.map((file) => createParamsForFile(section, file)); - staticParams.push(...sectionParams); - } - return staticParams; -} - -export async function generateMetadata({ params }) { - const { section, page } = await params; - const { component: { title, description } } = await loadContent(path); - return { title: `${title} - Rivet`, description }; -} -``` - -**Target Astro (`src/pages/docs/[...slug].astro`):** -```astro ---- -import { getCollection, render } from 'astro:content'; -import DocsLayout from '@/layouts/DocsLayout.astro'; -import * as mdxComponents from '@/components/mdx'; - -export async function getStaticPaths() { - const docs = await getCollection('docs'); - return docs.map((entry) => ({ - params: { slug: entry.id }, - props: { entry }, - })); -} - -const { entry } = Astro.props; -const { Content, headings } = await render(entry); - -// Extract title from first h1 heading or frontmatter -const title = entry.data.title || headings.find(h => h.depth === 1)?.text || 'Documentation'; -const description = entry.data.description || ''; ---- - - - - -``` - -#### 2. Blog Posts Route - -**Current Next.js (`src/app/(v2)/(blog)/blog/[...slug]/page.tsx`):** -```typescript -export function generateStaticParams() { - return generateArticlesPageParams(); -} - -export async function generateMetadata({ params }) { - const { slug } = await params; - const { title, description, author, published, category, image } = await loadArticle(slug.join("/")); - return { - title, - description, - openGraph: { type: "article", publishedTime: published.toISOString(), ... }, - }; -} -``` - -**Target Astro (`src/pages/blog/[...slug].astro`):** -```astro ---- -import { getCollection, render } from 'astro:content'; -import BlogLayout from '@/layouts/BlogLayout.astro'; -import { AUTHORS, CATEGORIES } from '@/lib/article'; -import * as mdxComponents from '@/components/mdx'; - -export async function getStaticPaths() { - const posts = await getCollection('posts'); - return posts.map((entry) => { - // entry.id will be like "2024-12-21-rivet-actors-launch/page" - // Transform to slug format - const slug = entry.id.replace(/\/page$/, ''); - return { - params: { slug }, - props: { entry }, - }; - }); -} - -const { entry } = Astro.props; -const { Content, headings } = await render(entry); - -const author = AUTHORS[entry.data.author]; -const category = CATEGORIES[entry.data.category]; - -// Load image (co-located in content folder) -const images = import.meta.glob('/src/content/posts/*/image.{png,jpg,gif}', { eager: true }); -const imagePath = Object.keys(images).find(p => p.includes(entry.id.replace('/page', ''))); -const image = imagePath ? images[imagePath] : null; ---- - - h.depth === 1)?.text || 'Blog Post'} - description={entry.data.description} - author={author} - published={entry.data.published} - category={category} - image={image} -> - - -``` - -#### 3. Templates Route (Data-driven) - -**Current Next.js (`src/app/(v2)/(marketing)/templates/[slug]/page.tsx`):** -```typescript -export async function generateStaticParams() { - return templates.map((template) => ({ slug: template.name })); -} -``` - -**Target Astro (`src/pages/templates/[slug].astro`):** -```astro ---- -import { templates } from '@/data/templates/shared'; -import TemplateLayout from '@/layouts/TemplateLayout.astro'; - -export async function getStaticPaths() { - return templates.map((template) => ({ - params: { slug: template.name }, - props: { template }, - })); -} - -const { template } = Astro.props; ---- - - - - -``` - -#### 4. Learn Section Route - -**Current Next.js (`src/app/(v2)/learn/[[...page]]/page.tsx`):** -```typescript -export async function generateStaticParams(): Promise<{ page: string[] }[]> { - const files = await fs.readdir(dir, { recursive: true }); - const mdxFiles = files.filter((file) => file.endsWith(".mdx")); - return mdxFiles.map((file) => { - const segments = file.replace(".mdx", "").split("/").filter(Boolean); - return { page: segments }; - }); -} -``` - -**Target Astro (`src/pages/learn/[...slug].astro`):** -```astro ---- -import { getCollection, render } from 'astro:content'; -import LearnLayout from '@/layouts/LearnLayout.astro'; -import * as mdxComponents from '@/components/mdx'; - -export async function getStaticPaths() { - const learn = await getCollection('learn'); - return learn.map((entry) => ({ - params: { slug: entry.id || undefined }, - props: { entry }, - })); -} - -const { entry } = Astro.props; -const { Content, headings } = await render(entry); ---- - - - - -``` - ---- - -## Data Files Migration - -### data/templates/shared.ts - -No changes needed - re-exports from `@rivetkit/example-registry`: -```typescript -export { - TECHNOLOGIES, - TAGS, - templates, - type Technology, - type Tag, - type Template, -} from "@rivetkit/example-registry"; -``` - -### data/use-cases.ts - -No changes needed - static TypeScript data file. - -### data/deploy/shared.ts - -No changes needed - static deployment options. - -### lib/article.tsx → lib/article.ts - -**Changes needed:** -1. Remove dynamic `import()` calls - use content collections instead -2. Keep `AUTHORS` and `CATEGORIES` constants -3. Remove `loadArticle`, `loadArticles`, `generateArticlesPageParams` - replaced by `getCollection()` - -**New lib/article.ts:** -```typescript -import nathanFlurry from '@/authors/nathan-flurry/avatar.jpeg'; -import nicholasKissel from '@/authors/nicholas-kissel/avatar.jpeg'; -import forestAnderson from '@/authors/forest-anderson/avatar.jpeg'; - -export const AUTHORS = { - "nathan-flurry": { - name: "Nathan Flurry", - role: "Co-founder & CTO", - avatar: nathanFlurry, - socials: { - twitter: "https://x.com/NathanFlurry/", - github: "https://github.com/nathanflurry", - bluesky: "https://bsky.app/profile/nathanflurry.com", - }, - }, - "nicholas-kissel": { - name: "Nicholas Kissel", - role: "Co-founder & CEO", - avatar: nicholasKissel, - socials: { - twitter: "https://x.com/NicholasKissel", - github: "https://github.com/nicholaskissel", - bluesky: "https://bsky.app/profile/nicholaskissel.com", - }, - }, - "forest-anderson": { - name: "Forest Anderson", - role: "Founding Engineer", - avatar: forestAnderson, - url: "https://twitter.com/angelonfira", - }, -} as const; - -export const CATEGORIES = { - changelog: { name: "Changelog" }, - "monthly-update": { name: "Monthly Update" }, - "launch-week": { name: "Launch Week" }, - technical: { name: "Technical" }, - guide: { name: "Guide" }, - frogs: { name: "Frogs" }, -} as const; - -export type AuthorId = keyof typeof AUTHORS; -export type CategoryId = keyof typeof CATEGORIES; -``` - ---- - -## Marketing Pages Migration - -Marketing pages are React components that need conversion to Astro. Many use client-side interactivity. - -### Home Page (`/`) - -**Current:** `src/app/(v2)/(marketing)/(index)/page.tsx` -- Uses multiple section components (RedesignedHero, StatsSection, etc.) -- `ScrollObserver` wraps entire page for scroll-based animations -- Fetches latest changelog title at build time - -**Target:** `src/pages/index.astro` -```astro ---- -import BaseLayout from '@/layouts/BaseLayout.astro'; -import { getCollection } from 'astro:content'; - -// Section components (keep as React with client:load for interactive ones) -import { RedesignedHero } from '@/components/home/RedesignedHero'; -import { StatsSection } from '@/components/home/StatsSection'; -import { ConceptSection } from '@/components/home/ConceptSection'; -// ... other sections - -// Get latest changelog title -const posts = await getCollection('posts'); -const changelogEntries = posts.filter(p => p.data.category === 'changelog'); -const latest = changelogEntries.sort((a, b) => - b.data.published.getTime() - a.data.published.getTime() -)[0]; - -// Extract h1 from MDX content -import { render } from 'astro:content'; -const { headings } = await render(latest); -const latestChangelogTitle = headings.find(h => h.depth === 1)?.text || ''; ---- - - -
-
- - - - -
-
-
-``` - -### Solutions Pages (Client-Side Heavy) - -Pages like `/solutions/agents/` are fully client-rendered with `"use client"`. - -**Strategy:** Keep as React components, wrap with Astro layout: - -```astro ---- -// src/pages/solutions/agents.astro -import BaseLayout from '@/layouts/BaseLayout.astro'; -import AgentsPage from '@/components/solutions/AgentsPage'; ---- - - - - -``` - -### Simple Marketing Pages - -Pages like `/sales/`, `/support/` that are mostly static can be converted to pure Astro. - -### Redirect Pages - -Some pages are simple redirects (e.g., `/pricing/` → `/cloud/`): - -```astro ---- -// src/pages/pricing.astro -return Astro.redirect('/cloud/', 301); ---- -``` - -Or use `astro.config.mjs` redirects: -```javascript -export default defineConfig({ - redirects: { - '/pricing': '/cloud/', - }, -}); -``` - ---- - -## API Routes Migration - -### RSS Feed (`/rss/feed.xml`) - -**Current:** `src/app/rss/feed.xml/route.tsx` - -**Target:** `src/pages/rss/feed.xml.ts` - -**Important:** For static pre-rendering, use `export const prerender = true;` (though this is the default for static output mode). - -```typescript -import type { APIRoute } from 'astro'; -import { getCollection } from 'astro:content'; -import { Feed } from 'feed'; -import { AUTHORS, CATEGORIES } from '@/lib/article'; - -// Ensure this route is pre-rendered at build time -export const prerender = true; - -export const GET: APIRoute = async ({ site }) => { - const siteUrl = site?.toString() || 'https://www.rivet.dev'; - const posts = await getCollection('posts'); - - const feed = new Feed({ - title: 'Rivet', - description: 'Rivet news', - id: siteUrl, - link: siteUrl, - image: `${siteUrl}/favicon.ico`, - favicon: `${siteUrl}/favicon.ico`, - copyright: `All rights reserved ${new Date().getFullYear()} Rivet Gaming, Inc.`, - feedLinks: { - rss2: `${siteUrl}/rss/feed.xml`, - }, - }); - - for (const post of posts) { - const slug = post.id.replace(/\/page$/, ''); - const url = `${siteUrl}/blog/${slug}`; - const author = AUTHORS[post.data.author]; - - feed.addItem({ - title: post.data.title || slug, - id: slug, - date: post.data.published, - author: [{ name: author.name }], - link: url, - description: post.data.description || '', - }); - } - - return new Response(feed.rss2(), { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - }, - }); -}; -``` - -### Changelog JSON (`/changelog.json`) - -**Current:** `src/app/(v2)/(blog)/changelog.json/route.ts` - -**Target:** `src/pages/changelog.json.ts` - -**Important:** Must be pre-rendered at build time for static hosting. - -```typescript -import type { APIRoute } from 'astro'; -import { getCollection, render } from 'astro:content'; -import { AUTHORS, CATEGORIES } from '@/lib/article'; - -// Ensure this route is pre-rendered at build time -export const prerender = true; - -export const GET: APIRoute = async () => { - const posts = await getCollection('posts'); - const changelogPosts = posts.filter(p => p.data.category === 'changelog'); - - const entries = await Promise.all( - changelogPosts - .sort((a, b) => b.data.published.getTime() - a.data.published.getTime()) - .map(async (entry) => { - const author = AUTHORS[entry.data.author]; - const { headings } = await render(entry); - const title = headings.find(h => h.depth === 1)?.text || entry.id; - - return { - title, - description: entry.data.description || '', - slug: entry.id.replace(/\/page$/, ''), - published: entry.data.published, - authors: [{ - name: author.name, - role: author.role, - avatar: { - url: author.avatar.src, - height: author.avatar.height, - width: author.avatar.width, - }, - }], - section: CATEGORIES[entry.data.category].name, - tags: entry.data.keywords || [], - }; - }) - ); - - return new Response(JSON.stringify(entries), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }); -}; -``` - ---- - -## Layout Migration - -Next.js uses nested layouts via route groups. Astro uses explicit layout imports. - -### Layout Hierarchy - -``` -Next.js Astro -───────────────────────────────────────────────────── -app/layout.tsx src/layouts/RootLayout.astro -└── (v2)/layout.tsx src/layouts/BaseLayout.astro (includes Footer) - ├── (marketing)/layout.tsx src/layouts/MarketingLayout.astro (includes Header) - ├── (blog)/layout.tsx src/layouts/BlogLayout.astro - ├── (content)/layout.tsx src/layouts/ContentLayout.astro (prose styling) - ├── [section]/layout.tsx src/layouts/DocsLayout.astro - └── learn/layout.tsx src/layouts/LearnLayout.astro -``` - -### BaseLayout.astro (Root) - -```astro ---- -import '@/styles/main.css'; -import { Footer } from '@/components/Footer'; -import { EmbedDetector } from '@/components/EmbedDetector'; - -interface Props { - title: string; - description?: string; - canonicalUrl?: string; - ogImage?: string; -} - -const { title, description, canonicalUrl, ogImage } = Astro.props; ---- - - - - - - - {title} - {description && } - {canonicalUrl && } - {ogImage && } - - - - - -