bunsql-native-migrate 0.3.2 → 0.4.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/cli/main.ts CHANGED
@@ -1,49 +1,176 @@
1
1
  #!/usr/bin/env bun
2
+ import path from "node:path";
2
3
  import { migrateUp } from "../api/up.js";
3
4
  import { migrateDown, parseSteps } from "../api/down.js";
5
+ import { migrateRedo } from "../api/redo.js";
4
6
  import { migrateStatus } from "../api/status.js";
5
7
  import { installMigrations } from "../api/install.js";
6
8
  import { createMigrationCommand, type MigrationLang } from "../api/create.js";
7
9
  import { initMigrations } from "../api/init.js";
8
10
  import { markMigrationsApplied } from "../api/mark.js";
9
- import { resolveLockTimeout } from "../api/lock.js";
10
- import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
11
+ import { resolveSecondsOption } from "../core/duration.js";
12
+ import { loadProjectConfig, InvalidConfigError } from "../core/config.js";
13
+ import {
14
+ ChecksumDriftError,
15
+ DatabaseWaitTimeoutError,
16
+ InvalidMigrationNameError,
17
+ MigrationFileMissingError,
18
+ MigrationLockError,
19
+ MigrationNotFoundError,
20
+ } from "../api/options.js";
11
21
  import { InvalidIdentifierError } from "../core/identifiers.js";
12
22
  import { log } from "../core/console.js";
23
+ import { EXIT_PENDING, EXIT_SUCCESS, EXIT_USAGE, exitCodeForError } from "./exit-codes.js";
13
24
 
14
25
  interface CliArgs {
15
26
  command: string | undefined;
16
27
  positional: string[];
28
+ url?: string | undefined;
17
29
  dir?: string | undefined;
30
+ config?: string | undefined;
18
31
  git: boolean;
19
32
  lang?: MigrationLang | undefined;
20
33
  to?: string | undefined;
21
34
  lockTimeout?: number | undefined;
35
+ wait?: number | undefined;
22
36
  table?: string | undefined;
23
37
  schema?: string | undefined;
24
38
  dryRun: boolean;
25
39
  all: boolean;
26
40
  strict: boolean;
27
41
  help: boolean;
42
+ version: boolean;
43
+ }
44
+
45
+ type FlagKey = Exclude<keyof CliArgs, "command" | "positional" | "help" | "version" | "config">;
46
+
47
+ const FLAG_NAMES: Record<FlagKey, string> = {
48
+ url: "--url",
49
+ dir: "--dir",
50
+ git: "--git",
51
+ lang: "--lang",
52
+ to: "--to",
53
+ lockTimeout: "--lock-timeout",
54
+ wait: "--wait",
55
+ table: "--table",
56
+ schema: "--schema",
57
+ dryRun: "--dry-run",
58
+ all: "--all",
59
+ strict: "--strict",
60
+ };
61
+
62
+ interface CommandSpec {
63
+ flags: ReadonlySet<FlagKey>;
64
+ positionalLimit: number;
65
+ }
66
+
67
+ const COMMAND_SPECS: Record<string, CommandSpec> = {
68
+ version: { flags: new Set<FlagKey>([]), positionalLimit: 0 },
69
+ up: {
70
+ flags: new Set(["url", "dir", "to", "lockTimeout", "wait", "table", "schema", "dryRun"]),
71
+ positionalLimit: 0,
72
+ },
73
+ down: {
74
+ flags: new Set(["url", "dir", "to", "wait", "table", "schema", "dryRun", "all"]),
75
+ positionalLimit: 1,
76
+ },
77
+ redo: {
78
+ flags: new Set(["url", "dir", "to", "lockTimeout", "wait", "table", "schema"]),
79
+ positionalLimit: 1,
80
+ },
81
+ init: { flags: new Set(["dir", "lang"]), positionalLimit: 0 },
82
+ install: { flags: new Set(["url", "wait", "table", "schema"]), positionalLimit: 0 },
83
+ create: { flags: new Set(["dir", "lang", "git"]), positionalLimit: 1 },
84
+ mark: { flags: new Set(["url", "dir", "wait", "table", "schema", "all"]), positionalLimit: 1 },
85
+ status: {
86
+ flags: new Set(["url", "dir", "wait", "table", "schema", "strict"]),
87
+ positionalLimit: 0,
88
+ },
89
+ };
90
+
91
+ function rejectDisallowedFlags(command: string, args: CliArgs): void {
92
+ const spec = COMMAND_SPECS[command];
93
+ if (spec === undefined) {
94
+ return;
95
+ }
96
+ const [extra] = args.positional.slice(spec.positionalLimit);
97
+ if (extra !== undefined) {
98
+ log({ text: `Unexpected argument for ${command}: ${extra}`, type: "error" });
99
+ usage(EXIT_USAGE);
100
+ }
101
+ for (const flag of Object.keys(FLAG_NAMES) as FlagKey[]) {
102
+ const value = args[flag];
103
+ if (value === undefined || value === false || spec.flags.has(flag)) {
104
+ continue;
105
+ }
106
+ log({ text: `${command} does not support ${FLAG_NAMES[flag]}.`, type: "error" });
107
+ usage(EXIT_USAGE);
108
+ }
109
+ }
110
+
111
+ function parseSecondsValue(flag: string, value: string | undefined): number {
112
+ try {
113
+ return resolveSecondsOption(flag, Number(value), 0);
114
+ } catch {
115
+ log({
116
+ text: `Invalid ${flag}: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
117
+ type: "error",
118
+ });
119
+ usage(EXIT_USAGE);
120
+ }
121
+ }
122
+
123
+ function parseStepsArg(stepsArg: string): number {
124
+ try {
125
+ return parseSteps(Number(stepsArg));
126
+ } catch {
127
+ log({
128
+ text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
129
+ type: "error",
130
+ });
131
+ usage(EXIT_USAGE);
132
+ }
133
+ }
134
+
135
+ function requireFlagValue(
136
+ argv: string[],
137
+ index: number,
138
+ flag: string,
139
+ description: string,
140
+ ): string {
141
+ const value = argv[index];
142
+ if (value !== undefined && value !== "" && !value.startsWith("-")) {
143
+ return value;
144
+ }
145
+ log({ text: `${flag} requires ${description}`, type: "error" });
146
+ usage(EXIT_USAGE);
28
147
  }
29
148
 
30
149
  function parseArgs(argv: string[]): CliArgs {
31
150
  const positional: string[] = [];
151
+ let url: string | undefined;
32
152
  let dir: string | undefined;
153
+ let config: string | undefined;
33
154
  let git = false;
34
155
  let lang: MigrationLang | undefined;
35
156
  let to: string | undefined;
36
157
  let lockTimeout: number | undefined;
158
+ let wait: number | undefined;
37
159
  let table: string | undefined;
38
160
  let schema: string | undefined;
39
161
  let dryRun = false;
40
162
  let all = false;
41
163
  let strict = false;
42
164
  let help = false;
165
+ let version = false;
43
166
  for (let i = 0; i < argv.length; i++) {
44
167
  const arg = argv[i]!;
45
- if (arg === "--dir") {
46
- dir = argv[++i];
168
+ if (arg === "--url") {
169
+ url = requireFlagValue(argv, ++i, "--url", "a database URL");
170
+ } else if (arg === "--dir") {
171
+ dir = requireFlagValue(argv, ++i, "--dir", "a migrations directory path");
172
+ } else if (arg === "--config") {
173
+ config = requireFlagValue(argv, ++i, "--config", "a config file path");
47
174
  } else if (arg === "--git") {
48
175
  git = true;
49
176
  } else if (arg === "--lang") {
@@ -53,38 +180,19 @@ function parseArgs(argv: string[]): CliArgs {
53
180
  text: `Unknown --lang value: ${value ?? "(missing)"} (expected js or ts)`,
54
181
  type: "error",
55
182
  });
56
- usage(1);
183
+ usage(EXIT_USAGE);
57
184
  }
58
185
  lang = value;
59
186
  } else if (arg === "--to") {
60
- to = argv[++i];
61
- if (to === undefined) {
62
- log({ text: "--to requires a migration file name", type: "error" });
63
- usage(1);
64
- }
187
+ to = requireFlagValue(argv, ++i, "--to", "a migration file name");
65
188
  } else if (arg === "--lock-timeout") {
66
- const value = argv[++i];
67
- try {
68
- lockTimeout = resolveLockTimeout(Number(value));
69
- } catch {
70
- log({
71
- text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
72
- type: "error",
73
- });
74
- usage(1);
75
- }
189
+ lockTimeout = parseSecondsValue("--lock-timeout", argv[++i]);
190
+ } else if (arg === "--wait") {
191
+ wait = parseSecondsValue("--wait", argv[++i]);
76
192
  } else if (arg === "--table") {
77
- table = argv[++i];
78
- if (table === undefined) {
79
- log({ text: "--table requires a tracking table name", type: "error" });
80
- usage(1);
81
- }
193
+ table = requireFlagValue(argv, ++i, "--table", "a tracking table name");
82
194
  } else if (arg === "--schema") {
83
- schema = argv[++i];
84
- if (schema === undefined) {
85
- log({ text: "--schema requires a postgres schema name", type: "error" });
86
- usage(1);
87
- }
195
+ schema = requireFlagValue(argv, ++i, "--schema", "a postgres schema name");
88
196
  } else if (arg === "--all") {
89
197
  all = true;
90
198
  } else if (arg === "--dry-run") {
@@ -93,54 +201,100 @@ function parseArgs(argv: string[]): CliArgs {
93
201
  strict = true;
94
202
  } else if (arg === "--help" || arg === "-h") {
95
203
  help = true;
204
+ } else if (arg === "--version") {
205
+ version = true;
96
206
  } else {
207
+ if (arg === "") {
208
+ log({ text: "Empty argument is not allowed", type: "error" });
209
+ usage(EXIT_USAGE);
210
+ }
97
211
  positional.push(arg);
98
212
  }
99
213
  }
100
214
  return {
101
215
  command: positional.shift(),
102
216
  positional,
217
+ url,
103
218
  dir,
219
+ config,
104
220
  git,
105
221
  lang,
106
222
  to,
107
223
  lockTimeout,
224
+ wait,
108
225
  table,
109
226
  schema,
110
227
  dryRun,
111
228
  all,
112
229
  strict,
113
230
  help,
231
+ version,
114
232
  };
115
233
  }
116
234
 
117
235
  function usage(exitCode: number): never {
118
236
  log({
119
- 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]",
237
+ text: "Usage: bunsql-native-migrate <init|up|down [n]|redo [n]|install|create [name]|mark [name]|status|version> [--url <url>] [--dir <migrations-dir>] [--config <path>] [--to <name>] [--lock-timeout <seconds>] [--wait <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--version] [--help]",
120
238
  type: "info",
121
239
  });
122
240
  process.exit(exitCode);
123
241
  }
124
242
 
243
+ async function printVersion(): Promise<void> {
244
+ const manifest = await Bun.file(path.resolve(import.meta.dir, "..", "..", "package.json")).json();
245
+ log({ text: String(manifest.version), type: "info" });
246
+ }
247
+
125
248
  const args = parseArgs(process.argv.slice(2));
126
- const listDirOptions = args.dir ? { listDir: args.dir } : {};
127
- const tableOptions = {
128
- ...(args.table !== undefined ? { tableName: args.table } : {}),
129
- ...(args.schema !== undefined ? { schema: args.schema } : {}),
130
- };
131
249
 
132
250
  if (args.help) {
133
- usage(0);
251
+ usage(EXIT_SUCCESS);
252
+ }
253
+
254
+ if (args.version) {
255
+ await printVersion();
256
+ process.exit(EXIT_SUCCESS);
257
+ }
258
+
259
+ if (args.command !== undefined) {
260
+ rejectDisallowedFlags(args.command, args);
134
261
  }
135
262
 
136
263
  try {
264
+ const config = args.command === "version" ? {} : await loadProjectConfig(args.config);
265
+
266
+ const databaseUrl = args.url ?? config.databaseUrl;
267
+ const listDir = args.dir ?? config.listDir;
268
+ const tableName = args.table ?? config.tableName;
269
+ const schema = args.schema ?? config.schema;
270
+ const lang = args.lang ?? config.lang;
271
+ const lockTimeout = args.lockTimeout ?? config.lockTimeout;
272
+ const waitTimeout = args.wait ?? config.waitTimeout;
273
+
274
+ const urlOptions = databaseUrl !== undefined ? { databaseUrl } : {};
275
+ const waitOptions = waitTimeout !== undefined && waitTimeout > 0 ? { waitTimeout } : {};
276
+ const listDirOptions = listDir !== undefined ? { listDir } : {};
277
+ const tableOptions = {
278
+ ...(tableName !== undefined ? { tableName } : {}),
279
+ ...(schema !== undefined ? { schema } : {}),
280
+ };
281
+ const connectOptions = {
282
+ ...urlOptions,
283
+ ...listDirOptions,
284
+ ...tableOptions,
285
+ ...waitOptions,
286
+ };
287
+
137
288
  switch (args.command) {
289
+ case "version": {
290
+ await printVersion();
291
+ break;
292
+ }
138
293
  case "up": {
139
294
  const { applied, planned } = await migrateUp({
140
- ...listDirOptions,
141
- ...tableOptions,
142
- ...(args.to ? { to: args.to } : {}),
143
- ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
295
+ ...connectOptions,
296
+ ...(args.to !== undefined ? { to: args.to } : {}),
297
+ ...(lockTimeout !== undefined ? { lockTimeout } : {}),
144
298
  ...(args.dryRun ? { dryRun: true } : {}),
145
299
  });
146
300
  if (planned !== undefined && planned.length > 0) {
@@ -155,26 +309,24 @@ try {
155
309
  const [stepsArg] = args.positional;
156
310
  if (args.all && stepsArg !== undefined) {
157
311
  log({ text: "Use either --all or a number of steps, not both.", type: "error" });
158
- usage(1);
312
+ usage(EXIT_USAGE);
313
+ }
314
+ if (args.to !== undefined && (args.all || stepsArg !== undefined)) {
315
+ log({
316
+ text: "Use either --to, --all, or a number of steps, not more than one of them.",
317
+ type: "error",
318
+ });
319
+ usage(EXIT_USAGE);
159
320
  }
160
321
  let steps: number | "all" = 1;
161
322
  if (args.all) {
162
323
  steps = "all";
163
324
  } else if (stepsArg !== undefined) {
164
- try {
165
- steps = parseSteps(Number(stepsArg));
166
- } catch {
167
- log({
168
- text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
169
- type: "error",
170
- });
171
- usage(1);
172
- }
325
+ steps = parseStepsArg(stepsArg);
173
326
  }
174
327
  const { reverted, planned } = await migrateDown({
175
- ...listDirOptions,
176
- ...tableOptions,
177
- steps,
328
+ ...connectOptions,
329
+ ...(args.to !== undefined ? { to: args.to } : { steps }),
178
330
  ...(args.dryRun ? { dryRun: true } : {}),
179
331
  });
180
332
  if (planned !== undefined && planned.length > 0) {
@@ -185,22 +337,43 @@ try {
185
337
  }
186
338
  break;
187
339
  }
340
+ case "redo": {
341
+ const [stepsArg] = args.positional;
342
+ if (stepsArg !== undefined && args.to !== undefined) {
343
+ log({ text: "Use either --to or a step count, not both.", type: "error" });
344
+ usage(EXIT_USAGE);
345
+ }
346
+ let steps: number | undefined;
347
+ if (stepsArg !== undefined) {
348
+ steps = parseStepsArg(stepsArg);
349
+ }
350
+ const { reverted } = await migrateRedo({
351
+ ...connectOptions,
352
+ ...(steps !== undefined ? { steps } : {}),
353
+ ...(args.to !== undefined ? { to: args.to } : {}),
354
+ ...(lockTimeout !== undefined ? { lockTimeout } : {}),
355
+ });
356
+ if (reverted.length > 0) {
357
+ log({ text: `Redid ${reverted.length} migration(s).`, type: "success" });
358
+ }
359
+ break;
360
+ }
188
361
  case "init": {
189
362
  await initMigrations({
190
363
  ...listDirOptions,
191
- ...(args.lang !== undefined ? { lang: args.lang } : {}),
364
+ ...(lang !== undefined ? { lang } : {}),
192
365
  });
193
366
  break;
194
367
  }
195
368
  case "install": {
196
- await installMigrations({ ...listDirOptions, ...tableOptions });
369
+ await installMigrations(connectOptions);
197
370
  break;
198
371
  }
199
372
  case "create": {
200
373
  const [name] = args.positional;
201
374
  await createMigrationCommand({
202
- ...(name ? { name } : {}),
203
- ...(args.lang ? { lang: args.lang } : {}),
375
+ ...(name !== undefined ? { name } : {}),
376
+ ...(lang !== undefined ? { lang } : {}),
204
377
  git: args.git,
205
378
  ...listDirOptions,
206
379
  });
@@ -210,15 +383,14 @@ try {
210
383
  const [name] = args.positional;
211
384
  if (args.all && name !== undefined) {
212
385
  log({ text: "Use either --all or a migration file name, not both.", type: "error" });
213
- usage(1);
386
+ usage(EXIT_USAGE);
214
387
  }
215
388
  if (!args.all && name === undefined) {
216
389
  log({ text: "mark requires a migration file name or --all.", type: "error" });
217
- usage(1);
390
+ usage(EXIT_USAGE);
218
391
  }
219
392
  const { marked } = await markMigrationsApplied({
220
- ...listDirOptions,
221
- ...tableOptions,
393
+ ...connectOptions,
222
394
  ...(name !== undefined ? { to: name } : {}),
223
395
  });
224
396
  if (marked.length > 0) {
@@ -227,7 +399,7 @@ try {
227
399
  break;
228
400
  }
229
401
  case "status": {
230
- const { applied, pending } = await migrateStatus({ ...listDirOptions, ...tableOptions });
402
+ const { applied, pending } = await migrateStatus(connectOptions);
231
403
  for (const entry of applied) {
232
404
  log({ text: `${entry.name} applied`, type: "info" });
233
405
  }
@@ -237,23 +409,27 @@ try {
237
409
  log({ text: `${applied.length} applied, ${pending.length} pending`, type: "info" });
238
410
  if (args.strict && pending.length > 0) {
239
411
  log({ text: `Strict mode: ${pending.length} pending migration(s).`, type: "warn" });
240
- process.exit(1);
412
+ process.exit(EXIT_PENDING);
241
413
  }
242
414
  break;
243
415
  }
244
416
  default:
245
- usage(1);
417
+ usage(EXIT_USAGE);
246
418
  }
247
419
  } catch (error) {
248
420
  if (
249
421
  error instanceof ChecksumDriftError ||
250
422
  error instanceof MigrationNotFoundError ||
423
+ error instanceof MigrationFileMissingError ||
251
424
  error instanceof MigrationLockError ||
252
- error instanceof InvalidIdentifierError
425
+ error instanceof DatabaseWaitTimeoutError ||
426
+ error instanceof InvalidIdentifierError ||
427
+ error instanceof InvalidConfigError ||
428
+ error instanceof InvalidMigrationNameError
253
429
  ) {
254
430
  log({ text: error.message, type: "error" });
255
431
  } else {
256
432
  log({ text: "Migration command failed", type: "error", error });
257
433
  }
258
- process.exit(1);
434
+ process.exit(exitCodeForError(error));
259
435
  }
@@ -0,0 +1,131 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { type MigrationLang } from "../api/create.js";
5
+
6
+ export interface ProjectConfig {
7
+ databaseUrl?: string;
8
+ listDir?: string;
9
+ tableName?: string;
10
+ schema?: string;
11
+ lang?: MigrationLang;
12
+ lockTimeout?: number;
13
+ waitTimeout?: number;
14
+ }
15
+
16
+ const CONFIG_FILE_NAMES = ["bunsql-migrate.config.ts", "bunsql-migrate.config.js"];
17
+
18
+ type ConfigKeyType = "string" | "lang" | "seconds";
19
+
20
+ const CONFIG_KEYS: Record<string, ConfigKeyType> = {
21
+ databaseUrl: "string",
22
+ listDir: "string",
23
+ tableName: "string",
24
+ schema: "string",
25
+ lang: "lang",
26
+ lockTimeout: "seconds",
27
+ waitTimeout: "seconds",
28
+ };
29
+
30
+ export class InvalidConfigError extends Error {
31
+ readonly configPath: string;
32
+
33
+ constructor(configPath: string, detail: string) {
34
+ super(`${configPath}: ${detail}`);
35
+ this.name = "InvalidConfigError";
36
+ this.configPath = configPath;
37
+ }
38
+ }
39
+
40
+ function resolveConfigPath(override?: string): string | null {
41
+ if (override !== undefined) {
42
+ const resolved = path.resolve(override);
43
+ if (!existsSync(resolved)) {
44
+ throw new InvalidConfigError(override, "config file not found");
45
+ }
46
+ return resolved;
47
+ }
48
+ for (const name of CONFIG_FILE_NAMES) {
49
+ const candidate = path.resolve(name);
50
+ if (existsSync(candidate)) {
51
+ return candidate;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function errorMessage(error: unknown): string {
58
+ return error instanceof Error ? error.message : String(error);
59
+ }
60
+
61
+ async function importConfig(filePath: string): Promise<Record<string, unknown>> {
62
+ let moduleNamespace: Record<string, unknown>;
63
+ try {
64
+ moduleNamespace = (await import(pathToFileURL(filePath).href)) as Record<string, unknown>;
65
+ } catch (error) {
66
+ throw new InvalidConfigError(
67
+ filePath,
68
+ `could not load the config file — ${errorMessage(error)}`,
69
+ );
70
+ }
71
+ const exported =
72
+ moduleNamespace["default"] !== undefined ? moduleNamespace["default"] : moduleNamespace;
73
+ if (typeof exported !== "object" || exported === null || Array.isArray(exported)) {
74
+ throw new InvalidConfigError(filePath, "must export a config object");
75
+ }
76
+ return exported as Record<string, unknown>;
77
+ }
78
+
79
+ function quoteKeys(keys: string[]): string {
80
+ return keys.map((key) => `"${key}"`).join(", ");
81
+ }
82
+
83
+ function validateConfig(filePath: string, raw: Record<string, unknown>): ProjectConfig {
84
+ const unknownKeys = Object.keys(raw).filter((key) => !(key in CONFIG_KEYS));
85
+ if (unknownKeys.length > 0) {
86
+ const label = unknownKeys.length > 1 ? "unknown config keys" : "unknown config key";
87
+ throw new InvalidConfigError(filePath, `${label} ${quoteKeys(unknownKeys)}`);
88
+ }
89
+
90
+ const problems: string[] = [];
91
+ const config: Record<string, string | number> = {};
92
+ for (const [key, kind] of Object.entries(CONFIG_KEYS)) {
93
+ const value = raw[key];
94
+ if (value === undefined) {
95
+ continue;
96
+ }
97
+ if (kind === "string") {
98
+ if (typeof value === "string") {
99
+ config[key] = value;
100
+ } else {
101
+ problems.push(`expected a string for "${key}"`);
102
+ }
103
+ continue;
104
+ }
105
+ if (kind === "lang") {
106
+ if (value === "js" || value === "ts") {
107
+ config[key] = value;
108
+ } else {
109
+ problems.push(`expected "js" or "ts" for "${key}"`);
110
+ }
111
+ continue;
112
+ }
113
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
114
+ config[key] = value;
115
+ } else {
116
+ problems.push(`expected a non-negative integer for "${key}"`);
117
+ }
118
+ }
119
+ if (problems.length > 0) {
120
+ throw new InvalidConfigError(filePath, problems.join("; "));
121
+ }
122
+ return config as ProjectConfig;
123
+ }
124
+
125
+ export async function loadProjectConfig(configPath?: string): Promise<ProjectConfig> {
126
+ const filePath = resolveConfigPath(configPath);
127
+ if (filePath === null) {
128
+ return {};
129
+ }
130
+ return validateConfig(filePath, await importConfig(filePath));
131
+ }
@@ -15,8 +15,11 @@ interface LogOptions {
15
15
 
16
16
  function formatError(error: unknown): void {
17
17
  if (error instanceof Error) {
18
+ if (error.stack) {
19
+ console.log(error.stack);
20
+ return;
21
+ }
18
22
  console.log(error.message);
19
- if (error.stack) console.log(error.stack);
20
23
  return;
21
24
  }
22
25
  if (typeof error !== "object" || error === null) {
@@ -17,6 +17,7 @@ export interface MigrationDriver {
17
17
  tryLock?(timeoutSeconds: number): Promise<boolean>;
18
18
  releaseLock?(): Promise<void>;
19
19
  close(): Promise<void>;
20
+ client?(): SQL;
20
21
  }
21
22
 
22
23
  export interface DriverTableOptions {
@@ -1,3 +1,19 @@
1
+ export function resolveSecondsOption(
2
+ option: string,
3
+ value: number | undefined,
4
+ fallback: number,
5
+ ): number {
6
+ if (value === undefined) {
7
+ return fallback;
8
+ }
9
+ if (!Number.isInteger(value) || value < 0) {
10
+ throw new Error(
11
+ `Invalid ${option}: ${String(value)} — expected a non-negative integer of seconds`,
12
+ );
13
+ }
14
+ return value;
15
+ }
16
+
1
17
  export function formatDuration(durationMs: number): string {
2
18
  const roundedMs = Math.round(durationMs);
3
19
  if (roundedMs < 1000) {
package/src/core/env.ts CHANGED
@@ -5,3 +5,7 @@ export function getDatabaseUrl(override?: string): string {
5
5
  }
6
6
  return url;
7
7
  }
8
+
9
+ export function getMigrationListDir(): string | undefined {
10
+ return process.env["MIGRATION_LIST_DIR"];
11
+ }