From ef106b1019ee75906ee2b9fd7558bd7af02eaf4a Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 02:09:00 +0200 Subject: [PATCH] feat(void): use SQLite migrations for WebSockets --- packages/deploy-core/src/index.ts | 9 + .../test/unit/durable-migrations.test.ts | 31 ++++ packages/void/AGENTS.md | 4 +- packages/void/src/cli/wrangler.ts | 167 +++++++++++++++++- .../integration/websockets-playground.test.ts | 29 +++ .../void/test/unit/cloudflare-deploy.test.ts | 41 ++++- packages/void/test/unit/wrangler.test.ts | 65 +++++++ 7 files changed, 334 insertions(+), 12 deletions(-) diff --git a/packages/deploy-core/src/index.ts b/packages/deploy-core/src/index.ts index 6f58802ad..31968b247 100644 --- a/packages/deploy-core/src/index.ts +++ b/packages/deploy-core/src/index.ts @@ -61,6 +61,8 @@ export type DurableObjectMigrationStep = { new_classes?: ReadonlyArray; new_sqlite_classes?: ReadonlyArray; deleted_classes?: ReadonlyArray; + renamed_classes?: ReadonlyArray<{ from: string; to: string }>; + transferred_classes?: ReadonlyArray<{ from: string; from_script: string; to: string }>; }; export type PlannedWebSocketMigrations = { @@ -146,6 +148,13 @@ export function appendSqliteClassMigration( for (const name of migration.new_sqlite_classes ?? []) { existingClasses.add(name); } + for (const rename of migration.renamed_classes ?? []) { + existingClasses.delete(rename.from); + existingClasses.add(rename.to); + } + for (const transfer of migration.transferred_classes ?? []) { + existingClasses.add(transfer.to); + } for (const name of migration.deleted_classes ?? []) { existingClasses.delete(name); } diff --git a/packages/deploy-core/test/unit/durable-migrations.test.ts b/packages/deploy-core/test/unit/durable-migrations.test.ts index 7926d3dc0..60f566df6 100644 --- a/packages/deploy-core/test/unit/durable-migrations.test.ts +++ b/packages/deploy-core/test/unit/durable-migrations.test.ts @@ -64,4 +64,35 @@ describe('Durable Object migration planning', () => { { tag: 'void-ws-v1-2', new_sqlite_classes: ['ChatRoom'] }, ]); }); + + it('respects classes established by rename and transfer history', () => { + expect( + appendSqliteClassMigration( + [ + { tag: 'v1', new_classes: ['LegacyRoom'] }, + { tag: 'v2', renamed_classes: [{ from: 'LegacyRoom', to: 'RenamedRoom' }] }, + { + tag: 'v3', + transferred_classes: [ + { from: 'ExternalRoom', from_script: 'old-worker', to: 'TransferredRoom' }, + ], + }, + ], + ['RenamedRoom', 'TransferredRoom'], + 'void-ws-v1', + ), + ).toEqual({ + migrations: [ + { tag: 'v1', new_classes: ['LegacyRoom'] }, + { tag: 'v2', renamed_classes: [{ from: 'LegacyRoom', to: 'RenamedRoom' }] }, + { + tag: 'v3', + transferred_classes: [ + { from: 'ExternalRoom', from_script: 'old-worker', to: 'TransferredRoom' }, + ], + }, + ], + added: false, + }); + }); }); diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e1f899e21..402eeb1ce 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -107,8 +107,8 @@ src/ - **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). - **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` runs codegen in production mode and adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. - **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env.local` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. -- **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `wrangler.jsonc`; never regenerate migration order solely from the current sorted files. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys, unlike legacy KV-backed `*.ws.ts` classes. -- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys. +- **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `wrangler.jsonc`; never regenerate migration order solely from the current sorted files. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys. Typed `*.ws.ts` routes likewise use SQLite-backed classes; their root-config sync uses the comment-preserving writer, retains legacy `new_classes` histories, and appends a new migration tag only for genuinely new classes. +- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. Motivated by [void-sdk/void#6](https://github.com/void-sdk/void/issues/6), where an orphan scaffolded `.sql` file half-applied against D1 and wedged the deploy. ### Self-Host AI + ISR diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 8e6b01d15..8c1f9951d 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -810,7 +810,7 @@ export function syncWranglerWebSockets( ) { return false; } - return syncWranglerBindings(root, NO_INFERRED_BINDINGS, { websockets }); + return writeProvisionedWranglerConfig(root, { webSockets: websockets }).modified; } /** @@ -1005,6 +1005,8 @@ export type ProvisionedWranglerPatch = { kv?: { binding: string; id: string }; r2?: { binding: string; bucketName: string }; hyperdrive?: { binding: string; id: string }; + /** Persist WebSocket bindings and append-only Durable Object migration history. */ + webSockets?: Array>; /** * Queue producer/consumer bindings, upserted into `queues.producers[]` / * `queues.consumers[]` idempotently, keyed by each section's wrangler identity @@ -1154,9 +1156,8 @@ export type WriteProvisionedWranglerResult = { }; /** - * Fill the parts of the root wrangler config that `wrangler create - * --update-config` leaves blank: top-level `account_id`, `name`, - * `compatibility_date`, and `migrations_dir` on a specific D1 entry. + * Fill root Wrangler config fields required by generated/provisioned resources, including + * append-only WebSocket Durable Object bindings and migrations. * * Comment-preserving: uses `jsonc-parser` `modify()` + `applyEdits()` so * comments and unrelated content survive (unlike `syncWranglerBindings`, which @@ -1277,6 +1278,95 @@ export function writeProvisionedWranglerConfig( // include queues.producers, which this collector deliberately omits). let workerBindingOwners: Map | undefined; + if (patch.webSockets && patch.webSockets.length > 0) { + const requested = new Map(); + for (const route of patch.webSockets) { + const existing = requested.get(route.bindingName); + if (existing && existing.className !== route.className) { + throw new Error( + `websocket: binding '${route.bindingName}' resolves to both '${existing.className}' and '${route.className}'. Rename one of the route files.`, + ); + } + requested.set(route.bindingName, route); + } + + const initialConfig = parseJsonc(text); + const initialBindings = bindingEntriesForField( + 'durable_objects', + initialConfig.durable_objects, + ); + const knownExistingClasses = initialBindings.map(({ entry }, index) => { + if (typeof entry.class_name !== 'string' || entry.class_name.length === 0) { + throw new Error( + `deploy: cannot update "${configPath}" — "durable_objects.bindings" entry at index ${index} is missing a non-empty string "class_name".`, + ); + } + return entry.class_name; + }); + + for (const route of requested.values()) { + const currentConfig = parseJsonc(text); + const durableObjectBindings = bindingEntriesForField( + 'durable_objects', + currentConfig.durable_objects, + ); + const matches = durableObjectBindings.filter(({ name }) => name === route.bindingName); + if (matches.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — Durable Object binding '${route.bindingName}' is declared more than once.`, + ); + } + if (matches.length === 1) { + const entry = matches[0].entry; + if ( + entry.class_name !== route.className || + entry.script_name !== undefined || + entry.environment !== undefined + ) { + throw new Error( + `websocket: binding '${route.bindingName}' is already configured for another class, script, or environment, but '${route.filePath}' requires the local class '${route.className}'.`, + ); + } + continue; + } + + const owners = collectWorkerBindingNames( + configWithoutField(currentConfig, 'durable_objects'), + ); + addQueueProducerOwners(owners, currentConfig); + const owner = owners.get(route.bindingName); + if (owner) { + throw new Error( + `deploy: cannot update "${configPath}" — WebSocket binding '${route.bindingName}' is already used by a ${owner} binding.`, + ); + } + text = applyMemberEdit( + text, + ['durable_objects', 'bindings', durableObjectBindings.length], + { name: route.bindingName, class_name: route.className }, + { formattingOptions, isArrayInsertion: true }, + ); + } + + const currentConfig = parseJsonc(text); + const rawMigrations = currentConfig.migrations; + const migrations = validateDurableObjectMigrations(rawMigrations, configPath); + const planned = appendSqliteClassMigration( + migrations, + [...requested.values()].map((route) => route.className), + 'void-ws-v1', + knownExistingClasses, + ); + if (planned.added) { + text = applyMemberEdit( + text, + ['migrations', migrations.length], + planned.migrations[planned.migrations.length - 1], + { formattingOptions, isArrayInsertion: true }, + ); + } + } + // Adopt fields — write the REAL id/name of an already-existing resource into // the binding's config entry (create the array + entry if absent), keyed by // `binding`. Runs BEFORE `migrations_dir` so an adopt that CREATES a D1 entry @@ -2212,6 +2302,75 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function validateDurableObjectMigrations( + value: unknown, + configPath: string, +): Array { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot update "${configPath}" — "migrations" must be an array of Durable Object migration steps.`, + ); + } + const tags = new Set(); + for (const [index, migration] of value.entries()) { + if (!isRecord(migration) || typeof migration.tag !== 'string' || migration.tag.length === 0) { + throw new Error( + `deploy: cannot update "${configPath}" — migration at index ${index} must be an object with a non-empty string "tag".`, + ); + } + if (tags.has(migration.tag)) { + throw new Error( + `deploy: cannot update "${configPath}" — migration tag '${migration.tag}' is declared more than once.`, + ); + } + tags.add(migration.tag); + for (const field of ['new_classes', 'new_sqlite_classes', 'deleted_classes'] as const) { + const classes = migration[field]; + if ( + classes !== undefined && + (!Array.isArray(classes) || classes.some((className) => typeof className !== 'string')) + ) { + throw new Error( + `deploy: cannot update "${configPath}" — "migrations[${index}].${field}" must be an array of class names.`, + ); + } + } + const renamed = migration.renamed_classes; + if ( + renamed !== undefined && + (!Array.isArray(renamed) || + renamed.some( + (entry) => + !isRecord(entry) || typeof entry.from !== 'string' || typeof entry.to !== 'string', + )) + ) { + throw new Error( + `deploy: cannot update "${configPath}" — "migrations[${index}].renamed_classes" has an invalid entry.`, + ); + } + const transferred = migration.transferred_classes; + if ( + transferred !== undefined && + (!Array.isArray(transferred) || + transferred.some( + (entry) => + !isRecord(entry) || + typeof entry.from !== 'string' || + typeof entry.from_script !== 'string' || + typeof entry.to !== 'string', + )) + ) { + throw new Error( + `deploy: cannot update "${configPath}" — "migrations[${index}].transferred_classes" has an invalid entry.`, + ); + } + } + return value as Array; +} + /** * wrangler's friendly label for each binding namespace. Mirrors * `friendlyBindingNames` in `wrangler-dist/cli.js` — the map diff --git a/packages/void/test/integration/websockets-playground.test.ts b/packages/void/test/integration/websockets-playground.test.ts index b7c43e0f6..9e0c95ea0 100644 --- a/packages/void/test/integration/websockets-playground.test.ts +++ b/packages/void/test/integration/websockets-playground.test.ts @@ -1,3 +1,4 @@ +import { readFileSync, rmSync } from 'node:fs'; import spawn from 'cross-spawn'; import { join } from 'pathslash'; import { chromium, type Browser, type Page } from 'playwright'; @@ -73,6 +74,15 @@ describe('playground/websockets', () => { let stderr = ''; beforeAll(async () => { + const playground = join(ROOT, 'playground/websockets'); + rmSync(join(playground, 'dist'), { recursive: true, force: true }); + const build = spawn.sync('vp', ['run', '--filter', './playground/websockets', 'build'], { + cwd: ROOT, + encoding: 'utf8', + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); + expect(build.status).toBe(0); + browser = await chromium.launch(); port = await getPort(); @@ -108,6 +118,25 @@ describe('playground/websockets', () => { await waitForServer(base, 20_000); }); + it('builds WebSocket routes as SQLite-backed Durable Objects', () => { + const config = JSON.parse( + readFileSync(join(ROOT, 'playground/websockets/dist/ssr/wrangler.json'), 'utf8'), + ); + expect(config.durable_objects.bindings).toEqual( + expect.arrayContaining([ + { name: 'WsChatRoomWs', class_name: 'WsChatRoomWs' }, + { name: 'WsNotificationsWs', class_name: 'WsNotificationsWs' }, + ]), + ); + expect(config.migrations).toContainEqual({ + tag: 'void-ws-v1', + new_sqlite_classes: ['WsChatRoomWs', 'WsNotificationsWs'], + }); + expect( + config.migrations.some((migration: Record) => 'new_classes' in migration), + ).toBe(false); + }); + afterAll(async () => { if (child) { await killChild(child); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index aa766cc7f..b0a51fa52 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1996,22 +1996,51 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { expect(wranglerArgs).toEqual([]); }); - it('deploys a WebSocket app with a SQLite Durable Object migration', async () => { + it('appends a WebSocket migration when a later deploy adds a route', async () => { const root = makeProject({ 'package.json': JSON.stringify({ name: 'wsapp' }), 'void.json': JSON.stringify({}), - 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123' }), + 'wrangler.jsonc': `{ + // Preserve this user-owned configuration. + "account_id": "acc123", + "vars": { "FEATURE": "on" } +}`, 'routes/index.ts': `export const GET = () => new Response('ok');`, 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws';\nexport default defineRoom({});`, }); - const { deps, trace, exitCalls } = harness(); - await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + const first = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, first.deps); - expect(exitCalls).toEqual([]); - expect(trace).toEqual(['wrangler:whoami', 'build', 'gate', 'wrangler:deploy']); + expect(first.exitCalls).toEqual([]); + expect(first.trace).toEqual(['wrangler:whoami', 'build', 'gate', 'wrangler:deploy']); expect(parseRootWranglerConfig(join(root, 'wrangler.jsonc'))).toMatchObject({ migrations: [{ tag: 'void-ws-v1', new_sqlite_classes: ['ChatRoomWs'] }], }); + + writeFileSync( + join(root, 'routes/notifications.ws.ts'), + `import { defineWebSocket } from 'void/ws';\nexport default defineWebSocket({});`, + ); + const second = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, second.deps); + + expect(second.exitCalls).toEqual([]); + expect(second.trace).toEqual(['wrangler:whoami', 'build', 'gate', 'wrangler:deploy']); + const raw = readFileSync(join(root, 'wrangler.jsonc'), 'utf8'); + expect(raw).toContain('// Preserve this user-owned configuration.'); + expect(parseRootWranglerConfig(join(root, 'wrangler.jsonc'))).toMatchObject({ + vars: { FEATURE: 'on' }, + durable_objects: { + bindings: [ + { name: 'ChatRoomWs', class_name: 'ChatRoomWs' }, + { name: 'NotificationsWs', class_name: 'NotificationsWs' }, + ], + }, + migrations: [ + { tag: 'void-ws-v1', new_sqlite_classes: ['ChatRoomWs'] }, + { tag: 'void-ws-v1-2', new_sqlite_classes: ['NotificationsWs'] }, + ], + }); }); it('does not reject a dev-only WebSocket route (.dev.ws.ts) — it never reaches production', async () => { diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index c29bc9654..68981beb9 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -767,6 +767,71 @@ describe('writeProvisionedWranglerConfig', () => { return parseJsoncRaw(readRaw(filename)) as Record; } + it('preserves comments and appends stable WebSocket migration history', () => { + setup( + 'wrangler.jsonc', + `{ + // Keep the worker identity comment. + "name": "chat-app", + "vars": { + // Keep unrelated bindings untouched. + "FEATURE": "on" + } +}`, + ); + const route = (className: string) => ({ + bindingName: className.toUpperCase(), + className, + filePath: `${className}.ws.ts`, + }); + + const first = writeProvisionedWranglerConfig(tmpDir, { + webSockets: [route('ChatWs')], + }); + expect(first.modified).toBe(true); + const afterFirst = readRaw('wrangler.jsonc'); + expect(afterFirst).toContain('// Keep the worker identity comment.'); + expect(afterFirst).toContain('// Keep unrelated bindings untouched.'); + + const unchanged = writeProvisionedWranglerConfig(tmpDir, { + webSockets: [route('ChatWs')], + }); + expect(unchanged.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + + const second = writeProvisionedWranglerConfig(tmpDir, { + webSockets: [route('ChatWs'), route('NotificationsWs')], + }); + expect(second.modified).toBe(true); + const parsed = parseRaw('wrangler.jsonc'); + expect(parsed.vars).toEqual({ FEATURE: 'on' }); + expect((parsed.durable_objects as { bindings: unknown }).bindings).toEqual([ + { name: 'CHATWS', class_name: 'ChatWs' }, + { name: 'NOTIFICATIONSWS', class_name: 'NotificationsWs' }, + ]); + expect(parsed.migrations).toEqual([ + { tag: 'void-ws-v1', new_sqlite_classes: ['ChatWs'] }, + { tag: 'void-ws-v1-2', new_sqlite_classes: ['NotificationsWs'] }, + ]); + expect(readRaw('wrangler.jsonc')).toContain('// Keep unrelated bindings untouched.'); + }); + + it('keeps an existing legacy WebSocket namespace authoritative', () => { + const config = `{ + "durable_objects": { + "bindings": [{ "name": "CHAT", "class_name": "ChatWs" }] + }, + // This class is already deployed with legacy KV storage. + "migrations": [{ "tag": "void-ws-v1", "new_classes": ["ChatWs"] }] +}`; + setup('wrangler.jsonc', config); + const result = writeProvisionedWranglerConfig(tmpDir, { + webSockets: [{ bindingName: 'CHAT', className: 'ChatWs', filePath: 'chat.ws.ts' }], + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(config); + }); + it('preserves comments while filling account_id, name, compatibility_date, and migrations_dir', () => { setup('wrangler.jsonc', commentedFixture); const result = writeProvisionedWranglerConfig(tmpDir, {