@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,87 @@
|
|
|
1
|
+
import type { Request as MssqlRequest } from 'mssql';
|
|
2
|
+
import type { MigrationClient, MigrationSqlParam } from '../migration-client.ts';
|
|
3
|
+
import { interopDefault } from '../infrastructure/interop-default.ts';
|
|
4
|
+
import { readSqlTemplate } from '../infrastructure/sql-templates.ts';
|
|
5
|
+
import { SqlDialectBase } from '../abstractions/sql-dialect-base.ts';
|
|
6
|
+
|
|
7
|
+
const [migratesDdl, migrateLogsDdl] = await Promise.all([
|
|
8
|
+
readSqlTemplate('sqlserver', 'migrates'),
|
|
9
|
+
readSqlTemplate('sqlserver', 'migrate_logs'),
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const bindMssql = (
|
|
13
|
+
req: Pick<MssqlRequest, 'input'>,
|
|
14
|
+
sql: string,
|
|
15
|
+
params: MigrationSqlParam[],
|
|
16
|
+
): string => {
|
|
17
|
+
let n = 0;
|
|
18
|
+
return sql.replace(/\?/g, () => {
|
|
19
|
+
const name = `p${n}`;
|
|
20
|
+
req.input(name, params[n]);
|
|
21
|
+
n++;
|
|
22
|
+
return `@${name}`;
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class SqlServerDialect extends SqlDialectBase {
|
|
27
|
+
readonly name = 'sqlserver' as const;
|
|
28
|
+
readonly connectionEnvironmentVariables = ['MSSQL_URL', 'DATABASE_URL'] as const;
|
|
29
|
+
readonly migratesDdl = migratesDdl;
|
|
30
|
+
readonly migrateLogsDdl = migrateLogsDdl;
|
|
31
|
+
|
|
32
|
+
quoteIdent(ident: string): string {
|
|
33
|
+
return `[${ident}]`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
nowExpr(): string {
|
|
37
|
+
return 'SYSUTCDATETIME()';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
limitClause(n: number): string {
|
|
41
|
+
return `OFFSET 0 ROWS FETCH NEXT ${n} ROWS ONLY`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async createClient(connection: string): Promise<MigrationClient> {
|
|
45
|
+
const mssql = interopDefault(await import('mssql'));
|
|
46
|
+
const pool = await mssql.connect(connection);
|
|
47
|
+
return {
|
|
48
|
+
dialect: 'sqlserver',
|
|
49
|
+
async exec(sql) {
|
|
50
|
+
await pool.request().batch(sql);
|
|
51
|
+
},
|
|
52
|
+
async query(sql, params = []) {
|
|
53
|
+
const req = pool.request();
|
|
54
|
+
const translated = bindMssql(req, sql, params);
|
|
55
|
+
const r = await req.query(translated);
|
|
56
|
+
return r.recordset ?? [];
|
|
57
|
+
},
|
|
58
|
+
async run(sql, params = []) {
|
|
59
|
+
const req = pool.request();
|
|
60
|
+
const translated = bindMssql(req, sql, params);
|
|
61
|
+
await req.query(translated);
|
|
62
|
+
},
|
|
63
|
+
async transaction(fn) {
|
|
64
|
+
const tx = pool.transaction();
|
|
65
|
+
await tx.begin();
|
|
66
|
+
try {
|
|
67
|
+
const r = await fn();
|
|
68
|
+
await tx.commit();
|
|
69
|
+
return r;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
try {
|
|
72
|
+
await tx.rollback();
|
|
73
|
+
} catch (rollbackErr) {
|
|
74
|
+
console.warn(
|
|
75
|
+
'sqlserver rollback failed after transaction error; original error rethrown',
|
|
76
|
+
rollbackErr,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
async close() {
|
|
83
|
+
await pool.close();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { generateHelpFiles, renderHelpTemplates } from './generate-help.ts';
|
|
7
|
+
|
|
8
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
|
+
const CLI_YAML = join(REPO_ROOT, 'cli.yaml');
|
|
10
|
+
const HELP_DIR = join(REPO_ROOT, 'shared', 'templates', 'help');
|
|
11
|
+
|
|
12
|
+
describe('generate-help', () => {
|
|
13
|
+
it('shared/templates/help matches cli.yaml', async () => {
|
|
14
|
+
const generated = renderHelpTemplates(parse(await readFile(CLI_YAML, 'utf8')));
|
|
15
|
+
for (const [verb, text] of Object.entries(generated)) {
|
|
16
|
+
const onDisk = await readFile(join(HELP_DIR, `${verb}.txt`), 'utf8');
|
|
17
|
+
expect(onDisk, verb).toBe(text);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('writes the verb templates', async () => {
|
|
22
|
+
const written = await generateHelpFiles();
|
|
23
|
+
expect(written.map((p) => p.slice(p.lastIndexOf('/') + 1)).sort()).toEqual([
|
|
24
|
+
'bundle.yaml',
|
|
25
|
+
'cli.yaml',
|
|
26
|
+
'create.txt',
|
|
27
|
+
'down.txt',
|
|
28
|
+
'init.txt',
|
|
29
|
+
'setup.txt',
|
|
30
|
+
'up.txt',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
|
|
6
|
+
type HelpArg = {
|
|
7
|
+
flag: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
required: boolean;
|
|
10
|
+
help: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type HelpCommand = {
|
|
14
|
+
verb: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
examples: string;
|
|
17
|
+
show_connection_env?: boolean;
|
|
18
|
+
args: HelpArg[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type HelpSpec = {
|
|
22
|
+
providers: string[];
|
|
23
|
+
connection_environment: Record<string, string[]>;
|
|
24
|
+
commands: HelpCommand[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
28
|
+
const CLI_YAML = join(REPO_ROOT, 'cli.yaml');
|
|
29
|
+
const HELP_DIR = join(REPO_ROOT, 'shared', 'templates', 'help');
|
|
30
|
+
|
|
31
|
+
const requireString = (value: unknown, path: string): string => {
|
|
32
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
33
|
+
throw new Error(`cli.yaml: ${path} must be a non-empty string`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const parseHelpSpec = (raw: unknown): HelpSpec => {
|
|
39
|
+
if (raw === null || typeof raw !== 'object') {
|
|
40
|
+
throw new Error('cli.yaml: root must be a mapping');
|
|
41
|
+
}
|
|
42
|
+
const doc = raw as Record<string, unknown>;
|
|
43
|
+
if (!Array.isArray(doc.providers) || doc.providers.some((p) => typeof p !== 'string')) {
|
|
44
|
+
throw new Error('cli.yaml: providers must be a string list');
|
|
45
|
+
}
|
|
46
|
+
const envRaw = doc.connection_environment;
|
|
47
|
+
if (envRaw === null || typeof envRaw !== 'object' || Array.isArray(envRaw)) {
|
|
48
|
+
throw new Error('cli.yaml: connection_environment must be a mapping');
|
|
49
|
+
}
|
|
50
|
+
const connection_environment: Record<string, string[]> = {};
|
|
51
|
+
for (const [dialect, vars] of Object.entries(envRaw)) {
|
|
52
|
+
if (!Array.isArray(vars) || vars.some((v) => typeof v !== 'string')) {
|
|
53
|
+
throw new Error(`cli.yaml: connection_environment.${dialect} must be a string list`);
|
|
54
|
+
}
|
|
55
|
+
connection_environment[dialect] = vars;
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(doc.commands)) {
|
|
58
|
+
throw new Error('cli.yaml: commands must be a list');
|
|
59
|
+
}
|
|
60
|
+
const commands = doc.commands.map((item, i) => {
|
|
61
|
+
if (item === null || typeof item !== 'object') {
|
|
62
|
+
throw new Error(`cli.yaml: commands[${i}] must be a mapping`);
|
|
63
|
+
}
|
|
64
|
+
const row = item as Record<string, unknown>;
|
|
65
|
+
const verb = requireString(row.verb, `commands[${i}].verb`);
|
|
66
|
+
if (!Array.isArray(row.args)) {
|
|
67
|
+
throw new Error(`cli.yaml: commands.${verb}.args must be a list`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
verb,
|
|
71
|
+
summary: requireString(row.summary, `commands.${verb}.summary`),
|
|
72
|
+
examples: requireString(row.examples, `commands.${verb}.examples`),
|
|
73
|
+
show_connection_env: row.show_connection_env === true,
|
|
74
|
+
args: row.args.map((arg, j) => {
|
|
75
|
+
if (arg === null || typeof arg !== 'object') {
|
|
76
|
+
throw new Error(`cli.yaml: commands.${verb}.args[${j}] must be a mapping`);
|
|
77
|
+
}
|
|
78
|
+
const a = arg as Record<string, unknown>;
|
|
79
|
+
return {
|
|
80
|
+
flag: requireString(a.flag, `commands.${verb}.args[${j}].flag`),
|
|
81
|
+
placeholder: typeof a.placeholder === 'string' ? a.placeholder : undefined,
|
|
82
|
+
required: a.required === true,
|
|
83
|
+
help: requireString(a.help, `commands.${verb}.args[${j}].help`),
|
|
84
|
+
};
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
providers: doc.providers as string[],
|
|
90
|
+
connection_environment,
|
|
91
|
+
commands,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const wrapArgHelp = (flag: string, help: string, flagWidth: number): string => {
|
|
96
|
+
const lines = help.replace(/\s+$/, '').split('\n');
|
|
97
|
+
const indent = ` ${flag.padEnd(flagWidth)} `;
|
|
98
|
+
const cont = ' '.repeat(indent.length);
|
|
99
|
+
return [indent + lines[0], ...lines.slice(1).map((line) => cont + line.trimStart())].join('\n');
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const renderFlags = (args: HelpArg[]): string => {
|
|
103
|
+
const flagWidth = Math.max(...args.map((a) => a.flag.length));
|
|
104
|
+
return args.map((a) => wrapArgHelp(a.flag, a.help, flagWidth)).join('\n');
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderConnectionEnv = (spec: HelpSpec): string => {
|
|
108
|
+
const nameWidth = Math.max(...spec.providers.map((p) => p.length));
|
|
109
|
+
const lines = spec.providers.map((name) => {
|
|
110
|
+
const vars = spec.connection_environment[name];
|
|
111
|
+
if (!vars) {
|
|
112
|
+
throw new Error(`cli.yaml: connection_environment missing ${name}`);
|
|
113
|
+
}
|
|
114
|
+
return ` ${name.padEnd(nameWidth)} ${vars.join(', ')}`;
|
|
115
|
+
});
|
|
116
|
+
return [
|
|
117
|
+
'Per-dialect env vars consulted when --connection is omitted:',
|
|
118
|
+
...lines,
|
|
119
|
+
].join('\n');
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const indentBlock = (text: string): string =>
|
|
123
|
+
text
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((line) => (line.length === 0 ? '' : ` ${line}`))
|
|
126
|
+
.join('\n');
|
|
127
|
+
|
|
128
|
+
export const renderHelpTemplate = (spec: HelpSpec, command: HelpCommand): string => {
|
|
129
|
+
const parts = [
|
|
130
|
+
'{{usage}}',
|
|
131
|
+
'',
|
|
132
|
+
command.summary.replace(/\s+$/, ''),
|
|
133
|
+
'',
|
|
134
|
+
renderFlags(command.args),
|
|
135
|
+
'',
|
|
136
|
+
'Examples:',
|
|
137
|
+
indentBlock(command.examples.replace(/\s+$/, '')),
|
|
138
|
+
];
|
|
139
|
+
if (command.show_connection_env) {
|
|
140
|
+
parts.push('', renderConnectionEnv(spec));
|
|
141
|
+
}
|
|
142
|
+
return `${parts.join('\n')}\n`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const renderHelpTemplates = (raw: unknown): Record<string, string> => {
|
|
146
|
+
const spec = parseHelpSpec(raw);
|
|
147
|
+
return Object.fromEntries(spec.commands.map((c) => [c.verb, renderHelpTemplate(spec, c)]));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const generateHelpFiles = async (): Promise<string[]> => {
|
|
151
|
+
const files = renderHelpTemplates(parse(await readFile(CLI_YAML, 'utf8')));
|
|
152
|
+
const written: string[] = [];
|
|
153
|
+
const packagedDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
154
|
+
const packagedCli = join(packagedDir, 'cli.yaml');
|
|
155
|
+
await writeFile(packagedCli, await readFile(CLI_YAML, 'utf8'));
|
|
156
|
+
written.push(packagedCli);
|
|
157
|
+
const packagedBundle = join(packagedDir, 'bundle.yaml');
|
|
158
|
+
await writeFile(packagedBundle, await readFile(join(REPO_ROOT, 'bundle.yaml'), 'utf8'));
|
|
159
|
+
written.push(packagedBundle);
|
|
160
|
+
for (const [verb, text] of Object.entries(files)) {
|
|
161
|
+
const path = join(HELP_DIR, `${verb}.txt`);
|
|
162
|
+
await writeFile(path, text);
|
|
163
|
+
written.push(path);
|
|
164
|
+
}
|
|
165
|
+
return written;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
169
|
+
const written = await generateHelpFiles();
|
|
170
|
+
for (const path of written) {
|
|
171
|
+
console.log(path);
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/help.test.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
fillHelpTemplate,
|
|
4
|
+
migrateCommand,
|
|
5
|
+
verbFromCommand,
|
|
6
|
+
type MigrateVerb,
|
|
7
|
+
} from './cli-contract.ts';
|
|
8
|
+
import { loadHelp, loadProgramUsage } from './help.ts';
|
|
9
|
+
|
|
10
|
+
const VERBS: MigrateVerb[] = ['setup', 'up', 'down', 'create'];
|
|
11
|
+
|
|
12
|
+
describe('unified help loader', () => {
|
|
13
|
+
it('maps migrate-<verb> onto the shared template file', () => {
|
|
14
|
+
expect(verbFromCommand('migrate-create')).toBe('create');
|
|
15
|
+
expect(migrateCommand('create')).toBe('migrate-create');
|
|
16
|
+
expect(() => verbFromCommand('MigrateRunner')).toThrow(/Unknown migrate command/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('fills {{command}} and {{usage}} from cli.yaml', () => {
|
|
20
|
+
const filled = fillHelpTemplate('{{usage}}\n {{command}} --name x\n', 'migrate-create');
|
|
21
|
+
expect(filled.startsWith('Usage: migrate-create --provider')).toBe(true);
|
|
22
|
+
expect(filled).toContain('migrate-create --name x');
|
|
23
|
+
expect(filled).not.toContain('{{');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('loads program usage with the same migrate-<verb> Usage lines', async () => {
|
|
27
|
+
const text = await loadProgramUsage();
|
|
28
|
+
for (const verb of VERBS) {
|
|
29
|
+
expect(text).toContain(`Usage: ${migrateCommand(verb)} --provider`);
|
|
30
|
+
}
|
|
31
|
+
expect(text).toContain('Usage: migrate-init');
|
|
32
|
+
expect(text).not.toContain('{{command}}');
|
|
33
|
+
expect(text).not.toContain('MigrateRunner');
|
|
34
|
+
expect(text).not.toMatch(/^Usage: (create|up|down|setup) /m);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('loads the same filled help the rust and csharp runners produce', async () => {
|
|
38
|
+
for (const verb of VERBS) {
|
|
39
|
+
const command = migrateCommand(verb);
|
|
40
|
+
const text = await loadHelp(command);
|
|
41
|
+
expect(text.startsWith(`Usage: ${command} --provider`)).toBe(true);
|
|
42
|
+
expect(text).toContain(`${command} --provider sqlite`);
|
|
43
|
+
expect(text).not.toContain('{{command}}');
|
|
44
|
+
expect(text).not.toMatch(/^Usage: (create|up|down|setup) /m);
|
|
45
|
+
expect(text).not.toMatch(/^\s+(create|up|down|setup) --provider/m);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fillHelpTemplate, programUsageText, verbFromCommand } from './cli-contract.ts';
|
|
4
|
+
import { templatesRoot } from './infrastructure/templates-root.ts';
|
|
5
|
+
|
|
6
|
+
export const loadHelp = async (command: string): Promise<string> => {
|
|
7
|
+
const verb = verbFromCommand(command);
|
|
8
|
+
const text = await readFile(join(templatesRoot(), 'help', `${verb}.txt`), 'utf8');
|
|
9
|
+
return fillHelpTemplate(text, command);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const loadProgramUsage = async (): Promise<string> => programUsageText();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from './datasource-migrate.ts';
|
|
2
|
+
export type {
|
|
3
|
+
CreateArg,
|
|
4
|
+
CreateResponse,
|
|
5
|
+
DownArg,
|
|
6
|
+
IMigrater,
|
|
7
|
+
SetupArg,
|
|
8
|
+
UpArg,
|
|
9
|
+
} from './abstractions/migrater.ts';
|
|
10
|
+
export { getMigrater, Migrater } from './migrater.ts';
|
|
11
|
+
export {
|
|
12
|
+
fillHelpTemplate,
|
|
13
|
+
migrateCommand,
|
|
14
|
+
verbFromCommand,
|
|
15
|
+
type MigrateVerb,
|
|
16
|
+
} from './cli-contract.ts';
|
|
17
|
+
export { loadHelp, loadProgramUsage } from './help.ts';
|
|
18
|
+
export {
|
|
19
|
+
fillTemplate,
|
|
20
|
+
loadMessage,
|
|
21
|
+
loadScaffold,
|
|
22
|
+
} from './infrastructure/message-templates.ts';
|
|
23
|
+
export { normalizeDialect, pathExists, type SqlDialect } from './sql.ts';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ISqlDialect } from '../abstractions/sql-dialect.ts';
|
|
2
|
+
|
|
3
|
+
export class ConnectionResolver {
|
|
4
|
+
constructor(private readonly env: NodeJS.ProcessEnv = process.env) {}
|
|
5
|
+
|
|
6
|
+
fromEnvironment(dialect: ISqlDialect): string | null {
|
|
7
|
+
for (const name of dialect.connectionEnvironmentVariables) {
|
|
8
|
+
const value = this.env[name];
|
|
9
|
+
if (value) return value;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
resolve(dialect: ISqlDialect, fromFlag: string | null): string | null {
|
|
15
|
+
if (fromFlag) return fromFlag;
|
|
16
|
+
return this.fromEnvironment(dialect);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defaultDialectFactory } from './default-factory.ts';
|
|
3
|
+
|
|
4
|
+
describe('defaultDialectFactory', () => {
|
|
5
|
+
it('registers every dialect name and alias', () => {
|
|
6
|
+
const names = defaultDialectFactory.all().map((d) => d.name);
|
|
7
|
+
expect(names).toEqual(['sqlite', 'postgres', 'mysql', 'sqlserver', 'oracle']);
|
|
8
|
+
expect(defaultDialectFactory.get('sqlite3').name).toBe('sqlite');
|
|
9
|
+
expect(defaultDialectFactory.get('postgresql').name).toBe('postgres');
|
|
10
|
+
expect(defaultDialectFactory.get('mariadb').name).toBe('mysql');
|
|
11
|
+
expect(defaultDialectFactory.get('mssql').name).toBe('sqlserver');
|
|
12
|
+
expect(defaultDialectFactory.get('mssqlserver').name).toBe('sqlserver');
|
|
13
|
+
expect(defaultDialectFactory.get('ms-sql-server').name).toBe('sqlserver');
|
|
14
|
+
expect(defaultDialectFactory.get('ora').name).toBe('oracle');
|
|
15
|
+
expect(defaultDialectFactory.tryGet('nope')).toBeUndefined();
|
|
16
|
+
expect(() => defaultDialectFactory.get('nope')).toThrow(/Unknown SQL dialect/);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SqlDialectFactory } from '../abstractions/sql-dialect-factory.ts';
|
|
2
|
+
import { MysqlDialect } from '../dialects/mysql-dialect.ts';
|
|
3
|
+
import { OracleDialect } from '../dialects/oracle-dialect.ts';
|
|
4
|
+
import { PostgresDialect } from '../dialects/postgres-dialect.ts';
|
|
5
|
+
import { SqliteDialect } from '../dialects/sqlite-dialect.ts';
|
|
6
|
+
import { SqlServerDialect } from '../dialects/sqlserver-dialect.ts';
|
|
7
|
+
import { ConnectionResolver } from './connection-resolver.ts';
|
|
8
|
+
|
|
9
|
+
export const defaultDialectFactory = new SqlDialectFactory([
|
|
10
|
+
new SqliteDialect(),
|
|
11
|
+
new PostgresDialect(),
|
|
12
|
+
new MysqlDialect(),
|
|
13
|
+
new SqlServerDialect(),
|
|
14
|
+
new OracleDialect(),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const connectionResolver = new ConnectionResolver();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { interopDefault } from './interop-default.ts';
|
|
3
|
+
|
|
4
|
+
describe('interopDefault', () => {
|
|
5
|
+
it('prefers default when present and falls back to the module namespace', () => {
|
|
6
|
+
expect(interopDefault({ default: { connect: 1 } })).toEqual({ connect: 1 });
|
|
7
|
+
expect(interopDefault({ connect: 2 })).toEqual({ connect: 2 });
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { templatesRoot } from './templates-root.ts';
|
|
4
|
+
|
|
5
|
+
export const fillTemplate = (text: string, vars: Record<string, string>): string => {
|
|
6
|
+
let out = text;
|
|
7
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
8
|
+
out = out.replaceAll(`{{${key}}}`, value);
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const loadMessage = async (
|
|
14
|
+
relativePath: string,
|
|
15
|
+
vars: Record<string, string> = {},
|
|
16
|
+
): Promise<string> => {
|
|
17
|
+
const text = await readFile(
|
|
18
|
+
join(templatesRoot(), 'messages', `${relativePath}.txt`),
|
|
19
|
+
'utf8',
|
|
20
|
+
);
|
|
21
|
+
return fillTemplate(text, vars);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const loadScaffold = async (
|
|
25
|
+
name: 'up' | 'down',
|
|
26
|
+
vars: Record<string, string>,
|
|
27
|
+
): Promise<string> => {
|
|
28
|
+
const text = await readFile(join(templatesRoot(), 'scaffold', `${name}.sql`), 'utf8');
|
|
29
|
+
return fillTemplate(text, vars);
|
|
30
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const GO_LINE = /^\s*GO\s*$/i;
|
|
2
|
+
|
|
3
|
+
/** Split migration SQL on whole-line GO batch separators. */
|
|
4
|
+
export function splitOnGo(text: string): string[] {
|
|
5
|
+
const batches: string[] = [];
|
|
6
|
+
let buf = '';
|
|
7
|
+
for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
|
|
8
|
+
if (GO_LINE.test(line)) {
|
|
9
|
+
const s = buf.trim();
|
|
10
|
+
if (s.length > 0) batches.push(s);
|
|
11
|
+
buf = '';
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
buf = buf.length > 0 ? `${buf}\n${line}` : line;
|
|
15
|
+
}
|
|
16
|
+
const tail = buf.trim();
|
|
17
|
+
if (tail.length > 0) batches.push(tail);
|
|
18
|
+
return batches;
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { SqlDialect } from '../sql.ts';
|
|
4
|
+
import { templatesRoot } from './templates-root.ts';
|
|
5
|
+
|
|
6
|
+
export const readSqlTemplate = async (
|
|
7
|
+
dialect: SqlDialect,
|
|
8
|
+
name: 'migrates' | 'migrate_logs',
|
|
9
|
+
): Promise<string> =>
|
|
10
|
+
(await readFile(join(templatesRoot(), 'sql', dialect, `${name}.sql`), 'utf8')).trim();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
6
|
+
/** Repo `shared/templates`; after build, `dist/templates` (copied from shared). */
|
|
7
|
+
export const templatesRoot = (): string => {
|
|
8
|
+
const normalized = HERE.replaceAll('\\', '/');
|
|
9
|
+
if (normalized.endsWith('/dist/infrastructure')) {
|
|
10
|
+
return join(HERE, '..', 'templates');
|
|
11
|
+
}
|
|
12
|
+
return join(HERE, '..', '..', '..', 'shared', 'templates');
|
|
13
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { loadSelectedPackages, resolveInitDialects } from './init-select.ts';
|
|
3
|
+
|
|
4
|
+
describe('init dialect selection', () => {
|
|
5
|
+
it('accepts aliases and keeps cli.yaml order', () => {
|
|
6
|
+
expect(resolveInitDialects(['ora', 'pg'], false)).toEqual(['postgres', 'oracle']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('restores every provider for --all', () => {
|
|
10
|
+
expect(resolveInitDialects([], true)).toEqual([
|
|
11
|
+
'sqlite',
|
|
12
|
+
'postgres',
|
|
13
|
+
'mysql',
|
|
14
|
+
'sqlserver',
|
|
15
|
+
'oracle',
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reads typescript packages from bundle.yaml', async () => {
|
|
20
|
+
const selected = await loadSelectedPackages(['sqlite', 'postgres']);
|
|
21
|
+
expect(selected.packages.map((p) => p.name).sort()).toEqual(['better-sqlite3', 'pg']);
|
|
22
|
+
expect(selected.allDriverPackages).toEqual(
|
|
23
|
+
expect.arrayContaining(['better-sqlite3', 'pg', 'mysql2', 'mssql', 'oracledb']),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parse } from 'yaml';
|
|
6
|
+
import { cliSpec } from './cli-spec.ts';
|
|
7
|
+
|
|
8
|
+
export type SelectedDialects = {
|
|
9
|
+
names: string[];
|
|
10
|
+
packages: { name: string; version: string }[];
|
|
11
|
+
allDriverPackages: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const stripKey = (raw: string): string => raw.replace(/[\s_-]/g, '').toLowerCase();
|
|
15
|
+
|
|
16
|
+
export const normalizeInitProvider = (raw: string): string | undefined => {
|
|
17
|
+
const key = stripKey(raw);
|
|
18
|
+
const direct = cliSpec.providers.find((p) => stripKey(p) === key);
|
|
19
|
+
if (direct) return direct;
|
|
20
|
+
return cliSpec.provider_aliases[key];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const resolveInitDialects = (raw: string[], all: boolean): string[] => {
|
|
24
|
+
if (all) return [...cliSpec.providers];
|
|
25
|
+
if (raw.length === 0) {
|
|
26
|
+
throw new Error('pass --dialect <provider> at least once, or --all');
|
|
27
|
+
}
|
|
28
|
+
const selected = new Set<string>();
|
|
29
|
+
for (const value of raw) {
|
|
30
|
+
const name = normalizeInitProvider(value);
|
|
31
|
+
if (!name) {
|
|
32
|
+
throw new Error(`unsupported provider: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
selected.add(name);
|
|
35
|
+
}
|
|
36
|
+
return cliSpec.providers.filter((name) => selected.has(name));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type BundleDialect = {
|
|
40
|
+
packages?: { name: string; version: string }[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type BundleDoc = {
|
|
44
|
+
languages?: {
|
|
45
|
+
typescript?: {
|
|
46
|
+
dialects?: Record<string, BundleDialect>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const bundleYamlPath = (): string => {
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const candidates = [
|
|
54
|
+
join(here, '..', 'bundle.yaml'),
|
|
55
|
+
join(here, '..', '..', 'bundle.yaml'),
|
|
56
|
+
join(process.cwd(), 'bundle.yaml'),
|
|
57
|
+
];
|
|
58
|
+
const found = candidates.find((candidate) => existsSync(candidate));
|
|
59
|
+
if (!found) {
|
|
60
|
+
throw new Error('bundle.yaml not found');
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const loadSelectedPackages = async (dialects: string[]): Promise<SelectedDialects> => {
|
|
66
|
+
const raw = parse(await readFile(bundleYamlPath(), 'utf8')) as BundleDoc;
|
|
67
|
+
const byName = raw.languages?.typescript?.dialects ?? {};
|
|
68
|
+
const allDriverPackages = [
|
|
69
|
+
...new Set(Object.values(byName).flatMap((d) => (d.packages ?? []).map((p) => p.name))),
|
|
70
|
+
];
|
|
71
|
+
const packages = dialects.flatMap((name) => byName[name]?.packages ?? []);
|
|
72
|
+
return { names: dialects, packages, allDriverPackages };
|
|
73
|
+
};
|