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
4 changes: 2 additions & 2 deletions docs/guide/database/d1.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ outline: deep

## Local Database

`void dev` and local `void db` commands share the same database. If `cloudflare.d1_databases` in `void.config.ts` declares the D1 binding, Void uses its ID. A `preview_database_id` takes precedence during local development.
The dev server and local `void db` commands share the same database. If `cloudflare.d1_databases` in `void.config.ts` declares the D1 binding, Void uses its ID. A `preview_database_id` takes precedence during local development.

To store local data outside `.void`, set `voidPlugin({ persistTo: 'local-state' })` in your Vite config. Start `void dev` once to record that directory before running database commands. After changing the Vite config, start `void dev` again to refresh it. Frameworks that manage their own Cloudflare runtime use `.wrangler/state`.
To store local data outside `.void`, set `voidPlugin({ persistTo: 'local-state' })` in your Vite config. Start the dev server once to record that directory before running database commands. After changing the Vite config, start it again to refresh it. Frameworks that manage their own Cloudflare runtime use `.wrangler/state`.

## Migrations & Prototyping

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/database/mysql.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Add a direct connection for local development:
DATABASE_URL=mysql://user:password@host:3306/app
```

Void reads `.env` for `void dev` and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding.
Void reads `.env` for the dev server and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding.

## Define a schema

Expand Down
10 changes: 6 additions & 4 deletions docs/reference/cli/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ For SQLite, Void checks that the migration history applies to a fresh database.

## `void db status` {#void-db-status}

Show migration status. Displays which migrations are applied or pending locally, then uses the saved deployment target for remote status: the hosted API for Void projects, the pinned D1 database and its configured migration table for direct Cloudflare SQLite projects, or the shell `DATABASE_URL` for direct Cloudflare PostgreSQL/MySQL projects. If the remote credential or service is unavailable, local status is still shown.
Show migration status. Displays which migrations are applied or pending locally, then uses the saved deployment target for remote status: the hosted API for Void projects, the pinned D1 database and its configured migration table for direct Cloudflare SQLite projects, or the shell `DATABASE_URL` for direct Cloudflare PostgreSQL/MySQL projects. If the remote credential or service is unavailable, local status is still shown. If the local PostgreSQL or MySQL database cannot be read, local status shows `unknown`; the command fails when remote status is not available either.

## `void db reset` {#void-db-reset}

```
void db reset [--yes]
```

Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`.
Drop the development database and re-apply all migrations. D1 resets the local database. PostgreSQL drops the tables, views, sequences, and custom types in the `public` schema of the `DATABASE_URL` database from `.env` and keeps installed extensions. If a table, view, or function in another schema uses one of those views, sequences, or types, reset stops before it drops anything and lists those objects. MySQL drops every table and view in that database.

When that `DATABASE_URL` points to a host other than `localhost`, Void names the database and asks before dropping its tables. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. If `.env` uses the same URL as your deployment (for example after `void db connect`), reset drops the deployed tables.

Expand Down Expand Up @@ -73,7 +73,7 @@ void db migrate [--remote]

Apply pending migrations to the local database without resetting. Unlike `void db reset`, this preserves existing data and only runs migrations that haven't been applied yet.

Pass `--remote` to apply pending migrations to the saved target. Hosted projects require a Void login and link. Direct Cloudflare D1 projects use the binding's configured migration directory, table, and pattern; direct PostgreSQL and MySQL projects use the shell `DATABASE_URL`.
Pass `--remote` to apply pending migrations to the saved target. Hosted projects require a Void login and link. Direct Cloudflare D1 projects use the binding's configured migration directory, table, and pattern. The D1 binding must set `migrations_dir`. Direct PostgreSQL and MySQL projects use the shell `DATABASE_URL`.

## `void db studio` {#void-db-studio}

Expand All @@ -91,7 +91,9 @@ By default, targets the local database. Pass `--remote` to open Studio against t

## `void db rename-migrations` {#void-db-rename-migrations}

Rename existing migrations from the old numeric prefix format (`0001_name.sql`) to timestamp-based format (`20260410161500_name.sql`). Updates local tracking table and remote records if logged in with a linked project.
Rename existing migrations from the old numeric prefix format (`0001_name.sql`) to timestamp-based format (`20260410161500_name.sql`). Updates local tracking table and remote records if logged in with a linked project. A project that deploys to Void must be linked first with `void project link`; otherwise the command stops without changes.

Direct Cloudflare deploys are not supported: Void cannot rename the records in the deployed database, so the command stops without changes. Keep the existing names; new migrations use timestamps and run after them.

## `void db connect` {#void-db-connect}

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli/generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Creates:
- An export from `db/schema.ts`
- `routes/api/posts/index.ts`: `GET` for list and `POST` for insert with validation
- `routes/api/posts/[id].ts`: `GET` by id with `404` handling
- Regenerated `.void/db.d.ts`
- Regenerated types and TypeScript configuration in `.void/`

The generated routes automatically detect your validation library from `package.json` (`valibot`, `zod`, or `arktype`). If none is found, you will be prompted to choose one or skip validation. Run `void db push` to apply the schema locally, or `void db generate` to create migrations before deploying.

Expand Down
31 changes: 28 additions & 3 deletions packages/void/src/auth/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type VoidAuthMigrationField = {
index: boolean;
bigint: boolean;
unbounded?: boolean;
/** A fixed default of a required field. Adding the column fills rows that exist with it. */
defaultValue?: string | number | boolean;
references?: {
table: string;
field: string;
Expand Down Expand Up @@ -177,6 +179,24 @@ function usesUnboundedMySqlText(
);
}

/**
* The default of a required field when the database can store it as a literal.
* Function, date and JSON defaults have none.
*/
function resolveFixedDefault(
field: DBFieldAttribute,
): Pick<VoidAuthMigrationField, 'defaultValue'> {
if (field.required === false || field.type === 'date' || isJsonField(field)) {
return {};
}
const value = field.defaultValue;
return typeof value === 'string' ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value))
? { defaultValue: value }
: {};
}

function resolveReferenceFieldName(
schema: BetterAuthDBSchema,
model: string,
Expand Down Expand Up @@ -307,6 +327,13 @@ function createColumnDefinition(
if (dialect === 'mysql' && idStrategy === 'serial') {
parts.push('.autoincrement()');
}
// Better Auth leaves these ids out of inserts on PostgreSQL, so the database makes them.
if (dialect === 'postgresql' && idStrategy === 'serial') {
parts.push('.generatedByDefaultAsIdentity()');
}
if (dialect === 'postgresql' && idStrategy === 'uuid') {
parts.push('.defaultRandom()');
}
}
if (field.required !== false || actualFieldName === 'id') {
parts.push('.notNull()');
Expand Down Expand Up @@ -358,9 +385,6 @@ function collectCoreImports(
for (const [defaultFieldName, field] of Object.entries(table.fields)) {
const actualFieldName = resolveFieldName(defaultFieldName, field);
if (actualFieldName === 'id' || field.references?.field === 'id') {
if (idStrategy === 'serial') {
imports.add('integer');
}
if (idStrategy === 'uuid' && dialect === 'postgresql') {
imports.add('uuid');
}
Expand Down Expand Up @@ -536,6 +560,7 @@ export function resolveVoidAuthMigrationSchema(
index: field.index === true,
bigint: field.bigint === true,
...(unboundedMySqlFields.has(`${name}\0${fieldName}`) ? { unbounded: true } : {}),
...resolveFixedDefault(field),
Comment thread
Brooooooklyn marked this conversation as resolved.
...(field.references
? {
references: {
Expand Down
24 changes: 17 additions & 7 deletions packages/void/src/auth/node.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { mkdirSync } from 'node:fs';
import { dirname } from 'pathslash';
import { readConfig } from '../config.ts';
import { getMiniflareDatabasePath } from '../migrations/runner.ts';
import {
Expand All @@ -18,7 +20,10 @@ export async function runLocalVoidAuthMigrations(
persistRoot: string,
databaseId: string,
configPath: string | null = findVoidAuthConfig(root),
/** The database to change. Without it, DATABASE_URL from the shell, then from `.env`. */
/**
* The database to change. Without it, DATABASE_URL from the shell, then from `.env`. An empty
* shell value counts as unset.
*/
databaseUrl?: string,
): Promise<void> {
const authConfig = await loadVoidAuthConfig(configPath);
Expand All @@ -27,12 +32,22 @@ export async function runLocalVoidAuthMigrations(
providers: readConfig(root).auth?.providers ?? null,
userConfig: authConfig,
};
const env = { ...loadLocalEnv(root), ...process.env } as unknown as VoidAuthEnv;
const localEnv = loadLocalEnv(root);
const connectionString =
databaseUrl ?? (process.env.DATABASE_URL || localEnv.DATABASE_URL || undefined);
// The auth config sees the DATABASE_URL that the migration uses.
const env = {
...localEnv,
...process.env,
...(connectionString !== undefined ? { DATABASE_URL: connectionString } : {}),
} as unknown as VoidAuthEnv;
const migrationSchema = resolveVoidAuthMigrationSchema(runtime, env);

if (dialect === 'sqlite') {
const { DatabaseSync } = await import('node:sqlite');
const dbPath = getMiniflareDatabasePath(persistRoot, databaseId);
// The first dev run gets here before Miniflare creates its D1 directory.
mkdirSync(dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
await applySqliteAuthMigrations(
Expand All @@ -52,11 +67,6 @@ export async function runLocalVoidAuthMigrations(
return;
}

const connectionString =
databaseUrl ??
(typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0
? env.DATABASE_URL
: undefined);
if (!connectionString) {
return;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/void/src/cli/cloudflare-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export type CloudflareD1Context = {
operation: CloudflareOperationContext;
binding: string;
migrateToken: string;
migrationsDir: string;
/** Unset when the binding has no `migrations_dir`; Cloudflare then applies `./migrations`. */
migrationsDir: string | undefined;
migrationsTable: string;
};

Expand Down Expand Up @@ -206,7 +207,7 @@ export function resolveCloudflareD1Context(
operation,
binding,
migrateToken: target.token,
migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : 'db/migrations',
migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : undefined,
migrationsTable: migrationsTable.present ? (migrationsTable.value as string) : 'd1_migrations',
};
}
Expand Down
23 changes: 22 additions & 1 deletion packages/void/src/cli/db-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
import type { SubcommandDef } from './subcommand-prompt.ts';

export const dbSubcommands: Array<SubcommandDef> = [
{ name: 'push', description: 'Push schema to local D1 (no migration files)' },
{ name: 'push', description: 'Push schema to the development database (no migration files)' },
{ name: 'generate', description: 'Generate SQL migrations from schema changes' },
{ name: 'status', description: 'Show migration status (local + remote)' },
{ name: 'reset', description: 'Reset local DB and re-run migrations' },
Expand Down Expand Up @@ -96,6 +96,23 @@ const MAX_POSITIONALS: Record<DbArgs['action'], number> = {
export: 0,
};

// Flags each action reads. Every other flag stops the command, so `reset --remote` cannot
// quietly act on the local database.
const ACTION_FLAGS: Record<DbArgs['action'], Array<string>> = {
push: [],
generate: [],
status: [],
reset: ['yes'],
seed: ['file', 'yes'],
execute: ['file', 'remote'],
migrate: ['remote'],
studio: ['remote'],
'set-url': [],
connect: ['provider', 'name', 'region', 'local-only'],
'rename-migrations': [],
export: ['output', 'no-data', 'no-schema', 'table'],
};

function isDbAction(value: string | undefined): value is DbArgs['action'] {
return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value);
}
Expand Down Expand Up @@ -131,6 +148,10 @@ export function parseDbArgs(args: Array<string>): DbArgs {
'db: `void db export` reads only the local database; --remote is not supported.',
);
}
const unsupported = Object.keys(values).find((flag) => !ACTION_FLAGS[action].includes(flag));
if (unsupported) {
throw new Error(`db: \`void db ${action}\` does not support --${unsupported}.`);
}
const maxPositionals = MAX_POSITIONALS[action];
if (rest.length > maxPositionals) {
throw new Error(`db: Unexpected argument '${rest[maxPositionals]}'.`);
Expand Down
Loading
Loading