bunsql-native-migrate 0.1.2 → 0.3.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/README.md +169 -28
- package/package.json +2 -1
- package/src/api/create.ts +26 -8
- package/src/api/down.ts +56 -15
- package/src/api/init.ts +57 -0
- package/src/api/load-migration.ts +45 -0
- package/src/api/lock.ts +50 -0
- package/src/api/mark.ts +43 -0
- package/src/api/options.ts +53 -1
- package/src/api/run-step.ts +4 -2
- package/src/api/run-with-driver.ts +4 -1
- package/src/api/status.ts +20 -0
- package/src/api/up.ts +104 -49
- package/src/cli/main.ts +178 -8
- package/src/core/driver.ts +20 -4
- package/src/core/duration.ts +7 -0
- package/src/core/fs.ts +4 -2
- package/src/core/identifiers.ts +32 -0
- package/src/drivers/mariadb.ts +73 -27
- package/src/drivers/postgres.ts +83 -18
- package/src/drivers/shared.ts +69 -5
- package/src/drivers/sqlite.ts +59 -22
- package/src/index.ts +27 -1
package/src/api/lock.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { log } from "../core/console.js";
|
|
2
|
+
import type { MigrationDriver } from "../core/driver.js";
|
|
3
|
+
import { MigrationLockError } from "./options.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_LOCK_TIMEOUT_SECONDS = 30;
|
|
6
|
+
|
|
7
|
+
const LOCK_RETRY_DELAY_MS = 100;
|
|
8
|
+
|
|
9
|
+
export function resolveLockTimeout(lockTimeout: number | undefined): number {
|
|
10
|
+
if (lockTimeout === undefined) return DEFAULT_LOCK_TIMEOUT_SECONDS;
|
|
11
|
+
if (!Number.isInteger(lockTimeout) || lockTimeout < 0) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid lockTimeout: ${String(lockTimeout)} — expected a non-negative integer of seconds`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return lockTimeout;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function withMigrationLock<T>(
|
|
20
|
+
driver: MigrationDriver,
|
|
21
|
+
timeoutSeconds: number,
|
|
22
|
+
run: () => Promise<T>,
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
const { tryLock, releaseLock } = driver;
|
|
25
|
+
if (tryLock === undefined || releaseLock === undefined) {
|
|
26
|
+
return run();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
30
|
+
let waitingLogged = false;
|
|
31
|
+
while (!(await tryLock(timeoutSeconds))) {
|
|
32
|
+
if (Date.now() >= deadline) {
|
|
33
|
+
throw new MigrationLockError(timeoutSeconds);
|
|
34
|
+
}
|
|
35
|
+
if (!waitingLogged) {
|
|
36
|
+
log({ text: "another migrate up holds the lock — waiting", type: "info" });
|
|
37
|
+
waitingLogged = true;
|
|
38
|
+
}
|
|
39
|
+
await Bun.sleep(LOCK_RETRY_DELAY_MS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await run();
|
|
44
|
+
await releaseLock();
|
|
45
|
+
return result;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await releaseLock().catch(() => undefined);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/api/mark.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
3
|
+
import { log } from "../core/console.js";
|
|
4
|
+
import { type MarkOptions, type MarkResult, MigrationNotFoundError } from "./options.js";
|
|
5
|
+
import { runWithDriver } from "./run-with-driver.js";
|
|
6
|
+
|
|
7
|
+
export async function markMigrationsApplied(options: MarkOptions = {}): Promise<MarkResult> {
|
|
8
|
+
const listDir = resolveListDir(options.listDir);
|
|
9
|
+
const target = options.to;
|
|
10
|
+
|
|
11
|
+
return runWithDriver(options, async (driver) => {
|
|
12
|
+
await driver.install();
|
|
13
|
+
|
|
14
|
+
const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
15
|
+
if (target !== undefined && !allFiles.includes(target)) {
|
|
16
|
+
throw new MigrationNotFoundError(target);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const executed = await driver.listExecuted();
|
|
20
|
+
const executedNames = new Set(executed.map((entry) => entry.name));
|
|
21
|
+
let pending = allFiles.filter((file) => !executedNames.has(file));
|
|
22
|
+
if (target !== undefined) {
|
|
23
|
+
if (executedNames.has(target)) {
|
|
24
|
+
log({ text: `${target} is already applied.`, type: "info" });
|
|
25
|
+
return { marked: [] };
|
|
26
|
+
}
|
|
27
|
+
pending = pending.slice(0, pending.indexOf(target) + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const marked: string[] = [];
|
|
31
|
+
for (const file of pending) {
|
|
32
|
+
const checksum = await checksumFile(path.join(listDir, file));
|
|
33
|
+
await driver.record(file, checksum);
|
|
34
|
+
marked.push(file);
|
|
35
|
+
log({ text: `${file} marked as applied`, type: "success" });
|
|
36
|
+
}
|
|
37
|
+
if (marked.length === 0) {
|
|
38
|
+
log({ text: "No pending migrations to mark.", type: "warn" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { marked };
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/api/options.ts
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
+
import type { ExecutedMigration } from "../core/driver.js";
|
|
2
|
+
|
|
1
3
|
export interface MigrateOptions {
|
|
2
4
|
databaseUrl?: string;
|
|
3
5
|
listDir?: string;
|
|
6
|
+
tableName?: string;
|
|
7
|
+
schema?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MigrateUpOptions extends MigrateOptions {
|
|
11
|
+
to?: string;
|
|
12
|
+
lockTimeout?: number;
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MigrateDownOptions extends MigrateOptions {
|
|
17
|
+
steps?: number | "all";
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MarkOptions extends MigrateOptions {
|
|
22
|
+
to?: string;
|
|
4
23
|
}
|
|
5
24
|
|
|
6
25
|
export interface MigrateUpResult {
|
|
7
26
|
applied: string[];
|
|
27
|
+
planned?: string[];
|
|
8
28
|
}
|
|
9
29
|
|
|
10
30
|
export interface MigrateDownResult {
|
|
11
|
-
reverted: string
|
|
31
|
+
reverted: string[];
|
|
32
|
+
planned?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MarkResult {
|
|
36
|
+
marked: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MigrateStatusResult {
|
|
40
|
+
applied: ExecutedMigration[];
|
|
41
|
+
pending: string[];
|
|
12
42
|
}
|
|
13
43
|
|
|
14
44
|
export class ChecksumDriftError extends Error {
|
|
@@ -23,6 +53,28 @@ export class ChecksumDriftError extends Error {
|
|
|
23
53
|
}
|
|
24
54
|
}
|
|
25
55
|
|
|
56
|
+
export class MigrationNotFoundError extends Error {
|
|
57
|
+
readonly file: string;
|
|
58
|
+
|
|
59
|
+
constructor(file: string) {
|
|
60
|
+
super(`${file} is not in the migrations directory — nothing was applied`);
|
|
61
|
+
this.name = "MigrationNotFoundError";
|
|
62
|
+
this.file = file;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class MigrationLockError extends Error {
|
|
67
|
+
readonly timeoutSeconds: number;
|
|
68
|
+
|
|
69
|
+
constructor(timeoutSeconds: number) {
|
|
70
|
+
super(
|
|
71
|
+
`could not acquire the migration lock within ${timeoutSeconds}s — another migrate up is probably still running`,
|
|
72
|
+
);
|
|
73
|
+
this.name = "MigrationLockError";
|
|
74
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
26
78
|
export class GitStageError extends Error {
|
|
27
79
|
readonly file: string;
|
|
28
80
|
readonly exitCode: number;
|
package/src/api/run-step.ts
CHANGED
|
@@ -4,10 +4,12 @@ import type { MigrationDriver } from "../core/driver.js";
|
|
|
4
4
|
export async function runMigrationStep(
|
|
5
5
|
driver: MigrationDriver,
|
|
6
6
|
step: (tx?: SQL) => Promise<void>,
|
|
7
|
-
): Promise<
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
const startedAt = performance.now();
|
|
8
9
|
if (step.length > 0) {
|
|
9
10
|
await driver.transaction((tx) => step(tx));
|
|
10
|
-
return;
|
|
11
|
+
return performance.now() - startedAt;
|
|
11
12
|
}
|
|
12
13
|
await step();
|
|
14
|
+
return performance.now() - startedAt;
|
|
13
15
|
}
|
|
@@ -7,7 +7,10 @@ export async function runWithDriver<T>(
|
|
|
7
7
|
run: (driver: MigrationDriver) => Promise<T>,
|
|
8
8
|
): Promise<T> {
|
|
9
9
|
const url = getDatabaseUrl(options.databaseUrl);
|
|
10
|
-
const driver = await createDriver(url
|
|
10
|
+
const driver = await createDriver(url, {
|
|
11
|
+
...(options.tableName !== undefined ? { tableName: options.tableName } : {}),
|
|
12
|
+
...(options.schema !== undefined ? { schema: options.schema } : {}),
|
|
13
|
+
});
|
|
11
14
|
try {
|
|
12
15
|
return await run(driver);
|
|
13
16
|
} finally {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
2
|
+
import type { MigrateOptions, MigrateStatusResult } from "./options.js";
|
|
3
|
+
import { runWithDriver } from "./run-with-driver.js";
|
|
4
|
+
|
|
5
|
+
export async function migrateStatus(options: MigrateOptions = {}): Promise<MigrateStatusResult> {
|
|
6
|
+
const listDir = resolveListDir(options.listDir);
|
|
7
|
+
|
|
8
|
+
return runWithDriver(options, async (driver) => {
|
|
9
|
+
await driver.install();
|
|
10
|
+
|
|
11
|
+
const files = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
12
|
+
const executed = await driver.listExecuted();
|
|
13
|
+
const appliedNames = new Set(executed.map((entry) => entry.name));
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
applied: executed,
|
|
17
|
+
pending: files.filter((file) => !appliedNames.has(file)),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/api/up.ts
CHANGED
|
@@ -1,68 +1,123 @@
|
|
|
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 {
|
|
4
|
+
import { formatDuration } from "../core/duration.js";
|
|
5
|
+
import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
|
|
6
|
+
import {
|
|
7
|
+
type MigrateUpOptions,
|
|
8
|
+
type MigrateUpResult,
|
|
9
|
+
ChecksumDriftError,
|
|
10
|
+
MigrationNotFoundError,
|
|
11
|
+
} from "./options.js";
|
|
5
12
|
import { runWithDriver } from "./run-with-driver.js";
|
|
6
13
|
import { runMigrationStep } from "./run-step.js";
|
|
14
|
+
import { loadMigration } from "./load-migration.js";
|
|
15
|
+
import { resolveLockTimeout, withMigrationLock } from "./lock.js";
|
|
7
16
|
|
|
8
|
-
|
|
17
|
+
async function listExecutedForPlan(driver: MigrationDriver): Promise<ExecutedMigration[]> {
|
|
18
|
+
if ((await driver.trackingTableExists?.()) === false) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return driver.listExecuted();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
|
|
9
25
|
const listDir = resolveListDir(options.listDir);
|
|
26
|
+
const target = options.to;
|
|
27
|
+
const dryRun = options.dryRun ?? false;
|
|
28
|
+
const lockTimeout = resolveLockTimeout(options.lockTimeout);
|
|
10
29
|
|
|
11
30
|
return runWithDriver(options, async (driver) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const checksums = new Map(
|
|
16
|
-
await Promise.all(
|
|
17
|
-
allFiles.map(async (file) => [file, await checksumFile(path.join(listDir, file))] as const),
|
|
18
|
-
),
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const executed = await driver.listExecuted();
|
|
22
|
-
const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
|
|
23
|
-
|
|
24
|
-
for (const [file, checksum] of checksums) {
|
|
25
|
-
const record = executedByName.get(file);
|
|
26
|
-
if (!record) continue;
|
|
27
|
-
|
|
28
|
-
if (record.checksum === null) {
|
|
29
|
-
await driver.setChecksum(file, checksum);
|
|
30
|
-
log({ text: `${file} checksum saved (legacy record)`, type: "info" });
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
31
|
+
if (!dryRun) {
|
|
32
|
+
await driver.install();
|
|
33
|
+
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const run = async (): Promise<MigrateUpResult> => {
|
|
36
|
+
const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
37
|
+
if (target !== undefined && !allFiles.includes(target)) {
|
|
38
|
+
throw new MigrationNotFoundError(target);
|
|
36
39
|
}
|
|
37
|
-
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
const checksums = new Map(
|
|
42
|
+
await Promise.all(
|
|
43
|
+
allFiles.map(
|
|
44
|
+
async (file) => [file, await checksumFile(path.join(listDir, file))] as const,
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return { applied };
|
|
45
|
-
}
|
|
49
|
+
const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
|
|
50
|
+
const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
for (const [file, checksum] of checksums) {
|
|
53
|
+
const record = executedByName.get(file);
|
|
54
|
+
if (!record) continue;
|
|
55
|
+
|
|
56
|
+
if (record.checksum === null) {
|
|
57
|
+
if (!dryRun) {
|
|
58
|
+
await driver.setChecksum(file, checksum);
|
|
59
|
+
log({ text: `${file} checksum saved (legacy record)`, type: "info" });
|
|
60
|
+
}
|
|
54
61
|
continue;
|
|
55
62
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
|
|
64
|
+
if (record.checksum !== checksum) {
|
|
65
|
+
throw new ChecksumDriftError(file);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let pending = allFiles.filter((file) => !executedByName.has(file));
|
|
70
|
+
if (target !== undefined) {
|
|
71
|
+
if (executedByName.has(target)) {
|
|
72
|
+
log({ text: `${target} is already applied.`, type: "info" });
|
|
73
|
+
return dryRun ? { applied: [], planned: [] } : { applied: [] };
|
|
74
|
+
}
|
|
75
|
+
pending = pending.slice(0, pending.indexOf(target) + 1);
|
|
63
76
|
}
|
|
64
|
-
}
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
if (dryRun) {
|
|
79
|
+
log({ text: "Dry run — no changes will be made.", type: "info" });
|
|
80
|
+
if (pending.length === 0) {
|
|
81
|
+
log({ text: "No pending migrations.", type: "warn" });
|
|
82
|
+
}
|
|
83
|
+
for (const file of pending) {
|
|
84
|
+
log({ text: `${file} would be applied`, type: "info" });
|
|
85
|
+
}
|
|
86
|
+
return { applied: [], planned: pending };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const applied: string[] = [];
|
|
90
|
+
|
|
91
|
+
if (pending.length === 0) {
|
|
92
|
+
log({ text: "No pending migrations.", type: "warn" });
|
|
93
|
+
return { applied };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const file of pending) {
|
|
97
|
+
const checksum = checksums.get(file);
|
|
98
|
+
if (!checksum) continue;
|
|
99
|
+
try {
|
|
100
|
+
const { up } = await loadMigration(listDir, file);
|
|
101
|
+
if (up === null) {
|
|
102
|
+
log({ text: `${file} has no up() export, skipping`, type: "warn" });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const durationMs = await runMigrationStep(driver, up);
|
|
106
|
+
await driver.record(file, checksum);
|
|
107
|
+
applied.push(file);
|
|
108
|
+
log({ text: `${file} migrated up (${formatDuration(durationMs)})`, type: "success" });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log({ text: `${file} migration failed`, type: "error", error });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { applied };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (dryRun) {
|
|
119
|
+
return run();
|
|
120
|
+
}
|
|
121
|
+
return withMigrationLock(driver, lockTimeout, run);
|
|
67
122
|
});
|
|
68
123
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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 {
|
|
6
|
+
import { createMigrationCommand, type MigrationLang } from "../api/create.js";
|
|
7
|
+
import { initMigrations } from "../api/init.js";
|
|
8
|
+
import { markMigrationsApplied } from "../api/mark.js";
|
|
9
|
+
import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
|
|
10
|
+
import { InvalidIdentifierError } from "../core/identifiers.js";
|
|
7
11
|
import { log } from "../core/console.js";
|
|
8
12
|
|
|
9
13
|
interface CliArgs {
|
|
@@ -11,6 +15,14 @@ interface CliArgs {
|
|
|
11
15
|
positional: string[];
|
|
12
16
|
dir?: string | undefined;
|
|
13
17
|
git: boolean;
|
|
18
|
+
lang?: MigrationLang | undefined;
|
|
19
|
+
to?: string | undefined;
|
|
20
|
+
lockTimeout?: number | undefined;
|
|
21
|
+
table?: string | undefined;
|
|
22
|
+
schema?: string | undefined;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
all: boolean;
|
|
25
|
+
strict: boolean;
|
|
14
26
|
help: boolean;
|
|
15
27
|
}
|
|
16
28
|
|
|
@@ -18,6 +30,14 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
18
30
|
const positional: string[] = [];
|
|
19
31
|
let dir: string | undefined;
|
|
20
32
|
let git = false;
|
|
33
|
+
let lang: MigrationLang | undefined;
|
|
34
|
+
let to: string | undefined;
|
|
35
|
+
let lockTimeout: number | undefined;
|
|
36
|
+
let table: string | undefined;
|
|
37
|
+
let schema: string | undefined;
|
|
38
|
+
let dryRun = false;
|
|
39
|
+
let all = false;
|
|
40
|
+
let strict = false;
|
|
21
41
|
let help = false;
|
|
22
42
|
for (let i = 0; i < argv.length; i++) {
|
|
23
43
|
const arg = argv[i]!;
|
|
@@ -25,18 +45,77 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
25
45
|
dir = argv[++i];
|
|
26
46
|
} else if (arg === "--git") {
|
|
27
47
|
git = true;
|
|
48
|
+
} else if (arg === "--lang") {
|
|
49
|
+
const value = argv[++i];
|
|
50
|
+
if (value !== "js" && value !== "ts") {
|
|
51
|
+
log({
|
|
52
|
+
text: `Unknown --lang value: ${value ?? "(missing)"} (expected js or ts)`,
|
|
53
|
+
type: "error",
|
|
54
|
+
});
|
|
55
|
+
usage(1);
|
|
56
|
+
}
|
|
57
|
+
lang = value;
|
|
58
|
+
} else if (arg === "--to") {
|
|
59
|
+
to = argv[++i];
|
|
60
|
+
if (to === undefined) {
|
|
61
|
+
log({ text: "--to requires a migration file name", type: "error" });
|
|
62
|
+
usage(1);
|
|
63
|
+
}
|
|
64
|
+
} else if (arg === "--lock-timeout") {
|
|
65
|
+
const value = argv[++i];
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
if (value === undefined || !Number.isInteger(parsed) || parsed < 0) {
|
|
68
|
+
log({
|
|
69
|
+
text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
|
|
70
|
+
type: "error",
|
|
71
|
+
});
|
|
72
|
+
usage(1);
|
|
73
|
+
}
|
|
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
|
+
}
|
|
87
|
+
} else if (arg === "--all") {
|
|
88
|
+
all = true;
|
|
89
|
+
} else if (arg === "--dry-run") {
|
|
90
|
+
dryRun = true;
|
|
91
|
+
} else if (arg === "--strict") {
|
|
92
|
+
strict = true;
|
|
28
93
|
} else if (arg === "--help" || arg === "-h") {
|
|
29
94
|
help = true;
|
|
30
95
|
} else {
|
|
31
96
|
positional.push(arg);
|
|
32
97
|
}
|
|
33
98
|
}
|
|
34
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
command: positional.shift(),
|
|
101
|
+
positional,
|
|
102
|
+
dir,
|
|
103
|
+
git,
|
|
104
|
+
lang,
|
|
105
|
+
to,
|
|
106
|
+
lockTimeout,
|
|
107
|
+
table,
|
|
108
|
+
schema,
|
|
109
|
+
dryRun,
|
|
110
|
+
all,
|
|
111
|
+
strict,
|
|
112
|
+
help,
|
|
113
|
+
};
|
|
35
114
|
}
|
|
36
115
|
|
|
37
116
|
function usage(exitCode: number): never {
|
|
38
117
|
log({
|
|
39
|
-
text: "Usage: bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--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]",
|
|
40
119
|
type: "info",
|
|
41
120
|
});
|
|
42
121
|
process.exit(exitCode);
|
|
@@ -44,6 +123,10 @@ function usage(exitCode: number): never {
|
|
|
44
123
|
|
|
45
124
|
const args = parseArgs(process.argv.slice(2));
|
|
46
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
|
+
};
|
|
47
130
|
|
|
48
131
|
if (args.help) {
|
|
49
132
|
usage(0);
|
|
@@ -52,34 +135,121 @@ if (args.help) {
|
|
|
52
135
|
try {
|
|
53
136
|
switch (args.command) {
|
|
54
137
|
case "up": {
|
|
55
|
-
const { applied } = await migrateUp(
|
|
138
|
+
const { applied, planned } = await migrateUp({
|
|
139
|
+
...listDirOptions,
|
|
140
|
+
...tableOptions,
|
|
141
|
+
...(args.to ? { to: args.to } : {}),
|
|
142
|
+
...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
|
|
143
|
+
...(args.dryRun ? { dryRun: true } : {}),
|
|
144
|
+
});
|
|
145
|
+
if (planned !== undefined && planned.length > 0) {
|
|
146
|
+
log({ text: `Would apply ${planned.length} migration(s).`, type: "info" });
|
|
147
|
+
}
|
|
56
148
|
if (applied.length > 0) {
|
|
57
149
|
log({ text: `Applied ${applied.length} migration(s).`, type: "success" });
|
|
58
150
|
}
|
|
59
151
|
break;
|
|
60
152
|
}
|
|
61
153
|
case "down": {
|
|
62
|
-
|
|
154
|
+
const [stepsArg] = args.positional;
|
|
155
|
+
if (args.all && stepsArg !== undefined) {
|
|
156
|
+
log({ text: "Use either --all or a number of steps, not both.", type: "error" });
|
|
157
|
+
usage(1);
|
|
158
|
+
}
|
|
159
|
+
let steps: number | "all" = 1;
|
|
160
|
+
if (args.all) {
|
|
161
|
+
steps = "all";
|
|
162
|
+
} else if (stepsArg !== undefined) {
|
|
163
|
+
const parsed = Number(stepsArg);
|
|
164
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
165
|
+
log({
|
|
166
|
+
text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
|
|
167
|
+
type: "error",
|
|
168
|
+
});
|
|
169
|
+
usage(1);
|
|
170
|
+
}
|
|
171
|
+
steps = parsed;
|
|
172
|
+
}
|
|
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
|
+
}
|
|
182
|
+
if (reverted.length > 0) {
|
|
183
|
+
log({ text: `Reverted ${reverted.length} migration(s).`, type: "success" });
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "init": {
|
|
188
|
+
await initMigrations({
|
|
189
|
+
...listDirOptions,
|
|
190
|
+
...(args.lang !== undefined ? { lang: args.lang } : {}),
|
|
191
|
+
});
|
|
63
192
|
break;
|
|
64
193
|
}
|
|
65
194
|
case "install": {
|
|
66
|
-
await installMigrations(listDirOptions);
|
|
195
|
+
await installMigrations({ ...listDirOptions, ...tableOptions });
|
|
67
196
|
break;
|
|
68
197
|
}
|
|
69
198
|
case "create": {
|
|
70
199
|
const [name] = args.positional;
|
|
71
200
|
await createMigrationCommand({
|
|
72
201
|
...(name ? { name } : {}),
|
|
202
|
+
...(args.lang ? { lang: args.lang } : {}),
|
|
73
203
|
git: args.git,
|
|
74
204
|
...listDirOptions,
|
|
75
205
|
});
|
|
76
206
|
break;
|
|
77
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
|
+
}
|
|
228
|
+
case "status": {
|
|
229
|
+
const { applied, pending } = await migrateStatus({ ...listDirOptions, ...tableOptions });
|
|
230
|
+
for (const entry of applied) {
|
|
231
|
+
log({ text: `${entry.name} applied`, type: "info" });
|
|
232
|
+
}
|
|
233
|
+
for (const file of pending) {
|
|
234
|
+
log({ text: `${file} pending`, type: "warn" });
|
|
235
|
+
}
|
|
236
|
+
log({ text: `${applied.length} applied, ${pending.length} pending`, type: "info" });
|
|
237
|
+
if (args.strict && pending.length > 0) {
|
|
238
|
+
log({ text: `Strict mode: ${pending.length} pending migration(s).`, type: "warn" });
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
78
243
|
default:
|
|
79
244
|
usage(1);
|
|
80
245
|
}
|
|
81
246
|
} catch (error) {
|
|
82
|
-
if (
|
|
247
|
+
if (
|
|
248
|
+
error instanceof ChecksumDriftError ||
|
|
249
|
+
error instanceof MigrationNotFoundError ||
|
|
250
|
+
error instanceof MigrationLockError ||
|
|
251
|
+
error instanceof InvalidIdentifierError
|
|
252
|
+
) {
|
|
83
253
|
log({ text: error.message, type: "error" });
|
|
84
254
|
} else {
|
|
85
255
|
log({ text: "Migration command failed", type: "error", error });
|