Skip to content

Commit 293ea34

Browse files
Brooooooklynclaude
andauthored
feat(db): support Drizzle's casing option in void/db and void db (#494)
Fixes #466 ## What ```ts // void.config.ts database: 'pg' // unchanged shorthand database: { dialect: 'pg', casing: 'snake_case' } // new object form database: { casing: 'snake_case' } // D1 (default dialect) ``` With `casing: 'snake_case'`, a column declared without a name (`createdAt: timestamp()`) is stored as `created_at`, both in `void/db` queries and in what `void db generate` writes. ``` database.casing ──► runtime void/db (D1, pg, mysql: db, createDb, createPrimaryDb) ├─► void db seed └─► .void/drizzle.config.json "casing" → void db generate / push / deploy drift check ``` ## Design - **Why the object form:** all database options stay under one key, and the string shorthand keeps working. `dialect` defaults to `"sqlite"` (D1). - **Validation:** unknown keys, a bad `dialect` or a bad `casing` each give a clear config error. `schema.json` accepts both forms. - **One way to read it:** every reader of `database` goes through `getDatabaseDialect` / `getDatabaseCasing`. That covers application analysis (deploy, Hyperdrive, manifest dialect), the CLI (`db`, `gen`, `init`, wrapper), nitro, routing, the drizzle plugin and the framework plugin. `void init` keeps the casing when it changes the dialect. - **No casing means no change:** without `casing`, the generated `void/db` module and the drizzle-kit config are byte-identical to `main`. This was checked in all 12 dialect × schema × forced-virtual combinations. - **Introspection** (drizzle-kit's separate `introspect.casing`) is not touched. - **Auth:** the built-in auth tables name every column explicitly, so `casing` doesn't change them. A real sign-up worked with snake_case on. ## Tests | Area | Test | |---|---| | Runtime, real queries | `db-casing.test.ts`: D1 (Miniflare, inside a request and outside it via `createPrimaryDb`), PGlite, and fake MySQL hit snake_case columns | | drizzle-kit | real drizzle-kit generate writes `created_at`; config writer emits `casing` | | Plugins | `plugin-vite-environment.test.ts`: Pages, class A (TanStack Start) and class B (SvelteKit) apps read casing from the config | | Config / init / seed / prepare | object-form parsing and errors; init keeps casing; seed uses casing; `void prepare` agrees with the plugin on `db.d.ts` | - On `main`'s source, 36 of these tests fail; with the change, all 482 pass. - Undoing each wiring point fails at least one test. - `check:sdk` (12/12), `check:platform` (7/7), `vp fmt --check`, `vp lint`, and 38 db/deploy/config/inference unit files (642 tests) all pass. - A scratch app (dev, build + preview, `void db generate`/`migrate`/`seed`/`push`, deploy drift check) showed snake_case columns in D1 (`PRAGMA table_info`). Docs: `docs/guide/database.md` (Column casing) and `docs/reference/config.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4270ac0 commit 293ea34

17 files changed

Lines changed: 937 additions & 55 deletions

‎docs/guide/database.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,32 @@ export * from "./schema/users";
114114
export * from "./schema/posts";
115115
```
116116

117+
### Column casing
118+
119+
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.
120+
121+
```ts
122+
// void.config.ts
123+
import { defineConfig } from 'void/config';
124+
125+
export default defineConfig({
126+
database: { dialect: 'pg', casing: 'snake_case' },
127+
});
128+
```
129+
130+
```ts
131+
// db/schema.ts
132+
import { pgTable, serial, text, timestamp } from 'void/schema-pg';
133+
134+
export const posts = pgTable('posts', {
135+
id: serial().primaryKey(),
136+
title: text().notNull(),
137+
createdAt: timestamp().notNull().defaultNow(), // created_at
138+
});
139+
```
140+
141+
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.
142+
117143
## Querying
118144

119145
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`:

‎docs/reference/config.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ Database backend. Omit for D1/SQLite (default). Set to `"pg"` or `"mysql"` for a
141141
| `"pg"` | PostgreSQL via Hyperdrive; bring your own database |
142142
| `"mysql"` | MySQL via Hyperdrive; bring your own database |
143143

144+
Use an object to also set how Drizzle names columns declared without a name:
145+
146+
```json
147+
{
148+
"database": { "dialect": "pg", "casing": "snake_case" }
149+
}
150+
```
151+
152+
| Key | Values | Description |
153+
| --------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154+
| `dialect` | `"sqlite"` (default), `"pg"`, `"mysql"` | Database backend. `"sqlite"` is D1 |
155+
| `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 |
156+
144157
When using an external database:
145158

146159
- Add `DATABASE_URL` to `.env` for local development

‎packages/void/schema.json‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,29 @@
392392
"additionalProperties": false
393393
},
394394
"database": {
395-
"type": "string",
396-
"enum": ["pg", "mysql"],
397-
"description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL or \"mysql\" for MySQL via Hyperdrive."
395+
"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.",
396+
"oneOf": [
397+
{
398+
"type": "string",
399+
"enum": ["pg", "mysql"]
400+
},
401+
{
402+
"type": "object",
403+
"properties": {
404+
"dialect": {
405+
"type": "string",
406+
"enum": ["sqlite", "pg", "mysql"],
407+
"description": "Database backend. Defaults to \"sqlite\" (D1)."
408+
},
409+
"casing": {
410+
"type": "string",
411+
"enum": ["snake_case", "camelCase"],
412+
"description": "Drizzle casing: how columns declared without a name are named in the database, for queries and generated migrations."
413+
}
414+
},
415+
"additionalProperties": false
416+
}
417+
]
398418
},
399419
"ai": {
400420
"type": "object",

‎packages/void/src/cli/db.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { createJiti } from 'jiti';
1515
import { basename, dirname, extname, join, relative, resolve } from 'pathslash';
1616
import {
1717
readConfig,
18+
getDatabaseCasing,
1819
getDatabaseDialect,
1920
resolveBindingNames,
21+
type DatabaseCasing,
2022
type DatabaseDialect,
2123
} from '../config.ts';
2224
import type { DatabaseProviderAdapter, DatabaseProviderDialect } from '../database-provider.ts';
@@ -2399,11 +2401,12 @@ async function runSeedModule(
23992401
dialect: DatabaseDialect,
24002402
): Promise<void> {
24012403
const schema = await loadSeedSchema(root);
2404+
const casing = getDatabaseCasing(readConfig(root));
24022405

24032406
if (dialect === 'postgresql') {
24042407
const { drizzle } = await import('drizzle-orm/node-postgres');
24052408
await withPgClient(root, async (client) => {
2406-
const options = getDrizzleSchemaOptions(schema);
2409+
const options = getDrizzleSchemaOptions(schema, casing);
24072410
const drizzleClient = client as never;
24082411
const db = options ? drizzle(drizzleClient, options) : drizzle(drizzleClient);
24092412
await seed({ dialect, db, schema });
@@ -2416,7 +2419,7 @@ async function runSeedModule(
24162419
const { createConnection } = await import('mysql2/promise');
24172420
const connection = await createConnection(getLocalDatabaseUrl(root, 'mysql'));
24182421
try {
2419-
const options = getDrizzleSchemaOptions(schema);
2422+
const options = getDrizzleSchemaOptions(schema, casing);
24202423
const db = options
24212424
? drizzle(connection, { ...options, mode: 'default' })
24222425
: drizzle(connection);
@@ -2432,7 +2435,7 @@ async function runSeedModule(
24322435
const { databasePath: dbPath } = getLocalD1Target(root);
24332436
const sqlite = await openSqliteDatabase(dbPath);
24342437
try {
2435-
const options = getDrizzleSchemaOptions(schema);
2438+
const options = getDrizzleSchemaOptions(schema, casing);
24362439
const db = options ? drizzle(sqlite as never, options) : drizzle(sqlite as never);
24372440
await seed({ dialect, db, schema });
24382441
} finally {
@@ -2510,8 +2513,14 @@ async function importLocalModule(modulePath: string): Promise<Record<string, unk
25102513
return jiti.import<Record<string, unknown>>(modulePath);
25112514
}
25122515

2516+
/** The schema and casing that `void/db` gives drizzle, or undefined when neither is set. */
25132517
function getDrizzleSchemaOptions(
25142518
schema: Record<string, unknown>,
2515-
): { schema: Record<string, unknown> } | undefined {
2516-
return Object.keys(schema).length > 0 ? { schema } : undefined;
2519+
casing: DatabaseCasing | undefined,
2520+
): { schema?: Record<string, unknown>; casing?: DatabaseCasing } | undefined {
2521+
const options = {
2522+
...(Object.keys(schema).length > 0 ? { schema } : {}),
2523+
...(casing ? { casing } : {}),
2524+
};
2525+
return Object.keys(options).length > 0 ? options : undefined;
25172526
}

‎packages/void/src/cli/init.ts‎

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ import { materializeCloudflareConfig } from '../cloudflare-config.ts';
1111
import { createProjectTsconfig, ensureProjectTsconfig } from '../codegen/project-tsconfig.ts';
1212
import { TYPED_CONFIG_FILE } from '../config-loader.ts';
1313
import { canRewriteVoidProjectConfig, writeVoidProjectConfig } from '../config-write.ts';
14-
import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts';
14+
import {
15+
databaseConfigValue,
16+
getDatabaseCasing,
17+
getDatabaseDialect,
18+
readConfig,
19+
type DatabaseDialect,
20+
type VoidConfig,
21+
} from '../config.ts';
1522
import { PLACEHOLDER_DB_URL, writeDrizzleConfig } from '../drizzle/config.ts';
1623
import { snapshotDrizzleOutputs } from '../drizzle/generate.ts';
1724
import { detectFramework, type DetectedFramework } from '../plugin-inference.ts';
@@ -1361,46 +1368,67 @@ export function ensureVoidWorkerCompatibilityDate(root: string): void {
13611368
log.step('Pinned worker compatibility_date in void.config.ts');
13621369
}
13631370

1371+
/** Write `dialect` to the config's `database`, keeping its casing. */
1372+
function writeDatabaseDialect(root: string, config: VoidConfig, dialect: DatabaseDialect): void {
1373+
const next: VoidConfig = { ...config };
1374+
const database = databaseConfigValue(dialect, getDatabaseCasing(config));
1375+
if (database === undefined) {
1376+
delete next.database;
1377+
} else {
1378+
next.database = database;
1379+
}
1380+
writeVoidProjectConfig(root, next);
1381+
}
1382+
13641383
function clearConfiguredDatabase(
13651384
root: string,
13661385
messages: { unchanged: string; updated: string },
13671386
): void {
1368-
const config = readConfig(root) as Record<string, unknown>;
1387+
const config = readConfig(root);
13691388

1370-
if (config.database !== 'pg' && config.database !== 'mysql') {
1389+
if (getDatabaseDialect(config) === 'sqlite') {
13711390
log.info(messages.unchanged);
13721391
return;
13731392
}
13741393

1375-
delete config.database;
1376-
1377-
writeVoidProjectConfig(root, config);
1394+
writeDatabaseDialect(root, config, 'sqlite');
13781395
log.step(messages.updated);
13791396
}
13801397

1398+
/** Remove `database` entirely: its casing alone still makes Void serve `void/db`. */
1399+
function removeDatabaseConfig(root: string): void {
1400+
const config = readConfig(root);
1401+
if (config.database === undefined) {
1402+
log.info('No database configured yet');
1403+
return;
1404+
}
1405+
const next: VoidConfig = { ...config };
1406+
delete next.database;
1407+
writeVoidProjectConfig(root, next);
1408+
log.step('Removed database config from void.config.ts');
1409+
}
1410+
13811411
function applyDatabaseDialect(root: string, dialect: DatabaseDialect): void {
1382-
const config = readConfig(root) as Record<string, unknown>;
1412+
const config = readConfig(root);
13831413

13841414
if (dialect === 'postgresql') {
1385-
if (config.database === 'pg') {
1415+
if (getDatabaseDialect(config) === 'postgresql') {
13861416
log.info('PostgreSQL already configured in void.config.ts');
13871417
return;
13881418
}
13891419

1390-
config.database = 'pg';
1391-
writeVoidProjectConfig(root, config);
1420+
writeDatabaseDialect(root, config, 'postgresql');
13921421
log.step('Configured PostgreSQL in void.config.ts');
13931422
return;
13941423
}
13951424

13961425
if (dialect === 'mysql') {
1397-
if (config.database === 'mysql') {
1426+
if (getDatabaseDialect(config) === 'mysql') {
13981427
log.info('MySQL already configured in void.config.ts');
13991428
return;
14001429
}
14011430

1402-
config.database = 'mysql';
1403-
writeVoidProjectConfig(root, config);
1431+
writeDatabaseDialect(root, config, 'mysql');
14041432
log.step('Configured MySQL in void.config.ts');
14051433
return;
14061434
}
@@ -1428,10 +1456,7 @@ function detectDatabaseSetup(root: string): DatabaseSetupChoice {
14281456

14291457
function applyDatabaseSetup(root: string, choice: DatabaseSetupChoice): void {
14301458
if (choice === 'none') {
1431-
clearConfiguredDatabase(root, {
1432-
unchanged: 'No database configured yet',
1433-
updated: 'Removed database config from void.config.ts',
1434-
});
1459+
removeDatabaseConfig(root);
14351460
return;
14361461
}
14371462

‎packages/void/src/codegen/prepare.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from 'pathslash';
44
import { analyzeApplication } from '../application-routing.ts';
55
import { loadVoidAuthConfig } from '../auth/config.ts';
66
import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '../auth/drizzle.ts';
7-
import { readConfig } from '../config.ts';
7+
import { getDatabaseCasing, readConfig } from '../config.ts';
88
import { discoverSchema } from '../drizzle/discover.ts';
99
import { loadLocalEnv } from '../shared/env.ts';
1010
import { generateDbDts } from './db-types.ts';
@@ -74,7 +74,8 @@ export async function prepareProject(root: string): Promise<PrepareProjectResult
7474
}
7575

7676
const schema = discoverSchema(root, paths);
77-
const hasDbRuntime = schema != null || authEnabled;
77+
// Matches the Vite plugin, which serves its own `void/db` for `database.casing`.
78+
const hasDbRuntime = schema != null || authEnabled || getDatabaseCasing(config) !== undefined;
7879
if (hasDbRuntime) {
7980
if (generateDbDts(outDir, schema, dialect)) {
8081
written.push(join('.void', 'db.d.ts'));

‎packages/void/src/config.ts‎

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ export type RedirectDestination = string | { to: string; status?: 301 | 302 | 30
1818
/** Supported database dialects (internal representation). */
1919
export type DatabaseDialect = 'sqlite' | 'postgresql' | 'mysql';
2020

21+
/** Drizzle's `casing`: how a column declared without a name is named in the database. */
22+
export type DatabaseCasing = 'snake_case' | 'camelCase';
23+
24+
/** Object form of `database`, for options next to the dialect. */
25+
export type DatabaseConfig = {
26+
/** Database backend. Defaults to `"sqlite"` (D1). */
27+
dialect?: 'sqlite' | 'pg' | 'mysql';
28+
/** Column naming for keys without an explicit column name, as in Drizzle's `casing` option. */
29+
casing?: DatabaseCasing;
30+
};
31+
2132
/** Curated Worker fields accepted by the `worker` config field. */
2233
export type WranglerConfig = {
2334
compatibility_date?: string;
@@ -173,7 +184,7 @@ export type VoidConfig = {
173184
worker?: WranglerConfig;
174185
cloudflare?: CloudflareConfig;
175186
deploy?: DeployConfig;
176-
database?: 'pg' | 'mysql';
187+
database?: 'pg' | 'mysql' | DatabaseConfig;
177188
ai?: AiConfig;
178189
email?: EmailConfig;
179190
};
@@ -200,6 +211,8 @@ const VALID_NOT_FOUND_HANDLING: Set<string> = new Set([
200211
*/
201212
export const VALID_REDIRECT_STATUSES: Set<number> = new Set([301, 302, 303, 307, 308]);
202213
const VALID_DATABASE_VALUES = new Set(['pg', 'mysql']);
214+
const VALID_DATABASE_DIALECTS = new Set(['sqlite', 'pg', 'mysql']);
215+
const VALID_DATABASE_CASINGS = new Set(['snake_case', 'camelCase']);
203216
/** Cloudflare's hard maximum for Workers CPU time per request (5 minutes). */
204217
const MAX_WORKER_CPU_MS = 300000;
205218
const VALID_SANDBOX_INSTANCE_TYPES = new Set<SandboxInstanceType>([
@@ -915,8 +928,36 @@ function validateConfigObject(obj: unknown): VoidConfig {
915928

916929
// Validate database
917930
if (obj.database !== undefined) {
918-
if (typeof obj.database !== 'string' || !VALID_DATABASE_VALUES.has(obj.database)) {
919-
throw new Error(`config: "database" in ${CONFIG_FILE} must be "pg" or "mysql".`);
931+
if (isRecord(obj.database)) {
932+
const databaseCfg = obj.database;
933+
for (const key of Object.keys(databaseCfg)) {
934+
if (key !== 'dialect' && key !== 'casing') {
935+
throw new Error(
936+
`config: Unknown key 'database.${key}' in ${CONFIG_FILE}. Valid: dialect, casing.`,
937+
);
938+
}
939+
}
940+
if (
941+
databaseCfg.dialect !== undefined &&
942+
(typeof databaseCfg.dialect !== 'string' ||
943+
!VALID_DATABASE_DIALECTS.has(databaseCfg.dialect))
944+
) {
945+
throw new Error(
946+
`config: "database.dialect" in ${CONFIG_FILE} must be "sqlite", "pg" or "mysql".`,
947+
);
948+
}
949+
if (
950+
databaseCfg.casing !== undefined &&
951+
(typeof databaseCfg.casing !== 'string' || !VALID_DATABASE_CASINGS.has(databaseCfg.casing))
952+
) {
953+
throw new Error(
954+
`config: "database.casing" in ${CONFIG_FILE} must be "snake_case" or "camelCase".`,
955+
);
956+
}
957+
} else if (typeof obj.database !== 'string' || !VALID_DATABASE_VALUES.has(obj.database)) {
958+
throw new Error(
959+
`config: "database" in ${CONFIG_FILE} must be "pg", "mysql" or an object with "dialect" and "casing".`,
960+
);
920961
}
921962
}
922963

@@ -1026,18 +1067,44 @@ export function resolveBindingNames(
10261067
}
10271068

10281069
/**
1029-
* Get the database dialect from config. Defaults to "sqlite".
1070+
* Get the database dialect from config, in its string or object form. Defaults to "sqlite".
10301071
*/
10311072
export function getDatabaseDialect(config: VoidConfig): DatabaseDialect {
1032-
if (config.database === 'pg') {
1073+
const database = config.database;
1074+
const value = typeof database === 'object' && database !== null ? database.dialect : database;
1075+
if (value === 'pg') {
10331076
return 'postgresql';
10341077
}
1035-
if (config.database === 'mysql') {
1078+
if (value === 'mysql') {
10361079
return 'mysql';
10371080
}
10381081
return 'sqlite';
10391082
}
10401083

1084+
/**
1085+
* Get Drizzle's `casing` from config. Undefined when unset: callers then pass no casing,
1086+
* so Drizzle keeps its default of naming a column after its key.
1087+
*/
1088+
export function getDatabaseCasing(config: VoidConfig): DatabaseCasing | undefined {
1089+
const database = config.database;
1090+
return typeof database === 'object' && database !== null ? database.casing : undefined;
1091+
}
1092+
1093+
/**
1094+
* The `database` value for a dialect that keeps the configured casing. Without casing it is
1095+
* the string shorthand, or undefined for the default D1 (SQLite).
1096+
*/
1097+
export function databaseConfigValue(
1098+
dialect: DatabaseDialect,
1099+
casing: DatabaseCasing | undefined,
1100+
): VoidConfig['database'] {
1101+
const shorthand = dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql' : undefined;
1102+
if (casing === undefined) {
1103+
return shorthand;
1104+
}
1105+
return shorthand ? { dialect: shorthand, casing } : { casing };
1106+
}
1107+
10411108
/**
10421109
* Convert the `headers` config field into `HeaderRule[]` for the deploy manifest.
10431110
*

0 commit comments

Comments
 (0)