@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.
Files changed (162) hide show
  1. package/bundle.yaml +262 -0
  2. package/dist/abstractions/migrater.d.ts +28 -0
  3. package/dist/abstractions/migrater.js +1 -0
  4. package/dist/abstractions/sql-dialect-base.d.ts +16 -0
  5. package/dist/abstractions/sql-dialect-base.js +19 -0
  6. package/dist/abstractions/sql-dialect-factory.d.ts +8 -0
  7. package/dist/abstractions/sql-dialect-factory.js +22 -0
  8. package/dist/abstractions/sql-dialect.d.ts +16 -0
  9. package/dist/abstractions/sql-dialect.js +1 -0
  10. package/dist/bin/migrate-create.d.ts +2 -0
  11. package/dist/bin/migrate-create.js +71 -0
  12. package/dist/bin/migrate-down.d.ts +2 -0
  13. package/dist/bin/migrate-down.js +135 -0
  14. package/dist/bin/migrate-init.d.ts +2 -0
  15. package/dist/bin/migrate-init.js +77 -0
  16. package/dist/bin/migrate-setup.d.ts +2 -0
  17. package/dist/bin/migrate-setup.js +87 -0
  18. package/dist/bin/migrate-up.d.ts +2 -0
  19. package/dist/bin/migrate-up.js +97 -0
  20. package/dist/cli-contract.d.ts +6 -0
  21. package/dist/cli-contract.js +19 -0
  22. package/dist/cli-spec.d.ts +24 -0
  23. package/dist/cli-spec.js +33 -0
  24. package/dist/cli.d.ts +9 -0
  25. package/dist/cli.js +41 -0
  26. package/dist/create-sequence.d.ts +4 -0
  27. package/dist/create-sequence.js +16 -0
  28. package/dist/datasource-migrate.d.ts +31 -0
  29. package/dist/datasource-migrate.js +206 -0
  30. package/dist/dialects/mysql-dialect.d.ts +12 -0
  31. package/dist/dialects/mysql-dialect.js +56 -0
  32. package/dist/dialects/oracle-dialect.d.ts +18 -0
  33. package/dist/dialects/oracle-dialect.js +76 -0
  34. package/dist/dialects/postgres-dialect.d.ts +11 -0
  35. package/dist/dialects/postgres-dialect.js +58 -0
  36. package/dist/dialects/sqlite-dialect.d.ts +13 -0
  37. package/dist/dialects/sqlite-dialect.js +96 -0
  38. package/dist/dialects/sqlserver-dialect.d.ts +12 -0
  39. package/dist/dialects/sqlserver-dialect.js +73 -0
  40. package/dist/help.d.ts +2 -0
  41. package/dist/help.js +10 -0
  42. package/dist/index.d.ts +7 -0
  43. package/dist/index.js +6 -0
  44. package/dist/infrastructure/connection-resolver.d.ts +7 -0
  45. package/dist/infrastructure/connection-resolver.js +19 -0
  46. package/dist/infrastructure/default-factory.d.ts +4 -0
  47. package/dist/infrastructure/default-factory.js +15 -0
  48. package/dist/infrastructure/interop-default.d.ts +3 -0
  49. package/dist/infrastructure/interop-default.js +1 -0
  50. package/dist/infrastructure/message-templates.d.ts +3 -0
  51. package/dist/infrastructure/message-templates.js +18 -0
  52. package/dist/infrastructure/split-on-go.d.ts +2 -0
  53. package/dist/infrastructure/split-on-go.js +20 -0
  54. package/dist/infrastructure/sql-templates.d.ts +2 -0
  55. package/dist/infrastructure/sql-templates.js +4 -0
  56. package/dist/infrastructure/templates-root.d.ts +2 -0
  57. package/dist/infrastructure/templates-root.js +11 -0
  58. package/dist/init-select.d.ts +12 -0
  59. package/dist/init-select.js +52 -0
  60. package/dist/migrater.d.ts +12 -0
  61. package/dist/migrater.js +84 -0
  62. package/dist/migration-client.d.ts +21 -0
  63. package/dist/migration-client.js +1 -0
  64. package/dist/path-exists.d.ts +1 -0
  65. package/dist/path-exists.js +10 -0
  66. package/dist/sql.d.ts +3 -0
  67. package/dist/sql.js +15 -0
  68. package/dist/templates/help/create.txt +16 -0
  69. package/dist/templates/help/down.txt +42 -0
  70. package/dist/templates/help/init.txt +13 -0
  71. package/dist/templates/help/setup.txt +41 -0
  72. package/dist/templates/help/up.txt +43 -0
  73. package/dist/templates/messages/checksum-drift-line.txt +1 -0
  74. package/dist/templates/messages/checksum-drift.txt +11 -0
  75. package/dist/templates/messages/down/confirm-mismatch.txt +1 -0
  76. package/dist/templates/messages/down/confirm-prompt.txt +1 -0
  77. package/dist/templates/messages/down/confirm-stdin-failed.txt +1 -0
  78. package/dist/templates/messages/down/confirm-token-mismatch.txt +1 -0
  79. package/dist/templates/messages/down/confirm-tty-required.txt +1 -0
  80. package/dist/templates/messages/down/destructive-header.txt +3 -0
  81. package/dist/templates/messages/errors/invalid-name.txt +1 -0
  82. package/dist/templates/messages/errors/migrate-path-not-dir.txt +1 -0
  83. package/dist/templates/messages/errors/missing-connection.txt +1 -0
  84. package/dist/templates/messages/errors/missing-name-hint.txt +1 -0
  85. package/dist/templates/messages/errors/missing-name.txt +1 -0
  86. package/dist/templates/messages/errors/missing-provider-hint.txt +1 -0
  87. package/dist/templates/messages/errors/missing-provider.txt +1 -0
  88. package/dist/templates/messages/errors/missing-value-for-flag.txt +1 -0
  89. package/dist/templates/messages/errors/rollback-no-down-sibling.txt +1 -0
  90. package/dist/templates/messages/errors/rollback-no-rollback-sibling.txt +1 -0
  91. package/dist/templates/messages/errors/rollback-not-in-path.txt +1 -0
  92. package/dist/templates/messages/errors/sqlite-prerequisite.txt +1 -0
  93. package/dist/templates/messages/errors/unknown-arg.txt +1 -0
  94. package/dist/templates/messages/errors/unsupported-provider.txt +1 -0
  95. package/dist/templates/messages/setup-complete.txt +9 -0
  96. package/dist/templates/messages/status/applied.txt +1 -0
  97. package/dist/templates/messages/status/created.txt +1 -0
  98. package/dist/templates/messages/status/no-more-pending.txt +1 -0
  99. package/dist/templates/messages/status/no-pending.txt +1 -0
  100. package/dist/templates/messages/status/no-rollback.txt +1 -0
  101. package/dist/templates/messages/status/rolled-back.txt +1 -0
  102. package/dist/templates/scaffold/down.sql +1 -0
  103. package/dist/templates/scaffold/up.sql +1 -0
  104. package/dist/templates/sql/mysql/migrate_logs.sql +11 -0
  105. package/dist/templates/sql/mysql/migrates.sql +7 -0
  106. package/dist/templates/sql/oracle/migrate_logs.sql +14 -0
  107. package/dist/templates/sql/oracle/migrates.sql +10 -0
  108. package/dist/templates/sql/postgres/migrate_logs.sql +11 -0
  109. package/dist/templates/sql/postgres/migrates.sql +7 -0
  110. package/dist/templates/sql/sqlite/migrate_logs.sql +11 -0
  111. package/dist/templates/sql/sqlite/migrates.sql +7 -0
  112. package/dist/templates/sql/sqlserver/migrate_logs.sql +12 -0
  113. package/dist/templates/sql/sqlserver/migrates.sql +8 -0
  114. package/package.json +78 -4
  115. package/src/abstractions/migrater.ts +13 -0
  116. package/src/abstractions/sql-dialect-base.ts +36 -0
  117. package/src/abstractions/sql-dialect-factory.ts +29 -0
  118. package/src/abstractions/sql-dialect.ts +17 -0
  119. package/src/bin/migrate-create.ts +80 -0
  120. package/src/bin/migrate-down.ts +163 -0
  121. package/src/bin/migrate-init.ts +88 -0
  122. package/src/bin/migrate-setup.ts +98 -0
  123. package/src/bin/migrate-up.ts +111 -0
  124. package/src/cli-contract.ts +25 -0
  125. package/src/cli-spec.ts +64 -0
  126. package/src/cli.ts +52 -0
  127. package/src/create-sequence.ts +17 -0
  128. package/src/datasource-migrate.ts +314 -0
  129. package/src/dialects/mysql-dialect.test.ts +93 -0
  130. package/src/dialects/mysql-dialect.ts +67 -0
  131. package/src/dialects/oracle-dialect.test.ts +118 -0
  132. package/src/dialects/oracle-dialect.ts +91 -0
  133. package/src/dialects/postgres-dialect.test.ts +80 -0
  134. package/src/dialects/postgres-dialect.ts +65 -0
  135. package/src/dialects/sqlite-dialect.rollback.test.ts +30 -0
  136. package/src/dialects/sqlite-dialect.test.ts +77 -0
  137. package/src/dialects/sqlite-dialect.ts +96 -0
  138. package/src/dialects/sqlserver-dialect.test.ts +99 -0
  139. package/src/dialects/sqlserver-dialect.ts +87 -0
  140. package/src/generate-help.test.ts +33 -0
  141. package/src/generate-help.ts +173 -0
  142. package/src/help.test.ts +48 -0
  143. package/src/help.ts +12 -0
  144. package/src/index.ts +23 -0
  145. package/src/infrastructure/connection-resolver.ts +18 -0
  146. package/src/infrastructure/default-factory.test.ts +18 -0
  147. package/src/infrastructure/default-factory.ts +17 -0
  148. package/src/infrastructure/interop-default.test.ts +9 -0
  149. package/src/infrastructure/interop-default.ts +2 -0
  150. package/src/infrastructure/message-templates.ts +30 -0
  151. package/src/infrastructure/split-on-go.ts +19 -0
  152. package/src/infrastructure/sql-templates.ts +10 -0
  153. package/src/infrastructure/templates-root.ts +13 -0
  154. package/src/init-select.test.ts +26 -0
  155. package/src/init-select.ts +73 -0
  156. package/src/migrater.ts +92 -0
  157. package/src/migration-client.ts +21 -0
  158. package/src/path-exists.ts +10 -0
  159. package/src/setup-sql.test.ts +16 -0
  160. package/src/sql.ts +17 -0
  161. package/tsconfig.build.json +19 -0
  162. package/README.md +0 -3
@@ -0,0 +1,64 @@
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
+
7
+ export type MigrateVerb = 'init' | 'setup' | 'up' | 'down' | 'create';
8
+
9
+ export type CliArg = {
10
+ flag: string;
11
+ placeholder?: string;
12
+ required: boolean;
13
+ };
14
+
15
+ export type CliCommand = {
16
+ verb: MigrateVerb;
17
+ args: CliArg[];
18
+ };
19
+
20
+ export type CliSpec = {
21
+ command: string;
22
+ providers: string[];
23
+ provider_aliases: Record<string, string>;
24
+ aliases: Record<string, string>;
25
+ env_var_list_separator: string;
26
+ connection_environment: Record<string, string[]>;
27
+ commands: CliCommand[];
28
+ };
29
+
30
+ export const cliYamlPath = (): string => {
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ const candidates = [
33
+ join(here, '..', 'cli.yaml'),
34
+ join(here, '..', '..', 'cli.yaml'),
35
+ join(process.cwd(), 'cli.yaml'),
36
+ ];
37
+ const found = candidates.find((candidate) => existsSync(candidate));
38
+ if (!found) {
39
+ throw new Error('cli.yaml not found');
40
+ }
41
+ return found;
42
+ };
43
+
44
+ export const cliSpec = parse(await readFile(cliYamlPath(), 'utf8')) as CliSpec;
45
+
46
+ export const migrateCommand = (verb: MigrateVerb): string =>
47
+ cliSpec.command.replace('{verb}', verb);
48
+
49
+ const providers = cliSpec.providers.join('|');
50
+
51
+ const argUsage = (arg: CliArg): string => {
52
+ const ph = arg.placeholder === 'providers' ? providers : arg.placeholder;
53
+ const body = ph ? `${arg.flag} <${ph}>` : arg.flag;
54
+ return arg.required ? body : `[${body}]`;
55
+ };
56
+
57
+ export const usageLine = (verb: MigrateVerb): string => {
58
+ const command = cliSpec.commands.find((c) => c.verb === verb);
59
+ if (!command) throw new Error(`cli.yaml: missing command ${verb}`);
60
+ return `Usage: ${migrateCommand(verb)} ${command.args.map(argUsage).join(' ')}`;
61
+ };
62
+
63
+ export const programUsageText = (): string =>
64
+ `${cliSpec.commands.map((c) => usageLine(c.verb)).join('\n')}\n`;
package/src/cli.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { connectionResolver, defaultDialectFactory } from './infrastructure/default-factory.ts';
2
+ import type { ISqlDialect } from './abstractions/sql-dialect.ts';
3
+ import { cliSpec } from './cli-spec.ts';
4
+ import { fillTemplate, loadMessage } from './infrastructure/message-templates.ts';
5
+
6
+ const missingProviderMsg = await loadMessage('errors/missing-provider');
7
+ const unsupportedProviderTpl = await loadMessage('errors/unsupported-provider');
8
+ const missingConnectionTpl = await loadMessage('errors/missing-connection');
9
+
10
+ export { missingProviderMsg };
11
+
12
+ export const requireDialect = (provider: string | null): ISqlDialect => {
13
+ if (!provider) {
14
+ process.stderr.write(missingProviderMsg);
15
+ process.exit(2);
16
+ }
17
+ const dialect = defaultDialectFactory.tryGet(provider);
18
+ if (!dialect) {
19
+ process.stderr.write(fillTemplate(unsupportedProviderTpl, { provider }));
20
+ process.exit(2);
21
+ }
22
+ return dialect;
23
+ };
24
+
25
+ export const requireConnection = (dialect: ISqlDialect, fromFlag: string | null): string => {
26
+ const connection = connectionResolver.resolve(dialect, fromFlag);
27
+ if (!connection) {
28
+ process.stderr.write(
29
+ fillTemplate(missingConnectionTpl, {
30
+ envVars: dialect.connectionEnvironmentVariables.join(cliSpec.env_var_list_separator),
31
+ }),
32
+ );
33
+ process.exit(2);
34
+ }
35
+ return connection;
36
+ };
37
+
38
+ export const takeFlag = (
39
+ rest: string[],
40
+ i: number,
41
+ arg: string,
42
+ flag: string,
43
+ ): { value: string; next: number } | undefined => {
44
+ const eq = `${flag}=`;
45
+ if (arg === flag) {
46
+ const value = rest[i + 1];
47
+ if (value === undefined) return undefined;
48
+ return { value, next: i + 1 };
49
+ }
50
+ if (arg.startsWith(eq)) return { value: arg.slice(eq.length), next: i };
51
+ return undefined;
52
+ };
@@ -0,0 +1,17 @@
1
+ export const MIGRATION_NAME_RE = /^[a-z][a-z0-9_]*$/;
2
+ const SEQ_RE = /^(\d{1,4})_.*_up\.sql$/;
3
+
4
+ export const isMigrationName = (name: string): boolean => MIGRATION_NAME_RE.test(name);
5
+
6
+ export const nextSequence = (filenames: Iterable<string>): number => {
7
+ let max = 0;
8
+ for (const name of filenames) {
9
+ const m = SEQ_RE.exec(name);
10
+ if (!m) continue;
11
+ const n = Number.parseInt(m[1] as string, 10);
12
+ if (n > max) max = n;
13
+ }
14
+ return max + 1;
15
+ };
16
+
17
+ export const pad4 = (n: number): string => String(n).padStart(4, '0');
@@ -0,0 +1,314 @@
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
+ }
@@ -0,0 +1,93 @@
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
+ });
@@ -0,0 +1,67 @@
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
+ }