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,221 @@
1
+ /**
2
+ * Drift Detector - Detect schema drift during migration execution
3
+ */
4
+
5
+ export class DriftDetector {
6
+ /**
7
+ * Compare before/after snapshots to detect drift
8
+ * @param {Object} snapshotBefore - Snapshot before migration
9
+ * @param {Object} snapshotAfter - Snapshot after migration
10
+ * @param {Object} expectedDiff - The diff we applied
11
+ * @returns {Object} Drift report
12
+ */
13
+ detect(snapshotBefore, snapshotAfter, expectedDiff) {
14
+ const drift = {
15
+ detected: false,
16
+ unexpectedChanges: [],
17
+ missingChanges: [],
18
+ extraChanges: [],
19
+ summary: {
20
+ objectsModified: 0,
21
+ objectsCreated: 0,
22
+ objectsDropped: 0,
23
+ },
24
+ };
25
+
26
+ if (!snapshotBefore?.checksums || !snapshotAfter?.checksums) {
27
+ return drift;
28
+ }
29
+
30
+ const beforeMap = new Map(
31
+ snapshotBefore.checksums.map(c => [`${c.schema}.${c.name}.${c.kind}`, c.checksum])
32
+ );
33
+ const afterMap = new Map(
34
+ snapshotAfter.checksums.map(c => [`${c.schema}.${c.name}.${c.kind}`, c.checksum])
35
+ );
36
+
37
+ const ourPaths = new Set(
38
+ (expectedDiff.changes || []).map(c => c.objectKey || c.path)
39
+ );
40
+
41
+ for (const [key, afterChecksum] of afterMap) {
42
+ const beforeChecksum = beforeMap.get(key);
43
+
44
+ if (beforeChecksum && beforeChecksum !== afterChecksum) {
45
+ if (!ourPaths.has(key)) {
46
+ drift.detected = true;
47
+ drift.unexpectedChanges.push({
48
+ path: key,
49
+ type: 'modified',
50
+ message: `Schema object ${key} was modified by another process during migration`,
51
+ });
52
+ drift.summary.objectsModified++;
53
+ }
54
+ }
55
+ }
56
+
57
+ for (const [key] of beforeMap) {
58
+ if (!afterMap.has(key)) {
59
+ const wasDropped = (expectedDiff.changes || []).some(
60
+ c => (c.objectKey === key || c.path === key) && c.changeType?.startsWith('DROP')
61
+ );
62
+ if (!wasDropped) {
63
+ drift.detected = true;
64
+ drift.unexpectedChanges.push({
65
+ path: key,
66
+ type: 'dropped',
67
+ message: `Schema object ${key} was dropped by another process during migration`,
68
+ });
69
+ drift.summary.objectsDropped++;
70
+ }
71
+ }
72
+ }
73
+
74
+ for (const [key] of afterMap) {
75
+ if (!beforeMap.has(key)) {
76
+ const wasCreated = (expectedDiff.changes || []).some(
77
+ c => (c.objectKey === key || c.path === key) && c.changeType?.startsWith('CREATE')
78
+ );
79
+ if (!wasCreated) {
80
+ drift.detected = true;
81
+ drift.unexpectedChanges.push({
82
+ path: key,
83
+ type: 'created',
84
+ message: `Schema object ${key} was created by another process during migration`,
85
+ });
86
+ drift.summary.objectsCreated++;
87
+ }
88
+ }
89
+ }
90
+
91
+ const expectedCreates = (expectedDiff.changes || []).filter(
92
+ c => c.changeType?.startsWith('CREATE')
93
+ );
94
+ for (const expected of expectedCreates) {
95
+ const key = expected.objectKey || expected.path;
96
+ if (!afterMap.has(key)) {
97
+ drift.missingChanges.push({
98
+ path: key,
99
+ expectedType: 'CREATE',
100
+ message: `Expected object ${key} was not created`,
101
+ });
102
+ }
103
+ }
104
+
105
+ return drift;
106
+ }
107
+
108
+ /**
109
+ * Compare two schema snapshots for column-level drift
110
+ * @param {Object} snapshotBefore
111
+ * @param {Object} snapshotAfter
112
+ * @param {string} tableName - Table to check
113
+ * @returns {Object} Column-level drift report
114
+ */
115
+ detectColumnDrift(snapshotBefore, snapshotAfter, tableName) {
116
+ const drift = {
117
+ tableName,
118
+ columnsAdded: [],
119
+ columnsDropped: [],
120
+ columnsModified: [],
121
+ };
122
+
123
+ const beforeCols = this.getTableColumns(snapshotBefore, tableName);
124
+ const afterCols = this.getTableColumns(snapshotAfter, tableName);
125
+
126
+ for (const [colName, afterCol] of Object.entries(afterCols)) {
127
+ if (!beforeCols[colName]) {
128
+ drift.columnsAdded.push(colName);
129
+ } else if (JSON.stringify(beforeCols[colName]) !== JSON.stringify(afterCol)) {
130
+ drift.columnsModified.push({
131
+ name: colName,
132
+ before: beforeCols[colName],
133
+ after: afterCol,
134
+ });
135
+ }
136
+ }
137
+
138
+ for (const colName of Object.keys(beforeCols)) {
139
+ if (!afterCols[colName]) {
140
+ drift.columnsDropped.push(colName);
141
+ }
142
+ }
143
+
144
+ drift.hasDrift = drift.columnsAdded.length > 0 ||
145
+ drift.columnsDropped.length > 0 ||
146
+ drift.columnsModified.length > 0;
147
+
148
+ return drift;
149
+ }
150
+
151
+ /**
152
+ * Get columns for a table from snapshot
153
+ * @param {Object} snapshot
154
+ * @param {string} tableName
155
+ * @returns {Object}
156
+ */
157
+ getTableColumns(snapshot, tableName) {
158
+ if (!snapshot?.schemas) return {};
159
+
160
+ const parts = tableName.split('.');
161
+ const schemaName = parts.length > 1 ? parts[0] : 'public';
162
+ const table = parts.length > 1 ? parts[1] : parts[0];
163
+
164
+ const schema = snapshot.schemas[schemaName];
165
+ if (!schema?.tables) return {};
166
+
167
+ const tableObj = schema.tables.find(t => t.name === table);
168
+ if (!tableObj?.columns) return {};
169
+
170
+ const cols = {};
171
+ for (const col of tableObj.columns) {
172
+ cols[col.name] = {
173
+ dataType: col.dataType,
174
+ isNullable: col.isNullable,
175
+ defaultValue: col.defaultValue,
176
+ };
177
+ }
178
+ return cols;
179
+ }
180
+
181
+ /**
182
+ * Quick drift check using pg_stat_* system view activity
183
+ * @param {import('pg').Pool} pool
184
+ * @returns {Promise<Object>}
185
+ */
186
+ async quickDriftCheck(pool) {
187
+ const client = await pool.connect();
188
+
189
+ try {
190
+ const activityQuery = `
191
+ SELECT DISTINCT
192
+ query,
193
+ state,
194
+ query_start,
195
+ now() - query_start AS duration
196
+ FROM pg_stat_activity
197
+ WHERE state = 'active'
198
+ AND query NOT LIKE 'pg_stat_activity%'
199
+ AND pid != pg_backend_pid()
200
+ AND now() - query_start < interval '5 minutes'
201
+ ORDER BY query_start
202
+ `;
203
+
204
+ const activity = await client.query(activityQuery);
205
+
206
+ const ddlActivity = activity.rows.filter(r =>
207
+ r.query.toUpperCase().match(/^(CREATE|ALTER|DROP|TRUNCATE)\s/)
208
+ );
209
+
210
+ return {
211
+ hasDDLActivity: ddlActivity.length > 0,
212
+ activityCount: activity.rows.length,
213
+ ddlActivityCount: ddlActivity.length,
214
+ ddlQueries: ddlActivity,
215
+ };
216
+
217
+ } finally {
218
+ client.release();
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,7 @@
1
+ export { MigrationExecutor } from './migration-executor.js';
2
+ export { TransactionManager } from './transaction-manager.js';
3
+ export { LockManager } from './lock-manager.js';
4
+ export { ProgressTracker } from './progress-tracker.js';
5
+ export { SnapshotManager } from './snapshot-manager.js';
6
+ export { DriftDetector } from './drift-detector.js';
7
+ export { RecoveryManager } from './recovery-manager.js';
@@ -0,0 +1,308 @@
1
+ import crypto from 'crypto';
2
+
3
+ export class LockManager {
4
+ isLocked = false;
5
+ lockId = null;
6
+ connectionId = null;
7
+ _heartbeatTimer = null;
8
+ _heartbeatClient = null;
9
+
10
+ constructor(pool, options = {}) {
11
+ this.pool = pool;
12
+ this.locks = new Map();
13
+ this.connectionId = options.connectionId || null;
14
+ this.lockId = this.computeLockKey(this.connectionId);
15
+ }
16
+
17
+ computeLockKey(connectionId) {
18
+ if (!connectionId) {
19
+ console.warn(
20
+ '[SchemaWeaver] No connectionId for lock key computation. ' +
21
+ 'Using default lock key. This may cause cross-database lock conflicts.'
22
+ );
23
+ return 12345;
24
+ }
25
+
26
+ const hash = crypto.createHash('sha256')
27
+ .update(`schema-weaver-lock:${connectionId}`)
28
+ .digest();
29
+
30
+ const lockKey = hash.readInt32BE(0);
31
+
32
+ return Math.abs(lockKey) || 1;
33
+ }
34
+
35
+ async acquire(lockKey, timeout) {
36
+ const effectiveKey = lockKey || this.lockId;
37
+ const client = await this.pool.connect();
38
+
39
+ try {
40
+ if (timeout) {
41
+ await client.query(`SET lock_timeout = '${timeout}'`);
42
+ }
43
+
44
+ const result = await client.query(
45
+ 'SELECT pg_try_advisory_lock($1) as acquired',
46
+ [effectiveKey]
47
+ );
48
+
49
+ const acquired = result.rows[0].acquired;
50
+
51
+ if (acquired) {
52
+ this.isLocked = true;
53
+ this.lockId = effectiveKey;
54
+ this.locks.set(effectiveKey, {
55
+ acquiredAt: new Date().toISOString(),
56
+ client,
57
+ key: effectiveKey,
58
+ });
59
+ }
60
+
61
+ return acquired;
62
+
63
+ } catch (error) {
64
+ client.release();
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ async acquireAndWait(lockKey, timeout) {
70
+ const effectiveKey = lockKey || this.lockId;
71
+ const client = await this.pool.connect();
72
+
73
+ try {
74
+ if (timeout) {
75
+ await client.query(`SET lock_timeout = '${timeout}'`);
76
+ }
77
+
78
+ const startTime = Date.now();
79
+
80
+ await client.query('SELECT pg_advisory_lock($1)', [effectiveKey]);
81
+
82
+ this.isLocked = true;
83
+ this.lockId = effectiveKey;
84
+ this.locks.set(effectiveKey, {
85
+ acquiredAt: new Date().toISOString(),
86
+ client,
87
+ key: effectiveKey,
88
+ waitTime: Date.now() - startTime,
89
+ });
90
+
91
+ return true;
92
+
93
+ } catch (error) {
94
+ client.release();
95
+
96
+ if (error.code === '55P03') {
97
+ return false;
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ async release(lockKey) {
104
+ const effectiveKey = lockKey || this.lockId;
105
+ const lockInfo = this.locks.get(effectiveKey);
106
+
107
+ this.stopHeartbeat();
108
+
109
+ if (lockInfo) {
110
+ const { client } = lockInfo;
111
+
112
+ try {
113
+ await client.query('SELECT pg_advisory_unlock($1)', [effectiveKey]);
114
+ this.locks.delete(effectiveKey);
115
+ this.isLocked = false;
116
+ this.lockId = null;
117
+ return true;
118
+ } catch {
119
+ this.isLocked = false;
120
+ this.lockId = null;
121
+ return false;
122
+ } finally {
123
+ client.release();
124
+ }
125
+ }
126
+
127
+ const standaloneClient = await this.pool.connect();
128
+ try {
129
+ await standaloneClient.query('SELECT pg_advisory_unlock($1)', [effectiveKey]);
130
+ this.locks.delete(effectiveKey);
131
+ this.isLocked = false;
132
+ this.lockId = null;
133
+ return true;
134
+ } catch {
135
+ this.isLocked = false;
136
+ this.lockId = null;
137
+ return false;
138
+ } finally {
139
+ standaloneClient.release();
140
+ }
141
+ }
142
+
143
+ async isLocked(lockKey) {
144
+ const effectiveKey = lockKey || this.lockId;
145
+ const selfHeld = this.locks.has(effectiveKey);
146
+
147
+ if (selfHeld) {
148
+ return { held: true, heldBySelf: true };
149
+ }
150
+
151
+ const client = await this.pool.connect();
152
+ try {
153
+ const result = await client.query(
154
+ 'SELECT pg_try_advisory_lock($1) as acquired',
155
+ [effectiveKey]
156
+ );
157
+
158
+ const acquired = result.rows[0].acquired;
159
+
160
+ if (acquired) {
161
+ await client.query('SELECT pg_advisory_unlock($1)', [effectiveKey]);
162
+ return { held: false, heldBySelf: false };
163
+ }
164
+
165
+ return { held: true, heldBySelf: false };
166
+
167
+ } finally {
168
+ client.release();
169
+ }
170
+ }
171
+
172
+ isHeldBySelf(lockKey) {
173
+ const effectiveKey = lockKey || this.lockId;
174
+ return this.locks.has(effectiveKey);
175
+ }
176
+
177
+ async releaseAll() {
178
+ let count = 0;
179
+
180
+ for (const [lockKey] of this.locks) {
181
+ try {
182
+ await this.release(lockKey);
183
+ count++;
184
+ } catch {
185
+ }
186
+ }
187
+
188
+ return count;
189
+ }
190
+
191
+ startHeartbeat(lockKey, intervalMs = 30000) {
192
+ this.stopHeartbeat();
193
+ const effectiveKey = lockKey || this.lockId;
194
+ this.lockId = effectiveKey;
195
+
196
+ this._heartbeatTimer = setInterval(async () => {
197
+ try {
198
+ await this.heartbeat();
199
+ } catch {
200
+ }
201
+ }, intervalMs);
202
+ }
203
+
204
+ stopHeartbeat() {
205
+ if (this._heartbeatTimer) {
206
+ clearInterval(this._heartbeatTimer);
207
+ this._heartbeatTimer = null;
208
+ }
209
+ }
210
+
211
+ async heartbeat() {
212
+ if (!this.isLocked || !this.lockId) {
213
+ return false;
214
+ }
215
+
216
+ const held = await this.isLockHeld(this.lockId);
217
+ if (!held) {
218
+ this.isLocked = false;
219
+ throw new Error(`Advisory lock ${this.lockId} is no longer held. Connection may have been recycled.`);
220
+ }
221
+ return true;
222
+ }
223
+
224
+ async isLockHeld(lockKey) {
225
+ const effectiveKey = lockKey || this.lockId;
226
+ const result = await this.pool.query(
227
+ `SELECT COUNT(*)::int AS count FROM pg_locks WHERE locktype = 'advisory' AND objid = $1`,
228
+ [effectiveKey]
229
+ );
230
+ return result.rows[0].count > 0;
231
+ }
232
+
233
+ async forceRelease(lockKey) {
234
+ const effectiveKey = lockKey || this.lockId;
235
+ const client = await this.pool.connect();
236
+ try {
237
+ const result = await client.query(
238
+ `SELECT pg_try_advisory_lock($1) AS acquired`,
239
+ [effectiveKey]
240
+ );
241
+ if (result.rows[0].acquired) {
242
+ await client.query(`SELECT pg_advisory_unlock($1)`, [effectiveKey]);
243
+ this.isLocked = false;
244
+ this.locks.delete(effectiveKey);
245
+ return true;
246
+ }
247
+ return false;
248
+ } finally {
249
+ client.release();
250
+ }
251
+ }
252
+
253
+ async checkDeadlock(lockKey) {
254
+ const effectiveKey = lockKey || this.lockId;
255
+ const client = await this.pool.connect();
256
+
257
+ try {
258
+ const lockWaitQuery = `
259
+ SELECT
260
+ blocked.pid AS blocked_pid,
261
+ blocked.query AS blocked_query,
262
+ blocking.pid AS blocking_pid,
263
+ blocking.query AS blocking_query,
264
+ now() - blocked.query_start AS wait_duration
265
+ FROM pg_stat_activity blocked
266
+ JOIN pg_locks blocked_locks ON blocked_locks.pid = blocked.pid
267
+ JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
268
+ AND blocking_locks.database = blocked_locks.database
269
+ AND blocking_locks.relation = blocked_locks.relation
270
+ AND blocking_locks.pid != blocked_locks.pid
271
+ JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
272
+ WHERE blocked_locks.granted = false
273
+ `;
274
+
275
+ const result = await client.query(lockWaitQuery);
276
+
277
+ return {
278
+ blockedQueries: result.rows,
279
+ hasPotentialDeadlock: result.rows.length > 0,
280
+ };
281
+
282
+ } finally {
283
+ client.release();
284
+ }
285
+ }
286
+
287
+ async getAllLocks() {
288
+ const client = await this.pool.connect();
289
+
290
+ try {
291
+ const result = await client.query(`
292
+ SELECT
293
+ locktype,
294
+ objid AS lock_key,
295
+ pid,
296
+ mode,
297
+ granted
298
+ FROM pg_locks
299
+ WHERE locktype = 'advisory'
300
+ `);
301
+
302
+ return result.rows;
303
+
304
+ } finally {
305
+ client.release();
306
+ }
307
+ }
308
+ }