From e467dad055e23bd0abbc0b53ec5003c4b8fcb235 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 19 Oct 2026 17:41:01 +0800 Subject: [PATCH 1/4] feat(db): support Drizzle's casing option in void/db and void db `database` in the Void config now also takes an object, `{ dialect?: 'sqlite' | 'pg' | 'mysql', casing?: 'snake_case' | 'camelCase' }`. The string shorthand (`'pg'`, `'mysql'`) is unchanged. `casing` is passed to every Drizzle instance on the app schema (the runtime `void/db` module for D1, PostgreSQL and MySQL, and `void db seed`) and to the drizzle-kit config that `void db generate`, `push` and the deploy drift check use, so a schema written for `snake_case` gets snake_case columns from both. Without `casing`, the generated module and drizzle-kit config are unchanged. Every reader of `database` goes through getDatabaseDialect or the new getDatabaseCasing, `void init` keeps the casing when it changes the dialect, and schema.json accepts the object form. Built-in auth tables name their columns explicitly, so they are not affected. Fixes #466 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/database.md | 26 ++ docs/reference/config.md | 13 + packages/void/schema.json | 26 +- packages/void/src/cli/db.ts | 19 +- packages/void/src/cli/init.ts | 41 ++- packages/void/src/codegen/prepare.ts | 5 +- packages/void/src/config.ts | 79 ++++- packages/void/src/drizzle/config.ts | 11 +- packages/void/src/index.ts | 4 +- packages/void/src/plugin-drizzle.ts | 36 ++- packages/void/src/plugin-framework.ts | 3 +- packages/void/test/unit/config.test.ts | 66 ++++- packages/void/test/unit/db-casing.test.ts | 270 ++++++++++++++++++ packages/void/test/unit/db-seed.test.ts | 49 +++- .../void/test/unit/drizzle-config.test.ts | 119 +++++++- packages/void/test/unit/init.test.ts | 52 +++- .../test/unit/plugin-vite-environment.test.ts | 27 ++ 17 files changed, 797 insertions(+), 49 deletions(-) create mode 100644 packages/void/test/unit/db-casing.test.ts diff --git a/docs/guide/database.md b/docs/guide/database.md index 9384fbc05..e78989c5d 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -114,6 +114,32 @@ export * from "./schema/users"; export * from "./schema/posts"; ``` +### Column casing + +By default, a column declared without a name, such as `createdAt: timestamp()`, uses its key as the column name. Set `database.casing` to `'snake_case'` to store these columns as `created_at` instead. It applies to `void/db` queries and to `void db` commands such as `generate`, `push`, and `seed`. Columns with an explicit name, such as `text('created_at')`, keep that name. + +```ts +// void.config.ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + database: { dialect: 'pg', casing: 'snake_case' }, +}); +``` + +```ts +// db/schema.ts +import { pgTable, serial, text, timestamp } from 'void/schema-pg'; + +export const posts = pgTable('posts', { + id: serial().primaryKey(), + title: text().notNull(), + createdAt: timestamp().notNull().defaultNow(), // created_at +}); +``` + +Omit `dialect` for D1. If your database already has tables, run `void db generate` after you change `casing` and review the migration before you apply it. + ## Querying Void re-exports all common [Drizzle query operators](https://orm.drizzle.team/docs/select) from `void/db` so you don't need to install `drizzle-orm` separately. Import `db` from `void/db` and your tables from `@schema`: diff --git a/docs/reference/config.md b/docs/reference/config.md index 4e97ce3ae..a89b56a3b 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -141,6 +141,19 @@ Database backend. Omit for D1/SQLite (default). Set to `"pg"` or `"mysql"` for a | `"pg"` | PostgreSQL via Hyperdrive; bring your own database | | `"mysql"` | MySQL via Hyperdrive; bring your own database | +Use an object to also set how Drizzle names columns declared without a name: + +```json +{ + "database": { "dialect": "pg", "casing": "snake_case" } +} +``` + +| Key | Values | Description | +| --------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dialect` | `"sqlite"` (default), `"pg"`, `"mysql"` | Database backend. `"sqlite"` is D1 | +| `casing` | `"snake_case"`, `"camelCase"` | [Drizzle casing](https://orm.drizzle.team/docs/sql-schema-declaration#camel-and-snake-casing) for `void/db` queries and `void db` commands. Omit to use each key as its column name | + When using an external database: - Add `DATABASE_URL` to `.env` for local development diff --git a/packages/void/schema.json b/packages/void/schema.json index 3921f17e3..2dd55bce1 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -392,9 +392,29 @@ "additionalProperties": false }, "database": { - "type": "string", - "enum": ["pg", "mysql"], - "description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL or \"mysql\" for MySQL via Hyperdrive." + "description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL or \"mysql\" for MySQL via Hyperdrive, or use an object to also set the column casing.", + "oneOf": [ + { + "type": "string", + "enum": ["pg", "mysql"] + }, + { + "type": "object", + "properties": { + "dialect": { + "type": "string", + "enum": ["sqlite", "pg", "mysql"], + "description": "Database backend. Defaults to \"sqlite\" (D1)." + }, + "casing": { + "type": "string", + "enum": ["snake_case", "camelCase"], + "description": "Drizzle casing: how columns declared without a name are named in the database, for queries and generated migrations." + } + }, + "additionalProperties": false + } + ] }, "ai": { "type": "object", diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 23c79cf48..851af9957 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -15,8 +15,10 @@ import { createJiti } from 'jiti'; import { basename, dirname, extname, join, relative, resolve } from 'pathslash'; import { readConfig, + getDatabaseCasing, getDatabaseDialect, resolveBindingNames, + type DatabaseCasing, type DatabaseDialect, } from '../config.ts'; import type { DatabaseProviderAdapter, DatabaseProviderDialect } from '../database-provider.ts'; @@ -2399,11 +2401,12 @@ async function runSeedModule( dialect: DatabaseDialect, ): Promise { const schema = await loadSeedSchema(root); + const casing = getDatabaseCasing(readConfig(root)); if (dialect === 'postgresql') { const { drizzle } = await import('drizzle-orm/node-postgres'); await withPgClient(root, async (client) => { - const options = getDrizzleSchemaOptions(schema); + const options = getDrizzleSchemaOptions(schema, casing); const drizzleClient = client as never; const db = options ? drizzle(drizzleClient, options) : drizzle(drizzleClient); await seed({ dialect, db, schema }); @@ -2416,7 +2419,7 @@ async function runSeedModule( const { createConnection } = await import('mysql2/promise'); const connection = await createConnection(getLocalDatabaseUrl(root, 'mysql')); try { - const options = getDrizzleSchemaOptions(schema); + const options = getDrizzleSchemaOptions(schema, casing); const db = options ? drizzle(connection, { ...options, mode: 'default' }) : drizzle(connection); @@ -2432,7 +2435,7 @@ async function runSeedModule( const { databasePath: dbPath } = getLocalD1Target(root); const sqlite = await openSqliteDatabase(dbPath); try { - const options = getDrizzleSchemaOptions(schema); + const options = getDrizzleSchemaOptions(schema, casing); const db = options ? drizzle(sqlite as never, options) : drizzle(sqlite as never); await seed({ dialect, db, schema }); } finally { @@ -2510,8 +2513,14 @@ async function importLocalModule(modulePath: string): Promise>(modulePath); } +/** The schema and casing that `void/db` gives drizzle, or undefined when neither is set. */ function getDrizzleSchemaOptions( schema: Record, -): { schema: Record } | undefined { - return Object.keys(schema).length > 0 ? { schema } : undefined; + casing: DatabaseCasing | undefined, +): { schema?: Record; casing?: DatabaseCasing } | undefined { + const options = { + ...(Object.keys(schema).length > 0 ? { schema } : {}), + ...(casing ? { casing } : {}), + }; + return Object.keys(options).length > 0 ? options : undefined; } diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 2b26fad89..b9fa9e494 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -11,7 +11,14 @@ import { materializeCloudflareConfig } from '../cloudflare-config.ts'; import { createProjectTsconfig, ensureProjectTsconfig } from '../codegen/project-tsconfig.ts'; import { TYPED_CONFIG_FILE } from '../config-loader.ts'; import { canRewriteVoidProjectConfig, writeVoidProjectConfig } from '../config-write.ts'; -import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts'; +import { + databaseConfigValue, + getDatabaseCasing, + getDatabaseDialect, + readConfig, + type DatabaseDialect, + type VoidConfig, +} from '../config.ts'; import { PLACEHOLDER_DB_URL, writeDrizzleConfig } from '../drizzle/config.ts'; import { snapshotDrizzleOutputs } from '../drizzle/generate.ts'; import { detectFramework, type DetectedFramework } from '../plugin-inference.ts'; @@ -1361,46 +1368,54 @@ export function ensureVoidWorkerCompatibilityDate(root: string): void { log.step('Pinned worker compatibility_date in void.config.ts'); } +/** Write `dialect` to the config's `database`, keeping its casing. */ +function writeDatabaseDialect(root: string, config: VoidConfig, dialect: DatabaseDialect): void { + const next: VoidConfig = { ...config }; + const database = databaseConfigValue(dialect, getDatabaseCasing(config)); + if (database === undefined) { + delete next.database; + } else { + next.database = database; + } + writeVoidProjectConfig(root, next); +} + function clearConfiguredDatabase( root: string, messages: { unchanged: string; updated: string }, ): void { - const config = readConfig(root) as Record; + const config = readConfig(root); - if (config.database !== 'pg' && config.database !== 'mysql') { + if (getDatabaseDialect(config) === 'sqlite') { log.info(messages.unchanged); return; } - delete config.database; - - writeVoidProjectConfig(root, config); + writeDatabaseDialect(root, config, 'sqlite'); log.step(messages.updated); } function applyDatabaseDialect(root: string, dialect: DatabaseDialect): void { - const config = readConfig(root) as Record; + const config = readConfig(root); if (dialect === 'postgresql') { - if (config.database === 'pg') { + if (getDatabaseDialect(config) === 'postgresql') { log.info('PostgreSQL already configured in void.config.ts'); return; } - config.database = 'pg'; - writeVoidProjectConfig(root, config); + writeDatabaseDialect(root, config, 'postgresql'); log.step('Configured PostgreSQL in void.config.ts'); return; } if (dialect === 'mysql') { - if (config.database === 'mysql') { + if (getDatabaseDialect(config) === 'mysql') { log.info('MySQL already configured in void.config.ts'); return; } - config.database = 'mysql'; - writeVoidProjectConfig(root, config); + writeDatabaseDialect(root, config, 'mysql'); log.step('Configured MySQL in void.config.ts'); return; } diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index 35cde1908..0c3b80cdf 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -4,7 +4,7 @@ import { join } from 'pathslash'; import { analyzeApplication } from '../application-routing.ts'; import { loadVoidAuthConfig } from '../auth/config.ts'; import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '../auth/drizzle.ts'; -import { readConfig } from '../config.ts'; +import { getDatabaseCasing, readConfig } from '../config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; import { loadLocalEnv } from '../shared/env.ts'; import { generateDbDts } from './db-types.ts'; @@ -74,7 +74,8 @@ export async function prepareProject(root: string): Promise = new Set([ */ export const VALID_REDIRECT_STATUSES: Set = new Set([301, 302, 303, 307, 308]); const VALID_DATABASE_VALUES = new Set(['pg', 'mysql']); +const VALID_DATABASE_DIALECTS = new Set(['sqlite', 'pg', 'mysql']); +const VALID_DATABASE_CASINGS = new Set(['snake_case', 'camelCase']); /** Cloudflare's hard maximum for Workers CPU time per request (5 minutes). */ const MAX_WORKER_CPU_MS = 300000; const VALID_SANDBOX_INSTANCE_TYPES = new Set([ @@ -915,8 +928,36 @@ function validateConfigObject(obj: unknown): VoidConfig { // Validate database if (obj.database !== undefined) { - if (typeof obj.database !== 'string' || !VALID_DATABASE_VALUES.has(obj.database)) { - throw new Error(`config: "database" in ${CONFIG_FILE} must be "pg" or "mysql".`); + if (isRecord(obj.database)) { + const databaseCfg = obj.database; + for (const key of Object.keys(databaseCfg)) { + if (key !== 'dialect' && key !== 'casing') { + throw new Error( + `config: Unknown key 'database.${key}' in ${CONFIG_FILE}. Valid: dialect, casing.`, + ); + } + } + if ( + databaseCfg.dialect !== undefined && + (typeof databaseCfg.dialect !== 'string' || + !VALID_DATABASE_DIALECTS.has(databaseCfg.dialect)) + ) { + throw new Error( + `config: "database.dialect" in ${CONFIG_FILE} must be "sqlite", "pg" or "mysql".`, + ); + } + if ( + databaseCfg.casing !== undefined && + (typeof databaseCfg.casing !== 'string' || !VALID_DATABASE_CASINGS.has(databaseCfg.casing)) + ) { + throw new Error( + `config: "database.casing" in ${CONFIG_FILE} must be "snake_case" or "camelCase".`, + ); + } + } else if (typeof obj.database !== 'string' || !VALID_DATABASE_VALUES.has(obj.database)) { + throw new Error( + `config: "database" in ${CONFIG_FILE} must be "pg", "mysql" or an object with "dialect" and "casing".`, + ); } } @@ -1026,18 +1067,44 @@ export function resolveBindingNames( } /** - * Get the database dialect from config. Defaults to "sqlite". + * Get the database dialect from config, in its string or object form. Defaults to "sqlite". */ export function getDatabaseDialect(config: VoidConfig): DatabaseDialect { - if (config.database === 'pg') { + const database = config.database; + const value = typeof database === 'object' && database !== null ? database.dialect : database; + if (value === 'pg') { return 'postgresql'; } - if (config.database === 'mysql') { + if (value === 'mysql') { return 'mysql'; } return 'sqlite'; } +/** + * Get Drizzle's `casing` from config. Undefined when unset: callers then pass no casing, + * so Drizzle keeps its default of naming a column after its key. + */ +export function getDatabaseCasing(config: VoidConfig): DatabaseCasing | undefined { + const database = config.database; + return typeof database === 'object' && database !== null ? database.casing : undefined; +} + +/** + * The `database` value for a dialect that keeps the configured casing. Without casing it is + * the string shorthand, or undefined for the default D1 (SQLite). + */ +export function databaseConfigValue( + dialect: DatabaseDialect, + casing: DatabaseCasing | undefined, +): VoidConfig['database'] { + const shorthand = dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql' : undefined; + if (casing === undefined) { + return shorthand; + } + return shorthand ? { dialect: shorthand, casing } : { casing }; +} + /** * Convert the `headers` config field into `HeaderRule[]` for the deploy manifest. * diff --git a/packages/void/src/drizzle/config.ts b/packages/void/src/drizzle/config.ts index fb1cdb584..62342c09e 100644 --- a/packages/void/src/drizzle/config.ts +++ b/packages/void/src/drizzle/config.ts @@ -1,7 +1,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'pathslash'; -import { readProjectPaths } from '../project-paths.ts'; +import { getDatabaseCasing, readConfig } from '../config.ts'; +import { resolveProjectPaths } from '../project-paths.ts'; import { discoverSchema, schemaSpecifier } from './discover.ts'; type DrizzleDialect = 'sqlite' | 'postgresql' | 'mysql'; @@ -18,7 +19,8 @@ export const PLACEHOLDER_DB_URL: Record = { * Schema and migration paths are relative to the project root: drizzle-kit * resolves them against its working directory, and `void db` always runs it * with `cwd` set to the project root, so the config file itself may live - * anywhere (see `outDir`). + * anywhere (see `outDir`). It carries `database.casing` from the project's + * config, which `void/db` also passes to drizzle. * * @param options.outDir Directory for the config file. When set, the file is * written readable by the owner only. @@ -30,7 +32,9 @@ export function writeDrizzleConfig( dialect: DrizzleDialect = 'sqlite', options?: { additionalSchemas?: Array; outDir?: string }, ): string { - const paths = readProjectPaths(root); + const projectConfig = readConfig(root); + const paths = resolveProjectPaths(root, projectConfig); + const casing = getDatabaseCasing(projectConfig); const schema = discoverSchema(root, paths); const schemaPaths = [ ...(schema ? [schemaSpecifier(schema, root)] : []), @@ -47,6 +51,7 @@ export function writeDrizzleConfig( const config = { dialect, + ...(casing ? { casing } : {}), schema: schemaPaths.length === 1 ? schemaPaths[0] : schemaPaths, out: `./${relative(root, paths.migrationsDir)}`, migrations: { prefix: 'timestamp' }, diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index c4b435705..9d056f4d4 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -27,7 +27,7 @@ import { restoreCloudflarePreviewPointer, stashCloudflareDeployPointer, } from './cloudflare-config.ts'; -import { readConfig, isNodeTarget } from './config.ts'; +import { getDatabaseCasing, readConfig, isNodeTarget } from './config.ts'; import { inferBindingsFromSource } from './inference/walk.ts'; import { resolveLocalD1Target, writeLocalD1PersistState } from './local-d1.ts'; import { voidLog, voidWarn } from './log.ts'; @@ -762,6 +762,7 @@ export function voidPlugin(options?: { drizzlePlugin(root, false, dialect, { d1BindingName: bindingNames?.d1, forceVirtualDb: authEnabled || bindingNames?.d1 !== undefined, + casing: getDatabaseCasing(config), localD1DatabaseId: localD1.databaseId, localD1PersistRoot: localD1.persistRoot, redirectDbTypePaths, @@ -999,6 +1000,7 @@ export function voidPlugin(options?: { drizzlePlugin(root, true, dialect, { d1BindingName: bindingNames?.d1, forceVirtualDb: authEnabled || bindingNames?.d1 !== undefined, + casing: getDatabaseCasing(config), localD1DatabaseId: localD1.databaseId, localD1PersistRoot: localD1.persistRoot, }), diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 996e4f98e..63d44925b 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -5,7 +5,14 @@ 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 { + getDatabaseCasing, + getDatabaseDialect, + readConfig, + resolveBindingNames, + type DatabaseCasing, + type VoidConfig, +} from './config.ts'; import { discoverSchema } from './drizzle/discover.ts'; import { voidWarn } from './log.ts'; import { readProjectPaths, resolveProjectPaths, type ProjectPaths } from './project-paths.ts'; @@ -50,6 +57,8 @@ function requestPool(resolve, own = {}) { type VoidDbModuleOptions = { d1BindingName?: string; forceVirtualDb?: boolean; + /** Drizzle's `casing` from `database.casing`. Unset passes none to drizzle. */ + casing?: DatabaseCasing; }; type DrizzlePluginOptions = VoidDbModuleOptions & { localD1DatabaseId?: string; @@ -71,7 +80,13 @@ function createVoidDbModule( ) { const schema = discoverSchema(root, paths); // 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'; + // It cannot apply `database.casing` either, which also names the columns of tables that an + // app keeps outside its schema. + const hasDbRuntime = + schema != null || + options.forceVirtualDb === true || + options.casing !== undefined || + dialect !== 'sqlite'; const d1BindingName = options.d1BindingName ?? 'DB'; return { @@ -130,12 +145,17 @@ export function createDb() { const schemaImport = schema ? `import * as schema from ${JSON.stringify(schema.path)};` : ''; - const drizzleConfig = schema ? ', { schema }' : ''; + const casingOption = options.casing ? `casing: ${JSON.stringify(options.casing)}` : ''; + const drizzleOptions = [schema ? 'schema' : '', casingOption].filter(Boolean); + const drizzleConfig = drizzleOptions.length > 0 ? `, { ${drizzleOptions.join(', ')} }` : ''; + // The same options as keys after the client in drizzle's single-object form. + const drizzleOptionKeys = drizzleOptions.map((option) => `, ${option}`).join(''); 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"' : ''; + const mysqlDrizzleConfig = + (schema ? ', schema, mode: "default"' : '') + (casingOption ? `, ${casingOption}` : ''); return ` import { drizzle } from ${JSON.stringify(drizzleMysql2Resolved)}; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; @@ -257,7 +277,7 @@ export const db = new Proxy({}, { // 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' : ''} }); + instance ??= drizzle({ client: requestPool(getPool)${drizzleOptionKeys} }); const value = Reflect.get(instance, prop); return typeof value === "function" ? value.bind(instance) : value; }, @@ -438,7 +458,11 @@ export function voidDbBundlerPlugin( root, dialect, // The package's own `void/db` reads D1 from the `DB` binding. - { d1BindingName, forceVirtualDb: d1BindingName !== undefined }, + { + d1BindingName, + forceVirtualDb: d1BindingName !== undefined, + casing: getDatabaseCasing(config), + }, resolveProjectPaths(root, config), ); const driverPackage = diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 9427b811f..3bffe1e62 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -10,7 +10,7 @@ import { resolveLiveBindingConfig, syncWranglerBindings, } from './cli/wrangler.ts'; -import { readConfig } from './config.ts'; +import { getDatabaseCasing, readConfig } from './config.ts'; import { findDependencyClosure, findVoidDependentPackages } from './installed-packages.ts'; import { resolveLocalD1Target } from './local-d1.ts'; import { voidLog } from './log.ts'; @@ -382,6 +382,7 @@ export default { drizzlePlugin(root, false, dialect, { d1BindingName: bindingNames?.d1, forceVirtualDb: bindingNames?.d1 !== undefined, + casing: getDatabaseCasing(config), localD1DatabaseId: localD1.databaseId, localD1PersistRoot: frameworkPersistRoot, nitroBundlesServer: detected.name === 'nuxt' || detected.name === 'analog', diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index b7c1f318c..912a4c44e 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -16,6 +16,8 @@ import { convertRewritesConfig, resolveBindingNames, isNodeTarget, + databaseConfigValue, + getDatabaseCasing, getDatabaseDialect, VALID_REDIRECT_STATUSES, countSplats, @@ -1430,17 +1432,69 @@ describe('database config', () => { cleanup(); }); - it('rejects invalid value', () => { - setup(JSON.stringify({ database: 'mariadb' })); - expect(() => readConfig(tmpDir)).toThrow('"database" in void.json must be "pg" or "mysql"'); + it('has no casing in the string form', () => { + setup(JSON.stringify({ database: 'pg' })); + expect(getDatabaseCasing(readConfig(tmpDir))).toBeUndefined(); cleanup(); }); - it('rejects object value', () => { - setup(JSON.stringify({ database: { dialect: 'pg' } })); - expect(() => readConfig(tmpDir)).toThrow('"database" in void.json must be "pg" or "mysql"'); + it.each([ + [{ dialect: 'pg', casing: 'snake_case' }, 'postgresql', 'snake_case'], + [{ dialect: 'mysql', casing: 'camelCase' }, 'mysql', 'camelCase'], + [{ dialect: 'sqlite', casing: 'snake_case' }, 'sqlite', 'snake_case'], + [{ casing: 'snake_case' }, 'sqlite', 'snake_case'], + [{ dialect: 'pg' }, 'postgresql', undefined], + [{}, 'sqlite', undefined], + ])('accepts the object form %j', (database, dialect, casing) => { + setup(JSON.stringify({ database })); + const config = readConfig(tmpDir); + expect(config.database).toEqual(database); + expect(getDatabaseDialect(config)).toBe(dialect); + expect(getDatabaseCasing(config)).toBe(casing); + cleanup(); + }); + + it.each([ + ['mariadb', '"database" in void.json must be "pg", "mysql" or an object'], + [1, '"database" in void.json must be "pg", "mysql" or an object'], + [['pg'], '"database" in void.json must be "pg", "mysql" or an object'], + [{ dialect: 'mariadb' }, '"database.dialect" in void.json must be "sqlite", "pg" or "mysql"'], + [ + { dialect: 'postgresql' }, + '"database.dialect" in void.json must be "sqlite", "pg" or "mysql"', + ], + [{ casing: 'snake' }, '"database.casing" in void.json must be "snake_case" or "camelCase"'], + [{ casing: 'preserve' }, '"database.casing" in void.json must be "snake_case" or "camelCase"'], + [ + { dialect: 'pg', url: 'x' }, + "Unknown key 'database.url' in void.json. Valid: dialect, casing.", + ], + ])('rejects %j', (database, message) => { + setup(JSON.stringify({ database })); + expect(() => readConfig(tmpDir)).toThrow(message); cleanup(); }); + + it('writes the string form unless a casing is set', () => { + expect(databaseConfigValue('sqlite', undefined)).toBeUndefined(); + expect(databaseConfigValue('postgresql', undefined)).toBe('pg'); + expect(databaseConfigValue('mysql', undefined)).toBe('mysql'); + expect(databaseConfigValue('sqlite', 'snake_case')).toEqual({ casing: 'snake_case' }); + expect(databaseConfigValue('postgresql', 'snake_case')).toEqual({ + dialect: 'pg', + casing: 'snake_case', + }); + }); + + it('schema.json database matches runtime validation', () => { + const schema = JSON.parse(readFileSync(join(import.meta.dirname, '../../schema.json'), 'utf8')); + const [shorthand, object] = schema.properties.database.oneOf; + expect(shorthand.enum).toEqual(['pg', 'mysql']); + expect(Object.keys(object.properties)).toEqual(['dialect', 'casing']); + expect(object.properties.dialect.enum).toEqual(['sqlite', 'pg', 'mysql']); + expect(object.properties.casing.enum).toEqual(['snake_case', 'camelCase']); + expect(object.additionalProperties).toBe(false); + }); }); describe('resolveBindingNames', () => { diff --git a/packages/void/test/unit/db-casing.test.ts b/packages/void/test/unit/db-casing.test.ts new file mode 100644 index 000000000..a8416a42a --- /dev/null +++ b/packages/void/test/unit/db-casing.test.ts @@ -0,0 +1,270 @@ +import { once } from 'node:events'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, 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 { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer } from 'pglite-server'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { generateVoidAuthDrizzleSchema } from '../../src/auth/drizzle.ts'; +import { prepareProject } from '../../src/codegen/prepare.ts'; +import type { DatabaseCasing } from '../../src/config.ts'; +import { drizzlePlugin, voidDbBundlerPlugin } from '../../src/plugin-drizzle.ts'; +import { envContext } from '../../src/runtime/env-raw.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; + +type Dialect = 'sqlite' | 'postgresql' | 'mysql'; +type Query = PromiseLike>>; +type Db = { + select(): { from(table: unknown): Query }; + insert(table: unknown): { values(row: Record): PromiseLike }; + $client: { end(): Promise }; +}; +type DbModule = { db: Db; createDb(client: unknown): Db }; + +const roots: Array = []; +// The generated module imports the built runtime env; tests share the source one. +const builtEnv = JSON.stringify(fileURLToPath(import.meta.resolve('#self/_env'))); +const sourceEnv = JSON.stringify(new URL('../../src/runtime/env.ts', import.meta.url).href); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +const SCHEMAS: Record string> = { + sqlite: (core) => `import { integer, sqliteTable, text } from ${core}; +export const users = sqliteTable('users', { + id: integer().primaryKey(), + firstName: text().notNull(), + createdAt: integer().notNull(), +}); +`, + postgresql: (core) => `import { integer, pgTable, text } from ${core}; +export const users = pgTable('users', { + id: integer().primaryKey(), + firstName: text().notNull(), + createdAt: integer().notNull(), +}); +`, + mysql: (core) => `import { int, mysqlTable, varchar } from ${core}; +export const users = mysqlTable('users', { + id: int().primaryKey(), + firstName: varchar({ length: 255 }).notNull(), + createdAt: int().notNull(), +}); +`, +}; + +const CORES: Record = { + sqlite: 'drizzle-orm/sqlite-core', + postgresql: 'drizzle-orm/pg-core', + mysql: 'drizzle-orm/mysql-core', +}; + +/** A project whose schema declares its columns without names, for drizzle's casing to name. */ +function createRoot(dialect: Dialect): string { + const root = mkdtempSync(join(tmpdir(), 'void-db-casing-')); + roots.push(root); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db', 'schema.ts'), + SCHEMAS[dialect](JSON.stringify(import.meta.resolve(CORES[dialect]))), + ); + return root; +} + +function loadSource(root: string, dialect: Dialect, casing?: DatabaseCasing): string { + const plugin = drizzlePlugin(root, false, dialect, casing ? { casing } : {}); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + return load.call({}, '\0virtual:void-db'); +} + +/** Load the app's generated `void/db` with drizzle's real drivers. */ +async function loadDb(root: string, dialect: Dialect, casing?: DatabaseCasing) { + const code = loadSource(root, dialect, casing); + expect(code).toContain(builtEnv); + writeFileSync(join(root, 'virtual-db.mjs'), code.replace(builtEnv, sourceEnv)); + const module = (await import( + `${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}` + )) as DbModule; + const { users } = (await import(pathToFileURL(join(root, 'db', 'schema.ts')).href)) as { + users: unknown; + }; + return { ...module, users }; +} + +const ADA = { id: 1, firstName: 'Ada', createdAt: 1815 }; + +describe('void/db with database.casing', () => { + it('queries snake_case columns on D1', async () => { + const root = createRoot('sqlite'); + const { db, createDb, users } = await loadDb(root, 'sqlite', 'snake_case'); + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + d1Databases: ['DB'], + }), + ); + try { + const binding = (await miniflare.getBindings()).DB as D1Database; + await binding + .prepare( + 'CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, created_at INTEGER NOT NULL)', + ) + .run(); + + const rows = await withRuntimeEnv({ DB: binding }, async () => { + await db.insert(users).values(ADA); + return db.select().from(users); + }); + + expect(rows).toEqual([ADA]); + // Outside a request, such as in a cron job or at startup, `db` reads from the primary. + expect(await envContext.run({ DB: binding }, () => db.select().from(users))).toEqual([ADA]); + expect(await createDb(binding).select().from(users)).toEqual([ADA]); + expect( + (await binding.prepare('SELECT first_name, created_at FROM users').all()).results, + ).toEqual([{ first_name: 'Ada', created_at: 1815 }]); + } finally { + await miniflare.dispose(); + } + }, 30_000); + + it('queries snake_case columns on PostgreSQL', async () => { + const root = createRoot('postgresql'); + const { db, createDb, users } = await loadDb(root, 'postgresql', 'snake_case'); + const pglite = new PGlite(); + await pglite.waitReady; + await pglite.exec( + 'CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, created_at INTEGER NOT NULL);', + ); + const server = createServer(pglite); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const url = `postgresql://postgres@127.0.0.1:${(server.address() as AddressInfo).port}/postgres`; + try { + const rows = await withRuntimeEnv({ DATABASE_URL: url }, async () => { + try { + await db.insert(users).values(ADA); + return await db.select().from(users); + } finally { + await db.$client.end(); + } + }); + expect(rows).toEqual([ADA]); + + const direct = createDb(url); + try { + expect(await direct.select().from(users)).toEqual([ADA]); + } finally { + await direct.$client.end(); + } + const stored = await pglite.query('SELECT first_name, created_at FROM users'); + expect(stored.rows).toEqual([{ first_name: 'Ada', created_at: 1815 }]); + } finally { + server.close(); + await once(server, 'close'); + await pglite.close(); + } + }, 30_000); + + it('queries snake_case columns on MySQL', async () => { + const root = createRoot('mysql'); + const { db, createDb, users } = await loadDb(root, 'mysql', 'snake_case'); + const queries: Array = []; + const mysql = await startFakeMySqlServer((sql) => { + queries.push(sql); + return [{ id: 1, first_name: 'Ada', created_at: 1815 }]; + }); + const url = `mysql://void@127.0.0.1:${mysql.port}/app`; + try { + const rows = await withRuntimeEnv( + { DATABASE_URL: url }, + async () => await db.select().from(users), + ); + expect(rows).toEqual([ADA]); + expect(await createDb(url).select().from(users)).toEqual([ADA]); + expect(queries).toEqual([ + 'select `id`, `first_name`, `created_at` from `users`', + 'select `id`, `first_name`, `created_at` from `users`', + ]); + } finally { + await mysql.close(); + } + }); + + // Better Auth's adapter queries through the app's `db`, so its tables get the same casing. + it('keeps the column names that the Better Auth tables declare', async () => { + const root = createRoot('sqlite'); + const { createDb, users } = await loadDb(root, 'sqlite', 'snake_case'); + writeFileSync( + join(root, 'db', 'auth.ts'), + generateVoidAuthDrizzleSchema({ dialect: 'sqlite', providers: ['email'] }).replace( + '"void/schema-d1"', + JSON.stringify(import.meta.resolve(CORES.sqlite)), + ), + ); + const { userTable } = (await import(pathToFileURL(join(root, 'db', 'auth.ts')).href)) as { + userTable: unknown; + }; + const db = createDb({ withSession: () => ({}) }); + const sql = (table: unknown) => + (db.select().from(table) as unknown as { toSQL(): { sql: string } }).toSQL().sql; + + expect(sql(users)).toBe('select "id", "first_name", "created_at" from "users"'); + expect(sql(userTable)).toContain('"emailVerified", "image", "createdAt", "updatedAt"'); + }); + + it.each(['sqlite', 'postgresql', 'mysql'] as const)( + 'passes no casing to the %s client without database.casing', + (dialect) => { + const code = loadSource(createRoot(dialect), dialect); + expect(code).not.toContain('casing:'); + expect(loadSource(createRoot(dialect), dialect, 'camelCase')).toContain( + 'casing: "camelCase"', + ); + }, + ); + + it('reads the casing from the config in bundles outside the Vite plugins', () => { + const root = createRoot('postgresql'); + const plugin = voidDbBundlerPlugin(root, { + config: { database: { dialect: 'pg', casing: 'snake_case' } }, + }); + const load = plugin.load as (id: string) => string; + const code = load('\0virtual:void-db'); + expect(code).toContain('drizzle-orm/node-postgres'); + expect(code).toContain( + 'drizzle({ client: requestPool(getPool), schema, casing: "snake_case" })', + ); + expect(code).toContain('drizzle(connectionString, { schema, casing: "snake_case" })'); + }); + + it('serves its own void/db for casing without a schema', () => { + const root = mkdtempSync(join(tmpdir(), 'void-db-casing-')); + roots.push(root); + const plugin = voidDbBundlerPlugin(root, { config: { database: { casing: 'snake_case' } } }); + const resolveId = plugin.resolveId as (id: string, importer?: string) => Promise; + return expect(resolveId.call({}, 'void/db')).resolves.toBe('\0virtual:void-db'); + }); + + it('keeps db.d.ts in void prepare for casing without a schema', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-db-casing-')); + roots.push(root); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: { casing: 'snake_case' } })); + drizzlePlugin(root, false, 'sqlite', { casing: 'snake_case' }); + expect(existsSync(join(root, '.void', 'db.d.ts'))).toBe(true); + + const { removed } = await prepareProject(root); + + expect(removed).not.toContain('.void/db.d.ts'); + expect(existsSync(join(root, '.void', 'db.d.ts'))).toBe(true); + }); +}); diff --git a/packages/void/test/unit/db-seed.test.ts b/packages/void/test/unit/db-seed.test.ts index 14f5a6515..c0c0913cb 100644 --- a/packages/void/test/unit/db-seed.test.ts +++ b/packages/void/test/unit/db-seed.test.ts @@ -50,18 +50,24 @@ function createProjectRoot(options: { seedFile: { name: string; content: string }; extraSeedFile?: { name: string; content: string }; schema?: string; + migration?: string; + config?: Record; }): string { const root = mkdtempSync(join(import.meta.dirname, '.tmp-db-seed-')); const migrationsDir = join(root, 'db', 'migrations'); mkdirSync(join(migrationsDir, 'meta'), { recursive: true }); writeFileSync( join(migrationsDir, '0000_messages.sql'), - `CREATE TABLE messages ( + options.migration ?? + `CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL ); `, ); + if (options.config) { + writeFileSync(join(root, 'void.json'), JSON.stringify(options.config)); + } // Keep the journal coherent with the migration file above. writeFileSync( join(migrationsDir, 'meta', '_journal.json'), @@ -166,6 +172,47 @@ export const messages = sqliteTable("messages", { expect(output.printSuccess).toHaveBeenCalledWith('Seeded from db/seed.ts.'); }); + it('seeds the columns that database.casing names', async () => { + const root = createProjectRoot({ + config: { database: { casing: 'snake_case' } }, + migration: `CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + sent_at INTEGER NOT NULL +); +`, + schema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const messages = sqliteTable("messages", { + id: integer().primaryKey({ autoIncrement: true }), + text: text().notNull(), + sentAt: integer().notNull(), +}); +`, + seedFile: { + name: 'seed.ts', + content: `export default async function seed({ db, schema }) { + await db.insert(schema.messages).values({ text: "alpha", sentAt: 1815 }); +} +`, + }, + }); + + await runDbCommand(root, { action: 'seed' }); + + const Database = (await import('better-sqlite3')).default; + const db = new Database(getMiniflareDatabasePath(join(root, '.void'), 'local'), { + readonly: true, + }); + try { + expect(db.prepare('SELECT text, sent_at FROM messages').all()).toEqual([ + { text: 'alpha', sent_at: 1815 }, + ]); + } finally { + db.close(); + } + }); + it('seeds when db/schema.ts re-exports an extensionless module', async () => { const root = createProjectRoot({ schema: 'export * from "./schema/messages";\n', diff --git a/packages/void/test/unit/drizzle-config.test.ts b/packages/void/test/unit/drizzle-config.test.ts index 601fa0e14..833e7da00 100644 --- a/packages/void/test/unit/drizzle-config.test.ts +++ b/packages/void/test/unit/drizzle-config.test.ts @@ -1,9 +1,22 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { toSlash } from 'pathslash'; import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test'; +import { generateVoidAuthDrizzleSchema } from '../../src/auth/drizzle.ts'; +import { validateAuthMigrationSchema } from '../../src/cli/deploy-migrations.ts'; import { withPrivateDrizzleConfig, writeDrizzleConfig } from '../../src/drizzle/config.ts'; +import { collectMigrations } from '../../src/migrations/collect.ts'; describe('writeDrizzleConfig', () => { let root: string; @@ -28,6 +41,13 @@ describe('writeDrizzleConfig', () => { expect(config.out).toBe('./db/migrations'); expect(config.dbCredentials.url).toBe('/tmp/test.sqlite'); expect(config.migrations).toEqual({ prefix: 'timestamp' }); + expect(config).not.toHaveProperty('casing'); + }); + + it.each(['snake_case', 'camelCase'])('passes database.casing %s to drizzle-kit', (casing) => { + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: { dialect: 'pg', casing } })); + const configPath = writeDrizzleConfig(root, 'postgresql://dummy', 'postgresql'); + expect(JSON.parse(readFileSync(configPath, 'utf-8')).casing).toBe(casing); }); it('generates PostgreSQL config', () => { @@ -168,3 +188,100 @@ describe('withPrivateDrizzleConfig', () => { }, ); }); + +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); +const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs'); + +// Real drizzle-kit processes; Windows CI can take over five seconds for one. +describe( + 'drizzle-kit generate with database.casing', + { + timeout: process.platform === 'win32' ? 30_000 : 10_000, + }, + () => { + let root: string; + + beforeEach(() => { + // drizzle-kit loads the schema, which must resolve drizzle-orm from the package. + root = mkdtempSync(join(packageRoot, '.tmp-drizzle-casing-')); + mkdirSync(join(root, 'db')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function generate(dialect: 'sqlite' | 'postgresql', additionalSchemas?: Array) { + const configPath = writeDrizzleConfig( + root, + dialect === 'sqlite' ? ':memory:' : 'postgresql://dummy', + dialect, + { additionalSchemas }, + ); + execFileSync(process.execPath, [drizzleKitBin, 'generate', '--config', configPath], { + cwd: root, + stdio: 'pipe', + }); + return collectMigrations(join(root, 'db', 'migrations')); + } + + it('names PostgreSQL columns without a name in snake_case', () => { + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: { dialect: 'pg', casing: 'snake_case' } }), + ); + writeFileSync( + join(root, 'db', 'schema.ts'), + [ + 'import { pgTable, serial, timestamp } from "drizzle-orm/pg-core";', + 'export const posts = pgTable("posts", {', + ' id: serial().primaryKey(),', + ' createdAt: timestamp().notNull(),', + '});', + '', + ].join('\n'), + ); + + const [migration] = generate('postgresql'); + + expect(migration.sql).toContain('"created_at" timestamp NOT NULL'); + expect(migration.sql).not.toContain('createdAt'); + }); + + it('keeps the Better Auth column names that the auth schema declares', async () => { + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: { casing: 'snake_case' } }), + ); + writeFileSync( + join(root, 'db', 'schema.ts'), + [ + 'import { integer, sqliteTable } from "drizzle-orm/sqlite-core";', + 'export const posts = sqliteTable("posts", {', + ' id: integer().primaryKey(),', + ' createdAt: integer().notNull(),', + '});', + '', + ].join('\n'), + ); + const authSchema = join(root, '.void', 'better-auth-schema.ts'); + mkdirSync(dirname(authSchema), { recursive: true }); + writeFileSync( + authSchema, + generateVoidAuthDrizzleSchema({ dialect: 'sqlite', providers: ['email'] }).replace( + '"void/schema-d1"', + '"drizzle-orm/sqlite-core"', + ), + ); + + const migrations = generate('sqlite', [authSchema]); + + expect(migrations).toHaveLength(1); + expect(migrations[0].sql).toContain('`created_at` integer NOT NULL'); + expect(migrations[0].sql).toContain('`emailVerified` integer'); + expect(migrations[0].sql).not.toContain('email_verified'); + // The deploy gate compares the SQL with the tables Better Auth reads and writes. + await expect(validateAuthMigrationSchema(root, migrations)).resolves.toBeUndefined(); + }); + }, +); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index ede9980e2..0522cf669 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -36,6 +36,7 @@ const mockText = vi.fn(); const mockNote = vi.fn(); const mockOutro = vi.fn(); const mockLogWarn = vi.fn(); +const mockLogInfo = vi.fn(); const mockGetToken = vi.fn(); const mockGetApiUrl = vi.fn(); const mockRunLogin = vi.fn(); @@ -188,7 +189,7 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ success: vi.fn(), warn: (...args: Array) => mockLogWarn(...args), error: vi.fn(), - info: vi.fn(), + info: (...args: Array) => mockLogInfo(...args), step: vi.fn(), }, confirm: (...args: Array) => mockConfirm(...args), @@ -457,6 +458,7 @@ afterEach(() => { mockNote.mockReset(); mockOutro.mockReset(); mockLogWarn.mockReset(); + mockLogInfo.mockReset(); mockGetToken.mockReset(); mockGetApiUrl.mockReset(); mockRunLogin.mockReset(); @@ -2736,6 +2738,54 @@ export default config; rmSync(root, { recursive: true, force: true }); } }); + it('keeps database.casing when switching from pg to sqlite', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: { dialect: 'pg', casing: 'snake_case' } }, null, 2) + '\n', + ); + mockSelect.mockResolvedValueOnce('sqlite'); + mockConfirm + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(readConfig(root)).toEqual({ + database: { casing: 'snake_case' }, + worker: { + compatibility_date: LATEST_KNOWN_COMPAT_DATE, + }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('keeps a pg database object with casing when choosing pg', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: { dialect: 'pg', casing: 'snake_case' } }, null, 2) + '\n', + ); + mockSelect.mockResolvedValueOnce('postgresql'); + mockConfirm + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(mockLogInfo).toHaveBeenCalledWith('PostgreSQL already configured in void.config.ts'); + expect(readConfig(root).database).toEqual({ dialect: 'pg', casing: 'snake_case' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('removes the pg config when choosing no database yet', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 0a5174f95..d4005cd48 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -241,6 +241,33 @@ describe('voidPlugin Vite environments', () => { }, ); + it.each([ + ['a Pages app', {}], + ['a class A framework', { '@tanstack/react-start': '*' }], + ['a class B framework', { '@sveltejs/kit': '*' }], + ])('passes database.casing to the void/db client of %s', async (_, dependencies) => { + const root = createProjectRoot(); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module', dependencies })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ + database: { dialect: 'pg', casing: 'snake_case' }, + 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, plugins: [voidPlugin()] }, 'serve', 'development'); + const plugin = config.plugins.find((candidate) => candidate.name === 'void:drizzle'); + const load = getHookHandler(plugin?.load) as (this: object, id: string) => string; + + expect(load.call({}, '\0virtual:void-db')).toContain( + 'drizzle(connectionString, { schema, casing: "snake_case" })', + ); + }); + 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()); From 5d289b2817e6ffe6679ab4cc39c527e818ef58cf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 19 Oct 2026 17:45:37 +0800 Subject: [PATCH 2/4] fix(init): remove a casing-only database config for "No database" Choosing "No database" treated `database: { casing: 'snake_case' }` as already D1 and left it in place, although casing alone still makes Void serve `void/db`. Remove the whole `database` field whenever it is set, as main did for `'pg'` and `'mysql'`. Switching to D1 still keeps the casing. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/init.ts | 18 ++++++++++++++---- packages/void/test/unit/init.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index b9fa9e494..bb90e7df5 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1395,6 +1395,19 @@ function clearConfiguredDatabase( log.step(messages.updated); } +/** Remove `database` entirely: its casing alone still makes Void serve `void/db`. */ +function removeDatabaseConfig(root: string): void { + const config = readConfig(root); + if (config.database === undefined) { + log.info('No database configured yet'); + return; + } + const next: VoidConfig = { ...config }; + delete next.database; + writeVoidProjectConfig(root, next); + log.step('Removed database config from void.config.ts'); +} + function applyDatabaseDialect(root: string, dialect: DatabaseDialect): void { const config = readConfig(root); @@ -1443,10 +1456,7 @@ function detectDatabaseSetup(root: string): DatabaseSetupChoice { function applyDatabaseSetup(root: string, choice: DatabaseSetupChoice): void { if (choice === 'none') { - clearConfiguredDatabase(root, { - unchanged: 'No database configured yet', - updated: 'Removed database config from void.config.ts', - }); + removeDatabaseConfig(root); return; } diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 0522cf669..b8ab75787 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -2805,6 +2805,28 @@ export default config; } }); + it('removes a casing-only database config when choosing no database yet', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: { casing: 'snake_case' } }, null, 2) + '\n', + ); + mockSelect.mockResolvedValueOnce('none'); + mockConfirm.mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(readConfig(root)).toEqual({ + worker: { + compatibility_date: LATEST_KNOWN_COMPAT_DATE, + }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('can log in and create a project during init', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { From cd7a2b4b6970764b2382e89648f1cf26ab0f080a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 19 Oct 2026 17:53:47 +0800 Subject: [PATCH 3/4] fix(db): inline void-dependent packages in SvelteKit D1 dev when casing is set A D1 app's installed packages kept their own void/db, which never gets database.casing. Inline them like PostgreSQL and MySQL apps when casing is configured, so they share the app's virtual module. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-framework.ts | 6 ++- .../test/unit/plugin-vite-environment.test.ts | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 3bffe1e62..a919d52c3 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -203,10 +203,12 @@ export function createFrameworkPlugins(input: { // 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. + // those packages stay with Node, unless `database.casing` is set: only the + // virtual module applies it. + const inlineDependents = dialect !== 'sqlite' || getDatabaseCasing(config) !== undefined; return { ssr: { - noExternal: ['void', ...(dialect === 'sqlite' ? [] : findVoidDependentPackages(root))], + noExternal: ['void', ...(inlineDependents ? findVoidDependentPackages(root) : [])], }, }; }, diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index d4005cd48..cd3d34b5a 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -519,6 +519,48 @@ describe('voidPlugin Vite environments', () => { } }); + it('serves the app void/db to installed packages that depend on void in SvelteKit D1 dev with casing', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'db-lib': '*' }, + devDependencies: { '@sveltejs/kit': '*', void: '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: { casing: 'snake_case' } })); + 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'); + const lib = await server.environments.ssr.transformRequest('/node_modules/db-lib/index.js'); + + expect(app?.code).not.toContain('__vite_ssr_import__("db-lib"'); + expect(lib?.code).toContain('virtual:void-db'); + } 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(); From e123ca43a497a181508dc0e6db55baed81313034 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 19 Oct 2026 18:01:18 +0800 Subject: [PATCH 4/4] fix(db): bundle void-dependent packages in SvelteKit D1 builds when casing is set A D1 build keeps Kit's output, so installed packages that import void stayed external and loaded their own void/db without database.casing. Bundle them when casing is set, as the dev server now inlines them. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-framework.ts | 18 +++++++++ .../test/unit/plugin-vite-environment.test.ts | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index a919d52c3..a0accd704 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -360,6 +360,23 @@ export default { } : undefined; + // A D1 build keeps Kit's output, so installed packages that import void stay external and + // load their own void/db. Only the virtual module applies `database.casing`: bundle them when + // it is set, as the dev server inlines them. + const svelteKitD1CasingBuildPlugin: Plugin | undefined = + detected.class === 'b' && dialect === 'sqlite' && getDatabaseCasing(config) !== undefined + ? { + name: 'void:sveltekit-d1-casing-build', + apply: 'build', + configEnvironment(name) { + if (name !== 'ssr') { + return; + } + return { resolve: { noExternal: findVoidDependentPackages(root) } }; + }, + } + : undefined; + return [ devTriggerSecretGuardPlugin(), createCloudflareBuiltinsExternalPlugin(), @@ -369,6 +386,7 @@ export default { ...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []), svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), + ...(svelteKitD1CasingBuildPlugin ? [svelteKitD1CasingBuildPlugin] : []), clientStubsPlugin, // Also serves the Node built-ins that the driver requires in SvelteKit's workerd build. serverRuntimeOptimizerPlugin, diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index cd3d34b5a..6b8e33038 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -561,6 +561,46 @@ describe('voidPlugin Vite environments', () => { } }); + it.each([ + ['bundles', { casing: 'snake_case' }], + ['leaves out', {} as { casing?: string }], + ])( + '%s installed packages that depend on void in a SvelteKit D1 build with database %j', + async (_, database) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'db-lib': '*' }, + devDependencies: { '@sveltejs/kit': '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database })); + installPackage(root, 'db-lib', { type: 'module', peerDependencies: { void: '*' } }, ''); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + // Kit's plugin adds the ssr environment that builds the server. + const config = await resolveConfig( + { + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + environments: { ssr: {} }, + }, + 'build', + 'production', + ); + + expect(config.environments.ssr!.resolve.noExternal).toEqual( + database.casing + ? expect.arrayContaining(['db-lib']) + : expect.not.arrayContaining(['db-lib']), + ); + }, + ); + // 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();