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/prompt.js ADDED
@@ -0,0 +1,109 @@
1
+ import readline from 'readline';
2
+ import path from 'path';
3
+ import { isInsidePackage, packageRoot, templateFolderProblem } from './paths.js';
4
+
5
+ const COMMANDS = [
6
+ { name: 'compare', description: 'Compare the database structure with JSON dumps (dry run)' },
7
+ { name: 'update', description: 'Update the database structure based on JSON dumps' },
8
+ { name: 'dump', description: 'Dump the database structure to files' },
9
+ { name: 'ui', description: 'Open the UI' },
10
+ { name: 'rebuild', description: "Rebuild the dump's JSON from each table's CREATE TABLE" }
11
+ ];
12
+
13
+ // Commands that connect to MySQL from the command line (the UI asks for its own connection)
14
+ export const DATABASE_COMMANDS = ['compare', 'update', 'dump'];
15
+
16
+ // Commands that read an existing template, so its folder must already hold one
17
+ const TEMPLATE_COMMANDS = ['compare', 'update', 'rebuild'];
18
+
19
+ const PASSWORD_LABEL = 'MySQL password (typing is hidden, Enter for none)';
20
+
21
+ const createPrompter = ({ input = process.stdin, output = process.stdout } = {}) => {
22
+ const rl = readline.createInterface({ input, output, terminal: true });
23
+
24
+ // Lets a password be typed without echoing it
25
+ let muted = false;
26
+ rl._writeToOutput = (text) => {
27
+ if (!muted) rl.output.write(text);
28
+ };
29
+
30
+ const question = (text) => new Promise(resolve => rl.question(text, resolve));
31
+ const ask = async (label, defaultValue) => {
32
+ const hint = defaultValue !== undefined && defaultValue !== null && defaultValue !== '' ? ` (${defaultValue})` : '';
33
+ const answer = (await question(`${label}${hint}: `)).trim();
34
+ return answer === '' ? defaultValue : answer;
35
+ };
36
+ const askHidden = async (label) => {
37
+ rl.output.write(`${label}: `);
38
+ muted = true;
39
+ const answer = await question('');
40
+ muted = false;
41
+ rl.output.write('\n');
42
+ return answer;
43
+ };
44
+ const confirm = async (label) => /^y(es)?$/i.test(await ask(`${label} (y/N)`));
45
+ const say = (text) => output.write(`${text}\n`);
46
+ return { ask, askHidden, confirm, say, close: () => rl.close() };
47
+ }
48
+
49
+ /**
50
+ * Ask for the MySQL password when a command was given without --password.
51
+ */
52
+ export const promptForPassword = async (argv, streams) => {
53
+ const prompter = createPrompter(streams);
54
+ try {
55
+ return await prompter.askHidden(`${PASSWORD_LABEL} for ${argv.user}@${argv.host}`);
56
+ } finally {
57
+ prompter.close();
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ask for the command and its options when mysqlsyncr is run with no arguments.
63
+ * Fills in argv and returns the chosen command, or null if the user cancels.
64
+ */
65
+ export const promptForOptions = async (argv, streams) => {
66
+ const { ask, askHidden, confirm, say, close } = createPrompter(streams);
67
+
68
+ try {
69
+ say('No command given. Choose one:');
70
+ COMMANDS.forEach((command, i) => say(` ${i + 1}) ${command.name.padEnd(8)} ${command.description}`));
71
+ const choice = String(await ask('Command', '1'));
72
+ const command = COMMANDS[Number(choice) - 1]?.name ?? COMMANDS.find(c => c.name === choice.toLowerCase())?.name;
73
+ if (!command) {
74
+ console.error(`Unknown command: ${choice}`);
75
+ return null;
76
+ }
77
+
78
+ if (DATABASE_COMMANDS.includes(command)) {
79
+ argv.host = await ask('MySQL host', argv.host);
80
+ argv.port = Number(await ask('MySQL port', argv.port));
81
+ argv.user = await ask('MySQL user', argv.user);
82
+ argv.password = await askHidden(PASSWORD_LABEL);
83
+ argv.database = (await ask('Database (blank for all)', argv.database ?? '')) || null;
84
+ }
85
+ if (command !== 'ui') {
86
+ argv.output = await ask(command === 'dump' ? 'Folder to dump to' : 'Template folder', argv.output);
87
+ for (;;) {
88
+ let problem = null;
89
+ if (!argv.output) problem = '';
90
+ else if (isInsidePackage(argv.output)) problem = `That folder is inside the mysqlsyncr package folder (${packageRoot}). Choose one outside it.`;
91
+ else if (TEMPLATE_COMMANDS.includes(command)) problem = templateFolderProblem(argv.output);
92
+ if (problem === null) break;
93
+ if (problem) say(problem);
94
+ argv.output = await ask('Folder');
95
+ }
96
+ if (command === 'dump') say(`Dumping to ${path.resolve(argv.output)}`);
97
+ }
98
+ if (command === 'rebuild') {
99
+ argv.write = await confirm('Write the rebuilt files? No lists the changes only');
100
+ }
101
+ if (command === 'update' && !await confirm(`This will change the structure of ${argv.database ?? 'every database'} on ${argv.host}. Continue?`)) {
102
+ say('Cancelled.');
103
+ return null;
104
+ }
105
+ return command;
106
+ } finally {
107
+ close();
108
+ }
109
+ }
package/src/rebuild.js ADDED
@@ -0,0 +1,106 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { parseCreateTableSQL } from './parsers.js';
4
+ import { normalizedDefault } from './differences.js';
5
+ import { loadSyncOptions, getTableOptions, applyAutoIncrementOption } from './options.js';
6
+ import { assertOutsidePackage } from './paths.js';
7
+ import { splitSqlFile } from './templateDrift.js';
8
+
9
+ const describeColumn = (col) => JSON.stringify({ Type: col.Type, NotNull: !!col.NotNull, Default: normalizedDefault(col) });
10
+ const describeIndexes = (indexes) => (indexes || [])
11
+ .map(i => `${i.Name}${i.Primary || i.Name === 'PRIMARY' ? ' PRIMARY' : ''}${i.Unique ? ' UNIQUE' : ''} (${i.ColumnName.join(', ')})`)
12
+ .sort();
13
+
14
+ // What would change in one table's JSON if it were rebuilt from its createSQL
15
+ const tableChanges = (table, rebuilt, createSQL) => {
16
+ const changes = [];
17
+ if (createSQL !== table.createSQL) {
18
+ changes.push('createSQL: AUTO_INCREMENT counter changed by the autoIncrement option');
19
+ }
20
+ for (const col of table.columns || []) {
21
+ const rebuiltCol = rebuilt.columns.find(c => c.Field === col.Field);
22
+ if (!rebuiltCol) {
23
+ changes.push(`column ${col.Field}: in the JSON but not in createSQL (removed)`);
24
+ } else if (describeColumn(col) !== describeColumn(rebuiltCol)) {
25
+ changes.push(`column ${col.Field}: JSON ${describeColumn(col)} -> createSQL ${describeColumn(rebuiltCol)}`);
26
+ }
27
+ }
28
+ for (const col of rebuilt.columns) {
29
+ if (!(table.columns || []).some(c => c.Field === col.Field)) {
30
+ changes.push(`column ${col.Field}: in createSQL but not in the JSON (added)`);
31
+ }
32
+ }
33
+ const before = describeIndexes(table.indexes);
34
+ const after = describeIndexes(rebuilt.indexes);
35
+ for (const index of before.filter(i => !after.includes(i))) changes.push(`index ${index}: removed`);
36
+ for (const index of after.filter(i => !before.includes(i))) changes.push(`index ${index}: added`);
37
+ return changes;
38
+ }
39
+
40
+ /**
41
+ * Rebuild each table's columns/indexes/constraints in a template from its
42
+ * createSQL with the current parser, applying the autoIncrement option to the
43
+ * createSQL. Returns { changes, notes }: changes is { 'db.table': [change, ...] }
44
+ * for tables that would change, notes is { 'db.table': note } for things to check
45
+ * by hand. Only writes the files when write is true.
46
+ */
47
+ export const rebuildTemplate = async (dir, { write = false } = {}) => {
48
+ if (write) assertOutsidePackage(dir);
49
+ const syncOptions = await loadSyncOptions(dir);
50
+ const report = {};
51
+ const notes = {};
52
+ const entries = await fs.readdir(dir, { withFileTypes: true });
53
+
54
+ for (const entry of entries.filter(e => e.isDirectory())) {
55
+ const database = entry.name;
56
+ const jsonPath = path.join(dir, database, `${database}.json`);
57
+ let structure;
58
+ try {
59
+ structure = JSON.parse(await fs.readFile(jsonPath, 'utf-8'));
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') continue;
62
+ throw new Error(`Could not read ${jsonPath}: ${err.message}`);
63
+ }
64
+
65
+ let changed = false;
66
+ for (const [tableName, table] of Object.entries(structure.tables || {})) {
67
+ if (!table.createSQL) continue;
68
+ const createSQL = applyAutoIncrementOption(table.createSQL, getTableOptions(syncOptions, database, tableName).autoIncrement);
69
+ const rebuilt = parseCreateTableSQL(createSQL);
70
+ const changes = tableChanges(table, rebuilt, createSQL);
71
+
72
+ // tables/<table>.sql should match createSQL, but it can hold notes or edits made
73
+ // by hand, so only the AUTO_INCREMENT option is applied to it; any other
74
+ // difference is reported, never overwritten
75
+ const sqlPath = path.join(dir, database, 'tables', `${tableName}.sql`);
76
+ const sqlFile = await fs.readFile(sqlPath, 'utf-8').catch(() => null);
77
+ const fixedSqlFile = sqlFile === null ? null : applyAutoIncrementOption(sqlFile, getTableOptions(syncOptions, database, tableName).autoIncrement);
78
+ const sqlCounterChanged = sqlFile !== null && fixedSqlFile !== sqlFile;
79
+ if (sqlCounterChanged) changes.push(`tables/${tableName}.sql: AUTO_INCREMENT counter changed by the autoIncrement option`);
80
+ // Notes written after the statement don't count
81
+ const sameSQL = (a, b) => a.replace(/\r\n/g, '\n').replace(/;?\s*$/, '') === b.replace(/\r\n/g, '\n').replace(/;?\s*$/, '');
82
+ const sqlFileDrifted = fixedSqlFile !== null && !sameSQL(splitSqlFile(fixedSqlFile).statement, createSQL);
83
+ if (changes.length === 0 && !sqlFileDrifted) continue;
84
+
85
+ if (changes.length > 0) report[`${database}.${tableName}`] = changes;
86
+ if (sqlFileDrifted) {
87
+ notes[`${database}.${tableName}`] = `tables/${tableName}.sql differs from the createSQL in ${database}.json (left as is; compare uses the JSON). Pick which to keep in the UI.`;
88
+ }
89
+ if (sqlCounterChanged && write) {
90
+ await fs.writeFile(sqlPath, fixedSqlFile, 'utf-8');
91
+ }
92
+ if (changes.length === 0 || changes.every(change => change.startsWith('tables/'))) continue; // the JSON is already right
93
+
94
+ changed = true;
95
+ table.columns = rebuilt.columns;
96
+ table.indexes = rebuilt.indexes;
97
+ table.constraints = rebuilt.constraints;
98
+ table.createSQL = createSQL;
99
+ }
100
+
101
+ if (changed && write) {
102
+ await fs.writeFile(jsonPath, JSON.stringify(structure, null, 4), 'utf-8');
103
+ }
104
+ }
105
+ return { changes: report, notes };
106
+ }
package/src/rows.js ADDED
@@ -0,0 +1,46 @@
1
+ const primaryKeyColumns = (table) => table?.indexes
2
+ ?.find(i => i.Primary || i.Name === 'PRIMARY')
3
+ ?.ColumnName.map(col => col.replace(/\(\d+\)$/, ''));
4
+
5
+ /**
6
+ * Seed rows from the sync options that aren't in the database yet, as
7
+ * missing_row differences. A row counts as present when a row with the same
8
+ * rowKey values exists, whatever its other columns hold. Rows for a table that
9
+ * doesn't exist yet are all missing; they're applied after the table is created.
10
+ */
11
+ export const findMissingRows = async (connection, database, expected, current, syncOptions) => {
12
+ const differences = [];
13
+ for (const [tableName, tableOptions] of Object.entries(syncOptions?.databases?.[database] || {})) {
14
+ if (!tableOptions?.rows?.length) continue;
15
+
16
+ const key = tableOptions.rowKey ?? primaryKeyColumns(expected.tables?.[tableName]) ?? primaryKeyColumns(current.tables?.[tableName]);
17
+ if (!key) {
18
+ throw new Error(`${database}.${tableName}: rows need a rowKey because the table has no primary key`);
19
+ }
20
+ const tableExists = !!current.tables?.[tableName];
21
+
22
+ for (const row of tableOptions.rows) {
23
+ const missingKeyColumns = key.filter(col => !(col in row));
24
+ if (missingKeyColumns.length > 0) {
25
+ throw new Error(`${database}.${tableName}: a row is missing rowKey column(s) ${missingKeyColumns.join(', ')}: ${JSON.stringify(row)}`);
26
+ }
27
+ const rowKey = Object.fromEntries(key.map(col => [col, row[col]]));
28
+
29
+ if (tableExists) {
30
+ const where = key.map(col => row[col] === null ? `\`${col}\` IS NULL` : `\`${col}\` = ?`).join(' AND ');
31
+ const values = key.filter(col => row[col] !== null).map(col => row[col]);
32
+ let found;
33
+ try {
34
+ found = await connection.query(`SELECT 1 FROM \`${database}\`.\`${tableName}\` WHERE ${where} LIMIT 1`, values);
35
+ } catch (err) {
36
+ // e.g. a key column the table doesn't have yet; checked again once the structure is fixed
37
+ console.error(`Could not check seed rows for ${database}.${tableName}: ${err.message}`);
38
+ break;
39
+ }
40
+ if (found.length > 0) continue;
41
+ }
42
+ differences.push({ type: 'missing_row', tableName, key: rowKey, row });
43
+ }
44
+ }
45
+ return differences;
46
+ }
@@ -2,17 +2,17 @@ import express from 'express';
2
2
  import http from 'http';
3
3
  import { dumpAllDatabases, getDatabasesFromExistingDumps } from './dump.js'; // Import necessary functions
4
4
  import { compareAllDatabases } from './compare.js';
5
- import { DBStructureChecker } from './index.js'; // Import your DBStructureChecker class
5
+ import { DBStructureChecker } from './DBStructureChecker.js';
6
6
  import { applyDifferences } from './apply.js';
7
+ import { packageRoot, assertOutsidePackage } from './paths.js';
8
+ import { acceptCurrentIntoTemplate } from './acceptCurrent.js';
9
+ import { getVersionInfo } from './versions.js';
10
+ import { findTemplateDrift, resolveTemplateDrift } from './templateDrift.js';
7
11
  import cors from 'cors';
8
12
  import { join } from 'path';
9
13
  import { readdir, mkdir } from 'fs/promises';
10
14
  import { resolve } from 'path';
11
- import { fileURLToPath } from 'url';
12
- import { dirname } from 'path';
13
15
 
14
- const __filename = fileURLToPath(import.meta.url);
15
- const __dirname = dirname(__filename);
16
16
 
17
17
  const setConfig = (config, argv) => {
18
18
  if(!config.host && argv.host){
@@ -33,21 +33,6 @@ const setConfig = (config, argv) => {
33
33
  return config;
34
34
  }
35
35
 
36
- // Create a function to get the package root directory that works in both ESM and CJS
37
- const getPackageRoot = () => {
38
- if (typeof __dirname !== 'undefined') {
39
- // CommonJS
40
- return join(__dirname, '..');
41
- } else {
42
- // ESM
43
- const __filename = fileURLToPath(import.meta.url);
44
- const __dirname = dirname(__filename);
45
- return join(__dirname, '..');
46
- }
47
- };
48
-
49
- // Use the function to get the root path
50
- const packageRoot = getPackageRoot();
51
36
 
52
37
  export const startApiServer = (port, argv) => {
53
38
  const app = express();
@@ -145,8 +130,103 @@ export const startApiServer = (port, argv) => {
145
130
  checker.options = req.body;
146
131
  try {
147
132
  console.log('Applying the database structure based on JSON dumps');
148
- await applyDifferences(checker.connection, database, diffs);
149
- res.json({ message: 'Apply completed successfully' });
133
+ const { attempted, failures, warnings } = await applyDifferences(checker.connection, database, diffs);
134
+ const warningText = warnings.length
135
+ ? ` ${warnings.length} applied with changes: ` + warnings.map(w => `${w.description} (${w.message})`).join('; ')
136
+ : '';
137
+ if (failures.length) {
138
+ // Reporting success here would be a lie: the same differences come
139
+ // back on the next compare with no indication of why.
140
+ res.status(500).json({
141
+ message: `Applied ${attempted - failures.length} of ${attempted}. ${failures.length} failed: `
142
+ + failures.map(f => `${f.description} (${f.message})`).join('; ') + warningText,
143
+ failures,
144
+ warnings,
145
+ });
146
+ } else {
147
+ res.json({
148
+ message: `Applied ${attempted} of ${attempted}.` + warningText,
149
+ warnings,
150
+ });
151
+ }
152
+ } catch (err) {
153
+ res.status(500).json({ message: err.message });
154
+ } finally {
155
+ await checker.closeConnection();
156
+ }
157
+ });
158
+ });
159
+
160
+ // API endpoint for tables whose tables/<table>.sql disagrees with the createSQL in the
161
+ // template's JSON. Reads only the template, so no database connection is needed
162
+ app.post('/api/templateDrift', async (req, res) => {
163
+ const config = setConfig(req.body.config || {}, argv);
164
+ if (!config.output) return res.status(400).json({ message: 'No template folder selected' });
165
+ try {
166
+ res.json(await findTemplateDrift(config.output));
167
+ } catch (err) {
168
+ res.status(500).json({ message: err.message });
169
+ }
170
+ });
171
+
172
+ // API endpoint to make one table's JSON createSQL and .sql file agree, keeping 'json' or 'sql'
173
+ app.post('/api/resolveTemplateDrift', async (req, res) => {
174
+ const { database, table, keep } = req.body;
175
+ const config = setConfig(req.body.config || {}, argv);
176
+ if (!config.output) return res.status(400).json({ message: 'No template folder selected' });
177
+ try {
178
+ await resolveTemplateDrift(config.output, database, table, keep);
179
+ res.json({ message: `${database}.${table}: kept the ${keep === 'json' ? 'JSON' : '.sql file'} version` });
180
+ } catch (err) {
181
+ res.status(500).json({ message: err.message });
182
+ }
183
+ });
184
+
185
+ // API endpoint for the server's and the template's MySQL versions
186
+ app.post('/api/versions', async (req, res) => {
187
+ let { config } = req.body;
188
+ config = setConfig(config || {}, argv);
189
+ const checker = new DBStructureChecker(config);
190
+
191
+ checker.connection.on('error', (err) => {
192
+ if (!res.headersSent) res.status(500).json({ message: 'Database connection failed', error: err.message });
193
+ });
194
+
195
+ checker.connection.on('connect', async () => {
196
+ try {
197
+ res.json(await getVersionInfo(checker.connection, config.output));
198
+ } catch (err) {
199
+ res.status(500).json({ message: err.message });
200
+ } finally {
201
+ await checker.closeConnection();
202
+ }
203
+ });
204
+ });
205
+
206
+ // API endpoint to overwrite the template with the database's current values
207
+ app.post('/api/acceptCurrent', async (req, res) => {
208
+ let { config, database, diffs } = req.body;
209
+ config = setConfig(config, argv);
210
+ if (!config.output) {
211
+ return res.status(400).json({ message: 'No template folder selected' });
212
+ }
213
+ try {
214
+ assertOutsidePackage(config.output);
215
+ } catch (err) {
216
+ return res.status(400).json({ message: err.message });
217
+ }
218
+ const checker = new DBStructureChecker(config, database);
219
+
220
+ checker.connection.on('error', (err) => {
221
+ console.error('Database connection error in route acceptCurrent:', err);
222
+ if (!res.headersSent) res.status(500).json({ message: 'Database connection failed', error: err.message });
223
+ });
224
+
225
+ checker.connection.on('connect', async () => {
226
+ try {
227
+ console.log(`Keeping current values in the template for ${diffs?.length ?? 0} difference(s) in ${database}`);
228
+ await acceptCurrentIntoTemplate(checker.connection, config.output, database, diffs || []);
229
+ res.json({ message: 'Template updated with the current values' });
150
230
  } catch (err) {
151
231
  res.status(500).json({ message: err.message });
152
232
  } finally {
@@ -157,7 +237,8 @@ export const startApiServer = (port, argv) => {
157
237
 
158
238
  app.post('/api/browse-folders', async (req, res) => {
159
239
  try {
160
- const { currentPath = packageRoot } = req.body;
240
+ // Start where mysqlsyncr was run from, not in the package folder
241
+ const { currentPath = process.cwd() } = req.body;
161
242
  console.log('Browsing folder:', currentPath);
162
243
 
163
244
  const resolvedPath = currentPath.startsWith('/') || currentPath.match(/^[A-Z]:\\/)
@@ -210,7 +291,8 @@ export const startApiServer = (port, argv) => {
210
291
 
211
292
  const resolvedPath = folderPath.startsWith('/') || folderPath.match(/^[A-Z]:\\/)
212
293
  ? resolve(folderPath)
213
- : resolve(packageRoot, folderPath);
294
+ : resolve(process.cwd(), folderPath);
295
+ assertOutsidePackage(resolvedPath);
214
296
 
215
297
  await mkdir(resolvedPath, { recursive: true });
216
298
 
@@ -0,0 +1,93 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { parseCreateTableSQL } from './parsers.js';
4
+ import { assertOutsidePackage } from './paths.js';
5
+
6
+ /**
7
+ * Split a tables/<table>.sql file into its CREATE TABLE statement (without a
8
+ * trailing semicolon) and whatever follows it, such as notes written by hand.
9
+ */
10
+ export const splitSqlFile = (content) => {
11
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
12
+ const start = lines.findIndex(line => /^\s*CREATE TABLE/i.test(line));
13
+ let end = start === -1 ? -1 : lines.findIndex((line, i) => i > start && line.trim().startsWith(')'));
14
+ if (start === -1 || end === -1) return { statement: content.trim(), semicolon: false, before: '', after: '' };
15
+ const closing = lines[end].replace(/\s+$/, '');
16
+ const semicolon = closing.endsWith(';');
17
+ return {
18
+ before: lines.slice(0, start).join('\n'),
19
+ statement: [...lines.slice(start, end), semicolon ? closing.slice(0, -1) : closing].join('\n'),
20
+ semicolon,
21
+ after: lines.slice(end + 1).join('\n')
22
+ };
23
+ }
24
+
25
+ const joinSqlFile = ({ before, statement, semicolon, after }) =>
26
+ `${before ? `${before}\n` : ''}${statement}${semicolon ? ';' : ''}\n${after.trim() ? `${after.replace(/\n+$/, '')}\n` : ''}`;
27
+
28
+ const sameStatement = (a, b) => a.replace(/\r\n/g, '\n').replace(/;?\s*$/, '') === b.replace(/\r\n/g, '\n').replace(/;?\s*$/, '');
29
+
30
+ /**
31
+ * Tables whose tables/<table>.sql statement differs from the createSQL in the
32
+ * database's JSON (which is what comparing uses). Notes after the statement
33
+ * don't count. Returns [{ database, table, file, json, sql }].
34
+ */
35
+ export const findTemplateDrift = async (dir) => {
36
+ const drift = [];
37
+ const entries = await fs.readdir(dir, { withFileTypes: true });
38
+ for (const entry of entries.filter(e => e.isDirectory())) {
39
+ const database = entry.name;
40
+ let structure;
41
+ try {
42
+ structure = JSON.parse(await fs.readFile(path.join(dir, database, `${database}.json`), 'utf-8'));
43
+ } catch {
44
+ continue;
45
+ }
46
+ for (const [table, contents] of Object.entries(structure.tables || {})) {
47
+ if (!contents.createSQL) continue;
48
+ const file = path.join('tables', `${table}.sql`);
49
+ const content = await fs.readFile(path.join(dir, database, file), 'utf-8').catch(() => null);
50
+ if (content === null) continue;
51
+ const { statement } = splitSqlFile(content);
52
+ if (!sameStatement(statement, contents.createSQL)) {
53
+ drift.push({ database, table, file: file.replace(/\\/g, '/'), json: contents.createSQL.replace(/;\s*$/, ''), sql: statement });
54
+ }
55
+ }
56
+ }
57
+ return drift;
58
+ }
59
+
60
+ /**
61
+ * Make one table's JSON createSQL and tables/<table>.sql agree.
62
+ * keep 'json': the .sql file's statement is replaced by the JSON's createSQL; notes after it stay
63
+ * keep 'sql': the JSON's createSQL becomes the .sql file's statement, and its
64
+ * columns/indexes/constraints are re-read from it
65
+ */
66
+ export const resolveTemplateDrift = async (dir, database, table, keep) => {
67
+ if (keep !== 'json' && keep !== 'sql') throw new Error(`keep must be 'json' or 'sql', not ${keep}`);
68
+ assertOutsidePackage(dir);
69
+ const jsonPath = path.join(dir, database, `${database}.json`);
70
+ const sqlPath = path.join(dir, database, 'tables', `${table}.sql`);
71
+ const structure = JSON.parse(await fs.readFile(jsonPath, 'utf-8'));
72
+ const contents = structure.tables?.[table];
73
+ if (!contents?.createSQL) throw new Error(`${database}.${table} has no createSQL in ${database}.json`);
74
+ const parts = splitSqlFile(await fs.readFile(sqlPath, 'utf-8'));
75
+
76
+ if (keep === 'json') {
77
+ await fs.writeFile(sqlPath, joinSqlFile({ ...parts, statement: contents.createSQL.replace(/;\s*$/, '') }), 'utf-8');
78
+ return;
79
+ }
80
+
81
+ const parsed = parseCreateTableSQL(parts.statement);
82
+ if (!parsed || parsed.name !== table) {
83
+ throw new Error(`tables/${table}.sql doesn't hold a CREATE TABLE for ${table}`);
84
+ }
85
+ structure.tables[table] = {
86
+ ...contents,
87
+ columns: parsed.columns,
88
+ indexes: parsed.indexes,
89
+ constraints: parsed.constraints,
90
+ createSQL: parts.statement
91
+ };
92
+ await fs.writeFile(jsonPath, JSON.stringify(structure, null, 4), 'utf-8');
93
+ }
@@ -0,0 +1,112 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ // '8.0.36-log' -> { major: 8, minor: 0 }; also takes '5.7'
5
+ const parseVersion = (version) => {
6
+ const match = typeof version === 'string' && version.match(/^(\d+)\.(\d+)/);
7
+ return match ? { major: Number(match[1]), minor: Number(match[2]) } : null;
8
+ }
9
+
10
+ export const majorMinor = (version) => {
11
+ const parsed = parseVersion(version);
12
+ return parsed ? `${parsed.major}.${parsed.minor}` : null;
13
+ }
14
+
15
+ const isBefore80 = (version) => {
16
+ const parsed = parseVersion(version);
17
+ return !!parsed && parsed.major < 8;
18
+ }
19
+
20
+ export const getServerVersion = async (connection) => {
21
+ try {
22
+ const rows = await connection.query('SELECT VERSION() AS version');
23
+ return rows?.[0]?.version ?? null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The MySQL version a template was dumped from: recorded in dumpInfo.json by
31
+ * dump, or else inferred from how its SQL is written. Returns
32
+ * { version, inferred, reason? } or null when it can't tell.
33
+ */
34
+ export const getTemplateVersion = async (dir) => {
35
+ try {
36
+ const info = JSON.parse(await fs.readFile(path.join(dir, 'dumpInfo.json'), 'utf-8'));
37
+ if (info.mysqlVersion) return { version: info.mysqlVersion, inferred: false };
38
+ } catch {
39
+ // No dumpInfo.json, or written before versions were recorded
40
+ }
41
+
42
+ let sql = '';
43
+ try {
44
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
45
+ if (!entry.isDirectory()) continue;
46
+ try {
47
+ const structure = JSON.parse(await fs.readFile(path.join(dir, entry.name, `${entry.name}.json`), 'utf-8'));
48
+ sql += Object.values(structure.tables || {}).map(t => t.createSQL || '').join('\n');
49
+ } catch {
50
+ // Not a database folder
51
+ }
52
+ }
53
+ } catch {
54
+ return null;
55
+ }
56
+ if (/utf8mb4_0900_|\butf8mb3\b/i.test(sql)) {
57
+ return { version: '8.0', inferred: true, reason: 'uses utf8mb4_0900 collations or utf8mb3, which only 8.0 prints' };
58
+ }
59
+ if (/\b(?:smallint|mediumint|int|bigint)\(\d+\)|\btinyint\((?!1\))\d+\)/i.test(sql)) {
60
+ return { version: '5.7', inferred: true, reason: 'shows integer display widths like int(11), as 5.7 and 8.0 before 8.0.19 do' };
61
+ }
62
+ if (/\b(?:smallint|mediumint|int|bigint)\b(?!\()/i.test(sql)) {
63
+ return { version: '8.0', inferred: true, reason: 'shows integers without display widths, as 8.0.19 and later do' };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * What to know when the template and the server are different MySQL versions.
70
+ */
71
+ export const versionNotes = (templateVersion, serverVersion) => {
72
+ const template = majorMinor(templateVersion);
73
+ const server = majorMinor(serverVersion);
74
+ if (!template || !server || template === server) return [];
75
+ const notes = [
76
+ `The template is from MySQL ${template} and the server is MySQL ${server}. Integer display widths (int(11) vs int), YEAR(4) and utf8 vs utf8mb3 are printed differently by these versions and are ignored when comparing.`
77
+ ];
78
+ if (isBefore80(serverVersion) && !isBefore80(templateVersion)) {
79
+ notes.push(`MySQL ${server} has no utf8mb4_0900 collations, so tables and columns are created with utf8mb4_unicode_ci (utf8mb4_bin for accent- or case-sensitive ones) instead.`);
80
+ notes.push(`Expression defaults such as DEFAULT (...) need MySQL 8.0.13 or later and fail on MySQL ${server}.`);
81
+ }
82
+ return notes;
83
+ }
84
+
85
+ export const getVersionInfo = async (connection, templateDir) => {
86
+ const server = await getServerVersion(connection);
87
+ const template = await getTemplateVersion(templateDir);
88
+ return { server, template, notes: versionNotes(template?.version, server) };
89
+ }
90
+
91
+ // MySQL 8 added the _0900_ collation family, which does not exist on 5.7. The _unicode_ci
92
+ // collation of the same charset is accent- and case-insensitive like _0900_ai_ci and is
93
+ // present on both versions, so a table created with it means the same thing either way.
94
+ // Accent- or case-sensitive _0900_ collations map to the charset's _bin. Null if collation
95
+ // isn't a _0900_ one.
96
+ export const portableCollation = (collation) => {
97
+ const match = /^(\w+?)_0900_(\w+)$/i.exec(collation || '');
98
+ if (!match) return null;
99
+ return /^(?:ai_ci|as_ci)$/i.test(match[2]) ? `${match[1]}_unicode_ci` : `${match[1]}_bin`;
100
+ }
101
+
102
+ /**
103
+ * Rewrite SQL from a template so the server can run it: MySQL 5.7 doesn't
104
+ * know the utf8mb4_0900 collations that 8.0 uses by default, or the utf8mb3_
105
+ * collation names 8.0 prints.
106
+ */
107
+ export const adaptSQLForServer = (sql, serverVersion) => {
108
+ if (typeof sql !== 'string' || !isBefore80(serverVersion)) return sql;
109
+ return sql
110
+ .replace(/\b\w+?_0900_\w+/gi, (collation) => portableCollation(collation) ?? collation)
111
+ .replace(/\butf8mb3_(\w+)/gi, 'utf8_$1');
112
+ }