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,423 @@
1
+ import crypto from 'crypto';
2
+
3
+ const ENGINE_VERSION = '1.0.0';
4
+
5
+ export class MigrationTable {
6
+ tableName = 'migration_history';
7
+
8
+ constructor(pool, connectionId = null) {
9
+ this.pool = pool;
10
+ this.connectionId = connectionId;
11
+
12
+ if (!connectionId) {
13
+ console.warn(
14
+ '[SchemaWeaver] No connectionId provided. ' +
15
+ 'Migration records will not be scoped to a specific database. ' +
16
+ 'This may cause issues in multi-database environments.'
17
+ );
18
+ }
19
+ }
20
+
21
+ async ensureTable() {
22
+ const tableCheck = await this.pool.query(`
23
+ SELECT EXISTS (
24
+ SELECT FROM information_schema.tables
25
+ WHERE table_name = 'migration_history'
26
+ )
27
+ `);
28
+
29
+ if (!tableCheck.rows[0].exists) {
30
+ await this.pool.query(`
31
+ CREATE TABLE IF NOT EXISTS migration_history (
32
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33
+ connection_id UUID,
34
+ version VARCHAR(30) NOT NULL,
35
+ name VARCHAR(255) NOT NULL,
36
+ checksum VARCHAR(64) NOT NULL DEFAULT '',
37
+ up_sql TEXT NOT NULL DEFAULT '',
38
+ down_sql TEXT,
39
+ full_snapshot_sql TEXT,
40
+ commit_message TEXT,
41
+ applied_by UUID,
42
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
43
+ execution_time_ms INTEGER,
44
+ error_message TEXT,
45
+ rolled_back_at TIMESTAMPTZ,
46
+ rolled_back_by UUID,
47
+ metadata JSONB,
48
+ applied_at TIMESTAMPTZ,
49
+ created_at TIMESTAMPTZ DEFAULT now(),
50
+
51
+ schema_diff JSONB,
52
+ sql_statements JSONB,
53
+ execution_results JSONB,
54
+ snapshot_before JSONB,
55
+ snapshot_after JSONB,
56
+ risk_summary JSONB,
57
+ warnings JSONB,
58
+ direction VARCHAR(10) DEFAULT 'up',
59
+ change_count INTEGER DEFAULT 0,
60
+ create_count INTEGER DEFAULT 0,
61
+ alter_count INTEGER DEFAULT 0,
62
+ drop_count INTEGER DEFAULT 0,
63
+ rename_count INTEGER DEFAULT 0,
64
+ pg_version VARCHAR(20),
65
+ engine_version VARCHAR(20),
66
+ rollback_sql JSONB,
67
+ tags TEXT[]
68
+ )
69
+ `);
70
+ } else {
71
+ await this.ensureEngineColumns();
72
+ }
73
+
74
+ await this.ensureIndexes();
75
+ await this.checkLegacyTable();
76
+ }
77
+
78
+ async ensureEngineColumns() {
79
+ const engineColumns = [
80
+ { name: 'schema_diff', type: 'JSONB' },
81
+ { name: 'sql_statements', type: 'JSONB' },
82
+ { name: 'execution_results', type: 'JSONB' },
83
+ { name: 'snapshot_before', type: 'JSONB' },
84
+ { name: 'snapshot_after', type: 'JSONB' },
85
+ { name: 'risk_summary', type: 'JSONB' },
86
+ { name: 'warnings', type: 'JSONB' },
87
+ { name: 'direction', type: 'VARCHAR(10)', def: "'up'" },
88
+ { name: 'change_count', type: 'INTEGER', def: '0' },
89
+ { name: 'create_count', type: 'INTEGER', def: '0' },
90
+ { name: 'alter_count', type: 'INTEGER', def: '0' },
91
+ { name: 'drop_count', type: 'INTEGER', def: '0' },
92
+ { name: 'rename_count', type: 'INTEGER', def: '0' },
93
+ { name: 'pg_version', type: 'VARCHAR(20)' },
94
+ { name: 'engine_version', type: 'VARCHAR(20)' },
95
+ { name: 'rollback_sql', type: 'JSONB' },
96
+ { name: 'tags', type: 'TEXT[]' },
97
+ ];
98
+
99
+ for (const col of engineColumns) {
100
+ const colCheck = await this.pool.query(`
101
+ SELECT EXISTS (
102
+ SELECT FROM information_schema.columns
103
+ WHERE table_name = 'migration_history'
104
+ AND column_name = $1
105
+ )
106
+ `, [col.name]);
107
+
108
+ if (!colCheck.rows[0].exists) {
109
+ const defaultClause = col.def ? ` DEFAULT ${col.def}` : '';
110
+ await this.pool.query(`
111
+ ALTER TABLE migration_history
112
+ ADD COLUMN ${col.name} ${col.type}${defaultClause}
113
+ `);
114
+ }
115
+ }
116
+ }
117
+
118
+ async ensureIndexes() {
119
+ const indexes = [
120
+ { name: 'idx_migration_history_status', on: 'status' },
121
+ { name: 'idx_migration_history_connection', on: 'connection_id' },
122
+ { name: 'idx_migration_history_applied_at', on: 'applied_at DESC NULLS LAST' },
123
+ { name: 'idx_migration_history_version', on: 'version' },
124
+ { name: 'idx_migration_history_name', on: 'name' },
125
+ ];
126
+
127
+ for (const idx of indexes) {
128
+ await this.pool.query(`
129
+ CREATE INDEX IF NOT EXISTS ${idx.name}
130
+ ON migration_history (${idx.on})
131
+ `).catch(() => {});
132
+ }
133
+ }
134
+
135
+ async checkLegacyTable() {
136
+ const legacyCheck = await this.pool.query(`
137
+ SELECT EXISTS (
138
+ SELECT FROM information_schema.tables
139
+ WHERE table_name = '_sw_migrations'
140
+ )
141
+ `);
142
+
143
+ if (legacyCheck.rows[0].exists) {
144
+ console.warn(
145
+ '[SchemaWeaver] Legacy table _sw_migrations detected. ' +
146
+ 'This table is deprecated. Migration history is now stored in migration_history.'
147
+ );
148
+ }
149
+ }
150
+
151
+ generateVersion() {
152
+ const now = new Date();
153
+ const yyyy = now.getFullYear();
154
+ const MM = String(now.getMonth() + 1).padStart(2, '0');
155
+ const dd = String(now.getDate()).padStart(2, '0');
156
+ const HH = String(now.getHours()).padStart(2, '0');
157
+ const mm = String(now.getMinutes()).padStart(2, '0');
158
+ const ss = String(now.getSeconds()).padStart(2, '0');
159
+ return `${yyyy}${MM}${dd}${HH}${mm}${ss}`;
160
+ }
161
+
162
+ computeChecksum(plan) {
163
+ const canonical = JSON.stringify(
164
+ plan.steps?.map(s => ({ sql: s.sql, id: s.id })) || []
165
+ );
166
+ return crypto.createHash('sha256').update(canonical).digest('hex');
167
+ }
168
+
169
+ generateUpSql(plan) {
170
+ return plan.steps?.map(s => s.sql).filter(Boolean).join(';\n') || '';
171
+ }
172
+
173
+ getEngineVersion() {
174
+ return ENGINE_VERSION;
175
+ }
176
+
177
+ mapExecutorStatus(executorStatus) {
178
+ const STATUS_MAP = {
179
+ 'COMPLETED': 'completed',
180
+ 'completed': 'completed',
181
+ 'PARTIALLY_APPLIED': 'partially_applied',
182
+ 'partially_applied': 'partially_applied',
183
+ 'FAILED': 'failed',
184
+ 'failed': 'failed',
185
+ 'DRY_RUN_SUCCESS': 'completed',
186
+ 'dry_run_success': 'completed',
187
+ 'DRY_RUN_FAILURE': 'failed',
188
+ 'dry_run_failure': 'failed',
189
+ 'running': 'running',
190
+ 'pending': 'pending',
191
+ 'rolled_back': 'rolled_back',
192
+ };
193
+ return STATUS_MAP[executorStatus] || executorStatus?.toLowerCase() || 'failed';
194
+ }
195
+
196
+ async createRecord(plan, connectionId = null) {
197
+ const cid = connectionId || this.connectionId;
198
+ const version = this.generateVersion();
199
+ const checksum = this.computeChecksum(plan);
200
+ const upSql = this.generateUpSql(plan);
201
+
202
+ const changes = plan.changes || plan.steps || [];
203
+ const changeCount = changes.length;
204
+ const createCount = changes.filter(c => c.changeType === 'CREATE').length;
205
+ const alterCount = changes.filter(c => c.changeType === 'ALTER').length;
206
+ const dropCount = changes.filter(c => c.changeType === 'DROP').length;
207
+ const renameCount = changes.filter(c => c.changeType === 'RENAME').length;
208
+
209
+ const result = await this.pool.query(`
210
+ INSERT INTO migration_history (
211
+ connection_id, version, name, checksum,
212
+ up_sql, status, direction,
213
+ schema_diff, sql_statements,
214
+ risk_summary, warnings,
215
+ change_count, create_count, alter_count, drop_count, rename_count,
216
+ pg_version, engine_version,
217
+ applied_at, created_at
218
+ ) VALUES (
219
+ $1, $2, $3, $4,
220
+ $5, 'running', $6,
221
+ $7, $8,
222
+ $9, $10,
223
+ $11, $12, $13, $14, $15,
224
+ $16, $17,
225
+ now(), now()
226
+ )
227
+ RETURNING id, version
228
+ `, [
229
+ cid,
230
+ version,
231
+ plan.name || `migration_${version}`,
232
+ checksum,
233
+ upSql,
234
+ plan.direction || 'up',
235
+ JSON.stringify(plan.schemaDiff || plan.diff || {}),
236
+ JSON.stringify(plan.steps?.map(s => ({ stepId: s.id, sql: s.sql })) || []),
237
+ JSON.stringify(plan.riskSummary || {}),
238
+ JSON.stringify(plan.warnings || []),
239
+ changeCount, createCount, alterCount, dropCount, renameCount,
240
+ plan.pgVersion || null,
241
+ this.getEngineVersion(),
242
+ ]);
243
+
244
+ return result.rows[0];
245
+ }
246
+
247
+ async updateStepProgress(recordId, stepId, status, durationMs) {
248
+ await this.pool.query(`
249
+ UPDATE migration_history
250
+ SET execution_results =
251
+ COALESCE(execution_results, '{}'::jsonb) ||
252
+ jsonb_build_object($1::text, jsonb_build_object(
253
+ 'status', $2,
254
+ 'duration_ms', $3,
255
+ 'completed_at', now()
256
+ ))
257
+ WHERE id = $4
258
+ `, [stepId, status, durationMs, recordId]);
259
+ }
260
+
261
+ async completeRecord(recordId, execResult) {
262
+ const status = this.mapExecutorStatus(execResult.status);
263
+ const errorMessage = execResult.errors?.length > 0
264
+ ? execResult.errors.map(e => `[${e.code || 'ERR'}] ${e.message}`).join('\n')
265
+ : null;
266
+
267
+ await this.pool.query(`
268
+ UPDATE migration_history
269
+ SET status = $1,
270
+ execution_time_ms = $2,
271
+ error_message = $3,
272
+ execution_results = $4,
273
+ snapshot_before = $5,
274
+ snapshot_after = $6,
275
+ rollback_sql = $7,
276
+ applied_at = CASE WHEN $1 IN ('completed', 'partially_applied') THEN now() ELSE applied_at END
277
+ WHERE id = $8
278
+ `, [
279
+ status,
280
+ execResult.durationMs || execResult.duration,
281
+ errorMessage,
282
+ JSON.stringify(execResult.intents || execResult.executionResults || {}),
283
+ JSON.stringify(execResult.snapshotBefore || execResult.snapshots?.before || null),
284
+ JSON.stringify(execResult.snapshotAfter || execResult.snapshots?.after || null),
285
+ JSON.stringify(execResult.rollbackSteps || []),
286
+ recordId,
287
+ ]);
288
+ }
289
+
290
+ async failRecord(recordId, error, executedSteps = []) {
291
+ const errorMessage = error.message || String(error);
292
+ const pgError = error.code ? `[${error.code}] ` : '';
293
+
294
+ await this.pool.query(`
295
+ UPDATE migration_history
296
+ SET status = 'failed',
297
+ error_message = $1,
298
+ execution_results = $2
299
+ WHERE id = $3
300
+ `, [
301
+ pgError + errorMessage,
302
+ JSON.stringify({ executedSteps, error: { message: errorMessage, code: error.code } }),
303
+ recordId,
304
+ ]);
305
+ }
306
+
307
+ async markRolledBack(recordId, rolledBackBy = null) {
308
+ await this.pool.query(`
309
+ UPDATE migration_history
310
+ SET status = 'rolled_back',
311
+ rolled_back_at = now(),
312
+ rolled_back_by = $1
313
+ WHERE id = $2
314
+ `, [rolledBackBy, recordId]);
315
+ }
316
+
317
+ async getHistory(connectionId = null, limit = 50, offset = 0) {
318
+ const cid = connectionId || this.connectionId;
319
+ const result = await this.pool.query(`
320
+ SELECT id, version, name, status, direction,
321
+ change_count, create_count, alter_count, drop_count, rename_count,
322
+ execution_time_ms, error_message,
323
+ applied_at, created_at, rolled_back_at
324
+ FROM migration_history
325
+ WHERE connection_id = $1
326
+ ORDER BY applied_at DESC NULLS LAST
327
+ LIMIT $2 OFFSET $3
328
+ `, [cid, limit, offset]);
329
+ return result.rows;
330
+ }
331
+
332
+ async getLastMigration(connectionId = null) {
333
+ const cid = connectionId || this.connectionId;
334
+ const result = await this.pool.query(`
335
+ SELECT id, version, name, status, checksum, applied_at
336
+ FROM migration_history
337
+ WHERE connection_id = $1 AND status = 'completed'
338
+ ORDER BY applied_at DESC
339
+ LIMIT 1
340
+ `, [cid]);
341
+ return result.rows[0] || null;
342
+ }
343
+
344
+ async getMigration(recordId) {
345
+ const result = await this.pool.query(`
346
+ SELECT * FROM migration_history WHERE id = $1
347
+ `, [recordId]);
348
+ return result.rows[0] || null;
349
+ }
350
+
351
+ async getMigrationByVersion(connectionId = null, version) {
352
+ const cid = connectionId || this.connectionId;
353
+ const result = await this.pool.query(`
354
+ SELECT * FROM migration_history
355
+ WHERE connection_id = $1 AND version = $2
356
+ `, [cid, version]);
357
+ return result.rows[0] || null;
358
+ }
359
+
360
+ async getByStatus(connectionId = null, status) {
361
+ const cid = connectionId || this.connectionId;
362
+ const result = await this.pool.query(`
363
+ SELECT * FROM migration_history
364
+ WHERE connection_id = $1 AND status = $2
365
+ ORDER BY created_at DESC
366
+ `, [cid, status]);
367
+ return result.rows;
368
+ }
369
+
370
+ async getRollbackSQL(recordId) {
371
+ const result = await this.pool.query(`
372
+ SELECT
373
+ id, name, schema_diff,
374
+ rollback_sql, sql_statements,
375
+ status, applied_at
376
+ FROM migration_history
377
+ WHERE id = $1
378
+ `, [recordId]);
379
+ return result.rows[0] || null;
380
+ }
381
+
382
+ async setRollbackSQL(recordId, rollbackSteps) {
383
+ await this.pool.query(`
384
+ UPDATE migration_history
385
+ SET rollback_sql = $1
386
+ WHERE id = $2
387
+ `, [JSON.stringify(rollbackSteps), recordId]);
388
+ }
389
+
390
+ async getStats(connectionId = null) {
391
+ const cid = connectionId || this.connectionId;
392
+ const result = await this.pool.query(`
393
+ SELECT
394
+ COUNT(*) as total_migrations,
395
+ COUNT(*) FILTER (WHERE status = 'completed') as completed,
396
+ COUNT(*) FILTER (WHERE status = 'failed') as failed,
397
+ COUNT(*) FILTER (WHERE status = 'rolled_back') as rolled_back,
398
+ COUNT(*) FILTER (WHERE status = 'running') as running,
399
+ COUNT(*) FILTER (WHERE status = 'partially_applied') as partially_applied,
400
+ COALESCE(SUM(change_count), 0) as total_changes,
401
+ COALESCE(SUM(execution_time_ms), 0) as total_execution_time_ms,
402
+ MAX(applied_at) as last_migration_at
403
+ FROM migration_history
404
+ WHERE connection_id = $1
405
+ `, [cid]);
406
+ return result.rows[0];
407
+ }
408
+
409
+ async cleanupOldRecords(keepCount = 100, connectionId = null) {
410
+ const cid = connectionId || this.connectionId;
411
+ const result = await this.pool.query(`
412
+ DELETE FROM migration_history
413
+ WHERE id NOT IN (
414
+ SELECT id FROM migration_history
415
+ WHERE connection_id = $1
416
+ ORDER BY applied_at DESC NULLS LAST
417
+ LIMIT $2
418
+ ) AND connection_id = $1
419
+ RETURNING id
420
+ `, [cid, keepCount]);
421
+ return result.rowCount;
422
+ }
423
+ }