bunsql-native-migrate 0.2.0 → 0.3.1

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/src/api/up.ts CHANGED
@@ -1,6 +1,7 @@
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 { formatDuration } from "../core/duration.js";
4
5
  import {
5
6
  type MigrateUpOptions,
6
7
  type MigrateUpResult,
@@ -9,17 +10,22 @@ import {
9
10
  } from "./options.js";
10
11
  import { runWithDriver } from "./run-with-driver.js";
11
12
  import { runMigrationStep } from "./run-step.js";
13
+ import { loadMigration } from "./load-migration.js";
12
14
  import { resolveLockTimeout, withMigrationLock } from "./lock.js";
15
+ import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
13
16
 
14
17
  export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
15
18
  const listDir = resolveListDir(options.listDir);
16
19
  const target = options.to;
20
+ const dryRun = options.dryRun ?? false;
17
21
  const lockTimeout = resolveLockTimeout(options.lockTimeout);
18
22
 
19
23
  return runWithDriver(options, async (driver) => {
20
- await driver.install();
24
+ if (!dryRun) {
25
+ await ensureTrackingTable(driver);
26
+ }
21
27
 
22
- return withMigrationLock(driver, lockTimeout, async () => {
28
+ const run = async (): Promise<MigrateUpResult> => {
23
29
  const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
24
30
  if (target !== undefined && !allFiles.includes(target)) {
25
31
  throw new MigrationNotFoundError(target);
@@ -33,7 +39,7 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
33
39
  ),
34
40
  );
35
41
 
36
- const executed = await driver.listExecuted();
42
+ const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
37
43
  const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
38
44
 
39
45
  for (const [file, checksum] of checksums) {
@@ -41,8 +47,10 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
41
47
  if (!record) continue;
42
48
 
43
49
  if (record.checksum === null) {
44
- await driver.setChecksum(file, checksum);
45
- log({ text: `${file} checksum saved (legacy record)`, type: "info" });
50
+ if (!dryRun) {
51
+ await driver.setChecksum(file, checksum);
52
+ log({ text: `${file} checksum saved (legacy record)`, type: "info" });
53
+ }
46
54
  continue;
47
55
  }
48
56
 
@@ -55,11 +63,22 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
55
63
  if (target !== undefined) {
56
64
  if (executedByName.has(target)) {
57
65
  log({ text: `${target} is already applied.`, type: "info" });
58
- return { applied: [] };
66
+ return dryRun ? { applied: [], planned: [] } : { applied: [] };
59
67
  }
60
68
  pending = pending.slice(0, pending.indexOf(target) + 1);
61
69
  }
62
70
 
71
+ if (dryRun) {
72
+ log({ text: "Dry run — no changes will be made.", type: "info" });
73
+ if (pending.length === 0) {
74
+ log({ text: "No pending migrations.", type: "warn" });
75
+ }
76
+ for (const file of pending) {
77
+ log({ text: `${file} would be applied`, type: "info" });
78
+ }
79
+ return { applied: [], planned: pending };
80
+ }
81
+
63
82
  const applied: string[] = [];
64
83
 
65
84
  if (pending.length === 0) {
@@ -71,15 +90,15 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
71
90
  const checksum = checksums.get(file);
72
91
  if (!checksum) continue;
73
92
  try {
74
- const mod = await import(path.join(listDir, file));
75
- if (typeof mod.up !== "function") {
93
+ const { up } = await loadMigration(listDir, file);
94
+ if (up === null) {
76
95
  log({ text: `${file} has no up() export, skipping`, type: "warn" });
77
96
  continue;
78
97
  }
79
- await runMigrationStep(driver, mod.up);
98
+ const durationMs = await runMigrationStep(driver, up);
80
99
  await driver.record(file, checksum);
81
100
  applied.push(file);
82
- log({ text: `${file} migrated up`, type: "success" });
101
+ log({ text: `${file} migrated up (${formatDuration(durationMs)})`, type: "success" });
83
102
  } catch (error) {
84
103
  log({ text: `${file} migration failed`, type: "error", error });
85
104
  throw error;
@@ -87,6 +106,11 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
87
106
  }
88
107
 
89
108
  return { applied };
90
- });
109
+ };
110
+
111
+ if (dryRun) {
112
+ return run();
113
+ }
114
+ return withMigrationLock(driver, lockTimeout, run);
91
115
  });
92
116
  }
package/src/cli/main.ts CHANGED
@@ -4,7 +4,10 @@ import { migrateDown } 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
+ import { initMigrations } from "../api/init.js";
8
+ import { markMigrationsApplied } from "../api/mark.js";
7
9
  import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
10
+ import { InvalidIdentifierError } from "../core/identifiers.js";
8
11
  import { log } from "../core/console.js";
9
12
 
10
13
  interface CliArgs {
@@ -15,6 +18,9 @@ interface CliArgs {
15
18
  lang?: MigrationLang | undefined;
16
19
  to?: string | undefined;
17
20
  lockTimeout?: number | undefined;
21
+ table?: string | undefined;
22
+ schema?: string | undefined;
23
+ dryRun: boolean;
18
24
  all: boolean;
19
25
  strict: boolean;
20
26
  help: boolean;
@@ -27,6 +33,9 @@ function parseArgs(argv: string[]): CliArgs {
27
33
  let lang: MigrationLang | undefined;
28
34
  let to: string | undefined;
29
35
  let lockTimeout: number | undefined;
36
+ let table: string | undefined;
37
+ let schema: string | undefined;
38
+ let dryRun = false;
30
39
  let all = false;
31
40
  let strict = false;
32
41
  let help = false;
@@ -63,8 +72,22 @@ function parseArgs(argv: string[]): CliArgs {
63
72
  usage(1);
64
73
  }
65
74
  lockTimeout = parsed;
75
+ } else if (arg === "--table") {
76
+ table = argv[++i];
77
+ if (table === undefined) {
78
+ log({ text: "--table requires a tracking table name", type: "error" });
79
+ usage(1);
80
+ }
81
+ } else if (arg === "--schema") {
82
+ schema = argv[++i];
83
+ if (schema === undefined) {
84
+ log({ text: "--schema requires a postgres schema name", type: "error" });
85
+ usage(1);
86
+ }
66
87
  } else if (arg === "--all") {
67
88
  all = true;
89
+ } else if (arg === "--dry-run") {
90
+ dryRun = true;
68
91
  } else if (arg === "--strict") {
69
92
  strict = true;
70
93
  } else if (arg === "--help" || arg === "-h") {
@@ -81,6 +104,9 @@ function parseArgs(argv: string[]): CliArgs {
81
104
  lang,
82
105
  to,
83
106
  lockTimeout,
107
+ table,
108
+ schema,
109
+ dryRun,
84
110
  all,
85
111
  strict,
86
112
  help,
@@ -89,7 +115,7 @@ function parseArgs(argv: string[]): CliArgs {
89
115
 
90
116
  function usage(exitCode: number): never {
91
117
  log({
92
- text: "Usage: bunsql-native-migrate <up|down [n]|install|create [name]|status> [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--all] [--lang <js|ts>] [--git] [--strict] [--help]",
118
+ text: "Usage: bunsql-native-migrate <init|up|down [n]|install|create [name]|mark [name]|status> [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--help]",
93
119
  type: "info",
94
120
  });
95
121
  process.exit(exitCode);
@@ -97,6 +123,10 @@ function usage(exitCode: number): never {
97
123
 
98
124
  const args = parseArgs(process.argv.slice(2));
99
125
  const listDirOptions = args.dir ? { listDir: args.dir } : {};
126
+ const tableOptions = {
127
+ ...(args.table !== undefined ? { tableName: args.table } : {}),
128
+ ...(args.schema !== undefined ? { schema: args.schema } : {}),
129
+ };
100
130
 
101
131
  if (args.help) {
102
132
  usage(0);
@@ -105,11 +135,16 @@ if (args.help) {
105
135
  try {
106
136
  switch (args.command) {
107
137
  case "up": {
108
- const { applied } = await migrateUp({
138
+ const { applied, planned } = await migrateUp({
109
139
  ...listDirOptions,
140
+ ...tableOptions,
110
141
  ...(args.to ? { to: args.to } : {}),
111
142
  ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
143
+ ...(args.dryRun ? { dryRun: true } : {}),
112
144
  });
145
+ if (planned !== undefined && planned.length > 0) {
146
+ log({ text: `Would apply ${planned.length} migration(s).`, type: "info" });
147
+ }
113
148
  if (applied.length > 0) {
114
149
  log({ text: `Applied ${applied.length} migration(s).`, type: "success" });
115
150
  }
@@ -135,14 +170,29 @@ try {
135
170
  }
136
171
  steps = parsed;
137
172
  }
138
- const { reverted } = await migrateDown({ ...listDirOptions, steps });
173
+ const { reverted, planned } = await migrateDown({
174
+ ...listDirOptions,
175
+ ...tableOptions,
176
+ steps,
177
+ ...(args.dryRun ? { dryRun: true } : {}),
178
+ });
179
+ if (planned !== undefined && planned.length > 0) {
180
+ log({ text: `Would revert ${planned.length} migration(s).`, type: "info" });
181
+ }
139
182
  if (reverted.length > 0) {
140
183
  log({ text: `Reverted ${reverted.length} migration(s).`, type: "success" });
141
184
  }
142
185
  break;
143
186
  }
187
+ case "init": {
188
+ await initMigrations({
189
+ ...listDirOptions,
190
+ ...(args.lang !== undefined ? { lang: args.lang } : {}),
191
+ });
192
+ break;
193
+ }
144
194
  case "install": {
145
- await installMigrations(listDirOptions);
195
+ await installMigrations({ ...listDirOptions, ...tableOptions });
146
196
  break;
147
197
  }
148
198
  case "create": {
@@ -155,8 +205,28 @@ try {
155
205
  });
156
206
  break;
157
207
  }
208
+ case "mark": {
209
+ const [name] = args.positional;
210
+ if (args.all && name !== undefined) {
211
+ log({ text: "Use either --all or a migration file name, not both.", type: "error" });
212
+ usage(1);
213
+ }
214
+ if (!args.all && name === undefined) {
215
+ log({ text: "mark requires a migration file name or --all.", type: "error" });
216
+ usage(1);
217
+ }
218
+ const { marked } = await markMigrationsApplied({
219
+ ...listDirOptions,
220
+ ...tableOptions,
221
+ ...(name !== undefined ? { to: name } : {}),
222
+ });
223
+ if (marked.length > 0) {
224
+ log({ text: `Marked ${marked.length} migration(s) as applied.`, type: "success" });
225
+ }
226
+ break;
227
+ }
158
228
  case "status": {
159
- const { applied, pending } = await migrateStatus(listDirOptions);
229
+ const { applied, pending } = await migrateStatus({ ...listDirOptions, ...tableOptions });
160
230
  for (const entry of applied) {
161
231
  log({ text: `${entry.name} applied`, type: "info" });
162
232
  }
@@ -177,7 +247,8 @@ try {
177
247
  if (
178
248
  error instanceof ChecksumDriftError ||
179
249
  error instanceof MigrationNotFoundError ||
180
- error instanceof MigrationLockError
250
+ error instanceof MigrationLockError ||
251
+ error instanceof InvalidIdentifierError
181
252
  ) {
182
253
  log({ text: error.message, type: "error" });
183
254
  } else {
@@ -12,12 +12,22 @@ export interface MigrationDriver {
12
12
  setChecksum(migration: string, checksum: string): Promise<void>;
13
13
  remove(migration: string): Promise<void>;
14
14
  transaction<T>(run: (tx: SQL) => Promise<T>): Promise<T>;
15
+ trackingTableExists?(): Promise<boolean>;
16
+ trackingTableCurrent?(): Promise<boolean>;
15
17
  tryLock?(timeoutSeconds: number): Promise<boolean>;
16
18
  releaseLock?(): Promise<void>;
17
19
  close(): Promise<void>;
18
20
  }
19
21
 
20
- export async function createDriver(databaseUrl: string): Promise<MigrationDriver> {
22
+ export interface DriverTableOptions {
23
+ tableName?: string;
24
+ schema?: string;
25
+ }
26
+
27
+ export async function createDriver(
28
+ databaseUrl: string,
29
+ options: DriverTableOptions = {},
30
+ ): Promise<MigrationDriver> {
21
31
  let protocol: string;
22
32
  try {
23
33
  protocol = new URL(databaseUrl).protocol.replace(":", "");
@@ -28,20 +38,25 @@ export async function createDriver(databaseUrl: string): Promise<MigrationDriver
28
38
  throw new Error(`Cannot parse database URL: ${databaseUrl}`);
29
39
  }
30
40
  }
41
+ if (options.schema !== undefined && protocol !== "postgres" && protocol !== "postgresql") {
42
+ throw new Error(
43
+ "The schema option is only supported for postgres URLs — MySQL/MariaDB selects the database in the URL, SQLite has no schemas",
44
+ );
45
+ }
31
46
  switch (protocol) {
32
47
  case "postgres":
33
48
  case "postgresql": {
34
49
  const mod = await import("./../drivers/postgres.js");
35
- return mod.create(databaseUrl);
50
+ return mod.create(databaseUrl, options);
36
51
  }
37
52
  case "sqlite": {
38
53
  const mod = await import("./../drivers/sqlite.js");
39
- return mod.create(databaseUrl);
54
+ return mod.create(databaseUrl, options);
40
55
  }
41
56
  case "mariadb":
42
57
  case "mysql": {
43
58
  const mod = await import("./../drivers/mariadb.js");
44
- return mod.create(databaseUrl);
59
+ return mod.create(databaseUrl, options);
45
60
  }
46
61
  default:
47
62
  throw new Error(
@@ -0,0 +1,7 @@
1
+ export function formatDuration(durationMs: number): string {
2
+ const roundedMs = Math.round(durationMs);
3
+ if (roundedMs < 1000) {
4
+ return `${roundedMs}ms`;
5
+ }
6
+ return `${(durationMs / 1000).toFixed(1)}s`;
7
+ }
package/src/core/fs.ts CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  export const DEFAULT_MIGRATIONS_DIR = "migrations";
5
5
 
6
- export const MIGRATION_EXTENSIONS = ["js", "ts"] as const;
6
+ export const MIGRATION_EXTENSIONS = ["js", "ts", "up.sql"] as const;
7
7
 
8
8
  export async function listFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
9
9
  const matchedFiles: string[] = [];
@@ -0,0 +1,32 @@
1
+ export type IdentifierKind = "table" | "schema";
2
+
3
+ const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
4
+
5
+ export class InvalidIdentifierError extends Error {
6
+ readonly kind: IdentifierKind;
7
+ readonly value: string;
8
+
9
+ constructor(kind: IdentifierKind, value: string, maxLength: number) {
10
+ super(
11
+ `Invalid ${kind} name: "${value}" — expected an identifier of letters, digits, underscores and dollar signs ` +
12
+ `starting with a letter or underscore, at most ${maxLength} characters`,
13
+ );
14
+ this.name = "InvalidIdentifierError";
15
+ this.kind = kind;
16
+ this.value = value;
17
+ }
18
+ }
19
+
20
+ export function validateIdentifier(kind: IdentifierKind, value: string, maxLength: number): void {
21
+ if (!IDENTIFIER_PATTERN.test(value) || value.length > maxLength) {
22
+ throw new InvalidIdentifierError(kind, value, maxLength);
23
+ }
24
+ }
25
+
26
+ export function doubleQuoted(name: string): string {
27
+ return `"${name}"`;
28
+ }
29
+
30
+ export function backtickQuoted(name: string): string {
31
+ return `\`${name}\``;
32
+ }
@@ -1,59 +1,103 @@
1
1
  import { type SQL } from "bun";
2
- import type { MigrationDriver } from "../core/driver.js";
2
+ import type { DriverTableOptions, MigrationDriver } from "../core/driver.js";
3
+ import { backtickQuoted, validateIdentifier } from "../core/identifiers.js";
3
4
  import { createReservedLock, createSqlDriver } from "./shared.js";
4
5
 
5
6
  const LOCK_NAME_PREFIX = "bunsql-native-migrate:";
6
7
 
7
- async function checksumColumnExists(db: SQL): Promise<boolean> {
8
+ 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
+
25
+ async function checksumColumnExists(db: SQL, tableName: string): Promise<boolean> {
8
26
  const rows = await db`SELECT column_name FROM information_schema.columns
9
27
  WHERE table_schema = DATABASE()
10
- AND table_name = 'migrations'
28
+ AND table_name = ${tableName}
11
29
  AND column_name = 'checksum'`;
12
30
  return rows.length > 0;
13
31
  }
14
32
 
15
- async function uniqueIndexExists(db: SQL): Promise<boolean> {
33
+ async function uniqueIndexExists(db: SQL, tableName: string): Promise<boolean> {
16
34
  const rows = await db`SELECT index_name FROM information_schema.statistics
17
35
  WHERE table_schema = DATABASE()
18
- AND table_name = 'migrations'
19
- AND index_name = 'migrations_migration_unique'`;
36
+ AND table_name = ${tableName}
37
+ AND index_name = ${`${tableName}${UNIQUE_INDEX_SUFFIX}`}`;
20
38
  return rows.length > 0;
21
39
  }
22
40
 
23
- export function create(databaseUrl: string): MigrationDriver {
24
- return createSqlDriver(databaseUrl, {
25
- async install(db) {
26
- await db`CREATE TABLE IF NOT EXISTS migrations (
27
- id INTEGER PRIMARY KEY AUTO_INCREMENT,
28
- migration VARCHAR(255) NOT NULL,
29
- checksum VARCHAR(64),
30
- CONSTRAINT migrations_migration_unique UNIQUE (migration)
31
- )`;
32
- if (!(await checksumColumnExists(db))) {
33
- await db`ALTER TABLE migrations ADD COLUMN checksum VARCHAR(64)`;
34
- }
35
- if (!(await uniqueIndexExists(db))) {
36
- await db`CREATE UNIQUE INDEX migrations_migration_unique ON migrations (migration)`;
37
- }
38
- },
39
- async record(db, migration, checksum) {
40
- await db`INSERT IGNORE INTO migrations (migration, checksum)
41
- VALUES (${migration}, ${checksum})`;
41
+ export function create(databaseUrl: string, options: DriverTableOptions = {}): MigrationDriver {
42
+ const { table, index, name } = resolveTableRef(options);
43
+ return createSqlDriver(
44
+ databaseUrl,
45
+ {
46
+ async install(db) {
47
+ await db`CREATE TABLE IF NOT EXISTS ${db.unsafe(table)} (
48
+ id INTEGER PRIMARY KEY AUTO_INCREMENT,
49
+ migration VARCHAR(255) NOT NULL,
50
+ checksum VARCHAR(64),
51
+ CONSTRAINT ${db.unsafe(index)} UNIQUE (migration)
52
+ )`;
53
+ if (!(await checksumColumnExists(db, name))) {
54
+ await db`ALTER TABLE ${db.unsafe(table)} ADD COLUMN checksum VARCHAR(64)`;
55
+ }
56
+ if (!(await uniqueIndexExists(db, name))) {
57
+ await db`CREATE UNIQUE INDEX ${db.unsafe(index)} ON ${db.unsafe(table)} (migration)`;
58
+ }
59
+ },
60
+ async trackingTableExists(db) {
61
+ const rows = await db`SELECT 1 FROM information_schema.tables
62
+ WHERE table_schema = DATABASE()
63
+ AND table_name = ${name}`;
64
+ return rows.length > 0;
65
+ },
66
+ async trackingTableCurrent(db) {
67
+ const rows = (await db`SELECT EXISTS (
68
+ SELECT 1 FROM information_schema.tables
69
+ WHERE table_schema = DATABASE() AND table_name = ${name}
70
+ ) AND EXISTS (
71
+ SELECT 1 FROM information_schema.columns
72
+ WHERE table_schema = DATABASE() AND table_name = ${name} AND column_name = 'checksum'
73
+ ) AND EXISTS (
74
+ SELECT 1 FROM information_schema.statistics
75
+ WHERE table_schema = DATABASE() AND table_name = ${name}
76
+ AND index_name = ${`${name}${UNIQUE_INDEX_SUFFIX}`}
77
+ ) AS current`) as Array<{ current: number | boolean }>;
78
+ const current = rows[0]?.current;
79
+ return current === 1 || current === true;
80
+ },
81
+ async record(db, migration, checksum) {
82
+ await db`INSERT IGNORE INTO ${db.unsafe(table)} (migration, checksum)
83
+ VALUES (${migration}, ${checksum})`;
84
+ },
85
+ createLock: (db) =>
86
+ createReservedLock(
87
+ db,
88
+ async (lock) => {
89
+ const rows =
90
+ (await lock`SELECT GET_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())), 0) AS locked`) as Array<{
91
+ locked: number | string | null;
92
+ }>;
93
+ const locked = rows[0]?.locked;
94
+ return locked === 1 || locked === "1";
95
+ },
96
+ async (lock) => {
97
+ await lock`SELECT RELEASE_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())))`;
98
+ },
99
+ ),
42
100
  },
43
- createLock: (db) =>
44
- createReservedLock(
45
- db,
46
- async (lock) => {
47
- const rows =
48
- (await lock`SELECT GET_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())), 0) AS locked`) as Array<{
49
- locked: number | string | null;
50
- }>;
51
- const locked = rows[0]?.locked;
52
- return locked === 1 || locked === "1";
53
- },
54
- async (lock) => {
55
- await lock`SELECT RELEASE_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())))`;
56
- },
57
- ),
58
- });
101
+ table,
102
+ );
59
103
  }
@@ -1,46 +1,98 @@
1
1
  import { type SQL } from "bun";
2
- import type { MigrationDriver } from "../core/driver.js";
2
+ import type { DriverTableOptions, MigrationDriver } from "../core/driver.js";
3
+ import { doubleQuoted, validateIdentifier } from "../core/identifiers.js";
3
4
  import { createReservedLock, createSqlDriver } from "./shared.js";
4
5
 
5
6
  const LOCK_SCOPE = "bunsql-native-migrate:up";
6
7
 
8
+ const IDENTIFIER_MAX_LENGTH = 63;
9
+ const UNIQUE_INDEX_SUFFIX = "_migration_unique";
10
+
11
+ interface TableRef {
12
+ table: string;
13
+ index: string;
14
+ name: string;
15
+ schemaName?: string;
16
+ }
17
+
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}`);
22
+ if (options.schema === undefined) {
23
+ return { table: doubleQuoted(tableName), index, name: tableName };
24
+ }
25
+ validateIdentifier("schema", options.schema, IDENTIFIER_MAX_LENGTH);
26
+ return {
27
+ table: `${doubleQuoted(options.schema)}.${doubleQuoted(tableName)}`,
28
+ index,
29
+ name: tableName,
30
+ schemaName: options.schema,
31
+ };
32
+ }
33
+
7
34
  async function advisoryKeyComponents(lock: SQL): Promise<[number, number]> {
8
35
  const rows = (await lock`SELECT current_database() AS name`) as Array<{ name: string }>;
9
36
  const hash = Bun.hash.wyhash(`${LOCK_SCOPE}:${rows[0]?.name ?? ""}`);
10
37
  return [Number((hash >> 32n) & 0x7fffffffn), Number(hash & 0x7fffffffn)];
11
38
  }
12
39
 
13
- export function create(databaseUrl: string): MigrationDriver {
14
- return createSqlDriver(databaseUrl, {
15
- async install(db) {
16
- await db`CREATE TABLE IF NOT EXISTS migrations (
17
- id SERIAL PRIMARY KEY,
18
- migration VARCHAR(255) NOT NULL,
19
- checksum VARCHAR(64)
20
- )`;
21
- await db`ALTER TABLE migrations ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
22
- await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
23
- },
24
- async record(db, migration, checksum) {
25
- await db`INSERT INTO migrations (migration, checksum)
26
- VALUES (${migration}, ${checksum})
27
- ON CONFLICT (migration) DO NOTHING`;
40
+ export function create(databaseUrl: string, options: DriverTableOptions = {}): MigrationDriver {
41
+ const { table, index, name, schemaName } = resolveTableRef(options);
42
+ return createSqlDriver(
43
+ databaseUrl,
44
+ {
45
+ async install(db) {
46
+ await db`CREATE TABLE IF NOT EXISTS ${db.unsafe(table)} (
47
+ id SERIAL PRIMARY KEY,
48
+ migration VARCHAR(255) NOT NULL,
49
+ checksum VARCHAR(64)
50
+ )`;
51
+ await db`ALTER TABLE ${db.unsafe(table)} ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
52
+ await db`CREATE UNIQUE INDEX IF NOT EXISTS ${db.unsafe(index)} ON ${db.unsafe(table)} (migration)`;
53
+ },
54
+ async trackingTableExists(db) {
55
+ const rows = schemaName
56
+ ? await db`SELECT 1 FROM information_schema.tables
57
+ WHERE table_schema = ${schemaName} AND table_name = ${name}`
58
+ : await db`SELECT 1 FROM information_schema.tables
59
+ WHERE table_schema = current_schema() AND table_name = ${name}`;
60
+ return rows.length > 0;
61
+ },
62
+ async trackingTableCurrent(db) {
63
+ const rows = (await db`SELECT EXISTS (
64
+ SELECT 1 FROM information_schema.columns
65
+ WHERE table_schema = COALESCE(${schemaName ?? null}, current_schema())
66
+ AND table_name = ${name} AND column_name = 'checksum'
67
+ ) AND EXISTS (
68
+ SELECT 1 FROM pg_indexes
69
+ WHERE schemaname = COALESCE(${schemaName ?? null}, current_schema())
70
+ AND indexname = ${`${name}${UNIQUE_INDEX_SUFFIX}`}
71
+ ) AS "current"`) as Array<{ current: boolean }>;
72
+ return rows[0]?.current === true;
73
+ },
74
+ async record(db, migration, checksum) {
75
+ await db`INSERT INTO ${db.unsafe(table)} (migration, checksum)
76
+ VALUES (${migration}, ${checksum})
77
+ ON CONFLICT (migration) DO NOTHING`;
78
+ },
79
+ createLock: (db) =>
80
+ createReservedLock(
81
+ db,
82
+ async (lock) => {
83
+ const [first, second] = await advisoryKeyComponents(lock);
84
+ const rows =
85
+ (await lock`SELECT pg_try_advisory_lock(${first}, ${second}) AS locked`) as Array<{
86
+ locked: boolean;
87
+ }>;
88
+ return rows[0]?.locked === true;
89
+ },
90
+ async (lock) => {
91
+ const [first, second] = await advisoryKeyComponents(lock);
92
+ await lock`SELECT pg_advisory_unlock(${first}, ${second})`;
93
+ },
94
+ ),
28
95
  },
29
- createLock: (db) =>
30
- createReservedLock(
31
- db,
32
- async (lock) => {
33
- const [first, second] = await advisoryKeyComponents(lock);
34
- const rows =
35
- (await lock`SELECT pg_try_advisory_lock(${first}, ${second}) AS locked`) as Array<{
36
- locked: boolean;
37
- }>;
38
- return rows[0]?.locked === true;
39
- },
40
- async (lock) => {
41
- const [first, second] = await advisoryKeyComponents(lock);
42
- await lock`SELECT pg_advisory_unlock(${first}, ${second})`;
43
- },
44
- ),
45
- });
96
+ table,
97
+ );
46
98
  }