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
28 changes: 27 additions & 1 deletion packages/bun-sqlgen-core/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +20,7 @@ import type {
} from '#types.ts';

type LoadedConfig = Partial<Omit<IntrospectorOptions, 'migrationsDir'>> &
Pick<GenerateOptions, 'schema'>;
Pick<GenerateOptions, 'schema' | 'checkMigrationOrder'>;

// 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.
Expand All @@ -38,6 +39,14 @@ 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 sequence prefix, all of one
* width — what makes filename order (the order they apply in) the intended one.
* 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?: 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`. */
Expand All @@ -55,6 +64,17 @@ export interface GenerateOptions {
schema?: boolean;
}

/** Settings for the migration-order check; its presence is what turns the check on. */
export interface MigrationOrderCheck {
/**
* What identifies a filename's sequence prefix — the part that has to be unique and
* 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;
}

export interface GenerateFailure {
name: string;
file: string;
Expand Down Expand Up @@ -91,6 +111,12 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
const migrationsDir = resolve(cwd, options.migrations);
const outPath = resolve(cwd, options.out ?? DEFAULT_OUT);

// Before anything expensive: a misordered set builds the wrong schema silently.
const orderCheck = options.checkMigrationOrder ?? config.checkMigrationOrder;
if (orderCheck) {
requireOrderedMigrations({ migrationsDir, prefixPattern: orderCheck.prefixPattern });
}

// Resolve the query globs; skip our own generated output.
const globs = Array.isArray(options.queries) ? options.queries : [options.queries];
const matched = new Set<string>();
Expand Down
1 change: 1 addition & 0 deletions packages/bun-sqlgen-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
GenerateFailure,
GenerateOptions,
GenerateResult,
MigrationOrderCheck,
} from '#generate.ts';
export { generate } from '#generate.ts';
export { oidToTs, PG_OID } from '#oids.ts';
Expand Down
69 changes: 65 additions & 4 deletions packages/bun-sqlgen-core/src/introspect/migrations.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,10 +19,7 @@ export async function applyMigrations(input: {
transformMigration?: (input: { sql: string; filename: string }) => string;
}): Promise<void> {
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) {
Expand All @@ -28,6 +32,63 @@ export async function applyMigrations(input: {
}
}

/**
* Migrations apply in filename order, so that is the order you meant only while every
* filename carries a sequence prefix, all of them the same width and none repeated:
* `1, 2, 10` applies as `1, 10, 2`. Width, not "is it a number", is the invariant —
* equal-width prefixes sort the same way in any positional scheme, so a letter or
* timestamp convention passes on its own terms. `prefixPattern` says where the prefix
* ends; there is no default, since only the caller knows the convention it meant.
*/
export function requireOrderedMigrations(input: {
migrationsDir: string;
prefixPattern: RegExp;
}): void {
// `exec` carries `lastIndex` between calls under `g`/`y`; each filename is its own test.
const { prefixPattern } = input;
const pattern = new RegExp(prefixPattern.source, prefixPattern.flags.replace(/[gy]/g, ''));

const problems: string[] = [];
const prefixes: Array<{ filename: string; prefix: string }> = [];

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;
}
prefixes.push({ filename, prefix });
}

// 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<string, string>();
for (const { filename, prefix } of prefixes) {
const claimed = claimedBy.get(prefix);
if (claimed === undefined) {
claimedBy.set(prefix, filename);
} else {
problems.push(`${filename} — same "${prefix}" prefix as ${claimed}`);
}
}

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}`);
}
}

if (problems.length) {
throw new Error(
`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.`,
);
}
}

export function firstLine(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
return message.split('\n')[0] ?? message;
Expand Down
16 changes: 16 additions & 0 deletions packages/bun-sqlgen/pkg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ 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 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+/ }, // or --check-migration-order '^\d+'
});
```

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
[`check-only` example](https://github.com/ilbertt/bun-sqlgen/tree/main/examples/check-only)
Expand Down
18 changes: 18 additions & 0 deletions packages/bun-sqlgen/src/cli/commands/generate/[glob].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <glob> (e.g. "src/**/*.ts").',
Expand Down Expand Up @@ -35,6 +44,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.string().optional(),
description:
'Fail unless every migration filename carries a unique sequence prefix matching <pattern>, all of one width (e.g. "^\\d+" for 0001_init.sql). Overrides config; not part of --check.',
},
dialect: {
schema: z.enum(['postgres', 'sqlite']).optional(),
description: 'Database engine to introspect against (default postgres; overrides config).',
Expand All @@ -60,6 +74,10 @@ export const command = defineCommand('generate [glob]', {
packageName: options.package,
configPath: options.config,
dialect: options.dialect,
// 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,
Expand Down
17 changes: 17 additions & 0 deletions packages/bun-sqlgen/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; its presence is what turns the check on. */
export interface MigrationOrderCheck {
/**
* What identifies a filename's sequence prefix — the part that has to be unique and
* 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;
}

interface BaseConfig {
/** Database engine the queries run against. Defaults to `postgres`. */
dialect?: Dialect;
Expand All @@ -11,6 +22,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 sequence prefix, all of one
* width — migrations apply in filename order, so `1, 2, 10` applies as `1, 10, 2`.
* Omit it and the check doesn't run; `--check-migration-order <pattern>` overrides it.
*/
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. */
Expand Down