@deterministic-code/migraters 0.0.0-stage → 0.0.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/bundle.yaml +262 -0
- package/dist/abstractions/migrater.d.ts +28 -0
- package/dist/abstractions/migrater.js +1 -0
- package/dist/abstractions/sql-dialect-base.d.ts +16 -0
- package/dist/abstractions/sql-dialect-base.js +19 -0
- package/dist/abstractions/sql-dialect-factory.d.ts +8 -0
- package/dist/abstractions/sql-dialect-factory.js +22 -0
- package/dist/abstractions/sql-dialect.d.ts +16 -0
- package/dist/abstractions/sql-dialect.js +1 -0
- package/dist/bin/migrate-create.d.ts +2 -0
- package/dist/bin/migrate-create.js +71 -0
- package/dist/bin/migrate-down.d.ts +2 -0
- package/dist/bin/migrate-down.js +135 -0
- package/dist/bin/migrate-init.d.ts +2 -0
- package/dist/bin/migrate-init.js +77 -0
- package/dist/bin/migrate-setup.d.ts +2 -0
- package/dist/bin/migrate-setup.js +87 -0
- package/dist/bin/migrate-up.d.ts +2 -0
- package/dist/bin/migrate-up.js +97 -0
- package/dist/cli-contract.d.ts +6 -0
- package/dist/cli-contract.js +19 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +33 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +41 -0
- package/dist/create-sequence.d.ts +4 -0
- package/dist/create-sequence.js +16 -0
- package/dist/datasource-migrate.d.ts +31 -0
- package/dist/datasource-migrate.js +206 -0
- package/dist/dialects/mysql-dialect.d.ts +12 -0
- package/dist/dialects/mysql-dialect.js +56 -0
- package/dist/dialects/oracle-dialect.d.ts +18 -0
- package/dist/dialects/oracle-dialect.js +76 -0
- package/dist/dialects/postgres-dialect.d.ts +11 -0
- package/dist/dialects/postgres-dialect.js +58 -0
- package/dist/dialects/sqlite-dialect.d.ts +13 -0
- package/dist/dialects/sqlite-dialect.js +96 -0
- package/dist/dialects/sqlserver-dialect.d.ts +12 -0
- package/dist/dialects/sqlserver-dialect.js +73 -0
- package/dist/help.d.ts +2 -0
- package/dist/help.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/infrastructure/connection-resolver.d.ts +7 -0
- package/dist/infrastructure/connection-resolver.js +19 -0
- package/dist/infrastructure/default-factory.d.ts +4 -0
- package/dist/infrastructure/default-factory.js +15 -0
- package/dist/infrastructure/interop-default.d.ts +3 -0
- package/dist/infrastructure/interop-default.js +1 -0
- package/dist/infrastructure/message-templates.d.ts +3 -0
- package/dist/infrastructure/message-templates.js +18 -0
- package/dist/infrastructure/split-on-go.d.ts +2 -0
- package/dist/infrastructure/split-on-go.js +20 -0
- package/dist/infrastructure/sql-templates.d.ts +2 -0
- package/dist/infrastructure/sql-templates.js +4 -0
- package/dist/infrastructure/templates-root.d.ts +2 -0
- package/dist/infrastructure/templates-root.js +11 -0
- package/dist/init-select.d.ts +12 -0
- package/dist/init-select.js +52 -0
- package/dist/migrater.d.ts +12 -0
- package/dist/migrater.js +84 -0
- package/dist/migration-client.d.ts +21 -0
- package/dist/migration-client.js +1 -0
- package/dist/path-exists.d.ts +1 -0
- package/dist/path-exists.js +10 -0
- package/dist/sql.d.ts +3 -0
- package/dist/sql.js +15 -0
- package/dist/templates/help/create.txt +16 -0
- package/dist/templates/help/down.txt +42 -0
- package/dist/templates/help/init.txt +13 -0
- package/dist/templates/help/setup.txt +41 -0
- package/dist/templates/help/up.txt +43 -0
- package/dist/templates/messages/checksum-drift-line.txt +1 -0
- package/dist/templates/messages/checksum-drift.txt +11 -0
- package/dist/templates/messages/down/confirm-mismatch.txt +1 -0
- package/dist/templates/messages/down/confirm-prompt.txt +1 -0
- package/dist/templates/messages/down/confirm-stdin-failed.txt +1 -0
- package/dist/templates/messages/down/confirm-token-mismatch.txt +1 -0
- package/dist/templates/messages/down/confirm-tty-required.txt +1 -0
- package/dist/templates/messages/down/destructive-header.txt +3 -0
- package/dist/templates/messages/errors/invalid-name.txt +1 -0
- package/dist/templates/messages/errors/migrate-path-not-dir.txt +1 -0
- package/dist/templates/messages/errors/missing-connection.txt +1 -0
- package/dist/templates/messages/errors/missing-name-hint.txt +1 -0
- package/dist/templates/messages/errors/missing-name.txt +1 -0
- package/dist/templates/messages/errors/missing-provider-hint.txt +1 -0
- package/dist/templates/messages/errors/missing-provider.txt +1 -0
- package/dist/templates/messages/errors/missing-value-for-flag.txt +1 -0
- package/dist/templates/messages/errors/rollback-no-down-sibling.txt +1 -0
- package/dist/templates/messages/errors/rollback-no-rollback-sibling.txt +1 -0
- package/dist/templates/messages/errors/rollback-not-in-path.txt +1 -0
- package/dist/templates/messages/errors/sqlite-prerequisite.txt +1 -0
- package/dist/templates/messages/errors/unknown-arg.txt +1 -0
- package/dist/templates/messages/errors/unsupported-provider.txt +1 -0
- package/dist/templates/messages/setup-complete.txt +9 -0
- package/dist/templates/messages/status/applied.txt +1 -0
- package/dist/templates/messages/status/created.txt +1 -0
- package/dist/templates/messages/status/no-more-pending.txt +1 -0
- package/dist/templates/messages/status/no-pending.txt +1 -0
- package/dist/templates/messages/status/no-rollback.txt +1 -0
- package/dist/templates/messages/status/rolled-back.txt +1 -0
- package/dist/templates/scaffold/down.sql +1 -0
- package/dist/templates/scaffold/up.sql +1 -0
- package/dist/templates/sql/mysql/migrate_logs.sql +11 -0
- package/dist/templates/sql/mysql/migrates.sql +7 -0
- package/dist/templates/sql/oracle/migrate_logs.sql +14 -0
- package/dist/templates/sql/oracle/migrates.sql +10 -0
- package/dist/templates/sql/postgres/migrate_logs.sql +11 -0
- package/dist/templates/sql/postgres/migrates.sql +7 -0
- package/dist/templates/sql/sqlite/migrate_logs.sql +11 -0
- package/dist/templates/sql/sqlite/migrates.sql +7 -0
- package/dist/templates/sql/sqlserver/migrate_logs.sql +12 -0
- package/dist/templates/sql/sqlserver/migrates.sql +8 -0
- package/package.json +78 -4
- package/src/abstractions/migrater.ts +13 -0
- package/src/abstractions/sql-dialect-base.ts +36 -0
- package/src/abstractions/sql-dialect-factory.ts +29 -0
- package/src/abstractions/sql-dialect.ts +17 -0
- package/src/bin/migrate-create.ts +80 -0
- package/src/bin/migrate-down.ts +163 -0
- package/src/bin/migrate-init.ts +88 -0
- package/src/bin/migrate-setup.ts +98 -0
- package/src/bin/migrate-up.ts +111 -0
- package/src/cli-contract.ts +25 -0
- package/src/cli-spec.ts +64 -0
- package/src/cli.ts +52 -0
- package/src/create-sequence.ts +17 -0
- package/src/datasource-migrate.ts +314 -0
- package/src/dialects/mysql-dialect.test.ts +93 -0
- package/src/dialects/mysql-dialect.ts +67 -0
- package/src/dialects/oracle-dialect.test.ts +118 -0
- package/src/dialects/oracle-dialect.ts +91 -0
- package/src/dialects/postgres-dialect.test.ts +80 -0
- package/src/dialects/postgres-dialect.ts +65 -0
- package/src/dialects/sqlite-dialect.rollback.test.ts +30 -0
- package/src/dialects/sqlite-dialect.test.ts +77 -0
- package/src/dialects/sqlite-dialect.ts +96 -0
- package/src/dialects/sqlserver-dialect.test.ts +99 -0
- package/src/dialects/sqlserver-dialect.ts +87 -0
- package/src/generate-help.test.ts +33 -0
- package/src/generate-help.ts +173 -0
- package/src/help.test.ts +48 -0
- package/src/help.ts +12 -0
- package/src/index.ts +23 -0
- package/src/infrastructure/connection-resolver.ts +18 -0
- package/src/infrastructure/default-factory.test.ts +18 -0
- package/src/infrastructure/default-factory.ts +17 -0
- package/src/infrastructure/interop-default.test.ts +9 -0
- package/src/infrastructure/interop-default.ts +2 -0
- package/src/infrastructure/message-templates.ts +30 -0
- package/src/infrastructure/split-on-go.ts +19 -0
- package/src/infrastructure/sql-templates.ts +10 -0
- package/src/infrastructure/templates-root.ts +13 -0
- package/src/init-select.test.ts +26 -0
- package/src/init-select.ts +73 -0
- package/src/migrater.ts +92 -0
- package/src/migration-client.ts +21 -0
- package/src/path-exists.ts +10 -0
- package/src/setup-sql.test.ts +16 -0
- package/src/sql.ts +17 -0
- package/tsconfig.build.json +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const pg = vi.hoisted(() => {
|
|
4
|
+
const state = {
|
|
5
|
+
failRollback: false,
|
|
6
|
+
ended: false,
|
|
7
|
+
queries: [] as { sql: string; params?: unknown[] }[],
|
|
8
|
+
};
|
|
9
|
+
class Client {
|
|
10
|
+
constructor(public readonly opts: { connectionString: string }) {}
|
|
11
|
+
async connect() {}
|
|
12
|
+
async query(sql: string, params?: unknown[]) {
|
|
13
|
+
state.queries.push({ sql, params });
|
|
14
|
+
if (sql === 'ROLLBACK' && state.failRollback) throw new Error('rollback failed');
|
|
15
|
+
return { rows: [{ n: 1 }] };
|
|
16
|
+
}
|
|
17
|
+
async end() {
|
|
18
|
+
state.ended = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { Client, state };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('pg', () => ({ default: { Client: pg.Client } }));
|
|
25
|
+
|
|
26
|
+
import { PostgresDialect } from './postgres-dialect.ts';
|
|
27
|
+
|
|
28
|
+
const dialect = new PostgresDialect();
|
|
29
|
+
|
|
30
|
+
describe('PostgresDialect', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
pg.state.failRollback = false;
|
|
33
|
+
pg.state.ended = false;
|
|
34
|
+
pg.state.queries = [];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('registers as postgres with NOW() and $n placeholders', async () => {
|
|
38
|
+
expect(dialect.name).toBe('postgres');
|
|
39
|
+
expect([...dialect.connectionEnvironmentVariables]).toEqual([
|
|
40
|
+
'PG_CONNECTION_STRING',
|
|
41
|
+
'DATABASE_URL',
|
|
42
|
+
]);
|
|
43
|
+
expect(dialect.usesLastInsertRowid).toBe(false);
|
|
44
|
+
expect(dialect.nowExpr()).toBe('NOW()');
|
|
45
|
+
expect(dialect.translatePlaceholders('WHERE a = ? AND b = ?')).toBe('WHERE a = $1 AND b = $2');
|
|
46
|
+
expect(dialect.migratesDdl).toMatch(/migrates/i);
|
|
47
|
+
expect(dialect.migrateLogsDdl).toMatch(/migrate_logs/i);
|
|
48
|
+
expect(await dialect.prerequisiteError('pg://x')).toBeNull();
|
|
49
|
+
await dialect.prepareSetup('pg://x');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('createClient talks through pg.Client with translated placeholders', async () => {
|
|
53
|
+
const client = await dialect.createClient('postgresql://localhost/db');
|
|
54
|
+
expect(client.dialect).toBe('postgres');
|
|
55
|
+
await client.exec('SELECT 1');
|
|
56
|
+
expect(await client.query('SELECT ?', [1])).toEqual([{ n: 1 }]);
|
|
57
|
+
expect(pg.state.queries.some((q) => q.sql === 'SELECT $1' && q.params?.[0] === 1)).toBe(true);
|
|
58
|
+
await client.run('UPDATE t SET x = ?', [2]);
|
|
59
|
+
expect(await client.transaction(async () => 'ok')).toBe('ok');
|
|
60
|
+
expect(pg.state.queries.map((q) => q.sql)).toContain('BEGIN');
|
|
61
|
+
expect(pg.state.queries.map((q) => q.sql)).toContain('COMMIT');
|
|
62
|
+
await expect(
|
|
63
|
+
client.transaction(async () => {
|
|
64
|
+
throw new Error('tx');
|
|
65
|
+
}),
|
|
66
|
+
).rejects.toThrow('tx');
|
|
67
|
+
expect(pg.state.queries.map((q) => q.sql)).toContain('ROLLBACK');
|
|
68
|
+
pg.state.failRollback = true;
|
|
69
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
70
|
+
await expect(
|
|
71
|
+
client.transaction(async () => {
|
|
72
|
+
throw new Error('tx2');
|
|
73
|
+
}),
|
|
74
|
+
).rejects.toThrow('tx2');
|
|
75
|
+
expect(warn).toHaveBeenCalled();
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
await client.close();
|
|
78
|
+
expect(pg.state.ended).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MigrationClient } from '../migration-client.ts';
|
|
2
|
+
import { readSqlTemplate } from '../infrastructure/sql-templates.ts';
|
|
3
|
+
import { SqlDialectBase } from '../abstractions/sql-dialect-base.ts';
|
|
4
|
+
|
|
5
|
+
const [migratesDdl, migrateLogsDdl] = await Promise.all([
|
|
6
|
+
readSqlTemplate('postgres', 'migrates'),
|
|
7
|
+
readSqlTemplate('postgres', 'migrate_logs'),
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export class PostgresDialect extends SqlDialectBase {
|
|
11
|
+
readonly name = 'postgres' as const;
|
|
12
|
+
readonly connectionEnvironmentVariables = ['PG_CONNECTION_STRING', 'DATABASE_URL'] as const;
|
|
13
|
+
readonly migratesDdl = migratesDdl;
|
|
14
|
+
readonly migrateLogsDdl = migrateLogsDdl;
|
|
15
|
+
|
|
16
|
+
nowExpr(): string {
|
|
17
|
+
return 'NOW()';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
translatePlaceholders(sql: string): string {
|
|
21
|
+
let n = 0;
|
|
22
|
+
return sql.replace(/\?/g, () => `$${++n}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async createClient(connection: string): Promise<MigrationClient> {
|
|
26
|
+
const { default: pg } = await import('pg');
|
|
27
|
+
const client = new pg.Client({ connectionString: connection });
|
|
28
|
+
await client.connect();
|
|
29
|
+
const translate = (sql: string) => this.translatePlaceholders(sql);
|
|
30
|
+
return {
|
|
31
|
+
dialect: 'postgres',
|
|
32
|
+
async exec(sql) {
|
|
33
|
+
await client.query(sql);
|
|
34
|
+
},
|
|
35
|
+
async query(sql, params = []) {
|
|
36
|
+
const r = await client.query(translate(sql), params);
|
|
37
|
+
return r.rows;
|
|
38
|
+
},
|
|
39
|
+
async run(sql, params = []) {
|
|
40
|
+
await client.query(translate(sql), params);
|
|
41
|
+
},
|
|
42
|
+
async transaction(fn) {
|
|
43
|
+
await client.query('BEGIN');
|
|
44
|
+
try {
|
|
45
|
+
const r = await fn();
|
|
46
|
+
await client.query('COMMIT');
|
|
47
|
+
return r;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
try {
|
|
50
|
+
await client.query('ROLLBACK');
|
|
51
|
+
} catch (rollbackErr) {
|
|
52
|
+
console.warn(
|
|
53
|
+
'postgres ROLLBACK failed after transaction error; original error rethrown',
|
|
54
|
+
rollbackErr,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
await client.end();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('better-sqlite3', () => ({
|
|
4
|
+
default: class {
|
|
5
|
+
exec(sql: string) {
|
|
6
|
+
if (sql === 'ROLLBACK') throw new Error('rollback failed');
|
|
7
|
+
}
|
|
8
|
+
prepare() {
|
|
9
|
+
return { all() { return []; }, run() {} };
|
|
10
|
+
}
|
|
11
|
+
close() {}
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { SqliteDialect } from './sqlite-dialect.ts';
|
|
16
|
+
|
|
17
|
+
describe('SqliteDialect rollback failure', () => {
|
|
18
|
+
it('warns when ROLLBACK itself fails and still rethrows the original error', async () => {
|
|
19
|
+
const client = await new SqliteDialect().createClient(':memory:');
|
|
20
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
21
|
+
await expect(
|
|
22
|
+
client.transaction(async () => {
|
|
23
|
+
throw new Error('tx');
|
|
24
|
+
}),
|
|
25
|
+
).rejects.toThrow('tx');
|
|
26
|
+
expect(warn).toHaveBeenCalled();
|
|
27
|
+
warn.mockRestore();
|
|
28
|
+
await client.close();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { SqliteDialect, sqliteFilesystemPath } from './sqlite-dialect.ts';
|
|
6
|
+
|
|
7
|
+
const dialect = new SqliteDialect();
|
|
8
|
+
|
|
9
|
+
describe('SqliteDialect', () => {
|
|
10
|
+
it('registers as sqlite with SQLITE_PATH / DB_PATH and last_insert_rowid', () => {
|
|
11
|
+
expect(dialect.name).toBe('sqlite');
|
|
12
|
+
expect([...dialect.connectionEnvironmentVariables]).toEqual(['SQLITE_PATH', 'DB_PATH']);
|
|
13
|
+
expect(dialect.usesLastInsertRowid).toBe(true);
|
|
14
|
+
expect(dialect.migratesDdl).toMatch(/migrates/i);
|
|
15
|
+
expect(dialect.migrateLogsDdl).toMatch(/migrate_logs/i);
|
|
16
|
+
expect(dialect.quoteIdent('t')).toBe('"t"');
|
|
17
|
+
expect(dialect.nowExpr()).toBe('CURRENT_TIMESTAMP');
|
|
18
|
+
expect(dialect.limitClause(3)).toBe('LIMIT 3');
|
|
19
|
+
expect(dialect.translatePlaceholders('? ?')).toBe('? ?');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses filesystem paths from every supported connection form', () => {
|
|
23
|
+
expect(sqliteFilesystemPath(':memory:')).toBeNull();
|
|
24
|
+
expect(sqliteFilesystemPath('')).toBeNull();
|
|
25
|
+
expect(sqliteFilesystemPath(' :memory: ')).toBeNull();
|
|
26
|
+
expect(sqliteFilesystemPath('./app.sqlite')).toBe('./app.sqlite');
|
|
27
|
+
expect(sqliteFilesystemPath('sqlite:///abs/app.sqlite')).toBe('/abs/app.sqlite');
|
|
28
|
+
expect(sqliteFilesystemPath('sqlite://rel.sqlite')).toBe('rel.sqlite');
|
|
29
|
+
expect(sqliteFilesystemPath('sqlite:foo.db')).toBe('foo.db');
|
|
30
|
+
expect(sqliteFilesystemPath('file:bar.db')).toBe('bar.db');
|
|
31
|
+
expect(sqliteFilesystemPath('Data Source=./from-ado.sqlite')).toBe('./from-ado.sqlite');
|
|
32
|
+
expect(sqliteFilesystemPath('data source = /tmp/x.sqlite')).toBe('/tmp/x.sqlite');
|
|
33
|
+
expect(sqliteFilesystemPath('sqlite://')).toBeNull();
|
|
34
|
+
expect(sqliteFilesystemPath('file:')).toBeNull();
|
|
35
|
+
expect(sqliteFilesystemPath('other=value.sqlite')).toBe('other=value.sqlite');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('prerequisiteError is null for memory, existing files, and missing-path-null', async () => {
|
|
39
|
+
expect(await dialect.prerequisiteError(':memory:')).toBeNull();
|
|
40
|
+
const dir = await mkdtemp(join(tmpdir(), 'sqlite-dialect-'));
|
|
41
|
+
const file = join(dir, 'exists.sqlite');
|
|
42
|
+
await writeFile(file, '');
|
|
43
|
+
expect(await dialect.prerequisiteError(file)).toBeNull();
|
|
44
|
+
const missing = join(dir, 'missing.sqlite');
|
|
45
|
+
const err = await dialect.prerequisiteError(missing);
|
|
46
|
+
expect(err).toContain(missing);
|
|
47
|
+
expect(err).toMatch(/migrate-setup --provider sqlite/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('prepareSetup creates parent directories for file connections and no-ops memory', async () => {
|
|
51
|
+
const dir = await mkdtemp(join(tmpdir(), 'sqlite-setup-'));
|
|
52
|
+
const file = join(dir, 'nested', 'db', 'app.sqlite');
|
|
53
|
+
await dialect.prepareSetup(file);
|
|
54
|
+
const { pathExists } = await import('../path-exists.ts');
|
|
55
|
+
expect(await pathExists(join(dir, 'nested', 'db'))).toBe(true);
|
|
56
|
+
await dialect.prepareSetup(':memory:');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('createClient rejects an empty path and runs exec/query/run/transaction on :memory:', async () => {
|
|
60
|
+
await expect(dialect.createClient('')).rejects.toThrow(/sqlite requires a database file path/);
|
|
61
|
+
const client = await dialect.createClient(':memory:');
|
|
62
|
+
expect(client.dialect).toBe('sqlite');
|
|
63
|
+
await client.exec('CREATE TABLE t (id INTEGER)');
|
|
64
|
+
await client.run('INSERT INTO t (id) VALUES (?)', [1]);
|
|
65
|
+
const viaQueryWrite = await client.query('INSERT INTO t (id) VALUES (?)', [2]);
|
|
66
|
+
expect(viaQueryWrite).toEqual([]);
|
|
67
|
+
expect(await client.query('SELECT id FROM t ORDER BY id')).toEqual([{ id: 1 }, { id: 2 }]);
|
|
68
|
+
const tx = await client.transaction(async () => 7);
|
|
69
|
+
expect(tx).toBe(7);
|
|
70
|
+
await expect(
|
|
71
|
+
client.transaction(async () => {
|
|
72
|
+
throw new Error('boom');
|
|
73
|
+
}),
|
|
74
|
+
).rejects.toThrow('boom');
|
|
75
|
+
await client.close();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { MigrationClient, MigrationRow, MigrationSqlParam } from '../migration-client.ts';
|
|
4
|
+
import { pathExists } from '../path-exists.ts';
|
|
5
|
+
import { readSqlTemplate } from '../infrastructure/sql-templates.ts';
|
|
6
|
+
import { fillTemplate, loadMessage } from '../infrastructure/message-templates.ts';
|
|
7
|
+
import { SqlDialectBase } from '../abstractions/sql-dialect-base.ts';
|
|
8
|
+
|
|
9
|
+
const [migratesDdl, migrateLogsDdl, sqlitePrerequisiteTpl] = await Promise.all([
|
|
10
|
+
readSqlTemplate('sqlite', 'migrates'),
|
|
11
|
+
readSqlTemplate('sqlite', 'migrate_logs'),
|
|
12
|
+
loadMessage('errors/sqlite-prerequisite'),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export const sqliteFilesystemPath = (connection: string): string | null => {
|
|
16
|
+
let s = connection.trim();
|
|
17
|
+
const eq = s.indexOf('=');
|
|
18
|
+
if (eq >= 0 && /data\s*source/i.test(s.slice(0, eq))) {
|
|
19
|
+
s = s.slice(eq + 1).trim();
|
|
20
|
+
} else if (/^sqlite:\/\//i.test(s)) {
|
|
21
|
+
s = s.slice('sqlite://'.length);
|
|
22
|
+
} else if (/^sqlite:/i.test(s)) {
|
|
23
|
+
s = s.slice('sqlite:'.length);
|
|
24
|
+
} else if (/^file:/i.test(s)) {
|
|
25
|
+
s = s.slice('file:'.length);
|
|
26
|
+
}
|
|
27
|
+
if (s === ':memory:' || s === '') return null;
|
|
28
|
+
return s;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class SqliteDialect extends SqlDialectBase {
|
|
32
|
+
readonly name = 'sqlite' as const;
|
|
33
|
+
readonly connectionEnvironmentVariables = ['SQLITE_PATH', 'DB_PATH'] as const;
|
|
34
|
+
readonly migratesDdl = migratesDdl;
|
|
35
|
+
readonly migrateLogsDdl = migrateLogsDdl;
|
|
36
|
+
override readonly usesLastInsertRowid = true;
|
|
37
|
+
|
|
38
|
+
async prerequisiteError(connection: string): Promise<string | null> {
|
|
39
|
+
const path = sqliteFilesystemPath(connection);
|
|
40
|
+
if (path === null) return null;
|
|
41
|
+
if (await pathExists(path)) return null;
|
|
42
|
+
return fillTemplate(sqlitePrerequisiteTpl, { path });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async prepareSetup(connection: string): Promise<void> {
|
|
46
|
+
const filePath = sqliteFilesystemPath(connection);
|
|
47
|
+
if (filePath !== null) {
|
|
48
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async createClient(connection: string): Promise<MigrationClient> {
|
|
53
|
+
if (!connection) throw new Error('sqlite requires a database file path');
|
|
54
|
+
const { default: Database } = await import('better-sqlite3');
|
|
55
|
+
const db = new Database(connection);
|
|
56
|
+
return {
|
|
57
|
+
dialect: 'sqlite',
|
|
58
|
+
async exec(sql) {
|
|
59
|
+
db.exec(sql);
|
|
60
|
+
},
|
|
61
|
+
async query(sql, params = []) {
|
|
62
|
+
const stmt = db.prepare<MigrationSqlParam[], MigrationRow>(sql);
|
|
63
|
+
try {
|
|
64
|
+
return stmt.all(...params);
|
|
65
|
+
} catch {
|
|
66
|
+
stmt.run(...params);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async run(sql, params = []) {
|
|
71
|
+
db.prepare(sql).run(...params);
|
|
72
|
+
},
|
|
73
|
+
async transaction(fn) {
|
|
74
|
+
db.exec('BEGIN');
|
|
75
|
+
try {
|
|
76
|
+
const r = await fn();
|
|
77
|
+
db.exec('COMMIT');
|
|
78
|
+
return r;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
try {
|
|
81
|
+
db.exec('ROLLBACK');
|
|
82
|
+
} catch (rollbackErr) {
|
|
83
|
+
console.warn(
|
|
84
|
+
'sqlite ROLLBACK failed after transaction error; original error rethrown',
|
|
85
|
+
rollbackErr,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async close() {
|
|
92
|
+
db.close();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mssql = vi.hoisted(() => {
|
|
4
|
+
const state = {
|
|
5
|
+
failRollback: false,
|
|
6
|
+
closed: false,
|
|
7
|
+
batches: [] as string[],
|
|
8
|
+
queries: [] as string[],
|
|
9
|
+
inputs: [] as { name: string; value: unknown }[],
|
|
10
|
+
};
|
|
11
|
+
const request = () => ({
|
|
12
|
+
input(name: string, value: unknown) {
|
|
13
|
+
state.inputs.push({ name, value });
|
|
14
|
+
},
|
|
15
|
+
async query(sql: string) {
|
|
16
|
+
state.queries.push(sql);
|
|
17
|
+
if (sql.includes('empty')) return {};
|
|
18
|
+
return { recordset: [{ n: 1 }] };
|
|
19
|
+
},
|
|
20
|
+
async batch(sql: string) {
|
|
21
|
+
state.batches.push(sql);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const transaction = () => ({
|
|
25
|
+
async begin() {},
|
|
26
|
+
async commit() {},
|
|
27
|
+
async rollback() {
|
|
28
|
+
if (state.failRollback) throw new Error('rollback failed');
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const pool = {
|
|
32
|
+
request,
|
|
33
|
+
transaction,
|
|
34
|
+
async close() {
|
|
35
|
+
state.closed = true;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
state,
|
|
40
|
+
connect: async () => pool,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock('mssql', () => ({ default: { connect: mssql.connect } }));
|
|
45
|
+
|
|
46
|
+
import { SqlServerDialect } from './sqlserver-dialect.ts';
|
|
47
|
+
|
|
48
|
+
const dialect = new SqlServerDialect();
|
|
49
|
+
|
|
50
|
+
describe('SqlServerDialect', () => {
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
mssql.state.failRollback = false;
|
|
53
|
+
mssql.state.closed = false;
|
|
54
|
+
mssql.state.batches = [];
|
|
55
|
+
mssql.state.queries = [];
|
|
56
|
+
mssql.state.inputs = [];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('registers as sqlserver with bracket quotes, SYSUTCDATETIME, and OFFSET/FETCH', async () => {
|
|
60
|
+
expect(dialect.name).toBe('sqlserver');
|
|
61
|
+
expect([...dialect.connectionEnvironmentVariables]).toEqual(['MSSQL_URL', 'DATABASE_URL']);
|
|
62
|
+
expect(dialect.quoteIdent('t')).toBe('[t]');
|
|
63
|
+
expect(dialect.nowExpr()).toBe('SYSUTCDATETIME()');
|
|
64
|
+
expect(dialect.limitClause(4)).toBe('OFFSET 0 ROWS FETCH NEXT 4 ROWS ONLY');
|
|
65
|
+
expect(dialect.migratesDdl).toMatch(/migrates/i);
|
|
66
|
+
expect(dialect.migrateLogsDdl).toMatch(/migrate_logs/i);
|
|
67
|
+
expect(await dialect.prerequisiteError('mssql://x')).toBeNull();
|
|
68
|
+
await dialect.prepareSetup('mssql://x');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('createClient binds ? as @pN and handles empty recordsets', async () => {
|
|
72
|
+
const client = await dialect.createClient('Server=localhost');
|
|
73
|
+
expect(client.dialect).toBe('sqlserver');
|
|
74
|
+
await client.exec('CREATE TABLE t (id INT)');
|
|
75
|
+
expect(mssql.state.batches).toContain('CREATE TABLE t (id INT)');
|
|
76
|
+
expect(await client.query('SELECT ?', [7])).toEqual([{ n: 1 }]);
|
|
77
|
+
expect(mssql.state.queries).toContain('SELECT @p0');
|
|
78
|
+
expect(mssql.state.inputs).toContainEqual({ name: 'p0', value: 7 });
|
|
79
|
+
expect(await client.query('empty')).toEqual([]);
|
|
80
|
+
await client.run('UPDATE t SET x = ?', [8]);
|
|
81
|
+
expect(await client.transaction(async () => 'ok')).toBe('ok');
|
|
82
|
+
await expect(
|
|
83
|
+
client.transaction(async () => {
|
|
84
|
+
throw new Error('tx');
|
|
85
|
+
}),
|
|
86
|
+
).rejects.toThrow('tx');
|
|
87
|
+
mssql.state.failRollback = true;
|
|
88
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
89
|
+
await expect(
|
|
90
|
+
client.transaction(async () => {
|
|
91
|
+
throw new Error('tx2');
|
|
92
|
+
}),
|
|
93
|
+
).rejects.toThrow('tx2');
|
|
94
|
+
expect(warn).toHaveBeenCalled();
|
|
95
|
+
warn.mockRestore();
|
|
96
|
+
await client.close();
|
|
97
|
+
expect(mssql.state.closed).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|