diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 2cc4d2310..d390b9724 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -7,6 +7,7 @@ import { mkdtempSync, readFileSync, readdirSync, + rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -389,8 +390,59 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E await task.stop(); } } + if (framework === 'react') { + // The Node target bundles the installed void into its build, so a ValidationError from + // void/handler is the class that page actions check, as in dev. + await run( + manager, + manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'], + fixture, + ); + writeFileSync( + join(fixture, 'void.config.ts'), + "import { defineConfig } from 'void/config';\nexport default defineConfig({ target: 'node' });\n", + ); + // KV and email are Cloudflare bindings. + rmSync(join(fixture, 'routes'), { recursive: true }); + writeFileSync( + join(fixture, 'pages/form.tsx'), + 'export default function Form() {\n return

Form

;\n}\n', + ); + writeFileSync( + join(fixture, 'pages/form.server.ts'), + "import { defineHandler, ValidationError } from 'void/handler';\nexport const action = defineHandler(() => {\n throw new ValidationError({ email: 'Email already taken' });\n});\n", + ); + await run(process.execPath, [vite, 'build'], fixture); + const task = launch( + process.execPath, + [vite, 'preview', '--host', '127.0.0.1', '--port', '0', '--strictPort'], + fixture, + ); + try { + const deadline = Date.now() + 60_000; + let base; + while (!base && Date.now() < deadline) { + if (task.child.exitCode !== null) { + throw new Error(task.failure()); + } + base = packedServerUrl(task.output()); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + assert(base, task.failure()); + const response = await fetch(`${base}/form`, { + method: 'POST', + headers: { accept: 'text/html' }, + body: new URLSearchParams({ email: 'taken@example.com' }), + }); + const body = await response.text(); + assert.equal(response.status, 200, `${body}\n${task.failure()}`); + assert(body.includes('Email already taken'), body); + } finally { + await task.stop(); + } + } console.log( - `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}`, + `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/docs/guide/database.md b/docs/guide/database.md index 2e94f11f0..9384fbc05 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -234,10 +234,6 @@ const usersWithPosts = await db.query.users.findMany({ }); ``` -::: warning ⚠️ Nuxt and Analog limitations -In Nuxt and Analog, use the query builder (`db.select().from(table)`). The `db.query.*` relational API is not available in these frameworks. -::: - ## Seeding Use `void db seed` to reset your local database, re-apply migrations, and then run a seed file. @@ -376,7 +372,7 @@ To keep an existing migration history: Review and commit the metadata, then run `void deploy` again. If no database has applied the SQL yet, you can instead replace it with migrations from `void db generate`. -For D1 migrations containing triggers, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together. +For D1 migrations containing triggers, and MySQL migrations containing triggers, procedures, functions or events, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together. ## Scaffolding diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index 22b783bef..b75dc3dcb 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -51,9 +51,20 @@ void deploy For direct Cloudflare deploys, export the production `DATABASE_URL` in the shell running `void deploy --platform cloudflare`. Void uses it to provision Hyperdrive and apply pending checked-in migrations before uploading the Worker; it is not written to the Worker config. -::: warning MySQL DDL is not transactional -MySQL may implicitly commit schema changes. If a multi-statement migration fails partway through, earlier statements can remain applied even though Void does not record the migration as complete. Keep migrations small, review them before deploy, and make failed migrations safe to rerun after repairing the database. -::: +Only one migration run changes a database at a time, whether it comes from `void db migrate`, `void db reset`, the reset that `void db seed` does before it loads data, `void deploy --platform cloudflare`, or a `void deploy` of any project that uses the database. Better Auth's table changes run inside the same run. Another run waits for it, up to about 20 seconds for a command that connects with `DATABASE_URL` and about 2 minutes for a `void deploy` to the Void platform, then stops with an error that asks you to try again. + +### Failed migrations {#failed-migrations} + +Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. On a sharded Vitess keyspace, such as a sharded PlanetScale database, a commit that fails on one shard does not undo the shards that already committed. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed. + +To recover, undo the applied statements, fix the file, then run `void db migrate` or `void deploy` again: + +```sh +void db execute "DROP TABLE comments" +void db execute --remote "DROP TABLE comments" # deployed database +``` + +MySQL rejects `IF NOT EXISTS` on `ADD COLUMN` and `CREATE INDEX`, so undoing a statement is usually simpler than making it safe to rerun. Small migrations leave less to undo. ## Database commands diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 4bf7608c5..a71f5592a 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -154,7 +154,7 @@ Void uses Access credentials for readiness checks without saving them. If checks ### Databases and secrets -For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error. +For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error; see [failed MySQL migrations](../guide/database/mysql.md#failed-migrations). Creating Hyperdrive requires an API token with Hyperdrive edit permission. Alternatively, create it in Cloudflare and add its ID to `cloudflare.hyperdrive`; an existing binding works with browser login. diff --git a/docs/integrations/frameworks/analog.md b/docs/integrations/frameworks/analog.md index 742a9398e..1ee8dfa39 100644 --- a/docs/integrations/frameworks/analog.md +++ b/docs/integrations/frameworks/analog.md @@ -26,7 +26,6 @@ Void provides the local Cloudflare binding runtime. `nitro-cloudflare-dev` creat ### 3. Configure `vite.config.ts` ```ts -import { resolve } from 'node:path'; import analog from '@analogjs/platform'; import { defineConfig } from 'vite'; import { voidPlugin } from 'void'; @@ -41,12 +40,7 @@ export default defineConfig({ nitro: { preset: 'cloudflare-module', cloudflareDev: { configPath: './.void-wrangler.jsonc' }, - modules: ['nitro-cloudflare-dev'], - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(__dirname, 'db/schema.ts'), - }, + modules: ['nitro-cloudflare-dev', 'void/nitro'], }, }), voidPlugin(), @@ -54,6 +48,8 @@ export default defineConfig({ }); ``` +The `void/nitro` module gives Analog API routes and page server loads your app's `void/db` and `@schema`. + ### 4. Create `void.config.ts` Void generates the Cloudflare config used by Analog's development runtime: @@ -98,10 +94,6 @@ export const load = async ({ event }: PageServerLoad) => { }; ``` -::: warning ⚠️ Analog limitation -Use `db.select().from(table)` for database queries in Analog. The `db.query.*` relational API is not available. -::: - ### Other resources Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`. diff --git a/docs/integrations/frameworks/nuxt.md b/docs/integrations/frameworks/nuxt.md index ba8641af5..25065a748 100644 --- a/docs/integrations/frameworks/nuxt.md +++ b/docs/integrations/frameworks/nuxt.md @@ -30,23 +30,24 @@ import { resolve } from 'node:path'; import { voidPlugin } from 'void'; export default defineNuxtConfig({ + alias: { + '@schema': resolve(import.meta.dirname, 'db/schema.ts'), + }, nitro: { preset: 'cloudflare-module', - cloudflareDev: { configPath: './.void-wrangler.jsonc' }, + modules: ['void/nitro'], + cloudflare: { dev: { configPath: './.void-wrangler.jsonc' } }, // Required for Nuxt 4.6.0 on Windows. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(import.meta.dirname, 'db/schema.ts'), - }, }, vite: { plugins: [voidPlugin()] }, }); ``` +The `void/nitro` module gives Nuxt server routes your app's `void/db` and `@schema`. The top-level `alias` lets `nuxi typecheck` resolve `@schema`, because Nuxt also checks server routes with the app's types. `cloudflare.dev` gives the development server the bindings from Void's generated Cloudflare config; Nuxt runs it in development when `compatibilityDate` is `2025-07-15` or later. + ### 4. Create `void.config.ts` Void generates the Cloudflare config used by Nuxt's development runtime: @@ -88,10 +89,6 @@ export default defineEventHandler(async () => { }); ``` -::: warning ⚠️ Nuxt limitation -Use `db.select().from(table)` for database queries in Nuxt. The `db.query.*` relational API is not available. -::: - ### Other resources Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`. diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 44b30e743..0d992023f 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -80,6 +80,8 @@ dist/ `vite preview` also uses `app.js` to serve your built app locally. +Installed packages that depend on `void` are bundled into `dist/ssr`. If one of their dependencies uses a native addon or reads files next to its own code, add that dependency, in the version the package uses, to your app's `dependencies`. The build then leaves it to Node. + ## What Works These Void features work identically across all targets: diff --git a/docs/reference/api.md b/docs/reference/api.md index dd56075a5..a3899c162 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -68,4 +68,5 @@ Import Void's app-facing APIs from these paths: | `void/queues` | `queues` proxy for sending messages to typed queues | | `void/sandbox` | `getSandbox`, `sandbox`, `SandboxLimitError`, `SandboxUnavailableError`, and the SDK 1.0 file, backup, and mount helpers. `getSandbox(id)` resolves a client. Commands, files, port requests, and process completion return promises; limits reject with `SandboxLimitError`, which extends `UsageLimit`. Container state lasts until inactivity (default ten minutes), a crash, or a restart. See [Sandboxes](../guide/sandboxes.md) for the API and deployment requirements. | | `void/env` | `defineEnv`, `env`, schema helpers (`string`, `number`, `boolean`, `url`, `email`, `oneOf`, `json`), and global Cloudflare env types | +| `void/nitro` | Nitro module for Nuxt and Analog. Add `'void/nitro'` to the Nitro `modules` so server routes get the app's `void/db` and `@schema` | | `void/sveltekit` | SvelteKit integration helpers: `withVoidTSConfig()` and `mergeVoidSvelteKitTsconfig()` | diff --git a/package.json b/package.json index 2f9d8f03c..596587b0e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test": "node test/affected.mjs", "test:full": "vp test run", "test:integration": "vp test run --project parallel-integration", - "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts", + "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nitro-worker.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts", "test:pages:react-vue": "vp test run --project parallel-integration packages/react packages/vue", "test:pages:svelte-solid": "vp test run --project parallel-integration packages/svelte packages/solid", "test:platform": "vp test run --project 'platform-*'", diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index d10bf736c..ac73f0c56 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -37,7 +37,7 @@ voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBinding voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] ``` -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. A Yarn Plug'n'Play install has no node_modules, so there it asks the PnP runtime that `yarn` loads (`findPnpApi` on `node:module`) for each package directory. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", where Vite also matches `development` or, when NODE_ENV is production, `production`; then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`). Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together. @@ -96,13 +96,13 @@ generated entries must preserve the module-boundary rules below. - **One-time deployment targets**: Scope saved project IDs and implicit OIDC slugs to their recorded platform origin. A different platform needs `--project` or `VOID_PROJECT`. Managed deployment may establish a first project link, but must never overwrite an existing link, another selected platform URL, or a Cloudflare/Skip deployment preference. Apply this to both prompted and explicit creation/selection, including failed deployments. Re-read the link after reauthentication so a first project created earlier in the attempt is reused. -- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. +- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. Class A frameworks and Astro get the same redirect for their server environments from `createBareVoidRedirectPlugin`, in dev and build: their dev trigger dispatch imports every cron and queue file into workerd, so one `import { defineScheduled } from 'void'` would otherwise break every trigger (and, in Astro, every request). -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev for PostgreSQL and MySQL apps, the plugin also inlines installed ES module packages that depend on void, so their `void/db` is the app's. In a D1 app, and for a CommonJS package or one that does not list void, the package stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. Bundles that never run Void's Vite plugins, Nitro (through `void/nitro`) and the deploy cron/queue handler build, get the same module from `voidDbBundlerPlugin`, which resolves `@schema` through the bundler so a schema folder works. Their Worker builds import the driver that `bundleWorkerSqlDriver` builds like the migration handler: Nitro's CommonJS handling and Node stubs break pg and mysql2 in workerd. Each `pg`/`mysql2` import gets its own build of the copy the bundler resolves for it, looked up as a `require` so Rollup's CommonJS plugin neither wraps nor loads the raw driver, so Void's drizzle-orm and an app's own driver import keep their own versions. An import of `mysql2/promise` gets that copy's promise API from the same build, so it shares the driver's modules as in Node. A pg older than 8.11 has no `pg-cloudflare` socket and a mysql2 older than 3.13 has no `disableEval` option, so the lookup fails the build with an error that names the driver version and the importer. In an npm, Yarn or Bun install, Void's hoisted drizzle-orm loads the hoisted driver, which is the app's own copy when the app lists one. For pg and MySQL, `void/nitro` also runs each request in `withRuntimeEnv` (as the Class A wrapper does) and keeps Void's `node:async_hooks` native in workerd builds, because Nitro's unenv AsyncLocalStorage drops the store after the first await. Nitro turns on `cloudflare.nodeCompat` only from a `wrangler.*` file, so `void/nitro` sets it for pg/MySQL Worker builds (every deploy adds `nodejs_compat` for them) and for D1 only when the flags a deploy gives the Worker list `nodejs_compat` (the Cloudflare config's, which a managed deploy replaces with `worker.compatibility_flags` when set): a Worker built that way does not start without the flag. Nuxt and Analog also build server code with Vite (Analog's page server loads, Nuxt's app renderer), and Nitro bundles that output: Void's Vite plugin leaves a pg/MySQL `void/db` import there external, so Nitro serves one module and one driver copy, or the package's D1 client and its `void/nitro` hint when the module is missing. The deploy handler build likewise runs each cron event and queue batch in `withRuntimeEnv`. - **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. @@ -111,7 +111,7 @@ generated entries must preserve the module-boundary rules below. - **Direct Sandbox entitlement invariant**: `void/sandbox` is opt-in and is the only public Void feature that currently requires Workers Paid on the native Cloudflare path. When source inference or `void.config.ts` enables it, deploy must verify Containers with a read-only account probe after authentication and before provisioning/building, then explain both the Workers Paid signup and Containers/Cloudchamber token permissions on failure. A non-Sandbox app must emit no Container and perform no Containers probe. - **Local D1 identity**: `local-d1.ts` selects the configured D1 binding from root JSON/JSONC config, preferring `preview_database_id` over `database_id` for local development. Development, schema checks, auth migrations, and every local database command use this same identity and persistence directory. `voidPlugin({ persistTo })` records its canonical path in `.void/local-d1.json`; CLI commands reread binding IDs and reject stale Vite-config state until `void dev` refreshes it. - **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` 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. +- **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` 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. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope has no request to own it). mysql2 starts its idle timer only when it builds a pool with `maxIdle` below `connectionLimit`, and that timer runs until the pool ends, so the MySQL pools set neither: each connection closes from its pool `release` event after 1 second idle (60 seconds for `createDb`). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper. - **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` 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. - **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. The server runtime reads the same embedded public values for boot validation and SSR. Client folding must observe the final shared `config.env`; Vite runs `configResolved` hooks concurrently. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys. - **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build. @@ -120,6 +120,8 @@ generated entries must preserve the module-boundary rules below. - **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: when the journal has entries, the first delegates to `drizzle-kit generate --custom --name `, which writes the empty `.sql`, appends the journal entry, and writes a snapshot of the current schema (it refuses if the last entry's snapshot is missing and byte-restores the journal if drizzle-kit fails); with no journal entries it writes a plain timestamped `.sql`. The second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). 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. - **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock. +- **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. Better Auth's MySQL migrations change the schema too, so each runner keeps its lock through them: the hosted handler renews the lease on a 30-second timer while they run (their statements cannot renew it), and `vp dev` holds the lock of the database each step changes: migration files go to `.env`'s `DATABASE_URL`, and Better Auth to the shell's `DATABASE_URL` before `.env`'s, a URL it resolves once and passes to `runLocalVoidAuthMigrations`. When both name one database, Better Auth runs as `runMySqlMigrations`'s `afterApply` under the same lock. `void db reset`, and the reset that `void db seed` does first, hold `GET_LOCK` from the first `DROP` until the files are applied again, and keep `_void_migration_lock`; seed data loads after the lock, like app writes. +- **MySQL statements**: Cloudflare documents multi-statement queries as unsupported by Hyperdrive (not tried on a live Hyperdrive), so both MySQL runners send one statement per query (`splitMySqlMigrationSql`) and the hosted connection does not set `multipleStatements`. Each breakpoint part splits again at semicolons outside strings, quoted names and comments; a statement that opens a stored program (`CREATE` or `ALTER` of a `TRIGGER`, `PROCEDURE`, `FUNCTION` or `EVENT`) keeps the rest of its part with it, since its body's semicolons look like statement ends. Without `multipleStatements` the server rejects SQL after the program as a syntax error before it runs any of the query, so the hosted runner adds a note that asks for a `--> statement-breakpoint` after the program. Words such as `event` or a `BEGIN;` transaction do not count, so hand-written files that use them still split. Vitess (PlanetScale) also rejects a comment after a statement's semicolon unless the client sets `multipleStatements`, so the splitter drops comments after a part's last statement. The CLI connections keep `multipleStatements`: seed SQL needs it, and a whole part with several statements keeps running there as before. ### Self-Host AI + ISR @@ -185,7 +187,7 @@ Agent instructions use versioned markers (`` / ` { const authConfig = await loadVoidAuthConfig(configPath); const runtime = { @@ -51,9 +53,10 @@ export async function runLocalVoidAuthMigrations( } const connectionString = - typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0 + databaseUrl ?? + (typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0 ? env.DATABASE_URL - : undefined; + : undefined); if (!connectionString) { return; } diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 487884c71..426567948 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -596,25 +596,32 @@ async function resetDatabase( const { hostname, host, pathname } = new URL(getLocalDatabaseUrl(root, dialect)); const target = `database "${decodeURIComponent(pathname.slice(1))}" on ${host}`; await confirmDropAllTables(hostname, target, yes); - await withMySqlClient(root, async (client) => { - // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. - const tables = await client.query( - "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", - ); - await client.query('SET FOREIGN_KEY_CHECKS = 0'); - try { - for (const row of tables) { - const table = String(row.table_name).replaceAll('`', '``'); - await client.query(`DROP TABLE IF EXISTS \`${table}\``); + const { applyPendingMySqlMigrations, withMySqlMigrationLock } = + await import('../migrations/runner-mysql.ts'); + // The migration lock keeps deploys and other migration runs out from the first drop until + // the files are applied again. + await withMySqlClient(root, (client) => + withMySqlMigrationLock(client, async () => { + // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. + const tables = await client.query( + "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", + ); + await client.query('SET FOREIGN_KEY_CHECKS = 0'); + try { + for (const row of tables) { + // Deploys of any project on this database keep their lease rows in this table. + if (row.table_name === '_void_migration_lock') { + continue; + } + const table = String(row.table_name).replaceAll('`', '``'); + await client.query(`DROP TABLE IF EXISTS \`${table}\``); + } + } finally { + await client.query('SET FOREIGN_KEY_CHECKS = 1'); } - } finally { - await client.query('SET FOREIGN_KEY_CHECKS = 1'); - } - }); - if (migrations.length > 0) { - const { applyMySqlMigrations } = await import('../migrations/runner-mysql.ts'); - await withMySqlClient(root, (client) => applyMySqlMigrations(client, migrations)); - } + await applyPendingMySqlMigrations(client, migrations); + }), + ); return migrations.length; } diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index e47de676e..e61ec1cc8 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -119,7 +119,9 @@ export async function migrateCloudflareWorker(input: { let appliedMigrations = 0; // Cloudflare never returns the Hyperdrive password, so the deploy environment provides the - // production URL. The runners record applied names, so retries are idempotent. + // production URL. A rerun skips migrations already recorded as finished. A failed PostgreSQL + // file rolls back. A failed MySQL file rolls back its data changes, but MySQL commits DDL on + // its own, so earlier statements of that file can stay applied. if (dialect !== 'sqlite' && migrations.length > 0) { const mysql = dialect === 'mysql'; const label = mysql ? 'MySQL' : 'PostgreSQL'; diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index 0ebcf10ac..d6bd48da1 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -133,7 +133,11 @@ export async function prepareCloudflareFrameworkArtifact(options: { voidConfig.worker?.compatibility_date ?? readWranglerTopLevelString(rootConfig, 'compatibility_date') ?? DEFAULT_COMPATIBILITY_DATE, - compatibilityFlags: preset.cloudflareCompatibilityFlags, + // The PostgreSQL and MySQL drivers need workerd's Node modules, as in a managed deploy. + compatibilityFlags: + analysis.dialect === 'sqlite' + ? preset.cloudflareCompatibilityFlags + : [...(preset.cloudflareCompatibilityFlags ?? []), 'nodejs_compat'], crons: [...new Set(crons.flatMap((job) => job.crons))], requiredSecrets: requiredSecretNames, versionMetadataBinding, diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 56c638545..51b767690 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2373,9 +2373,18 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { if (framework.name === 'nuxt') { note( `Detected Nuxt. Void init does not patch nuxt.config.* automatically.\n\n` + - `Add \`voidPlugin()\` to the Nuxt Vite plugins option:\n\n` + + `Add \`voidPlugin()\` to the Nuxt Vite plugins option, the \`@schema\` alias that \`nuxi typecheck\` reads, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to the Nitro options:\n\n` + + `import { resolve } from "node:path";\n` + `import { voidPlugin } from "void";\n\n` + `export default defineNuxtConfig({\n` + + ` alias: {\n` + + ` "@schema": resolve(import.meta.dirname, "db/schema.ts"),\n` + + ` },\n` + + ` nitro: {\n` + + ` preset: "cloudflare-module",\n` + + ` modules: ["void/nitro"],\n` + + ` cloudflare: { dev: { configPath: "./.void-wrangler.jsonc" } },\n` + + ` },\n` + ` vite: { plugins: [voidPlugin()] },\n` + `});`, 'Manual Nuxt config step', @@ -2383,6 +2392,30 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { return; } + if (framework.name === 'analog') { + note( + `Detected Analog. Void init does not patch vite.config.* automatically.\n\n` + + `Install \`nitro-cloudflare-dev\` as a dev dependency. Add \`voidPlugin()\` to the Vite plugins, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to Analog's Nitro options:\n\n` + + `import analog from "@analogjs/platform";\n` + + `import { voidPlugin } from "void";\n\n` + + `export default defineConfig({\n` + + ` plugins: [\n` + + ` analog({\n` + + ` ssr: true,\n` + + ` nitro: {\n` + + ` preset: "cloudflare-module",\n` + + ` modules: ["nitro-cloudflare-dev", "void/nitro"],\n` + + ` cloudflareDev: { configPath: "./.void-wrangler.jsonc" },\n` + + ` },\n` + + ` }),\n` + + ` voidPlugin(),\n` + + ` ],\n` + + `});`, + 'Manual Analog config step', + ); + return; + } + noteManualViteConfigSetup(); } diff --git a/packages/void/src/cli/pg-bundle-transform.ts b/packages/void/src/cli/worker-require-transform.ts similarity index 82% rename from packages/void/src/cli/pg-bundle-transform.ts rename to packages/void/src/cli/worker-require-transform.ts index 1237ef117..ae13e8e16 100644 --- a/packages/void/src/cli/pg-bundle-transform.ts +++ b/packages/void/src/cli/worker-require-transform.ts @@ -29,11 +29,17 @@ function nodes(root: AstNode): Array { return found; } -/** Rewrite the two CommonJS constructs emitted by the PG migration bundle. */ -export function transformPgWorkerBundle( +/** + * Replace the `createRequire(import.meta.url)` binding that the bundler emits for CommonJS + * database drivers with `requireShim`, which serves Node built-ins from static imports. + * workerd gives modules no `import.meta.url`, so the original binding fails at startup. + * PostgreSQL bundles also replace `pgpass`, which reads the filesystem. + */ +export function transformWorkerRequireBundle( code: string, filename: string, requireShim: string, + database: 'PostgreSQL' | 'MySQL', ): string { if (!code.includes('createRequire') && !code.includes('pgpass')) { return code; @@ -53,7 +59,9 @@ export function transformPgWorkerBundle( !isIdentifier(specifiers[0]?.imported as AstNode | undefined) || (specifiers[0]!.imported as { name: string }).name !== 'createRequire' ) { - throw new Error(`deploy: Unsupported node:module import in PostgreSQL bundle ${filename}.`); + throw new Error( + `deploy: Unsupported node:module import in ${database} bundle ${filename}.`, + ); } edits.push({ start: statement.start!, end: statement.end!, replacement: '' }); } @@ -67,7 +75,7 @@ export function transformPgWorkerBundle( ) ) { if (declarations.length !== 1 || hasRequireBinding) { - throw new Error(`deploy: Unsupported require binding in PostgreSQL bundle ${filename}.`); + throw new Error(`deploy: Unsupported require binding in ${database} bundle ${filename}.`); } hasRequireBinding = true; edits.push({ start: statement.start!, end: statement.end!, replacement: requireShim }); @@ -91,12 +99,12 @@ export function transformPgWorkerBundle( } if (code.includes('createRequire') && !hasRequireBinding) { throw new Error( - `deploy: Could not locate the generated require binding in PostgreSQL bundle ${filename}.`, + `deploy: Could not locate the generated require binding in ${database} bundle ${filename}.`, ); } if (code.includes('pgpass') && pgpassCalls === 0 && code.includes('__require')) { throw new Error( - `deploy: Could not locate the pgpass require in PostgreSQL bundle ${filename}.`, + `deploy: Could not locate the pgpass require in ${database} bundle ${filename}.`, ); } const ms = new MagicString(code); @@ -105,7 +113,9 @@ export function transformPgWorkerBundle( } const transformed = ms.toString(); if (transformed.includes('createRequire') || transformed.includes('__require("pgpass")')) { - throw new Error(`deploy: PostgreSQL bundle ${filename} still contains an unsupported require.`); + throw new Error( + `deploy: ${database} bundle ${filename} still contains an unsupported require.`, + ); } return pgpassCalls > 0 ? `const __voidPgpassNoop = Object.assign(function pgpass(_connectionInfo, callback) { callback(undefined); }, { warnTo() {} });\n${transformed}` diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 87c9cce71..cb22a7d31 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, @@ -10,12 +11,14 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import { dirname, join, extname } from 'pathslash'; +import { dirname, join, extname, toSlash } from 'pathslash'; import { build } from 'vite'; +import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; +import { voidDbBundlerPlugin } from '../plugin-drizzle.ts'; import { readProjectPaths } from '../project-paths.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; -import { transformPgWorkerBundle } from './pg-bundle-transform.ts'; +import { transformWorkerRequireBundle } from './worker-require-transform.ts'; export type WrapperFeatures = { /** Cron job definitions (from scanJobsSync). */ @@ -45,6 +48,8 @@ export type WrapperResult = { }; const BUNDLED_ENTRY_RE = /^index\.(?:m?js|cjs)$/; +// The env module that the app's `void/db` imports, so the jobs' request scope is the one it reads. +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); const WRAPPER_MARKER = '// Generated by Void: deployment wrapper.'; function isGeneratedWrapper(source: string, originalName: string): boolean { @@ -113,7 +118,8 @@ export async function generateWrapper( if (features.crons.length > 0 || features.queues.length > 0) { const tmpDir = join(root, '.void', 'handlers'); mkdirSync(tmpDir, { recursive: true }); - const handlersSource = generateHandlersSource(root, features); + const config = readConfig(root); + const handlersSource = generateHandlersSource(root, features, getDatabaseDialect(config)); const handlersEntry = join(tmpDir, 'handlers.ts'); writeFileSync(handlersEntry, handlersSource); @@ -121,6 +127,15 @@ export async function generateWrapper( await build({ root: tmpDir, logLevel: 'silent', + // This focused build does not run voidPlugin(), so give it the app's `void/db` and + // `@schema`. A PostgreSQL or MySQL driver comes prebuilt for workerd. + plugins: [ + voidDbBundlerPlugin(root, { + config, + sqlDriver: (dialect, entry) => + bundleWorkerSqlDriver(dialect, entry, join(tmpDir, 'sql-driver')), + }), + ], resolve: { // Cron/queue scaffolds intentionally import their lightweight runtime helpers from the // public `void` entry. This focused build does not run voidPlugin(), so reproduce its @@ -263,24 +278,15 @@ async function bundleMigrationHandler( root: tmpDir, logLevel: 'silent', resolve: isPostgresql - ? { - alias: { - 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(handlerPath), - 'pg-pool': resolvePgCjsDependency(handlerPath, 'pg-pool'), - }, - conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], - } + ? { alias: pgWorkerAliases(handlerPath), conditions: WORKER_CONDITIONS } : undefined, - ssr: isPostgresql - ? { - noExternal: ['pg', 'pg-cloudflare'], - resolve: { - conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], - }, - } - : isMysql - ? { noExternal: ['mysql2'] } - : undefined, + // A build decides what is external by resolving from `root`, which sits in the app. npm, + // Yarn and Bun hoist dependencies there, and workerd cannot load a bare import, so bundle + // every package. Node built-ins stay external for the require shims below. + ssr: { + noExternal: true, + ...(isPostgresql && { resolve: { conditions: WORKER_CONDITIONS } }), + }, build: { outDir, emptyOutDir: true, @@ -289,7 +295,7 @@ async function bundleMigrationHandler( target: 'esnext', rollupOptions: { input: handlerPath, - external: [/^cloudflare:/, /^node:/, /^pgpass$/], + external: SQL_WORKER_EXTERNAL, output: { entryFileNames: '__void_migration_handler.mjs', chunkFileNames: '__void_migration_handler-[hash].mjs', @@ -298,22 +304,120 @@ async function bundleMigrationHandler( }, }); - if (isPostgresql) { - patchPgWorkerBundle(outDir); + if (isPostgresql || isMysql) { + patchWorkerRequireBundle(outDir, isPostgresql ? 'PostgreSQL' : 'MySQL', 'migration bundle'); } for (const file of readdirSync(outDir)) { copyFileSync(join(outDir, file), join(workerDir, file)); } } -function patchPgWorkerBundle(outDir: string): void { +// workerd's conditions, then a browser build's, so packages pick their Worker builds. +const WORKER_CONDITIONS = ['workerd', 'worker', 'browser', 'module', 'import', 'default']; +const SQL_WORKER_EXTERNAL = [/^cloudflare:/, /^node:/, /^pgpass$/]; + +/** pg's workerd socket and its CommonJS pool, resolved from the pg that `fromPath` loads. */ +function pgWorkerAliases(fromPath: string): Record { + return { + 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(fromPath), + 'pg-pool': resolvePgCjsDependency(fromPath, 'pg-pool'), + }; +} + +/** + * Build the PostgreSQL or MySQL driver whose entry file is `main` as an ES module for workerd, + * the way the migration handler bundles Void's own, and return its path. Bundlers that do not run + * voidPlugin() import this module for `pg` or `mysql2`: their CommonJS handling breaks the + * drivers in workerd. Each driver copy gets its own folder in `baseDir`. The same build gives + * mysql2's `mysql2/promise` API as `promise.mjs` next to it, so both share one copy of the driver. + */ +export async function bundleWorkerSqlDriver( + dialect: 'postgresql' | 'mysql', + main: string, + baseDir: string, +): Promise { + const packageName = dialect === 'postgresql' ? 'pg' : 'mysql2'; + const outDir = join(baseDir, createHash('sha256').update(main).digest('hex').slice(0, 12)); + mkdirSync(outDir, { recursive: true }); + const input: Record = { + [packageName]: writeDriverEntry(join(outDir, 'entry.mjs'), main), + }; + const promise = dialect === 'mysql' ? resolveMysqlPromise(main) : undefined; + if (promise !== undefined) { + input.promise = writeDriverEntry(join(outDir, 'promise-entry.mjs'), promise); + } + const bundleDir = join(outDir, 'out'); + await build({ + root: outDir, + logLevel: 'silent', + resolve: { + alias: dialect === 'postgresql' ? pgWorkerAliases(main) : {}, + conditions: WORKER_CONDITIONS, + }, + ssr: { noExternal: true, resolve: { conditions: WORKER_CONDITIONS } }, + build: { + outDir: bundleDir, + emptyOutDir: true, + minify: false, + ssr: true, + target: 'esnext', + rollupOptions: { + input, + external: SQL_WORKER_EXTERNAL, + output: { + entryFileNames: '[name].mjs', + chunkFileNames: `${packageName}-[hash].mjs`, + }, + }, + }, + }); + patchWorkerRequireBundle( + bundleDir, + dialect === 'postgresql' ? 'PostgreSQL' : 'MySQL', + 'driver bundle', + ); + return join(bundleDir, `${packageName}.mjs`); +} + +/** Write an ES module at `entry` that exports the CommonJS module `file`, and return `entry`. */ +function writeDriverEntry(entry: string, file: string): string { + const names = Object.keys(createRequire(file)(file) as object).filter( + (name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name), + ); + writeFileSync( + entry, + `import driver from ${JSON.stringify(toSlash(file))};\nexport default driver;\nexport const { ${names.join(', ')} } = driver;\n`, + ); + return entry; +} + +/** The `mysql2/promise` file of the mysql2 copy whose entry file is `main`, if it has one. */ +function resolveMysqlPromise(main: string): string | undefined { + try { + return createRequire(main).resolve('mysql2/promise'); + } catch { + return undefined; + } +} + +/** `bundle` names the bundle in the error a failed require throws. */ +function patchWorkerRequireBundle( + outDir: string, + database: 'PostgreSQL' | 'MySQL', + bundle: string, +): void { for (const file of readdirSync(outDir)) { if (!file.endsWith('.mjs') && !file.endsWith('.js')) { continue; } const path = join(outDir, file); const code = readFileSync(path, 'utf-8'); - const transformed = transformPgWorkerBundle(code, file, pgWorkerRequireShim()); + const transformed = transformWorkerRequireBundle( + code, + file, + database === 'PostgreSQL' ? pgWorkerRequireShim(bundle) : mysqlWorkerRequireShim(bundle), + database, + ); if (transformed !== code) { writeFileSync(path, transformed); } @@ -334,12 +438,14 @@ function resolvePgCloudflareWorkerdEntry(fromPath: string): string { return join(dirname(packageJsonPath), 'dist', 'index.js'); } -function pgWorkerRequireShim(): string { +// pg requires `fs` only to read sslcert/sslkey/sslrootcert files, which a Worker cannot read. +// workerd has node:fs only from compatibility date 2025-09-15, and this module loads at Worker +// start, so that require stays unsupported and throws only if it runs. +function pgWorkerRequireShim(bundle: string): string { return [ 'import * as __voidNodeCrypto from "node:crypto";', 'import * as __voidNodeDns from "node:dns";', 'import * as __voidNodeEvents from "node:events";', - 'import * as __voidNodeFs from "node:fs";', 'import * as __voidNodeNet from "node:net";', 'import * as __voidNodeTls from "node:tls";', 'import * as __voidNodeUtil from "node:util";', @@ -348,7 +454,6 @@ function pgWorkerRequireShim(): string { ' crypto: __voidNodeCrypto,', ' dns: __voidNodeDns,', ' events: __voidNodeEvents,', - ' fs: __voidNodeFs,', ' net: __voidNodeNet,', ' tls: __voidNodeTls,', ' util: __voidNodeUtil,', @@ -358,7 +463,50 @@ function pgWorkerRequireShim(): string { ' if (id.startsWith("node:")) id = id.slice(5);', ' const mod = __voidNodeRequireModules[id];', ' if (mod) return mod;', - ' throw new Error(`Unsupported require("${id}") in Void PG migration bundle`);', + ` throw new Error(\`Unsupported require("\${id}") in Void PG ${bundle}\`);`, + '};', + ].join('\n'); +} + +// The Node built-ins that mysql2 and its bundled dependencies require. Each one is the +// module's default export, which matches CommonJS `module.exports`: safer-buffer calls +// `hasOwnProperty` on `require("buffer")`, and an ESM namespace object has no prototype. +// mysql2 wraps its optional `cardinal` require in try/catch, so it stays unsupported. +function mysqlWorkerRequireShim(bundle: string): string { + return [ + 'import __voidNodeBuffer from "node:buffer";', + 'import __voidNodeCrypto from "node:crypto";', + 'import __voidNodeDiagnosticsChannel from "node:diagnostics_channel";', + 'import __voidNodeEvents from "node:events";', + 'import __voidNodeNet from "node:net";', + 'import __voidNodeProcess from "node:process";', + 'import __voidNodeStream from "node:stream";', + 'import __voidNodeStringDecoder from "node:string_decoder";', + 'import __voidNodeTimers from "node:timers";', + 'import __voidNodeTls from "node:tls";', + 'import __voidNodeUrl from "node:url";', + 'import __voidNodeUtil from "node:util";', + 'import __voidNodeZlib from "node:zlib";', + 'var __voidNodeRequireModules = {', + ' buffer: __voidNodeBuffer,', + ' crypto: __voidNodeCrypto,', + ' diagnostics_channel: __voidNodeDiagnosticsChannel,', + ' events: __voidNodeEvents,', + ' net: __voidNodeNet,', + ' process: __voidNodeProcess,', + ' stream: __voidNodeStream,', + ' string_decoder: __voidNodeStringDecoder,', + ' timers: __voidNodeTimers,', + ' tls: __voidNodeTls,', + ' url: __voidNodeUrl,', + ' util: __voidNodeUtil,', + ' zlib: __voidNodeZlib,', + '};', + 'var __require = (id) => {', + ' if (id.startsWith("node:")) id = id.slice(5);', + ' const mod = __voidNodeRequireModules[id];', + ' if (mod) return mod;', + ` throw new Error(\`Unsupported require("\${id}") in Void MySQL ${bundle}\`);`, '};', ].join('\n'); } @@ -619,9 +767,21 @@ function generateWrapperSource( return lines.join('\n'); } -function generateHandlersSource(root: string, features: WrapperFeatures): string { +function generateHandlersSource( + root: string, + features: WrapperFeatures, + dialect: DatabaseDialect, +): string { const lines: Array = []; const paths = readProjectPaths(root); + // PostgreSQL and MySQL jobs run each event in Void's request scope, as a Void app's do, so + // void/db keeps one pool for the event instead of opening one for each query. D1 jobs keep + // their reads on the primary: in a request scope, void/db reads D1 through a replica-capable + // session. + const scoped = dialect !== 'sqlite'; + if (scoped) { + lines.push(`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`); + } // Scheduled handlers if (features.crons.length > 0) { @@ -631,7 +791,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string } lines.push(''); - lines.push('export async function handleScheduled(controller, env, ctx) {'); + lines.push( + scoped + ? 'async function __runScheduled(controller, env, ctx) {' + : 'export async function handleScheduled(controller, env, ctx) {', + ); lines.push(' const tasks = [];'); for (let i = 0; i < features.crons.length; i++) { lines.push(` if (${JSON.stringify(features.crons[i].crons)}.includes(controller.cron)) {`); @@ -652,6 +816,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string ); lines.push(' if (errors.length) throw new AggregateError(errors, "Scheduled jobs failed");'); lines.push('}'); + if (scoped) { + lines.push('export function handleScheduled(controller, env, ctx) {'); + lines.push(' return withRuntimeEnv(env, () => __runScheduled(controller, env, ctx));'); + lines.push('}'); + } } // Queue handlers @@ -665,7 +834,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string lines.push(''); lines.push(generateQueueResolverSource(features.queues.map((queue) => queue.name))); lines.push(''); - lines.push('export async function handleQueue(batch, env, ctx) {'); + lines.push( + scoped + ? 'async function __runQueue(batch, env, ctx) {' + : 'export async function handleQueue(batch, env, ctx) {', + ); lines.push(' const queueSuffix = batch.queue;'); lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);'); lines.push(' if (__queueIndex === -1) {'); @@ -688,6 +861,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string } lines.push(' }'); lines.push('}'); + if (scoped) { + lines.push('export function handleQueue(batch, env, ctx) {'); + lines.push(' return withRuntimeEnv(env, () => __runQueue(batch, env, ctx));'); + lines.push('}'); + } lines.push(` export async function handleQueueRelay(payload, env, ctx) { if (!payload || typeof payload.queue !== "string" || !Array.isArray(payload.messages) || diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 26384479c..f2d1b7d01 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -63,6 +63,7 @@ import { restartPlugin } from './plugin-restart.ts'; import { routingPlugin } from './plugin-routing.ts'; import { mergeWorkerBindings } from './plugin-worker-bindings.ts'; import { + createBareVoidRedirectPlugin, createServerRuntimeOptimizerPlugin, DEV_ASSET_ROUTING_EXCLUSIONS, createCloudflareBuiltinsExternalPlugin, @@ -108,18 +109,26 @@ export function voidPlugin(options?: { ? projectCloudflareConfigPath : undefined; let previewing = false; + // A preview reads the build's Wrangler deploy config, which the build moved aside. Put it back + // until the preview exits. + const restorePreviewPointer = () => { + if (!existsSync(join(root, 'void.config.ts'))) { + return; + } + if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) { + scheduledPreviewPointerCleanups.add(root); + process.once('exit', () => { + rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true }); + }); + } + }; const implicitDeployGuard: Plugin = { name: 'void:remove-implicit-cloudflare-deploy-pointer', enforce: 'pre', config(_config, environment) { previewing = environment.isPreview === true; - if (previewing && existsSync(join(root, 'void.config.ts'))) { - if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) { - scheduledPreviewPointerCleanups.add(root); - process.once('exit', () => { - rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true }); - }); - } + if (previewing) { + restorePreviewPointer(); } }, closeBundle() { @@ -157,6 +166,11 @@ export function voidPlugin(options?: { assertNoRootSourceConventionConflicts(paths); const detected = application.framework; const framework = detected?.class === 'a' ? (detected.name as Framework) : undefined; + if (detected?.name === 'astro' && process.argv[2] === 'preview') { + // `astro preview` loads this config but starts the adapter's preview server without its + // Vite plugins, after the adapter checks for the deploy config. + restorePreviewPointer(); + } const scanDirs = config.inference?.scanDirs ?? (detected ? FRAMEWORK_SCAN_DIRS : undefined); const frameworkOwnsCloudflareRuntime = @@ -208,8 +222,8 @@ export function voidPlugin(options?: { // graph and therefore another envContext, so request bindings disappear for // the prebundled copy. Every return path includes this plugin, including // framework and Node targets that do not all use bindingRuntimePlugin. - const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin( - !isNodeTarget(config.target) && !frameworkOwnsCloudflareRuntime, + const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin(dialect, () => + readWranglerCompat(root, config), ); // Scan queues/websocket routes early so both framework-mode and default-mode // branches can safely close over these bindings. @@ -619,6 +633,7 @@ export function voidPlugin(options?: { framework, root, devTriggerToken, + dialect, config.routing?.notFound, ); // Class A builds write no `assets` config, and `not_found_handling` alone would keep @@ -649,6 +664,7 @@ export function voidPlugin(options?: { framework, getOriginalFrameworkEntry(framework, root), true, + dialect, resolved.build.assetsDir, config.routing?.notFound, ); @@ -726,6 +742,7 @@ export function voidPlugin(options?: { return [ createCloudflareBuiltinsExternalPlugin(), + createBareVoidRedirectPlugin(), createWorkerEnvironmentSanitizerPlugin([ viteEnvironment.name, ...(viteEnvironment.childEnvironments ?? []), diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts new file mode 100644 index 000000000..bf9f41f96 --- /dev/null +++ b/packages/void/src/installed-packages.ts @@ -0,0 +1,273 @@ +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import module from 'node:module'; +import { dirname, join, normalize } from 'pathslash'; +import { parseSync } from 'vite'; +import { staticModuleReferences } from './shared/module-references.ts'; + +type PackageManifest = { + readonly type?: string; + readonly module?: unknown; + readonly main?: unknown; + readonly exports?: unknown; + readonly dependencies?: Record; + readonly devDependencies?: Record; + readonly optionalDependencies?: Record; + readonly peerDependencies?: Record; +}; + +function readManifest(dir: string): PackageManifest | undefined { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest; + } catch { + return undefined; + } +} + +type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null }; + +/** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */ +function findInstalledPackage(name: string, fromDir: string): string | undefined { + // A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds + // `findPnpApi` to `node:module` and serves the packages' files from its archives. + const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir); + if (pnp) { + try { + const manifest = pnp.resolveToUnqualified(`${name}/package.json`, `${fromDir}/`); + return manifest ? dirname(manifest) : undefined; + } catch { + return undefined; + } + } + for (let dir = fromDir; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', name); + if (existsSync(join(candidate, 'package.json'))) { + return realpathSync(candidate); + } + if (dirname(dir) === dir) { + return undefined; + } + } +} + +function isVoidPackage(name: string): boolean { + return name === 'void' || name.startsWith('@void/'); +} + +/** The `exports` conditions that an import matches in Vite's SSR graph. */ +const IMPORT_CONDITIONS = new Set(['node', 'import', 'module', 'default']); + +/** + * Vite's SSR graph also matches `production` or `development`, as NODE_ENV says. Vite sets + * NODE_ENV before it runs the plugin hooks that call this. + */ +function matchesImport(condition: string): boolean { + const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; + return IMPORT_CONDITIONS.has(condition) || condition === mode; +} + +/** The target an import picks from an `exports` value: its first matching key, as in Node. */ +function importTarget(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + for (const item of value) { + const target = importTarget(item); + if (target) { + return target; + } + } + return undefined; + } + if (!value || typeof value !== 'object') { + return undefined; + } + for (const [key, item] of Object.entries(value)) { + const target = matchesImport(key) ? importTarget(item) : undefined; + if (target) { + return target; + } + } + return undefined; +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** The file an import of the package loads: `exports` ".", then `module`, then `main`. */ +function findImportEntry(dir: string, manifest: PackageManifest): string | undefined { + const exports = manifest.exports; + const root = + exports && + typeof exports === 'object' && + Object.keys(exports).some((key) => key.startsWith('.')) + ? (exports as Record)['.'] + : exports; + const entry = + importTarget(root) ?? + (typeof manifest.module === 'string' ? manifest.module : undefined) ?? + (typeof manifest.main === 'string' ? manifest.main : 'index.js'); + // A `main` may leave out the extension or name a directory. + for (const candidate of [entry, `${entry}.js`, `${entry}/index.js`]) { + const file = join(dir, candidate); + if (isFile(file)) { + return file; + } + } + return undefined; +} + +/** Whether Node loads a `.mjs` or `.js` file in the package as an ES module. */ +function isEsmFile(file: string, dir: string, manifest: PackageManifest): boolean { + if (file.endsWith('.mjs')) { + return true; + } + if (!file.endsWith('.js')) { + return false; + } + // The nearest package.json sets the type, and a package can nest one in a subdirectory. + for (let scope = dirname(file); scope.startsWith(`${dir}/`); scope = dirname(scope)) { + const nested = readManifest(scope); + if (nested) { + return nested.type === 'module'; + } + } + return manifest.type === 'module'; +} + +/** A dynamic `import()` argument that is a single string literal naming a relative file. */ +const RELATIVE_LITERAL = /^(["'])(\.{1,2}\/[^"']*)\1$/; + +/** The relative files that a module's imports, re-exports and `import()` calls name. */ +function relativeImports(code: string, parsed: ReturnType): Array { + const specifiers = staticModuleReferences(parsed, code).map((reference) => reference.source); + for (const { moduleRequest } of parsed.module.dynamicImports) { + const literal = RELATIVE_LITERAL.exec(code.slice(moduleRequest.start, moduleRequest.end)); + if (literal) { + specifiers.push(literal[2]!); + } + } + return specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier)); +} + +/** The most files of one package that the check reads. A package with more stays with Node. */ +const MAX_PACKAGE_MODULES = 2000; + +/** + * Vite's SSR module runner evaluates an inlined package as ES modules. The package's import + * entry and every file it reaches through relative imports must be one. So the ES module + * wrapper in Node's dual-package guide, or any ES module of the package that imports a + * CommonJS file, keeps the package with Node. + */ +function shipsEsm(packageDir: string, manifest: PackageManifest): boolean { + const dir = normalize(packageDir); + const entry = findImportEntry(dir, manifest); + if (!entry) { + return false; + } + const files = [entry]; + const seen = new Set(files); + for (const file of files) { + if (!isEsmFile(file, dir, manifest)) { + return false; + } + let code: string; + try { + code = readFileSync(file, 'utf8'); + } catch { + return false; + } + // Vite's SSR transform parses with the same parser and fails on any error, so the module + // runner could not load a file that does not parse. + const parsed = parseSync(file, code, { lang: 'js', sourceType: 'module' }); + if (parsed.errors.length > 0) { + return false; + } + for (const specifier of relativeImports(code, parsed)) { + const next = join(dirname(file), specifier); + if (/\.[cm]?js$/.test(next) && !seen.has(next)) { + if (seen.size >= MAX_PACKAGE_MODULES) { + return false; + } + seen.add(next); + files.push(next); + } + } + } + return true; +} + +/** + * Installed ES module packages that list `void` or an `@void/` package as a dependency, among + * the app's own dependencies and, through them, the packages they depend on (the rule + * vite-plugin-svelte applies to Svelte libraries). Vite leaves a package in node_modules to Node, + * whose `void/*` imports then skip Void's plugins: the generated `void/db` and the Node target's + * guard. Listing these packages in `ssr.noExternal` keeps them in Vite's graph. + */ +export function findVoidDependentPackages(root: string): Array { + const app = readManifest(root); + if (!app) { + return []; + } + const found = new Set(); + const seen = new Set(); + const visit = (names: Iterable, fromDir: string) => { + for (const name of names) { + if (isVoidPackage(name)) { + continue; + } + const dir = findInstalledPackage(name, fromDir); + if (!dir || seen.has(dir)) { + continue; + } + seen.add(dir); + const manifest = readManifest(dir); + const dependencies = { + ...manifest?.dependencies, + ...manifest?.optionalDependencies, + }; + if ( + !manifest || + !Object.keys({ ...dependencies, ...manifest.peerDependencies }).some(isVoidPackage) || + !shipsEsm(dir, manifest) + ) { + continue; + } + found.add(name); + visit(Object.keys(dependencies), dir); + } + }; + visit( + Object.keys({ ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies }), + root, + ); + return [...found].sort(); +} + +/** `name` and every package it depends on, as installed for code in `fromDir`. */ +export function findDependencyClosure(name: string, fromDir: string): Array { + const found = new Set(); + const seen = new Set(); + const visit = (dependency: string, from: string) => { + const dir = findInstalledPackage(dependency, from); + if (!dir || seen.has(dir)) { + return; + } + seen.add(dir); + found.add(dependency); + const manifest = readManifest(dir); + for (const next of Object.keys({ + ...manifest?.dependencies, + ...manifest?.optionalDependencies, + })) { + visit(next, dir); + } + }; + visit(name, fromDir); + return [...found].sort(); +} diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index ca130b02d..de6a82356 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,5 +1,10 @@ import type { Connection } from 'mysql2/promise'; -import { splitMigrationSql } from '../runtime/migration-handler.ts'; +import { + mySqlMigrationLockError, + mySqlMigrationLockName, + readMySqlMigrationLease, +} from '../runtime/migration-lock-mysql.ts'; +import { splitMySqlMigrationSql } from '../runtime/mysql-migration-sql.ts'; import { collectMigrations } from './collect.ts'; export type MySqlClient = { @@ -47,6 +52,95 @@ export async function getMySqlAppliedMigrations(url: string): Promise { + const [row] = await client.query>('SELECT DATABASE() AS db'); + const database = row?.db ?? ''; + const name = mySqlMigrationLockName(database); + const [lock] = await client.query>>( + 'SELECT GET_LOCK(?, ?) AS acquired', + [name, MIGRATION_LOCK_TIMEOUT_SECONDS], + ); + // Vitess names the column after the call and ignores the alias. + if (Number(Object.values(lock ?? {})[0]) !== 1) { + throw mySqlMigrationLockError(database, 'run the command again'); + } + + const readLease = () => readMySqlMigrationLease((sql, params) => client.query(sql, params)); + const deadline = Date.now() + MIGRATION_LOCK_TIMEOUT_SECONDS * 1000; + let remaining = await readLease(); + while (remaining !== undefined && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + remaining = await readLease(); + } + if (remaining !== undefined) { + await client.query('SELECT RELEASE_LOCK(?)', [name]).catch(() => {}); + throw new Error( + `A deploy is applying migrations to MySQL database "${database}". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in ${remaining} seconds.`, + ); + } + return name; +} + +/** + * A DDL statement makes MySQL commit and close the open transaction. A savepoint can only be + * rolled back to inside a transaction, so this tells whether the file's changes can still be undone. + */ +async function isTransactionOpen(client: MySqlClient): Promise { + try { + await client.query('SAVEPOINT `void_migration`'); + await client.query('ROLLBACK TO SAVEPOINT `void_migration`'); + return true; + } catch { + return false; + } +} + +// ER_LOCK_DEADLOCK and ER_TRANSACTION_ROLLBACK_DURING_COMMIT: MySQL has rolled back the whole +// transaction itself, which also closes it, so the savepoint probe cannot tell it from a DDL commit. +const ROLLED_BACK_BY_SERVER_ERRNOS = new Set([1213, 3101]); + +function isRolledBackByServer(error: unknown): boolean { + const errno = + typeof error === 'object' && error !== null ? (error as { errno?: unknown }).errno : undefined; + return typeof errno === 'number' && ROLLED_BACK_BY_SERVER_ERRNOS.has(errno); +} + +function mySqlMigrationError(name: string, error: unknown, committed: boolean): Error { + const lines = [ + `migration ${name} failed: ${error instanceof Error ? error.message : String(error)}`, + ]; + if (committed) { + lines.push( + `MySQL commits schema changes immediately, so earlier statements in ${name} may already be applied. Undo them, fix the file, then run the command again.`, + 'See https://void.cloud/guide/database/mysql#failed-migrations', + ); + } + return new Error(lines.join('\n'), { cause: error }); +} + +/** + * Runs `work` while this session holds the database's migration lock, so no other migration run, + * deploy or reset changes the schema meanwhile. + */ +export async function withMySqlMigrationLock( + client: MySqlClient, + work: () => Promise, +): Promise { + const lock = await acquireMySqlMigrationLock(client); + try { + return await work(); + } finally { + await client.query('SELECT RELEASE_LOCK(?)', [lock]).catch(() => {}); + } +} + export async function applyMySqlMigrations( client: MySqlClient, migrations: Array<{ name: string; sql: string }>, @@ -54,6 +148,17 @@ export async function applyMySqlMigrations( if (migrations.length === 0) { return []; } + return withMySqlMigrationLock(client, () => applyPendingMySqlMigrations(client, migrations)); +} + +/** Applies the files not yet recorded. The caller holds the migration lock. */ +export async function applyPendingMySqlMigrations( + client: MySqlClient, + migrations: Array<{ name: string; sql: string }>, +): Promise> { + if (migrations.length === 0) { + return []; + } await client.query( 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', @@ -65,33 +170,65 @@ export async function applyMySqlMigrations( const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); for (const migration of pending) { - // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. - for (const statement of splitMigrationSql(migration.sql)) { - await client.query(statement); + let ran = 0; + // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, on its own. + await client.query('BEGIN'); + try { + // One statement per query, so `ran` tells whether earlier statements of the file ran. + for (const statement of splitMySqlMigrationSql(migration.sql)) { + await client.query(statement); + ran++; + } + await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ + migration.name, + new Date().toISOString(), + ]); + await client.query('COMMIT'); + } catch (error) { + const committed = + ran > 0 && !isRolledBackByServer(error) && !(await isTransactionOpen(client)); + // A failed ROLLBACK means the session is gone, and MySQL rolls back with it. + await client.query('ROLLBACK').catch(() => {}); + throw mySqlMigrationError(migration.name, error, committed); } - await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ - migration.name, - new Date().toISOString(), - ]); } return pending.map((migration) => migration.name); } +/** Connects to `url` and runs `work` while holding that database's migration lock. */ +export async function withMySqlMigrationLockAt( + url: string, + work: (client: MySqlClient) => Promise, +): Promise { + const { createConnection } = await import('mysql2/promise'); + const connection = await createConnection({ uri: url, multipleStatements: true }); + try { + const client = toMySqlClient(connection); + return await withMySqlMigrationLock(client, () => work(client)); + } finally { + await connection.end(); + } +} + +/** + * Applies the pending files in `migrationsDir`. `afterApply` gets the applied names and runs before + * the lock is released, so the schema changes it makes (Better Auth's tables) do not overlap + * another run either. + */ export async function runMySqlMigrations( migrationsDir: string, url: string, + afterApply?: (applied: Array) => Promise, ): Promise> { const migrations = collectMigrations(migrationsDir); - if (migrations.length === 0) { + if (migrations.length === 0 && !afterApply) { return []; } - const { createConnection } = await import('mysql2/promise'); - const connection = await createConnection({ uri: url, multipleStatements: true }); - try { - return await applyMySqlMigrations(toMySqlClient(connection), migrations); - } finally { - await connection.end(); - } + return withMySqlMigrationLockAt(url, async (client) => { + const applied = await applyPendingMySqlMigrations(client, migrations); + await afterApply?.(applied); + return applied; + }); } diff --git a/packages/void/src/migrations/runner-pg.ts b/packages/void/src/migrations/runner-pg.ts index 62e8f15f3..75e116695 100644 --- a/packages/void/src/migrations/runner-pg.ts +++ b/packages/void/src/migrations/runner-pg.ts @@ -71,8 +71,12 @@ export async function applyPgMigrations( ]); await client.query('COMMIT'); } catch (err) { - await client.query('ROLLBACK'); - throw err; + // A failed ROLLBACK means the session is gone, and PostgreSQL rolls back with it. + await client.query('ROLLBACK').catch(() => {}); + throw new Error( + `migration ${m.name} failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); } } diff --git a/packages/void/src/migrations/runner.ts b/packages/void/src/migrations/runner.ts index 18a296a36..d7ee3c861 100644 --- a/packages/void/src/migrations/runner.ts +++ b/packages/void/src/migrations/runner.ts @@ -270,7 +270,17 @@ export async function runMigrations(migrationsDir: string, dbPath: string): Prom return true; }); - if (tx.immediate()) { + // A throw inside the transaction rolls the whole file back. + let applied: boolean; + try { + applied = tx.immediate(); + } catch (err) { + throw new Error( + `migration ${migration.name} failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + if (applied) { appliedNames.push(migration.name); } } diff --git a/packages/void/src/nitro.ts b/packages/void/src/nitro.ts new file mode 100644 index 000000000..ee09a28aa --- /dev/null +++ b/packages/void/src/nitro.ts @@ -0,0 +1,138 @@ +import { resolve as resolveWindowsPath } from 'node:path/win32'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathslash'; +import { normalizePath, type Plugin } from 'vite'; +import { bundleWorkerSqlDriver } from './cli/wrapper.ts'; +import { getDatabaseDialect, readConfig } from './config.ts'; +import { voidDbBundlerPlugin } from './plugin-drizzle.ts'; + +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// The package's built modules, where the env runtime and its AsyncLocalStorage live. +const packageDistDir = `${normalizePath(dirname(dirname(envModuleFile)))}/`; +const REQUEST_SCOPE_PLUGIN = '#void/nitro-request-scope'; + +/** The parts of a Nitro instance that the module uses. */ +type NitroInstance = { + options: { + dev: boolean; + rootDir: string; + buildDir: string; + exportConditions?: Array; + plugins: Array; + virtual: Record string | Promise)>; + rollupConfig?: { plugins?: unknown }; + cloudflare?: { nodeCompat?: boolean }; + }; +}; + +const NODEJS_COMPAT_FLAGS = new Set(['nodejs_compat', 'nodejs_compat_v2']); + +/** + * `path` as Nitro's Rollup names its modules: with backslashes on Windows, as its node-resolve + * plugin returns them. That plugin looks up an absolute import, such as the `global` polyfill + * Nitro adds to pg, by walking up from the importer to a package.json. From a module named with + * forward slashes there, the walk does not stop at the drive root, so it never ends when no + * package.json is above the module. + */ +function nitroModuleId(path: string): string { + return process.platform === 'win32' ? resolveWindowsPath(path) : path; +} + +/** + * A Nitro runtime plugin that runs each request in Void's request scope, as the Class A wrapper + * does, so void/db keeps one PostgreSQL or MySQL client per request instead of one per query. + */ +function requestScopePluginSource(): string { + return `import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)}; + +export default function voidRequestScope(nitroApp) { + const handler = nitroApp.h3App.handler; + // Nitro's Cloudflare entry and dev plugins keep the env here; a dev plugin may first set a + // promise of it. + nitroApp.h3App.handler = async (event) => + withRuntimeEnv(await globalThis.__env__, () => handler(event)); +} +`; +} + +/** + * Keep Void's `node:async_hooks` imports for workerd's own module. With its `cloudflare.nodeCompat` + * option off, Nitro bundles a stub whose AsyncLocalStorage loses the request scope at the first + * await. + */ +const nativeAsyncHooksPlugin: Plugin = { + name: 'void:nitro-async-hooks', + resolveId(id, importer) { + if ( + id === 'node:async_hooks' && + importer !== undefined && + normalizePath(importer).startsWith(packageDistDir) + ) { + return { id, external: true }; + } + return null; + }, +}; + +/** + * Nitro module for apps whose server routes Nitro bundles (Nuxt and Analog). Nitro's bundler + * does not run Vite plugins, so without this module `void/db` in a server route, page server + * load or cron job is the package's D1 client without the app's schema. Register it in the + * Nitro config: `modules: ['void/nitro']`. + */ +const voidNitroModule = { + name: 'void', + setup(nitro: NitroInstance): void { + const { options } = nitro; + const config = readConfig(options.rootDir); + const dialect = getDatabaseDialect(config); + // Development runs Nitro in Node, which loads the drivers itself. + const workerd = !options.dev && options.exportConditions?.includes('workerd') === true; + const plugins: Array = [ + voidDbBundlerPlugin(options.rootDir, { + config, + ...(workerd && { + sqlDriver: async (sqlDialect, entry) => + nitroModuleId( + await bundleWorkerSqlDriver(sqlDialect, entry, join(options.buildDir, 'void')), + ), + }), + }), + ]; + // Nitro uses workerd's own Node modules instead of its stubs only when it finds nodejs_compat + // in a Wrangler config, and Void's Cloudflare config is not one it reads. Deploys add the flag + // for PostgreSQL and MySQL, whose drivers need it. Otherwise the Worker gets the Cloudflare + // config's flags, or in a managed deploy `worker.compatibility_flags` when those are set: a + // Worker built for native modules does not start without the flag. + const nodeCompat = (flags: Array | undefined) => + flags?.some((flag) => NODEJS_COMPAT_FLAGS.has(flag)) === true; + const cloudflareFlags = config.cloudflare?.compatibility_flags; + if ( + workerd && + (dialect !== 'sqlite' || + (nodeCompat(cloudflareFlags) && + nodeCompat(config.worker?.compatibility_flags ?? cloudflareFlags))) + ) { + options.cloudflare ??= {}; + options.cloudflare.nodeCompat ??= true; + } + // D1 apps keep Nitro's requests as they are: in a request scope, void/db reads D1 through + // a replica-capable session instead of the primary. + if (dialect !== 'sqlite') { + options.virtual[REQUEST_SCOPE_PLUGIN] = requestScopePluginSource(); + options.plugins.push(REQUEST_SCOPE_PLUGIN); + if (workerd) { + plugins.push(nativeAsyncHooksPlugin); + } + } + const rollupConfig = (options.rollupConfig ??= {}); + const existing = rollupConfig.plugins; + // Nitro adds its own resolvers after these, so `void/db` never reaches them. + rollupConfig.plugins = [ + ...plugins, + ...(Array.isArray(existing) ? existing : existing ? [existing] : []), + ]; + }, +}; + +export default voidNitroModule; diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 7d5c6ea51..996e4f98e 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -1,12 +1,14 @@ -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import { join } from 'pathslash'; +import { dirname, isAbsolute, join, relative } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; import { DRIZZLE_OPERATORS, generateDbDts } from './codegen/db-types.ts'; import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts'; +import { getDatabaseDialect, readConfig, resolveBindingNames, type VoidConfig } from './config.ts'; import { discoverSchema } from './drizzle/discover.ts'; import { voidWarn } from './log.ts'; -import { readProjectPaths } from './project-paths.ts'; +import { readProjectPaths, resolveProjectPaths, type ProjectPaths } from './project-paths.ts'; // Resolve drizzle-orm paths from void's own deps so they work in any project // (including metaframeworks where drizzle-orm isn't a direct dependency). @@ -15,124 +17,105 @@ const drizzleOrmResolved = fileURLToPath(import.meta.resolve('drizzle-orm')); const envModuleResolved = fileURLToPath(import.meta.resolve('#self/_env')); const drizzleNodePgResolved = fileURLToPath(import.meta.resolve('drizzle-orm/node-postgres')); const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); -const pgResolved = fileURLToPath(import.meta.resolve('pg')); -const mysql2Resolved = fileURLToPath(import.meta.resolve('mysql2')); const VIRTUAL_DB_ID = '\0virtual:void-db'; const VIRTUAL_SCHEMA_DB_ID = '\0virtual:void-db-schema'; -type DrizzlePluginOptions = { + +// Workers tie a connection to the request that opened it. void/db's PostgreSQL and MySQL +// templates share one drizzle instance whose client looks up the current request's pool on +// every call, so a query built or prepared in one request runs on the pool of the request that +// executes it. `has` and `getPrototypeOf` answer drizzle's pool checks before a transaction. +// Every pool has the same shape, so those answers are kept: outside a request scope, where each +// lookup builds a pool, a check would otherwise build one that never runs a query. +const REQUEST_POOL_PROXY = ` +function requestPool(resolve, own = {}) { + const has = new Map(); + let prototype; + return new Proxy({}, { + get(_, prop) { + if (Object.hasOwn(own, prop)) return own[prop]; + const pool = resolve(); + const value = Reflect.get(pool, prop); + return typeof value === "function" ? value.bind(pool) : value; + }, + has(_, prop) { + if (Object.hasOwn(own, prop)) return true; + if (!has.has(prop)) has.set(prop, Reflect.has(resolve(), prop)); + return has.get(prop); + }, + getPrototypeOf: () => (prototype ??= Object.getPrototypeOf(resolve())), + }); +} +`; +type VoidDbModuleOptions = { d1BindingName?: string; forceVirtualDb?: boolean; +}; +type DrizzlePluginOptions = VoidDbModuleOptions & { localD1DatabaseId?: string; localD1PersistRoot?: string; redirectDbTypePaths?: boolean; + /** Nitro bundles the server build's output (Nuxt and Analog). */ + nitroBundlesServer?: boolean; }; /** - * Vite plugin for Drizzle integration. - * - * - Intercepts `void/db` imports in SSR/worker environments and serves - * a virtual module that creates a Drizzle DB instance, using the user's - * schema when present. - * - Generates `.void/db.d.ts` with proper Drizzle types. - * - Sets up `@schema` Vite alias for convenient imports when a schema exists. - * - Updates `.void/tsconfig.json` with TypeScript paths. + * The app's `void/db` module for its dialect, schema and D1 binding. Resolving and loading + * have no side effects, so the Vite plugin and `voidDbBundlerPlugin` share them. */ -export function drizzlePlugin( +function createVoidDbModule( root: string, - hasRouteTypes: boolean, - dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite', - options: DrizzlePluginOptions = {}, -): Plugin { - const outDir = join(root, '.void'); - const paths = readProjectPaths(root); + dialect: 'sqlite' | 'postgresql' | 'mysql', + options: VoidDbModuleOptions, + paths: ProjectPaths = readProjectPaths(root), +) { const schema = discoverSchema(root, paths); - const hasDbRuntime = schema != null || options.forceVirtualDb === true; + // The package's own `void/db` is a D1 client, so PostgreSQL and MySQL always need this one. + const hasDbRuntime = schema != null || options.forceVirtualDb === true || dialect !== 'sqlite'; const d1BindingName = options.d1BindingName ?? 'DB'; - // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.) - ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { - redirectDbModule: options.redirectDbTypePaths, - paths, - }); - - // Generate db.d.ts when void/db is available, even without a user schema. - if (hasDbRuntime) { - generateDbDts(outDir, schema ?? null, dialect); - } - - let isBuild = false; - return { - name: 'void:drizzle', - enforce: 'pre', - - config(_, env) { - isBuild = env.command === 'build'; - if (!schema) { - return; - } - - return { - resolve: { - alias: { - '@schema': schema.path, - }, - }, - }; - }, - - resolveId: { - filter: { id: /^(void\/db|@schema|pg$)/ }, - handler(id, importer) { - // Resolve pg from void's own deps during build so PG projects - // don't need pg as a direct dependency. - if (id === 'pg' && isBuild && dialect === 'postgresql' && importer === VIRTUAL_DB_ID) { - return pgResolved; - } - - // Only intercept void/db in server/worker environments (not client) - if (id === 'void/db' && this.environment?.name !== 'client') { - if (!hasDbRuntime) { - return; - } // Fall through to the real module (stub) - // Don't redirect schema files to avoid circular dependency - // (virtual:void-db imports the schema, which imports void/db). - // Schema files only need SQL helpers, not the runtime DB instance. - if (schema && importer) { - // Normalize separators for Windows — Vite uses forward slashes, - // but schema.path from Node's path.join uses backslashes on Windows. - const normalizedImporter = normalizePath(importer); - const normalizedSchemaPath = normalizePath(schema.path); - if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) { - return VIRTUAL_SCHEMA_DB_ID; - } - if ( - schema.type === 'directory' && - normalizedImporter.startsWith(normalizedSchemaPath) - ) { - return VIRTUAL_SCHEMA_DB_ID; - } + schema, + hasDbRuntime, + + resolveId(id: string, importer: string | undefined): string | undefined { + if (id === 'void/db') { + if (!hasDbRuntime) { + return; + } // Fall through to the real module (stub) + // Don't redirect schema files to avoid circular dependency + // (virtual:void-db imports the schema, which imports void/db). + // Schema files only need SQL helpers, not the runtime DB instance. + if (schema && importer) { + // Normalize separators for Windows — Vite uses forward slashes, + // but schema.path from Node's path.join uses backslashes on Windows. + const normalizedImporter = normalizePath(importer); + const normalizedSchemaPath = normalizePath(schema.path); + if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) { + return VIRTUAL_SCHEMA_DB_ID; + } + if (schema.type === 'directory' && normalizedImporter.startsWith(normalizedSchemaPath)) { + return VIRTUAL_SCHEMA_DB_ID; } - return VIRTUAL_DB_ID; } + return VIRTUAL_DB_ID; + } - // Resolve @schema alias explicitly via resolveId so it works in - // meta-framework SSR environments (Astro's workerd runner, etc.) - // where Vite's config-level resolve.alias may not be applied. - if (schema && (id === '@schema' || id.startsWith('@schema/'))) { - if (id === '@schema') { - return schema.path; - } - return join(schema.path, id.slice('@schema/'.length)); + // Resolve @schema alias explicitly via resolveId so it works in + // meta-framework SSR environments (Astro's workerd runner, etc.) + // where Vite's config-level resolve.alias may not be applied. + if (schema && (id === '@schema' || id.startsWith('@schema/'))) { + if (id === '@schema') { + return schema.path; } - }, + return join(schema.path, id.slice('@schema/'.length)); + } }, load: { // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention filter: { id: /^\0virtual:void-db(?:-schema)?$/ }, - handler(id) { + handler(id: string): string { if (id === VIRTUAL_SCHEMA_DB_ID) { return ` export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -147,16 +130,14 @@ export function createDb() { const schemaImport = schema ? `import * as schema from ${JSON.stringify(schema.path)};` : ''; - const drizzleConfig = schema - ? dialect === 'mysql' - ? ', { schema, mode: "default" }' - : ', { schema }' - : ''; + const drizzleConfig = schema ? ', { schema }' : ''; if (dialect === 'mysql') { + // drizzle builds the pool from `connection`, so its bare mysql2 import is the only one + // and the worker prebundle serves it. Its config check rejects `mode` without `schema`. + const mysqlDrizzleConfig = schema ? ', schema, mode: "default"' : ''; return ` import { drizzle } from ${JSON.stringify(drizzleMysql2Resolved)}; -import { createPool } from ${JSON.stringify(mysql2Resolved)}; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -171,34 +152,74 @@ function getConnectionString() { throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function getInstance() { - return getRuntimeRequestResource(_requestResourceKey, () => { - const pool = createPool({ - uri: getConnectionString(), - connectionLimit: 1, - maxIdle: 1, - idleTimeout: 1000, - }); - return drizzle(pool${drizzleConfig}); +// mysql2 closes idle connections only from a timer that it starts when it builds a pool with +// maxIdle below connectionLimit, and that timer runs until the pool ends: workerd rejects it in +// global scope, and nothing ends Void's pools. Time each connection from its release instead, +// which runs in the request that ran the query. +function closeIdleConnections(pool, idleTimeout) { + const timers = new WeakMap(); + pool.on("release", (connection) => { + const timer = setTimeout(() => connection.destroy(), idleTimeout); + // A Node script that ends its pool exits without waiting for this timer. + timer.unref?.(); + timers.set(connection, timer); }); + pool.on("acquire", (connection) => clearTimeout(timers.get(connection))); + return pool; } +function createPools() { + const pool = closeIdleConnections( + drizzle({ + connection: { + uri: getConnectionString(), + connectionLimit: 1, + // Workers forbid eval, which mysql2 uses to build its row parsers. + disableEval: true, + }, + }).$client, + 1000, + ); + return { pool, promisePool: pool.promise() }; +} + +function getPools() { + // Framework entries that Void does not own run outside its request scope: + // they get a client for each use, never one shared across requests. + return getRuntimeRequestResource(_requestResourceKey, createPools, createPools); +} +${REQUEST_POOL_PROXY} +const promiseClient = requestPool(() => getPools().promisePool); +// drizzle asks for the promise API while it builds the database, which can run in global +// scope. No request exists there to own a pool, so answer without one. +const client = requestPool(() => getPools().pool, { promise: () => promiseClient }); + +let instance; + export const db = new Proxy({}, { get(_, prop) { - return Reflect.get(getInstance(), prop); + // drizzle reports a query's errors as "Failed query", so build the request's pool here: + // a missing connection string then throws its own message. Outside a request, build none. + getRuntimeRequestResource(_requestResourceKey, createPools, () => undefined); + instance ??= drizzle({ client${mysqlDrizzleConfig} }); + const value = Reflect.get(instance, prop); + return typeof value === "function" ? value.bind(instance) : value; }, }); export function createDb(connectionString) { - return drizzle(connectionString${drizzleConfig}); + const db = drizzle({ connection: { uri: connectionString, disableEval: true }${mysqlDrizzleConfig} }); + // 60 seconds is mysql2's own idleTimeout default. + closeIdleConnections(db.$client, 60000); + return db; } `; } if (dialect === 'postgresql') { + // Like MySQL: drizzle builds the pool, so its bare pg import is the only one. return ` import { drizzle } from ${JSON.stringify(drizzleNodePgResolved)}; -import pg from "pg"; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -213,20 +234,32 @@ function getConnectionString() { throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function getInstance() { - return getRuntimeRequestResource(_requestResourceKey, () => { - const pool = new pg.Pool({ +function createPool() { + return drizzle({ + connection: { connectionString: getConnectionString(), max: 1, idleTimeoutMillis: 1000, - }); - return drizzle(pool${drizzleConfig}); - }); + }, + }).$client; +} + +function getPool() { + // Framework entries that Void does not own run outside its request scope: + // they get a client for each use, never one shared across requests. + return getRuntimeRequestResource(_requestResourceKey, createPool, createPool); } +${REQUEST_POOL_PROXY} +let instance; export const db = new Proxy({}, { get(_, prop) { - return Reflect.get(getInstance(), prop); + // drizzle reports a query's errors as "Failed query", so build the request's pool here: + // a missing connection string then throws its own message. Outside a request, build none. + getRuntimeRequestResource(_requestResourceKey, createPool, () => undefined); + instance ??= drizzle({ client: requestPool(getPool)${schema ? ', schema' : ''} }); + const value = Reflect.get(instance, prop); + return typeof value === "function" ? value.bind(instance) : value; }, }); @@ -266,6 +299,83 @@ function createPrimaryDb(d1) { `; }, }, + }; +} + +/** + * Vite plugin for Drizzle integration. + * + * - Intercepts `void/db` imports in SSR/worker environments and serves + * a virtual module that creates a Drizzle DB instance, using the user's + * schema when present. + * - Generates `.void/db.d.ts` with proper Drizzle types. + * - Sets up `@schema` Vite alias for convenient imports when a schema exists. + * - Updates `.void/tsconfig.json` with TypeScript paths. + */ +export function drizzlePlugin( + root: string, + hasRouteTypes: boolean, + dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite', + options: DrizzlePluginOptions = {}, +): Plugin { + const outDir = join(root, '.void'); + const paths = readProjectPaths(root); + const dbModule = createVoidDbModule(root, dialect, options, paths); + const { schema, hasDbRuntime } = dbModule; + + // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.) + ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { + redirectDbModule: options.redirectDbTypePaths, + paths, + }); + + // Generate db.d.ts when void/db is available, even without a user schema. + if (hasDbRuntime) { + generateDbDts(outDir, schema ?? null, dialect); + } + + return { + name: 'void:drizzle', + enforce: 'pre', + + config() { + if (!schema) { + return; + } + + return { + resolve: { + alias: { + '@schema': schema.path, + }, + }, + }; + }, + + resolveId: { + filter: { id: /^(void\/db|@schema)/ }, + handler(id, importer) { + // Only intercept void/db in server/worker environments (not client) + if (id === 'void/db' && this.environment?.name === 'client') { + return; + } + const resolved = dbModule.resolveId(id, importer); + // Nitro bundles this output with the server routes. Leave a PostgreSQL or MySQL app's + // `void/db` to it, so the app has one client and one driver copy: the one `void/nitro` + // serves, or without the module the package's own, which says to add it. + if ( + resolved === VIRTUAL_DB_ID && + dialect !== 'sqlite' && + options.nitroBundlesServer && + this.environment?.mode === 'build' + ) { + return { id, external: true }; + } + return resolved; + }, + }, + + load: dbModule.load, async configureServer() { if (!schema) { @@ -300,3 +410,154 @@ function createPrimaryDb(d1) { }, }; } + +/** + * `void/db` and `@schema` for bundles that do not run Void's Vite plugins: Nitro's (Nuxt and + * Analog server routes, through `void/nitro`) and the cron and queue handler bundle that deploys + * build. Without it they load the package's static `void/db`, a D1 client without the app's + * schema. It reads the dialect, schema and D1 binding from the project, as the Vite plugin does. + * Its hooks are plain Rollup hooks; Vite also needs `enforce` to run them before its resolver. + * + * For a bundle that runs in workerd, `sqlDriver` builds a copy of the PostgreSQL or MySQL driver + * from its entry file, and the plugin serves that module for `pg` or `mysql2` (for + * `mysql2/promise`, the promise API from the same build). Each import gets the copy that the + * bundler resolves for it: the generated client's drizzle-orm gets the one it depends on, and an + * app's own import gets the app's version. + */ +export function voidDbBundlerPlugin( + root: string, + options: { + config?: VoidConfig; + sqlDriver?: (dialect: 'postgresql' | 'mysql', entry: string) => Promise; + } = {}, +): Plugin { + const config = options.config ?? readConfig(root); + const dialect = getDatabaseDialect(config); + const d1BindingName = resolveBindingNames(config.inference?.bindings)?.d1; + const dbModule = createVoidDbModule( + root, + dialect, + // The package's own `void/db` reads D1 from the `DB` binding. + { d1BindingName, forceVirtualDb: d1BindingName !== undefined }, + resolveProjectPaths(root, config), + ); + const driverPackage = + dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql2' : undefined; + const drivers = new Map>(); + const driverDirs: Array = []; + return { + name: 'void:db', + enforce: 'pre', + async resolveId(id, importer, resolveOptions) { + const promiseApi = driverPackage === 'mysql2' && MYSQL2_PROMISE.test(id); + if ((id === driverPackage || promiseApi) && options.sqlDriver && dialect !== 'sqlite') { + // Resolve it as a `require` would. For an import, Rollup's CommonJS plugin loads a CommonJS + // driver to wrap it: that hides the file, and its build of the driver can fail on optional + // native modules. + const custom = resolveOptions?.custom; + const resolved = await this.resolve(id, importer, { + ...resolveOptions, + skipSelf: true, + custom: { ...custom, 'node-resolve': { ...custom?.['node-resolve'], isRequire: true } }, + }); + const entry = + resolved && !resolved.external + ? resolvePackageEntry(driverPackage, resolved.id) + : undefined; + if (entry === undefined) { + return resolved; + } + let driver = drivers.get(entry); + if (!driver) { + assertWorkerDriver(driverPackage, entry, root, importer); + driver = options.sqlDriver(dialect, entry).then((path) => { + driverDirs.push(`${normalizePath(dirname(path))}/`); + return path; + }); + drivers.set(entry, driver); + } + // The promise API sits next to the driver, named the way `sqlDriver` names the driver. + return promiseApi ? (await driver).replace(/mysql2\.mjs$/, 'promise.mjs') : driver; + } + // The driver needs workerd's own Node modules: Nitro replaces them with stubs when its + // `cloudflare.nodeCompat` option is off. + if ( + id.startsWith('node:') && + importer && + driverDirs.some((dir) => normalizePath(importer).startsWith(dir)) + ) { + return { id, external: true }; + } + const resolved = dbModule.resolveId(id, importer); + if (resolved !== undefined && id !== 'void/db') { + // `@schema` can name a schema folder or a file without its extension. Only Vite's + // config alias hands those to a resolver, so apply the bundler's own rules here. + return this.resolve(resolved, importer, { skipSelf: true }); + } + return resolved ?? null; + }, + load(id) { + if (id !== VIRTUAL_DB_ID && id !== VIRTUAL_SCHEMA_DB_ID) { + return null; + } + return dbModule.load.handler(id); + }, + }; +} + +/** mysql2's promise API, which `bundleWorkerSqlDriver` builds next to the driver. */ +const MYSQL2_PROMISE = /^mysql2\/promise(?:\.js)?$/; + +/** + * The first driver versions that run in Workers: pg 8.11 brings pg-cloudflare, its socket there, + * and mysql2 3.13 the `disableEval` option, without which it compiles its parsers with + * `new Function`, which workerd forbids. + */ +const WORKER_DRIVER_MINIMUM: Record<'pg' | 'mysql2', [major: number, minor: number]> = { + pg: [8, 11], + mysql2: [3, 13], +}; + +/** Throw when the driver whose entry file is `entry` is older than Workers need. */ +function assertWorkerDriver( + name: 'pg' | 'mysql2', + entry: string, + root: string, + importer: string | undefined, +): void { + let version: string | undefined; + try { + const packageJson = createRequire(entry).resolve(`${name}/package.json`); + ({ version } = JSON.parse(readFileSync(packageJson, 'utf8')) as { version?: string }); + } catch { + return; + } + const match = /^(\d+)\.(\d+)\./.exec(version ?? ''); + const [major, minor] = WORKER_DRIVER_MINIMUM[name]; + if ( + !match || + Number(match[1]) > major || + (Number(match[1]) === major && Number(match[2]) >= minor) + ) { + return; + } + const source = importer ? ` Imported by ${relative(root, importer)}.` : ''; + throw new Error( + `${name} ${version} cannot run in Cloudflare Workers, which need ${name} ${major}.${minor} or later. Upgrade ${name}.${source}`, + ); +} + +/** + * The CommonJS entry of package `name`, from `file` inside one of its copies (which a bundler may + * have resolved to an ES module entry), or undefined if `file` is not on disk. + */ +function resolvePackageEntry(name: string, file: string): string | undefined { + if (!isAbsolute(file)) { + return undefined; + } + try { + return createRequire(file).resolve(name); + } catch { + return undefined; + } +} diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 728f484e3..6a098e944 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -1,7 +1,7 @@ import { mkdirSync } from 'node:fs'; import { join } from 'pathslash'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; -import { type NotFoundHandling } from './config.ts'; +import { type DatabaseDialect, type NotFoundHandling } from './config.ts'; import { isViteBuildOrPreview } from './plugin-dev-config.ts'; import { frameworkTriggersDispatchPath, @@ -22,6 +22,7 @@ export function getFrameworkEntry( framework: Framework, root: string, devTriggerToken: string, + dialect: DatabaseDialect, notFound?: NotFoundHandling, ): string { const isBuild = isViteBuildOrPreview(); @@ -38,6 +39,7 @@ export function getFrameworkEntry( framework, getOriginalFrameworkEntry(framework, root), isBuild, + dialect, undefined, notFound, ); @@ -63,6 +65,11 @@ export function getOriginalFrameworkEntry(framework: Framework, root: string): s * first, dispatches `/__void/{scheduled,queue}` if it matches, and otherwise * delegates to the framework's original entry. * + * For PostgreSQL and MySQL, both shapes run the framework inside Void's request scope + * (`withRuntimeEnv`), as Void's own handlers do, so void/db keeps one client per request. D1 apps + * keep the framework's own fetch: in a request scope, void/db reads D1 through a replica-capable + * session instead of the primary. + * * We emit two shapes of the wrapper depending on the Vite command: * * - Serve: dispatch is dynamically imported and gets a chance to handle @@ -80,6 +87,7 @@ export function generateClassATriggerWrapper( framework: Framework, original: string, isBuild: boolean, + dialect: DatabaseDialect, assetsDir = 'assets', /** * Resolved `assetConfig.not_found_handling`. A Class A app deploys through @@ -92,25 +100,9 @@ export function generateClassATriggerWrapper( const outDir = join(root, '.void'); mkdirSync(outDir, { recursive: true }); const wrapperPath = join(outDir, `${framework}-trigger-wrapper.ts`); - const body = isBuild - ? `// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. -import original from ${JSON.stringify(original)}; - -export default { - ...original, - async fetch(request, env, ctx) { - return serveWithAssets(request, env, await original.fetch(request, env, ctx)); - }, -}; -${compileAssetFallbackHelper(assetsDir, false, notFound)} -` - : `// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. -import original from ${JSON.stringify(original)}; - -export default { - ...original, - async fetch(request, env, ctx) { - if (request.method === "POST") { + const fetchBody = isBuild + ? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));` + : ` if (request.method === "POST") { const pathname = new URL(request.url).pathname; if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); @@ -118,10 +110,25 @@ export default { if (response) return response; } } - return original.fetch(request, env, ctx); - }, + return original.fetch(request, env, ctx);`; + const scoped = dialect !== 'sqlite'; + const fetchMethod = scoped + ? ` fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { +${fetchBody.replace(/^/gm, ' ')} + }); + },` + : ` async fetch(request, env, ctx) { +${fetchBody} + },`; + const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit. +import original from ${JSON.stringify(original)}; +${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''} +export default { + ...original, +${fetchMethod} }; -`; +${isBuild ? `${compileAssetFallbackHelper(assetsDir, false, notFound)}\n` : ''}`; writeFileIfChanged(wrapperPath, body); return wrapperPath; } diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index 9f66ab397..65d7205ec 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -554,6 +554,23 @@ export const onRequest = defineMiddleware(async (context, next) => { `; } +type AstroMiddlewareClaim = { servers: number; gitignoreLines: ReadonlyArray }; + +const ASTRO_MIDDLEWARE_CLAIMS: unique symbol = Symbol.for('void.astroDevMiddleware.v1'); + +/** + * The dev servers in this process that use the middleware Void wrote, by file path. Astro restarts + * the dev server in the same process with a fresh config, so a new plugin instance (from a new + * copy of this module, too) is set up before the old server closes. Kept on `globalThis` so both + * see the same claim. + */ +function astroMiddlewareClaims(): Map { + const store = globalThis as typeof globalThis & { + [ASTRO_MIDDLEWARE_CLAIMS]?: Map; + }; + return (store[ASTRO_MIDDLEWARE_CLAIMS] ??= new Map()); +} + /** * Writes a tiny Astro middleware to the user's project so * `/__void/scheduled` and `/__void/queue` are intercepted inside Astro's @@ -563,47 +580,72 @@ export const onRequest = defineMiddleware(async (context, next) => { * already has `src/middleware.ts` we don't overwrite it — instead we log a * one-shot snippet they can paste into their existing middleware. * - * If we wrote the file we remove it on dev-server close so the user's - * source tree stays clean. `src/middleware.ts` belongs to the user, so the - * `.gitignore` line we add for it is removed too; otherwise a middleware they - * write later would be silently left out of git. + * If we wrote the file we remove it when the last dev server using it closes, + * so the user's source tree stays clean. `src/middleware.ts` belongs to the + * user, so the `.gitignore` line we add for it is removed too; otherwise a + * middleware they write later would be silently left out of git. */ export function astroDevTriggerInjectPlugin(root: string): Plugin { const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE); const middlewareDirIndexPath = join(root, 'src', 'middleware', 'index.ts'); - let wroteFile = false; - let addedGitignoreLines: ReadonlyArray = []; - - const ensureFile = () => { + const claims = astroMiddlewareClaims(); + + /** Whether the dev server being set up now holds the middleware Void wrote. */ + const ensureFile = (): boolean => { + const claim = claims.get(middlewarePath); + if (claim && existsSync(middlewarePath)) { + // A restart: the server being replaced wrote this file and still holds it. + claim.servers++; + return true; + } if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) { voidWarn( 'Astro middleware already exists. To enable /__void/scheduled and /__void/queue,\n' + ' add the Void dev-trigger middleware to your existing onRequest sequence.\n' + ' See https://void.cloud/guide/jobs#local-development.', ); - return; + return false; } mkdirSync(dirname(middlewarePath), { recursive: true }); writeFileSync(middlewarePath, astroMiddlewareSource(frameworkTriggersDispatchPath(root))); - wroteFile = true; + if (claim) { + // Removed while the server being replaced still holds it. + claim.servers++; + } else { + claims.set(middlewarePath, { + servers: 1, + gitignoreLines: ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]), + }); + } + return true; }; - const cleanup = () => { - if (wroteFile) { - rmSync(middlewarePath, { force: true }); + const release = () => { + const claim = claims.get(middlewarePath); + if (!claim || --claim.servers > 0) { + return; } - removeGitignoreLines(root, addedGitignoreLines); - addedGitignoreLines = []; + claims.delete(middlewarePath); + rmSync(middlewarePath, { force: true }); + removeGitignoreLines(root, claim.gitignoreLines); }; return { name: 'void:astro-dev-trigger-inject', apply: 'serve', configureServer(server) { - ensureFile(); - if (wroteFile) { - addedGitignoreLines = ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); + if (!ensureFile()) { + return; } + // One release per dev server. Vite's own restart (after an .env edit) sets up this same + // plugin for the new server before it closes the old one. + let held = true; + const cleanup = () => { + if (held) { + held = false; + release(); + } + }; server.httpServer?.once('close', cleanup); process.once('exit', cleanup); }, diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index fa9fba0c4..774fc58b3 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -1,4 +1,6 @@ -import { type Plugin } from 'vite'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'pathslash'; +import { defaultServerConditions, type Plugin } from 'vite'; import { type ApplicationAnalysis } from './application-analysis.ts'; import { ensureWranglerCompat, @@ -9,6 +11,7 @@ import { syncWranglerBindings, } from './cli/wrangler.ts'; import { readConfig } from './config.ts'; +import { findDependencyClosure, findVoidDependentPackages } from './installed-packages.ts'; import { resolveLocalD1Target } from './local-d1.ts'; import { voidLog } from './log.ts'; import { bindingRuntimePlugin } from './plugin-binding-runtime.ts'; @@ -26,8 +29,19 @@ import { nitroDevTriggerInjectPlugin, } from './plugin-framework-triggers.ts'; import { migrationPlugin } from './plugin-migration.ts'; -import { createCloudflareBuiltinsExternalPlugin } from './plugin-worker-environment.ts'; -import { injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; +import { + createBareVoidRedirectPlugin, + createCloudflareBuiltinsExternalPlugin, +} from './plugin-worker-environment.ts'; +import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; + +const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// Packages Void depends on are installed for code in this directory. +const voidDir = dirname(fileURLToPath(import.meta.url)); + +const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; +const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; export function createFrameworkPlugins(input: { readonly localD1: ReturnType; @@ -180,6 +194,36 @@ export function createFrameworkPlugins(input: { const svelteKitHooksInjectPlugin: Plugin = { name: 'void:sveltekit-hooks-inject', apply: 'serve', + config() { + if (detected.class !== 'b') { + return; + } + // Kit's dev server runs server code in Vite's Node SSR graph. That graph + // externalizes a bare import resolving into node_modules before any plugin + // sees it, so an installed void/db would load the package's D1 runtime + // instead of the virtual module. Inlining keeps every void import, including + // the injected env import below and those of PostgreSQL and MySQL apps' + // installed packages that import void, in the graph that holds void/db's env + // module. In a D1 app, a package's own void/db reads the same D1 binding, so + // those packages stay with Node. + return { + ssr: { + noExternal: ['void', ...(dialect === 'sqlite' ? [] : findVoidDependentPackages(root))], + }, + }; + }, + resolveId: { + order: 'pre', + filter: { id: /^void$/ }, + handler() { + if (detected.class !== 'b' || this.environment?.name === 'client') { + return; + } + // Inlined, the package entry would load the whole Vite plugin into Kit's module + // runner. Server code gets the lightweight handler subpath, as in Void apps. + return handlerFile; + }, + }, transform(code, id) { if (detected.class !== 'b') { return; @@ -194,14 +238,111 @@ export function createFrameworkPlugins(input: { }, }; + // Astro's Cloudflare adapter owns the Worker entry. For PostgreSQL and MySQL, its default + // entry runs each request inside Void's request scope (`withRuntimeEnv`), as the Class A + // wrapper does, so void/db keeps one client per request instead of one per query. D1 apps + // keep Astro's own entry: in a request scope, void/db reads D1 through a replica-capable + // session instead of the primary. + const astroRequestScopePlugin: Plugin | undefined = + detected.name === 'astro' && dialect !== 'sqlite' + ? { + name: 'void:astro-request-scope', + enforce: 'pre', + resolveId: { + filter: { id: /^@astrojs\/cloudflare\/entrypoints\/server(?:\.js)?$/ }, + handler(_id, importer) { + if (importer === ASTRO_SCOPED_SERVER_ENTRY || this.environment?.name === 'client') { + return; + } + return ASTRO_SCOPED_SERVER_ENTRY; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:astro-server-entry$/ }, + handler() { + return `import original from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; +import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)}; +export * from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, () => original.fetch(request, env, ctx)); + }, +}; +`; + }, + }, + } + : undefined; + + // SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs + // that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg + // loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become + // `createRequire(import.meta.url)`. A driver that Vite can resolve from the app root (npm, Yarn + // and Bun installs, or an app that lists it) even stays external, and Void's browser-target + // packaging bundles it with browser stubs. Build void/db as the Cloudflare plugin builds + // Workers: void, drizzle-orm, the driver with every package it depends on, and installed + // packages that import void. Other dependencies stay external, as in Kit's own build: Kit's + // analyse step loads this output in Node, and a package's `workerd` build may import + // `cloudflare:` modules (capnweb does). Run each request in Void's request scope, as in dev, so + // void/db keeps one client per request. D1 apps keep Kit's output: in a request scope, void/db + // reads D1 through a replica-capable session instead of the primary. + const svelteKitWorkerBuildPlugin: Plugin | undefined = + detected.class === 'b' && dialect !== 'sqlite' + ? { + name: 'void:sveltekit-worker-build', + apply: 'build', + enforce: 'pre', + configEnvironment(name, environment) { + if (name !== 'ssr') { + return; + } + return { + resolve: { + conditions: environment.resolve?.conditions + ? ['workerd', 'worker'] + : ['workerd', 'worker', ...defaultServerConditions], + noExternal: [ + 'void', + // By name as well: a Yarn PnP install has no node_modules to read. The + // driver's own dependencies then resolve only through it, so they are bundled. + 'drizzle-orm', + dialect === 'mysql' ? 'mysql2' : 'pg', + ...findDependencyClosure('drizzle-orm', voidDir), + ...findDependencyClosure(dialect === 'mysql' ? 'mysql2' : 'pg', voidDir), + ...findVoidDependentPackages(root), + ], + }, + // Node output binds `require` at load with createRequire(import.meta.url). Neutral + // output throws only when a require runs, as for mysql2's optional `cardinal`. + build: { rolldownOptions: { platform: 'neutral' } }, + }; + }, + transform(code, id) { + if ( + !id.includes('.svelte-kit/generated/server/internal') && + !id.includes('.svelte-kit/generated/build/server.js') + ) { + return; + } + return injectSvelteKitBuildHooks(code, id, envModuleFile); + }, + } + : undefined; + return [ devTriggerSecretGuardPlugin(), createCloudflareBuiltinsExternalPlugin(), wranglerCompatPlugin, wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), + ...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []), svelteKitHooksInjectPlugin, + ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, + // Also serves the Node built-ins that the driver requires in SvelteKit's workerd build. serverRuntimeOptimizerPlugin, bindingRuntimePlugin(bindingNames), // Framework-agnostic env DX: typegen, client leak guard, and build-time @@ -217,6 +358,7 @@ export function createFrameworkPlugins(input: { forceVirtualDb: bindingNames?.d1 !== undefined, localD1DatabaseId: localD1.databaseId, localD1PersistRoot: frameworkPersistRoot, + nitroBundlesServer: detected.name === 'nuxt' || detected.name === 'analog', }), migrationPlugin(root, dialect, localD1.databaseId, frameworkPersistRoot, { enabled: false, @@ -227,7 +369,10 @@ export function createFrameworkPlugins(input: { ? [nitroDevTriggerInjectPlugin(root, { serverDir: 'server' })] : []), ...(detected.name === 'analog' ? [analogDevTriggerInjectPlugin(root)] : []), - ...(detected.name === 'astro' ? [astroDevTriggerInjectPlugin(root)] : []), + // Astro runs its server code, the dev cron and queue dispatch included, in workerd. + ...(detected.name === 'astro' + ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)] + : []), implicitDeployGuard, ]; } diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 1940b1a07..5c34d33d1 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -11,6 +11,11 @@ function readDatabaseUrl(root: string): string | undefined { return loadLocalEnv(root).DATABASE_URL; } +/** DATABASE_URL as Better Auth's local migrations read it: the shell before `.env`. */ +function readAuthDatabaseUrl(root: string): string | undefined { + return (process.env.DATABASE_URL ?? readDatabaseUrl(root)) || undefined; +} + /** * Vite plugin that runs local migrations on dev server startup. * For SQLite/D1: applies migrations to the local miniflare database. @@ -35,10 +40,37 @@ export function migrationPlugin( return; } + const migrateAuth = async (databaseUrl?: string) => { + const { runLocalVoidAuthMigrations } = await import('./auth/node.ts'); + await runLocalVoidAuthMigrations( + root, + dialect, + persistRoot, + databaseId, + auth?.configPath, + databaseUrl, + ); + }; + // MySQL runs Better Auth's migrations under the migration lock of the database they change. + const mysqlAuth = dialect === 'mysql' && auth?.enabled === true; + const logApplied = (applied: Array) => { + if (applied.length > 0) { + voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); + } + }; + try { - if (hasSqlMigrations && dialect !== 'sqlite') { - const url = readDatabaseUrl(root); - if (!url) { + if (dialect !== 'sqlite') { + const { collectMigrations } = await import('./migrations/collect.ts'); + const allMigrations = collectMigrations(migrationsDir); + // For MySQL an empty migrations folder has nothing to apply. + const runFiles = dialect === 'mysql' ? allMigrations.length > 0 : hasSqlMigrations; + // Migration files apply to the database in .env, as `void db migrate` does. Better + // Auth's migrations read DATABASE_URL from the shell before .env, and each step holds + // the lock of the database it changes. + const url = runFiles ? readDatabaseUrl(root) : undefined; + const authUrl = mysqlAuth ? readAuthDatabaseUrl(root) : undefined; + if ((runFiles && !url) || (mysqlAuth && !authUrl)) { afterServerReady(server, () => { console.log(); voidError( @@ -47,38 +79,55 @@ export function migrationPlugin( }); return; } - const { collectMigrations } = await import('./migrations/collect.ts'); - const { validateMigrations } = await import('./migrations/validate.ts'); + // One lock covers both steps when they change the same database. + const authWithFiles = authUrl !== undefined && authUrl === url; - const allMigrations = collectMigrations(migrationsDir); - const appliedNames = - dialect === 'mysql' - ? await import('./migrations/runner-mysql.ts').then((runner) => - runner.getMySqlAppliedMigrations(url), - ) - : await import('./migrations/runner-pg.ts').then((runner) => - runner.getPgAppliedMigrations(url), - ); - const appliedSet = new Set(appliedNames); - const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name)); - if (pending.length > 0) { - try { - validateMigrations(pending); - } catch (err) { - voidWarn(err instanceof Error ? err.message : String(err)); + if (url) { + const { validateMigrations } = await import('./migrations/validate.ts'); + const appliedNames = + dialect === 'mysql' + ? await import('./migrations/runner-mysql.ts').then((runner) => + runner.getMySqlAppliedMigrations(url), + ) + : await import('./migrations/runner-pg.ts').then((runner) => + runner.getPgAppliedMigrations(url), + ); + const appliedSet = new Set(appliedNames); + const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name)); + if (pending.length > 0) { + try { + validateMigrations(pending); + } catch (err) { + voidWarn(err instanceof Error ? err.message : String(err)); + } + } + + const applied = + dialect === 'mysql' + ? await import('./migrations/runner-mysql.ts').then((runner) => + runner.runMySqlMigrations( + migrationsDir, + url, + // Report the applied files before Better Auth's step, which can still fail. + authWithFiles + ? async (names) => { + logApplied(names); + await migrateAuth(url); + } + : undefined, + ), + ) + : await import('./migrations/runner-pg.ts').then((runner) => + runner.runPgMigrations(migrationsDir, url), + ); + if (!authWithFiles) { + logApplied(applied); } } - const applied = - dialect === 'mysql' - ? await import('./migrations/runner-mysql.ts').then((runner) => - runner.runMySqlMigrations(migrationsDir, url), - ) - : await import('./migrations/runner-pg.ts').then((runner) => - runner.runPgMigrations(migrationsDir, url), - ); - if (applied.length > 0) { - voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); + if (authUrl && !authWithFiles) { + const { withMySqlMigrationLockAt } = await import('./migrations/runner-mysql.ts'); + await withMySqlMigrationLockAt(authUrl, () => migrateAuth(authUrl)); } } else if (hasSqlMigrations) { const { runMigrations, getMiniflareDatabasePath, getAppliedMigrations } = @@ -106,8 +155,9 @@ export function migrationPlugin( } if (auth?.enabled) { - const { runLocalVoidAuthMigrations } = await import('./auth/node.ts'); - await runLocalVoidAuthMigrations(root, dialect, persistRoot, databaseId, auth.configPath); + if (!mysqlAuth) { + await migrateAuth(); + } voidLog('Applied Better Auth migrations'); } } catch (err) { diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index b9cadbed6..eefdcf6ac 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'pathslash'; import type { Plugin } from 'vite'; +import { findVoidDependentPackages } from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; /** Imports that require CF bindings and are not available in Node.js mode. */ @@ -111,17 +112,23 @@ function honoMiddleware( * single SSR build for API-only apps */ export function nodeTargetPlugin(target: NodeTarget): Array { + const unavailable = (id: string, root: string, importer: string | undefined) => { + // The import can come from an installed package, so name the file. + const source = importer ? ` Imported by ${relative(root, importer)}.` : ''; + return new Error( + `target: '${id}' is not available with target '${target}'. ` + + `CF bindings require target: "cloudflare".${source}`, + ); + }; + const guardPlugin: Plugin = { name: 'void:node-target', enforce: 'pre', resolveId: { filter: { id: CF_ONLY_REGEX }, - handler(id) { - throw new Error( - `target: '${id}' is not available with target '${target}'. ` + - `CF bindings require target: "cloudflare".`, - ); + handler(id, importer) { + throw unavailable(id, this.environment.config.root, importer); }, }, @@ -142,11 +149,20 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, config(_, { command }) { + const root = process.cwd(); + // Vite's SSR graph leaves a bare import that resolves into node_modules to Node before + // any plugin sees it. An installed void would then skip the guard above, and in a build + // the app's `void/handler` would load beside the copy that the generated routes bundle, + // so its ValidationError is not the class the routes check. Inline void, its adapters + // and installed packages that import void in the dev server, Vitest and the build alike, + // as with a workspace link. + const ssr = { + noExternal: ['void', /^@void\//, ...findVoidDependentPackages(root)], + }; if (command !== 'build') { - return; + return { ssr }; } - const root = process.cwd(); const voidDir = join(root, '.void'); const ssrBuildOptions = { outDir: 'dist/ssr', @@ -163,6 +179,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { // API-only apps need a single SSR build. if (existsSync(readProjectPaths(root).pagesDir)) { return { + ssr, build: { outDir: 'dist/client' }, environments: { ssr: { build: ssrBuildOptions }, @@ -174,6 +191,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { } return { + ssr, build: { ssr: true, ...ssrBuildOptions, @@ -181,14 +199,15 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }; }, - // Orchestrate dual-environment build for pages apps. - // For API-only apps (no `environments.ssr`), this hook is not triggered - // because Vite's builder is only created when extra environments exist. + // Orchestrate the builds. Vite runs this hook with either builder. API-only apps use + // the legacy single-environment builder, which creates only the `ssr` environment. async buildApp(builder) { // SSR must build first so that dist/ssr/app.js exists when the // prerender plugin runs in the client environment's closeBundle hook. await builder.build(builder.environments.ssr); - await builder.build(builder.environments.client); + if (builder.environments.client) { + await builder.build(builder.environments.client); + } }, configureServer(server) { @@ -224,5 +243,22 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, }; - return [guardPlugin]; + // The build bundles void, whose runtime loads `cloudflare:` modules with import() only when it + // runs in workerd. Keep those external. Node cannot load a static one, so fail the build. + const cloudflareModulesPlugin: Plugin = { + name: 'void:node-target-cloudflare-modules', + apply: 'build', + enforce: 'pre', + resolveId: { + filter: { id: /^cloudflare:/ }, + handler(id, importer, options) { + if (options.kind === 'dynamic-import') { + return { id, external: true }; + } + throw unavailable(id, this.environment.config.root, importer); + }, + }, + }; + + return [guardPlugin, cloudflareModulesPlugin]; } diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index 020b09a24..5bb0396ae 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -1,7 +1,10 @@ -import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { builtinModules, createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'pathslash'; import { type Plugin } from 'vite'; -import { ensureWranglerCompat } from './cli/wrangler.ts'; -import { readConfig, isNodeTarget } from './config.ts'; +import { ensureWranglerCompat, type WranglerCompat } from './cli/wrangler.ts'; +import { readConfig, isNodeTarget, type DatabaseDialect } from './config.ts'; const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; @@ -46,6 +49,29 @@ export function createCloudflareBuiltinsExternalPlugin(): Plugin { }; } +/** + * Send a bare `void` import in server code to the handler helpers (`defineScheduled`, + * `defineQueue`, ...), as Void apps and the cron/queue deploy bundles do. The package entry is the + * Vite plugin: loaded in a worker environment, it pulls Wrangler into the dependency optimizer. + */ +export function createBareVoidRedirectPlugin(): Plugin { + let handlerFile: string | undefined; + return { + name: 'void:bare-void-redirect', + resolveId: { + order: 'pre', + filter: { id: /^void$/ }, + handler() { + if (this.environment?.config.consumer === 'client') { + return; + } + handlerFile ??= fileURLToPath(import.meta.resolve('#self/handler')); + return handlerFile; + }, + }, + }; +} + export function createDefaultWorkerEnvironmentPlugin(): Plugin { return { name: 'void:default-worker-environment', @@ -130,27 +156,106 @@ export function createWranglerCompatPersistPlugin( }; } -export function createServerRuntimeOptimizerPlugin(voidOwnsWorkerRuntime: boolean): Plugin { +// void/db leaves its driver import to drizzle, so these nested prebundles serve it. +const DATABASE_DRIVER_DEPS: Record> = { + sqlite: [], + postgresql: ['void > pg'], + mysql: ['void > mysql2'], +}; + +const NODE_BUILTIN = new RegExp(`^(?:node:)?(?:${builtinModules.join('|')})$`); +const NODE_BUILTIN_REQUIRE_PREFIX = '\0void:node-require:'; + +/** + * The Node built-ins that workerd provides for the given compatibility settings, from the preset + * the Cloudflare plugin builds Workers with (a dependency of `@cloudflare/vite-plugin`). Before + * 2025-09-15 the preset fills in parts of `node:process` instead of listing it, but workerd + * provides that module at every date. + */ +async function readWorkerdNodeBuiltins(compat: WranglerCompat): Promise> { + const vitePlugin = fileURLToPath(import.meta.resolve('@cloudflare/vite-plugin')); + const manifestPath = createRequire(vitePlugin).resolve('@cloudflare/unenv-preset/package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + exports: { '.': { import: string } }; + }; + const { getCloudflarePreset } = (await import( + pathToFileURL(join(dirname(manifestPath), manifest.exports['.'].import)).href + )) as { + getCloudflarePreset(compat: { compatibilityDate: string; compatibilityFlags: Array }): { + external: Array; + }; + }; + const { external } = getCloudflarePreset({ + compatibilityDate: compat.compatibilityDate, + compatibilityFlags: compat.compatibilityFlags ?? [], + }); + return new Set([...external.map((name) => name.replace(/^node:/, '')), 'process']); +} + +export function createServerRuntimeOptimizerPlugin( + dialect: DatabaseDialect, + readCompat: () => WranglerCompat, +): Plugin { + let workerdNodeBuiltins: Promise> | undefined; const serverRuntimeOptimizerPlugin: Plugin = { name: 'void:server-runtime-optimizer', - configEnvironment(name) { + configEnvironment(name, environment) { if (name === 'client') { return; } - if (!voidOwnsWorkerRuntime) { + // The Cloudflare plugin gives every environment it runs in workerd these conditions from + // its config hook, which runs before any configEnvironment hook. That covers Void's own + // worker, class A frameworks and adapters such as @astrojs/cloudflare. Node runtimes load + // CommonJS themselves, and an include there turns on SSR prebundling nobody asked for. + if (!environment.resolve?.conditions?.includes('workerd')) { return { optimizeDeps: { exclude: ['void'] } }; } return { optimizeDeps: { exclude: ['void'], // Excluding void also skips converting its CommonJS dependencies, which workerd cannot - // evaluate: an installed void/email fails with "exports is not defined". Runtimes a - // framework owns stay untouched; Node loads CommonJS itself, and forcing an include - // there turns on SSR prebundling the framework did not ask for. - include: ['void > email-validator'], + // evaluate: an installed void/email fails with "exports is not defined". + include: ['void > email-validator', ...DATABASE_DRIVER_DEPS[dialect]], }, }; }, + // A build bundles void/db's driver, which is CommonJS. The Cloudflare plugin turns its requires + // of Node built-ins that workerd provides into imports with rolldown's esmExternalRequirePlugin, + // but rolldown skips that plugin unless the Vite copy running the build made it. pnpm installs + // Vite once per set of peers, so the app's Vite can differ from the one the Cloudflare plugin + // imports, and each require then throws in workerd. Serve those built-ins from an import here. + // A require of any other built-in stays as it is and throws only if it runs. + resolveId: { + // Before Vite's resolver, which makes a required built-in external. + order: 'pre', + filter: { id: NODE_BUILTIN }, + async handler(id, _importer, options) { + if ( + dialect === 'sqlite' || + options.kind !== 'require-call' || + this.environment?.mode !== 'build' || + !this.environment.config.resolve.conditions.includes('workerd') + ) { + return; + } + const name = id.replace(/^node:/, ''); + workerdNodeBuiltins ??= readWorkerdNodeBuiltins(readCompat()); + if (!(await workerdNodeBuiltins).has(name)) { + return; + } + return `${NODE_BUILTIN_REQUIRE_PREFIX}${name}`; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:node-require:/ }, + handler(id) { + // A built-in's default export is its CommonJS exports, and `require` returns an ES + // module's "module.exports" export. + const name = id.slice(NODE_BUILTIN_REQUIRE_PREFIX.length); + return `import builtin from "node:${name}";\nexport { builtin as "module.exports" };\n`; + }, + }, }; return serverRuntimeOptimizerPlugin; diff --git a/packages/void/src/runtime/better-auth-mysql.ts b/packages/void/src/runtime/better-auth-mysql.ts index 051a54f5c..adb9a9476 100644 --- a/packages/void/src/runtime/better-auth-mysql.ts +++ b/packages/void/src/runtime/better-auth-mysql.ts @@ -12,7 +12,10 @@ async function applyMigrations( migrationSchema: VoidAuthMigrationSchema, ): Promise { const { createConnection } = await import('mysql2/promise'); - const connection = await createConnection(resolveConnectionString(env, 'MySQL')); + const connection = await createConnection({ + uri: resolveConnectionString(env, 'MySQL'), + disableEval: true, + }); try { await applyMySqlAuthMigrations(mysqlMigrationRunner(connection), migrationSchema); } finally { diff --git a/packages/void/src/runtime/db.ts b/packages/void/src/runtime/db.ts index 12cc91cc5..8d6a74939 100644 --- a/packages/void/src/runtime/db.ts +++ b/packages/void/src/runtime/db.ts @@ -6,7 +6,7 @@ * This stub exists for the published npm package and type declarations. */ import { drizzle as _drizzle, type DrizzleD1Database } from 'drizzle-orm/d1'; -import { getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; +import { getRuntimeBinding, getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; // Re-export commonly used Drizzle utilities so consumers never need // to depend on drizzle-orm directly. @@ -56,6 +56,22 @@ function createPrimaryDb(d1: D1Database): DrizzleD1Database { const requestDbKey = {}; +function requireD1(): D1Database { + // Bundlers that skip Void's plugins load this D1 client for every app. Nitro needs the + // `void/nitro` module to serve a PostgreSQL or MySQL app's own `void/db`. Vite serves it to + // an installed package only when the package is an ES module that depends on void and the + // app lists it or reaches it through such packages. + if ( + getRuntimeBinding('DB') == null && + (getRuntimeBinding('HYPERDRIVE') != null || getRuntimeBinding('DATABASE_URL') != null) + ) { + throw new Error( + "db: Missing Cloudflare binding 'DB'. This app connects through HYPERDRIVE or DATABASE_URL, but its server bundle loaded the D1 client. In Nuxt or Analog, add 'void/nitro' to the Nitro modules. An installed package that imports void/db must be an ES module that lists void in its dependencies or peerDependencies, and the app must list it or reach it through such packages.", + ); + } + return requireRuntimeBinding('DB'); +} + /** * Default Drizzle D1 instance — resolves the `DB` binding from the runtime * environment. When the Void plugin is active, this is replaced with a @@ -65,10 +81,10 @@ export const db: DrizzleD1Database = new Proxy({} as DrizzleD1Database, { get(_, prop) { const instance = getRuntimeRequestResource( requestDbKey, - () => createDb(requireRuntimeBinding('DB')), + () => createDb(requireD1()), // Framework adapters do not always enter Void's request scope. Without a // shared session, each operation starts on primary to preserve fresh reads. - () => createPrimaryDb(requireRuntimeBinding('DB')), + () => createPrimaryDb(requireD1()), ); return Reflect.get(instance, prop); }, diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts index 908763d1d..33a0fe312 100644 --- a/packages/void/src/runtime/env-raw.ts +++ b/packages/void/src/runtime/env-raw.ts @@ -22,7 +22,18 @@ export function isIsrActive(rawEnv: RuntimeEnv): boolean { return rawEnv.__VOID_ISR_DEPLOYMENT_ID != null; } -export const envContext: AsyncLocalStorage = new AsyncLocalStorage(); +/** + * One process or isolate can evaluate this module more than once: Vite's SSR module + * runner keeps its own copy beside a package it leaves external, and a dependency + * optimizer reload re-evaluates it while a request is in flight. Every copy must read + * the env of the current request, so the store lives on `globalThis`, like + * `authContext`. Two Void versions in one process share it: change the key when the + * store shape changes. + */ +const ENV_CONTEXT_SYMBOL: unique symbol = Symbol.for('void.envContext.v1'); +const shared = globalThis as { [ENV_CONTEXT_SYMBOL]?: AsyncLocalStorage }; +shared[ENV_CONTEXT_SYMBOL] ??= new AsyncLocalStorage(); +export const envContext: AsyncLocalStorage = shared[ENV_CONTEXT_SYMBOL]; let cloudflareEnv: RuntimeEnv | undefined; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index 2e39cd786..5e553bbca 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -22,7 +22,11 @@ type RuntimeRequestScope = { resources: Map; }; -const runtimeRequestContext = new AsyncLocalStorage(); +// Shared across copies of this module for the reason given at `envContext`. +const REQUEST_SCOPE_SYMBOL: unique symbol = Symbol.for('void.runtimeRequestScope.v1'); +const shared = globalThis as { [REQUEST_SCOPE_SYMBOL]?: AsyncLocalStorage }; +shared[REQUEST_SCOPE_SYMBOL] ??= new AsyncLocalStorage(); +const runtimeRequestContext = shared[REQUEST_SCOPE_SYMBOL]; /** @internal Reuse a resource only within the current runtime request. */ export function getRuntimeRequestResource( diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index e5563f39b..2ad581ced 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -1,9 +1,11 @@ +import type { Connection } from 'mysql2/promise'; +import type { MigrationEntry, MigrationResponse, MigrationResult } from './migration-handler.ts'; import { - splitMigrationSql, - type MigrationEntry, - type MigrationResponse, - type MigrationResult, -} from './migration-handler.ts'; + acquireMySqlMigrationLease, + holdMySqlMigrationLease, + type MySqlMigrationLease, +} from './migration-lock-mysql.ts'; +import { splitMySqlMigrationStatements } from './mysql-migration-sql.ts'; export function createMigrationHandler( env: Record, @@ -20,11 +22,7 @@ export function createMigrationHandler( } try { - const result = await runMySqlMigrations(env, migrations); - if (result.ok && runAuthMigrations) { - await runAuthMigrations(); - } - return Response.json(result); + return Response.json(await runMySqlMigrations(env, migrations, runAuthMigrations)); } catch (error) { return Response.json( { @@ -41,6 +39,7 @@ export function createMigrationHandler( async function runMySqlMigrations( env: Record, migrations: Array, + runAuthMigrations?: () => Promise, ): Promise { const hyperdrive = env.HYPERDRIVE as { connectionString?: string } | undefined; if (!hyperdrive?.connectionString) { @@ -48,11 +47,23 @@ async function runMySqlMigrations( } const { createConnection } = await import('mysql2/promise'); + // Hyperdrive runs one statement per query, so a file is sent statement by statement. const connection = await createConnection({ uri: hyperdrive.connectionString, - multipleStatements: true, + disableEval: true, }); + let leaseConnection: Connection | undefined; + let lease: MySqlMigrationLease | undefined; try { + // Lease statements must stay out of the migration transactions, so they get their own connection. + const leaseClient = await createConnection({ + uri: hyperdrive.connectionString, + disableEval: true, + }); + leaseConnection = leaseClient; + lease = await acquireMySqlMigrationLease( + async (sql, params) => (await leaseClient.query(sql, params))[0], + ); await connection.query( 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', ); @@ -67,16 +78,24 @@ async function runMySqlMigrations( } const start = Date.now(); try { - // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. - for (const statement of splitMigrationSql(migration.sql)) { - await connection.query(statement); + // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, + // on its own. Hyperdrive keeps one connection for the open transaction. + await connection.query('BEGIN'); + for (const statement of splitMySqlMigrationStatements(migration.sql)) { + await lease.renew(); + await connection.query(statement.sql).catch((error: unknown) => { + throw statement.program ? withBreakpointNote(error, migration.name) : error; + }); } + await lease.renew(); await connection.query( 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [migration.name, new Date().toISOString()], ); + await connection.query('COMMIT'); results.push({ name: migration.name, applied: true, durationMs: Date.now() - start }); } catch (error) { + await connection.query('ROLLBACK').catch(() => {}); results.push({ name: migration.name, applied: false, @@ -87,8 +106,34 @@ async function runMySqlMigrations( } } + // Better Auth's migrations change the schema too, so they run before the lease is released. + if (runAuthMigrations) { + await holdMySqlMigrationLease(lease, runAuthMigrations); + } return { ok: true, migrations: results }; } finally { - await connection.end(); + await lease?.release().catch(() => {}); + await Promise.all([connection.end(), leaseConnection?.end()]); + } +} + +/** + * The splitter keeps the SQL after a trigger, routine or event with it. The CLI runs that with + * `multipleStatements`; here one query takes one statement, so the server rejects it as a syntax + * error before running any of it. MySQL reports 1064; Vitess (PlanetScale) reports "syntax error + * at position", or 1149 for several statements it can parse. A syntax error inside the body looks + * the same, so the note says "if". + */ +function withBreakpointNote(error: unknown, name: string): unknown { + if (!(error instanceof Error)) { + return error; + } + const errno = (error as { errno?: unknown }).errno; + if (errno !== 1064 && errno !== 1149 && !/syntax error at position/.test(error.message)) { + return error; } + return new Error( + `${error.message}\nThis deploy runs one statement per query. If ${name} has more SQL after a trigger, procedure, function or event, add a \`--> statement-breakpoint\` line after it.`, + { cause: error }, + ); } diff --git a/packages/void/src/runtime/migration-lock-mysql.ts b/packages/void/src/runtime/migration-lock-mysql.ts new file mode 100644 index 000000000..213c9c39b --- /dev/null +++ b/packages/void/src/runtime/migration-lock-mysql.ts @@ -0,0 +1,199 @@ +import { createHash, randomUUID } from 'node:crypto'; + +/** + * Migration runs on one MySQL database exclude each other with two kinds of lock, and each run + * checks for the other kind after it takes its own: + * + * - The CLI runner holds a named lock (GET_LOCK) for its session, so it ends with the connection. + * - The hosted handler reaches MySQL through Hyperdrive, which pools connections per transaction, + * so a session lock could stay on a pooled connection or be reset away. It writes a lease row + * with an expiry instead, using one statement at a time. Lease times are UTC: NOW() follows + * each session's time zone and its DST changes, and DATETIME stores no zone. + */ + +/** Hyperdrive ends a statement after 60 seconds, and the hosted handler renews before each one. */ +const LEASE_SECONDS = 120; +/** A deploy waits long enough for the lease of a run that stopped early to expire. */ +const DEPLOY_WAIT_MS = (LEASE_SECONDS + 10) * 1000; +const POLL_MS = 1000; +/** How often the lease is renewed while statements that cannot renew it themselves run. */ +const KEEP_ALIVE_MS = 30_000; + +/** Runs one statement and returns the rows or the result header. */ +export type MySqlLeaseQuery = (sql: string, params?: Array) => Promise; + +export type MySqlMigrationLease = { + /** Extends the lease. Throws when it expired or another run took it. */ + renew(): Promise; + release(): Promise; +}; + +/** + * The CLI runner's named lock. Lock names are server-wide and at most 64 characters, so the name + * carries a hash of the database name. Vitess reports a connection's target (`app@primary`, + * `app:-80@replica`) in DATABASE(), and the lock belongs to the keyspace. + */ +export function mySqlMigrationLockName(database: string): string { + return `void_migrations:${createHash('sha1').update(database.split(/[:@]/)[0]!).digest('hex')}`; +} + +export function mySqlMigrationLockError(database: string, retry: string): Error { + return new Error( + `Another migration run is applying migrations to MySQL database "${database}". Wait for it to finish, then ${retry}.`, + ); +} + +/** + * Seconds left on a deploy's lease, or undefined when no deploy holds one. The CLI runner calls + * this while it holds the named lock. + */ +export async function readMySqlMigrationLease(query: MySqlLeaseQuery): Promise { + try { + const [row] = rowsOf( + await query( + "SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(6), `expires_at`) AS remaining FROM `_void_migration_lock` WHERE `name` = 'app' AND `expires_at` > UTC_TIMESTAMP(6)", + ), + ); + return row ? Number(row.remaining) : undefined; + } catch (error) { + if (isMissingTable(error)) { + return undefined; + } + throw error; + } +} + +/** + * Takes the lease for a hosted run. It waits while another deploy holds the lease or a CLI run + * holds the named lock, and throws when that takes too long. + */ +export async function acquireMySqlMigrationLease( + query: MySqlLeaseQuery, +): Promise { + const [row] = rowsOf(await query('SELECT DATABASE() AS db')); + const database = String(row?.db ?? ''); + const lockName = mySqlMigrationLockName(database); + const owner = randomUUID(); + await query( + 'CREATE TABLE IF NOT EXISTS `_void_migration_lock` (`name` VARCHAR(64) NOT NULL PRIMARY KEY, `owner` VARCHAR(64) NOT NULL, `expires_at` DATETIME(6) NOT NULL)', + ); + + const lease: MySqlMigrationLease = { + async renew() { + const result = await query( + "UPDATE `_void_migration_lock` SET `expires_at` = UTC_TIMESTAMP(6) + INTERVAL ? SECOND WHERE `name` = 'app' AND `owner` = ? AND `expires_at` > UTC_TIMESTAMP(6)", + [LEASE_SECONDS, owner], + ); + if (affectedRows(result) !== 1) { + throw new Error( + `The migration lock on MySQL database "${database}" expired before this run finished. Deploy again.`, + ); + } + }, + // Expires the row instead of deleting it. When a delete meets two waiting inserts of the same + // key, InnoDB ends one of them with a deadlock. + async release() { + await query( + "UPDATE `_void_migration_lock` SET `expires_at` = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND WHERE `name` = 'app' AND `owner` = ?", + [owner], + ); + }, + }; + + const deadline = Date.now() + DEPLOY_WAIT_MS; + for (;;) { + if (await take(query, owner)) { + // Taken before this check, so a CLI run that starts later sees the lease and stops. + const [lock] = rowsOf(await query('SELECT IS_USED_LOCK(?)', [lockName])); + // Vitess names the column after the call and ignores any alias. + if (Object.values(lock ?? {})[0] == null) { + return lease; + } + await lease.release(); + } + if (Date.now() >= deadline) { + throw mySqlMigrationLockError(database, 'deploy again'); + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } +} + +/** + * Runs `work`, whose statements cannot renew the lease before each one (Better Auth's migrations), + * and renews the lease in the background meanwhile. Throws after `work` when a renewal failed, + * because another run may have taken the lease. + */ +export async function holdMySqlMigrationLease( + lease: MySqlMigrationLease, + work: () => Promise, +): Promise { + await lease.renew(); + let failure: { error: unknown } | undefined; + let renewal = Promise.resolve(); + const timer = setInterval(() => { + renewal = renewal + .then(() => lease.renew()) + .catch((error: unknown) => { + failure ??= { error }; + }); + }, KEEP_ALIVE_MS); + let result: T; + try { + result = await work(); + } finally { + clearInterval(timer); + await renewal; + } + if (failure) { + throw failure.error; + } + return result; +} + +async function take(query: MySqlLeaseQuery, owner: string): Promise { + try { + return await tryTake(query, owner); + } catch (error) { + // MySQL rolled the statement back as a deadlock victim, so the lease is not taken: poll again. + if (errnoOf(error) === 1213) { + return false; + } + throw error; + } +} + +async function tryTake(query: MySqlLeaseQuery, owner: string): Promise { + const inserted = await query( + "INSERT IGNORE INTO `_void_migration_lock` (`name`, `owner`, `expires_at`) VALUES ('app', ?, UTC_TIMESTAMP(6) + INTERVAL ? SECOND)", + [owner, LEASE_SECONDS], + ); + if (affectedRows(inserted) === 1) { + return true; + } + // The lease of a run that stopped early. + const taken = await query( + "UPDATE `_void_migration_lock` SET `owner` = ?, `expires_at` = UTC_TIMESTAMP(6) + INTERVAL ? SECOND WHERE `name` = 'app' AND `expires_at` < UTC_TIMESTAMP(6)", + [owner, LEASE_SECONDS], + ); + return affectedRows(taken) === 1; +} + +function rowsOf(result: unknown): Array> { + return Array.isArray(result) ? (result as Array>) : []; +} + +function affectedRows(result: unknown): number { + return Number((result as { affectedRows?: unknown } | undefined)?.affectedRows ?? 0); +} + +function isMissingTable(error: unknown): boolean { + const value = + typeof error === 'object' && error !== null ? (error as Record) : undefined; + return value?.code === 'ER_NO_SUCH_TABLE' || value?.errno === 1146; +} + +function errnoOf(error: unknown): unknown { + return typeof error === 'object' && error !== null + ? (error as { errno?: unknown }).errno + : undefined; +} diff --git a/packages/void/src/runtime/mysql-migration-sql.ts b/packages/void/src/runtime/mysql-migration-sql.ts new file mode 100644 index 000000000..369f4f01a --- /dev/null +++ b/packages/void/src/runtime/mysql-migration-sql.ts @@ -0,0 +1,149 @@ +import { splitMigrationSql } from './migration-handler.ts'; + +/** + * Splits a MySQL migration file into the statements Void runs one query at a time. Hyperdrive + * does not accept several statements in one query, and a file that runs statement by statement + * can tell whether a failure left earlier statements committed. + * + * The file first splits at drizzle-kit `--> statement-breakpoint` lines. Each part then splits at + * semicolons outside strings, quoted names and comments, and drops comments after its last + * statement. A statement that defines a trigger, routine or event keeps the rest of its part with + * it: the semicolons in its BEGIN ... END body cannot be told apart from the ones between + * statements without a parser. + */ +export function splitMySqlMigrationSql(sql: string): Array { + return splitMySqlMigrationStatements(sql).map((statement) => statement.sql); +} + +/** + * A statement from {@link splitMySqlMigrationSql}. `program` marks one that opens a trigger, + * routine, event or other compound statement, and so also holds the rest of its part. + */ +export type MySqlMigrationStatement = { sql: string; program: boolean }; + +/** {@link splitMySqlMigrationSql}, with each statement marked. */ +export function splitMySqlMigrationStatements(sql: string): Array { + return splitMigrationSql(sql).flatMap((part) => { + const statements = splitMySqlStatements(part); + return statements.length > 0 ? statements : [{ sql: part, program: false }]; + }); +} + +/** Words after CREATE or ALTER that name an object without a BEGIN ... END body. */ +const PLAIN_OBJECTS = + 'TABLE|INDEX|VIEW|DATABASE|SCHEMA|USER|ROLE|TABLESPACE|SERVER|UNIQUE|FULLTEXT|SPATIAL|TEMPORARY'; + +/** + * The start of a statement whose body can hold semicolons: a stored program, after options such + * as DEFINER, or a compound statement that MariaDB also runs outside one. It is matched against + * the statement with quoted text and comments blanked out, so a column named `event` or a plain + * `BEGIN` transaction does not match. + */ +const COMPOUND_START = new RegExp( + [ + `^\\s*(?:CREATE|ALTER)\\s+(?:(?!(?:${PLAIN_OBJECTS})\\b)\\S+\\s+){0,8}?(?:TRIGGER|PROCEDURE|FUNCTION|EVENT|PACKAGE)\\b`, + '^\\s*BEGIN\\s+NOT\\s+ATOMIC\\b', + '^\\s*(?:\\w+\\s*:\\s*)?(?:BEGIN|IF|CASE|LOOP|REPEAT|WHILE)\\b(?!\\s*(?:WORK\\s*)?$)', + ].join('|'), + 'i', +); + +/** The statements in `sql`, without the comments around them. */ +function splitMySqlStatements(sql: string): Array { + const statements: Array = []; + // The current statement with quoted text and comments blanked out. + let bare = ''; + // Where the current statement starts and ends, or -1 before its first character. + let start = -1; + let end = -1; + // Set once a statement opens a BEGIN ... END body: the rest of the part stays with it. + let compound = false; + let index = 0; + const take = (from: number, to: number) => { + if (start === -1) { + start = from; + } + end = to; + }; + + while (index < sql.length) { + const character = sql[index]!; + const next = sql[index + 1]; + + if (character === "'" || character === '"' || character === '`') { + const close = skipQuoted(sql, index, character); + take(index, close); + bare += ' '; + index = close; + continue; + } + // MySQL reads `--` as a comment only when whitespace or a control character follows it. + if ( + character === '#' || + (character === '-' && next === '-' && !/\S/.test(sql[index + 2] ?? ' ')) + ) { + const newline = sql.indexOf('\n', index); + index = newline === -1 ? sql.length : newline; + bare += ' '; + continue; + } + if (character === '/' && next === '*') { + const close = sql.indexOf('*/', index + 2); + const commentEnd = close === -1 ? sql.length : close + 2; + // `/*! ... */` is SQL that MySQL runs, and `/*+ ... */` holds optimizer hints. + if (sql[index + 2] === '!' || sql[index + 2] === '+') { + take(index, commentEnd); + } + bare += ' '; + index = commentEnd; + continue; + } + if (character === ';' && !compound) { + if (start !== -1 && COMPOUND_START.test(bare)) { + compound = true; + } else { + if (start !== -1) { + statements.push({ sql: sql.slice(start, index + 1), program: false }); + } + start = -1; + end = -1; + bare = ''; + index++; + continue; + } + } + + bare += character; + if (!/\s/.test(character)) { + take(index, index + 1); + } + index++; + } + + if (start !== -1) { + statements.push({ sql: sql.slice(start, end), program: compound }); + } + return statements; +} + +/** The index after the quoted string or name that opens at `open`. */ +function skipQuoted(sql: string, open: number, quote: string): number { + let index = open + 1; + while (index < sql.length) { + const character = sql[index]; + // Backslash escapes apply in strings, not in backtick-quoted names. + if (character === '\\' && quote !== '`') { + index += 2; + continue; + } + if (character === quote) { + if (sql[index + 1] === quote) { + index += 2; + continue; + } + return index + 1; + } + index++; + } + return sql.length; +} diff --git a/packages/void/src/sveltekit-hooks-transform.ts b/packages/void/src/sveltekit-hooks-transform.ts index d6f8b81ae..7e5aed7a1 100644 --- a/packages/void/src/sveltekit-hooks-transform.ts +++ b/packages/void/src/sveltekit-hooks-transform.ts @@ -7,12 +7,60 @@ import { type AstProgram, } from './shared/ast.ts'; +type HooksTransform = { code: string; map: ReturnType }; + /** Add Void's dev bindings to SvelteKit's generated get_hooks function. */ export function injectSvelteKitHooks( code: string, id: string, dispatchPath: string, -): { code: string; map: ReturnType } | undefined { +): HooksTransform | undefined { + return editGetHooks( + code, + id, + `\n const { withRuntimeEnv } = await import("void/_env");\n const { handleDevTrigger } = await import(${JSON.stringify(dispatchPath)});`, + `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + + ` handle = async ({ event, resolve }) => {\n` + + ` const __voidEnv = event.platform?.env ?? (await import('cloudflare:workers')).env;\n` + + ` return withRuntimeEnv(__voidEnv, async () => {\n` + + ` const __triggerResp = await handleDevTrigger(event.request, __voidEnv, undefined);\n` + + ` if (__triggerResp) return __triggerResp;\n` + + ` return __origHandle({ event, resolve });\n` + + ` });\n };\n `, + ); +} + +/** + * Run each request of a SvelteKit build in Void's request scope. `envModule` is the env module + * void/db imports. Kit prerenders in Node, where `cloudflare:workers` does not load and Kit 3 + * passes no platform, so those requests run outside the scope. + */ +export function injectSvelteKitBuildHooks( + code: string, + id: string, + envModule: string, +): HooksTransform | undefined { + return editGetHooks( + code, + id, + `\n const { withRuntimeEnv } = await import(${JSON.stringify(envModule)});`, + `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + + ` handle = async ({ event, resolve }) => {\n` + + ` const __voidEnv = event.platform?.env ??\n` + + ` (typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'\n` + + ` ? (await import('cloudflare:workers')).env\n` + + ` : undefined);\n` + + ` return withRuntimeEnv(__voidEnv, () => __origHandle({ event, resolve }));\n` + + ` };\n `, + ); +} + +function editGetHooks( + code: string, + id: string, + prologue: string, + handleWrapper: string, +): HooksTransform | undefined { if (!code.includes('get_hooks')) { return undefined; } @@ -45,20 +93,7 @@ export function injectSvelteKitHooks( throw new Error('sveltekit: Expected one object return in generated get_hooks.'); } const ms = new MagicString(code); - ms.appendLeft( - body.start + 1, - `\n const { withRuntimeEnv } = await import("void/_env");\n const { handleDevTrigger } = await import(${JSON.stringify(dispatchPath)});`, - ); - ms.appendLeft( - returns[0].start, - `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + - ` handle = async ({ event, resolve }) => {\n` + - ` const __voidEnv = event.platform?.env ?? (await import('cloudflare:workers')).env;\n` + - ` return withRuntimeEnv(__voidEnv, async () => {\n` + - ` const __triggerResp = await handleDevTrigger(event.request, __voidEnv, undefined);\n` + - ` if (__triggerResp) return __triggerResp;\n` + - ` return __origHandle({ event, resolve });\n` + - ` });\n };\n `, - ); + ms.appendLeft(body.start + 1, prologue); + ms.appendLeft(returns[0].start, handleWrapper); return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; } diff --git a/packages/void/test/helpers/fake-mysql-server.ts b/packages/void/test/helpers/fake-mysql-server.ts new file mode 100644 index 000000000..054e99b46 --- /dev/null +++ b/packages/void/test/helpers/fake-mysql-server.ts @@ -0,0 +1,117 @@ +import { once } from 'node:events'; +import { createServer, type AddressInfo, type Socket } from 'node:net'; +import mysql from 'mysql2'; + +/** The parts of mysql2's server-side connection that the fake server uses. */ +type ServerConnection = { + sequenceId: number; + serverHandshake(options: Record): void; + on(event: 'query', listener: (sql: string) => void): void; + on(event: 'error', listener: (error: Error) => void): void; + writeColumns(columns: Array>): void; + writeTextRow(values: Array): void; + writeEof(): void; + writeOk(packet?: { affectedRows?: number }): void; +}; + +const { Connection, ConnectionConfig } = mysql as unknown as { + Connection: new (options: { config: unknown }) => ServerConnection; + ConnectionConfig: new (options: { stream: Socket; isServer: true }) => unknown; +}; + +const CHARSET = mysql.Charsets.UTF8_GENERAL_CI; +// Every basic capability except CLIENT_SSL (0x0800), so clients never ask for TLS. +const CAPABILITY_FLAGS = 0xffff & ~0x0800; + +export type FakeMySqlRow = Record; + +export type FakeMySqlServer = { + port: number; + /** The client connections the server holds open. */ + openConnections(): number; + /** Every client connection the server has accepted. */ + acceptedConnections(): number; + close(): Promise; +}; + +function columnsOf(rows: Array) { + return Object.keys(rows[0] ?? {}).map((name) => ({ + catalog: 'def', + schema: 'app', + table: 'fake', + orgTable: 'fake', + name, + orgName: name, + characterSet: CHARSET, + columnLength: 255, + columnType: typeof rows[0]?.[name] === 'number' ? mysql.Types.LONG : mysql.Types.VAR_STRING, + flags: 0, + decimals: 0, + })); +} + +/** + * A MySQL wire-protocol server built on mysql2's own server API. It accepts any user, answers + * every SELECT with `rows`, or with what `rows` returns for the query (columns in key order of + * the first row), and every other statement with OK for one row. It speaks the text protocol + * only, which is what drizzle's mysql2 driver uses. + */ +export async function startFakeMySqlServer( + rows: Array | ((sql: string) => Array), +): Promise { + const sockets = new Set(); + let accepted = 0; + + const server = createServer((socket) => { + accepted++; + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + const connection = new Connection({ + config: new ConnectionConfig({ stream: socket, isServer: true }), + }); + connection.serverHandshake({ + protocolVersion: 10, + serverVersion: '8.4.0-void-test', + connectionId: 1, + statusFlags: 2, + characterSet: CHARSET, + capabilityFlags: CAPABILITY_FLAGS, + authCallback(_params: unknown, done: (error: null) => void) { + done(null); + // Each command starts a new packet sequence; the server API leaves the reset to us. + connection.sequenceId = 0; + }, + }); + connection.on('query', (sql) => { + if (/^\s*select\b/i.test(sql)) { + const answer = typeof rows === 'function' ? rows(sql) : rows; + const columns = columnsOf(answer); + connection.writeColumns(columns); + for (const row of answer) { + connection.writeTextRow(columns.map((column) => row[column.name])); + } + connection.writeEof(); + } else { + connection.writeOk({ affectedRows: 1 }); + } + connection.sequenceId = 0; + }); + // A client that drops its socket emits an error here; unhandled, it would end the test run. + connection.on('error', () => {}); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + return { + port: (server.address() as AddressInfo).port, + openConnections: () => sockets.size, + acceptedConnections: () => accepted, + async close() { + for (const socket of sockets) { + socket.destroy(); + } + server.close(); + await once(server, 'close'); + }, + }; +} diff --git a/packages/void/test/integration/analog.test.ts b/packages/void/test/integration/analog.test.ts index 3857cb09b..2c106d052 100644 --- a/packages/void/test/integration/analog.test.ts +++ b/packages/void/test/integration/analog.test.ts @@ -104,6 +104,12 @@ describe('playground/analog dev', () => { expect(existsSync(join(ROOT, `${PLAYGROUND}/wrangler.jsonc`))).toBe(false); }); + it("serves void/db with the app's schema to server routes", async () => { + const response = await fetch(`${DEV_BASE}/api/smoke`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ firstUser: { name: 'Alice' } }); + }); + it('does not serve the dev-trigger token', async () => { const token = readFileSync(join(PLAYGROUND_ROOT, '.void/dev-trigger-token'), 'utf8').trim(); // Nitro's dev bundle inlines the trigger dispatch module. Analog builds it before listening. diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index 0c54758a2..449e94be0 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -1,8 +1,19 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { once } from 'node:events'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; import { join } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; import spawn from 'cross-spawn'; +import { createServer } from 'pglite-server'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/astro'; @@ -19,6 +30,26 @@ function readWranglerConfig(root: string): { describe('playground/astro dev', () => { let child: ReturnType; + // Dev writes Void's trigger middleware into src/ and removes it when the server stops. taskkill + // on Windows skips the exit hooks, and a later copy of this playground would keep a middleware + // that checks this server's trigger token. + const removeLeftoverMiddleware = () => { + const root = join(ROOT, PLAYGROUND); + const middleware = join(root, 'src/middleware.ts'); + if ( + !existsSync(middleware) || + !readFileSync(middleware, 'utf8').includes('Auto-generated by Void in dev') + ) { + return; + } + rmSync(middleware); + const gitignore = join(root, '.gitignore'); + writeFileSync( + gitignore, + readFileSync(gitignore, 'utf8').replace(/^src\/middleware\.ts\r?\n/m, ''), + ); + }; + const startDev = async () => { const server = await startServer((port) => { child = spawn( @@ -61,6 +92,7 @@ describe('playground/astro dev', () => { if (child) { await killChild(child); } + removeLeftoverMiddleware(); }); it('syncs wrangler config with compat flag and inferred bindings', () => { @@ -96,3 +128,217 @@ describe('playground/astro dev', () => { } }); }); + +// Astro owns its Worker entry; Void runs it in a request scope for PostgreSQL. +describe('playground/astro with PostgreSQL', () => { + const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, + ]; + let pglite: PGlite; + let pgServer: ReturnType; + let playground: IsolatedPlayground; + let child: ReturnType | undefined; + let dev: StartedServer; + let connections = 0; + + function start(script: 'dev' | 'preview'): Promise { + const root = playground.root; + return startServer((port) => { + child = spawn( + process.execPath, + [ + join(root, 'node_modules/astro/bin/astro.mjs'), + script, + '--host', + '127.0.0.1', + '--port', + String(port), + ], + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ASTRO_DEV_BACKGROUND: 'false', + ASTRO_PREVIEW_BACKGROUND: 'false', + // Astro's update check writes .astro/settings.json in a fresh project, and the watcher + // can report that one write twice; the second restarts the dev server mid-request. + ASTRO_DISABLE_UPDATE_CHECK: 'true', + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; + }, 30_000); + } + + // Runs every query of a request on one connection, a query built at module load included. + async function expectOneConnectionPerRequest({ base, output }: StartedServer): Promise { + const before = connections; + const response = await fetch(`${base}/api/user-counts`); + const result = { + status: response.status, + body: await response.text(), + connections: connections - before, + }; + expect(result, output().slice(-4000)).toEqual({ + status: 200, + body: '[2,2,2,2]', + connections: 1, + }); + } + + beforeAll(async () => { + pglite = new PGlite(); + await pglite.exec( + 'CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);', + ); + for (const user of USERS) { + await pglite.query('INSERT INTO users (name, email) VALUES ($1, $2)', [ + user.name, + user.email, + ]); + } + pgServer = createServer(pglite); + pgServer.on('connection', () => connections++); + pgServer.listen(0, '127.0.0.1'); + await once(pgServer, 'listening'); + const { port } = pgServer.address() as AddressInfo; + + playground = createIsolatedPlayground(ROOT, PLAYGROUND); + const root = playground.root; + const configPath = join(root, 'void.config.ts'); + writeFileSync( + configPath, + readFileSync(configPath, 'utf8') + .replace('defineConfig({', "defineConfig({\n database: 'pg',") + .replace( + "compatibility_flags: ['nodejs_als']", + "compatibility_flags: ['nodejs_compat', 'nodejs_als']", + ), + ); + rmSync(join(root, 'db/migrations'), { recursive: true, force: true }); + writeFileSync( + join(root, 'db/schema.ts'), + `import { pgTable, serial, text } from 'void/schema-pg'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull(), +}); +`, + ); + mkdirSync(join(root, 'src/pages/api'), { recursive: true }); + writeFileSync( + join(root, 'src/pages/api/users.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export const prerender = false; + +export async function GET() { + return Response.json(await db.select().from(users)); +} +`, + ); + writeFileSync( + join(root, 'src/pages/api/user-counts.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export const prerender = false; + +// Built when the module loads, run by every request. +const allUsers = db.select().from(users); + +export async function GET() { + const results = await Promise.all([allUsers, allUsers, allUsers, allUsers]); + return Response.json(results.map((rows) => rows.length)); +} +`, + ); + // The import the jobs guide shows. The package entry is Void's Vite plugin, which workerd + // cannot load; the dev middleware imports every cron on each request. + writeFileSync( + join(root, 'crons/tick.ts'), + `import { defineScheduled } from 'void'; + +export const cron = '*/5 * * * *'; + +export default defineScheduled(async () => {}); +`, + ); + writeFileSync( + join(root, '.env'), + `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`, + ); + + dev = await start('dev'); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + playground?.cleanup(); + }); + + it('queries PostgreSQL through void/db from an API route in dev', async () => { + const response = await fetch(`${dev.base}/api/users`); + const result = { status: response.status, body: await response.text() }; + expect(result, dev.output().slice(-4000)).toEqual({ + status: 200, + body: JSON.stringify(USERS), + }); + }); + + it('runs every query of a request on one connection in dev', async () => { + for (const _request of [1, 2]) { + await expectOneConnectionPerRequest(dev); + } + }); + + it('runs a cron that imports defineScheduled from void in dev', async () => { + const token = readFileSync(join(playground.root, '.void/dev-trigger-token'), 'utf8').trim(); + const response = await fetch(`${dev.base}/__void/scheduled`, { + method: 'POST', + headers: { 'x-void-dev-trigger': token }, + body: JSON.stringify({ cron: '*/5 * * * *', scheduledTime: Date.now() }), + }); + const result = { status: response.status, body: await response.text() }; + expect(result, dev.output().slice(-4000)).toEqual({ status: 200, body: '{"ok":true}' }); + }); + + it('runs every query of a request on one connection in the built Worker', async () => { + // The dev server writes Void's dev-only middleware into src/ until it stops. + await killChild(dev.child); + child = undefined; + execFileSync( + process.execPath, + [join(playground.root, 'node_modules/astro/bin/astro.mjs'), 'build'], + { + cwd: playground.root, + stdio: 'pipe', + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }, + ); + // A plain build moves Wrangler's deploy config aside; `astro preview` puts it back. + expect(existsSync(join(playground.root, '.wrangler/deploy/config.json'))).toBe(false); + const server = await start('preview'); + try { + for (const _request of [1, 2]) { + await expectOneConnectionPerRequest(server); + } + } finally { + await killChild(server.child); + child = undefined; + } + }); +}); diff --git a/packages/void/test/integration/mysql-test.test.ts b/packages/void/test/integration/mysql-test.test.ts new file mode 100644 index 000000000..56639b2e8 --- /dev/null +++ b/packages/void/test/integration/mysql-test.test.ts @@ -0,0 +1,88 @@ +import { execSync, type ChildProcess } from 'node:child_process'; +import { rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + killChild, + spawnPlaygroundServer, + startServer, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = join(ROOT, 'playground/mysql-test'); +const ENV_PATH = join(PLAYGROUND, '.env'); +const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, +]; + +// workerd cannot evaluate mysql2's CommonJS source or the eval its row parsers use by default. +describe('playground/mysql-test', () => { + let mysqlServer: FakeMySqlServer; + let child: ChildProcess | undefined; + + beforeAll(async () => { + mysqlServer = await startFakeMySqlServer(USERS); + writeFileSync(ENV_PATH, `DATABASE_URL=mysql://void@127.0.0.1:${mysqlServer.port}/app\n`); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + await mysqlServer?.close(); + try { + unlinkSync(ENV_PATH); + } catch { + // Ignore missing cleanup files between test runs. + } + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer( + (port) => (child = spawnPlaygroundServer(ROOT, 'mysql-test', script, port)), + 30_000, + ); + } + + async function stop(): Promise { + if (child) { + await killChild(child); + child = undefined; + } + } + + async function expectUsers({ base, output }: StartedServer): Promise { + for (const path of ['/api/users', '/api/users/create-db']) { + const response = await fetch(base + path); + const result = { path, status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ + path, + status: 200, + body: JSON.stringify(USERS), + }); + } + } + + it('queries MySQL through void/db and createDb in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); + + it('queries MySQL through void/db and createDb in the built Worker', async () => { + rmSync(join(PLAYGROUND, 'dist'), { recursive: true, force: true }); + execSync('vp run --filter ./playground/mysql-test build', { cwd: ROOT, stdio: 'pipe' }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); +}); diff --git a/packages/void/test/integration/nitro-worker.test.ts b/packages/void/test/integration/nitro-worker.test.ts new file mode 100644 index 000000000..86c515533 --- /dev/null +++ b/packages/void/test/integration/nitro-worker.test.ts @@ -0,0 +1,454 @@ +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import voidNitroModule from '../../src/nitro.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } +}); + +// Half of the integration project's test timeout, which Windows triples. A step that stops +// making progress fails with its name before the case times out. +const STEP_TIMEOUT_MS = (process.platform === 'win32' ? 3 : 1) * 60_000; + +/** + * Run `step`, or fail with its `name` when it has not finished within STEP_TIMEOUT_MS. The error + * ends with `report()`, which says what the step was doing. + */ +async function withinStepTimeout( + name: string, + step: () => Promise, + report?: () => string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + step(), + new Promise((_, reject) => { + timer = setTimeout(() => { + const details = report ? `\n${report()}` : ''; + reject(new Error(`${name} did not finish in ${STEP_TIMEOUT_MS} ms.${details}`)); + }, STEP_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +type RollupHook = (this: unknown, ...args: Array) => unknown; +type RollupPlugin = { name: string } & Record; + +const TRACED_HOOKS = ['resolveId', 'load', 'transform', 'renderChunk'] as const; +// A plugin call is recorded when it fails or takes at least this long. +const SLOW_HOOK_MS = 1000; +const TRACE_TAIL = 40; +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..'); + +/** + * What a Nitro build is doing, for the error when it does not finish: the Rollup plugin calls + * (resolveId, load, transform, renderChunk) still running, the build steps, the last parsed + * modules with any plugin call that failed or was slow, the last results of void's plugins, and + * void's driver build files. Kept in memory: a build that finishes prints none of it. + */ +function createBuildTrace(root: string) { + const startedAt = Date.now(); + const elapsed = () => Date.now() - startedAt; + const steps: Array = []; + const records: Array = []; + const voidResults: Array = []; + const running = new Set<{ start: number; describe: () => string }>(); + let driverDir: string | undefined; + + const show = (path: string) => { + let text = path.replaceAll('\0', '\\0'); + for (const [prefix, name] of [ + [root, ''], + [REPO_ROOT, ''], + ] as const) { + text = text.replaceAll(prefix, name).replaceAll(prefix.replaceAll('\\', '/'), name); + } + text = text.replace(/\.pnpm([\\/])[^\\/]+[\\/]node_modules[\\/]/g, '.pnpm$1…$1'); + return text.length > 240 ? `${text.slice(0, 240)}…` : text; + }; + const record = (list: Array, text: string) => { + list.push(`[${elapsed()}ms] ${text}`); + if (list.length > TRACE_TAIL * 4) { + list.splice(0, list.length - TRACE_TAIL); + } + }; + /** Track a call while it runs. The returned function ends it and returns how long it took. */ + const begin = (describe: () => string) => { + const call = { start: elapsed(), describe }; + running.add(call); + return () => { + running.delete(call); + return elapsed() - call.start; + }; + }; + const describeCall = (hook: string, args: Array) => { + if (hook === 'resolveId') { + const importer = typeof args[1] === 'string' ? show(args[1]) : '(entry)'; + return `${show(String(args[0]))} <- ${importer}`; + } + if (hook === 'renderChunk') { + return String((args[1] as { fileName?: string } | undefined)?.fileName); + } + // load(id) and transform(code, id) + return show(String(hook === 'load' ? args[0] : args[1])); + }; + const describeResult = (hook: string, value: unknown): string | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (hook === 'resolveId') { + if (typeof value === 'object') { + const { id, external } = value as { id: string; external?: unknown }; + return `${show(id)}${external ? ' (external)' : ''}`; + } + return value === false ? '(external)' : show(String(value)); + } + const code = typeof value === 'object' ? (value as { code?: unknown }).code : value; + return typeof code === 'string' ? `${code.length} chars` : 'a result'; + }; + + const tracePlugin = (plugin: RollupPlugin): RollupPlugin => { + const results = plugin.name.startsWith('void:') ? voidResults : undefined; + // A copy: void/nitro's plugins are shared between builds. + const traced: RollupPlugin = { ...plugin }; + for (const hook of TRACED_HOOKS) { + const value = plugin[hook] as RollupHook | { handler: RollupHook } | undefined; + const handler = typeof value === 'function' ? value : value?.handler; + if (typeof handler !== 'function') { + continue; + } + const wrapped: RollupHook = function (...args) { + const describe = () => `${plugin.name} ${hook} ${describeCall(hook, args)}`; + const end = begin(describe); + // Recorded when it failed or was slow, and among void's results when it returned one. + const settle = (outcome: string | undefined, failed = false) => { + const took = end(); + const list = + failed || took >= SLOW_HOOK_MS ? records : outcome === undefined ? undefined : results; + if (list) { + record(list, `${describe()} => ${outcome ?? 'null'} (took ${took}ms)`); + } + }; + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + settle(`threw ${String(error)}`, true); + throw error; + } + if (typeof (result as PromiseLike | undefined)?.then === 'function') { + Promise.resolve(result).then( + (resolved) => settle(describeResult(hook, resolved)), + (error) => settle(`rejected ${String(error)}`, true), + ); + } else { + settle(describeResult(hook, result)); + } + return result; + }; + traced[hook] = typeof value === 'function' ? wrapped : { ...value, handler: wrapped }; + } + return traced; + }; + const stepsPlugin: RollupPlugin = { + name: 'nitro-worker-test:trace', + buildStart: () => record(steps, 'rollup buildStart'), + moduleParsed: ({ id }: { id: string }) => record(records, `parsed ${show(id)}`), + buildEnd: (error?: Error) => + record(steps, `rollup buildEnd${error ? `: ${error.message}` : ''}`), + renderStart: () => record(steps, 'rollup renderStart'), + writeBundle: () => record(steps, 'rollup writeBundle'), + }; + + /** Each file of void's driver builds, with when it was last written. */ + const driverFiles = () => { + if (driverDir === undefined) { + return []; + } + const dir = driverDir; + try { + const files = readdirSync(dir, { recursive: true, encoding: 'utf8' }).map( + (file) => ` [${Math.round(statSync(join(dir, file)).mtimeMs) - startedAt}ms] ${file}`, + ); + return [`Files in ${show(dir)}, by last write:`, ...files]; + } catch { + return [`No ${show(dir)} folder.`]; + } + }; + const resources = () => { + const counts = new Map(); + for (const type of process.getActiveResourcesInfo()) { + counts.set(type, (counts.get(type) ?? 0) + 1); + } + return [...counts].map(([type, count]) => `${type} x${count}`).join(', '); + }; + + return { + /** Run one step of the build, and record when it ended and how long it took. */ + async step(name: string, run: () => Promise): Promise { + const end = begin(() => name); + try { + const result = await run(); + record(steps, `${name} done (took ${end()}ms)`); + return result; + } catch (error) { + record(steps, `${name} failed (took ${end()}ms): ${String(error)}`); + throw error; + } + }, + /** Trace the Rollup plugins that `nitro` builds with, and void's driver builds in it. */ + attach(nitro: NitroInstance) { + driverDir = join(nitro.options.buildDir, 'void'); + nitro.hooks.hook('rollup:before', (_, config) => { + const plugins = [config.plugins].flat(Infinity as 1) as Array; + config.plugins = [ + stepsPlugin, + ...plugins.map((plugin) => + plugin && typeof plugin === 'object' && 'name' in plugin + ? tracePlugin(plugin as RollupPlugin) + : plugin, + ), + ]; + }); + }, + report: () => { + const open = [...running].sort((a, b) => a.start - b.start); + const more = open.length - TRACE_TAIL; + const list = (title: string, lines: Array) => [ + title, + ...lines.slice(-TRACE_TAIL).map((line) => ` ${line}`), + ]; + return [ + `Still running at ${elapsed()}ms (by start time):`, + ...open.slice(0, TRACE_TAIL).map((call) => ` [${call.start}ms] ${call.describe()}`), + ...(more > 0 ? [` and ${more} more`] : []), + ...list('Build steps:', steps), + ...list('Last parsed modules, and plugin calls that failed or were slow:', records), + ...list("Last results of void's plugins:", voidResults), + ...driverFiles(), + `Active resources: ${resources()}`, + ].join('\n'); + }, + }; +} + +type NitroInstance = { + options: { buildDir: string }; + hooks: { + hook(name: 'rollup:before', run: (nitro: unknown, config: { plugins?: unknown }) => void): void; + }; + close(): Promise; +}; + +type NitroBuildApi = { + createNitro(config: Record): Promise; + prepare(nitro: NitroInstance): Promise; + build(nitro: NitroInstance): Promise; +}; + +/** Nitro's build API, from the Nitro that the Nuxt playground's Nuxt installs. */ +async function importNitro(): Promise { + let require = createRequire( + join(import.meta.dirname, '../../../../playground/nuxt/package.json'), + ); + for (const name of ['nuxt', '@nuxt/nitro-server', 'nitropack']) { + require = createRequire(require.resolve(`${name}/package.json`)); + } + const { exports } = require('./package.json') as { exports: { '.': { import: string } } }; + return import(pathToFileURL(require.resolve(exports['.'].import)).href) as Promise; +} + +/** + * Build a Nitro Worker, as Nuxt and Analog do, whose `/users` route runs eight parallel void/db + * queries and whose `/hash` route hashes with node:crypto. Returns its server output folder. + */ +async function buildNitroWorker( + database: 'pg' | 'mysql', + nitroConfig: Record = {}, +): Promise { + // Windows runners report a short-name temp path, which the bundler refuses to serve. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-nitro-worker-'))); + roots.push(root); + const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + }; + write('void.json', JSON.stringify({ database })); + const [table, id] = database === 'pg' ? ['pgTable', 'serial'] : ['mysqlTable', 'int']; + write( + 'db/schema.ts', + `import { ${table}, ${id}, text } from 'void/schema-${database}'; +export const users = ${table}('users', { id: ${id}('id').primaryKey(), name: text('name').notNull() }); +`, + ); + write( + 'routes/users.ts', + `import { users } from '@schema'; +import { db } from 'void/db'; + +export default defineEventHandler(async () => { + const results = await Promise.all(Array.from({ length: 8 }, () => db.select().from(users))); + return results.map((rows) => rows.length); +}); +`, + ); + write( + 'routes/hash.ts', + `import { createHash } from 'node:crypto'; + +export default defineEventHandler(() => ({ hash: createHash('sha256').update('void').digest('hex') })); +`, + ); + // The schema imports Void's SQL helpers by package name, as an app does. + mkdirSync(join(root, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(root, 'node_modules', 'void'), + 'junction', + ); + + const trace = createBuildTrace(root); + await withinStepTimeout( + 'The Nitro build', + async () => { + const { createNitro, prepare, build } = await trace.step('import Nitro', importNitro); + const nitro = await trace.step('createNitro', () => + createNitro({ + rootDir: root, + dev: false, + preset: 'cloudflare-module', + compatibilityDate: '2026-02-24', + modules: [voidNitroModule], + logLevel: 0, + minify: false, + sourceMap: false, + ...nitroConfig, + }), + ); + trace.attach(nitro); + try { + await trace.step('prepare', () => prepare(nitro)); + await trace.step('build', () => build(nitro)); + } finally { + await trace.step('close', () => nitro.close()); + } + }, + trace.report, + ); + return join(root, '.output', 'server'); +} + +/** Request `paths` at once from the Worker in workerd, with the flags a deploy gives it. */ +async function fetchNitroWorker( + serverDir: string, + connectionString: string, + paths: Array, +): Promise> { + const modules = readdirSync(serverDir, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.mjs')) + .sort((a, b) => Number(b === 'index.mjs') - Number(a === 'index.mjs')) + .map((file) => ({ type: 'ESModule' as const, path: join(serverDir, file) })); + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: serverDir, + modules, + compatibilityDate: '2026-02-24', + compatibilityFlags: ['nodejs_compat'], + bindings: { HYPERDRIVE: { connectionString } }, + }), + ); + try { + await withinStepTimeout('workerd startup', () => miniflare.ready); + return await Promise.all( + paths.map((path) => + withinStepTimeout(`The Worker's response to ${path}`, async () => { + const response = await miniflare.dispatchFetch(`http://localhost${path}`); + return { status: response.status, body: await response.json() }; + }), + ), + ); + } finally { + await miniflare.dispose(); + } +} + +// Each case runs a Nitro build and starts workerd. +describe('a Nitro Worker with void/nitro', () => { + const requests = ['/users', '/users', '/users', '/hash']; + const hash = createHash('sha256').update('void').digest('hex'); + const expected = [ + ...Array.from({ length: 3 }, () => ({ status: 200, body: Array(8).fill(1) })), + { status: 200, body: { hash } }, + ]; + + it('runs PostgreSQL queries in workerd on one connection per request', async () => { + const serverDir = await buildNitroWorker('pg'); + const pglite = new PGlite(); + await withinStepTimeout('PGlite startup', async () => { + // pglite-server does not wait for PGlite: a query that arrives during startup spins + // forever. + await pglite.waitReady; + await pglite.exec( + "CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL); INSERT INTO users (name) VALUES ('alice');", + ); + }); + const server = createPgServer(pglite); + let connections = 0; + server.on('connection', () => connections++); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + const url = `postgresql://postgres@127.0.0.1:${port}/postgres`; + expect(await fetchNitroWorker(serverDir, url, requests)).toEqual(expected); + expect(connections).toBe(3); + } finally { + server.close(); + await pglite.close(); + } + }); + + // Nitro's `commonJS.strictRequires` wraps every CommonJS module, mysql2 included. + it.each([ + ['', {}], + [' when Nitro wraps every CommonJS module', { commonJS: { strictRequires: true } }], + ])('runs MySQL queries in workerd on one connection per request%s', async (_, nitroConfig) => { + const serverDir = await buildNitroWorker('mysql', nitroConfig); + const mysql = await startFakeMySqlServer([{ id: 1, name: 'alice' }]); + try { + const url = `mysql://void@127.0.0.1:${mysql.port}/app`; + expect(await fetchNitroWorker(serverDir, url, requests)).toEqual(expected); + expect(mysql.acceptedConnections()).toBe(3); + } finally { + await mysql.close(); + } + }); +}); diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts index 5a41d8cb5..6c5b55693 100644 --- a/packages/void/test/integration/nuxt.test.ts +++ b/packages/void/test/integration/nuxt.test.ts @@ -105,6 +105,12 @@ describe( expect(await bundle.text()).not.toContain(token); }); + it("serves void/db with the app's schema to server routes", async () => { + const response = await fetch(`${DEV_BASE}/api/smoke`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ firstUser: { name: 'Alice' } }); + }); + it('navigates to smoke route via SPA from home page', async () => { const { chromium } = await import('playwright'); const browser = await chromium.launch(); diff --git a/packages/void/test/integration/pg-api-test.test.ts b/packages/void/test/integration/pg-api-test.test.ts new file mode 100644 index 000000000..3f94d647b --- /dev/null +++ b/packages/void/test/integration/pg-api-test.test.ts @@ -0,0 +1,110 @@ +import { execSync, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { createServer } from 'pglite-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + killChild, + spawnPlaygroundServer, + startServer, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = join(ROOT, 'playground/pg-api-test'); +const ENV_PATH = join(PLAYGROUND, '.env'); +const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, +]; + +// No db/migrations and no auth: no other Void module imports pg, so void/db's own import +// must reach workerd prebundled. +describe('playground/pg-api-test', () => { + let pglite: PGlite; + let pgServer: ReturnType; + let child: ChildProcess | undefined; + + beforeAll(async () => { + pglite = new PGlite(); + await pglite.exec( + 'CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE);', + ); + for (const user of USERS) { + await pglite.query('INSERT INTO users (name, email) VALUES ($1, $2)', [ + user.name, + user.email, + ]); + } + pgServer = createServer(pglite); + pgServer.listen(0); + await once(pgServer, 'listening'); + const { port } = pgServer.address() as AddressInfo; + writeFileSync(ENV_PATH, `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + try { + unlinkSync(ENV_PATH); + } catch { + // Ignore missing cleanup files between test runs. + } + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer( + (port) => (child = spawnPlaygroundServer(ROOT, 'pg-api-test', script, port)), + 30_000, + ); + } + + async function stop(): Promise { + if (child) { + await killChild(child); + child = undefined; + } + } + + async function expectUsers({ base, output }: StartedServer): Promise { + for (const path of ['/api/users', '/api/users/create-db']) { + const response = await fetch(base + path); + const result = { path, status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ + path, + status: 200, + body: JSON.stringify(USERS), + }); + } + } + + it('queries PostgreSQL through void/db and createDb in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); + + it('queries PostgreSQL through void/db and createDb in the built Worker', async () => { + rmSync(join(PLAYGROUND, 'dist'), { recursive: true, force: true }); + execSync('vp run --filter ./playground/pg-api-test build', { cwd: ROOT, stdio: 'pipe' }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); +}); diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index f5402e3b0..7d26f7d1b 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -1,7 +1,16 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/react-router'; @@ -60,3 +69,155 @@ describe('playground/react-router dev', { retry: 2 }, () => { expect(html).toContain('ok'); }); }); + +// React Router owns its request handler; void/db must still reach MySQL from a loader. +describe('playground/react-router with MySQL', () => { + const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, + ]; + let mysqlServer: FakeMySqlServer; + let playground: IsolatedPlayground; + let child: ReturnType | undefined; + + beforeAll(async () => { + mysqlServer = await startFakeMySqlServer(USERS); + playground = createIsolatedPlayground(ROOT, PLAYGROUND); + const root = playground.root; + const configPath = join(root, 'void.config.ts'); + writeFileSync( + configPath, + readFileSync(configPath, 'utf8').replace( + 'defineConfig({', + "defineConfig({\n database: 'mysql',", + ), + ); + rmSync(join(root, 'db/migrations'), { recursive: true, force: true }); + rmSync(join(root, 'crons'), { recursive: true, force: true }); + // The import the jobs guide shows. The package entry is Void's Vite plugin, which workerd + // cannot load. + mkdirSync(join(root, 'crons')); + writeFileSync( + join(root, 'crons/tick.ts'), + `import { defineScheduled } from 'void'; + +export const cron = '*/5 * * * *'; + +export default defineScheduled(async () => {}); +`, + ); + writeFileSync( + join(root, 'db/schema.ts'), + `import { int, mysqlTable, varchar } from 'void/schema-mysql'; + +export const users = mysqlTable('users', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), +}); +`, + ); + writeFileSync( + join(root, 'app/routes/api.users.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export async function loader() { + return Response.json(await db.select().from(users)); +} +`, + ); + writeFileSync( + join(root, 'app/routes/api.user-count.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +// Built when the module loads, run by every request. +const allUsers = db.select().from(users); + +export async function loader() { + return Response.json((await allUsers).length); +} +`, + ); + const routesPath = join(root, 'app/routes.ts'); + writeFileSync( + routesPath, + readFileSync(routesPath, 'utf8').replace( + "route('smoke', 'routes/smoke.tsx'),", + "route('smoke', 'routes/smoke.tsx'),\n route('api/users', 'routes/api.users.ts'),\n route('api/user-count', 'routes/api.user-count.ts'),", + ), + ); + writeFileSync( + join(root, '.env'), + `DATABASE_URL=mysql://void@127.0.0.1:${mysqlServer.port}/app\n`, + ); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + await mysqlServer?.close(); + playground?.cleanup(); + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer((port) => { + child = spawn('vp', [script, '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); + return child; + }, 30_000); + } + + async function expectUsers({ base, output }: StartedServer): Promise { + const response = await fetch(`${base}/api/users`); + const result = { status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ status: 200, body: JSON.stringify(USERS) }); + // Each request runs the module-level query on its own connection. + for (const _request of [1, 2]) { + const count = await fetch(`${base}/api/user-count`); + expect({ status: count.status, body: await count.text() }, output().slice(-4000)).toEqual({ + status: 200, + body: String(USERS.length), + }); + } + } + + it('queries MySQL through void/db and runs a cron in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + const token = readFileSync(join(playground.root, '.void/dev-trigger-token'), 'utf8').trim(); + const response = await fetch(`${server.base}/__void/scheduled`, { + method: 'POST', + headers: { 'x-void-dev-trigger': token }, + body: JSON.stringify({ cron: '*/5 * * * *', scheduledTime: Date.now() }), + }); + const result = { status: response.status, body: await response.text() }; + expect(result, server.output().slice(-4000)).toEqual({ status: 200, body: '{"ok":true}' }); + } finally { + await killChild(server.child); + child = undefined; + } + }); + + it('queries MySQL through void/db in the built Worker', async () => { + execFileSync('vp', ['build'], { + cwd: playground.root, + stdio: 'pipe', + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + shell: process.platform === 'win32', + }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await killChild(server.child); + child = undefined; + } + }); +}); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index fe8bef57a..2033673b5 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -6549,6 +6549,41 @@ describe('runCloudflareDeploy — framework artifact guard', () => { }, ); + // The PostgreSQL and MySQL drivers import workerd's Node modules, as a managed deploy knows. + it.each([ + ['pg', ['nodejs_als', 'nodejs_compat']], + ['mysql', ['nodejs_als', 'nodejs_compat']], + ['d1', ['nodejs_als']], + ])( + 'gives a %s Nuxt Worker the compatibility flags its database needs', + async (database, flags) => { + const preset = FRAMEWORK_PRESETS.nuxt!; + const root = makeProject({ + 'void.json': JSON.stringify(database === 'd1' ? {} : { database }), + 'wrangler.jsonc': JSON.stringify({ + account_id: 'acc123', + name: 'nuxt-app', + compatibility_flags: ['nodejs_als'], + }), + [`${preset.workerDir}/${preset.workerMain}`]: + 'export default { fetch() { return new Response("page"); } };', + [`${preset.assetsDir}/client.js`]: 'console.log("client");', + }); + await prepareCloudflareFrameworkArtifact({ + root, + configPath: join(root, 'wrangler.jsonc'), + accountId: 'acc123', + workerName: 'nuxt-app', + voidConfig: readConfig(root), + framework: { name: 'nuxt', class: 'c' }, + preset, + }); + expect(parseRootWranglerConfig(join(root, 'wrangler.jsonc')).compatibility_flags).toEqual( + flags, + ); + }, + ); + it('does not add ISR to a framework worker when routing.isr is false', async () => { const preset = FRAMEWORK_PRESETS['tanstack-start']!; const root = makeProject({ diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index ffbbb0f8f..5b6d5b411 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -47,12 +47,18 @@ vi.mock('mysql2/promise', () => ({ const tables = /^SELECT table_name(?: AS (\w+))? FROM information_schema\.tables/.exec(sql); if (tables) { const key = tables[1] ?? 'TABLE_NAME'; - return [[{ [key]: '_void_migrations' }, { [key]: 'users' }], []]; + return [ + [{ [key]: '_void_migration_lock' }, { [key]: '_void_migrations' }, { [key]: 'users' }], + [], + ]; } if (sql === 'SELECT `name` FROM `_void_migrations`') { const dropped = mysqlQueries.includes('DROP TABLE IF EXISTS `_void_migrations`'); return [dropped ? [] : [{ name: '0000_init.sql' }], []]; } + if (sql.startsWith('SELECT GET_LOCK')) { + return [[{ acquired: 1 }], []]; + } return [[], []]; }, }), @@ -448,6 +454,16 @@ describe('void db reset', () => { expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `_void_migrations`'); expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `users`'); expect(mysqlQueries).toContain('CREATE TABLE `users` (id INT);'); + // The migration lock covers the drops and the new run, and deploys keep their lease table. + const lock = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT GET_LOCK')); + const firstDrop = mysqlQueries.findIndex((sql) => sql.startsWith('DROP TABLE')); + expect(lock).toBeGreaterThanOrEqual(0); + expect(lock).toBeLessThan(firstDrop); + const leaseCheck = mysqlQueries.findIndex((sql) => sql.includes('TIMESTAMPDIFF')); + expect(leaseCheck).toBeGreaterThan(lock); + expect(leaseCheck).toBeLessThan(firstDrop); + expect(mysqlQueries.at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + expect(mysqlQueries).not.toContain('DROP TABLE IF EXISTS `_void_migration_lock`'); }); }); diff --git a/packages/void/test/unit/db-request-pool.test.ts b/packages/void/test/unit/db-request-pool.test.ts new file mode 100644 index 000000000..0567622ea --- /dev/null +++ b/packages/void/test/unit/db-request-pool.test.ts @@ -0,0 +1,212 @@ +import { once } from 'node:events'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { createServer } from 'pglite-server'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; + +const USERS = [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Linus' }, +]; + +type Dialect = 'postgresql' | 'mysql'; +type Pool = { end(callback?: () => void): unknown }; +type Query = PromiseLike; +type Db = { + select(): { from(table: unknown): Query & { prepare(name?: string): { execute(): Query } } }; + query: { users: { findMany(): Query } }; + transaction(callback: (tx: Db) => Promise): Promise; +}; + +const roots: Array = []; +const stateKeys: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + for (const key of stateKeys.splice(0)) { + delete (globalThis as Record)[key]; + } +}); + +/** + * Load void/db with drizzle's own driver. Each request ends the pools built during it, as + * workerd does when a request finishes: nothing may reuse a request's connection afterwards. + */ +async function loadDb(dialect: Dialect, url: string) { + const root = mkdtempSync(join(tmpdir(), 'void-db-request-')); + roots.push(root); + const core = dialect === 'postgresql' ? 'pg-core' : 'mysql-core'; + const coreUrl = import.meta.resolve(`drizzle-orm/${core}`); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db', 'schema.ts'), + dialect === 'postgresql' + ? `import { integer, pgTable, text } from ${JSON.stringify(coreUrl)}; +export const users = pgTable('users', { id: integer('id').primaryKey(), name: text('name').notNull() }); +` + : `import { int, mysqlTable, varchar } from ${JSON.stringify(coreUrl)}; +export const users = mysqlTable('users', { id: int('id').primaryKey(), name: varchar('name', { length: 255 }).notNull() }); +`, + ); + + const plugin = drizzlePlugin(root, false, dialect); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const stateKey = `__void_db_request_pools_${crypto.randomUUID().replaceAll('-', '')}`; + const state = { pools: [] as Array, checkouts: 0 }; + (globalThis as Record)[stateKey] = state; + stateKeys.push(stateKey); + // Both drivers check a connection out of the pool for each query and each transaction. + const checkout = dialect === 'postgresql' ? 'connect' : 'getConnection'; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ drizzle \} from (.*);$/m, + (_match, driver: string) => `import { drizzle as createDrizzle } from ${driver}; +function drizzle(config) { + const database = createDrizzle(config); + const state = globalThis[${JSON.stringify(stateKey)}]; + const pool = database.$client; + if (config.connection) { + state.pools.push(pool); + const checkout = pool.${checkout}; + pool.${checkout} = (...args) => { + state.checkouts++; + return checkout.apply(pool, args); + }; + } + return database; +}`, + ) + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + const module = (await import( + `${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}` + )) as { db: Db }; + const { users } = (await import(pathToFileURL(join(root, 'db', 'schema.ts')).href)) as { + users: unknown; + }; + + async function endPools(first = 0): Promise { + await Promise.all( + state.pools + .slice(first) + .map((pool) => + dialect === 'postgresql' + ? pool.end() + : new Promise((resolve) => pool.end(() => resolve())), + ), + ); + } + + async function request(callback: () => Promise): Promise { + const first = state.pools.length; + try { + return await withRuntimeEnv({ DATABASE_URL: url }, callback); + } finally { + await endPools(first); + } + } + + return { db: module.db, users, state, request, endPools }; +} + +describe.each(['postgresql', 'mysql'] as const)('void/db %s across requests', (dialect) => { + let url: string; + let pglite: PGlite | undefined; + let pgServer: ReturnType | undefined; + let mysqlServer: FakeMySqlServer | undefined; + + beforeAll(async () => { + if (dialect === 'postgresql') { + pglite = new PGlite(); + await pglite.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);'); + await pglite.exec("INSERT INTO users VALUES (1, 'Ada'), (2, 'Linus');"); + pgServer = createServer(pglite); + pgServer.listen(0, '127.0.0.1'); + await once(pgServer, 'listening'); + url = `postgresql://postgres@127.0.0.1:${(pgServer.address() as AddressInfo).port}/postgres`; + } else { + mysqlServer = await startFakeMySqlServer(USERS); + url = `mysql://void@127.0.0.1:${mysqlServer.port}/app`; + } + }); + + afterAll(async () => { + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + await mysqlServer?.close(); + }); + + it('runs a query built or prepared in one request on the pool of a later request', async () => { + const { db, users, state, request } = await loadDb(dialect, url); + + // Module-level queries: built once, run by every request. + const { built, prepared, relational } = await request(async () => { + const built = db.select().from(users); + const prepared = db.select().from(users).prepare('void_users'); + const relational = db.query.users.findMany(); + expect(await built).toEqual(USERS); + return { built, prepared, relational }; + }); + const later = await request(async () => ({ + built: await built, + prepared: await prepared.execute(), + relational: await relational, + })); + + expect(later).toEqual({ built: USERS, prepared: USERS, relational: USERS }); + expect(state.pools).toHaveLength(2); + }); + + it('runs a transaction on one connection of the request pool', async () => { + const { db, users, state, request } = await loadDb(dialect, url); + + const rows = await request(() => db.transaction(async (tx) => tx.select().from(users))); + + expect(rows).toEqual(USERS); + // One checkout holds the connection for the whole transaction; drizzle only does that for + // a pool, so a stand-in client must still look like one. + expect(state.checkouts).toBe(1); + }); + + it('builds one pool for a transaction outside a request scope', async () => { + const { db, users, state, endPools } = await loadDb(dialect, url); + // A framework entry that Void does not wrap runs without its request scope. + const previousEnv = (globalThis as Record).__env__; + (globalThis as Record).__env__ = { DATABASE_URL: url }; + try { + await db.transaction(async (tx) => tx.select().from(users)); + const before = state.pools.length; + + const rows = await db.transaction(async (tx) => tx.select().from(users)); + + expect(rows).toEqual(USERS); + // Each use builds a pool here, so drizzle's pool checks must not build more. + expect(state.pools.length - before).toBe(1); + } finally { + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + await endPools(); + } + }); +}); diff --git a/packages/void/test/unit/env.test.ts b/packages/void/test/unit/env.test.ts index b0aad440c..f4c70851e 100644 --- a/packages/void/test/unit/env.test.ts +++ b/packages/void/test/unit/env.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { getRuntimeBinding, getRuntimeEnv, withRuntimeEnv } from '../../src/runtime/env.ts'; const globalScope = globalThis as { @@ -37,6 +37,31 @@ describe('runtime env resolution', () => { expect(value).toEqual({ tag: 'scoped-db' }); }); + // A package that Vite leaves external loads its own copy of these modules, and a + // dependency optimizer reload evaluates them again while a request is in flight. + it('shares the request env and scope with another copy of the env module', async () => { + vi.resetModules(); + const first = await import('../../src/runtime/env.ts'); + vi.resetModules(); + const second = await import('../../src/runtime/env.ts'); + expect(second.withRuntimeEnv).not.toBe(first.withRuntimeEnv); + + const key = {}; + const seen = await first.withRuntimeEnv({ KV: { tag: 'scoped-kv' } }, async () => { + await Promise.resolve(); + return { + binding: second.getRuntimeBinding('KV'), + resource: second.getRuntimeRequestResource( + key, + () => 'request', + () => 'outside', + ), + }; + }); + + expect(seen).toEqual({ binding: { tag: 'scoped-kv' }, resource: 'request' }); + }); + it('throws when no runtime env is available', () => { expect(() => getRuntimeEnv()).toThrow('env: Cloudflare env is unavailable'); }); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 20760122b..18b035fde 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1868,12 +1868,63 @@ jobs: expect.stringContaining('export default defineNuxtConfig'), 'Manual Nuxt config step', ); + // Without the preset Nuxt builds a Node server; without the dev config, dev has no env; + // without the top-level alias, `nuxi typecheck` cannot find `@schema`. + const nuxtNote = mockNote.mock.calls.find( + ([, title]) => title === 'Manual Nuxt config step', + ); + for (const line of [ + 'import { resolve } from "node:path";', + ' alias: {\n "@schema": resolve(import.meta.dirname, "db/schema.ts"),\n },\n nitro: {', + 'preset: "cloudflare-module",', + 'modules: ["void/nitro"],', + 'cloudflare: { dev: { configPath: "./.void-wrangler.jsonc" } },', + ]) { + expect(nuxtNote?.[0]).toContain(line); + } } finally { rmSync(root, { recursive: true, force: true }); } }, ); + it('prints an Analog-specific manual config step with the Nitro module', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'existing-analog-app', + private: true, + devDependencies: { '@analogjs/platform': '^2.8.0' }, + }, + null, + 2, + ) + '\n', + ); + mockSelect.mockResolvedValueOnce('none'); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(existsSync(join(root, 'vite.config.ts'))).toBe(false); + const analogNote = mockNote.mock.calls.find( + ([, title]) => title === 'Manual Analog config step', + ); + for (const line of [ + 'Install `nitro-cloudflare-dev` as a dev dependency.', + 'preset: "cloudflare-module",', + 'modules: ["nitro-cloudflare-dev", "void/nitro"],', + 'cloudflareDev: { configPath: "./.void-wrangler.jsonc" },', + ]) { + expect(analogNote?.[0]).toContain(line); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('adds voidPlugin to an existing Vite plugins array without replacing scripts', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { diff --git a/packages/void/test/unit/mysql-migration-sql.test.ts b/packages/void/test/unit/mysql-migration-sql.test.ts new file mode 100644 index 000000000..b92d6db3b --- /dev/null +++ b/packages/void/test/unit/mysql-migration-sql.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { splitMySqlMigrationSql } from '../../src/runtime/mysql-migration-sql.ts'; + +describe('splitMySqlMigrationSql', () => { + it('splits a file without breakpoints at its semicolons', () => { + expect( + splitMySqlMigrationSql( + '-- comments\nCREATE TABLE comments (id int primary key);\nALTER TABLE c ADD COLUMN n int;\n', + ), + ).toEqual(['CREATE TABLE comments (id int primary key);', 'ALTER TABLE c ADD COLUMN n int;']); + }); + + it('splits each breakpoint part, and keeps a statement without a semicolon', () => { + expect( + splitMySqlMigrationSql( + 'CREATE TABLE `a` (`id` int);--> statement-breakpoint\nINSERT INTO a VALUES (1); INSERT INTO a VALUES (2)\n--> statement-breakpoint\nCREATE INDEX `a_id` ON `a` (`id`)', + ), + ).toEqual([ + 'CREATE TABLE `a` (`id` int);', + 'INSERT INTO a VALUES (1);', + 'INSERT INTO a VALUES (2)', + 'CREATE INDEX `a_id` ON `a` (`id`)', + ]); + }); + + it('keeps semicolons inside strings, quoted names and comments', () => { + const statements = [ + "INSERT INTO t (s) VALUES ('a;b', 'it''s;', 'it\\'s; \\\\', \"x;y\");", + // A backslash does not escape in a quoted name. + 'CREATE TABLE `dir\\` (id int);', + 'CREATE TABLE `odd;name` (`a``;b` int); # trailing; comment', + 'UPDATE t SET n = 5--3; -- 5 minus -3; then a comment', + 'SELECT 1 /* a; b */ + 1;', + ]; + expect(splitMySqlMigrationSql(statements.join('\n'))).toEqual([ + "INSERT INTO t (s) VALUES ('a;b', 'it''s;', 'it\\'s; \\\\', \"x;y\");", + 'CREATE TABLE `dir\\` (id int);', + 'CREATE TABLE `odd;name` (`a``;b` int);', + 'UPDATE t SET n = 5--3;', + 'SELECT 1 /* a; b */ + 1;', + ]); + }); + + // MySQL runs `/*! ... */`, so it is a statement, not a comment to drop. + it('keeps an executable comment as a statement', () => { + expect(splitMySqlMigrationSql('SELECT 1;\n/*!40101 SET @x = 1 */;\n-- end')).toEqual([ + 'SELECT 1;', + '/*!40101 SET @x = 1 */;', + ]); + }); + + it('keeps a trigger, routine or event together with the rest of its part', () => { + const trigger = + 'CREATE TRIGGER c_bump BEFORE INSERT ON c FOR EACH ROW BEGIN SET NEW.v = NEW.v + 1; END;'; + expect(splitMySqlMigrationSql(`CREATE TABLE c (v int);\n${trigger}`)).toEqual([ + 'CREATE TABLE c (v int);', + trigger, + ]); + // Without a parser, the end of the body is unknown, so what follows in the part stays with it. + expect( + splitMySqlMigrationSql( + `${trigger}\nINSERT INTO c VALUES (1);\n--> statement-breakpoint\nSELECT 1;`, + ), + ).toEqual([`${trigger}\nINSERT INTO c VALUES (1);`, 'SELECT 1;']); + const procedure = + "create definer = `root`@'%' /* x; */ procedure p() begin select 1; select 2; end"; + expect(splitMySqlMigrationSql(`-- p\n${procedure} -- done`)).toEqual([procedure]); + const event = + 'ALTER EVENT e DO BEGIN DELETE FROM a; DELETE FROM b; END;\nCREATE FUNCTION f() RETURNS int RETURN 1;'; + expect(splitMySqlMigrationSql(event)).toEqual([event]); + }); + + // MariaDB also runs compound statements outside a stored program. + it('keeps a compound statement outside a stored program together with the rest of its part', () => { + for (const statement of [ + 'BEGIN NOT ATOMIC INSERT INTO a VALUES (1); INSERT INTO a VALUES (2); END;', + 'fill: LOOP INSERT INTO a VALUES (1); LEAVE fill; END LOOP fill;', + 'IF (SELECT COUNT(*) FROM a) = 0 THEN INSERT INTO a VALUES (1); END IF;', + ]) { + const part = `${statement}\nSELECT 1;`; + expect(splitMySqlMigrationSql(part)).toEqual([part]); + } + }); + + // A whole part with several statements cannot run where one query takes one statement. + it('splits statements that only use those keywords as names or transactions', () => { + expect( + splitMySqlMigrationSql( + "CREATE TABLE logs (id int, event varchar(20), begin int);\nINSERT INTO `begin` VALUES ('BEGIN');\nCREATE INDEX trigger ON event (id);", + ), + ).toEqual([ + 'CREATE TABLE logs (id int, event varchar(20), begin int);', + "INSERT INTO `begin` VALUES ('BEGIN');", + 'CREATE INDEX trigger ON event (id);', + ]); + expect( + splitMySqlMigrationSql( + 'BEGIN;\nINSERT INTO a VALUES (1);\nCOMMIT;\nDROP PROCEDURE IF EXISTS p;', + ), + ).toEqual(['BEGIN;', 'INSERT INTO a VALUES (1);', 'COMMIT;', 'DROP PROCEDURE IF EXISTS p;']); + expect(splitMySqlMigrationSql('BEGIN WORK;\nINSERT INTO a VALUES (1);\nCOMMIT WORK;')).toEqual([ + 'BEGIN WORK;', + 'INSERT INTO a VALUES (1);', + 'COMMIT WORK;', + ]); + }); + + it('sends a part with only comments as it is', () => { + expect(splitMySqlMigrationSql('-- Custom SQL migration file, put your code below! --')).toEqual( + ['-- Custom SQL migration file, put your code below! --'], + ); + }); +}); diff --git a/packages/void/test/unit/nitro.test.ts b/packages/void/test/unit/nitro.test.ts new file mode 100644 index 000000000..a1b1f4899 --- /dev/null +++ b/packages/void/test/unit/nitro.test.ts @@ -0,0 +1,349 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { toSlash } from 'pathslash'; +import type { Plugin } from 'vite'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import voidNitroModule from '../../src/nitro.ts'; +import { db } from '../../src/runtime/db.ts'; +import { getRuntimeRequestResource, withRuntimeEnv } from '../../src/runtime/env.ts'; + +const roots: Array = []; +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// The generated MySQL void/db imports mysql2 through Void's drizzle-orm. +const drizzleMysqlFile = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + delete (globalThis as { __env__?: unknown }).__env__; +}); + +function createApp(database: 'pg' | 'mysql' | 'd1', config: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), 'void-nitro-')); + roots.push(root); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ ...config, ...(database !== 'd1' && { database }) }), + ); + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db', 'schema.ts'), 'export const users = {};\n'); + return root; +} + +type NitroOptions = { + dev: boolean; + rootDir: string; + buildDir: string; + exportConditions: Array; + plugins: Array; + virtual: Record string | Promise)>; + rollupConfig: { plugins?: unknown }; + cloudflare?: { nodeCompat?: boolean }; +}; + +/** Run the module against the options of a Nitro Worker build. */ +function setUp( + root: string, + options: { dev: boolean; plugins?: unknown; cloudflare?: NitroOptions['cloudflare'] }, +): NitroOptions { + const nitro = { + options: { + dev: options.dev, + rootDir: root, + buildDir: join(root, '.nitro'), + exportConditions: ['workerd'], + plugins: [], + virtual: {}, + rollupConfig: { plugins: options.plugins }, + ...(options.cloudflare && { cloudflare: options.cloudflare }), + } as NitroOptions, + }; + voidNitroModule.setup(nitro); + return nitro.options; +} + +type ResolveResult = string | { id: string; external?: boolean } | null | undefined; + +// Nitro's Rollup calls the hooks as plain functions, with its plugin context as `this`. Its +// resolver finds a package the way Node does. +const context = { + async resolve(source: string, importer: string): Promise<{ id: string } | null> { + if (isAbsolute(source)) { + return { id: source }; + } + try { + return { id: createRequire(importer).resolve(source) }; + } catch { + return null; + } + }, +}; + +function resolve(plugin: Plugin, id: string, importer: string): Promise { + return (plugin.resolveId as (id: string, importer: string) => Promise).call( + context, + id, + importer, + ); +} + +describe('void/nitro', () => { + it("serves the app's void/db and @schema before Nitro's own plugins", async () => { + const root = createApp('pg'); + const existing = { name: 'existing' }; + const options = setUp(root, { dev: true, plugins: existing }); + const [plugin, ...rest] = options.rollupConfig.plugins as Array; + expect(rest).toEqual([existing]); + + const route = join(root, 'server', 'api', 'users.ts'); + const schema = toSlash(join(root, 'db', 'schema.ts')); + const id = await resolve(plugin!, 'void/db', route); + const code = (plugin!.load as (id: string) => string | null)(id as string); + expect(code).toMatch(/drizzle-orm[\\/]+node-postgres/); + expect(code).toContain(JSON.stringify(schema)); + expect(await resolve(plugin!, '@schema', route)).toEqual({ id: schema }); + // Development runs in Node, which loads the driver itself. + expect(await resolve(plugin!, 'pg', route)).toBeNull(); + }); + + it('serves a workerd build of the driver to a Worker build', async () => { + const root = createApp('mysql'); + const [plugin, asyncHooks] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + const route = join(root, 'server', 'api', 'users.ts'); + + const driver = await resolve(plugin!, 'mysql2', drizzleMysqlFile); + expect(typeof driver).toBe('string'); + expect(existsSync(driver as string)).toBe(true); + expect(readFileSync(driver as string, 'utf8')).toContain('export {'); + // mysql2's promise API comes from the same build. + const promiseApi = await resolve(plugin!, 'mysql2/promise', drizzleMysqlFile); + expect(dirname(promiseApi as string)).toBe(dirname(driver as string)); + // The driver and Void's request scope keep workerd's Node modules instead of Nitro's stubs. + expect(await resolve(plugin!, 'node:net', driver as string)).toEqual({ + id: 'node:net', + external: true, + }); + expect(await resolve(asyncHooks!, 'node:async_hooks', envModuleFile)).toEqual({ + id: 'node:async_hooks', + external: true, + }); + expect(await resolve(asyncHooks!, 'node:async_hooks', route)).toBeNull(); + }); + + it("gives the app's own driver import the app's copy, built for workerd", async () => { + const root = createApp('mysql'); + // An app that installs its own mysql2, which need not be the version Void's drizzle loads. + mkdirSync(join(root, 'node_modules', 'mysql2'), { recursive: true }); + writeFileSync( + join(root, 'node_modules', 'mysql2', 'package.json'), + JSON.stringify({ name: 'mysql2', version: '3.13.0', main: 'index.js' }), + ); + writeFileSync( + join(root, 'node_modules', 'mysql2', 'index.js'), + "exports.createPool = () => ({});\nexports.appCopy = require('node:util').format('app');\n", + ); + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + + const appDriver = (await resolve( + plugin!, + 'mysql2', + join(root, 'server', 'api', 'raw.ts'), + )) as string; + const voidDriver = (await resolve(plugin!, 'mysql2', drizzleMysqlFile)) as string; + expect(appDriver).not.toBe(voidDriver); + // Each is named by its native path, as Nitro's Rollup names modules: on Windows, Nitro's + // lookup of the `global` polyfill that pg imports never ends from a forward-slash name. + for (const driver of [appDriver, voidDriver]) { + expect(driver).toBe(fileURLToPath(pathToFileURL(driver))); + } + // Each copy exports its own names. + expect(readFileSync(appDriver, 'utf8')).toMatch(/export \{[^}]*\bappCopy\b/); + expect(readFileSync(voidDriver, 'utf8')).not.toContain('appCopy'); + for (const driver of [appDriver, voidDriver]) { + expect(await resolve(plugin!, 'node:util', driver)).toEqual({ + id: 'node:util', + external: true, + }); + } + // An import the bundler cannot resolve stays its error. + expect(await resolve(plugin!, 'mysql2', join(tmpdir(), 'elsewhere.ts'))).toBeNull(); + }); + + it("serves mysql2/promise from the build of the copy's driver", async () => { + const root = createApp('mysql'); + // Both entries of mysql2 load the same driver modules. + const copy = join(root, 'node_modules', 'mysql2'); + mkdirSync(join(copy, 'lib'), { recursive: true }); + writeFileSync( + join(copy, 'package.json'), + JSON.stringify({ + name: 'mysql2', + version: '3.13.0', + main: 'index.js', + exports: { + '.': './index.js', + './package.json': './package.json', + './promise': './promise.js', + }, + }), + ); + writeFileSync(join(copy, 'lib', 'state.js'), 'module.exports = { parsers: new Map() };\n'); + writeFileSync(join(copy, 'index.js'), "exports.state = require('./lib/state.js');\n"); + writeFileSync( + join(copy, 'promise.js'), + "exports.state = require('./lib/state.js');\nexports.createPool = () => ({});\n", + ); + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + const route = join(root, 'server', 'api', 'raw.ts'); + + const promiseApi = await resolve(plugin!, 'mysql2/promise', route); + expect(typeof promiseApi).toBe('string'); + const driver = (await resolve(plugin!, 'mysql2', route)) as string; + const [promiseModule, driverModule] = (await Promise.all( + [promiseApi as string, driver].map((file) => import(pathToFileURL(file).href)), + )) as Array<{ default: Record; state: object; createPool?: unknown }>; + expect(typeof promiseModule!.createPool).toBe('function'); + expect(promiseModule!.default.createPool).toBe(promiseModule!.createPool); + expect(promiseModule!.state).toBe(driverModule!.state); + }); + + it.each([ + // pg 8.10 predates pg-cloudflare, the socket pg uses in Workers. + { database: 'pg' as const, name: 'pg', id: 'pg', version: '8.10.0', minimum: '8.11' }, + // mysql2 3.12 has no disableEval option, so it compiles its parsers with new Function. + { + database: 'mysql' as const, + name: 'mysql2', + id: 'mysql2', + version: '3.12.0', + minimum: '3.13', + }, + { + database: 'mysql' as const, + name: 'mysql2', + id: 'mysql2/promise', + version: '3.12.0', + minimum: '3.13', + }, + ])( + 'names the import of $id from a $name too old for Workers', + async ({ database, name, id, version, minimum }) => { + const root = createApp(database); + mkdirSync(join(root, 'node_modules', name), { recursive: true }); + writeFileSync( + join(root, 'node_modules', name, 'package.json'), + JSON.stringify({ name, version, main: 'index.js' }), + ); + writeFileSync(join(root, 'node_modules', name, 'index.js'), 'exports.Client = class {};\n'); + if (id !== name) { + writeFileSync( + join(root, 'node_modules', name, 'promise.js'), + 'exports.Client = class {};\n', + ); + } + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + + await expect(resolve(plugin!, id, join(root, 'server', 'api', 'raw.ts'))).rejects.toThrow( + `${name} ${version} cannot run in Cloudflare Workers, which need ${name} ${minimum} or later. ` + + `Upgrade ${name}. Imported by server/api/raw.ts.`, + ); + }, + ); + + // Nitro keeps Node stubs unless it finds nodejs_compat in a Wrangler config, which it reads + // from wrangler.* files only. + it("turns on Nitro's Node compatibility for Workers that deploy with nodejs_compat", () => { + const nodeCompat = ( + database: 'pg' | 'mysql' | 'd1', + options: { dev?: boolean; config?: Record; nodeCompat?: boolean } = {}, + ) => + setUp(createApp(database, options.config), { + dev: options.dev ?? false, + ...(options.nodeCompat !== undefined && { + cloudflare: { nodeCompat: options.nodeCompat }, + }), + }).cloudflare?.nodeCompat; + + expect(nodeCompat('pg')).toBe(true); + expect(nodeCompat('mysql')).toBe(true); + expect(nodeCompat('d1')).toBeUndefined(); + const flags = (worker: Array | undefined, cloudflare: Array | undefined) => ({ + config: { + ...(worker && { worker: { compatibility_flags: worker } }), + ...(cloudflare && { cloudflare: { compatibility_flags: cloudflare } }), + }, + }); + expect(nodeCompat('d1', flags(undefined, ['nodejs_compat', 'nodejs_als']))).toBe(true); + expect(nodeCompat('d1', flags(undefined, ['nodejs_compat_v2']))).toBe(true); + // A native deploy gives the Worker only the Cloudflare config's flags, and a managed deploy + // prefers `worker.compatibility_flags`. A Worker built for native modules needs the flag. + expect(nodeCompat('d1', flags(['nodejs_compat'], undefined))).toBeUndefined(); + expect(nodeCompat('d1', flags(['nodejs_als'], ['nodejs_compat']))).toBeUndefined(); + expect(nodeCompat('pg', { nodeCompat: false })).toBe(false); + expect(nodeCompat('pg', { dev: true })).toBeUndefined(); + }); + + it('runs each PostgreSQL or MySQL request in one Void request scope', async () => { + const options = setUp(createApp('pg'), { dev: true }); + expect(options.plugins).toHaveLength(1); + const source = options.virtual[options.plugins[0]!] as string; + const pluginFile = join(roots[0]!, 'request-scope.mjs'); + writeFileSync( + pluginFile, + source.replace( + JSON.stringify(envModuleFile), + JSON.stringify(pathToFileURL(envModuleFile).href), + ), + ); + const { default: requestScope } = (await import(pathToFileURL(pluginFile).href)) as { + default: (nitroApp: { h3App: { handler: (event: string) => Promise } }) => void; + }; + + const key = {}; + const nitroApp = { + h3App: { + // A route that reads its request's resource before and after an await. + async handler(event: string) { + const first = getRuntimeRequestResource(key, () => ({ event })); + await new Promise((resolve) => setTimeout(resolve, 5)); + return [first, getRuntimeRequestResource(key, () => ({ event }))]; + }, + }, + }; + requestScope(nitroApp); + // A dev plugin can set a promise of the env before the env itself. + (globalThis as { __env__?: unknown }).__env__ = Promise.resolve({ DATABASE_URL: 'x' }); + + const [a, b] = (await Promise.all([ + nitroApp.h3App.handler('a'), + nitroApp.h3App.handler('b'), + ])) as Array<[object, object]>; + expect(a[1]).toBe(a[0]); + expect(b[1]).toBe(b[0]); + expect(a[0]).toEqual({ event: 'a' }); + expect(b[0]).toEqual({ event: 'b' }); + }); + + it('keeps D1 requests and Node modules as Nitro has them', () => { + const options = setUp(createApp('d1'), { dev: false }); + expect(options.plugins).toEqual([]); + expect(options.virtual).toEqual({}); + expect(options.rollupConfig.plugins).toHaveLength(1); + }); + + it('tells an app whose bundle skipped the module to add it', () => { + expect(() => + withRuntimeEnv({ HYPERDRIVE: { connectionString: 'postgresql://db/app' } }, () => db.select), + ).toThrow("add 'void/nitro' to the Nitro modules"); + // Vite serves the app's void/db only to the installed packages it inlines. + expect(() => + withRuntimeEnv({ HYPERDRIVE: { connectionString: 'postgresql://db/app' } }, () => db.select), + ).toThrow( + 'An installed package that imports void/db must be an ES module that lists void in its dependencies or peerDependencies', + ); + }); +}); diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 17859bbe3..1a71c9a45 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -1,10 +1,20 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Plugin, ResolvedConfig } from 'vite'; +import { createServer, normalizePath, type Plugin, type ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { frameworkTriggersDispatchPath } from '../../src/plugin-framework-triggers.ts'; vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); @@ -29,6 +39,16 @@ function createTanStackRoot(voidConfig?: Record): string { return root; } +/** The generated wrappers import Void's runtime env module by package name, as an app does. */ +function linkVoidPackage(root: string): void { + mkdirSync(join(root, 'node_modules'), { recursive: true }); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(root, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); +} + function callConfigResolved(plugin: Plugin, config: Partial) { const hook = plugin.configResolved; const fn = typeof hook === 'function' ? hook : hook?.handler; @@ -120,6 +140,209 @@ describe('Class A framework dev wrapper', () => { }); }); +describe('Class A bare void imports', () => { + // The package entry is the whole Vite plugin; a cron needs only `defineScheduled` and friends. + it.each([ + ['ssr', true], + ['client', false], + ] as const)( + 'sends bare void in the %s environment to the handler entry: %s', + async (environment, redirected) => { + const root = realpathSync.native(createTanStackRoot()); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { '.': './index.mjs' } }), + ); + writeFileSync(join(installed, 'index.mjs'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const resolved = await server.environments[environment]!.pluginContainer.resolveId( + 'void', + join(root, 'crons/tick.ts'), + ); + + expect(resolved?.id).toBe( + normalizePath( + redirected + ? createRequire(import.meta.url).resolve('void/handler') + : join(installed, 'index.mjs'), + ), + ); + } finally { + await server.close(); + } + }, + ); +}); + +describe('Class A framework request scope', () => { + /** The dev wrapper, written when the plugin is created, and the build wrapper. */ + async function generateWrappers(root: string): Promise<{ dev: string; build: string }> { + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const wrapperPlugin = voidPlugin().find( + (plugin) => plugin.name === 'void:framework-build-wrapper', + ); + if (!wrapperPlugin) { + throw new Error('Expected Class A wrapper plugin.'); + } + const wrapperPath = join(root, '.void', 'tanstack-start-trigger-wrapper.ts'); + const dev = readFileSync(wrapperPath, 'utf-8'); + callConfigResolved(wrapperPlugin, { + command: 'build', + build: { assetsDir: 'assets' } as ResolvedConfig['build'], + } as Partial); + const build = readFileSync(wrapperPath, 'utf-8'); + // The asset fallback helper that ends the build wrapper is covered above. + return { dev, build: build.slice(0, build.indexOf('import { createAssetFallback')) }; + } + + // A request scope would move D1 reads from the primary to a replica-capable session. + it.each([ + ['without a database', undefined], + ['with D1', 'export {};\n'], + ])('keeps the framework fetch unscoped for an app %s', async (_name, schema) => { + const root = createTanStackRoot(); + if (schema) { + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db/schema.ts'), schema); + } + + const { dev, build } = await generateWrappers(root); + + expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; + +export default { + ...original, + async fetch(request, env, ctx) { + if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx); + }, +}; +`); + expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; + +export default { + ...original, + async fetch(request, env, ctx) { + return serveWithAssets(request, env, await original.fetch(request, env, ctx)); + }, +}; +`); + }); + + it.each(['pg', 'mysql'])( + 'runs the framework fetch in the request scope with %s', + async (database) => { + const root = createTanStackRoot({ database }); + + const { dev, build } = await generateWrappers(root); + + expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; +import { withRuntimeEnv } from "void/_env"; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { + if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx); + }); + }, +}; +`); + expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; +import { withRuntimeEnv } from "void/_env"; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { + return serveWithAssets(request, env, await original.fetch(request, env, ctx)); + }); + }, +}; +`); + }, + ); + + it.each(['pg', 'mysql'])( + 'runs each dev request inside its own Void request scope with %s', + async (database) => { + const root = createTanStackRoot({ database }); + const packageRoot = join(root, 'node_modules/@tanstack/react-start'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ type: 'module', exports: { './server-entry': './server-entry.js' } }), + ); + // void/db keeps its PostgreSQL/MySQL client in this request resource. + writeFileSync( + join(packageRoot, 'server-entry.js'), + 'import { getRuntimeRequestResource } from "void/_env";\n' + + 'const key = {};\n' + + 'let created = 0;\n' + + 'export default { async fetch() {\n' + + ' const first = getRuntimeRequestResource(key, () => ++created);\n' + + ' await new Promise((resolve) => setTimeout(resolve, 0));\n' + + ' const second = getRuntimeRequestResource(key, () => ++created);\n' + + ' return Response.json([first, second]);\n' + + '} };\n', + ); + linkVoidPackage(root); + + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + voidPlugin(); + + const runner = join(root, 'run.mjs'); + writeFileSync( + runner, + `import worker from ${JSON.stringify(pathToFileURL(join(root, '.void/tanstack-start-trigger-wrapper.ts')).href)};\n` + + 'const env = {};\n' + + 'const responses = await Promise.all([1, 2].map(() => worker.fetch(new Request("https://example.test/"), env, {})));\n' + + 'process.stdout.write(JSON.stringify(await Promise.all(responses.map((response) => response.json()))));\n', + ); + expect( + JSON.parse(execFileSync(process.execPath, [runner], { cwd: root, encoding: 'utf8' })), + ).toEqual([ + [1, 1], + [2, 2], + ]); + }, + ); +}); + describe('Class A routing.notFound self-host warning', () => { /** Run a Class A `build` twice and return every line the plugin logged. */ async function buildTwiceWithNotFound(): Promise> { diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 6b7859fc7..76ee5516f 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Plugin } from 'vite'; @@ -81,6 +81,15 @@ function createFrameworkOwnedRuntimeProjectRoot(): string { return root; } +function createAstroProjectRoot(): string { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '^7.0.0' } }), + ); + return root; +} + function createNodeProjectRoot(): string { const root = createProjectRoot(); writeFileSync( @@ -90,6 +99,13 @@ function createNodeProjectRoot(): string { return root; } +function useDatabase(root: string, database: 'pg' | 'mysql'): string { + const configPath = join(root, 'void.json'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + writeFileSync(configPath, JSON.stringify({ ...config, database })); + return root; +} + function createDevOnlyApiProjectRoot(): string { const root = createProjectRoot(); mkdirSync(join(root, 'routes/api'), { recursive: true }); @@ -209,17 +225,23 @@ describe('voidPlugin dev asset routing', () => { config({}, { command: 'build', mode: 'production' }); } + // The Cloudflare plugin gives each environment it runs in workerd these conditions. + const workerd = { resolve: { conditions: ['workerd', 'worker', 'module', 'browser'] } }; + const node = { resolve: { conditions: ['module', 'node'] } }; + async function resolveRuntimeOptimizerConfig( root: string, environmentName: string, + environment: import('vite').EnvironmentOptions = {}, ): Promise { vi.spyOn(process, 'cwd').mockReturnValue(root); const { voidPlugin } = await import('../../src/index.ts'); const plugin = voidPlugin().find((entry) => entry.name === 'void:server-runtime-optimizer'); const configEnvironment = getHookHandler(plugin?.configEnvironment) as ( name: string, + environment: import('vite').EnvironmentOptions, ) => unknown; - return configEnvironment(environmentName); + return configEnvironment(environmentName, environment); } it('runs the env config hook for framework and Node early-return plugin paths', async () => { @@ -228,25 +250,50 @@ describe('voidPlugin dev asset routing', () => { await expect(runEnvConfig(createNodeProjectRoot())).resolves.toBeUndefined(); }); - it('keeps one Void runtime module graph in core, framework, and Node servers', async () => { + it('converts void CommonJS dependencies only in environments that run in workerd', async () => { const workerdOptimizer = { optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, }; - await expect( - resolveRuntimeOptimizerConfig(createApiOnlyProjectRoot(), 'void_worker'), - ).resolves.toEqual(workerdOptimizer); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(createFrameworkProjectRoot(), 'ssr'), - ).resolves.toEqual(workerdOptimizer); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(createFrameworkOwnedRuntimeProjectRoot(), 'ssr'), - ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); - vi.restoreAllMocks(); - await expect(resolveRuntimeOptimizerConfig(createNodeProjectRoot(), 'ssr')).resolves.toEqual({ - optimizeDeps: { exclude: ['void'] }, - }); + const nodeOptimizer = { optimizeDeps: { exclude: ['void'] } }; + const cases = [ + [createApiOnlyProjectRoot, 'void_worker', workerd, workerdOptimizer], + // Default mode never runs Vite's own ssr environment. + [createApiOnlyProjectRoot, 'ssr', node, nodeOptimizer], + [createFrameworkProjectRoot, 'ssr', workerd, workerdOptimizer], + // @astrojs/cloudflare runs Astro's ssr environment in workerd. + [createAstroProjectRoot, 'ssr', workerd, workerdOptimizer], + [createFrameworkOwnedRuntimeProjectRoot, 'ssr', node, nodeOptimizer], + [createNodeProjectRoot, 'ssr', {}, nodeOptimizer], + ] as const; + for (const [createRoot, name, environment, expected] of cases) { + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(createRoot(), name, environment), + `${createRoot.name} ${name}`, + ).resolves.toEqual(expected); + } + }); + + it('prebundles the void/db driver wherever workerd runs it', async () => { + const cases = [ + [createApiOnlyProjectRoot, 'mysql', workerd, ['void > mysql2']], + [createApiOnlyProjectRoot, 'pg', workerd, ['void > pg']], + [createAstroProjectRoot, 'mysql', workerd, ['void > mysql2']], + [createAstroProjectRoot, 'pg', workerd, ['void > pg']], + [createFrameworkOwnedRuntimeProjectRoot, 'mysql', node, undefined], + [createNodeProjectRoot, 'pg', {}, undefined], + ] as const; + for (const [createRoot, database, environment, drivers] of cases) { + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(useDatabase(createRoot(), database), 'ssr', environment), + `${createRoot.name} ${database}`, + ).resolves.toEqual({ + optimizeDeps: drivers + ? { exclude: ['void'], include: ['void > email-validator', ...drivers] } + : { exclude: ['void'] }, + }); + } }); it('leaves client dependency optimization unchanged', async () => { diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 3937d9cb2..fcb1bf4f9 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -2,6 +2,8 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { int, mysqlTable } from 'drizzle-orm/mysql-core'; +import { pgTable, serial } from 'drizzle-orm/pg-core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { getHookHandler } from '../helpers/vite-hooks.ts'; @@ -14,6 +16,8 @@ vi.mock('../../src/log', () => ({ import { voidWarn } from '../../src/log.ts'; import { getMiniflareDatabasePath, openSqliteDatabase } from '../../src/migrations/runner.ts'; import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const roots: Array = []; const fakeStateKeys: Array = []; @@ -32,11 +36,14 @@ function createProjectRoot(options?: { schema?: boolean }): string { type FakePoolRecord = { dialect: 'postgresql' | 'mysql'; options: Record; + drizzleConfig?: Record; }; type FakeDatabaseState = { nextId: number; pools: Array; + // drizzle configs of the databases built on a client that void/db passes in. + instances: Array>; clients: Array; }; @@ -58,30 +65,46 @@ async function loadExecutableDbModule( const load = getHookHandler(plugin.load) as (this: object, id: string) => string; let code = load.call({}, '\0virtual:void-db'); const stateKey = `__void_db_scope_${dialect}_${crypto.randomUUID().replaceAll('-', '')}`; - const state: FakeDatabaseState = { nextId: 0, pools: [], clients: [] }; + const state: FakeDatabaseState = { nextId: 0, pools: [], instances: [], clients: [] }; (globalThis as Record)[stateKey] = state; fakeStateKeys.push(stateKey); const fakeDriverPath = join(root, 'fake-driver.mjs'); - const fakePgPath = join(root, 'fake-pg.mjs'); const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; const stateExpression = `globalThis[${JSON.stringify(stateKey)}]`; const createDatabaseSource = ` -function createDatabase(dialect, pool) { +function createPool(dialect, options) { const state = ${stateExpression}; - const instanceId = ++state.nextId; - const connectionOwner = dialect === "postgresql" ? pool.options.connectionString : pool.options.uri; + const id = ++state.nextId; + const connectionOwner = dialect === "postgresql" ? options.connectionString : options.uri; let owner = connectionOwner; try { owner = getTestRuntimeBinding("REQUEST_ID") ?? connectionOwner; } catch {} - const database = { - instanceId, + const pool = { + id, + options, async query(expectedOwner) { - if (owner !== expectedOwner) throw new Error("database instance crossed request ownership"); - return instanceId; + if (owner !== expectedOwner) throw new Error("database pool crossed request ownership"); + return id; + }, + promise: () => pool, + // void/db listens to a MySQL pool to close idle connections. + on() {}, + }; + return pool; +} + +// Like drizzle, the database runs every query on its client; instanceId names the pool that +// serves the current call. +function createDatabase(client) { + const database = { + get instanceId() { + return client.id; }, + query: (expectedOwner) => client.query(expectedOwner), async transaction(callback) { return callback(database); }, + $client: client, }; return database; } @@ -102,54 +125,55 @@ export function drizzle(client) { 'import { drizzle } from "./fake-driver.mjs";', ); } else if (dialect === 'postgresql') { - writeFileSync( - fakePgPath, - `const state = ${stateExpression}; -export class Pool { - constructor(options) { - this.options = options; - state.pools.push({ dialect: "postgresql", options }); - } -} -export default { Pool }; -`, - ); writeFileSync( fakeDriverPath, `import { getRuntimeBinding as getTestRuntimeBinding } from ${JSON.stringify(envUrl)}; +const state = ${stateExpression}; ${createDatabaseSource} -export function drizzle(pool) { - if (typeof pool === "string") pool = { options: { connectionString: pool } }; - return createDatabase("postgresql", pool); +export function drizzle(config) { + if (typeof config === "string") return createDatabase(createPool("postgresql", { connectionString: config })); + const { connection, client, ...drizzleConfig } = config; + if (client) { + state.instances.push(drizzleConfig); + return createDatabase(client); + } + if (typeof connection !== "object" || connection === null) { + throw new Error("void/db must let drizzle build the pg pool from a connection object"); + } + state.pools.push({ dialect: "postgresql", options: connection, drizzleConfig }); + return createDatabase(createPool("postgresql", connection)); } `, ); - code = code - .replace(/^import \{ drizzle \} from .*;$/m, `import { drizzle } from "./fake-driver.mjs";`) - .replace('import pg from "pg";', 'import pg from "./fake-pg.mjs";'); + code = code.replace( + /^import \{ drizzle \} from .*;$/m, + `import { drizzle } from "./fake-driver.mjs";`, + ); } else { writeFileSync( fakeDriverPath, `import { getRuntimeBinding as getTestRuntimeBinding } from ${JSON.stringify(envUrl)}; const state = ${stateExpression}; ${createDatabaseSource} -export function createPool(options) { - const pool = { options }; - state.pools.push({ dialect: "mysql", options }); - return pool; -} -export function drizzle(pool) { - if (typeof pool === "string") pool = { options: { uri: pool } }; - return createDatabase("mysql", pool); +export function drizzle(config) { + const { connection, client, ...drizzleConfig } = config; + if (client) { + state.instances.push(drizzleConfig); + // Like drizzle, query through the promise API of a callback pool. + return createDatabase(typeof client.promise === "function" ? client.promise() : client); + } + if (typeof connection !== "object" || connection === null) { + throw new Error("void/db must let drizzle build the mysql2 pool from a connection object"); + } + state.pools.push({ dialect: "mysql", options: connection, drizzleConfig }); + return createDatabase(createPool("mysql", connection)); } `, ); - code = code - .replace(/^import \{ drizzle \} from .*;$/m, `import { drizzle } from "./fake-driver.mjs";`) - .replace( - /^import \{ createPool \} from .*;$/m, - `import { createPool } from "./fake-driver.mjs";`, - ); + code = code.replace( + /^import \{ drizzle \} from .*;$/m, + `import { drizzle } from "./fake-driver.mjs";`, + ); } code = code.replace( @@ -172,6 +196,68 @@ export const withEnv = (env, callback) => withRuntimeEnv(env, callback); return { module, state }; } +type DriverQuery = { toSQL(): { sql: string } }; + +/** Load void/db with drizzle's own driver and record each pool it builds. */ +async function loadDriverDbModule(dialect: 'postgresql' | 'mysql'): Promise<{ + db: { select(): { from(table: unknown): DriverQuery } }; + clients: Array<{ end(): unknown }>; +}> { + const root = createProjectRoot({ schema: false }); + const plugin = drizzlePlugin(root, false, dialect, { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const clientsKey = `__void_db_clients_${dialect}_${crypto.randomUUID().replaceAll('-', '')}`; + const clients: Array<{ end(): unknown }> = []; + (globalThis as Record)[clientsKey] = clients; + fakeStateKeys.push(clientsKey); + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ drizzle \} from (.*);$/m, + (_match, driver: string) => `import { drizzle as createDrizzle } from ${driver}; +function drizzle(config) { + const database = createDrizzle(config); + if (config.connection) globalThis[${JSON.stringify(clientsKey)}].push(database.$client); + return database; +}`, + ) + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + const module = (await import( + `${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}` + )) as { db: Awaited>['db'] }; + return { db: module.db, clients }; +} + +type MySqlDb = { + $client: { config: unknown; end(callback: () => void): void }; + execute(query: unknown): Promise; +}; + +/** Load the MySQL void/db module with drizzle's own driver and mysql2. */ +async function loadMySqlDbModule(): Promise<{ + db: MySqlDb; + createDb(connectionString: string): MySqlDb; + sql(strings: TemplateStringsArray): unknown; +}> { + const root = createProjectRoot(); + const plugin = drizzlePlugin(root, false, 'mysql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + return import(`${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}`); +} + afterEach(() => { for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); @@ -246,6 +332,74 @@ describe('drizzlePlugin db typing generation', () => { } }); + it.each(['postgresql', 'mysql'] as const)( + 'builds a %s client for each use when a framework handler has no Void request scope', + async (dialect) => { + const { module, state } = await loadExecutableDbModule(dialect); + const previousEnv = (globalThis as Record).__env__; + (globalThis as Record).__env__ = { + HYPERDRIVE: { connectionString: `${dialect}://hyperdrive` }, + }; + try { + const first = module.db.instanceId; + const second = module.db.instanceId; + expect(second).not.toBe(first); + expect(state.pools).toHaveLength(2); + for (const pool of state.pools) { + expect(pool.options).toMatchObject( + dialect === 'postgresql' + ? { connectionString: 'postgresql://hyperdrive', max: 1, idleTimeoutMillis: 1000 } + : { uri: 'mysql://hyperdrive', connectionLimit: 1, disableEval: true }, + ); + // mysql2 would start a timer when it builds a pool with these. + expect(pool.options).not.toHaveProperty('maxIdle'); + expect(pool.options).not.toHaveProperty('idleTimeout'); + } + } finally { + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + } + }, + ); + + it.each(['postgresql', 'mysql'] as const)( + 'builds no %s client for a query until it runs', + async (dialect) => { + const { db, clients } = await loadDriverDbModule(dialect); + const users = + dialect === 'postgresql' + ? pgTable('users', { id: serial('id') }) + : mysqlTable('users', { id: int('id') }); + + // A module-level query is built in global scope: no env exists there, and workerd + // rejects the timer a mysql2 pool may start. + const query = db.select().from(users); + + expect(query.toSQL().sql).toMatch(/^select .id. from .users.$/); + expect(clients).toEqual([]); + }, + ); + + it.each([ + ['postgresql', 'PostgreSQL'], + ['mysql', 'MySQL'], + ] as const)('reports a missing %s connection string in a request', async (dialect, name) => { + const { db, clients } = await loadDriverDbModule(dialect); + const users = + dialect === 'postgresql' + ? pgTable('users', { id: serial('id') }) + : mysqlTable('users', { id: int('id') }); + + // drizzle reports an error from a running query as "Failed query: ...". + await expect(withRuntimeEnv({}, async () => await db.select().from(users))).rejects.toThrow( + `db: ${name} is not configured.`, + ); + expect(clients).toEqual([]); + }); + it('keeps sqlite projects on the D1 internal db entry', () => { const root = createProjectRoot(); @@ -371,27 +525,205 @@ describe('drizzlePlugin db typing generation', () => { ); expect(await background).toBe(responseInstance); - expect(state.pools).toHaveLength(6); - for (const pool of state.pools) { + // MySQL createDb() builds its own pool through drizzle; the other pools are per request. + const requestPools = state.pools.filter((pool) => pool.options.uri !== explicitOwner); + expect(requestPools).toHaveLength(6); + for (const pool of requestPools) { + expect(pool.drizzleConfig).toEqual({}); if (dialect === 'postgresql') { expect(pool.options).toMatchObject({ max: 1, idleTimeoutMillis: 1000 }); } else { - expect(pool.options).toMatchObject({ connectionLimit: 1, maxIdle: 1, idleTimeout: 1000 }); + expect(pool.options).toMatchObject({ connectionLimit: 1, disableEval: true }); + expect(pool.options).not.toHaveProperty('maxIdle'); + expect(pool.options).not.toHaveProperty('idleTimeout'); + } + } + // Every request shares one drizzle database, which carries the schema. + expect(state.instances).toEqual([ + dialect === 'postgresql' + ? { schema: expect.anything() } + : { schema: expect.anything(), mode: 'default' }, + ]); + if (dialect === 'mysql') { + expect(state.pools).toHaveLength(7); + const explicitPool = state.pools.find((pool) => pool.options.uri === explicitOwner); + expect(explicitPool?.options).toEqual({ uri: explicitOwner, disableEval: true }); + expect(explicitPool?.drizzleConfig).toEqual({ schema: expect.anything(), mode: 'default' }); + for (const pool of state.pools) { + expect(pool.options).toMatchObject({ disableEval: true }); } } }, ); - it('uses the required default mode for MySQL databases with a schema', () => { + it.each([ + ['mysql', 'mysql2'], + ['postgresql', 'pg'], + ] as const)('leaves the %s driver import to drizzle', (dialect, driver) => { const root = createProjectRoot(); - const plugin = drizzlePlugin(root, false, 'mysql'); + const plugin = drizzlePlugin(root, false, dialect); const load = getHookHandler(plugin.load) as (this: object, id: string) => string; const code = load.call({}, '\0virtual:void-db'); + const specifiers = Array.from( + code.matchAll(/\b(?:from|import)\s*\(?\s*("[^"]+")/g), + (match) => JSON.parse(match[1]!) as string, + ); + + // The worker optimizer serves drizzle's bare driver import from a prebundle. A bare import + // here resolves from the app root, and an import by file path reaches workerd as CommonJS. + const driverPath = new RegExp(`(^|[\\\\/])${driver}([\\\\/]|$)`); + expect( + specifiers.filter( + (specifier) => driverPath.test(specifier) && !specifier.includes('drizzle-orm'), + ), + ).toEqual([]); + }); + + it.each([true, false])( + 'gives drizzle a mysql2 pool with eval disabled (schema: %s)', + async (hasSchema) => { + const root = createProjectRoot({ schema: hasSchema }); + const plugin = drizzlePlugin(root, false, 'mysql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + writeFileSync( + join(root, 'harness.mjs'), + `import { db, createDb } from "./virtual-db.mjs"; +import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; +export { createDb }; +export const requestPoolConfig = (env) => + withRuntimeEnv(env, () => { + const pool = db.$client; + const config = pool.config; + pool.end(); + return config; + }); +`, + ); + type MySqlPoolConfig = { connectionConfig: { disableEval: boolean } }; + const harness = (await import( + `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` + )) as { + createDb(url: string): { $client: { config: MySqlPoolConfig; end(): void } }; + requestPoolConfig(env: Record): MySqlPoolConfig; + }; + + const explicit = harness.createDb('mysql://void@127.0.0.1:9/app').$client; + try { + expect(explicit.config.connectionConfig.disableEval).toBe(true); + } finally { + explicit.end(); + } + const request = harness.requestPoolConfig({ DATABASE_URL: 'mysql://void@127.0.0.1:9/app' }); + expect(request.connectionConfig.disableEval).toBe(true); + }, + ); + + it('closes idle MySQL connections without a timer when it builds a pool', async () => { + const server = await startFakeMySqlServer([{ one: 1 }]); + const url = `mysql://void@127.0.0.1:${server.port}/app`; + const { db, createDb, sql } = await loadMySqlDbModule(); + const previousEnv = (globalThis as Record).__env__; + /** Run queries, then expect their connection to close once idle for `idleTimeout`. */ + const expectClosedWhenIdle = async (queries: () => Promise, idleTimeout: number) => { + await queries(); + expect(server.openConnections()).toBe(1); + // The pool releases a connection before the query resolves. + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(idleTimeout - 1); + expect(server.openConnections()).toBe(1); + vi.advanceTimersByTime(1); + await vi.waitFor(() => expect(server.openConnections()).toBe(0)); + }; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + // workerd rejects a timer started outside a request, and a pool can be built there. + await withRuntimeEnv({ DATABASE_URL: url }, async () => { + void db.$client; + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(async () => { + await db.execute(sql`select 1`); + await db.execute(sql`select 1`); + }, 1000); + }); + + // A framework entry that Void does not wrap runs outside its request scope, where each + // use builds a pool. + (globalThis as Record).__env__ = { DATABASE_URL: url }; + void db.$client.config; + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(() => db.execute(sql`select 1`), 1000); + + const own = createDb(url); + try { + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(() => own.execute(sql`select 1`), 60_000); + // The pool connects again for the next query. + await own.execute(sql`select 1`); + expect(server.openConnections()).toBe(1); + } finally { + await new Promise((resolve) => own.$client.end(() => resolve())); + } + } finally { + vi.useRealTimers(); + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + await server.close(); + } + }); - expect(code).toContain('return drizzle(pool, { schema, mode: "default" })'); - expect(code).toContain('drizzle(connectionString, { schema, mode: "default" })'); + it.each([true, false])( + 'gives drizzle a pg pool with the request options (schema: %s)', + async (hasSchema) => { + const root = createProjectRoot({ schema: hasSchema }); + const plugin = drizzlePlugin(root, false, 'postgresql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + writeFileSync( + join(root, 'harness.mjs'), + `import { db } from "./virtual-db.mjs"; +import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; +export const requestPoolOptions = (env) => + withRuntimeEnv(env, async () => { + const pool = db.$client; + const options = pool.options; + await pool.end(); + return options; }); +`, + ); + const harness = (await import( + `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` + )) as { + requestPoolOptions(env: Record): Promise>; + }; + + const url = 'postgres://void@127.0.0.1:9/app'; + await expect(harness.requestPoolOptions({ DATABASE_URL: url })).resolves.toMatchObject({ + connectionString: url, + max: 1, + idleTimeoutMillis: 1000, + }); + }, + ); it('serves schema imports of void/db from an operator-only virtual module', () => { const root = createProjectRoot(); @@ -444,8 +776,55 @@ describe('drizzlePlugin db typing generation', () => { const code = load.call({}, '\0virtual:void-db'); expect(code).toContain('import { drizzle } from "'); - expect(code).not.toContain('import * as schema'); - expect(code).toContain('return drizzle(pool)'); + expect(code).not.toMatch(/\bschema\b/); + }); + + // The package's own void/db is a D1 client, which never fits these dialects. + it.each(['postgresql', 'mysql'] as const)('serves the %s void/db without a schema', (dialect) => { + const root = createProjectRoot({ schema: false }); + const plugin = drizzlePlugin(root, false, dialect); + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string } }, + source: string, + importer: string | undefined, + options: { isEntry: boolean }, + ) => string | undefined; + + expect( + resolveId.call({ environment: { name: 'ssr' } }, 'void/db', undefined, { isEntry: false }), + ).toBe('\0virtual:void-db'); + expect(readFileSync(join(root, '.void', 'db.d.ts'), 'utf8')).toContain('Record'); + }); + + // Nitro bundles a Nuxt or Analog server build's output with the server routes, and serves + // them all one void/db: the app's through `void/nitro`, or the package's, which names it. + it('leaves a PostgreSQL or MySQL void/db to Nitro in a Nitro framework build', () => { + const root = createProjectRoot(); + const schemaPath = join(root, 'db', 'schema.ts'); + const resolveIn = ( + dialect: 'sqlite' | 'postgresql' | 'mysql', + mode: 'build' | 'dev', + importer?: string, + ) => { + const plugin = drizzlePlugin(root, false, dialect, { nitroBundlesServer: true }); + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string; mode: string } }, + source: string, + importer: string | undefined, + options: { isEntry: boolean }, + ) => unknown; + return resolveId.call({ environment: { name: 'ssr', mode } }, 'void/db', importer, { + isEntry: false, + }); + }; + + const page = join(root, 'src', 'app', 'pages', 'users.server.ts'); + expect(resolveIn('postgresql', 'build', page)).toEqual({ id: 'void/db', external: true }); + expect(resolveIn('mysql', 'build', page)).toEqual({ id: 'void/db', external: true }); + expect(resolveIn('postgresql', 'dev', page)).toBe('\0virtual:void-db'); + expect(resolveIn('sqlite', 'build', page)).toBe('\0virtual:void-db'); + // The schema's own import of void/db still gets its SQL helpers. + expect(resolveIn('postgresql', 'build', schemaPath)).toBe('\0virtual:void-db-schema'); }); it('uses a custom D1 binding name in the sqlite virtual module', () => { diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index 2ec2b9822..adade2714 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -577,6 +577,72 @@ describe('astroDevTriggerInjectPlugin', () => { expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(false); expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); }); + + // A restart sets up the new dev server, with a new plugin, before it closes the old one. + it.each([ + ['', false], + [' after it was deleted', true], + ])( + 'keeps its middleware for the dev server that replaces it on a restart%s', + (_name, deleted) => { + const root = createTempRoot(); + const middlewarePath = join(root, 'src', 'middleware.ts'); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'once').mockReturnValue(process); + const closeServer: Array<() => void> = []; + const startServer = () => + callConfigureServer(astroDevTriggerInjectPlugin(root), { + httpServer: { + once(_event: string, cb: () => void) { + closeServer.push(cb); + }, + }, + }); + + startServer(); + if (deleted) { + rmSync(middlewarePath); + } + startServer(); + closeServer[0]!(); + expect(existsSync(middlewarePath)).toBe(true); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toContain('src/middleware.ts'); + expect(log).not.toHaveBeenCalled(); + + closeServer[1]!(); + expect(existsSync(middlewarePath)).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }, + ); + + // Vite restarts on an .env edit with the same plugins: it sets up this plugin for the new + // server, then closes the old one. + it('removes its middleware when the last server closes after Vite restarts the same plugin', () => { + const root = createTempRoot(); + const middlewarePath = join(root, 'src', 'middleware.ts'); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + vi.spyOn(process, 'once').mockReturnValue(process); + const plugin = astroDevTriggerInjectPlugin(root); + const closeServer: Array<() => void> = []; + const startServer = () => + callConfigureServer(plugin, { + httpServer: { + once(_event: string, cb: () => void) { + closeServer.push(cb); + }, + }, + }); + + startServer(); + startServer(); + closeServer[0]!(); + expect(existsSync(middlewarePath)).toBe(true); + + closeServer[1]!(); + expect(existsSync(middlewarePath)).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }); }); describe('analogDevTriggerInjectPlugin', () => { diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index d04a49692..c414a1128 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -1,10 +1,58 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; import { migrationPlugin } from '../../src/plugin-migration.ts'; +const mysqlQueries = vi.hoisted((): Array => []); +// Each lock, release, recorded file and Better Auth run, with the database it went to. +const mysqlSteps = vi.hoisted((): Array => []); + +vi.mock('mysql2/promise', () => ({ + createConnection: async (options: string | { uri: string }) => { + const database = new URL(typeof options === 'string' ? options : options.uri).pathname.slice(1); + return { + async query(sql: string) { + mysqlQueries.push(sql); + const step = sql.startsWith('SELECT GET_LOCK') + ? 'lock' + : sql.startsWith('SELECT RELEASE_LOCK') + ? 'release' + : sql.startsWith('INSERT INTO `_void_migrations`') + ? 'record' + : undefined; + if (step) { + mysqlSteps.push(`${step} ${database}`); + } + if (sql === 'SELECT DATABASE() AS db') { + return [[{ db: 'app' }], []]; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [[{ acquired: 1 }], []]; + } + return [[], []]; + }, + async end() {}, + }; + }, +})); + +const authFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); + +vi.mock('../../src/auth/node.ts', () => ({ + runLocalVoidAuthMigrations: async (...args: Array) => { + mysqlQueries.push('Better Auth migrations'); + const databaseUrl = args[5]; + mysqlSteps.push( + `auth ${typeof databaseUrl === 'string' ? new URL(databaseUrl).pathname.slice(1) : '(no URL)'}`, + ); + if (authFailure.error) { + throw authFailure.error; + } + }, +})); + const roots: Array = []; type HookWithHandler = { handler: T }; type ConfigureServerHook = @@ -63,3 +111,134 @@ describe('migrationPlugin persistence roots', () => { expect(existsSync(dbPath)).toBe(true); }); }); + +describe('migrationPlugin with MySQL and Better Auth', () => { + // Better Auth reads the schema, then adds what is missing, so two runs at once both add it. + // An app without migration files reads DATABASE_URL like Better Auth: the shell before .env. + it.each([ + { files: true, url: '.env' }, + { files: false, url: '.env' }, + { files: false, url: 'shell' }, + ])( + 'runs Better Auth migrations under the migration lock (migration files: $files, DATABASE_URL from $url)', + async ({ files, url }) => { + const root = createProjectRoot(); + if (!files) { + rmSync(join(root, 'db'), { recursive: true }); + } + if (url === 'shell') { + vi.stubEnv('DATABASE_URL', 'mysql://root@127.0.0.1:3306/app'); + } else { + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://root@127.0.0.1:3306/app\n'); + } + mysqlQueries.length = 0; + + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + } finally { + vi.unstubAllEnvs(); + } + + const lock = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT GET_LOCK')); + const auth = mysqlQueries.indexOf('Better Auth migrations'); + const release = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT RELEASE_LOCK')); + expect(lock).toBeGreaterThanOrEqual(0); + expect(auth).toBeGreaterThan(lock); + expect(release).toBeGreaterThan(auth); + expect(mysqlQueries.filter((sql) => sql === 'Better Auth migrations')).toHaveLength(1); + }, + ); + + // Migration files apply to the database in .env, as `void db migrate` does. Better Auth's + // migrations change the one DATABASE_URL names in the shell, else in .env. + it.each([ + { + name: 'the shell and .env name different databases', + files: 'one', + shell: 'shell', + env: 'env', + steps: ['lock env', 'record env', 'release env', 'lock shell', 'auth shell', 'release shell'], + }, + { + name: 'no migration files, the shell and .env name different databases', + files: 'none', + shell: 'shell', + env: 'env', + steps: ['lock shell', 'auth shell', 'release shell'], + }, + { + name: 'an empty migrations folder, DATABASE_URL only in the shell', + files: 'empty', + shell: 'shell', + env: undefined, + steps: ['lock shell', 'auth shell', 'release shell'], + }, + { + name: 'the shell and .env name the same database', + files: 'one', + shell: 'app', + env: 'app', + steps: ['lock app', 'record app', 'auth app', 'release app'], + }, + ])( + 'holds the lock of the database each step changes ($name)', + async ({ files, shell, env, steps }) => { + const root = createProjectRoot(); + if (files === 'none') { + rmSync(join(root, 'db'), { recursive: true }); + } else if (files === 'empty') { + rmSync(join(root, 'db', 'migrations', '001_create_users.sql')); + } + if (env) { + writeFileSync(join(root, '.env'), `DATABASE_URL=mysql://root@127.0.0.1:3306/${env}\n`); + } + vi.stubEnv('DATABASE_URL', `mysql://root@127.0.0.1:3306/${shell}`); + mysqlSteps.length = 0; + + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + } finally { + vi.unstubAllEnvs(); + } + + expect(mysqlSteps).toEqual(steps); + }, + ); + + it('reports the applied files when Better Auth migrations fail afterwards', async () => { + const root = createProjectRoot(); + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://root@127.0.0.1:3306/app\n'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + authFailure.error = new Error('Better Auth migration failed'); + let lines: Array; + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + lines = log.mock.calls.map(([line]) => String(line)); + } finally { + authFailure.error = undefined; + log.mockRestore(); + } + + const applied = lines.findIndex((line) => + line.includes('Applied 1 migration(s): 001_create_users.sql'), + ); + const failed = lines.findIndex((line) => line.includes('Better Auth migration failed')); + expect(applied).toBeGreaterThanOrEqual(0); + expect(failed).toBeGreaterThan(applied); + }); +}); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 9a5b1301c..012951c6b 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1,9 +1,23 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { resolveConfig } from 'vite'; +import { pathToFileURL } from 'node:url'; +import { cloudflare } from '@cloudflare/vite-plugin'; +import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; +import { findVoidDependentPackages } from '../../src/installed-packages.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; const roots: Array = []; @@ -26,6 +40,7 @@ function createProjectRoot(): string { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); } @@ -145,4 +160,972 @@ describe('voidPlugin Vite environments', () => { expect(config.environments.ssr.resolve.external).toEqual([]); expect(config.environments.ssr.resolve.conditions).toContain('workerd'); }); + + it('prebundles void CommonJS dependencies only in the worker environment', async () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig({ root, plugins: [voidPlugin()] }, 'serve', 'development'); + + expect(config.environments.void_worker.optimizeDeps.include).toEqual( + expect.arrayContaining(['void > email-validator', 'void > pg']), + ); + expect(config.environments.ssr.optimizeDeps.include).toEqual([]); + }); + + it.each(['before', 'after'])( + 'prebundles them in an adapter-owned workerd environment with Void listed %s it', + async (order) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '^7.0.0' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'mysql', worker: { compatibility_date: '2026-02-24' } }), + ); + writeFileSync(join(root, 'worker.ts'), 'export default {};\n'); + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'adapter-app', + main: './worker.ts', + compatibility_date: '2026-02-24', + }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + // @astrojs/cloudflare runs Astro's ssr environment in workerd this way. + const adapter = cloudflare({ + configPath: join(root, 'wrangler.jsonc'), + viteEnvironment: { name: 'ssr' }, + }); + + const config = await resolveConfig( + { root, plugins: order === 'before' ? [voidPlugin(), adapter] : [adapter, voidPlugin()] }, + 'serve', + 'development', + ); + + expect(config.environments.ssr.optimizeDeps.include).toEqual( + expect.arrayContaining(['void > email-validator', 'void > mysql2']), + ); + }, + ); + + it.each([ + ['sveltekit', '@sveltejs/kit', 'serve', true], + ['sveltekit', '@sveltejs/kit', 'build', false], + ['nuxt', 'nuxt', 'serve', false], + ['analog', '@analogjs/platform', 'serve', false], + ['astro', 'astro', 'serve', false], + ] as const)( + 'keeps void in the Node SSR graph for %s (%s, %s): %s', + async (_name, dependency, command, inlined) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { [dependency]: '*' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig({ root, plugins: [voidPlugin()] }, command, 'development'); + + expect(config.ssr.noExternal).toEqual( + inlined ? expect.arrayContaining(['void']) : expect.not.arrayContaining(['void']), + ); + }, + ); + + it('serves the void/db virtual module to SvelteKit server code from an installed void', async () => { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { '@sveltejs/kit': '*', void: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db/schema.ts'), + "import { pgTable, serial } from 'void/schema-pg';\nexport const users = pgTable('users', { id: serial('id') });\n", + ); + // A packed install: Vite externalizes bare SSR imports that resolve into node_modules. + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ + name: 'void', + type: 'module', + exports: { './db': './db.mjs', './_env': './env.mjs', './schema-pg': './schema-pg.mjs' }, + }), + ); + for (const file of ['db.mjs', 'env.mjs', 'schema-pg.mjs']) { + writeFileSync(join(installed, file), 'export {};\n'); + } + mkdirSync(join(root, 'src')); + writeFileSync( + join(root, 'src/server.ts'), + "import { db } from 'void/db';\nimport { withRuntimeEnv } from 'void/_env';\nexport { db, withRuntimeEnv };\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const result = await server.environments.ssr.transformRequest('/src/server.ts'); + + expect(result?.code).toContain('virtual:void-db'); + expect(result?.code).not.toMatch(/__vite_ssr_import__\("void\/(?:db|_env)"/); + } finally { + await server.close(); + } + }); + + it('rejects Cloudflare-only imports from an installed void on the Node target dev server', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { void: '*' } }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { './kv': './kv.mjs' } }), + ); + writeFileSync(join(installed, 'kv.mjs'), 'export const kv = {};\n'); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/cache.ts'), + "import { kv } from 'void/kv';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + await expect( + server.environments.ssr.transformRequest('/routes/api/cache.ts'), + ).rejects.toThrow("target: 'void/kv' is not available with target 'node'."); + } finally { + await server.close(); + } + }); + + /** An installed package with the given manifest and `index.js`. */ + function installPackage(root: string, name: string, manifest: object, index: string): void { + const dir = join(root, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, main: 'index.js', ...manifest }), + ); + writeFileSync(join(dir, 'index.js'), index); + } + + it('serves the app void/db to installed ES module packages that depend on void in SvelteKit dev', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { + 'db-lib': '*', + 'cjs-db-lib': '*', + 'wrapped-db-lib': '*', + 'esm-wrapped-db-lib': '*', + }, + devDependencies: { '@sveltejs/kit': '*', void: '*' }, + }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db/schema.ts'), + "import { pgTable, serial } from 'void/schema-pg';\nexport const users = pgTable('users', { id: serial('id') });\n", + ); + installPackage( + root, + 'void', + { type: 'module', exports: { './db': './db.mjs', './schema-pg': './schema-pg.mjs' } }, + '', + ); + for (const file of ['db.mjs', 'schema-pg.mjs']) { + writeFileSync(join(root, 'node_modules/void', file), 'export {};\n'); + } + installPackage( + root, + 'db-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { db } from 'void/db';\n", + ); + // Vite's module runner cannot evaluate CommonJS, so these stay with Node: one CommonJS + // package, and one with an ES module wrapper that imports its CommonJS build. + installPackage( + root, + 'cjs-db-lib', + { peerDependencies: { void: '*' } }, + "exports.db = require('void/db').db;\n", + ); + installPackage( + root, + 'wrapped-db-lib', + { + exports: { import: './wrapper.mjs', require: './index.js' }, + peerDependencies: { void: '*' }, + }, + "exports.db = require('void/db').db;\n", + ); + writeFileSync( + join(root, 'node_modules/wrapped-db-lib/wrapper.mjs'), + "import lib from './index.js';\nexport const db = lib.db;\n", + ); + // The same wrapper in an ES module package, as in Node's dual-package guide. + installPackage( + root, + 'esm-wrapped-db-lib', + { + type: 'module', + exports: { import: './wrapper.mjs', require: './index.cjs' }, + peerDependencies: { void: '*' }, + }, + '', + ); + writeFileSync( + join(root, 'node_modules/esm-wrapped-db-lib/index.cjs'), + "exports.db = require('void/db').db;\n", + ); + writeFileSync( + join(root, 'node_modules/esm-wrapped-db-lib/wrapper.mjs'), + "import lib from './index.cjs';\nexport const db = lib.db;\n", + ); + mkdirSync(join(root, 'src')); + writeFileSync( + join(root, 'src/server.ts'), + "import { db } from 'db-lib';\nimport { db as cjsDb } from 'cjs-db-lib';\nimport { db as wrappedDb } from 'wrapped-db-lib';\nimport { db as esmWrappedDb } from 'esm-wrapped-db-lib';\nexport { db, cjsDb, wrappedDb, esmWrappedDb };\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const app = await server.environments.ssr.transformRequest('/src/server.ts'); + const lib = await server.environments.ssr.transformRequest('/node_modules/db-lib/index.js'); + + expect(app?.code).not.toContain('__vite_ssr_import__("db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("cjs-db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("wrapped-db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("esm-wrapped-db-lib"'); + expect(lib?.code).toContain('virtual:void-db'); + } finally { + await server.close(); + } + }); + + it('leaves installed packages that depend on void to Node in SvelteKit D1 dev', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'db-lib': '*' }, + devDependencies: { '@sveltejs/kit': '*', void: '*' }, + }), + ); + installPackage(root, 'void', { type: 'module', exports: { './db': './db.mjs' } }, ''); + writeFileSync(join(root, 'node_modules/void/db.mjs'), 'export {};\n'); + installPackage( + root, + 'db-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { db } from 'void/db';\n", + ); + mkdirSync(join(root, 'src')); + writeFileSync(join(root, 'src/server.ts'), "export { db } from 'db-lib';\n"); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const app = await server.environments.ssr.transformRequest('/src/server.ts'); + + expect(app?.code).toContain('__vite_ssr_import__("db-lib"'); + } finally { + await server.close(); + } + }); + + // The rule vite-plugin-svelte applies to Svelte libraries. + it("finds installed ES module packages that depend on void among the app's dependencies and theirs", () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { + 'db-lib': '*', + 'plain-lib': '*', + 'module-db-lib': '*', + 'mjs-db-lib': '*', + }, + devDependencies: { 'dev-db-lib': '*' }, + }), + ); + const usesVoid = { peerDependencies: { void: '*' } }; + installPackage( + root, + 'db-lib', + { type: 'module', dependencies: { 'inner-db-lib': '*' }, ...usesVoid }, + '', + ); + installPackage(root, 'inner-db-lib', { type: 'module', ...usesVoid }, ''); + installPackage(root, 'dev-db-lib', { type: 'module', ...usesVoid }, ''); + // Packages that a package without void depends on stay with it. + installPackage( + root, + 'plain-lib', + { type: 'module', dependencies: { 'hidden-db-lib': '*' } }, + '', + ); + installPackage(root, 'hidden-db-lib', { type: 'module', ...usesVoid }, ''); + // ES module entries outside a `type: "module"` package. + installPackage(root, 'module-db-lib', { module: './index.mjs', ...usesVoid }, ''); + installPackage(root, 'mjs-db-lib', { exports: './index.mjs', ...usesVoid }, ''); + for (const name of ['module-db-lib', 'mjs-db-lib']) { + writeFileSync(join(root, 'node_modules', name, 'index.mjs'), ''); + } + + expect(findVoidDependentPackages(root)).toEqual([ + 'db-lib', + 'dev-db-lib', + 'inner-db-lib', + 'mjs-db-lib', + 'module-db-lib', + ]); + }); + + it('finds packages that depend on void through the Yarn PnP runtime', () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { 'db-lib': '*' } }), + ); + // No node_modules: Yarn serves each package from its cache, and `exports` may hide its + // package.json, so the runtime's unqualified resolution finds the package. + const store = join(root, '.yarn/cache'); + const usesVoid = { type: 'module', exports: './index.js', peerDependencies: { void: '*' } }; + for (const [name, manifest] of [ + ['db-lib', { ...usesVoid, dependencies: { 'inner-db-lib': '*' } }], + ['inner-db-lib', usesVoid], + ] as const) { + mkdirSync(join(store, name), { recursive: true }); + writeFileSync(join(store, name, 'package.json'), JSON.stringify({ name, ...manifest })); + writeFileSync(join(store, name, 'index.js'), "export { db } from 'void/db';\n"); + } + // Yarn's API reads a Windows path with either separator, and the crawler passes + // forward slashes. + const inRoot = (path: string) => normalizePath(path).startsWith(normalizePath(root)); + const pnp = { + resolveToUnqualified(request: string, issuer: string) { + expect(inRoot(issuer)).toBe(true); + const [name] = request.split('/package.json'); + if (!existsSync(join(store, name!))) { + throw new Error(`${name} is not a dependency of ${issuer}`); + } + return join(store, request); + }, + }; + const runtime = module as { findPnpApi?: (path: string) => typeof pnp | null }; + runtime.findPnpApi = (path) => (inRoot(path) ? pnp : null); + try { + expect(findVoidDependentPackages(root)).toEqual(['db-lib', 'inner-db-lib']); + } finally { + delete runtime.findPnpApi; + } + }); + + // Vite's module runner evaluates an inlined package as ES modules, so a package whose import + // entry reaches CommonJS through relative imports stays with Node. + const wrapper = { + 'wrapper.mjs': "import lib from './index.cjs';\nexport const db = lib.db;\n", + 'index.cjs': 'exports.db = 1;\n', + }; + it.each([ + { + shape: 'separate ES module and CommonJS entries', + manifest: { exports: { '.': { import: './index.mjs', require: './index.cjs' } } }, + files: { 'index.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: true, + }, + { + shape: 'a nested type: module folder', + manifest: { exports: { import: './esm/index.js', require: './index.js' } }, + files: { 'esm/package.json': '{"type":"module"}', 'esm/index.js': 'export const db = 1;\n' }, + found: true, + }, + { + shape: "Node's dual-package wrapper", + manifest: { exports: { import: './wrapper.mjs', require: './index.cjs' } }, + files: wrapper, + found: false, + }, + { + shape: 'that wrapper under type: module', + manifest: { type: 'module', exports: { import: './wrapper.mjs', require: './index.cjs' } }, + files: wrapper, + found: false, + }, + { + shape: 'an ES module entry that imports .cjs', + manifest: { type: 'module' }, + files: { + 'index.js': "import vendor from './vendor.cjs';\nexport const db = vendor.db;\n", + 'vendor.cjs': 'module.exports = { db: 1 };\n', + }, + found: false, + }, + ...[ + "export * from './vendor.cjs';\n", + "export {} from './vendor.cjs';\n", + "import './vendor.cjs';\n", + "export const db = await import('./vendor.cjs');\n", + ].map((entry) => ({ + shape: `an ES module entry with \`${entry.trim()}\``, + manifest: { type: 'module' }, + files: { 'index.js': entry, 'vendor.cjs': 'module.exports = { db: 1 };\n' }, + found: false, + })), + { + shape: 'CommonJS imports only in comments and strings', + manifest: { type: 'module' }, + files: { + 'index.js': [ + "// Before 2.0 this file did `import './legacy.cjs'`.", + "/* export * from './legacy.cjs'; */", + 'export const note = "import legacy from \'./legacy.cjs\'";', + "export const hint = `await import('./legacy.cjs')`;", + 'export const db = 1;', + '', + ].join('\n'), + 'legacy.cjs': 'module.exports = { db: 1 };\n', + }, + found: true, + }, + // Vite's module runner parses the entry with the same parser, and cannot load it either. + { + shape: 'an entry that does not parse', + manifest: { type: 'module' }, + files: { 'index.js': 'export const db = ;\n' }, + found: false, + }, + { + shape: 'a CommonJS file that a relative ES module imports', + manifest: { type: 'module' }, + files: { + 'index.js': "export { db } from './internal.js';\n", + 'internal.js': "import legacy from './legacy.cjs';\nexport const db = legacy.db;\n", + 'legacy.cjs': 'module.exports = { db: 1 };\n', + }, + found: false, + }, + { + shape: 'a CommonJS folder that a dynamically imported ES module imports', + manifest: { type: 'module' }, + files: { + 'index.js': "export const load = () => import('./lib/internal.js');\n", + 'lib/internal.js': "export { db } from '../cjs/db.js';\n", + 'cjs/package.json': '{"type":"commonjs"}', + 'cjs/db.js': 'exports.db = 1;\n', + }, + found: false, + }, + { + shape: 'a relative ES module that does not parse', + manifest: { type: 'module' }, + files: { + 'index.js': "export * from './internal.js';\n", + 'internal.js': 'export const db = ;\n', + }, + found: false, + }, + { + shape: 'ES modules that import each other and CommonJS of another package', + manifest: { type: 'module' }, + files: { + 'index.js': "export * from './a.js';\n", + 'a.js': "export * from './b.mjs';\nexport const a = 1;\n", + 'b.mjs': + "import { a } from './a.js';\nimport 'other-lib/legacy.cjs';\nexport const db = () => a;\n", + }, + found: true, + }, + { + shape: 'more relative modules than the check reads', + manifest: { type: 'module' }, + files: { + 'index.js': Array.from({ length: 2000 }, (_, i) => `import './${i}.js';\n`).join(''), + ...Object.fromEntries(Array.from({ length: 2000 }, (_, i) => [`${i}.js`, ''])), + }, + found: false, + }, + // Vite also matches `development`, or `production` when NODE_ENV is production, as in a build. + { + shape: 'ES modules only under development', + manifest: { exports: { development: './dev.mjs', default: './index.cjs' } }, + files: { 'dev.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: true, + }, + { + shape: 'ES modules only under development, in production', + nodeEnv: 'production', + manifest: { exports: { development: './dev.mjs', default: './index.cjs' } }, + files: { 'dev.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: false, + }, + { + shape: 'CommonJS under development', + manifest: { exports: { development: './dev.cjs', default: './index.mjs' } }, + files: { 'dev.cjs': 'exports.db = 1;\n', 'index.mjs': 'export const db = 1;\n' }, + found: false, + }, + ])('finds an installed package with $shape: $found', ({ manifest, files, found, nodeEnv }) => { + if (nodeEnv) { + vi.stubEnv('NODE_ENV', nodeEnv); + } + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { 'db-lib': '*' } }), + ); + installPackage( + root, + 'db-lib', + { ...manifest, peerDependencies: { void: '*' } }, + 'exports.db = 1;\n', + ); + for (const [path, content] of Object.entries(files)) { + mkdirSync(join(root, 'node_modules/db-lib', path, '..'), { recursive: true }); + writeFileSync(join(root, 'node_modules/db-lib', path), content); + } + + expect(findVoidDependentPackages(root)).toEqual(found ? ['db-lib'] : []); + }); + + // Nitro bundles a Nuxt or Analog server build's output, and serves it the app's void/db. + it.each([ + ['nuxt', 'nuxt', true], + ['analog', '@analogjs/platform', true], + ['sveltekit', '@sveltejs/kit', false], + ] as const)( + 'leaves a PostgreSQL void/db to Nitro in a %s build (dependency %s): %s', + async (_name, dependency, external) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { [dependency]: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db/schema.ts'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + const plugin = config.plugins.find((candidate) => candidate.name === 'void:drizzle')!; + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string; mode: string } }, + source: string, + importer: string, + options: { isEntry: boolean }, + ) => unknown; + + expect( + await resolveId.call( + { environment: { name: 'ssr', mode: 'build' } }, + 'void/db', + join(root, 'src/server/users.ts'), + { isEntry: false }, + ), + ).toEqual(external ? { id: 'void/db', external: true } : '\0virtual:void-db'); + }, + ); + + it('rejects Cloudflare-only imports from installed packages on the Node target dev server', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'kv-lib': '*' }, + devDependencies: { void: '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + installPackage(root, 'void', { type: 'module', exports: { './kv': './kv.mjs' } }, ''); + writeFileSync(join(root, 'node_modules/void/kv.mjs'), 'export const kv = {};\n'); + installPackage( + root, + 'kv-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { kv } from 'void/kv';\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/cache.ts'), + "import { kv } from 'kv-lib';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + await expect(server.ssrLoadModule('/routes/api/cache.ts')).rejects.toThrow( + "target: 'void/kv' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by node_modules/kv-lib/index.js.", + ); + } finally { + await server.close(); + } + }); + + describe('Node target builds with an installed void', () => { + /** An API-only Node-target app whose route imports void and an adapter from node_modules. */ + function createNodeApp(route: string): string { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'kv-lib': '*' }, + devDependencies: { void: '*', '@void/adapter': '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + installPackage( + root, + 'void', + { type: 'module', exports: { './handler': './handler.mjs', './_env': './env.mjs' } }, + '', + ); + writeFileSync( + join(root, 'node_modules/void/handler.mjs'), + 'export class ValidationError extends Error {}\n', + ); + // Void's runtime imports cloudflare:workers only when it runs in workerd. + writeFileSync( + join(root, 'node_modules/void/env.mjs'), + "export const workers = () => typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers' ? import('cloudflare:workers') : null;\n", + ); + installPackage( + root, + '@void/adapter', + { type: 'module' }, + "export { ValidationError } from 'void/handler';\n", + ); + installPackage( + root, + 'kv-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { kv } from 'void/kv';\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync(join(root, 'routes/api/ping.ts'), route); + vi.spyOn(process, 'cwd').mockReturnValue(root); + return root; + } + + async function buildApp(root: string): Promise { + // The CLI's builder, which runs the plugins' buildApp hooks. + const builder = await createBuilder( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + null, + ); + await builder.buildApp(); + const output = join(root, 'dist/ssr'); + return readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(output, file), 'utf8')) + .join('\n'); + } + + it('bundles void and its adapters, as the dev server inlines them', async () => { + const root = createNodeApp( + "import { ValidationError } from 'void/handler';\nimport { ValidationError as AdapterError } from '@void/adapter';\nimport { workers } from 'void/_env';\nexport const GET = () => Response.json({ same: ValidationError === AdapterError, workers: typeof workers });\n", + ); + + const code = await buildApp(root); + + expect(code).toContain('ValidationError = class extends Error'); + expect(code).not.toMatch(/from\s*"(?:void|@void)\//); + expect(code).toContain('import("cloudflare:workers")'); + }); + + // A Pages app's build has a client environment too. + it('inlines void, its adapters and the packages that depend on it in a Pages app build', async () => { + const root = createNodeApp('export const GET = () => Response.json({});\n'); + mkdirSync(join(root, 'pages')); + + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + + expect(config.environments.ssr!.resolve.noExternal).toEqual( + expect.arrayContaining(['void', /^@void\//, 'kv-lib']), + ); + }); + + it('rejects Cloudflare-only imports from installed packages', async () => { + const root = createNodeApp( + "import { kv } from 'kv-lib';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + + await expect(buildApp(root)).rejects.toThrow( + "target: 'void/kv' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by node_modules/kv-lib/index.js.", + ); + }); + + it('rejects a static cloudflare: import, which Node cannot load', async () => { + const root = createNodeApp( + "import { env } from 'cloudflare:workers';\nexport const GET = () => Response.json({ env: typeof env });\n", + ); + + await expect(buildApp(root)).rejects.toThrow( + "target: 'cloudflare:workers' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by routes/api/ping.ts.", + ); + }); + }); + + // The package entry is the whole Vite plugin; server code needs only its handler helpers. + it.each([ + ['sveltekit', '@sveltejs/kit', 'ssr', true], + ['sveltekit', '@sveltejs/kit', 'client', false], + ['astro', 'astro', 'ssr', true], + ['astro', 'astro', 'client', false], + ] as const)( + 'sends bare void from %s %s code in the %s environment to the handler entry: %s', + async (_name, dependency, environment, redirected) => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { [dependency]: '*', void: '*' } }), + ); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { '.': './index.mjs' } }), + ); + writeFileSync(join(installed, 'index.mjs'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const resolved = await server.environments[environment]!.pluginContainer.resolveId( + 'void', + join(root, 'src/hooks.server.ts'), + ); + + expect(resolved?.id).toBe( + normalizePath( + redirected + ? createRequire(import.meta.url).resolve('void/handler') + : join(installed, 'index.mjs'), + ), + ); + } finally { + await server.close(); + } + }, + ); +}); + +describe('Astro preview', () => { + // `astro preview` loads the Astro config, which calls voidPlugin(), but runs none of its hooks. + it.each([ + ['preview', true], + ['dev', false], + ] as const)( + "puts back the build's Wrangler deploy config for `astro %s`: %s", + (command, restored) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), + ); + rmSync(join(root, 'void.json')); + writeFileSync(join(root, 'void.config.ts'), 'export default {};\n'); + const pointer = '{"configPath":"../../dist/server/wrangler.json"}'; + mkdirSync(join(root, '.void')); + writeFileSync(join(root, '.void/preview-deploy-config.json'), pointer); + vi.spyOn(process, 'cwd').mockReturnValue(root); + // Run the process exit cleanup below, before the temp root is removed. + const once = vi.spyOn(process, 'once').mockReturnValue(process); + const argv = process.argv; + process.argv = [argv[0]!, 'astro.mjs', command]; + try { + voidPlugin(); + } finally { + process.argv = argv; + } + + const deployConfig = join(root, '.wrangler/deploy/config.json'); + expect(existsSync(deployConfig) ? readFileSync(deployConfig, 'utf8') : undefined).toBe( + restored ? pointer : undefined, + ); + // As after a Vite preview, a deploy then finds no implicit Wrangler config. + for (const [event, listener] of once.mock.calls) { + if (event === 'exit') { + (listener as () => void)(); + } + } + expect(existsSync(deployConfig)).toBe(false); + }, + ); +}); + +describe('Astro request scope', () => { + const entry = '@astrojs/cloudflare/entrypoints/server'; + const scopedEntry = '\0void:astro-server-entry'; + + it.each([ + ['pg', true], + ['mysql', true], + [undefined, false], + ] as const)( + 'runs the Cloudflare adapter entry in a Void request scope (database: %s): %s', + async (database, scoped) => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database, worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const container = server.environments.ssr!.pluginContainer; + const resolved = await container.resolveId(entry); + expect(resolved?.id === scopedEntry).toBe(scoped); + if (!scoped) { + return; + } + // The wrapper imports the adapter's own entry. + expect((await container.resolveId(entry, scopedEntry))?.id).not.toBe(scopedEntry); + + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + writeFileSync( + join(root, 'adapter-entry.mjs'), + `import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envUrl)}; +export const named = "kept"; +export default { + marker: "kept", + fetch(request, env, ctx) { + // Throws outside a request scope. + getRuntimeRequestResource({}, () => ({})); + return Response.json({ url: request.url, database: getRuntimeBinding("DATABASE_URL"), ctx }); + }, +}; +`, + ); + const loaded = await container.load(scopedEntry); + const code = (typeof loaded === 'string' ? loaded : (loaded?.code ?? '')) + .replaceAll(JSON.stringify(entry), '"./adapter-entry.mjs"') + .replace( + /^import \{ withRuntimeEnv \} from .*;$/m, + `import { withRuntimeEnv } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'entry.mjs'), code); + const wrapper = (await import(pathToFileURL(join(root, 'entry.mjs')).href)) as { + named: string; + default: { + marker: string; + fetch(request: Request, env: unknown, ctx: unknown): Promise; + }; + }; + + expect([wrapper.named, wrapper.default.marker]).toEqual(['kept', 'kept']); + const response = await wrapper.default.fetch( + new Request('http://localhost/api'), + { DATABASE_URL: 'postgres://request' }, + { id: 'ctx' }, + ); + expect(await response.json()).toEqual({ + url: 'http://localhost/api', + database: 'postgres://request', + ctx: { id: 'ctx' }, + }); + } finally { + await server.close(); + } + }, + ); }); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 11031b0db..185e71e45 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,38 +1,305 @@ -import { describe, expect, it, vi } from 'vite-plus/test'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { runLocalVoidAuthMigrations } from '../../src/auth/node.ts'; import { applyMySqlMigrations, readMySqlAppliedMigrations, + runMySqlMigrations, type MySqlClient, } from '../../src/migrations/runner-mysql.ts'; +import { runVoidAuthMigrations } from '../../src/runtime/better-auth-mysql.ts'; import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; +import { holdMySqlMigrationLease } from '../../src/runtime/migration-lock-mysql.ts'; -const mysqlQueries = vi.hoisted(() => [] as Array); +const mysqlConnections = vi.hoisted(() => [] as Array); +const mysqlServer = vi.hoisted(() => ({ database: undefined as unknown })); +// Every connection the hosted handler opens reaches the test's FakeMySqlDatabase. vi.mock('mysql2/promise', () => ({ - createConnection: async () => ({ - query: async (sql: string) => { - mysqlQueries.push(sql); - return [[]]; - }, - end: async () => {}, - }), + createConnection: async (options: unknown) => { + mysqlConnections.push(options); + const database = mysqlServer.database as FakeMySqlDatabase; + const session = database.connect({ database: database.handlerDatabase }); + return { + query: async (sql: string, params?: Array) => [ + await database.query(session, sql, params), + ], + end: async () => database.disconnect(session), + }; + }, })); -describe('MySQL migration runner', () => { - it('applies only pending migrations and records them with MySQL placeholders', async () => { - const calls: Array<{ sql: string; params?: Array }> = []; - const client: MySqlClient = { - async query> = Array>>( +const LOCK_NAME = `void_migrations:${createHash('sha1').update('app').digest('hex')}`; +const CONTROL = + /^(BEGIN|COMMIT|ROLLBACK)$|SAVEPOINT|GET_LOCK|RELEASE_LOCK|IS_USED_LOCK|DATABASE\(\)|_void_migration_lock/; + +function mySqlError(message: string, code: string): Error { + return Object.assign(new Error(message), { code }); +} + +/** `expiresAt` is the stored DATETIME, which has no time zone, in milliseconds. */ +type Lease = { owner: string; expiresAt: number }; +type Session = { utcOffsetMs?: number; database?: string }; + +/** + * One MySQL database shared by every connection and client of a test. It keeps the tracking rows, + * the deploy lease and named locks, and logs each statement with its session and time. + * `SELECT SLEEP(n)` waits n seconds, so runs can overlap. Each session has a time zone, which + * NOW(6) follows and UTC_TIMESTAMP(6) does not, and the name DATABASE() reports. + */ +class FakeMySqlDatabase { + readonly log: Array<{ session: number; sql: string; at: number }> = []; + readonly failures = new Map(); + /** Statements that fail on their next run only, keyed by their first characters. */ + readonly failOnce = new Map(); + readonly applied: Array = []; + lease: Lease | undefined; + /** DATABASE() for the sessions the hosted handler opens. */ + readonly handlerDatabase: string; + private leaseTable = false; + private readonly locks = new Map(); + private readonly sessions: Array = []; + + constructor(options: { lease?: Lease; handlerDatabase?: string } = {}) { + this.lease = options.lease; + this.leaseTable = options.lease !== undefined; + this.handlerDatabase = options.handlerDatabase ?? 'app'; + } + + connect(session: Session = {}): number { + return this.sessions.push(session); + } + + disconnect(session: number): void { + for (const [name, holder] of this.locks) { + if (holder === session) { + this.locks.delete(name); + } + } + } + + client(options: { utcOffsetMs?: number } = {}): MySqlClient { + const session = this.connect(options); + return { + query: async >>( sql: string, params?: Array, - ): Promise { - calls.push({ sql, params }); - if (sql.startsWith('SELECT')) { - return [{ name: '001_initial.sql' }] as unknown as T; - } - return [] as unknown as T; + ) => { + const result = await this.query(session, sql, params); + return (Array.isArray(result) ? result : []) as T; }, }; + } + + statements(session?: number): Array { + return this.log + .filter((entry) => session === undefined || entry.session === session) + .map((entry) => entry.sql); + } + + async query(session: number, sql: string, params: Array = []): Promise { + this.log.push({ session, sql, at: Date.now() }); + const failure = this.failures.get(sql); + if (failure) { + throw failure; + } + for (const [prefix, error] of this.failOnce) { + if (sql.startsWith(prefix)) { + this.failOnce.delete(prefix); + throw error; + } + } + const sleep = /^SELECT SLEEP\((\d+(?:\.\d+)?)\)/.exec(sql); + if (sleep) { + await new Promise((resolve) => setTimeout(resolve, Number(sleep[1]) * 1000)); + return [{ slept: 0 }]; + } + const name = String(params[0]); + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: this.sessions[session - 1]!.database ?? 'app' }]; + } + if (sql.startsWith('SELECT GET_LOCK')) { + const holder = this.locks.get(name); + if (holder !== undefined && holder !== session) { + return [{ acquired: 0 }]; + } + this.locks.set(name, session); + return [{ acquired: 1 }]; + } + if (sql.startsWith('SELECT RELEASE_LOCK')) { + if (this.locks.get(name) === session) { + this.locks.delete(name); + } + return [{ released: 1 }]; + } + if (sql.startsWith('SELECT IS_USED_LOCK')) { + return [{ [`IS_USED_LOCK('${name}')`]: this.locks.get(name) ?? null }]; + } + if (sql.includes('`_void_migration_lock`')) { + return this.leaseStatement(session, sql, params); + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + return this.applied.map((applied) => ({ name: applied })); + } + if (sql.startsWith('INSERT INTO `_void_migrations`')) { + if (this.applied.includes(name)) { + throw mySqlError(`Duplicate entry '${name}' for key 'PRIMARY'`, 'ER_DUP_ENTRY'); + } + this.applied.push(name); + } + return /^\s*SELECT\b/i.test(sql) ? [] : { affectedRows: 1 }; + } + + private leaseStatement(session: number, sql: string, params: Array): unknown { + const now = Date.now(); + const clock = /UTC_TIMESTAMP\(6\)/.test(sql) + ? now + : now + (this.sessions[session - 1]!.utcOffsetMs ?? 0); + // The statement's own expiry filter, if it has one. + const filter = /`expires_at` ([<>]) (?:NOW|UTC_TIMESTAMP)\(6\)/.exec(sql)?.[1]; + const matches = (lease: Lease) => + filter === undefined || (filter === '<' ? lease.expiresAt < clock : lease.expiresAt > clock); + if (sql.startsWith('CREATE TABLE')) { + this.leaseTable = true; + return { affectedRows: 0 }; + } + if (!this.leaseTable) { + throw mySqlError("Table 'app._void_migration_lock' doesn't exist", 'ER_NO_SUCH_TABLE'); + } + if (sql.startsWith('SELECT TIMESTAMPDIFF')) { + return this.lease && matches(this.lease) + ? [{ remaining: Math.floor((this.lease.expiresAt - clock) / 1000) }] + : []; + } + if (sql.startsWith('INSERT IGNORE')) { + if (this.lease) { + return { affectedRows: 0 }; + } + this.lease = { owner: String(params[0]), expiresAt: clock + Number(params[1]) * 1000 }; + return { affectedRows: 1 }; + } + if (sql.startsWith('UPDATE') && sql.includes('SET `owner`')) { + if (!this.lease || !matches(this.lease)) { + return { affectedRows: 0 }; + } + this.lease = { owner: String(params[0]), expiresAt: clock + Number(params[1]) * 1000 }; + return { affectedRows: 1 }; + } + // The release keeps the row and moves its expiry into the past. + if (sql.startsWith('UPDATE') && sql.includes('- INTERVAL 1 SECOND')) { + if (!this.lease || this.lease.owner !== params[0]) { + return { affectedRows: 0 }; + } + this.lease.expiresAt = clock - 1000; + return { affectedRows: 1 }; + } + if (sql.startsWith('UPDATE')) { + if (!this.lease || !matches(this.lease) || this.lease.owner !== params[1]) { + return { affectedRows: 0 }; + } + this.lease.expiresAt = clock + Number(params[0]) * 1000; + return { affectedRows: 1 }; + } + throw new Error(`unexpected lease statement: ${sql}`); + } +} + +function useDatabase(database: FakeMySqlDatabase): FakeMySqlDatabase { + mysqlServer.database = database; + return database; +} + +beforeEach(() => { + mysqlConnections.length = 0; + useDatabase(new FakeMySqlDatabase()); +}); + +async function migrate( + migrations: Array<{ name: string; sql: string }>, + runAuthMigrations?: () => Promise, +): Promise<{ status: number; body: Record }> { + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + migrations, + runAuthMigrations, + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + return { status: response.status, body: (await response.json()) as Record }; +} + +/** Runs `start` to its end while fake time moves on one second at a time. */ +async function withFakeTime(start: () => Promise): Promise { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + try { + let settled = false; + const promise = start().finally(() => { + settled = true; + }); + while (!settled) { + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(1000); + } + return await promise; + } finally { + vi.useRealTimers(); + } +} + +/** + * A client for `applyMySqlMigrations`. It owns database `app`, grants the lock unless `locked`, + * and throws for statements in `failures`. With `committed`, MySQL has already committed the + * open transaction (after DDL), so rolling back to a savepoint fails as it does in MySQL. + */ +function fakeClient( + options: { + applied?: Array; + failures?: Record; + locked?: boolean; + committed?: boolean; + } = {}, +) { + const calls: Array<{ sql: string; params?: Array }> = []; + const client: MySqlClient = { + async query> = Array>>( + sql: string, + params?: Array, + ): Promise { + calls.push({ sql, params }); + const failure = options.failures?.[sql]; + if (failure) { + throw failure; + } + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: 'app' }] as unknown as T; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [{ acquired: options.locked ? 0 : 1 }] as unknown as T; + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + return (options.applied ?? []).map((name) => ({ name })) as unknown as T; + } + if (sql.startsWith('ROLLBACK TO SAVEPOINT') && options.committed) { + throw mySqlError('SAVEPOINT void_migration does not exist', 'ER_SP_DOES_NOT_EXIST'); + } + return [] as unknown as T; + }, + }; + return { client, calls, sql: () => calls.map((call) => call.sql) }; +} + +describe('MySQL migration runner', () => { + it('applies only pending migrations and records them with MySQL placeholders', async () => { + const { client, calls } = fakeClient({ applied: ['001_initial.sql'] }); const applied = await applyMySqlMigrations(client, [ { name: '001_initial.sql', sql: 'CREATE TABLE existing (id INT)' }, @@ -77,31 +344,758 @@ describe('MySQL migration runner', () => { 'CREATE TABLE `a` (`id` int);\n--> statement-breakpoint\nCREATE INDEX `a_id_idx` ON `a` (`id`);'; const statements = ['CREATE TABLE `a` (`id` int);', 'CREATE INDEX `a_id_idx` ON `a` (`id`);']; const migrationQueries = (queries: Array) => - queries.filter((query) => !query.includes('_void_migrations')); + queries.filter((query) => !query.includes('_void_migrations') && !CONTROL.test(query)); + + const { client, sql: calls } = fakeClient(); + await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); + expect(migrationQueries(calls())).toEqual(statements); + + const database = useDatabase(new FakeMySqlDatabase()); + expect((await migrate([{ name: '001_init.sql', sql }])).body).toMatchObject({ ok: true }); + expect(migrationQueries(database.statements())).toEqual(statements); + }); + + // Hyperdrive takes one statement per query, and the repair note counts the statements that ran. + it('runs each statement of a file without breakpoints as its own query', async () => { + const sql = '-- hand-written\nCREATE TABLE a (id int);\nCREATE TABLE b (id int); -- done\n'; + const statements = ['CREATE TABLE a (id int);', 'CREATE TABLE b (id int);']; + const migrationQueries = (queries: Array) => + queries.filter((query) => !query.includes('_void_migrations') && !CONTROL.test(query)); + + const { client, sql: calls } = fakeClient(); + await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); + expect(migrationQueries(calls())).toEqual(statements); + + const database = useDatabase(new FakeMySqlDatabase()); + expect((await migrate([{ name: '001_init.sql', sql }])).body).toMatchObject({ ok: true }); + expect(migrationQueries(database.statements())).toEqual(statements); + }); - const calls: Array = []; + // Two runs at once would both apply a pending data migration. + it('holds a named lock for the database while it reads and applies migrations', async () => { + const { client, calls, sql } = fakeClient(); + + await applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]); + + expect(calls[1]).toEqual({ sql: 'SELECT GET_LOCK(?, ?) AS acquired', params: [LOCK_NAME, 10] }); + expect(sql().indexOf('SELECT `name` FROM `_void_migrations`')).toBeGreaterThan(1); + expect(calls.at(-1)).toEqual({ sql: 'SELECT RELEASE_LOCK(?)', params: [LOCK_NAME] }); + }); + + // Vitess (PlanetScale) names the GET_LOCK column after the call and ignores the alias. + it('takes the lock when the server ignores the column alias', async () => { const client: MySqlClient = { async query> = Array>>( - query: string, + sql: string, ): Promise { - calls.push(query); + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: 'app' }] as unknown as T; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [{ [`get_lock('${LOCK_NAME}', 10)`]: 1 }] as unknown as T; + } return [] as unknown as T; }, }; - await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); - expect(migrationQueries(calls)).toEqual(statements); - const handler = createMigrationHandler( - { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, - [{ name: '001_init.sql', sql }], + await expect( + applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]), + ).resolves.toEqual(['001_bump.sql']); + }); + + it('stops before reading or applying migrations when another run holds the lock', async () => { + const { client, sql } = fakeClient({ locked: true }); + + await expect( + applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]), + ).rejects.toThrow( + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then run the command again.', + ); + expect(sql()).toEqual(['SELECT DATABASE() AS db', 'SELECT GET_LOCK(?, ?) AS acquired']); + }); + + it('commits each file with its tracking row and rolls back a failed file', async () => { + const cause = mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'); + const { client, sql } = fakeClient({ failures: { 'INSERT INTO nope VALUES (1);': cause } }); + + const error = await applyMySqlMigrations(client, [ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', + }, + ]).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "migration 002_backfill.sql failed: Table 'app.nope' doesn't exist", + ); + expect((error as Error).cause).toBe(cause); + const migrationQueries = sql().filter( + (query) => + !/GET_LOCK|DATABASE\(\)|CREATE TABLE IF NOT EXISTS|SELECT `name`|_void_migration_lock/.test( + query, + ), + ); + expect(migrationQueries).toEqual([ + 'BEGIN', + 'CREATE TABLE c (n INT);', + 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', + 'COMMIT', + 'BEGIN', + 'UPDATE c SET n = n + 1;', + 'INSERT INTO nope VALUES (1);', + 'SAVEPOINT `void_migration`', + 'ROLLBACK TO SAVEPOINT `void_migration`', + 'ROLLBACK', + 'SELECT RELEASE_LOCK(?)', + ]); + }); + + it.each([ + 'CREATE TABLE comments (id INT);\n--> statement-breakpoint\nALTER TABLE c ADD INDEX i (nope);', + 'CREATE TABLE comments (id INT);\nALTER TABLE c ADD INDEX i (nope);', + ])( + 'says how to repair a file whose earlier statements MySQL already committed: %j', + async (sql) => { + const cause = mySqlError( + "Key column 'nope' doesn't exist in table", + 'ER_KEY_COLUMN_DOES_NOT_EXITS', + ); + const { client } = fakeClient({ + committed: true, + failures: { 'ALTER TABLE c ADD INDEX i (nope);': cause }, + }); + + await expect( + applyMySqlMigrations(client, [{ name: '002_comments.sql', sql }]), + ).rejects.toThrow( + [ + "migration 002_comments.sql failed: Key column 'nope' doesn't exist in table", + 'MySQL commits schema changes immediately, so earlier statements in 002_comments.sql may already be applied. Undo them, fix the file, then run the command again.', + 'See https://void.cloud/guide/database/mysql#failed-migrations', + ].join('\n'), + ); + }, + ); + + it('does not warn about earlier statements when the first statement fails', async () => { + const cause = mySqlError("Duplicate column name 'n'", 'ER_DUP_FIELDNAME'); + const { client, sql } = fakeClient({ + committed: true, + failures: { 'ALTER TABLE c ADD COLUMN n INT;': cause }, + }); + + await expect( + applyMySqlMigrations(client, [ + { name: '002_dup.sql', sql: 'ALTER TABLE c ADD COLUMN n INT;' }, + ]), + ).rejects.toThrow(/^migration 002_dup\.sql failed: Duplicate column name 'n'$/); + expect(sql()).not.toContain('SAVEPOINT `void_migration`'); + }); + + // MySQL rolls back the whole transaction itself here, so the file left nothing behind. + it.each([ + { + failing: 'UPDATE d SET n = n + 1;', + cause: Object.assign( + mySqlError( + 'Deadlock found when trying to get lock; try restarting transaction', + 'ER_LOCK_DEADLOCK', + ), + { errno: 1213 }, + ), + }, + { + failing: 'COMMIT', + cause: Object.assign( + mySqlError( + 'Plugin instructed the server to rollback the current transaction.', + 'ER_TRANSACTION_ROLLBACK_DURING_COMMIT', + ), + { errno: 3101 }, + ), + }, + ])( + 'does not warn about earlier statements when MySQL rolled back $cause.code', + async ({ failing, cause }) => { + const { client, sql } = fakeClient({ committed: true, failures: { [failing]: cause } }); + + const error = await applyMySqlMigrations(client, [ + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nUPDATE d SET n = n + 1;', + }, + ]).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe(`migration 002_backfill.sql failed: ${cause.message}`); + expect((error as Error).cause).toBe(cause); + expect(sql()).toContain('ROLLBACK'); + }, + ); + + // A lost connection makes ROLLBACK fail too; the error must still name the file. + it('names the failed file when the rollback fails too', async () => { + const cause = mySqlError( + 'Connection lost: The server closed the connection.', + 'PROTOCOL_CONNECTION_LOST', + ); + const { client, sql } = fakeClient({ + failures: { + 'UPDATE c SET n = n + 1;': cause, + ROLLBACK: new Error("Can't add new command when connection is in closed state"), + }, + }); + + const error = await applyMySqlMigrations(client, [ + { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, + ]).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe(`migration 002_backfill.sql failed: ${cause.message}`); + expect((error as Error).cause).toBe(cause); + expect(sql().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + }); +}); + +describe('MySQL hosted migration handler', () => { + it('commits each file with its tracking row and rolls back a failed file', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( + 'INSERT INTO nope VALUES (1);', + mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'), + ); + + const { body } = await migrate([ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', + }, + ]); + + expect(body).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { name: '002_backfill.sql', applied: false, error: "Table 'app.nope' doesn't exist" }, + ], + }); + // Session 1 runs the migrations; the lease uses its own connection. + expect(database.statements(1).slice(2)).toEqual([ + 'BEGIN', + 'CREATE TABLE c (n INT);', + 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', + 'COMMIT', + 'BEGIN', + 'UPDATE c SET n = n + 1;', + 'INSERT INTO nope VALUES (1);', + 'ROLLBACK', + ]); + }); + + it('names the failed file when the rollback fails too', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( + 'UPDATE c SET n = n + 1;', + mySqlError('Connection lost: The server closed the connection.', 'PROTOCOL_CONNECTION_LOST'), + ); + database.failures.set( + 'ROLLBACK', + new Error("Can't add new command when connection is in closed state"), ); - const response = await handler( - new Request('http://localhost/__void/migrate', { - method: 'POST', - headers: { 'x-void-internal': 'token' }, - }), + + const { body } = await migrate([ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, + ]); + + expect(body).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { + name: '002_backfill.sql', + applied: false, + error: 'Connection lost: The server closed the connection.', + }, + ], + }); + }); + + // A stored program keeps the rest of its part, and one query here takes one statement, so the + // server rejects SQL after the program as a syntax error. + const programThenInsert = 'CREATE PROCEDURE p() BEGIN SELECT 1; END;\nINSERT INTO c VALUES (1);'; + const breakpointNote = + 'This deploy runs one statement per query. If 002_p.sql has more SQL after a trigger, procedure, function or event, add a `--> statement-breakpoint` line after it.'; + it.each([ + { + server: 'MySQL', + cause: Object.assign( + mySqlError( + "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'INSERT INTO c VALUES (1)' at line 2", + 'ER_PARSE_ERROR', + ), + { errno: 1064 }, + ), + }, + { + server: 'Vitess', + cause: Object.assign( + mySqlError("syntax error at position 50 near 'INSERT'", 'ER_UNKNOWN_ERROR'), + { errno: 1105 }, + ), + }, + ])('asks for a breakpoint after a stored program on $server', async ({ cause }) => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set(programThenInsert, cause); + + const { body } = await migrate([ + { name: '002_p.sql', sql: `CREATE TABLE c (n INT);\n${programThenInsert}` }, + ]); + + expect(body).toMatchObject({ + ok: false, + migrations: [{ name: '002_p.sql', error: `${cause.message}\n${breakpointNote}` }], + }); + }); + + it('does not ask for a breakpoint for other failures', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const syntax = Object.assign( + mySqlError("You have an error in your SQL syntax near 'TABL c' at line 1", 'ER_PARSE_ERROR'), + { errno: 1064 }, + ); + const missing = Object.assign(mySqlError("Table 'app.c' doesn't exist", 'ER_NO_SUCH_TABLE'), { + errno: 1146, + }); + database.failures.set('CREATE TABL c (n INT);', syntax); + database.failures.set(programThenInsert, missing); + + const plain = await migrate([{ name: '001_c.sql', sql: 'CREATE TABL c (n INT);' }]); + const program = await migrate([{ name: '002_p.sql', sql: programThenInsert }]); + + expect(plain.body).toMatchObject({ migrations: [{ error: syntax.message }] }); + expect(program.body).toMatchObject({ migrations: [{ error: missing.message }] }); + }); +}); + +// The CLI runner holds a named lock; a deploy, which reaches MySQL through Hyperdrive, holds a +// lease row. Each checks for the other, so runs on one database never apply a file twice. +describe('MySQL migration lock across runners', () => { + const RACE = [ + { + name: '0001_seed.sql', + sql: 'SELECT SLEEP(0.2);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS roles (name TEXT);\n--> statement-breakpoint\nINSERT INTO roles VALUES (1);', + }, + ]; + const seeded = (database: FakeMySqlDatabase) => + database.statements().filter((sql) => sql === 'INSERT INTO roles VALUES (1);').length; + + it('applies a pending file once when two deploys share a database', async () => { + const { database, bodies, finishedAt } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + // The second starts while the first sleeps, before either has recorded the file. + const first = migrate(RACE); + await new Promise((resolve) => setTimeout(resolve, 20)); + const results = await Promise.all([first, migrate(RACE)]); + return { database, bodies: results.map((result) => result.body), finishedAt: Date.now() }; + }); + + expect(seeded(database)).toBe(1); + expect(bodies.map((body) => body.ok)).toEqual([true, true]); + // Released by expiring the row: the next run's INSERT IGNORE finds it and takes it over. + expect(database.lease?.expiresAt).toBeLessThan(finishedAt); + }); + + it('applies a pending file once when a CLI run and a deploy share a database', async () => { + const { database, cli, deploy } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const [cli, deploy] = await Promise.all([ + applyMySqlMigrations(database.client(), RACE), + migrate(RACE), + ]); + return { database, cli, deploy }; + }); + + expect(seeded(database)).toBe(1); + expect(deploy.body.ok).toBe(true); + expect(cli).toEqual(['0001_seed.sql']); + }); + + // Vitess reports a connection's target in DATABASE(): Hyperdrive may connect to `app@primary` + // while the CLI connects to `app`. + it('makes a deploy wait for a CLI run that names a Vitess target', async () => { + const { database, deploy } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase({ handlerDatabase: 'app@primary' })); + const [, deploy] = await Promise.all([ + applyMySqlMigrations(database.client(), RACE), + new Promise((resolve) => setTimeout(resolve, 100)).then(() => migrate(RACE)), + ]); + return { database, deploy }; + }); + + expect(seeded(database)).toBe(1); + expect(deploy.body.ok).toBe(true); + }); + + it('stops a CLI run while a deploy holds the lease', async () => { + const error = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'deploy', expiresAt: Date.now() + 100_000 } }), + ); + const failure = await applyMySqlMigrations(database.client(), RACE).catch( + (caught: unknown) => caught, + ); + expect(database.statements()).not.toContain('SELECT `name` FROM `_void_migrations`'); + expect(database.statements().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + return failure; + }); + + expect((error as Error).message).toBe( + 'A deploy is applying migrations to MySQL database "app". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in 90 seconds.', + ); + }); + + it('stops a CLI run while a deploy holds the lease in another time zone', async () => { + const error = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'deploy', expiresAt: Date.now() + 100_000 } }), + ); + const failure = await applyMySqlMigrations( + database.client({ utcOffsetMs: 9 * 3_600_000 }), + RACE, + ).catch((caught: unknown) => caught); + expect(database.statements()).not.toContain('SELECT `name` FROM `_void_migrations`'); + return failure; + }); + + expect((error as Error).message).toBe( + 'A deploy is applying migrations to MySQL database "app". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in 90 seconds.', + ); + }); + + it('lets a CLI run apply after a deploy released its lease', async () => { + const applied = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'done', expiresAt: Date.now() - 1000 } }), + ); + return await applyMySqlMigrations(database.client(), [ + { name: '0001_a.sql', sql: 'CREATE TABLE a (id INT)' }, + ]); + }); + + expect(applied).toEqual(['0001_a.sql']); + }); + + it('makes a deploy wait for the lease of a run that stopped early to expire', async () => { + const { database, expiresAt, finishedAt, result } = await withFakeTime(async () => { + const expiresAt = Date.now() + 30_000; + const database = useDatabase(new FakeMySqlDatabase({ lease: { owner: 'gone', expiresAt } })); + const result = await migrate(RACE); + return { database, expiresAt, finishedAt: Date.now(), result }; + }); + + expect(result.body.ok).toBe(true); + const seededAt = database.log.find((entry) => entry.sql === 'INSERT INTO roles VALUES (1);'); + expect(seededAt?.at).toBeGreaterThanOrEqual(expiresAt); + expect(database.lease?.owner).not.toBe('gone'); + expect(database.lease?.expiresAt).toBeLessThan(finishedAt); + }); + + it('keeps waiting when MySQL ends its lease insert as a deadlock victim', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failOnce.set( + 'INSERT IGNORE', + Object.assign( + mySqlError( + 'Deadlock found when trying to get lock; try restarting transaction', + 'ER_LOCK_DEADLOCK', + ), + { errno: 1213 }, + ), + ); + return { database, result: await migrate(RACE) }; + }); + + expect(result.body.ok).toBe(true); + expect(seeded(database)).toBe(1); + }); + + it('fails a deploy that waited too long for another run', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'other', expiresAt: Date.now() + 3_600_000 } }), + ); + return { database, result: await migrate(RACE) }; + }); + + expect(result).toEqual({ + status: 500, + body: { + ok: false, + error: + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then deploy again.', + migrations: [], + }, + }); + expect(seeded(database)).toBe(0); + expect(database.lease?.owner).toBe('other'); + }); + + // Hyperdrive ends statements after 60 seconds, so this only happens if that limit changes. + it('stops a deploy whose lease expired during a statement', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const result = await migrate([ + { + name: '0001_slow.sql', + sql: 'SELECT SLEEP(200);\n--> statement-breakpoint\nUPDATE c SET n = n + 1;', + }, + ]); + return { database, result }; + }); + + expect(result.body).toMatchObject({ + ok: false, + migrations: [ + { + name: '0001_slow.sql', + applied: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + }, + ], + }); + expect(database.statements()).not.toContain('UPDATE c SET n = n + 1;'); + }); +}); + +// Better Auth reads the schema, then adds what is missing, so two runs at once both add it. +describe('MySQL Better Auth migrations under the lock', () => { + it("runs a deploy's Better Auth migrations before another deploy takes the lease", async () => { + const { bodies, runs } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const runs: Array<{ start: number; end: number }> = []; + const auth = async () => { + const start = Date.now(); + await database.query(database.connect(), 'SELECT SLEEP(5)'); + runs.push({ start, end: Date.now() }); + }; + // An app without migration files: the lease covers only the auth migrations. + const first = migrate([], auth); + await new Promise((resolve) => setTimeout(resolve, 20)); + const results = await Promise.all([first, migrate([], auth)]); + return { bodies: results.map((result) => result.body), runs }; + }); + + expect(bodies.map((body) => body.ok)).toEqual([true, true]); + expect(runs).toHaveLength(2); + expect(runs[1]!.start).toBeGreaterThanOrEqual(runs[0]!.end); + }); + + it('keeps the lease while Better Auth migrations outlast it', async () => { + const { first, second, runs } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + let runs = 0; + const auth = async () => { + runs++; + await database.query(database.connect(), 'SELECT SLEEP(300)'); + }; + const pending = migrate([], auth); + await new Promise((resolve) => setTimeout(resolve, 20)); + const [first, second] = await Promise.all([pending, migrate([], auth)]); + return { first, second, runs }; + }); + + expect(first.body.ok).toBe(true); + expect(second.body).toEqual({ + ok: false, + error: + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then deploy again.', + migrations: [], + }); + expect(runs).toBe(1); + }); + + it('fails a deploy whose lease another run took during Better Auth migrations', async () => { + const result = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + return migrate([], async () => { + database.lease = { owner: 'other', expiresAt: Date.now() + 3_600_000 }; + await database.query(database.connect(), 'SELECT SLEEP(40)'); + }); + }); + + expect(result).toEqual({ + status: 500, + body: { + ok: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + migrations: [], + }, + }); + }); + + it('skips Better Auth migrations after a failed file and releases the lease', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( + 'INSERT INTO nope VALUES (1);', + mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'), + ); + let authRuns = 0; + + const { status, body } = await migrate( + [{ name: '001_backfill.sql', sql: 'INSERT INTO nope VALUES (1);' }], + async () => { + authRuns++; + }, + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ ok: false, error: 'migration 001_backfill.sql failed' }); + expect(authRuns).toBe(0); + expect(database.lease!.expiresAt).toBeLessThan(Date.now()); + }); + + it('releases the lease when Better Auth migrations fail', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + + const result = await migrate([], async () => { + throw new Error('Better Auth migration failed'); + }); + + expect(result).toEqual({ + status: 500, + body: { ok: false, error: 'Better Auth migration failed', migrations: [] }, + }); + expect(database.lease!.expiresAt).toBeLessThan(Date.now()); + }); + + it('does not start Better Auth migrations after another run took the lease', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const query = database.query.bind(database); + // Another run takes the lease while this one reads its applied files. + vi.spyOn(database, 'query').mockImplementation(async (session, sql, params) => { + if (sql === 'SELECT `name` FROM `_void_migrations`') { + database.lease = { owner: 'other', expiresAt: Date.now() + 3_600_000 }; + } + return query(session, sql, params); + }); + let ran = false; + + const result = await migrate([], async () => { + ran = true; + }); + + expect(ran).toBe(false); + expect(result.body).toMatchObject({ + ok: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + }); + }); + + it('fails when a renewal still in flight at the end of Better Auth migrations fails', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + let renewals = 0; + let failRenewal: ((error: Error) => void) | undefined; + const lease = { + // The first renewal runs before the work; the timer's renewal is still waiting on MySQL. + renew: () => + renewals++ === 0 + ? Promise.resolve() + : new Promise((_resolve, reject) => { + failRenewal = reject; + }), + release: async () => {}, + }; + let finish: (() => void) | undefined; + const outcome = holdMySqlMigrationLease( + lease, + () => + new Promise((resolve) => { + finish = resolve; + }), + ).then( + () => 'done', + (error: Error) => error.message, + ); + + await vi.advanceTimersByTimeAsync(30_000); + finish!(); + await new Promise((resolve) => setImmediate(resolve)); + failRenewal!(new Error('lease taken')); + + expect(await outcome).toBe('lease taken'); + } finally { + vi.useRealTimers(); + } + }); + + // `vp dev` runs Better Auth's migrations this way when they change the database of the files. + it('runs follow-up work in a CLI run before it releases the named lock', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + let holder: unknown; + await runMySqlMigrations( + join(tmpdir(), 'void-no-migrations'), + 'mysql://localhost/app', + async () => { + const [row] = (await database.query(database.connect(), 'SELECT IS_USED_LOCK(?)', [ + LOCK_NAME, + ])) as Array>; + holder = Object.values(row ?? {})[0]; + }, + ); + + expect(holder).toEqual(expect.any(Number)); + expect(database.statements().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + }); + + // `vp dev` passes the database it holds the lock of. + it('runs local Better Auth migrations on the database it is given', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-url-')); + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://localhost/env\n'); + vi.stubEnv('DATABASE_URL', 'mysql://localhost/shell'); + try { + await runLocalVoidAuthMigrations( + root, + 'mysql', + join(root, '.void'), + 'local', + null, + 'mysql://localhost/locked', + ); + } finally { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + + expect(mysqlConnections).toEqual(['mysql://localhost/locked']); + }); +}); + +describe('MySQL connections in Workers', () => { + // Workers forbid eval, which mysql2 uses to build row parsers unless disableEval is set. + it('opens the hosted migration connections with eval disabled', async () => { + expect((await migrate([])).body).toMatchObject({ ok: true }); + // Hyperdrive does not take several statements in one query. + expect(mysqlConnections).toEqual([ + { uri: 'mysql://localhost/app', disableEval: true }, + { uri: 'mysql://localhost/app', disableEval: true }, + ]); + }); + + it('opens the Better Auth migration connection with eval disabled', async () => { + await runVoidAuthMigrations( + { DATABASE_URL: 'mysql://localhost/auth' }, + { dialect: 'mysql', providers: null }, + { dialect: 'mysql', idStrategy: 'text', tables: [] }, ); - expect(await response.json()).toMatchObject({ ok: true }); - expect(migrationQueries(mysqlQueries)).toEqual(statements); + expect(mysqlConnections).toEqual([{ uri: 'mysql://localhost/auth', disableEval: true }]); }); }); diff --git a/packages/void/test/unit/runner-pg.test.ts b/packages/void/test/unit/runner-pg.test.ts index d37196ab9..4d620c925 100644 --- a/packages/void/test/unit/runner-pg.test.ts +++ b/packages/void/test/unit/runner-pg.test.ts @@ -135,6 +135,38 @@ describe('applyPgMigrations', () => { const tracking = await pglite.query('SELECT name FROM _void_migrations'); expect(tracking.rows).toHaveLength(0); }); + + it('names the failed file and keeps the database error as the cause', async () => { + const migrations = [ + { name: '001_create_users.sql', sql: 'CREATE TABLE users (id SERIAL PRIMARY KEY)' }, + { name: '002_bad.sql', sql: 'INVALID SQL THAT WILL FAIL' }, + ]; + + const error = await applyPgMigrations(client, migrations).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + 'migration 002_bad.sql failed: syntax error at or near "INVALID"', + ); + expect((error as Error).cause).toMatchObject({ code: '42601' }); + }); + + it('names the failed file when the rollback fails too', async () => { + const failing: PgClient = { + async query = Record>(text: string) { + if (text === 'SELECT 1 FROM missing') { + throw new Error('connection lost'); + } + if (text === 'ROLLBACK') { + throw new Error('Client was closed and is not queryable'); + } + return { rows: [] as Array }; + }, + }; + + await expect( + applyPgMigrations(failing, [{ name: '001_x.sql', sql: 'SELECT 1 FROM missing' }]), + ).rejects.toThrow('migration 001_x.sql failed: connection lost'); + }); }); describe('readPgAppliedMigrations', () => { diff --git a/packages/void/test/unit/runner-sqlite.test.ts b/packages/void/test/unit/runner-sqlite.test.ts index 4fee832f1..51efed08b 100644 --- a/packages/void/test/unit/runner-sqlite.test.ts +++ b/packages/void/test/unit/runner-sqlite.test.ts @@ -240,6 +240,19 @@ describe('runMigrations (local SQLite)', () => { } }); + it('names the failed file and keeps the SQLite error as the cause', async () => { + const root = createDir(); + const migDir = join(root, 'migrations'); + const dbPath = join(root, 'test.sqlite'); + writeMigration(migDir, '001_ok.sql', 'CREATE TABLE a (id TEXT PRIMARY KEY)'); + writeMigration(migDir, '002_bad.sql', 'INSERT INTO nope VALUES (1);'); + + const error = await runMigrations(migDir, dbPath).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe('migration 002_bad.sql failed: no such table: nope'); + expect((error as Error).cause).toMatchObject({ code: 'SQLITE_ERROR' }); + }); + it('upgrades a legacy _migrations table into _void_migrations on first run', async () => { const root = createDir(); const migDir = join(root, 'migrations'); diff --git a/packages/void/test/unit/sveltekit-hooks-transform.test.ts b/packages/void/test/unit/sveltekit-hooks-transform.test.ts index d46f7743d..bbb950319 100644 --- a/packages/void/test/unit/sveltekit-hooks-transform.test.ts +++ b/packages/void/test/unit/sveltekit-hooks-transform.test.ts @@ -1,8 +1,11 @@ import { parseSync } from 'vite'; -import { describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getRawRuntimeEnv } from '../../src/runtime/env-raw.ts'; -import { withRuntimeEnv } from '../../src/runtime/env.ts'; -import { injectSvelteKitHooks } from '../../src/sveltekit-hooks-transform.ts'; +import { getRuntimeRequestResource, withRuntimeEnv } from '../../src/runtime/env.ts'; +import { + injectSvelteKitBuildHooks, + injectSvelteKitHooks, +} from '../../src/sveltekit-hooks-transform.ts'; describe('SvelteKit generated hook injection', () => { it('targets get_hooks even when another function returns an object first', () => { @@ -74,4 +77,72 @@ export async function get_hooks ( ) { expect(originalHandle).toHaveBeenCalledTimes(1); expect(load.mock.calls.some(([id]) => id === 'cloudflare:workers')).toBe(major === 3); }); + + describe('in a production build', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ['Kit 2 in workerd', { platform: true, workerd: true, scoped: true }], + ['Kit 3 in workerd', { platform: false, workerd: true, scoped: true }], + // Kit prerenders in Node, which cannot load cloudflare:workers. + ['Kit 3 prerendering in Node', { platform: false, workerd: false, scoped: false }], + ])('runs requests of %s in the request scope it can reach', async (_name, kit) => { + if (kit.workerd) { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + } + const env = { name: 'bindings' }; + // void/db keeps its pool in a request resource like this one. + const resource = {}; + const requestResource = () => + getRuntimeRequestResource( + resource, + () => ({}), + () => null, + ); + const appHandle = vi.fn(() => { + const first = requestResource(); + return Response.json({ scoped: first !== null && first === requestResource() }); + }); + const load = vi.fn(async (id: string) => { + if (id === '/void/env.mjs') { + return { withRuntimeEnv }; + } + if (id === 'cloudflare:workers') { + return { env }; + } + throw new Error(`Unexpected import: ${id}`); + }); + const source = `export async function get_hooks() { + let handle = originalHandle; + return { handle }; + }`; + const result = injectSvelteKitBuildHooks(source, 'server.js', '/void/env.mjs')!; + expect(() => parseSync('server.js', result.code)).not.toThrow(); + const getHooks = new Function( + 'load', + 'originalHandle', + `${result.code.replace(/^export /gm, '').replaceAll('import(', 'load(')}; return get_hooks;`, + )(load, appHandle) as () => Promise<{ + handle: (options: { + event: { request: Request; platform?: { env: typeof env } }; + }) => Promise; + }>; + const { handle } = await getHooks(); + const response = await handle({ + event: { + request: new Request('https://example.com/__void/scheduled'), + ...(kit.platform ? { platform: { env } } : {}), + }, + }); + + // A build has no dev triggers: every request reaches the app's handle. + expect(appHandle).toHaveBeenCalledTimes(1); + expect(await response.json()).toEqual({ scoped: kit.scoped }); + expect(load.mock.calls.some(([id]) => id === 'cloudflare:workers')).toBe( + kit.workerd && !kit.platform, + ); + }); + }); }); diff --git a/packages/void/test/unit/sveltekit-worker-build.test.ts b/packages/void/test/unit/sveltekit-worker-build.test.ts new file mode 100644 index 000000000..53afcfd3e --- /dev/null +++ b/packages/void/test/unit/sveltekit-worker-build.test.ts @@ -0,0 +1,423 @@ +import { spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { getWorkerBundleDir, readBuildOutput } from '@cloudflare/build-output-utils'; +import { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; +import { build } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { prepareCfFrameworkBuildOutput } from '../../src/cli/cf-build-output.ts'; +import { bundleEntry } from '../../src/cli/wrapper.ts'; +import { voidPlugin } from '../../src/index.ts'; +import { findDependencyClosure } from '../../src/installed-packages.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +vi.mock('../../src/installed-packages.ts', { spy: true }); + +// Each case runs two Vite builds and starts workerd. +const TIMEOUT_MS = (process.platform === 'win32' ? 3 : 1) * 60_000; +const COMPATIBILITY_DATE = '2026-02-24'; + +const roots: Array = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +type KitApp = { root: string; compatibilityDate: string }; + +/** + * A SvelteKit server build reduced to what Void sees: Kit's generated get_hooks module, a route + * that queries void/db, and an entry that calls `handle` as the adapter's Worker does. Like Kit's + * analyse step, it then loads the output in Node. + */ +async function buildKitServer( + database: 'pg' | 'mysql', + { + hoisted = false, + compatibilityDate = COMPATIBILITY_DATE, + kit = 3, + listsDriver = false, + }: { hoisted?: boolean; compatibilityDate?: string; kit?: 2 | 3; listsDriver?: boolean } = {}, +): Promise { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const parent = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-kit-worker-'))); + roots.push(parent); + const root = join(parent, 'app'); + const write = (path: string, content: string) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), content); + }; + write( + 'package.json', + JSON.stringify({ type: 'module', dependencies: { '@sveltejs/kit': '*', 'db-dep': '*' } }), + ); + if (hoisted) { + // npm, Yarn and Bun install every package at the top, so Vite resolves the driver and each + // package it depends on from the app root. pnpm's hidden hoisted directory holds them all. + // Link each package to its real folder: behind a Windows junction to the whole directory, + // pnpm's relative package links resolve at the wrong depth. + const hoistedDir = fileURLToPath( + new URL('../../../../node_modules/.pnpm/node_modules', import.meta.url), + ); + const link = (name: string) => + symlinkSync( + realpathSync.native(join(hoistedDir, name)), + join(parent, 'node_modules', name), + 'junction', + ); + mkdirSync(join(parent, 'node_modules')); + for (const name of readdirSync(hoistedDir)) { + if (name.startsWith('@')) { + mkdirSync(join(parent, 'node_modules', name)); + for (const scoped of readdirSync(join(hoistedDir, name))) { + link(join(name, scoped)); + } + } else if (!name.startsWith('.')) { + link(name); + } + } + } else { + // pnpm links the app's own dependencies, and keeps theirs, such as void's driver, hidden. + mkdirSync(join(root, 'node_modules'), { recursive: true }); + symlinkSync( + fileURLToPath(new URL('../..', import.meta.url)), + join(root, 'node_modules/void'), + 'junction', + ); + if (listsDriver) { + // Vite then resolves them from the app root. + for (const name of ['drizzle-orm', database === 'mysql' ? 'mysql2' : 'pg']) { + symlinkSync( + realpathSync.native( + fileURLToPath(new URL(`../../node_modules/${name}`, import.meta.url)), + ), + join(root, 'node_modules', name), + 'junction', + ); + } + } + } + // An app dependency whose workerd build imports a `cloudflare:` module, as capnweb's does. + write( + 'node_modules/cf-dep/package.json', + JSON.stringify({ + name: 'cf-dep', + type: 'module', + exports: { workerd: './worker.js', default: './node.js' }, + }), + ); + write( + 'node_modules/cf-dep/worker.js', + "import { connect } from 'cloudflare:sockets';\nexport const runtime = typeof connect;\n", + ); + write('node_modules/cf-dep/node.js', "export const runtime = 'node';\n"); + // An installed package that queries the app's database through void/db. + write( + 'node_modules/db-dep/package.json', + JSON.stringify({ name: 'db-dep', type: 'module', peerDependencies: { void: '*' } }), + ); + write('node_modules/db-dep/index.js', "export { db as packageDb } from 'void/db';\n"); + write( + 'void.json', + JSON.stringify({ + database, + worker: { compatibility_date: compatibilityDate, compatibility_flags: ['nodejs_compat'] }, + }), + ); + // An app's schema imports drizzle's table builders through void. + write( + 'db/schema.ts', + database === 'pg' + ? "import { pgTable, integer } from 'void/schema-pg';\nexport const users = pgTable('users', { id: integer('id') });\n" + : "import { mysqlTable, int } from 'void/schema-mysql';\nexport const users = mysqlTable('users', { id: int('id') });\n", + ); + // Kit 2 writes get_hooks to server/internal.js, Kit 3 to build/server.js. + const hooksModule = + kit === 2 + ? '.svelte-kit/generated/server/internal.js' + : '.svelte-kit/generated/build/server.js'; + write( + hooksModule, + 'export async function get_hooks() {\n let handle;\n return { handle };\n}\n', + ); + // workerd has no node:tty at this compatibility date, and a Worker never calls this. + write('src/terminal.cjs', "exports.isTerminal = () => require('tty').isatty(1);\n"); + write( + 'src/route.js', + `import { runtime } from 'cf-dep'; +import { packageDb } from 'db-dep'; +import { db, sql } from 'void/db'; +import { isTerminal } from './terminal.cjs'; + +export async function GET(request) { + if (new URL(request.url).searchParams.has('terminal')) { + return Response.json({ terminal: isTerminal(), runtime }); + } + const result = await packageDb.execute(sql\`select 1 as one\`); + // Outside Void's request scope, each use of db builds its own pool. + const pool = () => db.$client.options ?? db.$client.config; + return Response.json({ + rows: Array.isArray(result) ? result[0] : result.rows, + samePool: pool() === pool(), + }); +} +`, + ); + write( + 'src/entry.js', + `import { get_hooks } from '../${hooksModule}'; + +export default { + async fetch(request, env) { + const { handle = ({ event, resolve }) => resolve(event) } = await get_hooks(); + // Kit 2's adapter passes the bindings as platform.env. Kit 3 passes no platform. + const platform = new URL(request.url).searchParams.has('platform') ? { env } : undefined; + return handle({ + event: { request, platform }, + resolve: async () => (await import('./route.js')).GET(request), + }); + }, +}; +`, + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + build: { ssr: 'src/entry.js', outDir: 'build/server', emptyOutDir: true }, + }); + // Kit's analyse step imports the server nodes in a Node worker. + const output = join(root, 'build/server'); + const modules = readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => pathToFileURL(join(output, file)).href); + const analyse = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + 'for (const url of JSON.parse(process.argv[1])) await import(url);', + JSON.stringify(modules), + ], + { encoding: 'utf8' }, + ); + expect(analyse.status, analyse.stderr).toBe(0); + // The output holds void/db's drizzle-orm and driver, even where Node could load the app's. + for (const file of modules) { + expect(readFileSync(new URL(file), 'utf8')).not.toMatch( + /^(?:import|export)\b[^;]*?\bfrom\s*["'](?:drizzle-orm|pg|mysql2)(?:\/[^"']*)?["']/m, + ); + } + return { root, compatibilityDate }; +} + +/** Package the server build as the given deploy target does; return its modules. */ +async function packageWorker( + { root, compatibilityDate }: KitApp, + target: 'hosted' | 'native', +): Promise<{ directory: string; mainModule: string; dispose(): Promise }> { + const entry = join(root, 'build/server/entry.js'); + if (target === 'hosted') { + const { outputDir, entryFilename } = await bundleEntry(root, entry); + return { directory: outputDir, mainModule: entryFilename, async dispose() {} }; + } + mkdirSync(join(root, 'public')); + writeFileSync( + join(root, 'wrangler.json'), + JSON.stringify({ + name: 'kit-worker', + main: 'build/server/entry.js', + compatibility_date: compatibilityDate, + assets: { directory: 'public' }, + }), + ); + const prepared = await prepareCfFrameworkBuildOutput({ + projectRoot: root, + configPath: join(root, 'wrangler.json'), + accountId: 'account-id', + }); + if (!prepared) { + throw new Error('Expected native build output.'); + } + const built = await readBuildOutput(prepared.directory); + return { + directory: getWorkerBundleDir(prepared.directory), + mainModule: built.workers.default.config.manifest!.mainModule, + dispose: () => prepared.dispose(), + }; +} + +/** Serve the packaged Worker in workerd and request the route as Kit 3 and as Kit 2. */ +async function fetchRoute( + worker: { directory: string; mainModule: string }, + compatibilityDate: string, + connectionString: string, +): Promise> { + const modules = readdirSync(worker.directory, { recursive: true }) + .map(String) + .filter((file) => /\.m?js$/.test(file)) + .sort((a, b) => Number(b === worker.mainModule) - Number(a === worker.mainModule)) + .map((file) => ({ type: 'ESModule' as const, path: join(worker.directory, file) })); + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: worker.directory, + modules, + compatibilityDate, + compatibilityFlags: ['nodejs_compat'], + bindings: { HYPERDRIVE: { connectionString } }, + }), + ); + try { + const bodies: Array = []; + for (const path of ['/', '/?platform']) { + const response = await miniflare.dispatchFetch(`http://localhost${path}`); + const body = await response.text(); + expect(response.status, body).toBe(200); + bodies.push(JSON.parse(body)); + } + return bodies; + } finally { + await miniflare.dispose(); + } +} + +describe.each([ + ['only under void', false], + ['next to the app', true], +])('SvelteKit production builds with a Hyperdrive driver installed %s', (_layout, hoisted) => { + it.each(['hosted', 'native'] as const)( + 'serves PostgreSQL queries from the %s Worker in one pool per request', + async (target) => { + const app = await buildKitServer('pg', { hoisted }); + const worker = await packageWorker(app, target); + const pglite = new PGlite(); + // pglite-server does not wait for PGlite: a query that arrives during startup spins forever. + await pglite.waitReady; + const server = createPgServer(pglite); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `postgresql://postgres@127.0.0.1:${port}/postgres`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + server.close(); + await pglite.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, + ); + + it.each(['hosted', 'native'] as const)( + 'serves MySQL queries from the %s Worker in one pool per request', + async (target) => { + const app = await buildKitServer('mysql', { hoisted }); + const worker = await packageWorker(app, target); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, + ); +}); + +it( + "serves MySQL queries in one pool per request through Kit 2's generated hooks module", + async () => { + const app = await buildKitServer('mysql', { kit: 2 }); + const worker = await packageWorker(app, 'hosted'); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const [, withPlatform] = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(withPlatform).toEqual({ rows: [{ one: 1 }], samePool: true }); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, +); + +it( + 'serves MySQL queries from a Worker whose compatibility date predates the native node:process', + async () => { + // The Cloudflare preset lists node:process only from 2025-09-15, and mysql2 requires it + // when it loads. + const app = await buildKitServer('mysql', { compatibilityDate: '2025-06-01' }); + const worker = await packageWorker(app, 'hosted'); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, +); + +// A Yarn PnP install has no node_modules folder in which to find void's dependencies. +it( + 'bundles drizzle-orm and the driver that the app lists when their dependencies cannot be read', + async () => { + vi.mocked(findDependencyClosure).mockReturnValue([]); + try { + await buildKitServer('pg', { listsDriver: true }); + } finally { + vi.mocked(findDependencyClosure).mockRestore(); + } + }, + TIMEOUT_MS, +); diff --git a/packages/void/test/unit/worker-build-node-require.test.ts b/packages/void/test/unit/worker-build-node-require.test.ts new file mode 100644 index 000000000..4c7ee37c5 --- /dev/null +++ b/packages/void/test/unit/worker-build-node-require.test.ts @@ -0,0 +1,84 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createBuilder, type Plugin } from 'vite'; +import { afterEach, expect, it, vi } from 'vite-plus/test'; +import { voidPlugin } from '../../src/index.ts'; + +const roots: Array = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +// pnpm installs Vite once per set of peers, so the Vite that runs a build can differ from the one +// the Cloudflare plugin imports. Rolldown then skips the Cloudflare plugin's esmExternalRequirePlugin, +// as this plugin does. +const skipCloudflareRequireConversion: Plugin = { + name: 'test:skip-cloudflare-require-conversion', + configResolved(config) { + for (const environment of Object.values(config.environments)) { + const options = environment.build.rolldownOptions; + if (Array.isArray(options.plugins)) { + options.plugins = options.plugins.filter( + (plugin) => + !(plugin && 'name' in plugin && plugin.name === 'builtin:esm-external-require'), + ); + } + } + }, +}; + +it('serves the Node built-ins that CommonJS requires in a MySQL Worker build from imports', async () => { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-worker-require-'))); + roots.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'mysql', worker: { compatibility_date: '2026-02-24' } }), + ); + // mysql2 requires node:buffer like this. + mkdirSync(join(root, 'lib')); + writeFileSync( + join(root, 'lib/size.cjs'), + "exports.size = (text) => require('node:buffer').Buffer.byteLength(text);\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/size.ts'), + "import { size } from '../../lib/size.cjs';\nexport const GET = () => Response.json({ size: size('void') });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const builder = await createBuilder( + { + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin(), skipCloudflareRequireConversion], + }, + null, + ); + await builder.buildApp(); + + const output = join(root, 'dist/ssr'); + const code = readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(output, file), 'utf8')) + .join('\n'); + // workerd has no `require`, so this call would throw when the route runs. + expect(code).not.toContain('__require("node:buffer")'); + expect(code).toMatch(/^import \w+ from "node:buffer";$/m); +}); diff --git a/packages/void/test/unit/pg-bundle-transform.test.ts b/packages/void/test/unit/worker-require-transform.test.ts similarity index 69% rename from packages/void/test/unit/pg-bundle-transform.test.ts rename to packages/void/test/unit/worker-require-transform.test.ts index 916b21bc0..eeb6ae794 100644 --- a/packages/void/test/unit/pg-bundle-transform.test.ts +++ b/packages/void/test/unit/worker-require-transform.test.ts @@ -1,6 +1,6 @@ import { parseSync } from 'vite'; import { describe, expect, it } from 'vite-plus/test'; -import { transformPgWorkerBundle } from '../../src/cli/pg-bundle-transform.ts'; +import { transformWorkerRequireBundle } from '../../src/cli/worker-require-transform.ts'; const shim = 'var __require = (id) => { throw new Error(id); };'; @@ -9,7 +9,7 @@ describe('PostgreSQL migration bundle transform', () => { const source = `import { createRequire } from 'node:module'; var __require = /* bundler annotation */ (() => createRequire(import.meta.url))(); const value = __require ( 'pgpass' );`; - const transformed = transformPgWorkerBundle(source, 'bundle.mjs', shim); + const transformed = transformWorkerRequireBundle(source, 'bundle.mjs', shim, 'PostgreSQL'); expect(transformed).toContain('const value = __voidPgpassNoop;'); expect(transformed).toContain(shim); expect(transformed).not.toContain('createRequire'); @@ -18,12 +18,17 @@ const value = __require ( 'pgpass' );`; it('fails closed when the emitted require shape changes', () => { expect(() => - transformPgWorkerBundle('const x = createRequire(import.meta.url);', 'bundle.mjs', shim), + transformWorkerRequireBundle( + 'const x = createRequire(import.meta.url);', + 'bundle.mjs', + shim, + 'PostgreSQL', + ), ).toThrow('Could not locate the generated require binding'); }); it('leaves unrelated bundles untouched', () => { const source = 'export const value = 1;'; - expect(transformPgWorkerBundle(source, 'bundle.mjs', shim)).toBe(source); + expect(transformWorkerRequireBundle(source, 'bundle.mjs', shim, 'PostgreSQL')).toBe(source); }); }); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 00a41c0e9..45cbeb8eb 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -1,15 +1,25 @@ +import { once } from 'node:events'; import { + cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; +import { createRequire, isBuiltin } from 'node:module'; +import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; +import { parseSync } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { cleanupWrapper, @@ -18,6 +28,7 @@ import { selectBundledEntryFilename, } from '../../src/cli/wrapper.ts'; import type { WrapperFeatures } from '../../src/cli/wrapper.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const base: WrapperFeatures = { crons: [], @@ -585,20 +596,415 @@ describe('generateWrapper with migrations', () => { expect(bundled).toContain('__voidPgpassNoop'); }); - it('uses the MySQL migration handler when dialect is mysql', async () => { + /** Start the wrapped Worker in workerd, send one internal request and return its JSON body. */ + async function fetchWrappedWorker( + workerDir: string, + request: { path: string; body?: unknown }, + options: { + bindings?: Record; + d1Databases?: Array; + compatibilityDate?: string; + } = {}, + ): Promise { + // Upload every module, as the platform does; the wrapper imports the handler statically. + const modules = readdirSync(workerDir) + .filter((file) => file.endsWith('.mjs')) + .sort((a, b) => Number(b === 'index.mjs') - Number(a === 'index.mjs')) + .map((file) => ({ type: 'ESModule' as const, path: join(workerDir, file) })); + let miniflare: Miniflare | undefined; + try { + miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: workerDir, + modules, + compatibilityDate: options.compatibilityDate ?? '2026-02-24', + compatibilityFlags: ['nodejs_compat'], + bindings: { __VOID_PROXY_TOKEN: 'proxy-token', ...options.bindings }, + d1Databases: options.d1Databases, + }), + ); + const response = await miniflare.dispatchFetch(`http://localhost${request.path}`, { + method: 'POST', + headers: { 'x-void-internal': 'proxy-token', 'content-type': 'application/json' }, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + }); + return await response.json(); + } finally { + await miniflare?.dispose(); + } + } + + /** Run the wrapped MySQL Worker in workerd and return its `/__void/migrate` response body. */ + async function migrateMySqlInWorkerd(workerDir: string): Promise { + // The applied-migrations SELECT returns a row, so mysql2 also parses a result set. + // No CLI run holds the named lock, so the deploy's lease is enough. + const mysql = await startFakeMySqlServer((sql) => + sql.includes('IS_USED_LOCK') ? [{ holder: null }] : [{ name: '000_applied_elsewhere.sql' }], + ); + try { + return await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate' }, + { + bindings: { + HYPERDRIVE: { connectionString: `mysql://void@127.0.0.1:${mysql.port}/app` }, + }, + }, + ); + } finally { + await mysql.close(); + } + } + + /** + * npm, Yarn and Bun hoist dependencies into the app's node_modules, where the build finds them. + * Copy each package there (copies, not symlinks, so this also runs on Windows). + */ + function hoistIntoApp(projectRoot: string, packages: Record): void { + for (const [name, source] of Object.entries(packages)) { + cpSync(realpathSync(source), join(projectRoot, 'node_modules', name), { + recursive: true, + filter: (path) => !path.endsWith(`${sep}node_modules`), + }); + } + } + + /** The dependencies of `packageName`, as they sit next to its installed copy. */ + function dependencyDirs(packageName: string): Record { + const packageJson = createRequire(import.meta.url).resolve(`${packageName}/package.json`); + const { dependencies } = JSON.parse(readFileSync(packageJson, 'utf8')) as { + dependencies: Record; + }; + const installDir = dirname(dirname(realpathSync(packageJson))); + return Object.fromEntries( + Object.keys(dependencies).map((name) => [name, join(installDir, name)]), + ); + } + + /** workerd loads only relative, `node:` and `cloudflare:` imports, and the shims serve built-ins. */ + function expectSelfContainedHandler(workerDir: string, optionalRequires: Array = []) { + for (const file of readdirSync(workerDir).filter((name) => + name.startsWith('__void_migration_handler'), + )) { + const code = readFileSync(join(workerDir, file), 'utf8'); + const { module } = parseSync(file, code); + const imports = [ + ...module.staticImports.map((entry) => entry.moduleRequest.value), + ...module.staticExports.flatMap((entry) => + entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])), + ), + ]; + expect(imports.filter((id) => !/^(node:|cloudflare:|\.\/)/.test(id))).toEqual([]); + const required = Array.from( + code.matchAll(/\b__require\(\s*["']([^"']+)["']\s*\)/g), + (match) => match[1]!, + ); + expect(required.filter((id) => !isBuiltin(id) && !optionalRequires.includes(id))).toEqual([]); + } + } + + it('runs MySQL migrations from the wrapped Worker in workerd', async () => { const { workerDir, projectRoot } = createMigrationFixture({ name: '001_test.sql', sql: 'CREATE TABLE test (id INT);', }); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'mysql', + }); + + expect(await migrateMySqlInWorkerd(workerDir)).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + }); + // npm, Yarn and Bun hoist mysql2's dependencies into the app's node_modules. The build finds + // them there, so anything it does not bundle stays a require that workerd cannot load. + it('bundles the mysql2 dependencies that sit in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_test.sql', + sql: 'CREATE TABLE test (id INT);', + }); + hoistIntoApp(projectRoot, dependencyDirs('mysql2')); await generateWrapper(workerDir, 'index.mjs', projectRoot, { ...base, hasMigrations: true, migrationDialect: 'mysql', }); - const handler = readFileSync(join(workerDir, '__void_migration_handler.mjs'), 'utf-8'); - expect(handler).toContain('VARCHAR(255)'); + expect(await migrateMySqlInWorkerd(workerDir)).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + // mysql2 requires its optional `cardinal` inside try/catch. + expectSelfContainedHandler(workerDir, ['cardinal']); + }); + + it('bundles the pg dependencies that sit in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_test.sql', + sql: 'CREATE TABLE test (id SERIAL PRIMARY KEY);', + }); + hoistIntoApp(projectRoot, dependencyDirs('pg')); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'postgresql', + }); + + const pglite = new PGlite(); + // pglite-server does not wait for PGlite: a query that arrives during startup spins forever. + await pglite.waitReady; + const server = createPgServer(pglite); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate' }, + { + bindings: { + HYPERDRIVE: { connectionString: `postgresql://postgres@127.0.0.1:${port}/postgres` }, + }, + }, + ), + ).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + } finally { + server.close(); + await pglite.close(); + } + expectSelfContainedHandler(workerDir); + }); + + it('bundles @void/deploy-core when it sits in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_users.sql', + sql: 'CREATE TABLE users (id INTEGER PRIMARY KEY);', + }); + hoistIntoApp(projectRoot, { + '@void/deploy-core': join(import.meta.dirname, '..', '..', '..', 'deploy-core'), + }); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'sqlite', + }); + + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate/app', body: { attemptId: 'attempt-1', deploymentId: 'dep-1' } }, + { d1Databases: ['DB'] }, + ), + ).toMatchObject({ ok: true, confirmedApplied: ['001_users.sql'] }); + expectSelfContainedHandler(workerDir); + }); + + /** + * An app whose cron, and with `queue` a queue consumer, reads its users through `void/db`. + * The job throws unless each select and relational query, which needs the app's schema, returns + * `users`. The last two run in parallel. + */ + async function wrapUsersCron( + database: 'pg' | 'mysql', + users: Array<{ id: number; name: string }>, + options: { schemaImport: string; schemaFolder?: boolean; queue?: boolean }, + ): Promise { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'void.json'), JSON.stringify({ database })); + const [table, id] = database === 'pg' ? ['pgTable', 'serial'] : ['mysqlTable', 'int']; + const schema = `import { ${table}, ${id}, text } from 'void/schema-${database}'; +export const users = ${table}('users', { id: ${id}('id').primaryKey(), name: text('name').notNull() }); +`; + // A schema folder's files are also importable one by one, as `@schema/`. + let fileImport = ''; + if (options.schemaFolder) { + mkdirSync(join(projectRoot, 'db', 'schema')); + writeFileSync(join(projectRoot, 'db', 'schema', 'users.ts'), schema); + writeFileSync(join(projectRoot, 'db', 'schema', 'index.ts'), "export * from './users';\n"); + fileImport = `import { users as usersFile } from '@schema/users'; +if (usersFile !== users) throw new Error('@schema/users is another module'); +`; + } else { + writeFileSync(join(projectRoot, 'db', 'schema.ts'), schema); + } + const job = `import { users } from ${JSON.stringify(options.schemaImport)}; +import { db } from 'void/db'; +${fileImport} +export default async function () { + const found = JSON.stringify([ + await db.select().from(users), + ...(await Promise.all([db.select().from(users), db.query.users.findMany()])), + ]); + if (found !== ${JSON.stringify(JSON.stringify([users, users, users]))}) throw new Error(found); +} +`; + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'users.ts'), job); + if (options.queue) { + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'users.ts'), job); + } + // The schema imports Void's SQL helpers by package name, as an app does. + mkdirSync(join(projectRoot, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(projectRoot, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + crons: [{ name: 'users', filePath: 'users.ts', crons: ['0 * * * *'] }], + ...(options.queue && { + queues: [{ name: 'users', filePath: 'users.ts', bindingName: 'QUEUE_USERS' }], + }), + }); + return workerDir; + } + + const runCron = (workerDir: string, connectionString: string, compatibilityDate?: string) => + fetchWrappedWorker( + workerDir, + { path: '/__void/scheduled', body: { cron: '0 * * * *', scheduledTime: Date.now() } }, + { bindings: { HYPERDRIVE: { connectionString } }, compatibilityDate }, + ); + + /** Deliver one message to the wrapped `users` queue consumer through the relay endpoint. */ + const runQueue = (workerDir: string, connectionString: string) => + fetchWrappedWorker( + workerDir, + { + path: '/__void/queue', + body: { + queue: 'users', + messages: [{ id: 'one', timestamp: new Date().toISOString(), body: {}, attempts: 1 }], + }, + }, + { bindings: { HYPERDRIVE: { connectionString } } }, + ); + + /** Serve PGlite with one user to `run`, which can read how many connections the server took. */ + async function withPgUsers( + run: (connectionString: string, connections: () => number) => Promise, + ): Promise { + const pglite = new PGlite(); + await pglite.waitReady; + await pglite.exec( + "CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL); INSERT INTO users (name) VALUES ('alice');", + ); + const server = createPgServer(pglite); + let connections = 0; + server.on('connection', () => connections++); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + return await run(`postgresql://postgres@127.0.0.1:${port}/postgres`, () => connections); + } finally { + server.close(); + await pglite.close(); + } + } + + /** Run the wrapped PostgreSQL cron in workerd against PGlite with one user. */ + const runPgUsersCron = (workerDir: string, compatibilityDate?: string) => + withPgUsers((connectionString) => runCron(workerDir, connectionString, compatibilityDate)); + + it('gives a PostgreSQL cron the app database and schema in workerd', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + }); + expect(await runPgUsersCron(workerDir)).toEqual({ ok: true }); + }); + + // workerd provides node:fs only from compatibility date 2025-09-15, and apps keep older dates. + it('starts a PostgreSQL cron Worker at a compatibility date without node:fs', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + }); + expect(await runPgUsersCron(workerDir, '2024-09-23')).toEqual({ ok: true }); + }); + + it('gives a cron a schema folder through @schema', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + schemaFolder: true, + }); + expect(await runPgUsersCron(workerDir)).toEqual({ ok: true }); + }); + + it('gives a MySQL cron the app database and schema in workerd', async () => { + const users = [{ id: 1, name: 'alice' }]; + // A relative import: the cron must get the app's database even without @schema. + const workerDir = await wrapUsersCron('mysql', users, { schemaImport: '../db/schema.ts' }); + + const mysql = await startFakeMySqlServer(users); + try { + expect(await runCron(workerDir, `mysql://void@127.0.0.1:${mysql.port}/app`)).toEqual({ + ok: true, + }); + } finally { + await mysql.close(); + } + }); + + // As in a Void app, each event runs in one request scope, so void/db opens one connection for + // all of its queries, parallel ones included, instead of a pool for each query. + it('runs a PostgreSQL cron event and queue batch on one connection each', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + queue: true, + }); + await withPgUsers(async (connectionString, connections) => { + expect(await runCron(workerDir, connectionString)).toEqual({ ok: true }); + expect(connections()).toBe(1); + expect(await runQueue(workerDir, connectionString)).toEqual({ ok: true }); + expect(connections()).toBe(2); + }); + }); + + it('runs a MySQL cron event and queue batch on one connection each', async () => { + const users = [{ id: 1, name: 'alice' }]; + const workerDir = await wrapUsersCron('mysql', users, { schemaImport: '@schema', queue: true }); + + const mysql = await startFakeMySqlServer(users); + try { + const connectionString = `mysql://void@127.0.0.1:${mysql.port}/app`; + expect(await runCron(workerDir, connectionString)).toEqual({ ok: true }); + expect(mysql.acceptedConnections()).toBe(1); + expect(await runQueue(workerDir, connectionString)).toEqual({ ok: true }); + expect(mysql.acceptedConnections()).toBe(2); + } finally { + await mysql.close(); + } + }); + + // In a request scope, void/db reads D1 through a replica-capable session instead of the primary. + it('keeps D1 cron and queue jobs outside the request scope', async () => { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'db', 'schema.ts'), 'export {};\n'); + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'daily.ts'), 'export default async () => {};'); + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'emails.ts'), 'export default async () => {};'); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + crons: [{ name: 'daily', filePath: 'daily.ts', crons: ['0 0 * * *'] }], + queues: [{ name: 'emails', filePath: 'emails.ts', bindingName: 'QUEUE_EMAILS' }], + }); + + const handlers = readFileSync(join(projectRoot, '.void', 'handlers', 'handlers.ts'), 'utf8'); + expect(handlers).toContain('export async function handleScheduled(controller, env, ctx) {'); + expect(handlers).toContain('export async function handleQueue(batch, env, ctx) {'); + expect(handlers).not.toContain('withRuntimeEnv'); }); it('does not generate migration handler when hasMigrations is false', async () => { diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index faca20611..d38a383ba 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ 'src/runtime/ws-server.ts', 'src/runtime/live-server.ts', 'src/sveltekit.ts', + 'src/nitro.ts', 'src/pages/index.ts', 'src/pages/protocol.ts', 'src/pages/head.ts', diff --git a/playground/analog/package.json b/playground/analog/package.json index bd435314b..1f386eb8a 100644 --- a/playground/analog/package.json +++ b/playground/analog/package.json @@ -24,7 +24,6 @@ "@angular/build": "catalog:", "@angular/compiler-cli": "^22.2.2", "nitro-cloudflare-dev": "^0.2.2", - "pathslash": "catalog:", "typescript": "catalog:angular", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/playground/analog/src/server/routes/api/smoke.ts b/playground/analog/src/server/routes/api/smoke.ts index 2ea821b81..6359a04f1 100644 --- a/playground/analog/src/server/routes/api/smoke.ts +++ b/playground/analog/src/server/routes/api/smoke.ts @@ -6,9 +6,11 @@ import { kv } from 'void/kv'; export default defineEventHandler(async () => { const rows = await db.select().from(users).limit(1); const result = rows[0] ?? null; + // The relational API needs the app's schema in `void/db`. + const first = await db.query.users.findFirst({ columns: { name: true } }); await kv.put('smoke', 'ok'); const kvValue = await kv.get('smoke'); - return { user: result, kvValue }; + return { user: result, firstUser: first ?? null, kvValue }; }); diff --git a/playground/analog/vite.config.ts b/playground/analog/vite.config.ts index 13a71afb5..f7bee9c86 100644 --- a/playground/analog/vite.config.ts +++ b/playground/analog/vite.config.ts @@ -1,5 +1,4 @@ import analog from '@analogjs/platform'; -import { resolve } from 'pathslash'; import { defineConfig } from 'vite-plus'; import { voidPlugin } from 'void'; @@ -12,13 +11,8 @@ export default defineConfig({ ssr: true, nitro: { preset: 'cloudflare-module', - modules: ['nitro-cloudflare-dev'], + modules: ['nitro-cloudflare-dev', 'void/nitro'], cloudflareDev: { configPath: './.void-wrangler.jsonc' }, - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(__dirname, 'db/schema.ts'), - }, }, }), voidPlugin(), diff --git a/playground/mysql-test/db/schema.ts b/playground/mysql-test/db/schema.ts new file mode 100644 index 000000000..bf519fe1e --- /dev/null +++ b/playground/mysql-test/db/schema.ts @@ -0,0 +1,7 @@ +import { int, mysqlTable, varchar } from 'void/schema-mysql'; + +export const users = mysqlTable('users', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull().unique(), +}); diff --git a/playground/mysql-test/package.json b/playground/mysql-test/package.json new file mode 100644 index 000000000..6f8f11eb9 --- /dev/null +++ b/playground/mysql-test/package.json @@ -0,0 +1,17 @@ +{ + "name": "playground-mysql-test", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/mysql-test/routes/api/users.ts b/playground/mysql-test/routes/api/users.ts new file mode 100644 index 000000000..a4b9fb04d --- /dev/null +++ b/playground/mysql-test/routes/api/users.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { db } from 'void/db'; + +export const GET = defineHandler(async () => { + const all = await db.select().from(users); + return all; +}); diff --git a/playground/mysql-test/routes/api/users/create-db.ts b/playground/mysql-test/routes/api/users/create-db.ts new file mode 100644 index 000000000..c2b25cc9b --- /dev/null +++ b/playground/mysql-test/routes/api/users/create-db.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { createDb } from 'void/db'; + +export const GET = defineHandler(async (c) => { + const all = await createDb(String(c.env.DATABASE_URL)).select().from(users); + return all; +}); diff --git a/playground/mysql-test/tsconfig.json b/playground/mysql-test/tsconfig.json new file mode 100644 index 000000000..55e4a6313 --- /dev/null +++ b/playground/mysql-test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["void/env"], + "allowImportingTsExtensions": true + }, + "include": ["routes", "db"] +} diff --git a/playground/mysql-test/vite.config.ts b/playground/mysql-test/vite.config.ts new file mode 100644 index 000000000..2bae9de06 --- /dev/null +++ b/playground/mysql-test/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [voidPlugin()], +}); diff --git a/playground/mysql-test/void.config.ts b/playground/mysql-test/void.config.ts new file mode 100644 index 000000000..1d772b776 --- /dev/null +++ b/playground/mysql-test/void.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + database: 'mysql', + worker: { + compatibility_date: '2026-02-24', + }, +}); diff --git a/playground/nuxt/nuxt.config.ts b/playground/nuxt/nuxt.config.ts index 1a957a7cb..9f0e77848 100644 --- a/playground/nuxt/nuxt.config.ts +++ b/playground/nuxt/nuxt.config.ts @@ -4,18 +4,18 @@ import { voidPlugin } from 'void'; export default defineNuxtConfig({ compatibilityDate: '2026-02-24', + // Server routes are also typed in the app project, which reads Nuxt's aliases. + alias: { + '@schema': resolve(import.meta.dirname, 'db/schema.ts'), + }, nitro: { preset: 'cloudflare-module', - cloudflareDev: { configPath: './.void-wrangler.jsonc' }, + modules: ['void/nitro'], + cloudflare: { dev: { configPath: './.void-wrangler.jsonc' } }, // Remove when Nuxt ships https://github.com/nuxt/nuxt/commit/21657c6fcf. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(import.meta.dirname, 'db/schema.ts'), - }, }, vite: { plugins: [voidPlugin() as unknown as PluginOption], diff --git a/playground/nuxt/server/api/smoke.get.ts b/playground/nuxt/server/api/smoke.get.ts index 89b3fb938..3ad0eeed4 100644 --- a/playground/nuxt/server/api/smoke.get.ts +++ b/playground/nuxt/server/api/smoke.get.ts @@ -5,9 +5,11 @@ import { kv } from 'void/kv'; export default defineEventHandler(async () => { const rows = await db.select().from(users).limit(1); const result = rows[0] ?? null; + // The relational API needs the app's schema in `void/db`. + const first = await db.query.users.findFirst({ columns: { name: true } }); await kv.put('smoke', 'ok'); const kvValue = await kv.get('smoke'); - return { user: result, kvValue }; + return { user: result, firstUser: first ?? null, kvValue }; }); diff --git a/playground/pg-api-test/db/schema.ts b/playground/pg-api-test/db/schema.ts new file mode 100644 index 000000000..f6faf8f9d --- /dev/null +++ b/playground/pg-api-test/db/schema.ts @@ -0,0 +1,7 @@ +import { pgTable, serial, text } from 'void/schema-pg'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), +}); diff --git a/playground/pg-api-test/package.json b/playground/pg-api-test/package.json new file mode 100644 index 000000000..b844c5b08 --- /dev/null +++ b/playground/pg-api-test/package.json @@ -0,0 +1,17 @@ +{ + "name": "playground-pg-api-test", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/pg-api-test/routes/api/users.ts b/playground/pg-api-test/routes/api/users.ts new file mode 100644 index 000000000..a4b9fb04d --- /dev/null +++ b/playground/pg-api-test/routes/api/users.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { db } from 'void/db'; + +export const GET = defineHandler(async () => { + const all = await db.select().from(users); + return all; +}); diff --git a/playground/pg-api-test/routes/api/users/create-db.ts b/playground/pg-api-test/routes/api/users/create-db.ts new file mode 100644 index 000000000..c2b25cc9b --- /dev/null +++ b/playground/pg-api-test/routes/api/users/create-db.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { createDb } from 'void/db'; + +export const GET = defineHandler(async (c) => { + const all = await createDb(String(c.env.DATABASE_URL)).select().from(users); + return all; +}); diff --git a/playground/pg-api-test/tsconfig.json b/playground/pg-api-test/tsconfig.json new file mode 100644 index 000000000..55e4a6313 --- /dev/null +++ b/playground/pg-api-test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["void/env"], + "allowImportingTsExtensions": true + }, + "include": ["routes", "db"] +} diff --git a/playground/pg-api-test/vite.config.ts b/playground/pg-api-test/vite.config.ts new file mode 100644 index 000000000..2bae9de06 --- /dev/null +++ b/playground/pg-api-test/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [voidPlugin()], +}); diff --git a/playground/pg-api-test/void.config.ts b/playground/pg-api-test/void.config.ts new file mode 100644 index 000000000..ff6ebb3c8 --- /dev/null +++ b/playground/pg-api-test/void.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + database: 'pg', + worker: { + compatibility_date: '2026-02-24', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b182cfe3..ee33a5d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1157,9 +1157,6 @@ importers: nitro-cloudflare-dev: specifier: ^0.2.2 version: 0.2.2 - pathslash: - specifier: 'catalog:' - version: 0.1.0 typescript: specifier: catalog:angular version: 6.0.3 @@ -1251,6 +1248,21 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/mysql-test: + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.1.0(@types/node@26.6.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.1.0(@types/node@26.6.4)(@vitest/ui@5.0.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2(@typescript-eslint/types@8.71.0))(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/named-layouts: dependencies: hono: @@ -1381,6 +1393,21 @@ importers: specifier: 'catalog:' version: 4.150.0(@cloudflare/workers-types@5.20261009.1)(@types/node@26.6.4) + playground/pg-api-test: + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.1.0(@types/node@26.6.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.1.0(@types/node@26.6.4)(@vitest/ui@5.0.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2(@typescript-eslint/types@8.71.0))(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/pg-test: dependencies: hono: diff --git a/test/integration-timings.ts b/test/integration-timings.ts index 903906097..4b952f41e 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -9,7 +9,7 @@ export const integrationTimings: Readonly> = { 'packages/svelte/test/integration/svelte-pages-client.test.ts': 30000, 'packages/void/test/e2e/cloudflare-deploy-live.test.ts': 6, 'packages/void/test/integration/analog.test.ts': 38000, - 'packages/void/test/integration/astro.test.ts': 17000, + 'packages/void/test/integration/astro.test.ts': 26000, 'packages/void/test/integration/cli-help.test.ts': 1718, 'packages/void/test/integration/cloudflare-compatibility-build.test.ts': 5000, 'packages/void/test/integration/cloudflare-deploy-build.test.ts': 6700, @@ -21,17 +21,20 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/full-deploy-manifest.test.ts': 6370, 'packages/void/test/integration/kitchen-sink-build.test.ts': 7365, 'packages/void/test/integration/kitchen-sink.test.ts': 20887, + 'packages/void/test/integration/mysql-test.test.ts': 11000, 'packages/void/test/integration/named-layouts.test.ts': 9939, + 'packages/void/test/integration/nitro-worker.test.ts': 30000, 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, 'packages/void/test/integration/nuxt.test.ts': 43000, 'packages/void/test/integration/operator-cli.test.ts': 4910, 'packages/void/test/integration/pages-target-assets.test.ts': 10966, + 'packages/void/test/integration/pg-api-test.test.ts': 11000, 'packages/void/test/integration/pg-test.test.ts': 16237, 'packages/void/test/integration/plugin-env-schema.test.ts': 236, 'packages/void/test/integration/prepare-cli.test.ts': 1957, 'packages/void/test/integration/queue-dispatch.test.ts': 208, - 'packages/void/test/integration/react-router.test.ts': 9000, + 'packages/void/test/integration/react-router.test.ts': 21000, 'packages/void/test/integration/sandbox-managed-rpc.test.ts': 722, 'packages/void/test/integration/spa-middleware-auth.test.ts': 26264, 'packages/void/test/integration/ssr-playground.test.ts': 13008, diff --git a/vite.config.ts b/vite.config.ts index ad93d62da..2eba2563a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,9 +24,12 @@ const cliUnitTests = [ // SDK unit files that start PGlite. Add new PGlite unit files here. const sdkPgliteUnitTests = [ 'packages/void/test/unit/db-export-roundtrip.test.ts', + 'packages/void/test/unit/db-request-pool.test.ts', 'packages/void/test/unit/platform-database-lock.test.ts', 'packages/void/test/unit/platform-database-migrations.test.ts', 'packages/void/test/unit/runner-pg.test.ts', + 'packages/void/test/unit/sveltekit-worker-build.test.ts', + 'packages/void/test/unit/wrapper.test.ts', ]; // Vitest forwards a FILTERED env to test workers — in CI only VOID_E2E_CF_TOKEN survives, while