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
package/src/index.js ADDED
@@ -0,0 +1,483 @@
1
+ import { SchemaIntrospector } from './introspection/index.js';
2
+ import { SchemaDiffer } from './differ/index.js';
3
+ import { MigrationPlanner } from './planner/index.js';
4
+ import { MigrationExecutor } from './executor/migration-executor.js';
5
+ import { TransactionManager } from './executor/transaction-manager.js';
6
+ import { MigrationTable } from './storage/migration-table.js';
7
+ import { RollbackGenerator } from './storage/rollback-generator.js';
8
+ import { BehavioralExtractor } from './behavioral/behavioral-extractor.js';
9
+ import { BehavioralApplier } from './behavioral/behavioral-applier.js';
10
+ import { DdlGenerator } from './ddl-generator/index.js';
11
+ import { RiskEngine } from './risk/index.js';
12
+ import { InMemoryStorageProvider } from './storage/index.js';
13
+ import {
14
+ MigrationError,
15
+ ExecutionError,
16
+ IntrospectionError,
17
+ DiffError,
18
+ DDLGenerationError,
19
+ PreCheckFailedError,
20
+ PostCheckFailedError,
21
+ MigrationConflictError,
22
+ VersionIncompatibilityError,
23
+ RollbackError,
24
+ DriftDetectedError,
25
+ LockAcquisitionError,
26
+ TimeoutError,
27
+ ValidationError,
28
+ StorageError,
29
+ PlanBlockedError,
30
+ RecoveryError,
31
+ } from './errors.js';
32
+
33
+ export {
34
+ SchemaIntrospector,
35
+ SchemaDiffer,
36
+ DdlGenerator,
37
+ RiskEngine,
38
+ MigrationPlanner,
39
+ MigrationExecutor,
40
+ BehavioralExtractor,
41
+ BehavioralApplier,
42
+ TransactionManager,
43
+ MigrationTable,
44
+ RollbackGenerator,
45
+ InMemoryStorageProvider,
46
+ MigrationError,
47
+ ExecutionError,
48
+ IntrospectionError,
49
+ DiffError,
50
+ DDLGenerationError,
51
+ PreCheckFailedError,
52
+ PostCheckFailedError,
53
+ MigrationConflictError,
54
+ VersionIncompatibilityError,
55
+ RollbackError,
56
+ DriftDetectedError,
57
+ LockAcquisitionError,
58
+ TimeoutError,
59
+ ValidationError,
60
+ StorageError,
61
+ PlanBlockedError,
62
+ RecoveryError,
63
+ };
64
+
65
+ import { RISK_LEVELS, RISK_LEVEL_ORDER, mapExecutorStatusToDb } from './constants.js';
66
+
67
+ /**
68
+ * @typedef {Object} EngineConfig
69
+ * @property {string} [engineVersion='1.0.0']
70
+ * @property {string} [lockTimeout='5s']
71
+ * @property {string} [statementTimeout='30s']
72
+ * @property {boolean} [dryRun=false]
73
+ * @property {boolean} [snapshotBefore=true]
74
+ * @property {boolean} [verifyAfter=true]
75
+ * @property {string} [connectionId] - Database connection ID for multi-DB support
76
+ * @property {'none'|'low'|'medium'|'high'|'critical'} [allowRiskBelow] - Only allow migrations below this risk level
77
+ */
78
+
79
+ export class SwMigrationEngine {
80
+ /**
81
+ * @param {EngineConfig} config
82
+ */
83
+ constructor(config = {}) {
84
+ this.config = {
85
+ engineVersion: '1.0.0',
86
+ lockTimeout: '5s',
87
+ statementTimeout: '30s',
88
+ dryRun: false,
89
+ snapshotBefore: true,
90
+ verifyAfter: true,
91
+ connectionId: config.connectionId || null,
92
+ allowRiskBelow: 'critical',
93
+ ...config,
94
+ };
95
+
96
+ if (!this.config.connectionId) {
97
+ console.warn(
98
+ '[SwMigrationEngine] No connectionId provided. ' +
99
+ 'Migration records will not be scoped to a database. ' +
100
+ 'This may cause issues in multi-database environments.'
101
+ );
102
+ }
103
+
104
+ this.pool = null;
105
+ this.introspector = null;
106
+ this.differ = new SchemaDiffer();
107
+ this.planner = new MigrationPlanner();
108
+ this.riskEngine = new RiskEngine();
109
+ this.ddlGenerator = new DdlGenerator();
110
+ this.behavioralExtractor = new BehavioralExtractor();
111
+ this.behavioralApplier = new BehavioralApplier();
112
+ this.rollbackGenerator = new RollbackGenerator();
113
+ }
114
+
115
+ /**
116
+ * Set the database pool
117
+ * @param {import('pg').Pool} pool
118
+ */
119
+ setPool(pool) {
120
+ this.pool = pool;
121
+ this.introspector = new SchemaIntrospector(pool);
122
+ }
123
+
124
+ /**
125
+ * STEP 1: Introspect a live database
126
+ * @param {import('pg').Pool} [pool]
127
+ * @param {Object} [options]
128
+ * @returns {Promise<import('./types/schema.js').SchemaSnapshot>}
129
+ */
130
+ async introspect(pool, options = {}) {
131
+ const usePool = pool || this.pool;
132
+ if (!usePool) {
133
+ throw new IntrospectionError('Database pool is required. Call setPool() or pass pool to introspect().');
134
+ }
135
+
136
+ this.introspector = new SchemaIntrospector(usePool);
137
+ return this.introspector.introspect(options);
138
+ }
139
+
140
+ /**
141
+ * STEP 2: Diff two schema snapshots
142
+ * @param {import('./types/schema.js').SchemaSnapshot} desired - Target schema
143
+ * @param {import('./types/schema.js').SchemaSnapshot} current - Current schema
144
+ * @returns {import('./types/changes.js').SchemaDiff}
145
+ */
146
+ diff(desired, current) {
147
+ return this.differ.diff(desired, current);
148
+ }
149
+
150
+ /**
151
+ * STEP 3: Generate DDL from a diff
152
+ * @param {import('./types/changes.js').SchemaDiff} diff
153
+ * @param {Object} [options]
154
+ * @returns {string}
155
+ */
156
+ generateDDL(diff, options = {}) {
157
+ const changes = Array.isArray(diff) ? diff : (diff.changes || []);
158
+ return this.ddlGenerator.generate(changes, options);
159
+ }
160
+
161
+ /**
162
+ * STEP 4: Create a migration plan from a diff
163
+ * @param {import('./types/changes.js').SchemaDiff} diff
164
+ * @param {Object} [options]
165
+ * @returns {import('./types/migration.js').MigrationPlan}
166
+ */
167
+ plan(diff, options = {}) {
168
+ const plan = this.planner.createPlan(diff, {
169
+ ...options,
170
+ pgVersion: options.pgVersion,
171
+ });
172
+
173
+ const riskAssessment = this.riskEngine.assess(plan.changes || [], options.pgVersion);
174
+ plan.riskAssessment = riskAssessment;
175
+
176
+ const riskOrder = ['none', 'low', 'medium', 'high', 'critical'];
177
+ const maxAllowedIdx = riskOrder.indexOf(this.config.allowRiskBelow);
178
+ const hasCritical = (plan.summary?.riskSummary?.critical || 0) > 0;
179
+ const hasHigh = (plan.summary?.riskSummary?.high || 0) > 0;
180
+
181
+ if (hasCritical && maxAllowedIdx <= riskOrder.indexOf('critical')) {
182
+ plan.blocked = true;
183
+ plan.blockReason = `Migration blocked: ${plan.summary.riskSummary.critical} critical risk(s) detected.`;
184
+ } else if (hasHigh && maxAllowedIdx < riskOrder.indexOf('high')) {
185
+ plan.blocked = true;
186
+ plan.blockReason = `Migration blocked: ${plan.summary.riskSummary.high} high risk(s) detected.`;
187
+ }
188
+
189
+ return plan;
190
+ }
191
+
192
+ /**
193
+ * STEP 5: Execute a migration plan
194
+ * @param {import('pg').Pool} [pool]
195
+ * @param {import('./types/migration.js').MigrationPlan} plan
196
+ * @param {import('./types/execution.js').ExecutionOptions} [options]
197
+ * @returns {Promise<import('./types/migration.js').MigrationResult>}
198
+ */
199
+ async execute(pool, plan, options = {}) {
200
+ const usePool = pool || this.pool;
201
+ if (!usePool) {
202
+ throw new ExecutionError('Database pool is required. Call setPool() or pass pool to execute().');
203
+ }
204
+
205
+ if (plan.blocked && !options.allowBlocked) {
206
+ throw new PlanBlockedError(plan.blockReason || 'Migration is blocked', { plan });
207
+ }
208
+
209
+ const connectionId = options.connectionId || this.config.connectionId || null;
210
+ if (!connectionId) {
211
+ console.warn(
212
+ '[SwMigrationEngine] No connectionId for execute(). ' +
213
+ 'Migration records will not be scoped to a database.'
214
+ );
215
+ }
216
+
217
+ const storage = new MigrationTable(usePool, connectionId);
218
+ await storage.ensureTable();
219
+
220
+ const introspector = new SchemaIntrospector(usePool);
221
+ const executor = new MigrationExecutor(usePool, introspector, storage, {
222
+ ...this.config,
223
+ ...options,
224
+ connectionId,
225
+ });
226
+
227
+ if (options.onProgress) {
228
+ executor.onProgress(options.onProgress);
229
+ }
230
+
231
+ return executor.execute(plan, options);
232
+ }
233
+
234
+ /**
235
+ * Dry-run a migration — validates SQL without applying changes
236
+ * @param {import('pg').Pool} [pool]
237
+ * @param {import('./types/migration.js').MigrationPlan} plan
238
+ * @param {Object} [options]
239
+ * @returns {Promise<Object>}
240
+ */
241
+ async dryRun(pool, plan, options = {}) {
242
+ return this.execute(pool, plan, { ...options, dryRun: true });
243
+ }
244
+
245
+ /**
246
+ * ONE-SHOT: Introspect → Diff → Plan → Execute
247
+ * Convenience method for the full pipeline
248
+ * @param {import('pg').Pool} [pool]
249
+ * @param {import('./types/schema.js').SchemaSnapshot} desired - Target schema
250
+ * @param {Object} [options]
251
+ * @returns {Promise<import('./types/migration.js').MigrationResult>}
252
+ */
253
+ async migrate(pool, desired, options = {}) {
254
+ const usePool = pool || this.pool;
255
+ if (!usePool) {
256
+ throw new ExecutionError('Database pool is required. Call setPool() or pass pool to migrate().');
257
+ }
258
+
259
+ const connectionId = options.connectionId || this.config.connectionId || null;
260
+ if (!connectionId) {
261
+ console.warn(
262
+ '[SwMigrationEngine] No connectionId for migrate(). ' +
263
+ 'Migration records will not be scoped to a database.'
264
+ );
265
+ }
266
+
267
+ const current = await this.introspect(usePool, options);
268
+
269
+ if (current.checksum === desired.checksum) {
270
+ return {
271
+ success: true,
272
+ status: 'no_changes',
273
+ migrationId: null,
274
+ message: 'No schema changes detected.',
275
+ diff: { summary: { totalChanges: 0 }, changes: [] },
276
+ executedSteps: [],
277
+ };
278
+ }
279
+
280
+ const diff = this.diff(desired, current);
281
+
282
+ if (diff.summary.totalChanges === 0) {
283
+ return {
284
+ success: true,
285
+ status: 'no_changes',
286
+ migrationId: null,
287
+ message: 'No schema changes detected.',
288
+ diff,
289
+ executedSteps: [],
290
+ };
291
+ }
292
+
293
+ const plan = this.plan(diff, options);
294
+
295
+ if (plan.blocked) {
296
+ return {
297
+ success: false,
298
+ status: 'blocked',
299
+ migrationId: null,
300
+ message: plan.blockReason,
301
+ plan,
302
+ diff,
303
+ executedSteps: [],
304
+ };
305
+ }
306
+
307
+ const execOptions = { ...options, connectionId };
308
+
309
+ if (options.dryRun) {
310
+ const dryRunResult = await this.dryRun(usePool, plan, execOptions);
311
+ return {
312
+ ...dryRunResult,
313
+ status: 'dry_run',
314
+ plan,
315
+ diff,
316
+ };
317
+ }
318
+
319
+ const result = await this.execute(usePool, plan, execOptions);
320
+ return {
321
+ ...result,
322
+ plan,
323
+ diff,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Get migration history
329
+ * @param {import('pg').Pool} [pool]
330
+ * @param {Object} [options]
331
+ * @returns {Promise<Array>}
332
+ */
333
+ async getHistory(pool, options = {}) {
334
+ const usePool = pool || this.pool;
335
+ if (!usePool) {
336
+ throw new StorageError('Database pool is required.');
337
+ }
338
+
339
+ const connectionId = options.connectionId || this.config.connectionId || null;
340
+ const storage = new MigrationTable(usePool, connectionId);
341
+ await storage.ensureTable();
342
+ return storage.getHistory(connectionId, options.limit || 50, options.offset || 0);
343
+ }
344
+
345
+ /**
346
+ * Get last successful migration
347
+ * @param {import('pg').Pool} [pool]
348
+ * @param {Object} [options]
349
+ * @returns {Promise<Object|null>}
350
+ */
351
+ async getLastMigration(pool, options = {}) {
352
+ const usePool = pool || this.pool;
353
+ if (!usePool) {
354
+ throw new StorageError('Database pool is required.');
355
+ }
356
+
357
+ const connectionId = options.connectionId || this.config.connectionId || null;
358
+ const storage = new MigrationTable(usePool, connectionId);
359
+ await storage.ensureTable();
360
+ return storage.getLastMigration(connectionId);
361
+ }
362
+
363
+ /**
364
+ * Rollback a migration (best-effort)
365
+ * @param {import('pg').Pool} [pool]
366
+ * @param {string} migrationId
367
+ * @param {Object} [options]
368
+ * @returns {Promise<Object>}
369
+ */
370
+ async rollback(pool, migrationId, options = {}) {
371
+ const usePool = pool || this.pool;
372
+ if (!usePool) {
373
+ throw new StorageError('Database pool is required.');
374
+ }
375
+
376
+ const connectionId = options.connectionId || this.config.connectionId || null;
377
+ const storage = new MigrationTable(usePool, connectionId);
378
+ await storage.ensureTable();
379
+
380
+ const migration = await storage.getRollbackSQL(migrationId);
381
+ if (!migration) {
382
+ throw new RollbackError(`Migration ${migrationId} not found.`);
383
+ }
384
+
385
+ if (migration.status !== 'completed') {
386
+ throw new RollbackError(`Cannot rollback migration with status "${migration.status}".`);
387
+ }
388
+
389
+ const rollbackSteps = this.rollbackGenerator.generateRollback(migration);
390
+
391
+ if (rollbackSteps.length === 0) {
392
+ return {
393
+ success: false,
394
+ migrationId,
395
+ status: 'no_rollback_available',
396
+ message: 'No rollback steps could be generated for this migration.',
397
+ };
398
+ }
399
+
400
+ for (const step of rollbackSteps) {
401
+ if (!step.isTransactional) {
402
+ step.warning = 'This step runs outside a transaction and cannot be rolled back.';
403
+ }
404
+ }
405
+
406
+ const transactional = rollbackSteps.filter(s => s.isTransactional);
407
+ const nonTransactional = rollbackSteps.filter(s => !s.isTransactional);
408
+
409
+ const results = [];
410
+
411
+ if (transactional.length > 0) {
412
+ const tm = new TransactionManager(usePool);
413
+ const txResults = await tm.executeTransactional(
414
+ transactional.map(s => ({ id: s.originalChangeId, sql: s.sql })),
415
+ {
416
+ lockTimeout: this.config.lockTimeout,
417
+ statementTimeout: this.config.statementTimeout,
418
+ }
419
+ );
420
+ results.push(...txResults);
421
+ }
422
+
423
+ for (const step of nonTransactional) {
424
+ const tm = new TransactionManager(usePool);
425
+ const result = await tm.executeNonTransactional(
426
+ { id: step.originalChangeId, sql: step.sql },
427
+ { statementTimeout: this.config.statementTimeout }
428
+ );
429
+ results.push(result);
430
+ }
431
+
432
+ await storage.markRolledBack(migrationId);
433
+
434
+ return {
435
+ success: results.every(r => r.success),
436
+ migrationId,
437
+ status: 'rolled_back',
438
+ steps: results,
439
+ warning: 'Rollback is best-effort. Some changes (DROP TABLE, DROP COLUMN, enum value removal) cannot be reversed.',
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Validate a desired schema against PG version constraints
445
+ * @param {import('./types/schema.js').SchemaSnapshot} desired
446
+ * @param {number} pgVersion
447
+ * @returns {Object}
448
+ */
449
+ validate(desired, pgVersion) {
450
+ return this.riskEngine.validateVersionCompatibility(desired, pgVersion);
451
+ }
452
+
453
+ /**
454
+ * Assess risk for changes (alias for riskEngine.assess)
455
+ * @param {Array} changes
456
+ * @param {number} [pgVersion]
457
+ * @returns {Object}
458
+ */
459
+ assessRisk(changes, pgVersion) {
460
+ return this.riskEngine.assess(changes, pgVersion);
461
+ }
462
+
463
+ /**
464
+ * Create a migration plan from changes
465
+ * @param {Array} changes
466
+ * @param {Object} options
467
+ * @returns {import('./types/migration.js').MigrationPlan}
468
+ */
469
+ createMigrationPlan(changes, options) {
470
+ const diff = { changes, summary: { totalChanges: changes.length } };
471
+ return this.plan(diff, options);
472
+ }
473
+
474
+ /**
475
+ * Diff two schemas (alias for diff)
476
+ * @param {Object} desired
477
+ * @param {Object} current
478
+ * @returns {import('./types/changes.js').SchemaDiff}
479
+ */
480
+ diffSchemas(desired, current) {
481
+ return this.diff(desired, current);
482
+ }
483
+ }
@@ -0,0 +1,4 @@
1
+ export { SchemaIntrospector } from './introspector.js';
2
+ export { translateSnapshot, normalizeSchema } from './translator.js';
3
+ export { detectPgVersion, majorVersion } from './version-detector.js';
4
+ export * as queries from './queries/index.js';