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