Skip to content

Commit b89a733

Browse files
Brooooooklynclaude
andauthored
fix(db): keep migrations, reset, seed and auth schema upgrades from damaging databases (#435)
## Problem Some `void db` commands, the dev migration step, and the auth schema upgrade could change the wrong database, apply SQL that was never checked, or leave a database that no command can repair. ``` command / step what happened on main ───────────────────────────────────── ───────────────────────────────────────────────────────────── vp dev, pending DROP without pragma ──▶ warning, then applied anyway (also to DATABASE_URL in .env) db migrate --remote, D1 binding ──▶ checks db/migrations, Cloudflare applies ./migrations without migrations_dir (unchecked SQL on production D1) db rename-migrations, direct CF ──▶ files renamed, deployed records not → next deploy runs them again db rename-migrations, MySQL ──▶ half of the tracking UPDATEs committed, files rolled back db reset / seed, PostgreSQL enum ──▶ tables dropped, enum kept → migration fails → empty database db reset / seed, MySQL view ──▶ same, with "Table already exists" db seed --file typo ──▶ database reset first, then "file not found" db reset --remote ──▶ flag ignored, local database reset, success printed db status, PG/MySQL down ──▶ every migration "pending", exit 0 db export, MySQL ──▶ DATE/DATETIME as ISO-Z (restore fails), JSON as [object Object], tables named _x left out auth field added to a table w/ rows ──▶ ADD COLUMN NOT NULL fails (SQLite/D1, PG), even with a default generateId: 'serial' / 'uuid' (PG) ──▶ generated auth schema has no id default → sign-up fails first vp dev, D1 + auth ──▶ "unable to open database file" PG auth, no DATABASE_URL ──▶ nothing created, "Applied Better Auth migrations" printed hosted PG/MySQL, auth step fails ──▶ 500 with migrations: [] → platform loses the applied files ``` ## What this PR changes ``` destructive SQL ──▶ vp dev applies none of the pending files, prints the validator error, keeps running (same rule as void db migrate and deploy) remote D1 ──▶ db migrate --remote stops when the binding has no migrations_dir rename ──▶ stops for direct Cloudflare (saved, or a real D1/Hyperdrive id in checked-in config) and for an unlinked Void project; MySQL tracking renames run in one transaction reset / seed ──▶ PG drops public tables, views, standalone sequences and custom types (public.<name>); stops first if an object in another schema uses one of those views, sequences or types MySQL drops tables and views seed: check file → confirm non-local DB → import module → reset → seed flags ──▶ each void db action accepts only the flags it reads status ──▶ unreadable local PG/MySQL: `unknown` + warning, or exit 1 when remote is unknown too export ──▶ MySQL dates, JSON, BIGINT as MySQL text; only Void's own tables are skipped auth schema ──▶ fixed defaults on ADD COLUMN; clear refusal on a populated SQLite/PG table PG serial → identity, uuid → gen_random_uuid(); D1 folder created; PG URL check handlers ──▶ 500 body lists the files that committed before the auth step failed messages ──▶ paths follow sourceDir; "Start the dev server" (no `void dev` command exists) codegen / init ──▶ .void/tsconfig.json lists only files that exist; init demo follows the chosen dialect ``` ## Decisions | Topic | Decision | | --- | --- | | Destructive file in dev | Refuse every pending file for that start, not only the bad one, so files never apply out of order. The dev server still starts. | | `rename-migrations` on direct Cloudflare | Refuse. Renaming is cosmetic: old numeric names and new timestamp names sort the same for Void and for D1. A remote rename needs credentials and an undo across two systems. | | Auth fields on MySQL | No refusal. MySQL fills old rows on its own, and that path works today. MySQL now gets the declared default. | | Auth defaults in the Drizzle schema | Not added. `.default(false)` on `user.emailVerified` makes drizzle-kit rebuild `user` on SQLite; on D1 the `DROP TABLE user` cascades and deletes every session and account. | | Unknown `void db` flags | Error. Every flag in help, docs and skills stays accepted. | | PG reset and other schemas | Before any drop, reset stops and lists the objects in other schemas that use a `public` view, sequence or type it drops (`DROP TYPE … CASCADE` would delete their columns). Table drops keep the existing `CASCADE` behavior. | ## Behavior changes to note in the release - `vp dev` no longer applies a pending destructive migration without `-- void:allow-destructive`. It applies no pending file for that start. - `void db migrate --remote` needs `migrations_dir` on the D1 binding. Void writes it on every provision. - `void db rename-migrations` stops for direct Cloudflare projects and for Void projects that are not linked. - `void db <action>` rejects flags that the action does not use, for example `void db reset --remote`. - `void db status` exits 1 when it can read neither the local nor the remote PostgreSQL/MySQL database. - `void db seed` asks about a non-local database before it loads the seed module. - Adding a required auth field with no fixed default to a SQLite/D1 or PostgreSQL table that has rows fails with a message that says how to fix it. - Adding a required `unique` auth field to a table with two or more rows fails before any change, on every dialect. - PostgreSQL apps with `generateId: 'serial'` or `'uuid'`: deploy reports schema drift until `void db generate` writes the migration that adds the id default. ## Known limits - Direct Cloudflare auth upgrades come from `void db generate`; a required field with a default still has no backfill there. - A fresh clone of a hosted project has no `.void/project.json`, so `rename-migrations` cannot see the deployment. After `void connect` it stops as expected. - PostgreSQL reset drops `public` tables with `CASCADE`, as before: a view, a foreign key, an inheriting table or a partition in another schema that depends on a `public` table goes with it. - PostgreSQL reset keeps plain functions and schemas other than `public`. A migration with `CREATE FUNCTION` or `CREATE SCHEMA` still fails on reset. - A MySQL dump restored with `NO_BACKSLASH_ESCAPES` changes backslashes in strings (same as main). MySQL export still skips views (same as main). - MySQL DDL commits on its own, so a failed MySQL migration can stay partly applied. The error and the docs explain the recovery. ## Testing | Check | Result | | --- | --- | | New and changed tests | Each fails on main (20 test files, 40 failures on main), for the right reason | | PostgreSQL reset | postgres:17 and PGlite: enums, domains, ranges, composite types, sequences, views, matviews, extension objects, `search_path`, objects in other schemas | | MySQL reset, export, restore | mysql:8.4: views, FKs, dates in a non-UTC zone, JSON, BIGINT limits, DECIMAL, BLOB, BIT, round trip through `void db execute --file` | | Auth migrations | node:sqlite, Miniflare D1, postgres:17, mysql:8.4: defaults with quotes and backslashes, refusal, serial and uuid ids with Better Auth sign-up | | Dev plugin | SQLite, PG, MySQL: harmless start, pragma'd DROP, refused DROP | | CLI | Built CLI against real databases: every documented flag, menu path, status, seed, rename, absolute paths | | `vp run build:all`, `vp run check`, `vp lint`, `vp fmt --check` | pass | | `vp run test` | 711 files pass, 1 skipped | 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7b17057 commit b89a733

53 files changed

Lines changed: 2271 additions & 227 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/guide/database/d1.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ outline: deep
88

99
## Local Database
1010

11-
`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.
11+
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.
1212

13-
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`.
13+
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`.
1414

1515
## Migrations & Prototyping
1616

‎docs/guide/database/mysql.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Add a direct connection for local development:
2222
DATABASE_URL=mysql://user:password@host:3306/app
2323
```
2424

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

2727
## Define a schema
2828

‎docs/reference/cli/database.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ For SQLite, Void checks that the migration history applies to a fresh database.
2020

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

23-
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.
23+
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.
2424

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

2727
```
2828
void db reset [--yes]
2929
```
3030

31-
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`.
31+
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.
3232

3333
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.
3434

@@ -73,7 +73,7 @@ void db migrate [--remote]
7373

7474
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.
7575

76-
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`.
76+
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`.
7777

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

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

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

94-
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.
94+
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.
95+
96+
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.
9597

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

‎docs/reference/cli/generate.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Creates:
2222
- An export from `db/schema.ts`
2323
- `routes/api/posts/index.ts`: `GET` for list and `POST` for insert with validation
2424
- `routes/api/posts/[id].ts`: `GET` by id with `404` handling
25-
- Regenerated `.void/db.d.ts`
25+
- Regenerated types and TypeScript configuration in `.void/`
2626

2727
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.
2828

‎packages/void/src/auth/drizzle.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export type VoidAuthMigrationField = {
3030
index: boolean;
3131
bigint: boolean;
3232
unbounded?: boolean;
33+
/** A fixed default of a required field. Adding the column fills rows that exist with it. */
34+
defaultValue?: string | number | boolean;
3335
references?: {
3436
table: string;
3537
field: string;
@@ -177,6 +179,24 @@ function usesUnboundedMySqlText(
177179
);
178180
}
179181

182+
/**
183+
* The default of a required field when the database can store it as a literal.
184+
* Function, date and JSON defaults have none.
185+
*/
186+
function resolveFixedDefault(
187+
field: DBFieldAttribute,
188+
): Pick<VoidAuthMigrationField, 'defaultValue'> {
189+
if (field.required === false || field.type === 'date' || isJsonField(field)) {
190+
return {};
191+
}
192+
const value = field.defaultValue;
193+
return typeof value === 'string' ||
194+
typeof value === 'boolean' ||
195+
(typeof value === 'number' && Number.isFinite(value))
196+
? { defaultValue: value }
197+
: {};
198+
}
199+
180200
function resolveReferenceFieldName(
181201
schema: BetterAuthDBSchema,
182202
model: string,
@@ -307,6 +327,13 @@ function createColumnDefinition(
307327
if (dialect === 'mysql' && idStrategy === 'serial') {
308328
parts.push('.autoincrement()');
309329
}
330+
// Better Auth leaves these ids out of inserts on PostgreSQL, so the database makes them.
331+
if (dialect === 'postgresql' && idStrategy === 'serial') {
332+
parts.push('.generatedByDefaultAsIdentity()');
333+
}
334+
if (dialect === 'postgresql' && idStrategy === 'uuid') {
335+
parts.push('.defaultRandom()');
336+
}
310337
}
311338
if (field.required !== false || actualFieldName === 'id') {
312339
parts.push('.notNull()');
@@ -358,9 +385,6 @@ function collectCoreImports(
358385
for (const [defaultFieldName, field] of Object.entries(table.fields)) {
359386
const actualFieldName = resolveFieldName(defaultFieldName, field);
360387
if (actualFieldName === 'id' || field.references?.field === 'id') {
361-
if (idStrategy === 'serial') {
362-
imports.add('integer');
363-
}
364388
if (idStrategy === 'uuid' && dialect === 'postgresql') {
365389
imports.add('uuid');
366390
}
@@ -536,6 +560,7 @@ export function resolveVoidAuthMigrationSchema(
536560
index: field.index === true,
537561
bigint: field.bigint === true,
538562
...(unboundedMySqlFields.has(`${name}\0${fieldName}`) ? { unbounded: true } : {}),
563+
...resolveFixedDefault(field),
539564
...(field.references
540565
? {
541566
references: {

‎packages/void/src/auth/node.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { mkdirSync } from 'node:fs';
2+
import { dirname } from 'pathslash';
13
import { readConfig } from '../config.ts';
24
import { getMiniflareDatabasePath } from '../migrations/runner.ts';
35
import {
@@ -18,7 +20,10 @@ export async function runLocalVoidAuthMigrations(
1820
persistRoot: string,
1921
databaseId: string,
2022
configPath: string | null = findVoidAuthConfig(root),
21-
/** The database to change. Without it, DATABASE_URL from the shell, then from `.env`. */
23+
/**
24+
* The database to change. Without it, DATABASE_URL from the shell, then from `.env`. An empty
25+
* shell value counts as unset.
26+
*/
2227
databaseUrl?: string,
2328
): Promise<void> {
2429
const authConfig = await loadVoidAuthConfig(configPath);
@@ -27,12 +32,22 @@ export async function runLocalVoidAuthMigrations(
2732
providers: readConfig(root).auth?.providers ?? null,
2833
userConfig: authConfig,
2934
};
30-
const env = { ...loadLocalEnv(root), ...process.env } as unknown as VoidAuthEnv;
35+
const localEnv = loadLocalEnv(root);
36+
const connectionString =
37+
databaseUrl ?? (process.env.DATABASE_URL || localEnv.DATABASE_URL || undefined);
38+
// The auth config sees the DATABASE_URL that the migration uses.
39+
const env = {
40+
...localEnv,
41+
...process.env,
42+
...(connectionString !== undefined ? { DATABASE_URL: connectionString } : {}),
43+
} as unknown as VoidAuthEnv;
3144
const migrationSchema = resolveVoidAuthMigrationSchema(runtime, env);
3245

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

55-
const connectionString =
56-
databaseUrl ??
57-
(typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0
58-
? env.DATABASE_URL
59-
: undefined);
6070
if (!connectionString) {
6171
return;
6272
}

‎packages/void/src/cli/cloudflare-operations.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export type CloudflareD1Context = {
6363
operation: CloudflareOperationContext;
6464
binding: string;
6565
migrateToken: string;
66-
migrationsDir: string;
66+
/** Unset when the binding has no `migrations_dir`; Cloudflare then applies `./migrations`. */
67+
migrationsDir: string | undefined;
6768
migrationsTable: string;
6869
};
6970

@@ -206,7 +207,7 @@ export function resolveCloudflareD1Context(
206207
operation,
207208
binding,
208209
migrateToken: target.token,
209-
migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : 'db/migrations',
210+
migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : undefined,
210211
migrationsTable: migrationsTable.present ? (migrationsTable.value as string) : 'd1_migrations',
211212
};
212213
}

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
22
import type { SubcommandDef } from './subcommand-prompt.ts';
33

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

99+
// Flags each action reads. Every other flag stops the command, so `reset --remote` cannot
100+
// quietly act on the local database.
101+
const ACTION_FLAGS: Record<DbArgs['action'], Array<string>> = {
102+
push: [],
103+
generate: [],
104+
status: [],
105+
reset: ['yes'],
106+
seed: ['file', 'yes'],
107+
execute: ['file', 'remote'],
108+
migrate: ['remote'],
109+
studio: ['remote'],
110+
'set-url': [],
111+
connect: ['provider', 'name', 'region', 'local-only'],
112+
'rename-migrations': [],
113+
export: ['output', 'no-data', 'no-schema', 'table'],
114+
};
115+
99116
function isDbAction(value: string | undefined): value is DbArgs['action'] {
100117
return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value);
101118
}
@@ -131,6 +148,10 @@ export function parseDbArgs(args: Array<string>): DbArgs {
131148
'db: `void db export` reads only the local database; --remote is not supported.',
132149
);
133150
}
151+
const unsupported = Object.keys(values).find((flag) => !ACTION_FLAGS[action].includes(flag));
152+
if (unsupported) {
153+
throw new Error(`db: \`void db ${action}\` does not support --${unsupported}.`);
154+
}
134155
const maxPositionals = MAX_POSITIONALS[action];
135156
if (rest.length > maxPositionals) {
136157
throw new Error(`db: Unexpected argument '${rest[maxPositionals]}'.`);

0 commit comments

Comments
 (0)