From aff048ff70ce8773767e903c314ca8ec234bbca8 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 11:33:28 +0100 Subject: [PATCH 1/6] feat: check that migrations are ordered Migrations apply in filename order, which matches the order their numbers imply only while the prefixes are padded to the same width. Opt-in via `--check-migration-order`, or `checkMigrationOrder` in the config. --- packages/bun-sqlgen-core/src/generate.ts | 14 +++- .../src/introspect/migrations.ts | 65 +++++++++++++++++-- packages/bun-sqlgen/pkg/README.md | 19 +++++- .../src/cli/commands/generate/[glob].ts | 7 ++ packages/bun-sqlgen/src/config.ts | 6 ++ 5 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/bun-sqlgen-core/src/generate.ts b/packages/bun-sqlgen-core/src/generate.ts index 7f98cb8..5897654 100644 --- a/packages/bun-sqlgen-core/src/generate.ts +++ b/packages/bun-sqlgen-core/src/generate.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url'; import { createDiscoverer } from '#discover.ts'; import { emitModule, GENERATED_MARKER } from '#emit/index.ts'; import { createIntrospector } from '#introspect/index.ts'; +import { requireOrderedMigrations } from '#introspect/migrations.ts'; import { parseColumnComments, parseOverrides, @@ -19,7 +20,7 @@ import type { } from '#types.ts'; type LoadedConfig = Partial> & - Pick; + Pick; // Where the aggregated module lands when `--out` is omitted. A `.ts`, not a `.d.ts`: // the module is a normal source file, so it can carry values as well as types. @@ -38,6 +39,12 @@ export interface GenerateOptions { checkQueries?: boolean; /** Fail if the committed generated module is out of date. Read-only (no write). */ checkStale?: boolean; + /** + * Fail unless every migration filename carries a unique, consistently zero-padded + * sequence number. Overrides config; defaults to `false`. Unlike the other checks it + * guards generation itself, so it runs in every mode rather than replacing the write. + */ + checkMigrationOrder?: boolean; /** Explicit path to `sqlgen.config.{ts,js,mjs}`; auto-discovered otherwise. */ configPath?: string; /** Output path for the aggregated module, relative to `cwd`. Defaults to `src/queries.gen.ts`. */ @@ -91,6 +98,11 @@ export async function generate(options: GenerateOptions): Promise(); diff --git a/packages/bun-sqlgen-core/src/introspect/migrations.ts b/packages/bun-sqlgen-core/src/introspect/migrations.ts index 2e28902..d1f2cb4 100644 --- a/packages/bun-sqlgen-core/src/introspect/migrations.ts +++ b/packages/bun-sqlgen-core/src/introspect/migrations.ts @@ -1,6 +1,13 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +/** The `*.sql` files in `migrationsDir`, in the order they are applied. */ +function listMigrations(migrationsDir: string): string[] { + return readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql')) + .sort(); +} + /** * Apply every `*.sql` migration in filename order to the throwaway DB, optionally * rewriting each via `transformMigration`. `exec` is the engine's multi-statement @@ -12,10 +19,7 @@ export async function applyMigrations(input: { transformMigration?: (input: { sql: string; filename: string }) => string; }): Promise { const { migrationsDir, exec, transformMigration } = input; - const files = readdirSync(migrationsDir) - .filter((f) => f.endsWith('.sql')) - .sort(); - for (const filename of files) { + for (const filename of listMigrations(migrationsDir)) { try { let sql = readFileSync(join(migrationsDir, filename), 'utf8'); if (transformMigration) { @@ -28,6 +32,59 @@ export async function applyMigrations(input: { } } +const SEQUENCE_PREFIX = /^\d+/; + +/** + * Migrations apply in filename order, which agrees with the order their numbers imply + * only while every prefix is padded to the same width: `1, 2, 10` applies as + * `1, 10, 2`, building a different schema than production without failing. Opt-in via + * `checkMigrationOrder`, since an unnumbered scheme is a valid choice on its own. + */ +export function requireOrderedMigrations(migrationsDir: string): void { + const problems: string[] = []; + const numbered: Array<{ filename: string; sequence: number }> = []; + + for (const filename of listMigrations(migrationsDir)) { + const prefix = SEQUENCE_PREFIX.exec(filename)?.[0]; + if (prefix === undefined) { + problems.push(`${filename} — no leading sequence number`); + continue; + } + numbered.push({ filename, sequence: Number(prefix) }); + } + + // Two migrations claiming the same number — the usual merge accident — leave the + // order between them to the rest of the filename. + const bySequence = new Map(); + for (const { filename, sequence } of numbered) { + const claimed = bySequence.get(sequence); + if (claimed === undefined) { + bySequence.set(sequence, filename); + } else { + problems.push(`${filename} — same sequence number as ${claimed}`); + } + } + + // The list is already in applied order, so "ascending across every adjacent pair" + // is exactly "filename order == numeric order". + let previous: { filename: string; sequence: number } | null = null; + for (const entry of numbered) { + if (previous && previous.sequence > entry.sequence) { + problems.push( + `${previous.filename} — applies before ${entry.filename}, but ${previous.sequence} > ${entry.sequence}`, + ); + } + previous = entry; + } + + if (problems.length) { + throw new Error( + `migrations are not in sequence order:\n${problems.map((p) => ` ✗ ${p}`).join('\n')}\n` + + ' Migrations apply in filename order — zero-pad the prefixes (0001, 0002, … 0010) so it matches.', + ); + } +} + export function firstLine(e: unknown): string { const message = e instanceof Error ? e.message : String(e); return message.split('\n')[0] ?? message; diff --git a/packages/bun-sqlgen/pkg/README.md b/packages/bun-sqlgen/pkg/README.md index 3c97d2c..3c9476a 100644 --- a/packages/bun-sqlgen/pkg/README.md +++ b/packages/bun-sqlgen/pkg/README.md @@ -143,6 +143,20 @@ non-zero on a problem: - **`--check-stale`** — fail if the committed `queries.gen.ts` is out of date. - **`--check`** — run both; the one-flag CI default. +A fourth flag, **`--check-migration-order`**, guards the migrations themselves rather +than the output, so it runs on a plain generate too and is *not* folded into `--check`: + +```sh +bun bun-sqlgen generate 'src/**/*.ts' --migrations db/migrations --check-migration-order +``` + +Migrations apply in filename order, which only matches the order their numbers imply +while every prefix is padded to the same width — `1, 2, 10` applies as `1, 10, 2`, +building a schema production never had. The flag fails on a migration with no leading +sequence number, on two migrations claiming the same number, and on any pair whose +filename order disagrees with their numbers. Turn it on for the whole project with +`checkMigrationOrder: true` in `sqlgen.config.ts`. + Commit the generated file and run `--check` in CI so an edited query can never type-check against a stale shape. The [`check-only` example](https://github.com/ilbertt/bun-sqlgen/tree/main/examples/check-only) @@ -213,13 +227,16 @@ export default defineConfig({ // rewrite/strip statements PGlite can't run, per migration file (CREATE INDEX // CONCURRENTLY can't run inside the transaction a multi-statement file applies in). transformMigration: ({ sql }) => sql.replace(/\bCONCURRENTLY\b/g, ''), + // require every migration to be numbered 0001, 0002, … so filename order + // (the order they apply in) matches the order the numbers imply. + checkMigrationOrder: true, }); ``` `defineConfig` is optional — a plain `export default { … }` still works, but you lose the type-checking and autocompletion. -A runnable walkthrough of all three fields lives in the +A runnable walkthrough of the three schema-shaping fields lives in the [`with-config` example](https://github.com/ilbertt/bun-sqlgen/tree/main/examples/with-config). `extensions` is Postgres-only; `prelude` and `transformMigration` apply to both dialects, and `dialect: 'sqlite'` selects SQLite (the `--dialect` flag overrides it). diff --git a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts index b0d140c..258f129 100644 --- a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts +++ b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts @@ -35,6 +35,11 @@ export const command = defineCommand('generate [glob]', { schema: z.boolean().optional(), description: 'Fail if the committed generated types are out of date. Writes nothing.', }, + 'check-migration-order': { + schema: z.boolean().optional(), + description: + 'Fail unless every migration filename carries a unique, consistently zero-padded sequence number (0001, 0002, … 0010). Not part of --check.', + }, dialect: { schema: z.enum(['postgres', 'sqlite']).optional(), description: 'Database engine to introspect against (default postgres; overrides config).', @@ -60,6 +65,8 @@ export const command = defineCommand('generate [glob]', { packageName: options.package, configPath: options.config, dialect: options.dialect, + // Absent, the config decides; the flag only ever turns the check on. + checkMigrationOrder: options['check-migration-order'] || undefined, // Absent, the config decides; the flag only ever turns the schema block off. schema: options['no-schema'] ? false : undefined, checkQueries, diff --git a/packages/bun-sqlgen/src/config.ts b/packages/bun-sqlgen/src/config.ts index 7407164..5dd8183 100644 --- a/packages/bun-sqlgen/src/config.ts +++ b/packages/bun-sqlgen/src/config.ts @@ -11,6 +11,12 @@ interface BaseConfig { * constraint names. Defaults to `true`; `--no-schema` turns it off from the CLI. */ schema?: boolean; + /** + * Fail unless every migration filename carries a unique, consistently zero-padded + * sequence number — migrations apply in filename order, so `1, 2, 10` applies as + * `1, 10, 2`. Defaults to `false`; `--check-migration-order` turns it on from the CLI. + */ + checkMigrationOrder?: boolean; /** SQL run before migrations (stub functions/types/extensions). */ prelude?: string; /** Rewrite or strip statements the throwaway DB can't run, per migration file. */ From ebb222d7f857050926aa393afa6ecd91a3eedcf4 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 11:43:47 +0100 Subject: [PATCH 2/6] refactor: check migration prefix width, not numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equal-width prefixes sort the same way in any positional scheme, so width is the invariant that makes filename order the intended order — a letter or timestamp convention holds on its own terms. `checkMigrationOrder` now also takes a RegExp saying where the prefix ends. --- packages/bun-sqlgen-core/src/generate.ts | 15 +++-- .../src/introspect/migrations.ts | 66 ++++++++++--------- packages/bun-sqlgen/pkg/README.md | 29 ++++++-- .../src/cli/commands/generate/[glob].ts | 2 +- packages/bun-sqlgen/src/config.ts | 10 +-- 5 files changed, 74 insertions(+), 48 deletions(-) diff --git a/packages/bun-sqlgen-core/src/generate.ts b/packages/bun-sqlgen-core/src/generate.ts index 5897654..2fcfdba 100644 --- a/packages/bun-sqlgen-core/src/generate.ts +++ b/packages/bun-sqlgen-core/src/generate.ts @@ -40,11 +40,13 @@ export interface GenerateOptions { /** Fail if the committed generated module is out of date. Read-only (no write). */ checkStale?: boolean; /** - * Fail unless every migration filename carries a unique, consistently zero-padded - * sequence number. Overrides config; defaults to `false`. Unlike the other checks it - * guards generation itself, so it runs in every mode rather than replacing the write. + * Fail unless every migration filename carries a unique sequence prefix, all of one + * width — what makes filename order (the order they apply in) the intended one. + * `true` expects a numeric prefix; a `RegExp` says where the prefix ends instead. + * Overrides config; defaults to `false`. Unlike the other checks it guards generation + * itself, so it runs in every mode rather than replacing the write. */ - checkMigrationOrder?: boolean; + checkMigrationOrder?: boolean | RegExp; /** Explicit path to `sqlgen.config.{ts,js,mjs}`; auto-discovered otherwise. */ configPath?: string; /** Output path for the aggregated module, relative to `cwd`. Defaults to `src/queries.gen.ts`. */ @@ -99,8 +101,9 @@ export async function generate(options: GenerateOptions): Promise = []; + const prefixes: Array<{ filename: string; prefix: string }> = []; - for (const filename of listMigrations(migrationsDir)) { - const prefix = SEQUENCE_PREFIX.exec(filename)?.[0]; - if (prefix === undefined) { - problems.push(`${filename} — no leading sequence number`); + for (const filename of listMigrations(input.migrationsDir)) { + const match = pattern.exec(filename); + // A match anywhere but the start orders nothing. + const prefix = match?.index === 0 ? match[0] : null; + if (prefix === null) { + problems.push(`${filename} — no ${pattern} prefix`); continue; } - numbered.push({ filename, sequence: Number(prefix) }); + prefixes.push({ filename, prefix }); } - // Two migrations claiming the same number — the usual merge accident — leave the - // order between them to the rest of the filename. - const bySequence = new Map(); - for (const { filename, sequence } of numbered) { - const claimed = bySequence.get(sequence); + // Two migrations claiming one prefix — the usual merge accident — leave the order + // between them to whatever the rest of the filename happens to be. + const claimedBy = new Map(); + for (const { filename, prefix } of prefixes) { + const claimed = claimedBy.get(prefix); if (claimed === undefined) { - bySequence.set(sequence, filename); + claimedBy.set(prefix, filename); } else { - problems.push(`${filename} — same sequence number as ${claimed}`); + problems.push(`${filename} — same "${prefix}" prefix as ${claimed}`); } } - // The list is already in applied order, so "ascending across every adjacent pair" - // is exactly "filename order == numeric order". - let previous: { filename: string; sequence: number } | null = null; - for (const entry of numbered) { - if (previous && previous.sequence > entry.sequence) { - problems.push( - `${previous.filename} — applies before ${entry.filename}, but ${previous.sequence} > ${entry.sequence}`, - ); + const widest = Math.max(0, ...prefixes.map((p) => p.prefix.length)); + for (const { filename, prefix } of prefixes) { + if (prefix.length < widest) { + problems.push(`${filename} — "${prefix}" is ${prefix.length} wide, the widest is ${widest}`); } - previous = entry; } if (problems.length) { throw new Error( - `migrations are not in sequence order:\n${problems.map((p) => ` ✗ ${p}`).join('\n')}\n` + - ' Migrations apply in filename order — zero-pad the prefixes (0001, 0002, … 0010) so it matches.', + `migrations are not in a dependable order:\n${problems.map((p) => ` ✗ ${p}`).join('\n')}\n` + + ` They apply in filename order — give each a unique ${pattern} prefix, padded to one width.`, ); } } diff --git a/packages/bun-sqlgen/pkg/README.md b/packages/bun-sqlgen/pkg/README.md index 3c9476a..7b10a60 100644 --- a/packages/bun-sqlgen/pkg/README.md +++ b/packages/bun-sqlgen/pkg/README.md @@ -150,12 +150,26 @@ than the output, so it runs on a plain generate too and is *not* folded into `-- bun bun-sqlgen generate 'src/**/*.ts' --migrations db/migrations --check-migration-order ``` -Migrations apply in filename order, which only matches the order their numbers imply -while every prefix is padded to the same width — `1, 2, 10` applies as `1, 10, 2`, -building a schema production never had. The flag fails on a migration with no leading -sequence number, on two migrations claiming the same number, and on any pair whose -filename order disagrees with their numbers. Turn it on for the whole project with -`checkMigrationOrder: true` in `sqlgen.config.ts`. +Migrations apply in filename order, which is the order you meant only while every +filename carries a sequence prefix of the same width — `1, 2, 10` applies as +`1, 10, 2`, building a schema production never had. The flag fails on a migration with +no prefix, on two claiming the same prefix, and on prefixes of differing widths. Gaps +are fine. + +Width, not "is it a number", is what's checked: equal-width prefixes sort the same way +in any positional scheme. So if you don't number your migrations, name the scheme +instead of the default and it's held to its own terms: + +```ts +// sqlgen.config.ts +export default defineConfig({ + checkMigrationOrder: /^[a-z]{4}_/, // aaaa_init.sql, aaab_add_users.sql, … +}); +``` + +`true` uses the default numeric prefix (`/^\d+/`), which is what the CLI flag turns on; +`/^\d{14}_/` covers a timestamp convention. The config field applies on a plain +generate too, so the check holds whether or not CI passed the flag. Commit the generated file and run `--check` in CI so an edited query can never type-check against a stale shape. The @@ -228,7 +242,8 @@ export default defineConfig({ // CONCURRENTLY can't run inside the transaction a multi-statement file applies in). transformMigration: ({ sql }) => sql.replace(/\bCONCURRENTLY\b/g, ''), // require every migration to be numbered 0001, 0002, … so filename order - // (the order they apply in) matches the order the numbers imply. + // (the order they apply in) is the order they were meant to run in. A RegExp + // here replaces the numeric default with your own prefix scheme. checkMigrationOrder: true, }); ``` diff --git a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts index 258f129..7edfdfd 100644 --- a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts +++ b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts @@ -38,7 +38,7 @@ export const command = defineCommand('generate [glob]', { 'check-migration-order': { schema: z.boolean().optional(), description: - 'Fail unless every migration filename carries a unique, consistently zero-padded sequence number (0001, 0002, … 0010). Not part of --check.', + 'Fail unless every migration filename carries a unique, equally wide sequence number (0001, 0002, … 0010). Set checkMigrationOrder in the config for a non-numeric scheme. Not part of --check.', }, dialect: { schema: z.enum(['postgres', 'sqlite']).optional(), diff --git a/packages/bun-sqlgen/src/config.ts b/packages/bun-sqlgen/src/config.ts index 5dd8183..22254e7 100644 --- a/packages/bun-sqlgen/src/config.ts +++ b/packages/bun-sqlgen/src/config.ts @@ -12,11 +12,13 @@ interface BaseConfig { */ schema?: boolean; /** - * Fail unless every migration filename carries a unique, consistently zero-padded - * sequence number — migrations apply in filename order, so `1, 2, 10` applies as - * `1, 10, 2`. Defaults to `false`; `--check-migration-order` turns it on from the CLI. + * Fail unless every migration filename carries a unique sequence prefix, all of one + * width — migrations apply in filename order, so `1, 2, 10` applies as `1, 10, 2`. + * `true` expects a numeric prefix; pass a `RegExp` for any other scheme + * (`/^\d{14}_/` for timestamps, `/^[a-z]{4}_/` for letters). Defaults to `false`; + * `--check-migration-order` turns on the numeric default from the CLI. */ - checkMigrationOrder?: boolean; + checkMigrationOrder?: boolean | RegExp; /** SQL run before migrations (stub functions/types/extensions). */ prelude?: string; /** Rewrite or strip statements the throwaway DB can't run, per migration file. */ From 4a6a9d8aa16b10f3956bfbc2aaa8b373e9ead6b2 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 12:36:51 +0100 Subject: [PATCH 3/6] refactor: take migration-order settings as an object `checkMigrationOrder: { enabled, prefixPattern }` replaces the boolean-or-RegExp union, and core merges the CLI flag over the config instead of replacing it, so `--check-migration-order` turns the check on without discarding a configured prefixPattern. --- packages/bun-sqlgen-core/src/generate.ts | 30 ++++++++++++++----- packages/bun-sqlgen-core/src/index.ts | 1 + .../src/introspect/migrations.ts | 8 ++--- packages/bun-sqlgen/pkg/README.md | 18 ++++++----- .../src/cli/commands/generate/[glob].ts | 7 +++-- packages/bun-sqlgen/src/config.ts | 18 ++++++++--- 6 files changed, 56 insertions(+), 26 deletions(-) diff --git a/packages/bun-sqlgen-core/src/generate.ts b/packages/bun-sqlgen-core/src/generate.ts index 2fcfdba..8990207 100644 --- a/packages/bun-sqlgen-core/src/generate.ts +++ b/packages/bun-sqlgen-core/src/generate.ts @@ -42,11 +42,12 @@ export interface GenerateOptions { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — what makes filename order (the order they apply in) the intended one. - * `true` expects a numeric prefix; a `RegExp` says where the prefix ends instead. - * Overrides config; defaults to `false`. Unlike the other checks it guards generation - * itself, so it runs in every mode rather than replacing the write. + * Merged over config rather than replacing it, so turning the check on from the CLI + * keeps the `prefixPattern` the config set. Off unless enabled. Unlike the other + * checks it guards generation itself, so it runs in every mode rather than + * replacing the write. */ - checkMigrationOrder?: boolean | RegExp; + checkMigrationOrder?: MigrationOrderCheck; /** Explicit path to `sqlgen.config.{ts,js,mjs}`; auto-discovered otherwise. */ configPath?: string; /** Output path for the aggregated module, relative to `cwd`. Defaults to `src/queries.gen.ts`. */ @@ -64,6 +65,17 @@ export interface GenerateOptions { schema?: boolean; } +/** Settings for the migration-order check. */ +export interface MigrationOrderCheck { + enabled: boolean; + /** + * What identifies a filename's sequence prefix — the part that has to be unique and + * equally wide across every migration. Defaults to a leading run of digits (`/^\d+/`); + * `/^\d{14}_/` covers a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + */ + prefixPattern?: RegExp; +} + export interface GenerateFailure { name: string; file: string; @@ -100,10 +112,12 @@ export async function generate(options: GenerateOptions): Promise sql.replace(/\bCONCURRENTLY\b/g, ''), // require every migration to be numbered 0001, 0002, … so filename order - // (the order they apply in) is the order they were meant to run in. A RegExp - // here replaces the numeric default with your own prefix scheme. - checkMigrationOrder: true, + // (the order they apply in) is the order they were meant to run in. + // `prefixPattern` swaps the numeric default for your own scheme. + checkMigrationOrder: { enabled: true }, }); ``` diff --git a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts index 7edfdfd..c419ca4 100644 --- a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts +++ b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts @@ -38,7 +38,7 @@ export const command = defineCommand('generate [glob]', { 'check-migration-order': { schema: z.boolean().optional(), description: - 'Fail unless every migration filename carries a unique, equally wide sequence number (0001, 0002, … 0010). Set checkMigrationOrder in the config for a non-numeric scheme. Not part of --check.', + 'Fail unless every migration filename carries a unique, equally wide sequence prefix (0001, 0002, … 0010). Set checkMigrationOrder.prefixPattern in the config for a non-numeric scheme. Not part of --check.', }, dialect: { schema: z.enum(['postgres', 'sqlite']).optional(), @@ -65,8 +65,9 @@ export const command = defineCommand('generate [glob]', { packageName: options.package, configPath: options.config, dialect: options.dialect, - // Absent, the config decides; the flag only ever turns the check on. - checkMigrationOrder: options['check-migration-order'] || undefined, + // Absent, the config decides; the flag only ever turns the check on, and core + // merges it over the config so a configured `prefixPattern` survives. + checkMigrationOrder: options['check-migration-order'] ? { enabled: true } : undefined, // Absent, the config decides; the flag only ever turns the schema block off. schema: options['no-schema'] ? false : undefined, checkQueries, diff --git a/packages/bun-sqlgen/src/config.ts b/packages/bun-sqlgen/src/config.ts index 22254e7..116147b 100644 --- a/packages/bun-sqlgen/src/config.ts +++ b/packages/bun-sqlgen/src/config.ts @@ -3,6 +3,17 @@ import type { Extensions } from '@electric-sql/pglite'; /** Which engine introspects the migrations at build time. Defaults to `postgres`. */ export type Dialect = 'postgres' | 'sqlite'; +/** Settings for the migration-order check. */ +export interface MigrationOrderCheck { + enabled: boolean; + /** + * What identifies a filename's sequence prefix — the part that has to be unique and + * equally wide across every migration. Defaults to a leading run of digits (`/^\d+/`); + * `/^\d{14}_/` covers a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + */ + prefixPattern?: RegExp; +} + interface BaseConfig { /** Database engine the queries run against. Defaults to `postgres`. */ dialect?: Dialect; @@ -14,11 +25,10 @@ interface BaseConfig { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — migrations apply in filename order, so `1, 2, 10` applies as `1, 10, 2`. - * `true` expects a numeric prefix; pass a `RegExp` for any other scheme - * (`/^\d{14}_/` for timestamps, `/^[a-z]{4}_/` for letters). Defaults to `false`; - * `--check-migration-order` turns on the numeric default from the CLI. + * Off unless enabled; `--check-migration-order` enables it from the CLI without + * discarding the `prefixPattern` set here. */ - checkMigrationOrder?: boolean | RegExp; + checkMigrationOrder?: MigrationOrderCheck; /** SQL run before migrations (stub functions/types/extensions). */ prelude?: string; /** Rewrite or strip statements the throwaway DB can't run, per migration file. */ From 09874cd7e6f16b76db64d648791fb59ef8f19e49 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 12:41:36 +0100 Subject: [PATCH 4/6] refactor: require checkMigrationOrder.prefixPattern No default: only the project knows which convention its filenames were named for, and a wrong guess passes a check that never looked at the right prefix. `--check-migration-order` contributes only `enabled`, so the pattern has to come from the config. --- packages/bun-sqlgen-core/src/generate.ts | 24 ++++++---- .../src/introspect/migrations.ts | 12 ++--- packages/bun-sqlgen/pkg/README.md | 47 ++++++++++--------- .../src/cli/commands/generate/[glob].ts | 4 +- packages/bun-sqlgen/src/config.ts | 11 +++-- 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/packages/bun-sqlgen-core/src/generate.ts b/packages/bun-sqlgen-core/src/generate.ts index 8990207..e3eb67c 100644 --- a/packages/bun-sqlgen-core/src/generate.ts +++ b/packages/bun-sqlgen-core/src/generate.ts @@ -42,12 +42,12 @@ export interface GenerateOptions { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — what makes filename order (the order they apply in) the intended one. - * Merged over config rather than replacing it, so turning the check on from the CLI - * keeps the `prefixPattern` the config set. Off unless enabled. Unlike the other - * checks it guards generation itself, so it runs in every mode rather than - * replacing the write. + * Partial because `--check-migration-order` contributes only `enabled`: this is + * merged over the config's settings, and the result still needs a `prefixPattern`. + * Off unless enabled. Unlike the other checks it guards generation itself, so it + * runs in every mode rather than replacing the write. */ - checkMigrationOrder?: MigrationOrderCheck; + checkMigrationOrder?: Partial; /** Explicit path to `sqlgen.config.{ts,js,mjs}`; auto-discovered otherwise. */ configPath?: string; /** Output path for the aggregated module, relative to `cwd`. Defaults to `src/queries.gen.ts`. */ @@ -70,10 +70,11 @@ export interface MigrationOrderCheck { enabled: boolean; /** * What identifies a filename's sequence prefix — the part that has to be unique and - * equally wide across every migration. Defaults to a leading run of digits (`/^\d+/`); - * `/^\d{14}_/` covers a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + * equally wide across every migration. Required, and deliberately not defaulted: only + * you know which convention your migrations were named for. `/^\d+/` matches + * `0001_init.sql`, `/^\d{14}_/` a timestamp scheme, `/^[a-z]{4}_/` a lettered one. */ - prefixPattern?: RegExp; + prefixPattern: RegExp; } export interface GenerateFailure { @@ -117,6 +118,13 @@ export async function generate(options: GenerateOptions): Promise = []; diff --git a/packages/bun-sqlgen/pkg/README.md b/packages/bun-sqlgen/pkg/README.md index 1b5f3e0..ee79858 100644 --- a/packages/bun-sqlgen/pkg/README.md +++ b/packages/bun-sqlgen/pkg/README.md @@ -143,37 +143,43 @@ non-zero on a problem: - **`--check-stale`** — fail if the committed `queries.gen.ts` is out of date. - **`--check`** — run both; the one-flag CI default. -A fourth flag, **`--check-migration-order`**, guards the migrations themselves rather -than the output, so it runs on a plain generate too and is *not* folded into `--check`: +### Migration order -```sh -bun bun-sqlgen generate 'src/**/*.ts' --migrations db/migrations --check-migration-order +A fourth check guards the migrations themselves rather than the output, so it runs on a +plain generate too and is *not* folded into `--check`. It's configured, not flagged on, +because it needs to know your naming convention: + +```ts +// sqlgen.config.ts +export default defineConfig({ + checkMigrationOrder: { + enabled: true, + prefixPattern: /^\d+/, // 0001_init.sql, 0002_add_users.sql, … + }, +}); ``` Migrations apply in filename order, which is the order you meant only while every filename carries a sequence prefix of the same width — `1, 2, 10` applies as -`1, 10, 2`, building a schema production never had. The flag fails on a migration with +`1, 10, 2`, building a schema production never had. The check fails on a migration with no prefix, on two claiming the same prefix, and on prefixes of differing widths. Gaps are fine. Width, not "is it a number", is what's checked: equal-width prefixes sort the same way -in any positional scheme. So if you don't number your migrations, name the scheme -instead of the default and it's held to its own terms: +in any positional scheme, so a scheme that doesn't number at all is held to its own +terms — `/^[a-z]{4}_/` for `aaaa_init.sql`, `/^\d{14}_/` for a timestamp convention. +`prefixPattern` is required and has no default: only you know which convention your +filenames were named for. -```ts -// sqlgen.config.ts -export default defineConfig({ - checkMigrationOrder: { - enabled: true, - prefixPattern: /^[a-z]{4}_/, // aaaa_init.sql, aaab_add_users.sql, … - }, -}); +`--check-migration-order` sets `enabled` from the CLI, for a project that wants the +check in CI but not on every local generate: + +```sh +bun bun-sqlgen generate 'src/**/*.ts' --migrations db/migrations --check-migration-order ``` -`prefixPattern` defaults to a leading run of digits (`/^\d+/`); `/^\d{14}_/` covers a -timestamp convention. The config applies on a plain generate too, so the check holds -whether or not CI passed the flag — and passing the flag only sets `enabled`, so a -`prefixPattern` configured here still governs. +The flag contributes only `enabled` and is merged over the config, so `prefixPattern` +still has to be configured — the flag alone can't guess one. Commit the generated file and run `--check` in CI so an edited query can never type-check against a stale shape. The @@ -247,8 +253,7 @@ export default defineConfig({ transformMigration: ({ sql }) => sql.replace(/\bCONCURRENTLY\b/g, ''), // require every migration to be numbered 0001, 0002, … so filename order // (the order they apply in) is the order they were meant to run in. - // `prefixPattern` swaps the numeric default for your own scheme. - checkMigrationOrder: { enabled: true }, + checkMigrationOrder: { enabled: true, prefixPattern: /^\d+/ }, }); ``` diff --git a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts index c419ca4..7ffd58a 100644 --- a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts +++ b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts @@ -38,7 +38,7 @@ export const command = defineCommand('generate [glob]', { 'check-migration-order': { schema: z.boolean().optional(), description: - 'Fail unless every migration filename carries a unique, equally wide sequence prefix (0001, 0002, … 0010). Set checkMigrationOrder.prefixPattern in the config for a non-numeric scheme. Not part of --check.', + 'Enable the migration-order check: fail unless every migration filename carries a unique, equally wide sequence prefix. Reads its prefixPattern from checkMigrationOrder in sqlgen.config.ts, which is required. Not part of --check.', }, dialect: { schema: z.enum(['postgres', 'sqlite']).optional(), @@ -66,7 +66,7 @@ export const command = defineCommand('generate [glob]', { configPath: options.config, dialect: options.dialect, // Absent, the config decides; the flag only ever turns the check on, and core - // merges it over the config so a configured `prefixPattern` survives. + // merges it over the config, which is where the `prefixPattern` comes from. checkMigrationOrder: options['check-migration-order'] ? { enabled: true } : undefined, // Absent, the config decides; the flag only ever turns the schema block off. schema: options['no-schema'] ? false : undefined, diff --git a/packages/bun-sqlgen/src/config.ts b/packages/bun-sqlgen/src/config.ts index 116147b..b70592e 100644 --- a/packages/bun-sqlgen/src/config.ts +++ b/packages/bun-sqlgen/src/config.ts @@ -8,10 +8,11 @@ export interface MigrationOrderCheck { enabled: boolean; /** * What identifies a filename's sequence prefix — the part that has to be unique and - * equally wide across every migration. Defaults to a leading run of digits (`/^\d+/`); - * `/^\d{14}_/` covers a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + * equally wide across every migration. Required, and deliberately not defaulted: only + * you know which convention your migrations were named for. `/^\d+/` matches + * `0001_init.sql`, `/^\d{14}_/` a timestamp scheme, `/^[a-z]{4}_/` a lettered one. */ - prefixPattern?: RegExp; + prefixPattern: RegExp; } interface BaseConfig { @@ -25,8 +26,8 @@ interface BaseConfig { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — migrations apply in filename order, so `1, 2, 10` applies as `1, 10, 2`. - * Off unless enabled; `--check-migration-order` enables it from the CLI without - * discarding the `prefixPattern` set here. + * Off unless enabled; `--check-migration-order` enables it from the CLI, and needs + * the `prefixPattern` to come from here. */ checkMigrationOrder?: MigrationOrderCheck; /** SQL run before migrations (stub functions/types/extensions). */ From 6e1931d6b98bc78992db2b943cac570b72bb48e4 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 12:45:08 +0100 Subject: [PATCH 5/6] refactor: drop checkMigrationOrder.enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With prefixPattern required, naming a pattern is already the opt-in. The CLI flag takes the pattern as its value, so it no longer needs merging over the config — it overrides it like every other option. --- packages/bun-sqlgen-core/src/generate.ts | 33 +++++++------------ packages/bun-sqlgen/pkg/README.md | 26 ++++++--------- .../src/cli/commands/generate/[glob].ts | 20 ++++++++--- packages/bun-sqlgen/src/config.ts | 12 +++---- 4 files changed, 41 insertions(+), 50 deletions(-) diff --git a/packages/bun-sqlgen-core/src/generate.ts b/packages/bun-sqlgen-core/src/generate.ts index e3eb67c..fc088f4 100644 --- a/packages/bun-sqlgen-core/src/generate.ts +++ b/packages/bun-sqlgen-core/src/generate.ts @@ -42,12 +42,11 @@ export interface GenerateOptions { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — what makes filename order (the order they apply in) the intended one. - * Partial because `--check-migration-order` contributes only `enabled`: this is - * merged over the config's settings, and the result still needs a `prefixPattern`. - * Off unless enabled. Unlike the other checks it guards generation itself, so it - * runs in every mode rather than replacing the write. + * Overrides config; absent from both, the check doesn't run. Unlike the other checks + * it guards generation itself, so it runs in every mode rather than replacing the + * write. */ - checkMigrationOrder?: Partial; + checkMigrationOrder?: MigrationOrderCheck; /** Explicit path to `sqlgen.config.{ts,js,mjs}`; auto-discovered otherwise. */ configPath?: string; /** Output path for the aggregated module, relative to `cwd`. Defaults to `src/queries.gen.ts`. */ @@ -65,14 +64,13 @@ export interface GenerateOptions { schema?: boolean; } -/** Settings for the migration-order check. */ +/** Settings for the migration-order check; its presence is what turns the check on. */ export interface MigrationOrderCheck { - enabled: boolean; /** * What identifies a filename's sequence prefix — the part that has to be unique and - * equally wide across every migration. Required, and deliberately not defaulted: only - * you know which convention your migrations were named for. `/^\d+/` matches - * `0001_init.sql`, `/^\d{14}_/` a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + * equally wide across every migration. Not defaulted: only you know which convention + * your filenames were named for. `/^\d+/` matches `0001_init.sql`, `/^\d{14}_/` a + * timestamp scheme, `/^[a-z]{4}_/` a lettered one. */ prefixPattern: RegExp; } @@ -113,18 +111,9 @@ export async function generate(options: GenerateOptions): Promise sql.replace(/\bCONCURRENTLY\b/g, ''), // require every migration to be numbered 0001, 0002, … so filename order // (the order they apply in) is the order they were meant to run in. - checkMigrationOrder: { enabled: true, prefixPattern: /^\d+/ }, + checkMigrationOrder: { prefixPattern: /^\d+/ }, }); ``` diff --git a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts index 7ffd58a..d571b20 100644 --- a/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts +++ b/packages/bun-sqlgen/src/cli/commands/generate/[glob].ts @@ -3,6 +3,15 @@ import { generate } from '@repo/bun-sqlgen-core'; import { z } from 'zod'; import { GenerationFailed } from '#cli/errors.ts'; +// The flag carries the pattern itself, so a bad one is a CLI mistake worth naming. +function toPrefixPattern(source: string): RegExp { + try { + return new RegExp(source); + } catch { + throw new Error(`--check-migration-order is not a valid regular expression: ${source}`); + } +} + export const command = defineCommand('generate [glob]', { description: 'Generate result types for the Bun.sql queries matching (e.g. "src/**/*.ts").', @@ -36,9 +45,9 @@ export const command = defineCommand('generate [glob]', { description: 'Fail if the committed generated types are out of date. Writes nothing.', }, 'check-migration-order': { - schema: z.boolean().optional(), + schema: z.string().optional(), description: - 'Enable the migration-order check: fail unless every migration filename carries a unique, equally wide sequence prefix. Reads its prefixPattern from checkMigrationOrder in sqlgen.config.ts, which is required. Not part of --check.', + 'Fail unless every migration filename carries a unique sequence prefix matching , all of one width (e.g. "^\\d+" for 0001_init.sql). Overrides config; not part of --check.', }, dialect: { schema: z.enum(['postgres', 'sqlite']).optional(), @@ -65,9 +74,10 @@ export const command = defineCommand('generate [glob]', { packageName: options.package, configPath: options.config, dialect: options.dialect, - // Absent, the config decides; the flag only ever turns the check on, and core - // merges it over the config, which is where the `prefixPattern` comes from. - checkMigrationOrder: options['check-migration-order'] ? { enabled: true } : undefined, + // Absent, the config decides — including whether the check runs at all. + checkMigrationOrder: options['check-migration-order'] + ? { prefixPattern: toPrefixPattern(options['check-migration-order']) } + : undefined, // Absent, the config decides; the flag only ever turns the schema block off. schema: options['no-schema'] ? false : undefined, checkQueries, diff --git a/packages/bun-sqlgen/src/config.ts b/packages/bun-sqlgen/src/config.ts index b70592e..5965a54 100644 --- a/packages/bun-sqlgen/src/config.ts +++ b/packages/bun-sqlgen/src/config.ts @@ -3,14 +3,13 @@ import type { Extensions } from '@electric-sql/pglite'; /** Which engine introspects the migrations at build time. Defaults to `postgres`. */ export type Dialect = 'postgres' | 'sqlite'; -/** Settings for the migration-order check. */ +/** Settings for the migration-order check; its presence is what turns the check on. */ export interface MigrationOrderCheck { - enabled: boolean; /** * What identifies a filename's sequence prefix — the part that has to be unique and - * equally wide across every migration. Required, and deliberately not defaulted: only - * you know which convention your migrations were named for. `/^\d+/` matches - * `0001_init.sql`, `/^\d{14}_/` a timestamp scheme, `/^[a-z]{4}_/` a lettered one. + * equally wide across every migration. Not defaulted: only you know which convention + * your filenames were named for. `/^\d+/` matches `0001_init.sql`, `/^\d{14}_/` a + * timestamp scheme, `/^[a-z]{4}_/` a lettered one. */ prefixPattern: RegExp; } @@ -26,8 +25,7 @@ interface BaseConfig { /** * Fail unless every migration filename carries a unique sequence prefix, all of one * width — migrations apply in filename order, so `1, 2, 10` applies as `1, 10, 2`. - * Off unless enabled; `--check-migration-order` enables it from the CLI, and needs - * the `prefixPattern` to come from here. + * Omit it and the check doesn't run; `--check-migration-order ` overrides it. */ checkMigrationOrder?: MigrationOrderCheck; /** SQL run before migrations (stub functions/types/extensions). */ From afb66bac73a0effdf90aa2798b7187e134b3b13f Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 1 Sep 2026 12:49:04 +0100 Subject: [PATCH 6/6] docs: trim the migration-order section One paragraph, one example. Drops the duplicate from the config block, which is about shaping the introspection DB. --- packages/bun-sqlgen/pkg/README.md | 37 ++++++++----------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/bun-sqlgen/pkg/README.md b/packages/bun-sqlgen/pkg/README.md index 2f15fd7..f755bbe 100644 --- a/packages/bun-sqlgen/pkg/README.md +++ b/packages/bun-sqlgen/pkg/README.md @@ -143,37 +143,21 @@ non-zero on a problem: - **`--check-stale`** — fail if the committed `queries.gen.ts` is out of date. - **`--check`** — run both; the one-flag CI default. -### Migration order - -A fourth check guards the migrations themselves rather than the output, so it runs on a -plain generate too and is *not* folded into `--check`. It needs to know your naming -convention, so you give it one — either in the config, or as the flag's value: +A fourth check guards the migrations rather than the output, so it runs on a plain +generate too and isn't folded into `--check`. Migrations apply in filename order, which +is the order you meant only while every prefix is the same width — `1, 2, 10` applies +as `1, 10, 2`. Naming the prefix turns the check on: ```ts // sqlgen.config.ts export default defineConfig({ - checkMigrationOrder: { - prefixPattern: /^\d+/, // 0001_init.sql, 0002_add_users.sql, … - }, + checkMigrationOrder: { prefixPattern: /^\d+/ }, // or --check-migration-order '^\d+' }); ``` -```sh -bun bun-sqlgen generate 'src/**/*.ts' --migrations db/migrations --check-migration-order '^\d+' -``` - -Naming a pattern is what turns the check on; there is no default, because only you know -which convention your filenames were named for. The flag overrides the config. - -Migrations apply in filename order, which is the order you meant only while every -filename carries a sequence prefix of the same width — `1, 2, 10` applies as -`1, 10, 2`, building a schema production never had. The check fails on a migration with -no prefix, on two claiming the same prefix, and on prefixes of differing widths. Gaps -are fine. - -Width, not "is it a number", is what's checked: equal-width prefixes sort the same way -in any positional scheme, so a scheme that doesn't number at all is held to its own -terms — `/^[a-z]{4}_/` for `aaaa_init.sql`, `/^\d{14}_/` for a timestamp convention. +It fails on a migration with no prefix, on two sharing one, and on prefixes of differing +widths; gaps are fine. Width is the whole rule, so an unnumbered scheme works on its own +terms: `/^[a-z]{4}_/`, `/^\d{14}_/`. Commit the generated file and run `--check` in CI so an edited query can never type-check against a stale shape. The @@ -245,16 +229,13 @@ export default defineConfig({ // rewrite/strip statements PGlite can't run, per migration file (CREATE INDEX // CONCURRENTLY can't run inside the transaction a multi-statement file applies in). transformMigration: ({ sql }) => sql.replace(/\bCONCURRENTLY\b/g, ''), - // require every migration to be numbered 0001, 0002, … so filename order - // (the order they apply in) is the order they were meant to run in. - checkMigrationOrder: { prefixPattern: /^\d+/ }, }); ``` `defineConfig` is optional — a plain `export default { … }` still works, but you lose the type-checking and autocompletion. -A runnable walkthrough of the three schema-shaping fields lives in the +A runnable walkthrough of all three fields lives in the [`with-config` example](https://github.com/ilbertt/bun-sqlgen/tree/main/examples/with-config). `extensions` is Postgres-only; `prelude` and `transformMigration` apply to both dialects, and `dialect: 'sqlite'` selects SQLite (the `--dialect` flag overrides it).