bunsql-native-migrate 0.1.1 → 0.2.0

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,69 +1,92 @@
1
1
  import path from "node:path";
2
- import { checksumFile, listFiles, resolveListDir } from "../core/fs.js";
2
+ import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
3
3
  import { log } from "../core/console.js";
4
- import { type MigrateOptions, type MigrateUpResult, ChecksumDriftError } from "./options.js";
4
+ import {
5
+ type MigrateUpOptions,
6
+ type MigrateUpResult,
7
+ ChecksumDriftError,
8
+ MigrationNotFoundError,
9
+ } from "./options.js";
5
10
  import { runWithDriver } from "./run-with-driver.js";
11
+ import { runMigrationStep } from "./run-step.js";
12
+ import { resolveLockTimeout, withMigrationLock } from "./lock.js";
6
13
 
7
- export async function migrateUp(options: MigrateOptions = {}): Promise<MigrateUpResult> {
14
+ export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
8
15
  const listDir = resolveListDir(options.listDir);
16
+ const target = options.to;
17
+ const lockTimeout = resolveLockTimeout(options.lockTimeout);
9
18
 
10
19
  return runWithDriver(options, async (driver) => {
11
20
  await driver.install();
12
21
 
13
- const allFiles = await listFiles(listDir, "js");
14
- const checksumEntries = await Promise.all(
15
- allFiles.map(async (file) => [file, await checksumFile(path.join(listDir, file))] as const),
16
- );
17
- const checksums = new Map(checksumEntries);
22
+ return withMigrationLock(driver, lockTimeout, async () => {
23
+ const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
24
+ if (target !== undefined && !allFiles.includes(target)) {
25
+ throw new MigrationNotFoundError(target);
26
+ }
18
27
 
19
- const executed = await driver.listExecuted();
20
- const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
28
+ const checksums = new Map(
29
+ await Promise.all(
30
+ allFiles.map(
31
+ async (file) => [file, await checksumFile(path.join(listDir, file))] as const,
32
+ ),
33
+ ),
34
+ );
21
35
 
22
- for (const file of allFiles) {
23
- const record = executedByName.get(file);
24
- if (!record) continue;
36
+ const executed = await driver.listExecuted();
37
+ const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
25
38
 
26
- const checksum = checksums.get(file);
27
- if (!checksum) continue;
39
+ for (const [file, checksum] of checksums) {
40
+ const record = executedByName.get(file);
41
+ if (!record) continue;
28
42
 
29
- if (record.checksum === null) {
30
- await driver.setChecksum(file, checksum);
31
- log({ text: `${file} checksum saved (legacy record)`, type: "info" });
32
- continue;
43
+ if (record.checksum === null) {
44
+ await driver.setChecksum(file, checksum);
45
+ log({ text: `${file} checksum saved (legacy record)`, type: "info" });
46
+ continue;
47
+ }
48
+
49
+ if (record.checksum !== checksum) {
50
+ throw new ChecksumDriftError(file);
51
+ }
33
52
  }
34
53
 
35
- if (record.checksum !== checksum) {
36
- throw new ChecksumDriftError(file);
54
+ let pending = allFiles.filter((file) => !executedByName.has(file));
55
+ if (target !== undefined) {
56
+ if (executedByName.has(target)) {
57
+ log({ text: `${target} is already applied.`, type: "info" });
58
+ return { applied: [] };
59
+ }
60
+ pending = pending.slice(0, pending.indexOf(target) + 1);
37
61
  }
38
- }
39
62
 
40
- const pending = allFiles.filter((file) => !executedByName.has(file));
41
- const applied: string[] = [];
63
+ const applied: string[] = [];
42
64
 
43
- if (pending.length === 0) {
44
- log({ text: "No pending migrations.", type: "warn" });
45
- return { applied };
46
- }
65
+ if (pending.length === 0) {
66
+ log({ text: "No pending migrations.", type: "warn" });
67
+ return { applied };
68
+ }
47
69
 
48
- for (const file of pending) {
49
- const checksum = checksums.get(file);
50
- if (!checksum) continue;
51
- try {
52
- const mod = await import(path.join(listDir, file));
53
- if (typeof mod.up !== "function") {
54
- log({ text: `${file} has no up() export, skipping`, type: "warn" });
55
- continue;
70
+ for (const file of pending) {
71
+ const checksum = checksums.get(file);
72
+ if (!checksum) continue;
73
+ try {
74
+ const mod = await import(path.join(listDir, file));
75
+ if (typeof mod.up !== "function") {
76
+ log({ text: `${file} has no up() export, skipping`, type: "warn" });
77
+ continue;
78
+ }
79
+ await runMigrationStep(driver, mod.up);
80
+ await driver.record(file, checksum);
81
+ applied.push(file);
82
+ log({ text: `${file} migrated up`, type: "success" });
83
+ } catch (error) {
84
+ log({ text: `${file} migration failed`, type: "error", error });
85
+ throw error;
56
86
  }
57
- await mod.up();
58
- await driver.record(file, checksum);
59
- applied.push(file);
60
- log({ text: `${file} migrated up`, type: "success" });
61
- } catch (error) {
62
- log({ text: `${file} migration failed`, type: "error", error });
63
- throw error;
64
87
  }
65
- }
66
88
 
67
- return { applied };
89
+ return { applied };
90
+ });
68
91
  });
69
92
  }
package/src/cli/main.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { migrateUp } from "../api/up.js";
3
3
  import { migrateDown } from "../api/down.js";
4
+ import { migrateStatus } from "../api/status.js";
4
5
  import { installMigrations } from "../api/install.js";
5
- import { createMigrationCommand } from "../api/create.js";
6
- import { ChecksumDriftError } from "../api/options.js";
6
+ import { createMigrationCommand, type MigrationLang } from "../api/create.js";
7
+ import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
7
8
  import { log } from "../core/console.js";
8
9
 
9
10
  interface CliArgs {
@@ -11,6 +12,11 @@ interface CliArgs {
11
12
  positional: string[];
12
13
  dir?: string | undefined;
13
14
  git: boolean;
15
+ lang?: MigrationLang | undefined;
16
+ to?: string | undefined;
17
+ lockTimeout?: number | undefined;
18
+ all: boolean;
19
+ strict: boolean;
14
20
  help: boolean;
15
21
  }
16
22
 
@@ -18,6 +24,11 @@ function parseArgs(argv: string[]): CliArgs {
18
24
  const positional: string[] = [];
19
25
  let dir: string | undefined;
20
26
  let git = false;
27
+ let lang: MigrationLang | undefined;
28
+ let to: string | undefined;
29
+ let lockTimeout: number | undefined;
30
+ let all = false;
31
+ let strict = false;
21
32
  let help = false;
22
33
  for (let i = 0; i < argv.length; i++) {
23
34
  const arg = argv[i]!;
@@ -25,18 +36,60 @@ function parseArgs(argv: string[]): CliArgs {
25
36
  dir = argv[++i];
26
37
  } else if (arg === "--git") {
27
38
  git = true;
39
+ } else if (arg === "--lang") {
40
+ const value = argv[++i];
41
+ if (value !== "js" && value !== "ts") {
42
+ log({
43
+ text: `Unknown --lang value: ${value ?? "(missing)"} (expected js or ts)`,
44
+ type: "error",
45
+ });
46
+ usage(1);
47
+ }
48
+ lang = value;
49
+ } else if (arg === "--to") {
50
+ to = argv[++i];
51
+ if (to === undefined) {
52
+ log({ text: "--to requires a migration file name", type: "error" });
53
+ usage(1);
54
+ }
55
+ } else if (arg === "--lock-timeout") {
56
+ const value = argv[++i];
57
+ const parsed = Number(value);
58
+ if (value === undefined || !Number.isInteger(parsed) || parsed < 0) {
59
+ log({
60
+ text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
61
+ type: "error",
62
+ });
63
+ usage(1);
64
+ }
65
+ lockTimeout = parsed;
66
+ } else if (arg === "--all") {
67
+ all = true;
68
+ } else if (arg === "--strict") {
69
+ strict = true;
28
70
  } else if (arg === "--help" || arg === "-h") {
29
71
  help = true;
30
72
  } else {
31
73
  positional.push(arg);
32
74
  }
33
75
  }
34
- return { command: positional.shift(), positional, dir, git, help };
76
+ return {
77
+ command: positional.shift(),
78
+ positional,
79
+ dir,
80
+ git,
81
+ lang,
82
+ to,
83
+ lockTimeout,
84
+ all,
85
+ strict,
86
+ help,
87
+ };
35
88
  }
36
89
 
37
90
  function usage(exitCode: number): never {
38
91
  log({
39
- text: "Usage: bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]",
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]",
40
93
  type: "info",
41
94
  });
42
95
  process.exit(exitCode);
@@ -52,14 +105,40 @@ if (args.help) {
52
105
  try {
53
106
  switch (args.command) {
54
107
  case "up": {
55
- const { applied } = await migrateUp(listDirOptions);
108
+ const { applied } = await migrateUp({
109
+ ...listDirOptions,
110
+ ...(args.to ? { to: args.to } : {}),
111
+ ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
112
+ });
56
113
  if (applied.length > 0) {
57
114
  log({ text: `Applied ${applied.length} migration(s).`, type: "success" });
58
115
  }
59
116
  break;
60
117
  }
61
118
  case "down": {
62
- await migrateDown(listDirOptions);
119
+ const [stepsArg] = args.positional;
120
+ if (args.all && stepsArg !== undefined) {
121
+ log({ text: "Use either --all or a number of steps, not both.", type: "error" });
122
+ usage(1);
123
+ }
124
+ let steps: number | "all" = 1;
125
+ if (args.all) {
126
+ steps = "all";
127
+ } else if (stepsArg !== undefined) {
128
+ const parsed = Number(stepsArg);
129
+ if (!Number.isInteger(parsed) || parsed < 1) {
130
+ log({
131
+ text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
132
+ type: "error",
133
+ });
134
+ usage(1);
135
+ }
136
+ steps = parsed;
137
+ }
138
+ const { reverted } = await migrateDown({ ...listDirOptions, steps });
139
+ if (reverted.length > 0) {
140
+ log({ text: `Reverted ${reverted.length} migration(s).`, type: "success" });
141
+ }
63
142
  break;
64
143
  }
65
144
  case "install": {
@@ -70,16 +149,36 @@ try {
70
149
  const [name] = args.positional;
71
150
  await createMigrationCommand({
72
151
  ...(name ? { name } : {}),
152
+ ...(args.lang ? { lang: args.lang } : {}),
73
153
  git: args.git,
74
154
  ...listDirOptions,
75
155
  });
76
156
  break;
77
157
  }
158
+ case "status": {
159
+ const { applied, pending } = await migrateStatus(listDirOptions);
160
+ for (const entry of applied) {
161
+ log({ text: `${entry.name} applied`, type: "info" });
162
+ }
163
+ for (const file of pending) {
164
+ log({ text: `${file} pending`, type: "warn" });
165
+ }
166
+ log({ text: `${applied.length} applied, ${pending.length} pending`, type: "info" });
167
+ if (args.strict && pending.length > 0) {
168
+ log({ text: `Strict mode: ${pending.length} pending migration(s).`, type: "warn" });
169
+ process.exit(1);
170
+ }
171
+ break;
172
+ }
78
173
  default:
79
174
  usage(1);
80
175
  }
81
176
  } catch (error) {
82
- if (error instanceof ChecksumDriftError) {
177
+ if (
178
+ error instanceof ChecksumDriftError ||
179
+ error instanceof MigrationNotFoundError ||
180
+ error instanceof MigrationLockError
181
+ ) {
83
182
  log({ text: error.message, type: "error" });
84
183
  } else {
85
184
  log({ text: "Migration command failed", type: "error", error });
@@ -2,7 +2,7 @@ const colors = {
2
2
  success: "\x1b[32m%s\x1b[0m",
3
3
  warn: "\x1b[33m%s\x1b[0m",
4
4
  error: "\x1b[31m%s\x1b[0m",
5
- info: "",
5
+ info: "%s",
6
6
  } as const;
7
7
 
8
8
  type LogLevel = keyof typeof colors;
@@ -1,3 +1,5 @@
1
+ import { type SQL } from "bun";
2
+
1
3
  export interface ExecutedMigration {
2
4
  name: string;
3
5
  checksum: string | null;
@@ -9,6 +11,9 @@ export interface MigrationDriver {
9
11
  record(migration: string, checksum: string): Promise<void>;
10
12
  setChecksum(migration: string, checksum: string): Promise<void>;
11
13
  remove(migration: string): Promise<void>;
14
+ transaction<T>(run: (tx: SQL) => Promise<T>): Promise<T>;
15
+ tryLock?(timeoutSeconds: number): Promise<boolean>;
16
+ releaseLock?(): Promise<void>;
12
17
  close(): Promise<void>;
13
18
  }
14
19
 
package/src/core/fs.ts CHANGED
@@ -3,11 +3,13 @@ import path from "node:path";
3
3
 
4
4
  export const DEFAULT_MIGRATIONS_DIR = "migrations";
5
5
 
6
- export async function listFiles(dir: string, ext: string): Promise<string[]> {
6
+ export const MIGRATION_EXTENSIONS = ["js", "ts"] as const;
7
+
8
+ export async function listFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
7
9
  const matchedFiles: string[] = [];
8
10
  const entries = await readdir(dir, { withFileTypes: true });
9
11
  for (const entry of entries) {
10
- if (entry.isFile() && entry.name.endsWith(`.${ext}`)) {
12
+ if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(`.${ext}`))) {
11
13
  matchedFiles.push(entry.name);
12
14
  }
13
15
  }
@@ -1,54 +1,20 @@
1
1
  const adjectives = [
2
2
  "brave",
3
3
  "calm",
4
- "dusty",
5
4
  "eager",
6
- "fair",
7
5
  "golden",
8
- "hasty",
9
6
  "jolly",
10
7
  "keen",
11
8
  "lucky",
12
9
  "merry",
13
10
  "noble",
14
- "proud",
15
11
  "quick",
16
12
  "sharp",
17
13
  "swift",
18
- "tall",
19
14
  "vivid",
20
15
  "warm",
21
- "young",
22
16
  "bold",
23
17
  "crisp",
24
- "dry",
25
- "fine",
26
- "glad",
27
- "kind",
28
- "light",
29
- "mild",
30
- "neat",
31
- "prime",
32
- "rare",
33
- "safe",
34
- "true",
35
- "vast",
36
- "wise",
37
- "apt",
38
- "bright",
39
- "deep",
40
- "free",
41
- "grand",
42
- "honest",
43
- "just",
44
- "lean",
45
- "open",
46
- "plain",
47
- "still",
48
- "wild",
49
- "cool",
50
- "soft",
51
- "dark",
52
18
  ];
53
19
 
54
20
  const nouns = [
@@ -61,47 +27,13 @@ const nouns = [
61
27
  "breeze",
62
28
  "creek",
63
29
  "ember",
64
- "glacier",
65
- "harbor",
66
- "island",
67
- "jasper",
68
- "kettle",
69
30
  "lantern",
70
31
  "meadow",
71
- "nectar",
72
- "orchid",
73
- "prism",
74
32
  "quartz",
75
33
  "ridge",
76
34
  "tide",
77
35
  "valley",
78
36
  "willow",
79
- "zephyr",
80
- "bolt",
81
- "cliff",
82
- "dawn",
83
- "fern",
84
- "grove",
85
- "haze",
86
- "iris",
87
- "jet",
88
- "kite",
89
- "lark",
90
- "marsh",
91
- "nest",
92
- "opal",
93
- "peak",
94
- "reed",
95
- "sage",
96
- "thorn",
97
- "vine",
98
- "wolf",
99
- "ash",
100
- "bay",
101
- "cape",
102
- "dune",
103
- "flint",
104
- "gleam",
105
37
  ];
106
38
 
107
39
  export function randomName(): string {
@@ -1,45 +1,59 @@
1
- import { SQL } from "bun";
2
- import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
1
+ import { type SQL } from "bun";
2
+ import type { MigrationDriver } from "../core/driver.js";
3
+ import { createReservedLock, createSqlDriver } from "./shared.js";
3
4
 
4
- export function create(databaseUrl: string): MigrationDriver {
5
- const db = new SQL(databaseUrl);
5
+ const LOCK_NAME_PREFIX = "bunsql-native-migrate:";
6
+
7
+ async function checksumColumnExists(db: SQL): Promise<boolean> {
8
+ const rows = await db`SELECT column_name FROM information_schema.columns
9
+ WHERE table_schema = DATABASE()
10
+ AND table_name = 'migrations'
11
+ AND column_name = 'checksum'`;
12
+ return rows.length > 0;
13
+ }
14
+
15
+ async function uniqueIndexExists(db: SQL): Promise<boolean> {
16
+ const rows = await db`SELECT index_name FROM information_schema.statistics
17
+ WHERE table_schema = DATABASE()
18
+ AND table_name = 'migrations'
19
+ AND index_name = 'migrations_migration_unique'`;
20
+ return rows.length > 0;
21
+ }
6
22
 
7
- return {
8
- async install() {
23
+ export function create(databaseUrl: string): MigrationDriver {
24
+ return createSqlDriver(databaseUrl, {
25
+ async install(db) {
9
26
  await db`CREATE TABLE IF NOT EXISTS migrations (
10
27
  id INTEGER PRIMARY KEY AUTO_INCREMENT,
11
28
  migration VARCHAR(255) NOT NULL,
12
- checksum VARCHAR(64)
29
+ checksum VARCHAR(64),
30
+ CONSTRAINT migrations_migration_unique UNIQUE (migration)
13
31
  )`;
14
- await db`ALTER TABLE migrations ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
15
- await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
16
- },
17
-
18
- async listExecuted() {
19
- const rows = await db`SELECT migration, checksum FROM migrations ORDER BY id ASC`;
20
- return rows.map(
21
- (r: { migration: string; checksum: string | null }): ExecutedMigration => ({
22
- name: r.migration,
23
- checksum: r.checksum ?? null,
24
- }),
25
- );
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
+ }
26
38
  },
27
-
28
- async record(migration: string, checksum: string) {
39
+ async record(db, migration, checksum) {
29
40
  await db`INSERT IGNORE INTO migrations (migration, checksum)
30
41
  VALUES (${migration}, ${checksum})`;
31
42
  },
32
-
33
- async setChecksum(migration: string, checksum: string) {
34
- await db`UPDATE migrations SET checksum = ${checksum} WHERE migration = ${migration}`;
35
- },
36
-
37
- async remove(migration: string) {
38
- await db`DELETE FROM migrations WHERE migration = ${migration}`;
39
- },
40
-
41
- async close() {
42
- db.close({ timeout: 0 });
43
- },
44
- };
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
+ });
45
59
  }
@@ -1,11 +1,18 @@
1
- import { SQL } from "bun";
2
- import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
1
+ import { type SQL } from "bun";
2
+ import type { MigrationDriver } from "../core/driver.js";
3
+ import { createReservedLock, createSqlDriver } from "./shared.js";
3
4
 
4
- export function create(databaseUrl: string): MigrationDriver {
5
- const db = new SQL(databaseUrl);
5
+ const LOCK_SCOPE = "bunsql-native-migrate:up";
6
+
7
+ async function advisoryKeyComponents(lock: SQL): Promise<[number, number]> {
8
+ const rows = (await lock`SELECT current_database() AS name`) as Array<{ name: string }>;
9
+ const hash = Bun.hash.wyhash(`${LOCK_SCOPE}:${rows[0]?.name ?? ""}`);
10
+ return [Number((hash >> 32n) & 0x7fffffffn), Number(hash & 0x7fffffffn)];
11
+ }
6
12
 
7
- return {
8
- async install() {
13
+ export function create(databaseUrl: string): MigrationDriver {
14
+ return createSqlDriver(databaseUrl, {
15
+ async install(db) {
9
16
  await db`CREATE TABLE IF NOT EXISTS migrations (
10
17
  id SERIAL PRIMARY KEY,
11
18
  migration VARCHAR(255) NOT NULL,
@@ -14,33 +21,26 @@ export function create(databaseUrl: string): MigrationDriver {
14
21
  await db`ALTER TABLE migrations ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
15
22
  await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
16
23
  },
17
-
18
- async listExecuted() {
19
- const rows = await db`SELECT migration, checksum FROM migrations ORDER BY id ASC`;
20
- return rows.map(
21
- (r: { migration: string; checksum: string | null }): ExecutedMigration => ({
22
- name: r.migration,
23
- checksum: r.checksum ?? null,
24
- }),
25
- );
26
- },
27
-
28
- async record(migration: string, checksum: string) {
24
+ async record(db, migration, checksum) {
29
25
  await db`INSERT INTO migrations (migration, checksum)
30
26
  VALUES (${migration}, ${checksum})
31
27
  ON CONFLICT (migration) DO NOTHING`;
32
28
  },
33
-
34
- async setChecksum(migration: string, checksum: string) {
35
- await db`UPDATE migrations SET checksum = ${checksum} WHERE migration = ${migration}`;
36
- },
37
-
38
- async remove(migration: string) {
39
- await db`DELETE FROM migrations WHERE migration = ${migration}`;
40
- },
41
-
42
- async close() {
43
- db.close({ timeout: 0 });
44
- },
45
- };
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
+ });
46
46
  }