bunsql-native-migrate 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -261,7 +261,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
261
261
 
262
262
  Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
263
263
 
264
- `createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). It also accepts the same `{ tableName, schema }` options as the API functions. Custom drivers may also implement the optional `tryLock(timeoutSeconds)` / `releaseLock()` pair to participate in [concurrent-run locking](#concurrent-runs) — without them, `migrateUp` simply runs unlocked — and the optional `trackingTableExists()` used by `up`'s [dry run](#dry-run) to plan against a database that has no tracking table yet.
264
+ `createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). It also accepts the same `{ tableName, schema }` options as the API functions. Custom drivers may also implement the optional `tryLock(timeoutSeconds)` / `releaseLock()` pair to participate in [concurrent-run locking](#concurrent-runs) — without them, `migrateUp` simply runs unlocked — the optional `trackingTableExists()` used by `up`'s [dry run](#dry-run) to plan against a database that has no tracking table yet, and the optional `trackingTableCurrent()` probe that lets `up`/`down`/`status`/`mark` skip re-running `install()` when the tracking table already has its checksum column and unique index — without the probe, those commands install the table up front as before.
265
265
 
266
266
  ### Tracking table
267
267
 
@@ -309,7 +309,7 @@ Would apply 2 migration(s).
309
309
 
310
310
  - Nothing is executed and nothing is recorded — not even the tracking table is created, so it is safe against any database, production included. A database without the table simply plans everything as pending.
311
311
  - The plan honors every option the real run would: `to`, `steps`, `--table`/`--schema`. The checksum drift check runs too — a dry run reports `ChecksumDriftError` exactly where the real run would fail (the legacy NULL-checksum backfill is the one write it skips).
312
- - `down --dry-run` plans the revert list in reverse apply order; on an empty history it prints `No migrations to rollback.` like the real command.
312
+ - `down --dry-run` plans the revert list in reverse apply order; on an empty history — a database that has never seen an `up`, included — it prints `No migrations to rollback.` like the real command. A real `down` creates the tracking table when it is missing, so it degrades to the same message instead of a driver error.
313
313
  - In the API result the plan lands in `planned: string[]` while `applied`/`reverted` stay empty — existing consumers keep working untouched.
314
314
 
315
315
  ### Concurrent runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsql-native-migrate",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Zero-ORM SQL file migrations for Bun: PostgreSQL, MySQL/MariaDB and SQLite through the built-in Bun.SQL client",
5
5
  "keywords": [
6
6
  "bun",
package/src/api/down.ts CHANGED
@@ -6,16 +6,22 @@ import type { MigrateDownOptions, MigrateDownResult } from "./options.js";
6
6
  import { runWithDriver } from "./run-with-driver.js";
7
7
  import { runMigrationStep } from "./run-step.js";
8
8
  import { isSqlMigration, loadMigration } from "./load-migration.js";
9
+ import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
9
10
 
10
- function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
11
+ export function parseSteps(steps: number | "all" | undefined): number | "all" {
11
12
  if (steps === undefined) return 1;
12
- if (steps === "all") return appliedCount;
13
+ if (steps === "all") return "all";
13
14
  if (!Number.isInteger(steps) || steps < 1) {
14
15
  throw new Error(`Invalid steps: ${String(steps)} — expected a positive integer or "all"`);
15
16
  }
16
17
  return steps;
17
18
  }
18
19
 
20
+ function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
21
+ const parsed = parseSteps(steps);
22
+ return parsed === "all" ? appliedCount : parsed;
23
+ }
24
+
19
25
  async function revertOne(driver: MigrationDriver, listDir: string, file: string): Promise<void> {
20
26
  const { down } = await loadMigration(listDir, file);
21
27
 
@@ -37,17 +43,19 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
37
43
  const dryRun = options.dryRun ?? false;
38
44
 
39
45
  return runWithDriver(options, async (driver) => {
40
- const executed = await driver.listExecuted();
46
+ if (!dryRun) {
47
+ await ensureTrackingTable(driver);
48
+ }
49
+
50
+ const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
41
51
  if (executed.length === 0) {
42
52
  log({ text: "No migrations to rollback.", type: "warn" });
43
53
  return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
44
54
  }
45
55
 
46
56
  const count = resolveStepCount(options.steps, executed.length);
47
- const plan = executed
48
- .slice(-count)
49
- .reverse()
50
- .map((entry) => entry.name);
57
+ const revertList = executed.slice(-count).reverse();
58
+ const plan = revertList.map((entry) => entry.name);
51
59
 
52
60
  if (dryRun) {
53
61
  log({ text: "Dry run — no changes will be made.", type: "info" });
@@ -59,7 +67,7 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
59
67
 
60
68
  const reverted: string[] = [];
61
69
 
62
- for (const entry of executed.slice(-count).reverse()) {
70
+ for (const entry of revertList) {
63
71
  try {
64
72
  await revertOne(driver, listDir, entry.name);
65
73
  } catch (error) {
package/src/api/mark.ts CHANGED
@@ -1,35 +1,40 @@
1
1
  import path from "node:path";
2
2
  import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
3
3
  import { log } from "../core/console.js";
4
- import { type MarkOptions, type MarkResult, MigrationNotFoundError } from "./options.js";
4
+ import { type MarkOptions, type MarkResult } from "./options.js";
5
+ import { resolvePendingToTarget } from "./pending.js";
5
6
  import { runWithDriver } from "./run-with-driver.js";
7
+ import { ensureTrackingTable } from "./tracking-table.js";
6
8
 
7
9
  export async function markMigrationsApplied(options: MarkOptions = {}): Promise<MarkResult> {
8
10
  const listDir = resolveListDir(options.listDir);
9
11
  const target = options.to;
10
12
 
11
13
  return runWithDriver(options, async (driver) => {
12
- await driver.install();
14
+ await ensureTrackingTable(driver);
13
15
 
14
16
  const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
15
- if (target !== undefined && !allFiles.includes(target)) {
16
- throw new MigrationNotFoundError(target);
17
- }
18
-
19
17
  const executed = await driver.listExecuted();
20
- const executedNames = new Set(executed.map((entry) => entry.name));
21
- let pending = allFiles.filter((file) => !executedNames.has(file));
22
- if (target !== undefined) {
23
- if (executedNames.has(target)) {
24
- log({ text: `${target} is already applied.`, type: "info" });
25
- return { marked: [] };
26
- }
27
- pending = pending.slice(0, pending.indexOf(target) + 1);
18
+
19
+ const { pending, targetApplied } = resolvePendingToTarget({
20
+ allFiles,
21
+ executedNames: executed.map((entry) => entry.name),
22
+ target,
23
+ });
24
+ if (targetApplied) {
25
+ return { marked: [] };
28
26
  }
29
27
 
28
+ const checksums = new Map(
29
+ await Promise.all(
30
+ pending.map(async (file) => [file, await checksumFile(path.join(listDir, file))] as const),
31
+ ),
32
+ );
33
+
30
34
  const marked: string[] = [];
31
35
  for (const file of pending) {
32
- const checksum = await checksumFile(path.join(listDir, file));
36
+ const checksum = checksums.get(file);
37
+ if (!checksum) continue;
33
38
  await driver.record(file, checksum);
34
39
  marked.push(file);
35
40
  log({ text: `${file} marked as applied`, type: "success" });
@@ -0,0 +1,37 @@
1
+ import { log } from "../core/console.js";
2
+ import { MigrationNotFoundError } from "./options.js";
3
+
4
+ export interface PendingToTargetResult {
5
+ pending: string[];
6
+ targetApplied: boolean;
7
+ }
8
+
9
+ interface ResolvePendingOptions {
10
+ allFiles: string[];
11
+ executedNames: string[];
12
+ target: string | undefined;
13
+ }
14
+
15
+ export function resolvePendingToTarget({
16
+ allFiles,
17
+ executedNames,
18
+ target,
19
+ }: ResolvePendingOptions): PendingToTargetResult {
20
+ const executed = new Set(executedNames);
21
+
22
+ if (target !== undefined && !allFiles.includes(target)) {
23
+ throw new MigrationNotFoundError(target);
24
+ }
25
+
26
+ let pending = allFiles.filter((file) => !executed.has(file));
27
+ let targetApplied = false;
28
+ if (target !== undefined) {
29
+ if (executed.has(target)) {
30
+ log({ text: `${target} is already applied.`, type: "info" });
31
+ targetApplied = true;
32
+ } else {
33
+ pending = pending.slice(0, pending.indexOf(target) + 1);
34
+ }
35
+ }
36
+ return { pending, targetApplied };
37
+ }
package/src/api/status.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
2
2
  import type { MigrateOptions, MigrateStatusResult } from "./options.js";
3
3
  import { runWithDriver } from "./run-with-driver.js";
4
+ import { ensureTrackingTable } from "./tracking-table.js";
4
5
 
5
6
  export async function migrateStatus(options: MigrateOptions = {}): Promise<MigrateStatusResult> {
6
7
  const listDir = resolveListDir(options.listDir);
7
8
 
8
9
  return runWithDriver(options, async (driver) => {
9
- await driver.install();
10
+ await ensureTrackingTable(driver);
10
11
 
11
12
  const files = await listFiles(listDir, MIGRATION_EXTENSIONS);
12
13
  const executed = await driver.listExecuted();
@@ -0,0 +1,15 @@
1
+ import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
2
+
3
+ export async function ensureTrackingTable(driver: MigrationDriver): Promise<void> {
4
+ if (driver.trackingTableCurrent !== undefined && (await driver.trackingTableCurrent())) {
5
+ return;
6
+ }
7
+ await driver.install();
8
+ }
9
+
10
+ export async function listExecutedForPlan(driver: MigrationDriver): Promise<ExecutedMigration[]> {
11
+ if ((await driver.trackingTableExists?.()) === false) {
12
+ return [];
13
+ }
14
+ return driver.listExecuted();
15
+ }
package/src/api/up.ts CHANGED
@@ -2,24 +2,13 @@ import path from "node:path";
2
2
  import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
3
3
  import { log } from "../core/console.js";
4
4
  import { formatDuration } from "../core/duration.js";
5
- import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
6
- import {
7
- type MigrateUpOptions,
8
- type MigrateUpResult,
9
- ChecksumDriftError,
10
- MigrationNotFoundError,
11
- } from "./options.js";
5
+ import { type MigrateUpOptions, type MigrateUpResult, ChecksumDriftError } from "./options.js";
12
6
  import { runWithDriver } from "./run-with-driver.js";
13
7
  import { runMigrationStep } from "./run-step.js";
14
8
  import { loadMigration } from "./load-migration.js";
9
+ import { resolvePendingToTarget } from "./pending.js";
15
10
  import { resolveLockTimeout, withMigrationLock } from "./lock.js";
16
-
17
- async function listExecutedForPlan(driver: MigrationDriver): Promise<ExecutedMigration[]> {
18
- if ((await driver.trackingTableExists?.()) === false) {
19
- return [];
20
- }
21
- return driver.listExecuted();
22
- }
11
+ import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
23
12
 
24
13
  export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
25
14
  const listDir = resolveListDir(options.listDir);
@@ -29,14 +18,11 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
29
18
 
30
19
  return runWithDriver(options, async (driver) => {
31
20
  if (!dryRun) {
32
- await driver.install();
21
+ await ensureTrackingTable(driver);
33
22
  }
34
23
 
35
24
  const run = async (): Promise<MigrateUpResult> => {
36
25
  const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
37
- if (target !== undefined && !allFiles.includes(target)) {
38
- throw new MigrationNotFoundError(target);
39
- }
40
26
 
41
27
  const checksums = new Map(
42
28
  await Promise.all(
@@ -66,13 +52,13 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
66
52
  }
67
53
  }
68
54
 
69
- let pending = allFiles.filter((file) => !executedByName.has(file));
70
- if (target !== undefined) {
71
- if (executedByName.has(target)) {
72
- log({ text: `${target} is already applied.`, type: "info" });
73
- return dryRun ? { applied: [], planned: [] } : { applied: [] };
74
- }
75
- pending = pending.slice(0, pending.indexOf(target) + 1);
55
+ const { pending, targetApplied } = resolvePendingToTarget({
56
+ allFiles,
57
+ executedNames: executed.map((entry) => entry.name),
58
+ target,
59
+ });
60
+ if (targetApplied) {
61
+ return dryRun ? { applied: [], planned: [] } : { applied: [] };
76
62
  }
77
63
 
78
64
  if (dryRun) {
package/src/cli/main.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  import { migrateUp } from "../api/up.js";
3
- import { migrateDown } from "../api/down.js";
3
+ import { migrateDown, parseSteps } from "../api/down.js";
4
4
  import { migrateStatus } from "../api/status.js";
5
5
  import { installMigrations } from "../api/install.js";
6
6
  import { createMigrationCommand, type MigrationLang } from "../api/create.js";
7
7
  import { initMigrations } from "../api/init.js";
8
8
  import { markMigrationsApplied } from "../api/mark.js";
9
+ import { resolveLockTimeout } from "../api/lock.js";
9
10
  import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
10
11
  import { InvalidIdentifierError } from "../core/identifiers.js";
11
12
  import { log } from "../core/console.js";
@@ -63,15 +64,15 @@ function parseArgs(argv: string[]): CliArgs {
63
64
  }
64
65
  } else if (arg === "--lock-timeout") {
65
66
  const value = argv[++i];
66
- const parsed = Number(value);
67
- if (value === undefined || !Number.isInteger(parsed) || parsed < 0) {
67
+ try {
68
+ lockTimeout = resolveLockTimeout(Number(value));
69
+ } catch {
68
70
  log({
69
71
  text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
70
72
  type: "error",
71
73
  });
72
74
  usage(1);
73
75
  }
74
- lockTimeout = parsed;
75
76
  } else if (arg === "--table") {
76
77
  table = argv[++i];
77
78
  if (table === undefined) {
@@ -160,15 +161,15 @@ try {
160
161
  if (args.all) {
161
162
  steps = "all";
162
163
  } else if (stepsArg !== undefined) {
163
- const parsed = Number(stepsArg);
164
- if (!Number.isInteger(parsed) || parsed < 1) {
164
+ try {
165
+ steps = parseSteps(Number(stepsArg));
166
+ } catch {
165
167
  log({
166
168
  text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
167
169
  type: "error",
168
170
  });
169
171
  usage(1);
170
172
  }
171
- steps = parsed;
172
173
  }
173
174
  const { reverted, planned } = await migrateDown({
174
175
  ...listDirOptions,
@@ -13,32 +13,32 @@ interface LogOptions {
13
13
  error?: unknown;
14
14
  }
15
15
 
16
- /* eslint-disable @typescript-eslint/no-explicit-any */
17
- function formatError(error: any): void {
16
+ function formatError(error: unknown): void {
18
17
  if (error instanceof Error) {
19
18
  console.log(error.message);
20
19
  if (error.stack) console.log(error.stack);
21
20
  return;
22
21
  }
23
- if (typeof error === "object" && error !== null) {
24
- if ("code" in error && "detail" in error) {
25
- console.table(error);
26
- return;
27
- }
28
- if ("code" in error && "errno" in error) {
29
- console.log(error.code);
30
- console.log(error.errno);
31
- if ("byteOffset" in error) console.log(error.byteOffset);
32
- return;
33
- }
34
- if ("message" in error) {
35
- console.log(error.message);
36
- return;
37
- }
22
+ if (typeof error !== "object" || error === null) {
23
+ console.log(String(error));
24
+ return;
25
+ }
26
+ if ("code" in error && "detail" in error) {
27
+ console.table(error);
28
+ return;
29
+ }
30
+ if ("code" in error && "errno" in error) {
31
+ console.log(error.code);
32
+ console.log(error.errno);
33
+ if ("byteOffset" in error) console.log(error.byteOffset);
34
+ return;
35
+ }
36
+ if ("message" in error) {
37
+ console.log(error.message);
38
+ return;
38
39
  }
39
40
  console.log(String(error));
40
41
  }
41
- /* eslint-enable @typescript-eslint/no-explicit-any */
42
42
 
43
43
  export function log({ text, type, error = null }: LogOptions): void {
44
44
  console.log(colors[type], text);
@@ -13,6 +13,7 @@ export interface MigrationDriver {
13
13
  remove(migration: string): Promise<void>;
14
14
  transaction<T>(run: (tx: SQL) => Promise<T>): Promise<T>;
15
15
  trackingTableExists?(): Promise<boolean>;
16
+ trackingTableCurrent?(): Promise<boolean>;
16
17
  tryLock?(timeoutSeconds: number): Promise<boolean>;
17
18
  releaseLock?(): Promise<void>;
18
19
  close(): Promise<void>;
@@ -1,26 +1,16 @@
1
1
  import { type SQL } from "bun";
2
2
  import type { DriverTableOptions, MigrationDriver } from "../core/driver.js";
3
- import { backtickQuoted, validateIdentifier } from "../core/identifiers.js";
4
- import { createReservedLock, createSqlDriver } from "./shared.js";
3
+ import { backtickQuoted } from "../core/identifiers.js";
4
+ import {
5
+ createReservedLock,
6
+ createSqlDriver,
7
+ resolveTableRef,
8
+ UNIQUE_INDEX_SUFFIX,
9
+ } from "./shared.js";
5
10
 
6
11
  const LOCK_NAME_PREFIX = "bunsql-native-migrate:";
7
12
 
8
13
  const TABLE_NAME_MAX_LENGTH = 47;
9
- const UNIQUE_INDEX_SUFFIX = "_migration_unique";
10
-
11
- function resolveTableRef(options: DriverTableOptions): {
12
- table: string;
13
- index: string;
14
- name: string;
15
- } {
16
- const tableName = options.tableName ?? "migrations";
17
- validateIdentifier("table", tableName, TABLE_NAME_MAX_LENGTH);
18
- return {
19
- table: backtickQuoted(tableName),
20
- index: backtickQuoted(`${tableName}${UNIQUE_INDEX_SUFFIX}`),
21
- name: tableName,
22
- };
23
- }
24
14
 
25
15
  async function checksumColumnExists(db: SQL, tableName: string): Promise<boolean> {
26
16
  const rows = await db`SELECT column_name FROM information_schema.columns
@@ -39,7 +29,10 @@ async function uniqueIndexExists(db: SQL, tableName: string): Promise<boolean> {
39
29
  }
40
30
 
41
31
  export function create(databaseUrl: string, options: DriverTableOptions = {}): MigrationDriver {
42
- const { table, index, name } = resolveTableRef(options);
32
+ const { table, index, name } = resolveTableRef(options, {
33
+ quote: backtickQuoted,
34
+ maxLength: TABLE_NAME_MAX_LENGTH,
35
+ });
43
36
  return createSqlDriver(
44
37
  databaseUrl,
45
38
  {
@@ -63,6 +56,21 @@ export function create(databaseUrl: string, options: DriverTableOptions = {}): M
63
56
  AND table_name = ${name}`;
64
57
  return rows.length > 0;
65
58
  },
59
+ async trackingTableCurrent(db) {
60
+ const rows = (await db`SELECT EXISTS (
61
+ SELECT 1 FROM information_schema.tables
62
+ WHERE table_schema = DATABASE() AND table_name = ${name}
63
+ ) AND EXISTS (
64
+ SELECT 1 FROM information_schema.columns
65
+ WHERE table_schema = DATABASE() AND table_name = ${name} AND column_name = 'checksum'
66
+ ) AND EXISTS (
67
+ SELECT 1 FROM information_schema.statistics
68
+ WHERE table_schema = DATABASE() AND table_name = ${name}
69
+ AND index_name = ${`${name}${UNIQUE_INDEX_SUFFIX}`}
70
+ ) AS current`) as Array<{ current: number | boolean }>;
71
+ const current = rows[0]?.current;
72
+ return current === 1 || current === true;
73
+ },
66
74
  async record(db, migration, checksum) {
67
75
  await db`INSERT IGNORE INTO ${db.unsafe(table)} (migration, checksum)
68
76
  VALUES (${migration}, ${checksum})`;
@@ -1,32 +1,34 @@
1
1
  import { type SQL } from "bun";
2
2
  import type { DriverTableOptions, MigrationDriver } from "../core/driver.js";
3
3
  import { doubleQuoted, validateIdentifier } from "../core/identifiers.js";
4
- import { createReservedLock, createSqlDriver } from "./shared.js";
4
+ import {
5
+ createReservedLock,
6
+ createSqlDriver,
7
+ resolveTableRef,
8
+ UNIQUE_INDEX_SUFFIX,
9
+ type TableRef,
10
+ } from "./shared.js";
5
11
 
6
12
  const LOCK_SCOPE = "bunsql-native-migrate:up";
7
13
 
8
14
  const IDENTIFIER_MAX_LENGTH = 63;
9
- const UNIQUE_INDEX_SUFFIX = "_migration_unique";
10
15
 
11
- interface TableRef {
12
- table: string;
13
- index: string;
14
- name: string;
16
+ interface PostgresTableRef extends TableRef {
15
17
  schemaName?: string;
16
18
  }
17
19
 
18
- function resolveTableRef(options: DriverTableOptions): TableRef {
19
- const tableName = options.tableName ?? "migrations";
20
- validateIdentifier("table", tableName, IDENTIFIER_MAX_LENGTH);
21
- const index = doubleQuoted(`${tableName}${UNIQUE_INDEX_SUFFIX}`);
20
+ function resolvePostgresTableRef(options: DriverTableOptions): PostgresTableRef {
21
+ const base = resolveTableRef(options, {
22
+ quote: doubleQuoted,
23
+ maxLength: IDENTIFIER_MAX_LENGTH,
24
+ });
22
25
  if (options.schema === undefined) {
23
- return { table: doubleQuoted(tableName), index, name: tableName };
26
+ return base;
24
27
  }
25
28
  validateIdentifier("schema", options.schema, IDENTIFIER_MAX_LENGTH);
26
29
  return {
27
- table: `${doubleQuoted(options.schema)}.${doubleQuoted(tableName)}`,
28
- index,
29
- name: tableName,
30
+ ...base,
31
+ table: `${doubleQuoted(options.schema)}.${base.table}`,
30
32
  schemaName: options.schema,
31
33
  };
32
34
  }
@@ -38,7 +40,7 @@ async function advisoryKeyComponents(lock: SQL): Promise<[number, number]> {
38
40
  }
39
41
 
40
42
  export function create(databaseUrl: string, options: DriverTableOptions = {}): MigrationDriver {
41
- const { table, index, name, schemaName } = resolveTableRef(options);
43
+ const { table, index, name, schemaName } = resolvePostgresTableRef(options);
42
44
  return createSqlDriver(
43
45
  databaseUrl,
44
46
  {
@@ -59,16 +61,35 @@ export function create(databaseUrl: string, options: DriverTableOptions = {}): M
59
61
  WHERE table_schema = current_schema() AND table_name = ${name}`;
60
62
  return rows.length > 0;
61
63
  },
64
+ async trackingTableCurrent(db) {
65
+ const rows = (await db`SELECT EXISTS (
66
+ SELECT 1 FROM information_schema.columns
67
+ WHERE table_schema = COALESCE(${schemaName ?? null}, current_schema())
68
+ AND table_name = ${name} AND column_name = 'checksum'
69
+ ) AND EXISTS (
70
+ SELECT 1 FROM pg_indexes
71
+ WHERE schemaname = COALESCE(${schemaName ?? null}, current_schema())
72
+ AND indexname = ${`${name}${UNIQUE_INDEX_SUFFIX}`}
73
+ ) AS "current"`) as Array<{ current: boolean }>;
74
+ return rows[0]?.current === true;
75
+ },
62
76
  async record(db, migration, checksum) {
63
77
  await db`INSERT INTO ${db.unsafe(table)} (migration, checksum)
64
78
  VALUES (${migration}, ${checksum})
65
79
  ON CONFLICT (migration) DO NOTHING`;
66
80
  },
67
- createLock: (db) =>
68
- createReservedLock(
81
+ createLock: (db) => {
82
+ let cachedKey: [number, number] | null = null;
83
+ const resolveAdvisoryKey = async (lock: SQL): Promise<[number, number]> => {
84
+ if (cachedKey === null) {
85
+ cachedKey = await advisoryKeyComponents(lock);
86
+ }
87
+ return cachedKey;
88
+ };
89
+ return createReservedLock(
69
90
  db,
70
91
  async (lock) => {
71
- const [first, second] = await advisoryKeyComponents(lock);
92
+ const [first, second] = await resolveAdvisoryKey(lock);
72
93
  const rows =
73
94
  (await lock`SELECT pg_try_advisory_lock(${first}, ${second}) AS locked`) as Array<{
74
95
  locked: boolean;
@@ -76,10 +97,11 @@ export function create(databaseUrl: string, options: DriverTableOptions = {}): M
76
97
  return rows[0]?.locked === true;
77
98
  },
78
99
  async (lock) => {
79
- const [first, second] = await advisoryKeyComponents(lock);
100
+ const [first, second] = await resolveAdvisoryKey(lock);
80
101
  await lock`SELECT pg_advisory_unlock(${first}, ${second})`;
81
102
  },
82
- ),
103
+ );
104
+ },
83
105
  },
84
106
  table,
85
107
  );
@@ -1,5 +1,29 @@
1
1
  import { SQL, type ReservedSQL } from "bun";
2
- import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
2
+ import type { DriverTableOptions, ExecutedMigration, MigrationDriver } from "../core/driver.js";
3
+ import { validateIdentifier } from "../core/identifiers.js";
4
+
5
+ export const UNIQUE_INDEX_SUFFIX = "_migration_unique";
6
+
7
+ export interface TableRef {
8
+ table: string;
9
+ index: string;
10
+ name: string;
11
+ }
12
+
13
+ interface TableRefSpec {
14
+ quote: (identifier: string) => string;
15
+ maxLength: number;
16
+ }
17
+
18
+ export function resolveTableRef(options: DriverTableOptions, spec: TableRefSpec): TableRef {
19
+ const tableName = options.tableName ?? "migrations";
20
+ validateIdentifier("table", tableName, spec.maxLength);
21
+ return {
22
+ table: spec.quote(tableName),
23
+ index: spec.quote(`${tableName}${UNIQUE_INDEX_SUFFIX}`),
24
+ name: tableName,
25
+ };
26
+ }
3
27
 
4
28
  export interface SqlLock {
5
29
  tryLock(timeoutSeconds: number): Promise<boolean>;
@@ -11,6 +35,7 @@ export interface SqlDialect {
11
35
  install(db: SQL): Promise<void>;
12
36
  record(db: SQL, migration: string, checksum: string): Promise<void>;
13
37
  trackingTableExists?(db: SQL): Promise<boolean>;
38
+ trackingTableCurrent?(db: SQL): Promise<boolean>;
14
39
  createLock?(db: SQL): SqlLock;
15
40
  }
16
41
 
@@ -85,6 +110,9 @@ export function createSqlDriver(
85
110
  ...(dialect.trackingTableExists
86
111
  ? { trackingTableExists: () => dialect.trackingTableExists!(db) }
87
112
  : {}),
113
+ ...(dialect.trackingTableCurrent
114
+ ? { trackingTableCurrent: () => dialect.trackingTableCurrent!(db) }
115
+ : {}),
88
116
  ...(lock
89
117
  ? {
90
118
  tryLock: (timeoutSeconds: number) => lock.tryLock(timeoutSeconds),
@@ -1,26 +1,14 @@
1
1
  import type { DriverTableOptions, MigrationDriver } from "../core/driver.js";
2
- import { doubleQuoted, validateIdentifier } from "../core/identifiers.js";
3
- import { createSqlDriver } from "./shared.js";
2
+ import { doubleQuoted } from "../core/identifiers.js";
3
+ import { createSqlDriver, resolveTableRef, UNIQUE_INDEX_SUFFIX } from "./shared.js";
4
4
 
5
5
  const TABLE_NAME_MAX_LENGTH = 128;
6
- const UNIQUE_INDEX_SUFFIX = "_migration_unique";
7
-
8
- function resolveTableRef(options: DriverTableOptions): {
9
- table: string;
10
- index: string;
11
- name: string;
12
- } {
13
- const tableName = options.tableName ?? "migrations";
14
- validateIdentifier("table", tableName, TABLE_NAME_MAX_LENGTH);
15
- return {
16
- table: doubleQuoted(tableName),
17
- index: doubleQuoted(`${tableName}${UNIQUE_INDEX_SUFFIX}`),
18
- name: tableName,
19
- };
20
- }
21
6
 
22
7
  export function create(databaseUrl: string, options: DriverTableOptions = {}): MigrationDriver {
23
- const { table, index, name } = resolveTableRef(options);
8
+ const { table, index, name } = resolveTableRef(options, {
9
+ quote: doubleQuoted,
10
+ maxLength: TABLE_NAME_MAX_LENGTH,
11
+ });
24
12
  return createSqlDriver(
25
13
  databaseUrl,
26
14
  {
@@ -43,6 +31,16 @@ export function create(databaseUrl: string, options: DriverTableOptions = {}): M
43
31
  const rows = await db`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${name}`;
44
32
  return rows.length > 0;
45
33
  },
34
+ async trackingTableCurrent(db) {
35
+ const columns = (await db.unsafe(`PRAGMA table_info(${table})`)) as Array<{
36
+ name: string;
37
+ }>;
38
+ if (!columns.some((column) => column.name === "checksum")) return false;
39
+ const indexName = `${name}${UNIQUE_INDEX_SUFFIX}`;
40
+ const indexes = (await db`SELECT 1 FROM sqlite_master WHERE type = 'index'
41
+ AND name = ${indexName}`) as Array<unknown>;
42
+ return indexes.length > 0;
43
+ },
46
44
  async record(db, migration, checksum) {
47
45
  await db`INSERT OR IGNORE INTO ${db.unsafe(table)} (migration, checksum)
48
46
  VALUES (${migration}, ${checksum})`;