pg-migration-engine 0.1.0

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 (104) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +304 -0
  3. package/package.json +52 -0
  4. package/src/behavioral/behavioral-applier.js +374 -0
  5. package/src/behavioral/behavioral-extractor.js +266 -0
  6. package/src/behavioral/behavioral-puller.js +35 -0
  7. package/src/behavioral/index.js +4 -0
  8. package/src/behavioral/phase-sorter.js +24 -0
  9. package/src/constants.js +97 -0
  10. package/src/ddl-generator/alter-generator.js +1512 -0
  11. package/src/ddl-generator/comment-generator.js +95 -0
  12. package/src/ddl-generator/create-generator.js +1426 -0
  13. package/src/ddl-generator/drop-generator.js +194 -0
  14. package/src/ddl-generator/generator.js +78 -0
  15. package/src/ddl-generator/grant-generator.js +31 -0
  16. package/src/ddl-generator/index.js +3 -0
  17. package/src/ddl-generator/pg-version.js +15 -0
  18. package/src/ddl-generator/rename-generator.js +88 -0
  19. package/src/ddl-generator/safe-patterns.js +241 -0
  20. package/src/differ/change-classifier.js +192 -0
  21. package/src/differ/dependency-resolver.js +523 -0
  22. package/src/differ/index.js +12 -0
  23. package/src/differ/object-matcher.js +510 -0
  24. package/src/differ/property-differ.js +1134 -0
  25. package/src/differ/risk-tagger.js +561 -0
  26. package/src/differ/schema-differ.js +309 -0
  27. package/src/differ/utils/levenshtein.js +210 -0
  28. package/src/differ/utils/path-builder.js +198 -0
  29. package/src/differ/utils/type-compatibility.js +472 -0
  30. package/src/errors.js +147 -0
  31. package/src/executor/drift-detector.js +221 -0
  32. package/src/executor/index.js +7 -0
  33. package/src/executor/lock-manager.js +308 -0
  34. package/src/executor/migration-executor.js +1249 -0
  35. package/src/executor/progress-tracker.js +81 -0
  36. package/src/executor/recovery-manager.js +47 -0
  37. package/src/executor/snapshot-manager.js +22 -0
  38. package/src/executor/sql-splitter.js +120 -0
  39. package/src/executor/transaction-manager.js +288 -0
  40. package/src/index.js +483 -0
  41. package/src/introspection/index.js +4 -0
  42. package/src/introspection/introspector.js +250 -0
  43. package/src/introspection/queries/access-methods.js +29 -0
  44. package/src/introspection/queries/casts.js +38 -0
  45. package/src/introspection/queries/collations.js +51 -0
  46. package/src/introspection/queries/comments.js +66 -0
  47. package/src/introspection/queries/constraints.js +65 -0
  48. package/src/introspection/queries/conversions.js +39 -0
  49. package/src/introspection/queries/databases.js +43 -0
  50. package/src/introspection/queries/default-privileges.js +37 -0
  51. package/src/introspection/queries/event-triggers.js +43 -0
  52. package/src/introspection/queries/extensions.js +21 -0
  53. package/src/introspection/queries/foreign-data.js +136 -0
  54. package/src/introspection/queries/functions.js +75 -0
  55. package/src/introspection/queries/grants.js +38 -0
  56. package/src/introspection/queries/index.js +33 -0
  57. package/src/introspection/queries/indexes.js +99 -0
  58. package/src/introspection/queries/inheritance.js +24 -0
  59. package/src/introspection/queries/multiranges.js +37 -0
  60. package/src/introspection/queries/operators.js +180 -0
  61. package/src/introspection/queries/partitions.js +42 -0
  62. package/src/introspection/queries/pg18-19.js +43 -0
  63. package/src/introspection/queries/policies.js +35 -0
  64. package/src/introspection/queries/procedural-languages.js +36 -0
  65. package/src/introspection/queries/publications.js +114 -0
  66. package/src/introspection/queries/roles.js +66 -0
  67. package/src/introspection/queries/rules.js +49 -0
  68. package/src/introspection/queries/sequences.js +37 -0
  69. package/src/introspection/queries/statistics.js +74 -0
  70. package/src/introspection/queries/subscriptions.js +99 -0
  71. package/src/introspection/queries/tables.js +151 -0
  72. package/src/introspection/queries/tablespaces.js +36 -0
  73. package/src/introspection/queries/text-search.js +146 -0
  74. package/src/introspection/queries/triggers.js +61 -0
  75. package/src/introspection/queries/types.js +106 -0
  76. package/src/introspection/queries/views.js +146 -0
  77. package/src/introspection/translator.js +1283 -0
  78. package/src/introspection/version-detector.js +25 -0
  79. package/src/planner/backfill-planner.js +36 -0
  80. package/src/planner/dry-run.js +21 -0
  81. package/src/planner/index.js +6 -0
  82. package/src/planner/migration-planner.js +356 -0
  83. package/src/planner/smart-migrator.js +82 -0
  84. package/src/planner/step-sequencer.js +61 -0
  85. package/src/planner/type-registry.js +47 -0
  86. package/src/risk/compatibility-checker.js +29 -0
  87. package/src/risk/data-loss-checker.js +32 -0
  88. package/src/risk/destructive-checker.js +38 -0
  89. package/src/risk/index.js +6 -0
  90. package/src/risk/lock-analyzer.js +39 -0
  91. package/src/risk/recommendations.js +44 -0
  92. package/src/risk/risk-engine.js +25 -0
  93. package/src/storage/index.js +5 -0
  94. package/src/storage/memory-storage.js +87 -0
  95. package/src/storage/migration-table.js +423 -0
  96. package/src/storage/rollback-generator.js +344 -0
  97. package/src/types/changes.js +136 -0
  98. package/src/types/connection.js +17 -0
  99. package/src/types/execution.js +107 -0
  100. package/src/types/index.js +1 -0
  101. package/src/types/migration.js +67 -0
  102. package/src/types/risk.js +32 -0
  103. package/src/types/schema.d.ts +1004 -0
  104. package/src/types/schema.js +561 -0
@@ -0,0 +1,81 @@
1
+ export class ProgressTracker {
2
+ constructor() {
3
+ this.progress = new Map();
4
+ this.listeners = new Set();
5
+ this.events = [];
6
+ }
7
+
8
+ /**
9
+ * @param {string} stepId
10
+ * @param {'PENDING'|'RUNNING'|'COMPLETED'|'FAILED'} status
11
+ * @param {string} [message]
12
+ */
13
+ update(stepId, status, message) {
14
+ this.progress.set(stepId, { status, message, timestamp: Date.now() });
15
+ for (const listener of this.listeners) {
16
+ listener({ stepId, status, message, timestamp: Date.now() });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Emit a progress event
22
+ * @param {Object} event
23
+ */
24
+ emit(event) {
25
+ this.events.push({
26
+ ...event,
27
+ timestamp: event.timestamp || Date.now(),
28
+ });
29
+
30
+ for (const listener of this.listeners) {
31
+ try {
32
+ listener(event);
33
+ } catch (e) {
34
+ // Don't let listener errors break execution
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Subscribe to progress events
41
+ * @param {Function} listener
42
+ * @returns {Function} Unsubscribe function
43
+ */
44
+ subscribe(listener) {
45
+ this.listeners.add(listener);
46
+ return () => this.listeners.delete(listener);
47
+ }
48
+
49
+ /**
50
+ * Get progress for a step
51
+ * @param {string} stepId
52
+ * @returns {Object}
53
+ */
54
+ get(stepId) {
55
+ return this.progress.get(stepId) || { status: 'PENDING' };
56
+ }
57
+
58
+ /**
59
+ * Get all progress
60
+ * @returns {Object}
61
+ */
62
+ getAll() {
63
+ return Object.fromEntries(this.progress);
64
+ }
65
+
66
+ /**
67
+ * Get all events
68
+ * @returns {Array}
69
+ */
70
+ getEvents() {
71
+ return this.events;
72
+ }
73
+
74
+ /**
75
+ * Clear all progress and events
76
+ */
77
+ clear() {
78
+ this.progress.clear();
79
+ this.events = [];
80
+ }
81
+ }
@@ -0,0 +1,47 @@
1
+ export class RecoveryManager {
2
+ /** @param {import('../types/execution.js').StorageProvider} storage */
3
+ constructor(storage) {
4
+ this.storage = storage;
5
+ }
6
+
7
+ /**
8
+ * @param {string} migrationId
9
+ * @returns {Promise<import('../types/execution.js').RecoveryResult>}
10
+ */
11
+ async recover(migrationId) {
12
+ const migration = await this.storage.getMigration(migrationId);
13
+ if (!migration) {
14
+ return { action: 'UNKNOWN', message: 'Migration not found' };
15
+ }
16
+
17
+ if (migration.status === 'COMPLETED') {
18
+ return { action: 'NONE', message: 'Migration already completed' };
19
+ }
20
+
21
+ if (migration.status === 'FAILED') {
22
+ return { action: 'RETRY', message: 'Database is consistent, migration can be retried' };
23
+ }
24
+
25
+ if (migration.status === 'RUNNING') {
26
+ return { action: 'WAIT', message: 'Migration is still running' };
27
+ }
28
+
29
+ if (migration.status === 'STALE') {
30
+ return { action: 'STALE_CLEANUP', message: 'Migration process died, cleaning up' };
31
+ }
32
+
33
+ return { action: 'UNKNOWN', message: `Unknown migration status: ${migration.status}` };
34
+ }
35
+
36
+ /**
37
+ * @param {string} migrationId
38
+ * @returns {Promise<void>}
39
+ */
40
+ async markFailed(migrationId) {
41
+ const migration = await this.storage.getMigration(migrationId);
42
+ if (migration) {
43
+ migration.status = 'FAILED';
44
+ await this.storage.saveMigration(migration);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,22 @@
1
+ export class SnapshotManager {
2
+ /**
3
+ * @param {import('../introspection/index.js').SchemaIntrospector} introspector
4
+ * @param {import('../types/execution.js').StorageProvider} storage
5
+ */
6
+ constructor(introspector, storage) {
7
+ this.introspector = introspector;
8
+ this.storage = storage;
9
+ }
10
+
11
+ /** @returns {Promise<string>} */
12
+ async capture() {
13
+ const schema = await this.introspector.introspect();
14
+ await this.storage.saveSnapshot(schema.checksum, schema);
15
+ return schema.checksum;
16
+ }
17
+
18
+ /** @param {string} checksum */
19
+ async restore(checksum) {
20
+ return this.storage.loadSnapshot(checksum);
21
+ }
22
+ }
@@ -0,0 +1,120 @@
1
+ const DOLLAR_QUOTE_REGEX = /\$([a-zA-Z_][a-zA-Z0-9_]*)?\$/g;
2
+
3
+ export function splitSqlStatements(sql) {
4
+ if (!sql || typeof sql !== 'string') return [];
5
+
6
+ const statements = [];
7
+ let current = '';
8
+ let i = 0;
9
+
10
+ while (i < sql.length) {
11
+ if (sql[i] === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
12
+ current += sql[i];
13
+ i++;
14
+ while (i < sql.length && sql[i] !== '\n') {
15
+ current += sql[i];
16
+ i++;
17
+ }
18
+ if (i < sql.length) {
19
+ current += sql[i];
20
+ i++;
21
+ }
22
+ continue;
23
+ }
24
+
25
+ if (sql[i] === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
26
+ current += sql[i];
27
+ i++;
28
+ current += sql[i];
29
+ i++;
30
+ while (i < sql.length && !(sql[i - 1] === '*' && sql[i] === '/')) {
31
+ current += sql[i];
32
+ i++;
33
+ }
34
+ if (i < sql.length) {
35
+ current += sql[i];
36
+ i++;
37
+ }
38
+ continue;
39
+ }
40
+
41
+ if (sql[i] === '$') {
42
+ const dollarMatch = sql.substring(i).match(DOLLAR_QUOTE_REGEX);
43
+ if (dollarMatch) {
44
+ const tag = dollarMatch[0];
45
+ current += tag;
46
+ i += tag.length;
47
+
48
+ const endPattern = tag;
49
+ while (i < sql.length) {
50
+ if (sql.substring(i, i + tag.length) === endPattern) {
51
+ current += tag;
52
+ i += tag.length;
53
+ break;
54
+ }
55
+ current += sql[i];
56
+ i++;
57
+ }
58
+ continue;
59
+ }
60
+ }
61
+
62
+ if (sql[i] === "'") {
63
+ current += sql[i];
64
+ i++;
65
+ while (i < sql.length) {
66
+ if (sql[i] === "'") {
67
+ current += sql[i];
68
+ i++;
69
+ if (i < sql.length && sql[i] === "'") {
70
+ current += sql[i];
71
+ i++;
72
+ continue;
73
+ }
74
+ break;
75
+ }
76
+ current += sql[i];
77
+ i++;
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (sql[i] === '"') {
83
+ current += sql[i];
84
+ i++;
85
+ while (i < sql.length && sql[i] !== '"') {
86
+ current += sql[i];
87
+ i++;
88
+ }
89
+ if (i < sql.length) {
90
+ current += sql[i];
91
+ i++;
92
+ }
93
+ continue;
94
+ }
95
+
96
+ if (sql[i] === ';') {
97
+ const trimmed = current.trim();
98
+ if (trimmed.length > 0) {
99
+ statements.push(trimmed);
100
+ }
101
+ current = '';
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ current += sql[i];
107
+ i++;
108
+ }
109
+
110
+ const trimmed = current.trim();
111
+ if (trimmed.length > 0) {
112
+ statements.push(trimmed);
113
+ }
114
+
115
+ return statements;
116
+ }
117
+
118
+ export function sanitizeSavepointName(str) {
119
+ return str.replace(/[^a-zA-Z0-9_]/g, '_').substring(0, 63);
120
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Transaction Manager - Handles transaction execution with proper error handling
3
+ */
4
+
5
+ export class TransactionManager {
6
+ /**
7
+ * @param {import('pg').Pool} pool
8
+ */
9
+ constructor(pool) {
10
+ this.pool = pool;
11
+ }
12
+
13
+ /**
14
+ * Execute steps in a transaction
15
+ * @param {Array} steps - Steps to execute
16
+ * @param {Object} options - { lockTimeout, statementTimeout, dryRun, continueOnError }
17
+ * @returns {Promise<Array>} Results of each step
18
+ */
19
+ async executeTransactional(steps, options = {}) {
20
+ const client = await this.pool.connect();
21
+ const results = [];
22
+
23
+ try {
24
+ await client.query('BEGIN');
25
+
26
+ if (options.lockTimeout) {
27
+ await client.query(`SET LOCAL lock_timeout = '${options.lockTimeout}'`);
28
+ }
29
+ if (options.statementTimeout) {
30
+ await client.query(`SET LOCAL statement_timeout = '${options.statementTimeout}'`);
31
+ }
32
+
33
+ await client.query(`SET LOCAL search_path = 'public'`);
34
+
35
+ for (const step of steps) {
36
+ const startTime = Date.now();
37
+ try {
38
+ const result = await client.query(step.sql);
39
+ results.push({
40
+ stepId: step.id,
41
+ success: true,
42
+ duration: Date.now() - startTime,
43
+ rowCount: result.rowCount,
44
+ rows: result.rows,
45
+ });
46
+ } catch (error) {
47
+ results.push({
48
+ stepId: step.id,
49
+ success: false,
50
+ duration: Date.now() - startTime,
51
+ error: error.message,
52
+ code: error.code,
53
+ });
54
+
55
+ if (!options.continueOnError) {
56
+ throw error;
57
+ }
58
+ }
59
+ }
60
+
61
+ if (options.dryRun) {
62
+ await client.query('ROLLBACK');
63
+ } else {
64
+ await client.query('COMMIT');
65
+ }
66
+
67
+ return results;
68
+
69
+ } catch (error) {
70
+ await client.query('ROLLBACK').catch(() => {});
71
+ throw error;
72
+ } finally {
73
+ client.release();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Execute a single non-transactional step
79
+ * @param {Object} step - The step to execute
80
+ * @param {Object} options - { statementTimeout }
81
+ * @returns {Promise<Object>}
82
+ */
83
+ async executeNonTransactional(step, options = {}) {
84
+ const client = await this.pool.connect();
85
+
86
+ try {
87
+ if (options.statementTimeout) {
88
+ await client.query(`SET statement_timeout = '${options.statementTimeout}'`);
89
+ }
90
+
91
+ const startTime = Date.now();
92
+ const result = await client.query(step.sql);
93
+
94
+ return {
95
+ stepId: step.id,
96
+ success: true,
97
+ duration: Date.now() - startTime,
98
+ rowCount: result.rowCount,
99
+ isTransactional: false,
100
+ warning: 'This step was executed outside a transaction and cannot be automatically rolled back',
101
+ };
102
+
103
+ } catch (error) {
104
+ return {
105
+ stepId: step.id,
106
+ success: false,
107
+ error: error.message,
108
+ code: error.code,
109
+ isTransactional: false,
110
+ requiresManualRecovery: true,
111
+ };
112
+ } finally {
113
+ client.release();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Execute steps with validation first, then for real
119
+ * @param {Array} steps
120
+ * @param {Object} options
121
+ * @returns {Promise<Object>}
122
+ */
123
+ async executeWithValidation(steps, options = {}) {
124
+ const validationResult = await this.executeTransactional(steps, {
125
+ ...options,
126
+ dryRun: true,
127
+ });
128
+
129
+ const hasErrors = validationResult.some(r => !r.success);
130
+ if (hasErrors) {
131
+ return {
132
+ validated: false,
133
+ validationResults: validationResult,
134
+ message: 'Validation failed. Migration not applied.',
135
+ };
136
+ }
137
+
138
+ if (!options.dryRun) {
139
+ const executionResult = await this.executeTransactional(steps, {
140
+ ...options,
141
+ dryRun: false,
142
+ });
143
+ return {
144
+ validated: true,
145
+ executionResults: executionResult,
146
+ };
147
+ }
148
+
149
+ return {
150
+ validated: true,
151
+ validationResults: validationResult,
152
+ message: 'Validation passed (dry run). No changes applied.',
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Execute with savepoint support for partial rollback
158
+ * @param {Array} steps
159
+ * @param {Object} options
160
+ * @returns {Promise<Object>}
161
+ */
162
+ async executeWithSavepoints(steps, options = {}) {
163
+ const client = await this.pool.connect();
164
+ const results = [];
165
+ let lastSuccessfulSavepoint = 0;
166
+
167
+ try {
168
+ await client.query('BEGIN');
169
+
170
+ if (options.lockTimeout) {
171
+ await client.query(`SET LOCAL lock_timeout = '${options.lockTimeout}'`);
172
+ }
173
+ if (options.statementTimeout) {
174
+ await client.query(`SET LOCAL statement_timeout = '${options.statementTimeout}'`);
175
+ }
176
+
177
+ for (let i = 0; i < steps.length; i++) {
178
+ const step = steps[i];
179
+ const savepointName = `sp_${i}`;
180
+
181
+ await client.query(`SAVEPOINT ${savepointName}`);
182
+
183
+ const startTime = Date.now();
184
+ try {
185
+ const result = await client.query(step.sql);
186
+ results.push({
187
+ stepId: step.id,
188
+ success: true,
189
+ duration: Date.now() - startTime,
190
+ rowCount: result.rowCount,
191
+ savepoint: savepointName,
192
+ });
193
+ lastSuccessfulSavepoint = i;
194
+ } catch (error) {
195
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
196
+
197
+ results.push({
198
+ stepId: step.id,
199
+ success: false,
200
+ duration: Date.now() - startTime,
201
+ error: error.message,
202
+ code: error.code,
203
+ savepoint: savepointName,
204
+ });
205
+
206
+ if (!options.continueOnError) {
207
+ throw error;
208
+ }
209
+ }
210
+ }
211
+
212
+ if (options.dryRun) {
213
+ await client.query('ROLLBACK');
214
+ } else {
215
+ await client.query('COMMIT');
216
+ }
217
+
218
+ return {
219
+ success: true,
220
+ results,
221
+ lastSuccessfulSavepoint,
222
+ };
223
+
224
+ } catch (error) {
225
+ await client.query('ROLLBACK').catch(() => {});
226
+ return {
227
+ success: false,
228
+ results,
229
+ lastSuccessfulSavepoint,
230
+ error: error.message,
231
+ };
232
+ } finally {
233
+ client.release();
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Execute with retry on deadlock
239
+ * @param {Array} steps
240
+ * @param {Object} options
241
+ * @returns {Promise<Object>}
242
+ */
243
+ async executeWithRetry(steps, options = {}) {
244
+ const maxRetries = options.maxRetries || 3;
245
+ const retryDelay = options.retryDelay || 1000;
246
+ let lastError;
247
+
248
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
249
+ try {
250
+ const result = await this.executeTransactional(steps, options);
251
+ return {
252
+ success: true,
253
+ results: result,
254
+ attempts: attempt,
255
+ };
256
+ } catch (error) {
257
+ lastError = error;
258
+
259
+ const isDeadlock = error.code === '40P01';
260
+ const isLockNotAvailable = error.code === '55P03';
261
+ const isSerializationFailure = error.code === '40001';
262
+
263
+ if (!isDeadlock && !isLockNotAvailable && !isSerializationFailure) {
264
+ throw error;
265
+ }
266
+
267
+ if (attempt < maxRetries) {
268
+ await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
269
+ }
270
+ }
271
+ }
272
+
273
+ return {
274
+ success: false,
275
+ error: lastError?.message,
276
+ attempts: maxRetries,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Dry run a single step
282
+ * @param {Object} step
283
+ * @returns {Promise<Object>}
284
+ */
285
+ async dryRun(step) {
286
+ return this.executeTransactional([step], { dryRun: true });
287
+ }
288
+ }