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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/guide/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions packages/void/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions packages/void/src/cli/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2399,11 +2401,12 @@ async function runSeedModule(
dialect: DatabaseDialect,
): Promise<void> {
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 });
Expand All @@ -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);
Comment thread
Brooooooklyn marked this conversation as resolved.
Expand All @@ -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 {
Expand Down Expand Up @@ -2510,8 +2513,14 @@ async function importLocalModule(modulePath: string): Promise<Record<string, unk
return jiti.import<Record<string, unknown>>(modulePath);
}

/** The schema and casing that `void/db` gives drizzle, or undefined when neither is set. */
function getDrizzleSchemaOptions(
schema: Record<string, unknown>,
): { schema: Record<string, unknown> } | undefined {
return Object.keys(schema).length > 0 ? { schema } : undefined;
casing: DatabaseCasing | undefined,
): { schema?: Record<string, unknown>; casing?: DatabaseCasing } | undefined {
const options = {
...(Object.keys(schema).length > 0 ? { schema } : {}),
...(casing ? { casing } : {}),
};
return Object.keys(options).length > 0 ? options : undefined;
}
59 changes: 42 additions & 17 deletions packages/void/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1361,46 +1368,67 @@ 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<string, unknown>;
const config = readConfig(root);

if (config.database !== 'pg' && config.database !== 'mysql') {
if (getDatabaseDialect(config) === 'sqlite') {
log.info(messages.unchanged);
return;
Comment thread
Brooooooklyn marked this conversation as resolved.
}

delete config.database;

writeVoidProjectConfig(root, config);
writeDatabaseDialect(root, config, 'sqlite');
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) as Record<string, unknown>;
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;
}
Expand Down Expand Up @@ -1428,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;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/void/src/codegen/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,7 +74,8 @@ export async function prepareProject(root: string): Promise<PrepareProjectResult
}

const schema = discoverSchema(root, paths);
const hasDbRuntime = schema != null || authEnabled;
// Matches the Vite plugin, which serves its own `void/db` for `database.casing`.
const hasDbRuntime = schema != null || authEnabled || getDatabaseCasing(config) !== undefined;
if (hasDbRuntime) {
if (generateDbDts(outDir, schema, dialect)) {
written.push(join('.void', 'db.d.ts'));
Expand Down
79 changes: 73 additions & 6 deletions packages/void/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export type RedirectDestination = string | { to: string; status?: 301 | 302 | 30
/** Supported database dialects (internal representation). */
export type DatabaseDialect = 'sqlite' | 'postgresql' | 'mysql';

/** Drizzle's `casing`: how a column declared without a name is named in the database. */
export type DatabaseCasing = 'snake_case' | 'camelCase';

/** Object form of `database`, for options next to the dialect. */
export type DatabaseConfig = {
/** Database backend. Defaults to `"sqlite"` (D1). */
dialect?: 'sqlite' | 'pg' | 'mysql';
/** Column naming for keys without an explicit column name, as in Drizzle's `casing` option. */
casing?: DatabaseCasing;
};

/** Curated Worker fields accepted by the `worker` config field. */
export type WranglerConfig = {
compatibility_date?: string;
Expand Down Expand Up @@ -173,7 +184,7 @@ export type VoidConfig = {
worker?: WranglerConfig;
cloudflare?: CloudflareConfig;
deploy?: DeployConfig;
database?: 'pg' | 'mysql';
database?: 'pg' | 'mysql' | DatabaseConfig;
ai?: AiConfig;
email?: EmailConfig;
};
Expand All @@ -200,6 +211,8 @@ const VALID_NOT_FOUND_HANDLING: Set<string> = new Set([
*/
export const VALID_REDIRECT_STATUSES: Set<number> = 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<SandboxInstanceType>([
Expand Down Expand Up @@ -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".`,
);
}
}

Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading