@deterministic-code/migraters 0.0.1 → 0.0.2
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 +1 -0
- package/cli.yaml +264 -0
- package/dist/bin/migrate-create.js +0 -0
- package/dist/bin/migrate-down.js +0 -0
- package/dist/bin/migrate-init.js +0 -0
- package/dist/bin/migrate-setup.js +0 -0
- package/dist/bin/migrate-up.js +0 -0
- package/package.json +7 -6
- package/src/abstractions/migrater.ts +0 -13
- package/src/abstractions/sql-dialect-base.ts +0 -36
- package/src/abstractions/sql-dialect-factory.ts +0 -29
- package/src/abstractions/sql-dialect.ts +0 -17
- package/src/bin/migrate-create.ts +0 -80
- package/src/bin/migrate-down.ts +0 -163
- package/src/bin/migrate-init.ts +0 -88
- package/src/bin/migrate-setup.ts +0 -98
- package/src/bin/migrate-up.ts +0 -111
- package/src/cli-contract.ts +0 -25
- package/src/cli-spec.ts +0 -64
- package/src/cli.ts +0 -52
- package/src/create-sequence.ts +0 -17
- package/src/datasource-migrate.ts +0 -314
- package/src/dialects/mysql-dialect.test.ts +0 -93
- package/src/dialects/mysql-dialect.ts +0 -67
- package/src/dialects/oracle-dialect.test.ts +0 -118
- package/src/dialects/oracle-dialect.ts +0 -91
- package/src/dialects/postgres-dialect.test.ts +0 -80
- package/src/dialects/postgres-dialect.ts +0 -65
- package/src/dialects/sqlite-dialect.rollback.test.ts +0 -30
- package/src/dialects/sqlite-dialect.test.ts +0 -77
- package/src/dialects/sqlite-dialect.ts +0 -96
- package/src/dialects/sqlserver-dialect.test.ts +0 -99
- package/src/dialects/sqlserver-dialect.ts +0 -87
- package/src/generate-help.test.ts +0 -33
- package/src/generate-help.ts +0 -173
- package/src/help.test.ts +0 -48
- package/src/help.ts +0 -12
- package/src/index.ts +0 -23
- package/src/infrastructure/connection-resolver.ts +0 -18
- package/src/infrastructure/default-factory.test.ts +0 -18
- package/src/infrastructure/default-factory.ts +0 -17
- package/src/infrastructure/interop-default.test.ts +0 -9
- package/src/infrastructure/interop-default.ts +0 -2
- package/src/infrastructure/message-templates.ts +0 -30
- package/src/infrastructure/split-on-go.ts +0 -19
- package/src/infrastructure/sql-templates.ts +0 -10
- package/src/infrastructure/templates-root.ts +0 -13
- package/src/init-select.test.ts +0 -26
- package/src/init-select.ts +0 -73
- package/src/migrater.ts +0 -92
- package/src/migration-client.ts +0 -21
- package/src/path-exists.ts +0 -10
- package/src/setup-sql.test.ts +0 -16
- package/src/sql.ts +0 -17
- package/tsconfig.build.json +0 -19
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { defaultDialectFactory } from './infrastructure/default-factory.ts';
|
|
5
|
-
import { splitOnGo } from './infrastructure/split-on-go.ts';
|
|
6
|
-
import { fillTemplate, loadMessage } from './infrastructure/message-templates.ts';
|
|
7
|
-
import type { ISqlDialect } from './abstractions/sql-dialect.ts';
|
|
8
|
-
import type { MigrationClient, MigrationRowValue } from './migration-client.ts';
|
|
9
|
-
import type { SqlDialect } from './sql.ts';
|
|
10
|
-
|
|
11
|
-
export { normalizeDialect } from './sql.ts';
|
|
12
|
-
export type {
|
|
13
|
-
MigrationClient,
|
|
14
|
-
MigrationRow,
|
|
15
|
-
MigrationSqlParam,
|
|
16
|
-
SqliteRawDatabase,
|
|
17
|
-
} from './migration-client.ts';
|
|
18
|
-
export { applyMysqlDdlViaTextProtocol } from './dialects/mysql-dialect.ts';
|
|
19
|
-
|
|
20
|
-
/** One discovered migration: its up script and the rollback sibling when present. */
|
|
21
|
-
export interface MigrationDescriptor {
|
|
22
|
-
name: string;
|
|
23
|
-
upPath: string;
|
|
24
|
-
downPath: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const UP_SUFFIX = '_up.sql';
|
|
28
|
-
const DOWN_SUFFIX = '_down.sql';
|
|
29
|
-
|
|
30
|
-
const [
|
|
31
|
-
checksumDriftTpl,
|
|
32
|
-
checksumDriftLineTpl,
|
|
33
|
-
rollbackNotInPathMsg,
|
|
34
|
-
rollbackNoSiblingTpl,
|
|
35
|
-
migratePathNotDirTpl,
|
|
36
|
-
] = await Promise.all([
|
|
37
|
-
loadMessage('checksum-drift'),
|
|
38
|
-
loadMessage('checksum-drift-line'),
|
|
39
|
-
loadMessage('errors/rollback-not-in-path'),
|
|
40
|
-
loadMessage('errors/rollback-no-rollback-sibling'),
|
|
41
|
-
loadMessage('errors/migrate-path-not-dir'),
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const missingMigrationsDir = (path: string): Error =>
|
|
45
|
-
new Error(fillTemplate(migratePathNotDirTpl, { path }).trimEnd());
|
|
46
|
-
|
|
47
|
-
export async function discoverMigrations({
|
|
48
|
-
migratePath,
|
|
49
|
-
dialect,
|
|
50
|
-
}: {
|
|
51
|
-
migratePath: string;
|
|
52
|
-
dialect: string;
|
|
53
|
-
}): Promise<MigrationDescriptor[]> {
|
|
54
|
-
requireDialect(dialect);
|
|
55
|
-
let st;
|
|
56
|
-
try {
|
|
57
|
-
st = await stat(migratePath);
|
|
58
|
-
} catch {
|
|
59
|
-
throw missingMigrationsDir(migratePath);
|
|
60
|
-
}
|
|
61
|
-
if (!st.isDirectory()) throw missingMigrationsDir(migratePath);
|
|
62
|
-
|
|
63
|
-
const entries = await readdir(migratePath, { withFileTypes: true });
|
|
64
|
-
const files = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
|
|
65
|
-
const ups: MigrationDescriptor[] = [];
|
|
66
|
-
for (const name of files) {
|
|
67
|
-
if (!name.endsWith(UP_SUFFIX)) continue;
|
|
68
|
-
const stem = name.slice(0, -UP_SUFFIX.length);
|
|
69
|
-
const downName = `${stem}${DOWN_SUFFIX}`;
|
|
70
|
-
ups.push({
|
|
71
|
-
name: stem,
|
|
72
|
-
upPath: join(migratePath, name),
|
|
73
|
-
downPath: files.has(downName) ? join(migratePath, downName) : null,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
ups.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
77
|
-
return ups;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Strip pre-refactor `-- deterministic-content-hash: <sha>` header if present so upgrading users carrying the legacy header keep the same checksum migrate:up recorded. */
|
|
81
|
-
const LEGACY_HEADER_RE = /^(?:\/\/|--|#)\s+deterministic-content-hash:\s+[0-9a-f]{64}\s*\r?\n?/;
|
|
82
|
-
|
|
83
|
-
export function checksum(text: string): string {
|
|
84
|
-
const body = text.replace(LEGACY_HEADER_RE, '');
|
|
85
|
-
return createHash('sha256').update(body, 'utf8').digest('hex');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function applyOneUp({
|
|
89
|
-
client,
|
|
90
|
-
migrations,
|
|
91
|
-
env = process.env,
|
|
92
|
-
}: {
|
|
93
|
-
client: MigrationClient;
|
|
94
|
-
migrations: MigrationDescriptor[];
|
|
95
|
-
env?: NodeJS.ProcessEnv;
|
|
96
|
-
}): Promise<{ applied: boolean; name: string | null }> {
|
|
97
|
-
const applied = await appliedRecords(client);
|
|
98
|
-
await assertNoChecksumDrift({ migrations, applied, env });
|
|
99
|
-
const next = migrations.find((m) => !applied.has(m.name));
|
|
100
|
-
if (!next) return { applied: false, name: null };
|
|
101
|
-
|
|
102
|
-
const sqlText = await readFile(next.upPath, 'utf8');
|
|
103
|
-
const sum = checksum(sqlText);
|
|
104
|
-
|
|
105
|
-
const logId = await insertLogStarted(client, next.name, 'up');
|
|
106
|
-
const startedAt = Date.now();
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await client.transaction(async () => {
|
|
110
|
-
const stmts = splitOnGo(await readFile(next.upPath, 'utf8'));
|
|
111
|
-
for (const stmt of stmts) {
|
|
112
|
-
await client.exec(stmt);
|
|
113
|
-
}
|
|
114
|
-
await insertMigrate(client, next.name, sum);
|
|
115
|
-
});
|
|
116
|
-
} catch (e) {
|
|
117
|
-
const err = e as Error;
|
|
118
|
-
await updateLogTerminal({
|
|
119
|
-
client,
|
|
120
|
-
id: logId,
|
|
121
|
-
status: 'error',
|
|
122
|
-
durationMs: Date.now() - startedAt,
|
|
123
|
-
errorMessage: String(err?.message ?? e),
|
|
124
|
-
});
|
|
125
|
-
throw e;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
await updateLogTerminal({
|
|
129
|
-
client,
|
|
130
|
-
id: logId,
|
|
131
|
-
status: 'success',
|
|
132
|
-
durationMs: Date.now() - startedAt,
|
|
133
|
-
errorMessage: null,
|
|
134
|
-
});
|
|
135
|
-
return { applied: true, name: next.name };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function applyOneDown({
|
|
139
|
-
client,
|
|
140
|
-
migrations,
|
|
141
|
-
env = process.env,
|
|
142
|
-
}: {
|
|
143
|
-
client: MigrationClient;
|
|
144
|
-
migrations: MigrationDescriptor[];
|
|
145
|
-
env?: NodeJS.ProcessEnv;
|
|
146
|
-
}): Promise<{ rolledBack: boolean; name: string | null }> {
|
|
147
|
-
const applied = await appliedRecords(client);
|
|
148
|
-
await assertNoChecksumDrift({ migrations, applied, env });
|
|
149
|
-
if (applied.size === 0) return { rolledBack: false, name: null };
|
|
150
|
-
|
|
151
|
-
const lastApplied = [...migrations].reverse().find((m) => applied.has(m.name));
|
|
152
|
-
if (!lastApplied) {
|
|
153
|
-
throw new Error(rollbackNotInPathMsg.trimEnd());
|
|
154
|
-
}
|
|
155
|
-
if (!lastApplied.downPath) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
fillTemplate(rollbackNoSiblingTpl, { name: lastApplied.name }).trimEnd(),
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
const downPath = lastApplied.downPath;
|
|
161
|
-
|
|
162
|
-
const logId = await insertLogStarted(client, lastApplied.name, 'down');
|
|
163
|
-
const startedAt = Date.now();
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
await client.transaction(async () => {
|
|
167
|
-
const stmts = splitOnGo(await readFile(downPath, 'utf8'));
|
|
168
|
-
for (const stmt of stmts) {
|
|
169
|
-
await client.exec(stmt);
|
|
170
|
-
}
|
|
171
|
-
await deleteMigrate(client, lastApplied.name);
|
|
172
|
-
});
|
|
173
|
-
} catch (e) {
|
|
174
|
-
const err = e as Error;
|
|
175
|
-
await updateLogTerminal({
|
|
176
|
-
client,
|
|
177
|
-
id: logId,
|
|
178
|
-
status: 'error',
|
|
179
|
-
durationMs: Date.now() - startedAt,
|
|
180
|
-
errorMessage: String(err?.message ?? e),
|
|
181
|
-
});
|
|
182
|
-
throw e;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
await updateLogTerminal({
|
|
186
|
-
client,
|
|
187
|
-
id: logId,
|
|
188
|
-
status: 'success',
|
|
189
|
-
durationMs: Date.now() - startedAt,
|
|
190
|
-
errorMessage: null,
|
|
191
|
-
});
|
|
192
|
-
return { rolledBack: true, name: lastApplied.name };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function dialectOf(client: MigrationClient): ISqlDialect {
|
|
196
|
-
return defaultDialectFactory.get(client.dialect);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function appliedRecords(client: MigrationClient): Promise<Map<string, string | null>> {
|
|
200
|
-
const d = dialectOf(client);
|
|
201
|
-
const q = (ident: string) => d.quoteIdent(ident);
|
|
202
|
-
const rows = await client.query(
|
|
203
|
-
`SELECT ${q('name')} AS name, ${q('checksum')} AS checksum FROM ${q('migrates')}`,
|
|
204
|
-
);
|
|
205
|
-
return new Map(
|
|
206
|
-
rows.map(
|
|
207
|
-
(r) => [r.name ?? r.NAME, r.checksum ?? r.CHECKSUM ?? null] as [string, string | null],
|
|
208
|
-
),
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function assertNoChecksumDrift({
|
|
213
|
-
migrations,
|
|
214
|
-
applied,
|
|
215
|
-
env = process.env,
|
|
216
|
-
}: {
|
|
217
|
-
migrations: MigrationDescriptor[];
|
|
218
|
-
applied: Map<string, string | null>;
|
|
219
|
-
env?: NodeJS.ProcessEnv;
|
|
220
|
-
}): Promise<void> {
|
|
221
|
-
if (env.MIGRATE_ALLOW_CHECKSUM_DRIFT === '1') return;
|
|
222
|
-
const drifted: { name: string; stored: string; current: string }[] = [];
|
|
223
|
-
for (const m of migrations) {
|
|
224
|
-
if (!applied.has(m.name)) continue;
|
|
225
|
-
const stored = applied.get(m.name);
|
|
226
|
-
if (stored == null) continue;
|
|
227
|
-
const current = checksum(await readFile(m.upPath, 'utf8'));
|
|
228
|
-
if (current !== stored) drifted.push({ name: m.name, stored, current });
|
|
229
|
-
}
|
|
230
|
-
if (drifted.length === 0) return;
|
|
231
|
-
const lines = drifted
|
|
232
|
-
.map((d) =>
|
|
233
|
-
fillTemplate(checksumDriftLineTpl, {
|
|
234
|
-
name: d.name,
|
|
235
|
-
stored: d.stored.slice(0, 12),
|
|
236
|
-
current: d.current.slice(0, 12),
|
|
237
|
-
}).trimEnd(),
|
|
238
|
-
)
|
|
239
|
-
.join('\n');
|
|
240
|
-
throw new Error(fillTemplate(checksumDriftTpl, { lines }).trimEnd());
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function insertMigrate(client: MigrationClient, name: string, sum: string): Promise<void> {
|
|
244
|
-
const d = dialectOf(client);
|
|
245
|
-
const t = d.quoteIdent('migrates');
|
|
246
|
-
await client.run(
|
|
247
|
-
`INSERT INTO ${t} (${d.quoteIdent('name')}, ${d.quoteIdent('checksum')}) VALUES (?, ?)`,
|
|
248
|
-
[name, sum],
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function deleteMigrate(client: MigrationClient, name: string): Promise<void> {
|
|
253
|
-
const d = dialectOf(client);
|
|
254
|
-
const t = d.quoteIdent('migrates');
|
|
255
|
-
await client.run(`DELETE FROM ${t} WHERE ${d.quoteIdent('name')} = ?`, [name]);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function insertLogStarted(
|
|
259
|
-
client: MigrationClient,
|
|
260
|
-
name: string,
|
|
261
|
-
direction: string,
|
|
262
|
-
): Promise<MigrationRowValue> {
|
|
263
|
-
const d = dialectOf(client);
|
|
264
|
-
const t = d.quoteIdent('migrate_logs');
|
|
265
|
-
if (d.usesLastInsertRowid) {
|
|
266
|
-
const stmt = `INSERT INTO ${t} (${d.quoteIdent('migrate_name')}, ${d.quoteIdent('direction')}, ${d.quoteIdent('status')}) VALUES (?, ?, 'started')`;
|
|
267
|
-
const db = client._raw;
|
|
268
|
-
if (db) {
|
|
269
|
-
const info = db.prepare(stmt).run(name, direction);
|
|
270
|
-
return info.lastInsertRowid;
|
|
271
|
-
}
|
|
272
|
-
await client.run(stmt, [name, direction]);
|
|
273
|
-
const rows = await client.query(
|
|
274
|
-
`SELECT ${d.quoteIdent('id')} AS id FROM ${t} WHERE ${d.quoteIdent('migrate_name')} = ? AND ${d.quoteIdent('direction')} = ? ORDER BY id DESC ${d.limitClause(1)}`,
|
|
275
|
-
[name, direction],
|
|
276
|
-
);
|
|
277
|
-
return rows[0]?.id;
|
|
278
|
-
}
|
|
279
|
-
await client.run(
|
|
280
|
-
`INSERT INTO ${t} (${d.quoteIdent('migrate_name')}, ${d.quoteIdent('direction')}, ${d.quoteIdent('status')}) VALUES (?, ?, 'started')`,
|
|
281
|
-
[name, direction],
|
|
282
|
-
);
|
|
283
|
-
const rows = await client.query(
|
|
284
|
-
`SELECT ${d.quoteIdent('id')} AS id FROM ${t} WHERE ${d.quoteIdent('migrate_name')} = ? AND ${d.quoteIdent('direction')} = ? ORDER BY ${d.quoteIdent('id')} DESC ${d.limitClause(1)}`,
|
|
285
|
-
[name, direction],
|
|
286
|
-
);
|
|
287
|
-
return rows[0]?.id ?? rows[0]?.ID;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function updateLogTerminal({
|
|
291
|
-
client,
|
|
292
|
-
id,
|
|
293
|
-
status,
|
|
294
|
-
durationMs,
|
|
295
|
-
errorMessage,
|
|
296
|
-
}: {
|
|
297
|
-
client: MigrationClient;
|
|
298
|
-
id: MigrationRowValue;
|
|
299
|
-
status: string;
|
|
300
|
-
durationMs: number;
|
|
301
|
-
errorMessage: string | null;
|
|
302
|
-
}): Promise<void> {
|
|
303
|
-
const d = dialectOf(client);
|
|
304
|
-
const t = d.quoteIdent('migrate_logs');
|
|
305
|
-
const setNow = d.nowExpr();
|
|
306
|
-
await client.run(
|
|
307
|
-
`UPDATE ${t} SET ${d.quoteIdent('status')} = ?, ${d.quoteIdent('finished_at')} = ${setNow}, ${d.quoteIdent('duration_ms')} = ?, ${d.quoteIdent('error_message')} = ?, ${d.quoteIdent('updated')} = ${setNow} WHERE ${d.quoteIdent('id')} = ?`,
|
|
308
|
-
[status, durationMs, errorMessage, id],
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function requireDialect(dialect: string): SqlDialect {
|
|
313
|
-
return defaultDialectFactory.get(dialect).name;
|
|
314
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const mysql = vi.hoisted(() => {
|
|
4
|
-
const state = {
|
|
5
|
-
failRollback: false,
|
|
6
|
-
ended: false,
|
|
7
|
-
queries: [] as string[],
|
|
8
|
-
executes: [] as { sql: string; params?: unknown[] }[],
|
|
9
|
-
};
|
|
10
|
-
const conn = {
|
|
11
|
-
async query(sql: string) {
|
|
12
|
-
state.queries.push(sql);
|
|
13
|
-
},
|
|
14
|
-
async execute(sql: string, params?: unknown[]) {
|
|
15
|
-
state.executes.push({ sql, params });
|
|
16
|
-
return [[{ n: 1 }]];
|
|
17
|
-
},
|
|
18
|
-
async beginTransaction() {},
|
|
19
|
-
async commit() {},
|
|
20
|
-
async rollback() {
|
|
21
|
-
if (state.failRollback) throw new Error('rollback failed');
|
|
22
|
-
},
|
|
23
|
-
async end() {
|
|
24
|
-
state.ended = true;
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
return {
|
|
28
|
-
state,
|
|
29
|
-
createConnection: async () => conn,
|
|
30
|
-
};
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
vi.mock('mysql2/promise', () => ({
|
|
34
|
-
createConnection: mysql.createConnection,
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
import { applyMysqlDdlViaTextProtocol, MysqlDialect } from './mysql-dialect.ts';
|
|
38
|
-
|
|
39
|
-
const dialect = new MysqlDialect();
|
|
40
|
-
|
|
41
|
-
describe('MysqlDialect', () => {
|
|
42
|
-
afterEach(() => {
|
|
43
|
-
mysql.state.failRollback = false;
|
|
44
|
-
mysql.state.ended = false;
|
|
45
|
-
mysql.state.queries = [];
|
|
46
|
-
mysql.state.executes = [];
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('registers as mysql with backtick identifiers', async () => {
|
|
50
|
-
expect(dialect.name).toBe('mysql');
|
|
51
|
-
expect([...dialect.connectionEnvironmentVariables]).toEqual(['MYSQL_URL', 'DATABASE_URL']);
|
|
52
|
-
expect(dialect.quoteIdent('t')).toBe('`t`');
|
|
53
|
-
expect(dialect.nowExpr()).toBe('CURRENT_TIMESTAMP');
|
|
54
|
-
expect(dialect.limitClause(2)).toBe('LIMIT 2');
|
|
55
|
-
expect(dialect.translatePlaceholders('?')).toBe('?');
|
|
56
|
-
expect(dialect.migratesDdl).toMatch(/migrates/i);
|
|
57
|
-
expect(dialect.migrateLogsDdl).toMatch(/migrate_logs/i);
|
|
58
|
-
expect(await dialect.prerequisiteError('mysql://x')).toBeNull();
|
|
59
|
-
await dialect.prepareSetup('mysql://x');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('applyMysqlDdlViaTextProtocol uses query, not execute', async () => {
|
|
63
|
-
const seen: string[] = [];
|
|
64
|
-
await applyMysqlDdlViaTextProtocol({ query: async (sql: string) => { seen.push(sql); } }, 'CREATE TABLE t (id INT)');
|
|
65
|
-
expect(seen).toEqual(['CREATE TABLE t (id INT)']);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('createClient uses text-protocol exec and execute for query/run', async () => {
|
|
69
|
-
const client = await dialect.createClient('mysql://localhost/db');
|
|
70
|
-
expect(client.dialect).toBe('mysql');
|
|
71
|
-
await client.exec('DROP PROCEDURE p');
|
|
72
|
-
expect(mysql.state.queries).toContain('DROP PROCEDURE p');
|
|
73
|
-
expect(await client.query('SELECT ?', [1])).toEqual([{ n: 1 }]);
|
|
74
|
-
await client.run('UPDATE t SET x = ?', [2]);
|
|
75
|
-
expect(await client.transaction(async () => 9)).toBe(9);
|
|
76
|
-
await expect(
|
|
77
|
-
client.transaction(async () => {
|
|
78
|
-
throw new Error('tx');
|
|
79
|
-
}),
|
|
80
|
-
).rejects.toThrow('tx');
|
|
81
|
-
mysql.state.failRollback = true;
|
|
82
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
83
|
-
await expect(
|
|
84
|
-
client.transaction(async () => {
|
|
85
|
-
throw new Error('tx2');
|
|
86
|
-
}),
|
|
87
|
-
).rejects.toThrow('tx2');
|
|
88
|
-
expect(warn).toHaveBeenCalled();
|
|
89
|
-
warn.mockRestore();
|
|
90
|
-
await client.close();
|
|
91
|
-
expect(mysql.state.ended).toBe(true);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { Connection as MysqlConnection } from 'mysql2/promise';
|
|
2
|
-
import type { MigrationClient, MigrationRow } from '../migration-client.ts';
|
|
3
|
-
import { readSqlTemplate } from '../infrastructure/sql-templates.ts';
|
|
4
|
-
import { SqlDialectBase } from '../abstractions/sql-dialect-base.ts';
|
|
5
|
-
|
|
6
|
-
const [migratesDdl, migrateLogsDdl] = await Promise.all([
|
|
7
|
-
readSqlTemplate('mysql', 'migrates'),
|
|
8
|
-
readSqlTemplate('mysql', 'migrate_logs'),
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
export async function applyMysqlDdlViaTextProtocol(
|
|
12
|
-
conn: Pick<MysqlConnection, 'query'>,
|
|
13
|
-
sql: string,
|
|
14
|
-
): Promise<void> {
|
|
15
|
-
// why: DDL statements (DROP/CREATE PROCEDURE) reject prepared-statement protocol (MySQL 1295)
|
|
16
|
-
await conn.query(sql);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class MysqlDialect extends SqlDialectBase {
|
|
20
|
-
readonly name = 'mysql' as const;
|
|
21
|
-
readonly connectionEnvironmentVariables = ['MYSQL_URL', 'DATABASE_URL'] as const;
|
|
22
|
-
readonly migratesDdl = migratesDdl;
|
|
23
|
-
readonly migrateLogsDdl = migrateLogsDdl;
|
|
24
|
-
|
|
25
|
-
quoteIdent(ident: string): string {
|
|
26
|
-
return `\`${ident}\``;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async createClient(connection: string): Promise<MigrationClient> {
|
|
30
|
-
const mysql = await import('mysql2/promise');
|
|
31
|
-
const conn = await mysql.createConnection(connection);
|
|
32
|
-
return {
|
|
33
|
-
dialect: 'mysql',
|
|
34
|
-
async exec(sql) {
|
|
35
|
-
await applyMysqlDdlViaTextProtocol(conn, sql);
|
|
36
|
-
},
|
|
37
|
-
async query(sql, params = []) {
|
|
38
|
-
const [rows] = await conn.execute(sql, params);
|
|
39
|
-
return rows as MigrationRow[];
|
|
40
|
-
},
|
|
41
|
-
async run(sql, params = []) {
|
|
42
|
-
await conn.execute(sql, params);
|
|
43
|
-
},
|
|
44
|
-
async transaction(fn) {
|
|
45
|
-
await conn.beginTransaction();
|
|
46
|
-
try {
|
|
47
|
-
const r = await fn();
|
|
48
|
-
await conn.commit();
|
|
49
|
-
return r;
|
|
50
|
-
} catch (e) {
|
|
51
|
-
try {
|
|
52
|
-
await conn.rollback();
|
|
53
|
-
} catch (rollbackErr) {
|
|
54
|
-
console.warn(
|
|
55
|
-
'mysql rollback failed after transaction error; original error rethrown',
|
|
56
|
-
rollbackErr,
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
throw e;
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
async close() {
|
|
63
|
-
await conn.end();
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const oracle = vi.hoisted(() => {
|
|
4
|
-
const state = {
|
|
5
|
-
failRollback: false,
|
|
6
|
-
closed: false,
|
|
7
|
-
executes: [] as { sql: string; params: unknown[]; opts?: object }[],
|
|
8
|
-
lastConfig: undefined as unknown,
|
|
9
|
-
};
|
|
10
|
-
const conn = {
|
|
11
|
-
async execute(sql: string, params: unknown[] = [], opts?: object) {
|
|
12
|
-
state.executes.push({ sql, params, opts });
|
|
13
|
-
if (sql.includes('NOROWS')) return {};
|
|
14
|
-
return { rows: [{ N: 1, Name: 'x' }] };
|
|
15
|
-
},
|
|
16
|
-
async commit() {},
|
|
17
|
-
async rollback() {
|
|
18
|
-
if (state.failRollback) throw new Error('rollback failed');
|
|
19
|
-
},
|
|
20
|
-
async close() {
|
|
21
|
-
state.closed = true;
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
return {
|
|
25
|
-
state,
|
|
26
|
-
OUT_FORMAT_OBJECT: 4002,
|
|
27
|
-
getConnection: async (cfg: unknown) => {
|
|
28
|
-
state.lastConfig = cfg;
|
|
29
|
-
return conn;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
vi.mock('oracledb', () => ({
|
|
35
|
-
default: {
|
|
36
|
-
getConnection: oracle.getConnection,
|
|
37
|
-
OUT_FORMAT_OBJECT: oracle.OUT_FORMAT_OBJECT,
|
|
38
|
-
},
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
import { OracleDialect, parseOracleConnection } from './oracle-dialect.ts';
|
|
42
|
-
|
|
43
|
-
const dialect = new OracleDialect();
|
|
44
|
-
|
|
45
|
-
describe('parseOracleConnection', () => {
|
|
46
|
-
it('accepts a config object or user/password@connectString', () => {
|
|
47
|
-
const obj = { user: 'u', password: 'p', connectString: 'c' };
|
|
48
|
-
expect(parseOracleConnection(obj)).toBe(obj);
|
|
49
|
-
expect(parseOracleConnection('scott/tiger@localhost/orcl')).toEqual({
|
|
50
|
-
user: 'scott',
|
|
51
|
-
password: 'tiger',
|
|
52
|
-
connectString: 'localhost/orcl',
|
|
53
|
-
});
|
|
54
|
-
expect(() => parseOracleConnection('not-a-dsn')).toThrow(
|
|
55
|
-
/Oracle connection must look like user\/password@connectString/,
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('OracleDialect', () => {
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
oracle.state.failRollback = false;
|
|
63
|
-
oracle.state.closed = false;
|
|
64
|
-
oracle.state.executes = [];
|
|
65
|
-
oracle.state.lastConfig = undefined;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('registers as oracle with :n placeholders and FETCH FIRST', async () => {
|
|
69
|
-
expect(dialect.name).toBe('oracle');
|
|
70
|
-
expect([...dialect.connectionEnvironmentVariables]).toEqual([
|
|
71
|
-
'ORACLE_CONNECT_STRING',
|
|
72
|
-
'DATABASE_URL',
|
|
73
|
-
]);
|
|
74
|
-
expect(dialect.quoteIdent('t')).toBe('"t"');
|
|
75
|
-
expect(dialect.translatePlaceholders('WHERE a = ? AND b = ?')).toBe('WHERE a = :1 AND b = :2');
|
|
76
|
-
expect(dialect.limitClause(5)).toBe('FETCH FIRST 5 ROWS ONLY');
|
|
77
|
-
expect(dialect.migratesDdl).toMatch(/migrates/i);
|
|
78
|
-
expect(dialect.migrateLogsDdl).toMatch(/migrate_logs/i);
|
|
79
|
-
expect(await dialect.prerequisiteError('u/p@c')).toBeNull();
|
|
80
|
-
await dialect.prepareSetup('u/p@c');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('createClient parses the DSN, lowercases row keys, and commits or rolls back', async () => {
|
|
84
|
-
const client = await dialect.createClient('scott/tiger@localhost/orcl');
|
|
85
|
-
expect(client.dialect).toBe('oracle');
|
|
86
|
-
expect(oracle.state.lastConfig).toEqual({
|
|
87
|
-
user: 'scott',
|
|
88
|
-
password: 'tiger',
|
|
89
|
-
connectString: 'localhost/orcl',
|
|
90
|
-
});
|
|
91
|
-
await client.exec('ALTER SESSION SET NLS_DATE_FORMAT = "YYYY"');
|
|
92
|
-
expect(oracle.state.executes[0]?.opts).toEqual({ autoCommit: true });
|
|
93
|
-
expect(await client.query('SELECT ?', [1])).toEqual([{ n: 1, name: 'x' }]);
|
|
94
|
-
expect(await client.query('NOROWS')).toEqual([]);
|
|
95
|
-
expect(oracle.state.executes.some((e) => e.sql === 'SELECT :1')).toBe(true);
|
|
96
|
-
await client.run('UPDATE t SET x = ?', [2]);
|
|
97
|
-
expect(
|
|
98
|
-
oracle.state.executes.some((e) => e.sql === 'UPDATE t SET x = :1' && e.opts && 'autoCommit' in e.opts && e.opts.autoCommit === false),
|
|
99
|
-
).toBe(true);
|
|
100
|
-
expect(await client.transaction(async () => 'ok')).toBe('ok');
|
|
101
|
-
await expect(
|
|
102
|
-
client.transaction(async () => {
|
|
103
|
-
throw new Error('tx');
|
|
104
|
-
}),
|
|
105
|
-
).rejects.toThrow('tx');
|
|
106
|
-
oracle.state.failRollback = true;
|
|
107
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
108
|
-
await expect(
|
|
109
|
-
client.transaction(async () => {
|
|
110
|
-
throw new Error('tx2');
|
|
111
|
-
}),
|
|
112
|
-
).rejects.toThrow('tx2');
|
|
113
|
-
expect(warn).toHaveBeenCalled();
|
|
114
|
-
warn.mockRestore();
|
|
115
|
-
await client.close();
|
|
116
|
-
expect(oracle.state.closed).toBe(true);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import type { MigrationClient, MigrationRow } from '../migration-client.ts';
|
|
2
|
-
import { interopDefault } from '../infrastructure/interop-default.ts';
|
|
3
|
-
import { readSqlTemplate } from '../infrastructure/sql-templates.ts';
|
|
4
|
-
import { SqlDialectBase } from '../abstractions/sql-dialect-base.ts';
|
|
5
|
-
|
|
6
|
-
const [migratesDdl, migrateLogsDdl] = await Promise.all([
|
|
7
|
-
readSqlTemplate('oracle', 'migrates'),
|
|
8
|
-
readSqlTemplate('oracle', 'migrate_logs'),
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
interface OracleConnectionConfig {
|
|
12
|
-
user: string;
|
|
13
|
-
password: string;
|
|
14
|
-
connectString: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const parseOracleConnection = (
|
|
18
|
-
connection: string | OracleConnectionConfig,
|
|
19
|
-
): OracleConnectionConfig => {
|
|
20
|
-
if (typeof connection === 'object' && connection) return connection;
|
|
21
|
-
const m = /^([^/]+)\/([^@]+)@(.+)$/.exec(String(connection));
|
|
22
|
-
if (!m) {
|
|
23
|
-
throw new Error(`Oracle connection must look like user/password@connectString`);
|
|
24
|
-
}
|
|
25
|
-
return { user: m[1] as string, password: m[2] as string, connectString: m[3] as string };
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const lowercaseKeys = (row: MigrationRow): MigrationRow => {
|
|
29
|
-
const out: MigrationRow = {};
|
|
30
|
-
for (const [k, v] of Object.entries(row)) out[k.toLowerCase()] = v;
|
|
31
|
-
return out;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export class OracleDialect extends SqlDialectBase {
|
|
35
|
-
readonly name = 'oracle' as const;
|
|
36
|
-
readonly connectionEnvironmentVariables = ['ORACLE_CONNECT_STRING', 'DATABASE_URL'] as const;
|
|
37
|
-
readonly migratesDdl = migratesDdl;
|
|
38
|
-
readonly migrateLogsDdl = migrateLogsDdl;
|
|
39
|
-
|
|
40
|
-
translatePlaceholders(sql: string): string {
|
|
41
|
-
let n = 0;
|
|
42
|
-
return sql.replace(/\?/g, () => `:${++n}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
limitClause(n: number): string {
|
|
46
|
-
return `FETCH FIRST ${n} ROWS ONLY`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async createClient(connection: string): Promise<MigrationClient> {
|
|
50
|
-
const oracledb = interopDefault(await import('oracledb'));
|
|
51
|
-
const conn = await oracledb.getConnection(parseOracleConnection(connection));
|
|
52
|
-
const translate = (sql: string) => this.translatePlaceholders(sql);
|
|
53
|
-
return {
|
|
54
|
-
dialect: 'oracle',
|
|
55
|
-
async exec(sql) {
|
|
56
|
-
await conn.execute(sql, [], { autoCommit: true });
|
|
57
|
-
},
|
|
58
|
-
async query(sql, params = []) {
|
|
59
|
-
const r = await conn.execute<MigrationRow>(translate(sql), params, {
|
|
60
|
-
outFormat: oracledb.OUT_FORMAT_OBJECT,
|
|
61
|
-
});
|
|
62
|
-
return (r.rows ?? []).map(lowercaseKeys);
|
|
63
|
-
},
|
|
64
|
-
async run(sql, params = []) {
|
|
65
|
-
await conn.execute(translate(sql), params, {
|
|
66
|
-
autoCommit: false,
|
|
67
|
-
});
|
|
68
|
-
},
|
|
69
|
-
async transaction(fn) {
|
|
70
|
-
try {
|
|
71
|
-
const r = await fn();
|
|
72
|
-
await conn.commit();
|
|
73
|
-
return r;
|
|
74
|
-
} catch (e) {
|
|
75
|
-
try {
|
|
76
|
-
await conn.rollback();
|
|
77
|
-
} catch (rollbackErr) {
|
|
78
|
-
console.warn(
|
|
79
|
-
'oracle rollback failed after transaction error; original error rethrown',
|
|
80
|
-
rollbackErr,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
throw e;
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
async close() {
|
|
87
|
-
await conn.close();
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
}
|