bunsql-native-migrate 0.4.0 → 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.
@@ -1,4 +1,4 @@
1
- import { listFiles, MIGRATION_EXTENSIONS } from "../core/fs.js";
1
+ import { listMigrationFiles } from "../core/fs.js";
2
2
  import { log } from "../core/console.js";
3
3
  import { MigrationNotFoundError } from "./options.js";
4
4
 
@@ -29,7 +29,7 @@ export async function assertTargetOptions(
29
29
  throw new Error(`Invalid ${command} options: "to" and "steps" cannot be combined`);
30
30
  }
31
31
  if (target !== undefined) {
32
- assertTargetInFiles(await listFiles(listDir, MIGRATION_EXTENSIONS), target);
32
+ assertTargetInFiles(await listMigrationFiles(listDir), target);
33
33
  }
34
34
  }
35
35
 
package/src/api/status.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
1
+ import { listMigrationFiles, resolveListDir } from "../core/fs.js";
2
2
  import type { MigrateOptions, MigrateStatusResult } from "./options.js";
3
3
  import { runWithDriver } from "./run-with-driver.js";
4
4
  import { ensureTrackingTable } from "./tracking-table.js";
@@ -9,7 +9,7 @@ export async function migrateStatus(options: MigrateOptions = {}): Promise<Migra
9
9
  return runWithDriver(options, async (driver) => {
10
10
  await ensureTrackingTable(driver);
11
11
 
12
- const files = await listFiles(listDir, MIGRATION_EXTENSIONS);
12
+ const files = await listMigrationFiles(listDir);
13
13
  const executed = await driver.listExecuted();
14
14
  const appliedNames = new Set(executed.map((entry) => entry.name));
15
15
 
@@ -13,3 +13,14 @@ export async function listExecutedForPlan(driver: MigrationDriver): Promise<Exec
13
13
  }
14
14
  return driver.listExecuted();
15
15
  }
16
+
17
+ export async function loadExecutedHistory(
18
+ driver: MigrationDriver,
19
+ dryRun: boolean,
20
+ ): Promise<ExecutedMigration[]> {
21
+ if (dryRun) {
22
+ return listExecutedForPlan(driver);
23
+ }
24
+ await ensureTrackingTable(driver);
25
+ return driver.listExecuted();
26
+ }
package/src/api/up.ts CHANGED
@@ -1,5 +1,4 @@
1
- import path from "node:path";
2
- import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
1
+ import { checksumFiles, listMigrationFiles, resolveListDir } from "../core/fs.js";
3
2
  import { log } from "../core/console.js";
4
3
  import { formatDuration } from "../core/duration.js";
5
4
  import { type MigrateUpOptions, type MigrateUpResult, ChecksumDriftError } from "./options.js";
@@ -8,7 +7,7 @@ import { runMigrationStep } from "./run-step.js";
8
7
  import { loadMigration } from "./load-migration.js";
9
8
  import { resolvePendingToTarget } from "./pending.js";
10
9
  import { resolveLockTimeout, withMigrationLock } from "./lock.js";
11
- import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
10
+ import { loadExecutedHistory } from "./tracking-table.js";
12
11
 
13
12
  export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
14
13
  const listDir = resolveListDir(options.listDir);
@@ -17,22 +16,10 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
17
16
  const lockTimeout = resolveLockTimeout(options.lockTimeout);
18
17
 
19
18
  return runWithDriver(options, async (driver) => {
20
- if (!dryRun) {
21
- await ensureTrackingTable(driver);
22
- }
23
-
24
19
  const run = async (): Promise<MigrateUpResult> => {
25
- const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
26
-
27
- const checksums = new Map(
28
- await Promise.all(
29
- allFiles.map(
30
- async (file) => [file, await checksumFile(path.join(listDir, file))] as const,
31
- ),
32
- ),
33
- );
34
-
35
- const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
20
+ const allFiles = await listMigrationFiles(listDir);
21
+ const checksums = await checksumFiles(listDir, allFiles);
22
+ const executed = await loadExecutedHistory(driver, dryRun);
36
23
  const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
37
24
 
38
25
  for (const [file, checksum] of checksums) {
@@ -81,7 +68,9 @@ export async function migrateUp(options: MigrateUpOptions = {}): Promise<Migrate
81
68
 
82
69
  for (const file of pending) {
83
70
  const checksum = checksums.get(file);
84
- if (!checksum) continue;
71
+ if (checksum === undefined) {
72
+ throw new Error(`checksum for ${file} was not computed`);
73
+ }
85
74
  try {
86
75
  const { up } = await loadMigration(listDir, file);
87
76
  if (up === null) {
@@ -0,0 +1,31 @@
1
+ import {
2
+ ChecksumDriftError,
3
+ InvalidMigrationNameError,
4
+ MigrationLockError,
5
+ } from "../api/options.js";
6
+ import { InvalidIdentifierError } from "../core/identifiers.js";
7
+ import { InvalidConfigError } from "../core/config.js";
8
+
9
+ export const EXIT_SUCCESS = 0;
10
+ export const EXIT_GENERIC = 1;
11
+ export const EXIT_PENDING = 2;
12
+ export const EXIT_CHECKSUM_DRIFT = 3;
13
+ export const EXIT_LOCK_TIMEOUT = 4;
14
+ export const EXIT_USAGE = 5;
15
+
16
+ export function exitCodeForError(error: unknown): number {
17
+ if (error instanceof ChecksumDriftError) {
18
+ return EXIT_CHECKSUM_DRIFT;
19
+ }
20
+ if (error instanceof MigrationLockError) {
21
+ return EXIT_LOCK_TIMEOUT;
22
+ }
23
+ if (
24
+ error instanceof InvalidIdentifierError ||
25
+ error instanceof InvalidConfigError ||
26
+ error instanceof InvalidMigrationNameError
27
+ ) {
28
+ return EXIT_USAGE;
29
+ }
30
+ return EXIT_GENERIC;
31
+ }
package/src/cli/main.ts CHANGED
@@ -9,20 +9,25 @@ import { createMigrationCommand, type MigrationLang } from "../api/create.js";
9
9
  import { initMigrations } from "../api/init.js";
10
10
  import { markMigrationsApplied } from "../api/mark.js";
11
11
  import { resolveSecondsOption } from "../core/duration.js";
12
+ import { loadProjectConfig, InvalidConfigError } from "../core/config.js";
12
13
  import {
13
14
  ChecksumDriftError,
14
15
  DatabaseWaitTimeoutError,
16
+ InvalidMigrationNameError,
17
+ MigrationFileMissingError,
15
18
  MigrationLockError,
16
19
  MigrationNotFoundError,
17
20
  } from "../api/options.js";
18
21
  import { InvalidIdentifierError } from "../core/identifiers.js";
19
22
  import { log } from "../core/console.js";
23
+ import { EXIT_PENDING, EXIT_SUCCESS, EXIT_USAGE, exitCodeForError } from "./exit-codes.js";
20
24
 
21
25
  interface CliArgs {
22
26
  command: string | undefined;
23
27
  positional: string[];
24
28
  url?: string | undefined;
25
29
  dir?: string | undefined;
30
+ config?: string | undefined;
26
31
  git: boolean;
27
32
  lang?: MigrationLang | undefined;
28
33
  to?: string | undefined;
@@ -37,7 +42,7 @@ interface CliArgs {
37
42
  version: boolean;
38
43
  }
39
44
 
40
- type FlagKey = Exclude<keyof CliArgs, "command" | "positional" | "help" | "version">;
45
+ type FlagKey = Exclude<keyof CliArgs, "command" | "positional" | "help" | "version" | "config">;
41
46
 
42
47
  const FLAG_NAMES: Record<FlagKey, string> = {
43
48
  url: "--url",
@@ -74,7 +79,7 @@ const COMMAND_SPECS: Record<string, CommandSpec> = {
74
79
  positionalLimit: 1,
75
80
  },
76
81
  init: { flags: new Set(["dir", "lang"]), positionalLimit: 0 },
77
- install: { flags: new Set(["url", "dir", "wait", "table", "schema"]), positionalLimit: 0 },
82
+ install: { flags: new Set(["url", "wait", "table", "schema"]), positionalLimit: 0 },
78
83
  create: { flags: new Set(["dir", "lang", "git"]), positionalLimit: 1 },
79
84
  mark: { flags: new Set(["url", "dir", "wait", "table", "schema", "all"]), positionalLimit: 1 },
80
85
  status: {
@@ -91,7 +96,7 @@ function rejectDisallowedFlags(command: string, args: CliArgs): void {
91
96
  const [extra] = args.positional.slice(spec.positionalLimit);
92
97
  if (extra !== undefined) {
93
98
  log({ text: `Unexpected argument for ${command}: ${extra}`, type: "error" });
94
- usage(1);
99
+ usage(EXIT_USAGE);
95
100
  }
96
101
  for (const flag of Object.keys(FLAG_NAMES) as FlagKey[]) {
97
102
  const value = args[flag];
@@ -99,7 +104,7 @@ function rejectDisallowedFlags(command: string, args: CliArgs): void {
99
104
  continue;
100
105
  }
101
106
  log({ text: `${command} does not support ${FLAG_NAMES[flag]}.`, type: "error" });
102
- usage(1);
107
+ usage(EXIT_USAGE);
103
108
  }
104
109
  }
105
110
 
@@ -111,14 +116,41 @@ function parseSecondsValue(flag: string, value: string | undefined): number {
111
116
  text: `Invalid ${flag}: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
112
117
  type: "error",
113
118
  });
114
- usage(1);
119
+ usage(EXIT_USAGE);
115
120
  }
116
121
  }
117
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);
147
+ }
148
+
118
149
  function parseArgs(argv: string[]): CliArgs {
119
150
  const positional: string[] = [];
120
151
  let url: string | undefined;
121
152
  let dir: string | undefined;
153
+ let config: string | undefined;
122
154
  let git = false;
123
155
  let lang: MigrationLang | undefined;
124
156
  let to: string | undefined;
@@ -134,13 +166,11 @@ function parseArgs(argv: string[]): CliArgs {
134
166
  for (let i = 0; i < argv.length; i++) {
135
167
  const arg = argv[i]!;
136
168
  if (arg === "--url") {
137
- url = argv[++i];
138
- if (url === undefined) {
139
- log({ text: "--url requires a database URL", type: "error" });
140
- usage(1);
141
- }
169
+ url = requireFlagValue(argv, ++i, "--url", "a database URL");
142
170
  } else if (arg === "--dir") {
143
- dir = argv[++i];
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");
144
174
  } else if (arg === "--git") {
145
175
  git = true;
146
176
  } else if (arg === "--lang") {
@@ -150,31 +180,19 @@ function parseArgs(argv: string[]): CliArgs {
150
180
  text: `Unknown --lang value: ${value ?? "(missing)"} (expected js or ts)`,
151
181
  type: "error",
152
182
  });
153
- usage(1);
183
+ usage(EXIT_USAGE);
154
184
  }
155
185
  lang = value;
156
186
  } else if (arg === "--to") {
157
- to = argv[++i];
158
- if (to === undefined) {
159
- log({ text: "--to requires a migration file name", type: "error" });
160
- usage(1);
161
- }
187
+ to = requireFlagValue(argv, ++i, "--to", "a migration file name");
162
188
  } else if (arg === "--lock-timeout") {
163
189
  lockTimeout = parseSecondsValue("--lock-timeout", argv[++i]);
164
190
  } else if (arg === "--wait") {
165
191
  wait = parseSecondsValue("--wait", argv[++i]);
166
192
  } else if (arg === "--table") {
167
- table = argv[++i];
168
- if (table === undefined) {
169
- log({ text: "--table requires a tracking table name", type: "error" });
170
- usage(1);
171
- }
193
+ table = requireFlagValue(argv, ++i, "--table", "a tracking table name");
172
194
  } else if (arg === "--schema") {
173
- schema = argv[++i];
174
- if (schema === undefined) {
175
- log({ text: "--schema requires a postgres schema name", type: "error" });
176
- usage(1);
177
- }
195
+ schema = requireFlagValue(argv, ++i, "--schema", "a postgres schema name");
178
196
  } else if (arg === "--all") {
179
197
  all = true;
180
198
  } else if (arg === "--dry-run") {
@@ -186,6 +204,10 @@ function parseArgs(argv: string[]): CliArgs {
186
204
  } else if (arg === "--version") {
187
205
  version = true;
188
206
  } else {
207
+ if (arg === "") {
208
+ log({ text: "Empty argument is not allowed", type: "error" });
209
+ usage(EXIT_USAGE);
210
+ }
189
211
  positional.push(arg);
190
212
  }
191
213
  }
@@ -194,6 +216,7 @@ function parseArgs(argv: string[]): CliArgs {
194
216
  positional,
195
217
  url,
196
218
  dir,
219
+ config,
197
220
  git,
198
221
  lang,
199
222
  to,
@@ -211,7 +234,7 @@ function parseArgs(argv: string[]): CliArgs {
211
234
 
212
235
  function usage(exitCode: number): never {
213
236
  log({
214
- text: "Usage: bunsql-native-migrate <init|up|down [n]|redo [n]|install|create [name]|mark [name]|status|version> [--url <url>] [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--wait <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--version] [--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]",
215
238
  type: "info",
216
239
  });
217
240
  process.exit(exitCode);
@@ -223,33 +246,45 @@ async function printVersion(): Promise<void> {
223
246
  }
224
247
 
225
248
  const args = parseArgs(process.argv.slice(2));
226
- const urlOptions = args.url !== undefined ? { databaseUrl: args.url } : {};
227
- const waitOptions = args.wait !== undefined && args.wait > 0 ? { waitTimeout: args.wait } : {};
228
- const listDirOptions = args.dir ? { listDir: args.dir } : {};
229
- const tableOptions = {
230
- ...(args.table !== undefined ? { tableName: args.table } : {}),
231
- ...(args.schema !== undefined ? { schema: args.schema } : {}),
232
- };
233
- const connectOptions = {
234
- ...urlOptions,
235
- ...listDirOptions,
236
- ...tableOptions,
237
- ...waitOptions,
238
- };
239
249
 
240
250
  if (args.help) {
241
- usage(0);
251
+ usage(EXIT_SUCCESS);
242
252
  }
243
253
 
244
254
  if (args.version) {
245
255
  await printVersion();
246
- process.exit(0);
256
+ process.exit(EXIT_SUCCESS);
257
+ }
258
+
259
+ if (args.command !== undefined) {
260
+ rejectDisallowedFlags(args.command, args);
247
261
  }
248
262
 
249
263
  try {
250
- if (args.command !== undefined) {
251
- rejectDisallowedFlags(args.command, args);
252
- }
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
+
253
288
  switch (args.command) {
254
289
  case "version": {
255
290
  await printVersion();
@@ -258,8 +293,8 @@ try {
258
293
  case "up": {
259
294
  const { applied, planned } = await migrateUp({
260
295
  ...connectOptions,
261
- ...(args.to ? { to: args.to } : {}),
262
- ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
296
+ ...(args.to !== undefined ? { to: args.to } : {}),
297
+ ...(lockTimeout !== undefined ? { lockTimeout } : {}),
263
298
  ...(args.dryRun ? { dryRun: true } : {}),
264
299
  });
265
300
  if (planned !== undefined && planned.length > 0) {
@@ -274,28 +309,20 @@ try {
274
309
  const [stepsArg] = args.positional;
275
310
  if (args.all && stepsArg !== undefined) {
276
311
  log({ text: "Use either --all or a number of steps, not both.", type: "error" });
277
- usage(1);
312
+ usage(EXIT_USAGE);
278
313
  }
279
314
  if (args.to !== undefined && (args.all || stepsArg !== undefined)) {
280
315
  log({
281
316
  text: "Use either --to, --all, or a number of steps, not more than one of them.",
282
317
  type: "error",
283
318
  });
284
- usage(1);
319
+ usage(EXIT_USAGE);
285
320
  }
286
321
  let steps: number | "all" = 1;
287
322
  if (args.all) {
288
323
  steps = "all";
289
324
  } else if (stepsArg !== undefined) {
290
- try {
291
- steps = parseSteps(Number(stepsArg));
292
- } catch {
293
- log({
294
- text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
295
- type: "error",
296
- });
297
- usage(1);
298
- }
325
+ steps = parseStepsArg(stepsArg);
299
326
  }
300
327
  const { reverted, planned } = await migrateDown({
301
328
  ...connectOptions,
@@ -314,25 +341,17 @@ try {
314
341
  const [stepsArg] = args.positional;
315
342
  if (stepsArg !== undefined && args.to !== undefined) {
316
343
  log({ text: "Use either --to or a step count, not both.", type: "error" });
317
- usage(1);
344
+ usage(EXIT_USAGE);
318
345
  }
319
346
  let steps: number | undefined;
320
347
  if (stepsArg !== undefined) {
321
- try {
322
- steps = parseSteps(Number(stepsArg));
323
- } catch {
324
- log({
325
- text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
326
- type: "error",
327
- });
328
- usage(1);
329
- }
348
+ steps = parseStepsArg(stepsArg);
330
349
  }
331
350
  const { reverted } = await migrateRedo({
332
351
  ...connectOptions,
333
352
  ...(steps !== undefined ? { steps } : {}),
334
353
  ...(args.to !== undefined ? { to: args.to } : {}),
335
- ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
354
+ ...(lockTimeout !== undefined ? { lockTimeout } : {}),
336
355
  });
337
356
  if (reverted.length > 0) {
338
357
  log({ text: `Redid ${reverted.length} migration(s).`, type: "success" });
@@ -342,7 +361,7 @@ try {
342
361
  case "init": {
343
362
  await initMigrations({
344
363
  ...listDirOptions,
345
- ...(args.lang !== undefined ? { lang: args.lang } : {}),
364
+ ...(lang !== undefined ? { lang } : {}),
346
365
  });
347
366
  break;
348
367
  }
@@ -353,8 +372,8 @@ try {
353
372
  case "create": {
354
373
  const [name] = args.positional;
355
374
  await createMigrationCommand({
356
- ...(name ? { name } : {}),
357
- ...(args.lang ? { lang: args.lang } : {}),
375
+ ...(name !== undefined ? { name } : {}),
376
+ ...(lang !== undefined ? { lang } : {}),
358
377
  git: args.git,
359
378
  ...listDirOptions,
360
379
  });
@@ -364,11 +383,11 @@ try {
364
383
  const [name] = args.positional;
365
384
  if (args.all && name !== undefined) {
366
385
  log({ text: "Use either --all or a migration file name, not both.", type: "error" });
367
- usage(1);
386
+ usage(EXIT_USAGE);
368
387
  }
369
388
  if (!args.all && name === undefined) {
370
389
  log({ text: "mark requires a migration file name or --all.", type: "error" });
371
- usage(1);
390
+ usage(EXIT_USAGE);
372
391
  }
373
392
  const { marked } = await markMigrationsApplied({
374
393
  ...connectOptions,
@@ -390,24 +409,27 @@ try {
390
409
  log({ text: `${applied.length} applied, ${pending.length} pending`, type: "info" });
391
410
  if (args.strict && pending.length > 0) {
392
411
  log({ text: `Strict mode: ${pending.length} pending migration(s).`, type: "warn" });
393
- process.exit(1);
412
+ process.exit(EXIT_PENDING);
394
413
  }
395
414
  break;
396
415
  }
397
416
  default:
398
- usage(1);
417
+ usage(EXIT_USAGE);
399
418
  }
400
419
  } catch (error) {
401
420
  if (
402
421
  error instanceof ChecksumDriftError ||
403
422
  error instanceof MigrationNotFoundError ||
423
+ error instanceof MigrationFileMissingError ||
404
424
  error instanceof MigrationLockError ||
405
425
  error instanceof DatabaseWaitTimeoutError ||
406
- error instanceof InvalidIdentifierError
426
+ error instanceof InvalidIdentifierError ||
427
+ error instanceof InvalidConfigError ||
428
+ error instanceof InvalidMigrationNameError
407
429
  ) {
408
430
  log({ text: error.message, type: "error" });
409
431
  } else {
410
432
  log({ text: "Migration command failed", type: "error", error });
411
433
  }
412
- process.exit(1);
434
+ process.exit(exitCodeForError(error));
413
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) {
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
+ }