mysqlsyncr 1.1.10 → 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 +246 -186
  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 +4070 -1730
  13. package/dist/index.js +4106 -1768
  14. package/package.json +66 -59
  15. package/rollup.config.js +25 -24
  16. package/src/DBStructureChecker.js +47 -0
  17. package/src/acceptCurrent.js +225 -0
  18. package/src/apply.js +481 -348
  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 -170
  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 +197 -145
  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 +311 -229
  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/parsers.js CHANGED
@@ -1,145 +1,197 @@
1
- export const parseCreateTableSQL = (createTableSQL) => {
2
- if(!createTableSQL) return null;
3
- const name = createTableSQL.match(/CREATE TABLE\s+`([^`]+)`/)[1];
4
- const columns = [];
5
- const indexes = [];
6
- const constraints = [];
7
- let engine = null;
8
- let charset = null;
9
- let collate = null;
10
-
11
- // Split the SQL statement into lines and filter out empty lines
12
- const lines = createTableSQL.split('\n').map(line => line.trim()).filter(line => line.length > 0);
13
-
14
- // Regular expression to match column definitions
15
- const columnRegex = /`([^`]+)`\s+([^\s,]+)(\s+NOT NULL|\s+NULL)?(\s+DEFAULT\s+([^,\s]+))?(\s+AUTO_INCREMENT)?/;
16
-
17
- // Regular expression to match index definitions
18
- const indexRegex = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(([^)]+)\)|PRIMARY KEY\s*\(([^)]+)\)/;
19
-
20
- // Regular expression to match table options (ENGINE, CHARSET, COLLATE)
21
- const optionsRegex = /ENGINE=(\w+)\s+DEFAULT\s+CHARSET=(\w+)(\s+COLLATE=(\w+))?/;
22
-
23
-
24
- // Process each line
25
- for (const line of lines) {
26
- if (line.includes('CREATE TABLE')) {
27
- continue;
28
- }
29
- // Check if the line contains a column definition
30
- const columnMatch = columnRegex.exec(line);
31
- const indexMatch = indexRegex.exec(line);
32
- const constraintMatch = parseConstraint(line);
33
- if (columnMatch && !indexMatch && !constraintMatch) {
34
- const columnType = parseColumnType(line);
35
- const column = {
36
- Field: columnMatch[1],
37
- Type: columnType
38
- };
39
-
40
- // Set Null property based on NOT NULL presence
41
- if (columnMatch[3] && columnMatch[3].includes('NOT NULL')) {
42
- column.NotNull = true; // Set to false if NOT NULL is present
43
- }
44
-
45
- // Set Default property if it exists
46
- if (columnMatch[5]) {
47
- // console.log('columnMatch[5]', columnMatch[5], columnMatch[5].replace(/'/g, ''))
48
- column.Default = columnMatch[5].replace(/'/g, '');
49
- }
50
-
51
- // Set AutoIncrement property if it exists
52
- if (columnMatch[6]) {
53
- column.AutoIncrement = true; // Set to true if AUTO_INCREMENT is present
54
- }
55
-
56
- columns.push(column);
57
- } else if (indexMatch && !constraintMatch) {
58
-
59
- const isPrimary = line.includes("PRIMARY KEY"); // Check for PRIMARY in both patterns
60
- const isUnique = line.includes("UNIQUE KEY"); // Only set Unique if it matches
61
-
62
- const keyName = isPrimary ? null : indexMatch[2]; // Set KeyName to null if it's a primary key
63
- const columnNames = indexMatch[3]
64
- ? indexMatch[3].split(',').map(col => col.trim().replace(/`/g, '')) // Remove backticks
65
- : indexMatch[4].split(',').map(col => col.trim().replace(/`/g, '')); // Remove backticks
66
- const index = {
67
- ColumnName: columnNames
68
- };
69
- if (isPrimary) index.Primary = true, index.Name = "PRIMARY"
70
- if (isUnique) index.Unique = true;
71
- if (keyName) index.Name = keyName;
72
- indexes.push(index);
73
-
74
- } else if (constraintMatch) {
75
- constraints.push(constraintMatch);
76
- }
77
-
78
- }
79
-
80
- // Handle table options
81
- const optionsMatch = optionsRegex.exec(createTableSQL);
82
- if (optionsMatch) {
83
- engine = optionsMatch[1];
84
- charset = optionsMatch[2];
85
- collate = optionsMatch[4] || null; // Collate may not be present
86
- }
87
- return { name, columns, indexes, constraints, engine, charset, collate };
88
- }
89
-
90
- export const parseConstraint = (sql) => {
91
- if(!sql) return null;
92
- const regex = /FOREIGN KEY \((`[^`]+`)\) REFERENCES (`[^`]+`)\s?\((`[^`]+`)\)(?:\sON DELETE (NO|CASCADE|SET NULL|SET DEFAULT))?(?:\sON UPDATE (NO ACTION|CASCADE|SET NULL|SET DEFAULT))?/i;
93
- const match = sql.match(regex);
94
-
95
- if (!match) {
96
- return null;
97
- }
98
- let onDelete = null;
99
- let onUpdate = null;
100
- if(sql.includes('ON DELETE CASCADE')){
101
- onDelete = 'CASCADE';
102
- }else if(sql.includes('ON DELETE SET NULL')){
103
- onDelete = 'SET NULL';
104
- }else if(sql.includes('ON DELETE SET DEFAULT')){
105
- onDelete = 'SET DEFAULT';
106
- }else{
107
- onDelete = 'NO ACTION';
108
- }
109
-
110
- if(sql.includes('ON UPDATE CASCADE')){
111
- onUpdate = 'CASCADE';
112
- }else if(sql.includes('ON UPDATE SET NULL')){
113
- onUpdate = 'SET NULL';
114
- }else if(sql.includes('ON UPDATE SET DEFAULT')){
115
- onUpdate = 'SET DEFAULT';
116
- }else{
117
- onUpdate = 'NO ACTION';
118
- }
119
- let columnName = match[1] || ''
120
- columnName = columnName.replace(/`/g, '')
121
- let table = match[2] || ''
122
- table = table.replace(/`/g, '')
123
- let column = match[3] || ''
124
- column = column.replace(/`/g, '')
125
- return {
126
- Name: "AlertRule", // Replace with actual constraint name if needed
127
- Type: "FOREIGN KEY",
128
- ColumnName: [columnName], // Column in the current table
129
- References: {
130
- Table: table, // Referenced table
131
- Column: column, // Referenced column
132
- },
133
- OnDelete: onDelete,
134
- OnUpdate: onUpdate,
135
- };
136
- };
137
-
138
- const parseColumnType = (line) => {
139
- // Match type pattern including parameters, handling both single and double parameter types
140
- const typeRegex = /`[^`]+`\s+([a-zA-Z]+(?:\([0-9]+(?:,[0-9]+)?\))?)/i;
141
- const match = line.match(typeRegex);
142
-
143
- if (!match) return null;
144
- return match[1]; // Returns the full type including parameters
145
- };
1
+ export const parseCreateTableSQL = (createTableSQL) => {
2
+ if(!createTableSQL) return null;
3
+ const name = createTableSQL.match(/CREATE TABLE\s+`([^`]+)`/)[1];
4
+ const columns = [];
5
+ const indexes = [];
6
+ const constraints = [];
7
+ let engine = null;
8
+ let charset = null;
9
+ let collate = null;
10
+
11
+ // Split the SQL statement into lines and filter out empty lines
12
+ const lines = createTableSQL.split('\n').map(line => line.trim()).filter(line => line.length > 0);
13
+
14
+ // Regular expression to match index definitions
15
+ const indexRegex_Old = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(([^)]+)\)|PRIMARY KEY\s*\(([^)]+)\)/;
16
+
17
+ // Updated to support indexes for text fields
18
+ // 'KEY `idx_e2alarms_alarmstring` (`alarmString`(255))' -- would cut off the final bracket and wouldnt work.
19
+ const indexRegex = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(((?:`[^`]+`(?:\(\d+\))?(?:\s*,\s*)?)+)\)|PRIMARY KEY\s*\(((?:`[^`]+`(?:\(\d+\))?(?:\s*,\s*)?)+)\)/;
20
+
21
+ // Regular expression to match table options (ENGINE, CHARSET, COLLATE)
22
+ const optionsRegex = /ENGINE=(\w+)\s+DEFAULT\s+CHARSET=(\w+)(\s+COLLATE=(\w+))?/;
23
+
24
+
25
+ // Process each line
26
+ for (const line of lines) {
27
+ if (line.includes('CREATE TABLE')) {
28
+ continue;
29
+ }
30
+ // Column definitions are the only lines that start with a backticked name
31
+ const column = parseColumnDefinition(line);
32
+ const indexMatch = indexRegex.exec(line);
33
+
34
+ const constraintMatch = parseConstraint(line);
35
+ if (column) {
36
+ columns.push(column);
37
+ } else if (indexMatch && !constraintMatch) {
38
+
39
+ const isPrimary = line.includes("PRIMARY KEY"); // Check for PRIMARY in both patterns
40
+ const isUnique = line.includes("UNIQUE KEY"); // Only set Unique if it matches
41
+
42
+ const keyName = isPrimary ? null : indexMatch[2]; // Set KeyName to null if it's a primary key
43
+ const columnNames = indexMatch[3]
44
+ ? indexMatch[3].split(',').map(col => col.trim().replace(/`/g, '')) // Remove backticks
45
+ : indexMatch[4].split(',').map(col => col.trim().replace(/`/g, '')); // Remove backticks
46
+ const index = {
47
+ ColumnName: columnNames
48
+ };
49
+ if (isPrimary) index.Primary = true, index.Name = "PRIMARY"
50
+ if (isUnique) index.Unique = true;
51
+ if (keyName) index.Name = keyName;
52
+ indexes.push(index);
53
+
54
+ } else if (constraintMatch) {
55
+ constraints.push(constraintMatch);
56
+ }
57
+
58
+ }
59
+
60
+ // Handle table options
61
+ const optionsMatch = optionsRegex.exec(createTableSQL);
62
+ if (optionsMatch) {
63
+ engine = optionsMatch[1];
64
+ charset = optionsMatch[2];
65
+ collate = optionsMatch[4] || null; // Collate may not be present
66
+ }
67
+ return { name, columns, indexes, constraints, engine, charset, collate };
68
+ }
69
+
70
+ export const parseConstraint = (sql) => {
71
+ if(!sql) return null;
72
+ const regex = /FOREIGN KEY \((`[^`]+`)\) REFERENCES (`[^`]+`)\s?\((`[^`]+`)\)(?:\sON DELETE (NO|CASCADE|SET NULL|SET DEFAULT))?(?:\sON UPDATE (NO ACTION|CASCADE|SET NULL|SET DEFAULT))?/i;
73
+ const match = sql.match(regex);
74
+
75
+ if (!match) {
76
+ return null;
77
+ }
78
+ let onDelete = null;
79
+ let onUpdate = null;
80
+ if(sql.includes('ON DELETE CASCADE')){
81
+ onDelete = 'CASCADE';
82
+ }else if(sql.includes('ON DELETE SET NULL')){
83
+ onDelete = 'SET NULL';
84
+ }else if(sql.includes('ON DELETE SET DEFAULT')){
85
+ onDelete = 'SET DEFAULT';
86
+ }else{
87
+ onDelete = 'NO ACTION';
88
+ }
89
+
90
+ if(sql.includes('ON UPDATE CASCADE')){
91
+ onUpdate = 'CASCADE';
92
+ }else if(sql.includes('ON UPDATE SET NULL')){
93
+ onUpdate = 'SET NULL';
94
+ }else if(sql.includes('ON UPDATE SET DEFAULT')){
95
+ onUpdate = 'SET DEFAULT';
96
+ }else{
97
+ onUpdate = 'NO ACTION';
98
+ }
99
+ let columnName = match[1] || ''
100
+ columnName = columnName.replace(/`/g, '')
101
+ let table = match[2] || ''
102
+ table = table.replace(/`/g, '')
103
+ let column = match[3] || ''
104
+ column = column.replace(/`/g, '')
105
+ return {
106
+ Name: "AlertRule", // Replace with actual constraint name if needed
107
+ Type: "FOREIGN KEY",
108
+ ColumnName: [columnName], // Column in the current table
109
+ References: {
110
+ Table: table, // Referenced table
111
+ Column: column, // Referenced column
112
+ },
113
+ OnDelete: onDelete,
114
+ OnUpdate: onUpdate,
115
+ };
116
+ };
117
+
118
+ // A quoted SQL string, allowing '' and backslash escapes inside it
119
+ const quotedStringRegex = /'(?:[^'\\]|\\.|'')*'/g;
120
+
121
+ const unquote = (quoted) => quoted.slice(1, -1).replace(/''/g, "'").replace(/\\(.)/g, '$1');
122
+
123
+ // Type with its parameters: int, float(10,2), enum('a','b'), varchar(45); then unsigned/zerofill
124
+ const columnTypeRegex = /^([a-zA-Z]+(?:\((?:'(?:[^'\\]|\\.|'')*'|[^)'])*\))?)((?:\s+(?:unsigned|zerofill)\b)*)/i;
125
+
126
+ /**
127
+ * Parse one column line of a SHOW CREATE TABLE statement, e.g.
128
+ * `IID` varchar(25) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'x',
129
+ * Returns null if the line isn't a column definition.
130
+ */
131
+ export const parseColumnDefinition = (line) => {
132
+ const lineMatch = line.trim().match(/^`([^`]+)`\s+(.*?),?$/);
133
+ if (!lineMatch) return null;
134
+ const typeMatch = lineMatch[2].match(columnTypeRegex);
135
+ if (!typeMatch) return null;
136
+
137
+ const column = {
138
+ Field: lineMatch[1],
139
+ // unsigned/zerofill are part of the type: a column restated without them changes
140
+ Type: typeMatch[1] + typeMatch[2].toLowerCase()
141
+ };
142
+
143
+ // Swap quoted strings for numbered placeholders so keywords inside a
144
+ // COMMENT or DEFAULT value aren't matched as attributes
145
+ const strings = [];
146
+ const quoted = [];
147
+ const rest = lineMatch[2].slice(typeMatch[0].length).replace(quotedStringRegex, (text) => {
148
+ strings.push(unquote(text));
149
+ quoted.push(text);
150
+ return `'${strings.length - 1}'`;
151
+ });
152
+ // Put the original quoted text back, e.g. b'0' or (_utf8mb4'[]')
153
+ const restoreQuoted = (text) => text.replace(/'(\d+)'/g, (_, i) => quoted[i]);
154
+
155
+ if (/\bNOT NULL\b/i.test(rest)) {
156
+ column.NotNull = true;
157
+ }
158
+
159
+ const defaultMatch = rest.match(/\bDEFAULT\s+('\d+'|\((?:[^()]|\([^()]*\))*\)|[^\s,]+)/i);
160
+ if (defaultMatch) {
161
+ // A plain string default is stored unquoted; anything else (NULL,
162
+ // CURRENT_TIMESTAMP, b'0', an expression) is kept as written
163
+ const whole = defaultMatch[1].match(/^'(\d+)'$/);
164
+ column.Default = whole ? strings[whole[1]] : restoreQuoted(defaultMatch[1]);
165
+ }
166
+
167
+ const generatedMatch = rest.match(/\bGENERATED ALWAYS AS\s*(\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))\s*(STORED|VIRTUAL)?/i);
168
+ if (generatedMatch) {
169
+ column.Generated = `${restoreQuoted(generatedMatch[1])}${generatedMatch[2] ? ` ${generatedMatch[2].toUpperCase()}` : ''}`;
170
+ }
171
+
172
+ if (/\bAUTO_INCREMENT\b/i.test(rest)) {
173
+ column.AutoIncrement = true;
174
+ }
175
+
176
+ const onUpdateMatch = rest.match(/\bON UPDATE\s+(CURRENT_TIMESTAMP(?:\(\d*\))?)/i);
177
+ if (onUpdateMatch) {
178
+ column.OnUpdate = onUpdateMatch[1];
179
+ }
180
+
181
+ const charsetMatch = rest.match(/\bCHARACTER SET\s+(\w+)/i);
182
+ if (charsetMatch) {
183
+ column.Charset = charsetMatch[1];
184
+ }
185
+
186
+ const collateMatch = rest.match(/\bCOLLATE\s+(\w+)/i);
187
+ if (collateMatch) {
188
+ column.Collate = collateMatch[1];
189
+ }
190
+
191
+ const commentMatch = rest.match(/\bCOMMENT\s+'(\d+)'/i);
192
+ if (commentMatch) {
193
+ column.Comment = strings[commentMatch[1]];
194
+ }
195
+
196
+ return column;
197
+ };
package/src/paths.js ADDED
@@ -0,0 +1,41 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // The mysqlsyncr package folder, from src/ or the dist/ build. The CJS build
6
+ // rewrites import.meta.url from __filename; a global __dirname can't be trusted
7
+ // (node -e sets it to '.')
8
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+
10
+ export const isInsidePackage = (dir) => {
11
+ const relative = path.relative(packageRoot, path.resolve(dir));
12
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
13
+ }
14
+
15
+ /**
16
+ * Throw if dir is the mysqlsyncr package folder or inside it. Dumps and
17
+ * templates kept there are lost when the package is reinstalled or updated.
18
+ */
19
+ export const assertOutsidePackage = (dir) => {
20
+ if (dir && isInsidePackage(dir)) {
21
+ throw new Error(`${path.resolve(dir)} is inside the mysqlsyncr package folder (${packageRoot}). Choose a folder outside it with --output.`);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Why dir can't be read as a template: it doesn't exist, isn't a folder, or has
27
+ * no <database>/<database>.json in it. Null when it's usable.
28
+ */
29
+ export const templateFolderProblem = (dir) => {
30
+ const resolved = path.resolve(dir);
31
+ let stat;
32
+ try {
33
+ stat = fs.statSync(resolved);
34
+ } catch {
35
+ return `${resolved} doesn't exist.`;
36
+ }
37
+ if (!stat.isDirectory()) return `${resolved} isn't a folder.`;
38
+ const hasDatabase = fs.readdirSync(resolved, { withFileTypes: true })
39
+ .some(entry => entry.isDirectory() && fs.existsSync(path.join(resolved, entry.name, `${entry.name}.json`)));
40
+ return hasDatabase ? null : `${resolved} has no template in it (no <database>/<database>.json). Run dump first, or choose the template folder.`;
41
+ }
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
+ }