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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/deploy-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export type DurableObjectMigrationStep = {
new_classes?: ReadonlyArray<string>;
new_sqlite_classes?: ReadonlyArray<string>;
deleted_classes?: ReadonlyArray<string>;
renamed_classes?: ReadonlyArray<{ from: string; to: string }>;
transferred_classes?: ReadonlyArray<{ from: string; from_script: string; to: string }>;
};

export type PlannedWebSocketMigrations = {
Expand Down Expand Up @@ -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);
}
Expand Down
31 changes: 31 additions & 0 deletions packages/deploy-core/test/unit/durable-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
4 changes: 2 additions & 2 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<persist-root>/v3/d1/miniflare-D1DatabaseObject/<hash>.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 <url>` 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/<prefix>_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/<idx:04d>_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/<prefix>_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/<idx:04d>_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

Expand Down
167 changes: 163 additions & 4 deletions packages/void/src/cli/wrangler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ export function syncWranglerWebSockets(
) {
return false;
}
return syncWranglerBindings(root, NO_INFERRED_BINDINGS, { websockets });
return writeProvisionedWranglerConfig(root, { webSockets: websockets }).modified;
}

/**
Expand Down Expand Up @@ -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<Pick<WebSocketRouteDefinition, 'bindingName' | 'className' | 'filePath'>>;
/**
* Queue producer/consumer bindings, upserted into `queues.producers[]` /
* `queues.consumers[]` idempotently, keyed by each section's wrangler identity
Expand Down Expand Up @@ -1154,9 +1156,8 @@ export type WriteProvisionedWranglerResult = {
};

/**
* Fill the parts of the root wrangler config that `wrangler <res> 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
Expand Down Expand Up @@ -1277,6 +1278,95 @@ export function writeProvisionedWranglerConfig(
// include queues.producers, which this collector deliberately omits).
let workerBindingOwners: Map<string, string> | undefined;

if (patch.webSockets && patch.webSockets.length > 0) {
const requested = new Map<string, (typeof patch.webSockets)[number]>();
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
Expand Down Expand Up @@ -2212,6 +2302,75 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function validateDurableObjectMigrations(
value: unknown,
configPath: string,
): Array<DurableObjectMigration> {
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<string>();
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<DurableObjectMigration>;
}

/**
* wrangler's friendly label for each binding namespace. Mirrors
* `friendlyBindingNames` in `wrangler-dist/cli.js` — the map
Expand Down
29 changes: 29 additions & 0 deletions packages/void/test/integration/websockets-playground.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown>) => 'new_classes' in migration),
).toBe(false);
});

afterAll(async () => {
if (child) {
await killChild(child);
Expand Down
Loading