mysqlsyncr 1.1.12 → 1.2.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 (62) hide show
  1. package/README.md +59 -0
  2. package/__tests__/acceptCurrent.test.js +117 -0
  3. package/__tests__/columnAttributes.test.js +101 -0
  4. package/__tests__/options.test.js +259 -0
  5. package/__tests__/parser.test.js +167 -166
  6. package/__tests__/parser2.test.js +155 -152
  7. package/__tests__/paths.test.js +21 -0
  8. package/__tests__/prompt.test.js +106 -0
  9. package/__tests__/sync.test.js +348 -0
  10. package/__tests__/templateDrift.test.js +72 -0
  11. package/__tests__/versions.test.js +138 -0
  12. package/dist/index.cjs +4059 -1733
  13. package/dist/index.js +4101 -1777
  14. package/package.json +66 -59
  15. package/rollup.config.js +2 -1
  16. package/src/DBStructureChecker.js +47 -0
  17. package/src/acceptCurrent.js +225 -0
  18. package/src/apply.js +254 -136
  19. package/src/cli.js +61 -34
  20. package/src/compare.js +106 -92
  21. package/src/differences.js +258 -212
  22. package/src/dump.js +63 -54
  23. package/src/getters.js +151 -139
  24. package/src/index.js +223 -169
  25. package/src/loggers.js +156 -139
  26. package/src/normalizers.js +28 -6
  27. package/src/options.js +133 -0
  28. package/src/parsers.js +82 -36
  29. package/src/paths.js +41 -0
  30. package/src/prompt.js +109 -0
  31. package/src/rebuild.js +106 -0
  32. package/src/rows.js +46 -0
  33. package/src/socketServer.js +106 -24
  34. package/src/templateDrift.js +93 -0
  35. package/src/versions.js +112 -0
  36. package/ui/README.md +55 -40
  37. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  38. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  39. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  40. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  41. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  42. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  43. package/ui/dist/spa/assets/MainLayout-DLH1Kfg-.js +1 -0
  44. package/ui/dist/spa/assets/axios-2hQ_j_Tz.js +1 -0
  45. package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  46. package/ui/dist/spa/assets/index-Cqrg985P.js +23 -0
  47. package/ui/dist/spa/assets/index-pOWtXlXQ.css +1 -0
  48. package/ui/dist/spa/index.html +2 -2
  49. package/ui/package.json +43 -42
  50. package/ui/src/components/DefinitionDiff.vue +169 -0
  51. package/ui/src/pages/MainComponent.vue +704 -78
  52. package/ui/dist/spa/assets/KFOkCnqEu92Fr1MmgVxIIzQ-Y2mpUUkw.woff +0 -0
  53. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmEU9fBBc--ViM7Ag4Q.woff +0 -0
  54. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmSU5fBBc--CrFzpgl6.woff +0 -0
  55. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmWUlfBBc--CW3_XjG_.woff +0 -0
  56. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmYUtfBBc--Dr8fnyGc.woff +0 -0
  57. package/ui/dist/spa/assets/KFOmCnqEu92Fr1Mu4mxM-DAYh6T4l.woff +0 -0
  58. package/ui/dist/spa/assets/MainLayout-CvQD3xKr.js +0 -1
  59. package/ui/dist/spa/assets/axios-C0XcclIb.js +0 -1
  60. package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  61. package/ui/dist/spa/assets/index-CwWo8xmP.js +0 -40
  62. package/ui/dist/spa/assets/index-kXpA8n8r.css +0 -5
package/src/apply.js CHANGED
@@ -1,6 +1,167 @@
1
1
  import { logger } from './loggers.js';
2
+ import { adaptSQLForServer, getServerVersion, portableCollation } from './versions.js';
2
3
 
4
+ const quoteString = (value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
5
+
6
+ // Types MySQL won't give a literal default
7
+ const noLiteralDefaultRegex = /^(tiny|medium|long)?(text|blob)$|^json$|^geometry|^point$|^linestring$|^polygon$/i;
8
+
9
+ const defaultClause = (field) => {
10
+ const value = field.Default;
11
+ if (value === undefined || value === null || value === 'NULL') {
12
+ // A NOT NULL column can't default to NULL; leave it without a default
13
+ return field.NotNull ? '' : 'DEFAULT NULL';
14
+ }
15
+ // Functions, expressions, and bit/hex literals such as b'0' are written as they were read
16
+ if (/^(CURRENT_TIMESTAMP|now)(\(\d*\))?$/i.test(value) || value.startsWith('(') || /^[bx]'[0-9a-f]*'$/i.test(value) || /^0x[0-9a-f]+$/i.test(value)) {
17
+ return `DEFAULT ${value}`;
18
+ }
19
+ if (noLiteralDefaultRegex.test(field.Type)) {
20
+ return '';
21
+ }
22
+ return `DEFAULT ${quoteString(value)}`;
23
+ }
24
+
25
+ // Template value for a column detail, or the live column's when the template doesn't record it
26
+ const columnDetail = (field, currentField, key) => key in field ? field[key] : currentField?.[key];
27
+
28
+ /**
29
+ * Full column definition for ADD/CHANGE, e.g.
30
+ * timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
31
+ * NULL/NOT NULL is always explicit: without it a timestamp is NOT NULL when
32
+ * explicit_defaults_for_timestamp is OFF, and DEFAULT NULL then fails.
33
+ * Charset, collation, comment, ON UPDATE and a generated column's expression
34
+ * come from the template when it has them and are otherwise kept from the live
35
+ * column, since CHANGE drops anything not restated.
36
+ */
37
+ export const columnDefinition = (field, currentField) => {
38
+ const charset = columnDetail(field, currentField, 'Charset');
39
+ const collate = columnDetail(field, currentField, 'Collate');
40
+ const onUpdate = columnDetail(field, currentField, 'OnUpdate');
41
+ const comment = columnDetail(field, currentField, 'Comment');
42
+ const generated = columnDetail(field, currentField, 'Generated');
43
+ if (generated) {
44
+ // A generated column takes no DEFAULT, ON UPDATE or AUTO_INCREMENT
45
+ return [
46
+ field.Type,
47
+ charset ? `CHARACTER SET ${charset}` : '',
48
+ collate ? `COLLATE ${collate}` : '',
49
+ `GENERATED ALWAYS AS ${generated}`,
50
+ field.NotNull ? 'NOT NULL' : 'NULL',
51
+ comment ? `COMMENT ${quoteString(comment)}` : ''
52
+ ].filter(Boolean).join(' ');
53
+ }
54
+ return [
55
+ field.Type,
56
+ charset ? `CHARACTER SET ${charset}` : '',
57
+ collate ? `COLLATE ${collate}` : '',
58
+ field.NotNull ? 'NOT NULL' : 'NULL',
59
+ defaultClause(field),
60
+ onUpdate ? `ON UPDATE ${onUpdate}` : '',
61
+ field.AutoIncrement ? 'AUTO_INCREMENT' : '',
62
+ comment ? `COMMENT ${quoteString(comment)}` : ''
63
+ ].filter(Boolean).join(' ');
64
+ }
65
+
66
+ // `col`, or `col`(255) for a prefix index on a text column
67
+ const formatIndexColumns = (columns) => columns.map(col => {
68
+ const match = col.match(/^`?([^`(]+)`?(?:\((\d+)\))?$/);
69
+ if (!match) return `\`${col}\``;
70
+ return `\`${match[1]}\`${match[2] ? `(${match[2]})` : ''}`;
71
+ }).join(', ');
72
+
73
+ const isPrimaryIndex = (index) => !!index.Primary || index.Name === 'PRIMARY';
74
+
75
+ const addIndexClause = (index) => isPrimaryIndex(index)
76
+ ? `ADD PRIMARY KEY (${formatIndexColumns(index.ColumnName)})`
77
+ : `ADD ${index.Unique ? 'UNIQUE ' : ''}INDEX \`${index.Name}\` (${formatIndexColumns(index.ColumnName)})`;
78
+
79
+ const dropIndexClause = (index) => isPrimaryIndex(index) ? 'DROP PRIMARY KEY' : `DROP INDEX \`${index.Name}\``;
80
+
81
+ const describeDifference = (diff) => {
82
+ const name = diff.tableName || diff.viewName || diff.Name || diff.database || '';
83
+ const detail = diff.field?.Field || diff.indexName || diff.trigger?.Name || (diff.key ? JSON.stringify(diff.key) : '');
84
+ return `${diff.type} ${name}${detail ? '.' + detail : ''}`;
85
+ }
86
+
87
+ // Rewrites one collation in a CREATE TABLE, both as a table option (COLLATE=x) and as a
88
+ // column attribute (COLLATE x). Passing null for `to` removes the clause entirely.
89
+ const replaceCollation = (sql, from, to) => {
90
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ return sql
92
+ .replace(new RegExp('COLLATE(\\s*)=(\\s*)' + escaped, 'gi'), to ? `COLLATE$1=$2${to}` : '')
93
+ .replace(new RegExp('COLLATE(\\s+)' + escaped, 'gi'), to ? `COLLATE$1${to}` : '');
94
+ };
95
+
96
+ // Rewrites that let DDL dumped from a newer server run on an older one. Each inspects the
97
+ // error the server actually returned and returns the amended SQL plus what changed, or
98
+ // null when it has nothing to offer - so a rewrite only ever fires for the failure it
99
+ // was written for, and anything else still surfaces as a failure. These catch what
100
+ // adaptSQLForServer can't anticipate, e.g. when the server's version couldn't be read.
101
+ const compatRewrites = [
102
+ (sql, errorMessage) => {
103
+ const unknown = /Unknown collation: '([^']+)'/i.exec(errorMessage);
104
+ if (!unknown) return null;
105
+ const substitute = portableCollation(unknown[1]);
106
+ const rewritten = replaceCollation(sql, unknown[1], substitute);
107
+ if (rewritten === sql) return null;
108
+ return {
109
+ sql: rewritten,
110
+ message: `server does not support collation ${unknown[1]}, created with ${substitute || 'the charset default'} instead`,
111
+ };
112
+ },
113
+ (sql, errorMessage) => {
114
+ // Index visibility is MySQL 8.0.23+. Only strip the keyword where it is legal -
115
+ // directly after an index definition's closing parenthesis - so the word cannot
116
+ // be removed from a column comment or a column named `visible`.
117
+ if (!/error in your SQL syntax/i.test(errorMessage) || !/\b(?:IN)?VISIBLE\b/i.test(errorMessage)) return null;
118
+ const rewritten = sql.replace(/\)(\s+)(?:IN)?VISIBLE(?=\s*[,)\r\n])/g, ')');
119
+ if (rewritten === sql) return null;
120
+ return {
121
+ sql: rewritten,
122
+ message: 'server does not support index visibility, created with every index visible',
123
+ };
124
+ },
125
+ ];
126
+
127
+ /**
128
+ * Apply the differences in order. A failing difference doesn't stop the rest.
129
+ * Returns { attempted, failures, warnings }: failures are the differences that
130
+ * couldn't be applied, warnings the ones applied with a change for this server
131
+ * (e.g. an 8.0 collation on 5.7), each as { type, tableName, description, message }.
132
+ */
3
133
  export const applyDifferences = async (connection, database, differences) => {
134
+ const failures = [];
135
+ const warnings = [];
136
+ let current = null; // the difference being applied, for warnings from query()
137
+ const warn = (diff, message) => {
138
+ console.warn(`Applied ${describeDifference(diff)} with a change: ${message}`);
139
+ warnings.push({ type: diff.type, tableName: diff.tableName, description: describeDifference(diff), message });
140
+ };
141
+
142
+ // Template SQL from MySQL 8.0 can name collations a 5.7 server doesn't have
143
+ const serverVersion = await getServerVersion(connection);
144
+ const query = async (sql, values) => {
145
+ const adapted = adaptSQLForServer(sql, serverVersion);
146
+ if (adapted !== sql) {
147
+ console.log(`Adapted for MySQL ${serverVersion}: ${adapted}`);
148
+ if (current) warn(current, `MySQL ${serverVersion} has no utf8mb4_0900 collations, so the closest one it has was used`);
149
+ }
150
+ return connection.query(adapted, values);
151
+ };
152
+
153
+ // A dump written by MySQL 8 assumes explicit_defaults_for_timestamp is on, where a
154
+ // `timestamp NOT NULL` column with no DEFAULT is just a column with no default. With
155
+ // the MySQL 5.7 default of off, the server instead assigns every TIMESTAMP NOT NULL
156
+ // column after the first an implicit '0000-00-00 00:00:00', which strict mode then
157
+ // rejects at CREATE TABLE time. Matching MySQL 8 for this session applies that DDL as
158
+ // written; DDL that states its defaults - everything SHOW CREATE TABLE emits, on
159
+ // either version - parses the same way with the setting on or off.
160
+ try {
161
+ await connection.query('SET SESSION explicit_defaults_for_timestamp = 1');
162
+ } catch (e) {
163
+ logger(`Could not set explicit_defaults_for_timestamp for this session: ${e.message}`);
164
+ }
4
165
 
5
166
  differences.sort((a, b) => {
6
167
  //Sort to make sure we apply the differences in a logical order.
@@ -21,7 +182,9 @@ export const applyDifferences = async (connection, database, differences) => {
21
182
  'extra_view': 6,
22
183
  'missing_trigger': 7,
23
184
  'mismatched_trigger': 7,
24
- 'extra_trigger': 7
185
+ 'extra_trigger': 7,
186
+ // after the table and its columns exist
187
+ 'missing_row': 8
25
188
  };
26
189
 
27
190
  return (typePriority[a.type] || 999) - (typePriority[b.type] || 999);
@@ -29,57 +192,71 @@ export const applyDifferences = async (connection, database, differences) => {
29
192
 
30
193
 
31
194
  for (const diff of differences) {
195
+ current = diff;
32
196
  try {
33
197
 
34
198
  if(diff.type == 'missing_database'){
35
- const createDatabaseSQL = `CREATE DATABASE \`${differences[0].database}\`;`;
199
+ const createDatabaseSQL = `CREATE DATABASE \`${diff.database}\`;`;
36
200
  console.log(`Executing: ${createDatabaseSQL}`);
37
- await connection.query(createDatabaseSQL);
201
+ await query(createDatabaseSQL);
38
202
  continue;
39
203
  }
40
204
 
41
205
 
42
- await connection.query(`USE \`${database}\``);
206
+ await query(`USE \`${database}\``);
43
207
  if (diff.type === 'missing_table') {
44
208
  const { tableName, createSQL } = diff;
45
209
  logger(`Creating missing table ${tableName}`);
46
210
  console.log(`Executing: ${createSQL}`);
47
- await connection.query(createSQL);
211
+ try {
212
+ await query(createSQL);
213
+ } catch (e) {
214
+ // The dump can name things this server has never heard of - a MySQL 8
215
+ // collation, index visibility. Amend the statement for whatever the
216
+ // server objected to and retry, one objection at a time, since it only
217
+ // reports the first. The table then exists, but not exactly as dumped,
218
+ // so record every change rather than calling it a clean success.
219
+ let sql = createSQL;
220
+ let error = e;
221
+ const changes = [];
222
+
223
+ while (error && changes.length < compatRewrites.length) {
224
+ const message = error.message;
225
+ const rewrite = compatRewrites.reduce((found, fn) => found || fn(sql, message), null);
226
+ if (!rewrite) break;
227
+
228
+ sql = rewrite.sql;
229
+ changes.push(rewrite.message);
230
+ console.log(`Retrying: ${sql}`);
231
+ try {
232
+ await query(sql);
233
+ error = null;
234
+ } catch (retryError) {
235
+ error = retryError;
236
+ }
237
+ }
238
+
239
+ if (error) throw error;
240
+ warn(diff, changes.join('; '));
241
+ }
48
242
 
49
243
  } else if (diff.type === 'extra_table') {
50
244
  const { tableName } = diff;
51
245
  logger(`Dropping extra table ${tableName}`);
52
246
  const dropTableSQL = `DROP TABLE \`${tableName}\`;`;
53
247
  console.log(`Executing: ${dropTableSQL}`);
54
- await connection.query(dropTableSQL);
248
+ await query(dropTableSQL);
55
249
 
56
250
  } else if (diff.type === 'missing_field') {
57
- // Step 1: Add column with DEFAULT NULL to handle existing records
58
- const addFieldSQL = `ALTER TABLE \`${diff.tableName}\` ADD \`${diff.field.Field}\` ${diff.field.Type} DEFAULT NULL;`;
251
+ // Existing rows get the column's default, or the type's implicit default for NOT NULL without one
252
+ const addFieldSQL = `ALTER TABLE \`${diff.tableName}\` ADD COLUMN \`${diff.field.Field}\` ${columnDefinition(diff.field)};`;
59
253
  console.log(`Executing: ${addFieldSQL}`);
60
- await connection.query(addFieldSQL);
61
-
62
- // Step 2: If NOT NULL is required and no default was specified, modify the column to remove DEFAULT NULL
63
- if (diff.field.NotNull && !diff.field.Default) {
64
- const modifyFieldSQL = `ALTER TABLE \`${diff.tableName}\` MODIFY \`${diff.field.Field}\` ${diff.field.Type} NOT NULL;`;
65
- console.log(`Executing: ${modifyFieldSQL}`);
66
- await connection.query(modifyFieldSQL);
67
- }
68
- if(diff.field.Default){
69
- const defaultClause = diff.field.Default === 'CURRENT_TIMESTAMP'
70
- ? 'DEFAULT CURRENT_TIMESTAMP'
71
- : diff.field.Default !== undefined && diff.field.Default !== null
72
- ? `DEFAULT '${diff.field.Default}'`
73
- : '';
74
- const modifyFieldSQL = `ALTER TABLE \`${diff.tableName}\` MODIFY \`${diff.field.Field}\` ${diff.field.Type} ${defaultClause};`;
75
- console.log(`Executing: ${modifyFieldSQL}`);
76
- await connection.query(modifyFieldSQL);
77
- }
254
+ await query(addFieldSQL);
78
255
 
79
256
  } else if (diff.type === 'extra_field') {
80
257
  const dropFieldSQL = `ALTER TABLE \`${diff.tableName}\` DROP COLUMN \`${diff.field.Field}\`;`;
81
258
  console.log(`Executing: ${dropFieldSQL}`);
82
- await connection.query(dropFieldSQL);
259
+ await query(dropFieldSQL);
83
260
 
84
261
  } else if (diff.type === 'mismatched_field') {
85
262
  const { tableName, field, currentField } = diff;
@@ -132,7 +309,7 @@ export const applyDifferences = async (connection, database, differences) => {
132
309
  // // Step 1: Add new column
133
310
  // const addColumnSQL = `ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.Field}_new\` ${field.Type} DEFAULT NULL;`;
134
311
  // console.log(`Adding new column for type conversion: ${addColumnSQL}`);
135
- // await connection.query(addColumnSQL);
312
+ // await query(addColumnSQL);
136
313
 
137
314
  // // Step 2: Copy data in batches
138
315
  // let totalUpdated = 0;
@@ -147,7 +324,7 @@ export const applyDifferences = async (connection, database, differences) => {
147
324
  // AND \`${field.Field}\` IS NOT NULL
148
325
  // LIMIT ${batchSize};
149
326
  // `;
150
- // const result = await connection.query(updateSQL);
327
+ // const result = await query(updateSQL);
151
328
  // const rowsAffected = result.affectedRows;
152
329
 
153
330
  // if (rowsAffected === 0) break;
@@ -163,7 +340,7 @@ export const applyDifferences = async (connection, database, differences) => {
163
340
  // WHERE \`${field.Field}\` IS NOT NULL
164
341
  // AND \`${field.Field}_new\` IS NULL;
165
342
  // `;
166
- // const [verifyResult] = await connection.query(verifySQL);
343
+ // const [verifyResult] = await query(verifySQL);
167
344
  // if (verifyResult.mismatch > 0) {
168
345
  // console.log(`Warning: ${verifyResult.mismatch} rows may have failed conversion`);
169
346
  // }
@@ -175,189 +352,130 @@ export const applyDifferences = async (connection, database, differences) => {
175
352
  // RENAME COLUMN \`${field.Field}_new\` TO \`${field.Field}\`
176
353
  // `;
177
354
  // console.log(`Executing: ${swapColumnsSQL}`);
178
- // await connection.query(swapColumnsSQL);
355
+ // await query(swapColumnsSQL);
179
356
 
180
357
  // console.log(`Completed type conversion for ${field.Field}. Total rows converted: ${totalUpdated}`);
181
358
  // } else {
182
359
  // Original mismatched field handling for simple changes
183
- const isTimestamp = field.Type.toLowerCase().includes('timestamp');
184
-
185
- // Handle timestamp specific attributes
186
- const defaultClause = field.Default === 'CURRENT_TIMESTAMP'
187
- ? 'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
188
- : field.Default === 'NULL'
189
- ? isTimestamp ? 'NULL DEFAULT NULL' : 'DEFAULT NULL'
190
- : field.Default !== undefined && field.Default !== null
191
- ? `DEFAULT '${field.Default}'`
192
- : isTimestamp ? 'NULL DEFAULT NULL' : 'DEFAULT NULL';
193
-
194
- // Remove ON UPDATE CURRENT_TIMESTAMP if we're changing away from CURRENT_TIMESTAMP
195
- const hasOnUpdate = currentField?.Default === 'CURRENT_TIMESTAMP' &&
196
- field.Default !== 'CURRENT_TIMESTAMP';
197
-
198
- const modifyFieldSQL = `ALTER TABLE \`${tableName}\` CHANGE \`${field.Field}\` \`${field.Field}\` ${field.Type} ${field.NotNull ? 'NOT NULL' : ''} ${defaultClause}${hasOnUpdate ? ' ON UPDATE CURRENT_TIMESTAMP' : ''};`;
360
+ const modifyFieldSQL = `ALTER TABLE \`${tableName}\` CHANGE \`${field.Field}\` \`${field.Field}\` ${columnDefinition(field, currentField)};`;
199
361
  console.log(`Executing: ${modifyFieldSQL}`);
200
- await connection.query(modifyFieldSQL);
362
+ await query(modifyFieldSQL);
201
363
  // }
202
364
 
203
365
  } else if (diff.type === 'missing_index') {
204
- const existingColumns = await connection.query(`SHOW COLUMNS FROM \`${diff.tableName}\``);
366
+ const existingColumns = await query(`SHOW COLUMNS FROM \`${diff.tableName}\``);
205
367
  const columnNames = existingColumns.map(col => col.Field);
206
368
 
207
369
  // Check if all columns for the index exist
208
370
  const missingColumns = Array.isArray(diff.index.ColumnName) ? diff.index.ColumnName.filter(col => !columnNames.includes(col) && !columnNames.includes(col.split("(")?.[0])) : [];
209
371
 
210
372
  if (missingColumns.length > 0) {
211
- logger(`Cannot create index ${diff.index.Name} on ${diff.tableName}. Missing columns: ${missingColumns.join(', ')}`);
212
- continue; // Skip index creation if columns are missing
373
+ throw new Error(`Cannot create index ${diff.index.Name} on ${diff.tableName}. Missing columns: ${missingColumns.join(', ')}`);
213
374
  }
214
375
 
215
- // Prepare the index columns with proper formatting
216
- const indexColumns = diff.index.ColumnName.map(col => {
217
- if(col.includes("(")){
218
-
219
- const match = col.match(/^(?:`([^`]+)`|([A-Za-z_][A-Za-z0-9_\$]*))\((\d+)\)$/);
220
-
221
- if (match) {
222
- const name = match[1] || match[2]; // one of them will be filled
223
- const size = match[3];
224
- console.log(name, size);
225
- return `\`${name}\`(${size})`;
226
- }
227
-
228
- return `\`${col.split("(")[0]}\``
229
- } else {
230
- return `\`${col}\``; // Format column names
231
- }
232
- });
233
- // Handle PRIMARY index creation
234
- if (diff.index.Name === 'PRIMARY') {
235
- if (indexColumns.length > 0) {
236
- const primaryKeySQL = `ALTER TABLE \`${diff.tableName}\` ADD PRIMARY KEY (${indexColumns.join(', ')});`;
237
- console.log(`Executing: ${primaryKeySQL}`);
238
- await connection.query(primaryKeySQL);
239
- } else {
240
- logger(`Cannot create PRIMARY index on ${diff.tableName}. ColumnName is undefined or empty.`, diff.index);
241
- }
242
- } else {
243
- // Create the new index with UNIQUE if applicable
244
- const uniqueSQL = diff.index.Unique ? 'UNIQUE ' : ''; // Check if the index is unique
245
- if (indexColumns.length > 0) {
246
- const addIndexSQL = `ALTER TABLE \`${diff.tableName}\` ADD ${uniqueSQL}INDEX \`${diff.index.Name}\` (${indexColumns.join(', ')});`;
247
- console.log(`Executing: ${addIndexSQL}`);
248
- await connection.query(addIndexSQL);
249
- } else {
250
- logger(`Cannot create index ${diff.index.Name} on ${diff.tableName}. ColumnName is undefined or empty.`, diff.index);
251
- }
376
+ if (!Array.isArray(diff.index.ColumnName) || diff.index.ColumnName.length === 0) {
377
+ throw new Error(`Cannot create index ${diff.index.Name} on ${diff.tableName}. ColumnName is undefined or empty.`);
252
378
  }
379
+ const addIndexSQL = `ALTER TABLE \`${diff.tableName}\` ${addIndexClause(diff.index)};`;
380
+ console.log(`Executing: ${addIndexSQL}`);
381
+ await query(addIndexSQL);
253
382
 
254
383
  } else if (diff.type === 'extra_index') {
255
- // Handle dropping of extra indexes
256
- if (diff.index.Name === 'PRIMARY') {
257
- const dropPrimaryKeySQL = `ALTER TABLE \`${diff.tableName}\` DROP PRIMARY KEY;`;
258
- console.log(`Executing: ${dropPrimaryKeySQL}`);
259
- await connection.query(dropPrimaryKeySQL);
260
- } else {
261
- const dropIndexSQL = `DROP INDEX \`${diff.index.Name}\` ON \`${diff.tableName}\`;`;
262
- console.log(`Executing: ${dropIndexSQL}`);
263
- await connection.query(dropIndexSQL);
264
- }
384
+ const dropIndexSQL = `ALTER TABLE \`${diff.tableName}\` ${dropIndexClause(diff.index)};`;
385
+ console.log(`Executing: ${dropIndexSQL}`);
386
+ await query(dropIndexSQL);
265
387
 
266
388
  } else if (diff.type === 'mismatched_index') {
267
389
  const { tableName, index } = diff;
268
- // Drop the existing index
269
- if (index.Name === 'PRIMARY') {
270
- const dropPrimaryKeySQL = `ALTER TABLE \`${tableName}\` DROP PRIMARY KEY;`;
271
- console.log(`Executing: ${dropPrimaryKeySQL}`);
272
- await connection.query(dropPrimaryKeySQL);
273
- } else {
274
- const dropIndexSQL = `DROP INDEX \`${index.Name}\` ON \`${tableName}\`;`;
275
- console.log(`Executing: ${dropIndexSQL}`);
276
- await connection.query(dropIndexSQL);
277
- }
278
- console.log('index', index)
279
-
280
- // Create the new index
281
- const uniqueSQL = index.Unique ? 'UNIQUE ' : ''; // Check if the index is unique
282
- if (index.Name === 'PRIMARY') {
283
- const addPrimaryKeySQL = `ALTER TABLE \`${tableName}\` ADD PRIMARY KEY (${index.ColumnName.join(', ')});`;
284
- console.log(`Executing: ${addPrimaryKeySQL}`);
285
- await connection.query(addPrimaryKeySQL);
286
- } else {
287
- const addIndexSQL = `ALTER TABLE \`${tableName}\` ADD ${uniqueSQL}INDEX \`${index.Name}\` (${index.ColumnName.join(', ')});`; // Include UNIQUE if applicable
288
- console.log(`Executing: ${addIndexSQL}`);
289
- await connection.query(addIndexSQL);
290
- }
390
+ // Drop and re-add in one statement: the index is never missing if the add fails,
391
+ // and a primary key on an AUTO_INCREMENT column can be replaced
392
+ const replaceIndexSQL = `ALTER TABLE \`${tableName}\` ${dropIndexClause(index)}, ${addIndexClause(index)};`;
393
+ console.log(`Executing: ${replaceIndexSQL}`);
394
+ await query(replaceIndexSQL);
291
395
 
292
396
  } else if (diff.type === 'missing_procedure' || diff.type === 'mismatched_procedure') {
293
397
 
294
398
  // Drop the procedure if it exists
295
399
  const dropCurrentProcSQL = `DROP PROCEDURE IF EXISTS \`${diff.Name}\`;`;
296
400
  console.log(`Executing: ${dropCurrentProcSQL}`);
297
- await connection.query(dropCurrentProcSQL);
401
+ await query(dropCurrentProcSQL);
298
402
 
299
403
  // Create the procedure
300
404
  const createProcSQL = diff.Definition;
301
405
  console.log(`Executing: ${createProcSQL}`); // Log the SQL statement
302
- await connection.query(createProcSQL);
406
+ await query(createProcSQL);
303
407
 
304
408
 
305
409
 
306
410
  } else if (diff.type === 'missing_view') {
307
411
  const dropViewSQL = `DROP VIEW IF EXISTS \`${diff.viewName}\`;`;
308
412
  console.log(`Executing: ${dropViewSQL}`);
309
- await connection.query(dropViewSQL);
413
+ await query(dropViewSQL);
310
414
  const createViewSQL = diff.definition; // Use the expected definition to create the view
311
415
  console.log(`Executing: ${createViewSQL}`);
312
- await connection.query(createViewSQL);
416
+ await query(createViewSQL);
313
417
 
314
418
 
315
419
  } else if (diff.type === 'mismatched_view') {
316
420
  const dropViewSQL = `DROP VIEW IF EXISTS \`${diff.viewName}\`;`;
317
421
  console.log(`Executing: ${dropViewSQL}`);
318
- await connection.query(dropViewSQL);
422
+ await query(dropViewSQL);
319
423
  const createViewSQL = diff.definition; // Use the expected definition to create the view
320
424
  console.log(`Executing: ${createViewSQL}`);
321
- await connection.query(createViewSQL);
425
+ await query(createViewSQL);
322
426
 
323
427
 
324
428
  } else if (diff.type === 'extra_view') {
325
429
  const dropViewSQL = `DROP VIEW IF EXISTS \`${diff.viewName}\`;`;
326
430
  console.log(`Executing: ${dropViewSQL}`);
327
- await connection.query(dropViewSQL);
431
+ await query(dropViewSQL);
328
432
 
329
433
 
330
434
  } else if (diff.type === 'missing_trigger') {
331
435
  const createTriggerSQL = diff.trigger.Definition;
332
436
  logger('fixing trigger', diff.trigger.Name)
333
437
  console.log(`Executing: ${createTriggerSQL}`);
334
- await connection.query(createTriggerSQL);
438
+ await query(createTriggerSQL);
335
439
 
336
440
 
337
441
  } else if (diff.type === 'mismatched_trigger') {
338
442
  const dropTriggerSQL = `DROP TRIGGER IF EXISTS \`${diff.trigger.Name}\`;`;
339
443
  console.log(`Executing: ${dropTriggerSQL}`);
340
- await connection.query(dropTriggerSQL);
444
+ await query(dropTriggerSQL);
341
445
  const createTriggerSQL = diff.trigger.Definition;
342
446
  console.log(`Executing: ${createTriggerSQL}`);
343
- await connection.query(createTriggerSQL);
447
+ await query(createTriggerSQL);
344
448
 
345
449
 
346
450
  } else if (diff.type === 'extra_trigger') {
347
451
  const dropTriggerSQL = `DROP TRIGGER IF EXISTS \`${diff.trigger.Name}\`;`;
348
452
  console.log(`Executing: ${dropTriggerSQL}`);
349
- await connection.query(dropTriggerSQL);
453
+ await query(dropTriggerSQL);
454
+
455
+ } else if (diff.type === 'missing_row') {
456
+ const columns = Object.keys(diff.row);
457
+ const insertRowSQL = `INSERT INTO \`${diff.tableName}\` (${columns.map(col => `\`${col}\``).join(', ')}) VALUES (${columns.map(() => '?').join(', ')});`;
458
+ console.log(`Executing: ${insertRowSQL}`, JSON.stringify(diff.row));
459
+ await query(insertRowSQL, columns.map(col => diff.row[col]));
350
460
 
351
461
  }else if(diff.type === 'missing_database'){
352
462
  logger('fixing missing database', diff.database)
353
463
  const createDatabaseSQL = `CREATE DATABASE \`${diff.database}\`;`;
354
464
  console.log(`Executing: ${createDatabaseSQL}`);
355
- await connection.query(createDatabaseSQL);
465
+ await query(createDatabaseSQL);
356
466
  } else {
357
- logger(`Error applying difference of type:${diff.type}`, diff)
467
+ throw new Error(`Unknown difference type: ${diff.type}`);
358
468
  }
359
469
  } catch (e) {
360
- logger(`Error applying difference of type:${diff.type}. Error: ${e.message}`, diff);
470
+ console.error(`Failed to apply ${describeDifference(diff)}: ${e.message}`);
471
+ logger(diff);
472
+ failures.push({ type: diff.type, tableName: diff.tableName, description: describeDifference(diff), message: e.message });
361
473
  }
362
474
  }
363
- }
475
+
476
+ return { attempted: differences.length, failures, warnings };
477
+ }
478
+
479
+ // One line summing up the failures from applyDifferences, for errors and logs
480
+ export const describeFailures = ({ attempted, failures }) =>
481
+ `${failures.length} of ${attempted} difference(s) failed to apply: ${failures.map(f => `${f.description} (${f.message})`).join('; ')}`;
package/src/cli.js CHANGED
@@ -1,35 +1,62 @@
1
- import yargs from 'yargs';
2
- import { hideBin } from 'yargs/helpers';
3
- import { setVerbose } from './loggers.js';
4
-
5
- export const cli = () => {
6
- const argv = yargs(hideBin(process.argv))
7
- .command('dump', 'Dump the database structure to files')
8
- .command('compare', 'Compare the database structure with JSON dumps (dry run)')
9
- .command('update', 'Update the database structure based on JSON dumps')
10
- .command('ui', 'Open the UI')
11
- .option('uiPort', { alias: 'U', describe: 'UI port', default: 3600 })
12
- .option('openUI', { alias: 'O', describe: 'Open UI', default: true })
13
- .option('host', { alias: 'h', describe: 'Database host', default: 'localhost' })
14
- .option('port', { alias: 'P', describe: 'Database port', default: 3306 })
15
- .option('user', { alias: 'u', describe: 'Database user', default: 'root' })
16
- .option('password', { alias: 'p', describe: 'Database password', default: '' })
17
- .option('database', { alias: 'd', describe: 'Target database', default: null })
18
- .option('output', { alias: 'o', describe: 'Output directory for dumping. default is one level up from the current directory in a new folder called db-dump', default: '../db-dump' })
19
- .option('verbose', { alias: 'v', describe: 'Enable verbose logging', type: 'boolean', default: false })
20
- // .demandCommand(1)
21
- .help()
22
- .argv;
23
-
24
- const command = argv._[0];
25
- const config = {
26
- host: argv.host,
27
- port: argv.port,
28
- user: argv.user,
29
- password: argv.password,
30
- };
31
-
32
- setVerbose(argv.verbose);
33
-
34
- return { command, config, argv };
1
+ import yargs from 'yargs';
2
+ import { hideBin } from 'yargs/helpers';
3
+ import { setVerbose } from './loggers.js';
4
+ import { promptForOptions, promptForPassword, DATABASE_COMMANDS } from './prompt.js';
5
+
6
+ const COMMANDS = ['dump', 'compare', 'update', 'ui', 'rebuild'];
7
+
8
+ /**
9
+ * Parse the command line. With no command, asks for the command and its options
10
+ * in a terminal, or prints the help otherwise. command is null when there's nothing to run.
11
+ */
12
+ export const cli = async () => {
13
+ const parser = yargs(hideBin(process.argv))
14
+ .command('dump', 'Dump the database structure to files')
15
+ .command('compare', 'Compare the database structure with JSON dumps (dry run)')
16
+ .command('update', 'Update the database structure based on JSON dumps')
17
+ .command('ui', 'Open the UI')
18
+ .command('rebuild', "Rebuild the dump's JSON from each table's CREATE TABLE and list what changes (dry run unless --write)")
19
+ .option('uiPort', { alias: 'U', describe: 'UI port', default: 3600 })
20
+ .option('openUI', { alias: 'O', describe: 'Open UI', default: true })
21
+ .option('host', { alias: 'h', describe: 'Database host', default: 'localhost' })
22
+ .option('port', { alias: 'P', describe: 'Database port', default: 3306 })
23
+ .option('user', { alias: 'u', describe: 'Database user', default: 'root' })
24
+ // No default, so a missing password can be asked for; -p with no value means none
25
+ .option('password', { alias: 'p', describe: 'Database password (asked for in a terminal when not given)', type: 'string' })
26
+ .option('database', { alias: 'd', describe: 'Target database', default: null })
27
+ .option('output', { alias: 'o', describe: 'Output directory for dumping. default is one level up from the current directory in a new folder called db-dump', default: '../db-dump' })
28
+ .option('write', { describe: 'With rebuild, write the rebuilt files', type: 'boolean', default: false })
29
+ .option('verbose', { alias: 'v', describe: 'Enable verbose logging', type: 'boolean', default: false })
30
+ // .demandCommand(1)
31
+ .help();
32
+ const argv = parser.argv;
33
+
34
+ let command = argv._[0];
35
+ if (command === undefined) {
36
+ if (process.stdin.isTTY && process.stdout.isTTY) {
37
+ command = await promptForOptions(argv);
38
+ } else {
39
+ parser.showHelp();
40
+ command = null;
41
+ }
42
+ } else if (!COMMANDS.includes(command)) {
43
+ console.error(`Unknown command: ${command}. Use one of: ${COMMANDS.join(', ')}`);
44
+ command = null;
45
+ }
46
+
47
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
48
+ if (DATABASE_COMMANDS.includes(command) && argv.password === undefined) {
49
+ argv.password = interactive ? await promptForPassword(argv) : '';
50
+ }
51
+
52
+ const config = {
53
+ host: argv.host,
54
+ port: argv.port,
55
+ user: argv.user,
56
+ password: argv.password,
57
+ };
58
+
59
+ setVerbose(argv.verbose);
60
+
61
+ return { command, config, argv };
35
62
  };