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,1249 @@
1
+ import { TransactionManager } from './transaction-manager.js';
2
+ import { LockManager } from './lock-manager.js';
3
+ import { ProgressTracker } from './progress-tracker.js';
4
+ import { DriftDetector } from './drift-detector.js';
5
+ import { splitSqlStatements, sanitizeSavepointName } from './sql-splitter.js';
6
+ import {
7
+ ExecutionError,
8
+ PreCheckFailedError,
9
+ MigrationConflictError,
10
+ VersionIncompatibilityError,
11
+ DriftDetectedError,
12
+ } from '../errors.js';
13
+ import {
14
+ MIGRATION_STATUS,
15
+ RISK_LEVELS,
16
+ mapExecutorStatusToDb,
17
+ } from '../constants.js';
18
+
19
+ /**
20
+ * Extract structured error information from a PostgreSQL error.
21
+ * The pg driver puts all PG error fields on the Error object.
22
+ */
23
+ export function extractPgError(error) {
24
+ return {
25
+ message: error.message || 'Unknown error',
26
+ code: error.code || 'UNKNOWN',
27
+ severity: error.severity || 'ERROR',
28
+ detail: error.detail || null,
29
+ hint: error.hint || null,
30
+ schema: error.schema || null,
31
+ table: error.table || null,
32
+ column: error.column || null,
33
+ datatype: error.datatype || null,
34
+ constraint: error.constraint || null,
35
+ position: error.position || null,
36
+ where: error.where || null,
37
+ isPgError: !!error.code && /^[0-9A-Z]{5}$/.test(error.code),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Classify PostgreSQL error code for recovery decisions.
43
+ */
44
+ export function classifyPgError(errorCode) {
45
+ if (!errorCode || typeof errorCode !== 'string') {
46
+ return { category: 'unknown', recoverable: false, action: 'abort' };
47
+ }
48
+
49
+ const prefix = errorCode.substring(0, 2);
50
+
51
+ const CLASSIFICATION = {
52
+ '42': {
53
+ '42P01': { category: 'undefined_object', recoverable: false, action: 'skip_if_drop' },
54
+ '42P07': { category: 'duplicate_object', recoverable: false, action: 'skip' },
55
+ '42P06': { category: 'duplicate_schema', recoverable: false, action: 'skip' },
56
+ '42701': { category: 'duplicate_column', recoverable: false, action: 'skip' },
57
+ '42710': { category: 'duplicate_object', recoverable: false, action: 'skip' },
58
+ '42723': { category: 'duplicate_function', recoverable: false, action: 'skip' },
59
+ '42P16': { category: 'invalid_schema', recoverable: false, action: 'abort' },
60
+ '42P09': { category: 'ambiguous_alias', recoverable: false, action: 'abort' },
61
+ '42501': { category: 'insufficient_privilege', recoverable: false, action: 'abort' },
62
+ 'default': { category: 'syntax_or_access', recoverable: false, action: 'abort' },
63
+ },
64
+ '23': {
65
+ '23505': { category: 'unique_violation', recoverable: false, action: 'abort' },
66
+ '23503': { category: 'foreign_key_violation', recoverable: false, action: 'abort' },
67
+ '23514': { category: 'check_violation', recoverable: false, action: 'abort' },
68
+ '23502': { category: 'not_null_violation', recoverable: false, action: 'abort' },
69
+ 'default': { category: 'integrity', recoverable: false, action: 'abort' },
70
+ },
71
+ '53': {
72
+ '53100': { category: 'disk_full', recoverable: false, action: 'abort' },
73
+ '54000': { category: 'too_many_columns', recoverable: false, action: 'abort' },
74
+ 'default': { category: 'insufficient_resources', recoverable: false, action: 'abort' },
75
+ },
76
+ '54': { category: 'program_limit', recoverable: false, action: 'abort' },
77
+ '58': { category: 'system_error', recoverable: true, action: 'retry' },
78
+ '40': {
79
+ '40001': { category: 'serialization_failure', recoverable: true, action: 'retry' },
80
+ '40P01': { category: 'deadlock', recoverable: true, action: 'retry' },
81
+ 'default': { category: 'tx_integrity', recoverable: false, action: 'abort' },
82
+ },
83
+ '55': {
84
+ '55006': { category: 'object_in_use', recoverable: true, action: 'wait_retry' },
85
+ 'default': { category: 'object_not_prerequisite', recoverable: false, action: 'abort' },
86
+ },
87
+ '57': {
88
+ '57P03': { category: 'cannot_connect_now', recoverable: true, action: 'wait_retry' },
89
+ '57P04': { category: 'database_dropped', recoverable: false, action: 'abort' },
90
+ 'default': { category: 'lock_not_available', recoverable: true, action: 'wait_retry' },
91
+ },
92
+ '25': {
93
+ '25P02': { category: 'in_failed_tx', recoverable: false, action: 'abort' },
94
+ 'default': { category: 'invalid_tx_state', recoverable: false, action: 'abort' },
95
+ },
96
+ '08': { category: 'connection_error', recoverable: true, action: 'retry' },
97
+ '0A': { category: 'feature_not_supported', recoverable: false, action: 'abort' },
98
+ '0B': { category: 'invalid_tx_init', recoverable: false, action: 'abort' },
99
+ 'F0': { category: 'config_error', recoverable: false, action: 'abort' },
100
+ 'HV': { category: 'fdw_error', recoverable: false, action: 'abort' },
101
+ 'P0': { category: 'plpgsql_error', recoverable: false, action: 'abort' },
102
+ 'XX': { category: 'internal_error', recoverable: false, action: 'abort' },
103
+ };
104
+
105
+ const prefixLookup = CLASSIFICATION[prefix];
106
+ if (prefixLookup) {
107
+ if (typeof prefixLookup === 'object') {
108
+ const fullCodeLookup = prefixLookup[errorCode];
109
+ if (fullCodeLookup) {
110
+ return fullCodeLookup;
111
+ }
112
+ if (prefixLookup.default) {
113
+ return prefixLookup.default;
114
+ }
115
+ }
116
+ return prefixLookup;
117
+ }
118
+
119
+ return { category: 'unknown', recoverable: false, action: 'abort' };
120
+ }
121
+
122
+ /**
123
+ * Detect SQL statements that cannot run inside a transaction.
124
+ */
125
+ export function isNonTransactionalSQL(sql, step = {}) {
126
+ if (step.isTransactional === false) return true;
127
+ if (step.isConcurrent === true) return true;
128
+
129
+ if (!sql || typeof sql !== 'string') return false;
130
+
131
+ const normalizedSql = sql.trim().toUpperCase();
132
+
133
+ if (/\b(CREATE|DROP|REINDEX)\s+INDEX\s+CONCURRENTLY\b/i.test(sql)) {
134
+ return true;
135
+ }
136
+
137
+ if (/\bALTER\s+TYPE\s+.*\bADD\s+VALUE\b/i.test(sql)) {
138
+ if (step.pgVersion && parseFloat(step.pgVersion) >= 12) {
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ if (/\bVACUUM\b/i.test(normalizedSql) &&
145
+ !normalizedSql.includes('ANALYZE') &&
146
+ !normalizedSql.startsWith('ANALYZE')) {
147
+ return true;
148
+ }
149
+
150
+ if (/\bCLUSTER\b/i.test(normalizedSql) && !/\bCLUSTERED\b/.test(normalizedSql)) {
151
+ return true;
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Detect PostgreSQL server version.
159
+ */
160
+ async function detectPgVersion(pool) {
161
+ try {
162
+ const result = await pool.query('SELECT version()');
163
+ const versionString = result.rows[0].version;
164
+ const match = versionString.match(/PostgreSQL\s+(\d+)(?:\.(\d+))?/);
165
+ if (match) {
166
+ return `${match[1]}.${match[2] || '0'}`;
167
+ }
168
+ return null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @typedef {Object} ExecutionConfig
176
+ * @property {boolean} [dryRun=false]
177
+ * @property {number} [timeout=300000]
178
+ * @property {boolean} [continueOnError=false]
179
+ * @property {boolean} [snapshotBefore=true]
180
+ * @property {boolean} [verifyAfter=true]
181
+ * @property {string} [lockTimeout='5s']
182
+ * @property {string} [statementTimeout='30s']
183
+ * @property {number} [lockKey] - Advisory lock key
184
+ */
185
+
186
+ export class MigrationExecutor {
187
+ pgVersion = null;
188
+ connectionId = null;
189
+
190
+ /**
191
+ * @param {import('pg').Pool} pool
192
+ * @param {import('../introspection/index.js').Introspector} introspector
193
+ * @param {import('../storage/migration-table.js').MigrationTable} storage
194
+ * @param {ExecutionConfig} [config]
195
+ */
196
+ constructor(pool, introspector, storage, config = {}) {
197
+ this.pool = pool;
198
+ this.introspector = introspector;
199
+ this.storage = storage;
200
+ this.connectionId = config.connectionId || null;
201
+
202
+ if (!this.connectionId) {
203
+ console.warn(
204
+ '[MigrationExecutor] No connectionId provided. ' +
205
+ 'Migrations will not be scoped to a database and will use fallback lock key.'
206
+ );
207
+ }
208
+
209
+ this.config = {
210
+ dryRun: config.dryRun || false,
211
+ timeout: config.timeout || 300000,
212
+ continueOnError: config.continueOnError || false,
213
+ snapshotBefore: config.snapshotBefore !== false,
214
+ verifyAfter: config.verifyAfter !== false,
215
+ lockTimeout: config.lockTimeout || '5s',
216
+ statementTimeout: config.statementTimeout || '30s',
217
+ lockHeartbeatInterval: config.lockHeartbeatInterval || 30000,
218
+ continueOnLockLoss: config.continueOnLockLoss || false,
219
+ };
220
+
221
+ this.txManager = new TransactionManager(pool);
222
+ this.lockManager = new LockManager(pool, { connectionId: this.connectionId });
223
+ this.progressTracker = new ProgressTracker();
224
+ this.driftDetector = new DriftDetector();
225
+
226
+ this.state = 'idle';
227
+ this.executedSteps = [];
228
+ this.intents = [];
229
+ this.snapshots = { before: null, after: null };
230
+ this.migrationRecord = null;
231
+ this._heartbeatTimer = null;
232
+ }
233
+
234
+ /**
235
+ * Execute a migration plan
236
+ * @param {import('../types/migration.js').MigrationPlan} plan
237
+ * @param {import('../types/execution.js').ExecutionOptions} [options]
238
+ * @returns {Promise<import('../types/migration.js').MigrationResult>}
239
+ */
240
+ async execute(plan, options = {}) {
241
+ const startTime = Date.now();
242
+ const connectionId = options.connectionId || this.connectionId;
243
+ const mergedConfig = { ...this.config, ...options, connectionId };
244
+
245
+ const result = {
246
+ migrationId: null,
247
+ status: MIGRATION_STATUS.RUNNING,
248
+ stepsCompleted: 0,
249
+ stepsSkipped: 0,
250
+ stepsFailed: 0,
251
+ stepsTotal: plan.steps?.length || 0,
252
+ success: true,
253
+ errors: [],
254
+ warnings: [],
255
+ intents: [],
256
+ startedAt: new Date(startTime).toISOString(),
257
+ connectionId,
258
+ };
259
+
260
+ try {
261
+ this.state = 'running';
262
+ this.intents = [];
263
+
264
+ this.emitProgress({ type: 'execution_start', planId: plan.id, timestamp: new Date().toISOString(), connectionId });
265
+
266
+ this.pgVersion = await detectPgVersion(this.pool);
267
+
268
+ await this.preflightCheck(plan, mergedConfig);
269
+
270
+ const lockKey = this.lockManager.computeLockKey(connectionId);
271
+ await this.acquireAdvisoryLock({ ...mergedConfig, lockKey });
272
+ this._startLockHeartbeat({ ...mergedConfig, lockKey });
273
+
274
+ if (mergedConfig.snapshotBefore) {
275
+ this.snapshots.before = await this.captureSnapshot();
276
+ }
277
+
278
+ this.migrationRecord = await this.storage.createRecord(plan, connectionId);
279
+ result.migrationId = this.migrationRecord?.id || this.migrationRecord?.migration_id || plan.id;
280
+
281
+ const detectedSteps = this._detectNonTransactionalSteps(plan.steps || [], result);
282
+ const phases = this.groupStepsByPhase(detectedSteps);
283
+ const phaseOrder = Object.keys(phases).map(Number).sort((a, b) => a - b);
284
+
285
+ for (const phaseNum of phaseOrder) {
286
+ const phaseSteps = phases[phaseNum];
287
+ await this.executePhase(phaseNum, phaseSteps, mergedConfig, result);
288
+ }
289
+
290
+ if (result.stepsFailed === 0) {
291
+ result.status = mergedConfig.dryRun ? MIGRATION_STATUS.DRY_RUN_SUCCESS : MIGRATION_STATUS.COMPLETED;
292
+ result.success = true;
293
+ } else if (mergedConfig.continueOnError && result.stepsCompleted > 0) {
294
+ result.status = mergedConfig.dryRun ? MIGRATION_STATUS.DRY_RUN_FAILURE : MIGRATION_STATUS.PARTIALLY_APPLIED;
295
+ result.success = false;
296
+ } else {
297
+ result.status = mergedConfig.dryRun ? MIGRATION_STATUS.DRY_RUN_FAILURE : MIGRATION_STATUS.FAILED;
298
+ result.success = false;
299
+ }
300
+
301
+ if (mergedConfig.verifyAfter && !mergedConfig.dryRun) {
302
+ await this.postflightVerify(plan, mergedConfig);
303
+ }
304
+
305
+ if (mergedConfig.snapshotBefore && !mergedConfig.dryRun) {
306
+ this.snapshots.after = await this.captureSnapshot();
307
+ }
308
+
309
+ await this.completeMigrationRecord();
310
+
311
+ this._stopLockHeartbeat();
312
+ await this.releaseAdvisoryLock();
313
+
314
+ this.state = 'completed';
315
+
316
+ return this.buildResult(plan, result.status.toLowerCase(), startTime, result);
317
+
318
+ } catch (error) {
319
+ this.state = 'failed';
320
+ this._stopLockHeartbeat();
321
+ const recoveryInfo = await this.handleFailure(error, plan);
322
+ result.status = 'FAILED';
323
+ result.success = false;
324
+ result.errors.push({
325
+ message: error.message,
326
+ code: error.code || 'UNKNOWN',
327
+ recovery: recoveryInfo,
328
+ });
329
+ throw new ExecutionError(
330
+ `Migration failed: ${error.message}`,
331
+ { cause: error, recovery: recoveryInfo, result }
332
+ );
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Detect and fix non-transactional step classification
338
+ */
339
+ _detectNonTransactionalSteps(steps, result) {
340
+ return steps.map(step => {
341
+ const detectedNonTx = isNonTransactionalSQL(step.sql || '', { ...step, pgVersion: step.pgVersion || this.pgVersion });
342
+
343
+ if (detectedNonTx && step.isTransactional !== false) {
344
+ result.warnings.push({
345
+ step: step.id,
346
+ message: `Step was marked as transactional but contains non-transactional SQL. Automatically moved to non-transactional execution.`,
347
+ severity: 'high',
348
+ });
349
+ return { ...step, isTransactional: false };
350
+ }
351
+
352
+ if (!detectedNonTx && step.isTransactional === false) {
353
+ result.warnings.push({
354
+ step: step.id,
355
+ message: `Step is marked non-transactional but SQL appears transaction-safe.`,
356
+ severity: 'low',
357
+ });
358
+ }
359
+
360
+ return step;
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Start lock heartbeat timer
366
+ */
367
+ _startLockHeartbeat(config) {
368
+ this._stopLockHeartbeat();
369
+ if (config.lockHeartbeatInterval > 0) {
370
+ this._heartbeatTimer = setInterval(async () => {
371
+ try {
372
+ const lockInfo = this.lockManager.isHeldBySelf(config.lockKey || this.lockManager.lockId);
373
+ if (!lockInfo) {
374
+ this.emitProgress({
375
+ type: 'warning',
376
+ message: 'Advisory lock lost during migration. Another migration may be running.',
377
+ severity: 'critical',
378
+ });
379
+ }
380
+ } catch {
381
+ // Ignore heartbeat check errors
382
+ }
383
+ }, config.lockHeartbeatInterval);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Stop lock heartbeat timer
389
+ */
390
+ _stopLockHeartbeat() {
391
+ if (this._heartbeatTimer) {
392
+ clearInterval(this._heartbeatTimer);
393
+ this._heartbeatTimer = null;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Record step intent before execution
399
+ */
400
+ _recordIntent(step, status = 'INTENT') {
401
+ const intent = {
402
+ stepId: step.id,
403
+ phase: step.phase,
404
+ changeType: step.changeType,
405
+ objectType: step.objectType,
406
+ objectKey: step.objectKey,
407
+ objectName: step.objectName,
408
+ sql: step.sql,
409
+ isTransactional: step.isTransactional !== false,
410
+ preCheck: step.preCheck || false,
411
+ recoverySql: step.recoverySql || null,
412
+ undoSql: step.undoSql || step.rollbackSql || null,
413
+ recordedAt: new Date().toISOString(),
414
+ status,
415
+ };
416
+ this.intents.push(intent);
417
+ return intent;
418
+ }
419
+
420
+ /**
421
+ * Update intent after execution
422
+ */
423
+ _updateIntent(intent, updates) {
424
+ Object.assign(intent, updates);
425
+ return intent;
426
+ }
427
+
428
+ /**
429
+ * Group steps by their phase number
430
+ * @param {Array} steps
431
+ * @returns {Object<number, Array>}
432
+ */
433
+ groupStepsByPhase(steps) {
434
+ const phases = {};
435
+ for (const step of steps) {
436
+ const phase = step.phase || 10;
437
+ if (!phases[phase]) phases[phase] = [];
438
+ phases[phase].push(step);
439
+ }
440
+ return phases;
441
+ }
442
+
443
+ /**
444
+ * Execute all steps in a phase
445
+ * @param {number} phaseNum
446
+ * @param {Array} steps
447
+ * @param {ExecutionConfig} config
448
+ * @param {Object} result
449
+ */
450
+ async executePhase(phaseNum, steps, config, result) {
451
+ const phaseName = this.getPhaseName(phaseNum);
452
+
453
+ this.emitProgress({
454
+ type: 'phase_start',
455
+ phase: phaseNum,
456
+ phaseName,
457
+ stepCount: steps.length,
458
+ });
459
+
460
+ const transactional = steps.filter(s => s.isTransactional !== false);
461
+ const nonTransactional = steps.filter(s => s.isTransactional === false);
462
+
463
+ if (transactional.length > 0) {
464
+ await this.executeInTransaction(transactional, phaseNum, config, result);
465
+ }
466
+
467
+ for (const step of nonTransactional) {
468
+ await this.executeNonTransactionalStep(step, phaseNum, config, result);
469
+ }
470
+
471
+ this.emitProgress({
472
+ type: 'phase_complete',
473
+ phase: phaseNum,
474
+ phaseName,
475
+ });
476
+ }
477
+
478
+ /**
479
+ * Execute transactional steps in a single transaction
480
+ * @param {Array} steps
481
+ * @param {number} phaseNum
482
+ * @param {ExecutionConfig} config
483
+ * @param {Object} result
484
+ */
485
+ async executeInTransaction(steps, phaseNum, config, result) {
486
+ const phaseName = this.getPhaseName(phaseNum);
487
+ const client = await this.pool.connect();
488
+
489
+ const stepsCompleted = [];
490
+ const stepsFailed = [];
491
+ const stepsSkipped = [];
492
+
493
+ try {
494
+ await client.query('BEGIN');
495
+
496
+ await client.query(`SET LOCAL lock_timeout = '${config.lockTimeout}'`);
497
+ await client.query(`SET LOCAL statement_timeout = '${config.statementTimeout}'`);
498
+ await client.query(`SET LOCAL search_path = 'public'`);
499
+
500
+ for (const step of steps) {
501
+ const savepointName = `sp_${sanitizeSavepointName(step.id)}`;
502
+ const intent = this._recordIntent(step, 'INTENT');
503
+ result.intents.push(intent);
504
+
505
+ try {
506
+ await client.query(`SAVEPOINT ${savepointName}`);
507
+
508
+ await this.executeStepWithRetry(client, step, phaseNum, phaseName, config);
509
+
510
+ await client.query(`RELEASE SAVEPOINT ${savepointName}`);
511
+ stepsCompleted.push(step);
512
+
513
+ this._updateIntent(intent, {
514
+ status: 'COMPLETED',
515
+ completedAt: new Date().toISOString(),
516
+ durationMs: Date.now() - new Date(intent.recordedAt).getTime(),
517
+ });
518
+
519
+ result.stepsCompleted++;
520
+
521
+ } catch (error) {
522
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
523
+ await client.query(`RELEASE SAVEPOINT ${savepointName}`);
524
+
525
+ const pgError = extractPgError(error);
526
+ const classification = classifyPgError(pgError.code);
527
+
528
+ const isDropStep = step.changeType === 'DROP';
529
+ const shouldSkipDuplicate = classification.action === 'skip';
530
+ const shouldSkipIfDrop = classification.action === 'skip_if_drop' && isDropStep;
531
+
532
+ if (shouldSkipDuplicate || shouldSkipIfDrop) {
533
+ stepsSkipped.push({ step, reason: classification.category });
534
+
535
+ this._updateIntent(intent, {
536
+ status: 'SKIPPED',
537
+ completedAt: new Date().toISOString(),
538
+ skipReason: classification.category,
539
+ pgCode: pgError.code,
540
+ });
541
+
542
+ result.warnings.push({
543
+ step: step.id,
544
+ message: `Skipped: ${pgError.message} (${pgError.code})`,
545
+ severity: 'low',
546
+ pgCode: pgError.code,
547
+ });
548
+ result.stepsSkipped++;
549
+ continue;
550
+ }
551
+
552
+ stepsFailed.push({ step, error, pgError, classification });
553
+
554
+ this._updateIntent(intent, {
555
+ status: 'FAILED',
556
+ completedAt: new Date().toISOString(),
557
+ durationMs: Date.now() - new Date(intent.recordedAt).getTime(),
558
+ errorCode: pgError.code,
559
+ errorMessage: pgError.message,
560
+ });
561
+
562
+ result.stepsFailed++;
563
+ result.errors.push({
564
+ step: step.id,
565
+ sql: step.sql,
566
+ message: pgError.message,
567
+ code: pgError.code,
568
+ severity: pgError.severity,
569
+ detail: pgError.detail,
570
+ hint: pgError.hint,
571
+ schema: pgError.schema,
572
+ table: pgError.table,
573
+ column: pgError.column,
574
+ constraint: pgError.constraint,
575
+ classification: classification.category,
576
+ isNonTransactional: false,
577
+ });
578
+
579
+ this.executedSteps.push({
580
+ stepId: step.id,
581
+ sql: step.sql,
582
+ phase: phaseNum,
583
+ status: 'failed',
584
+ error: error.message,
585
+ errorCode: pgError.code,
586
+ timestamp: new Date().toISOString(),
587
+ });
588
+
589
+ if (!config.continueOnError) {
590
+ await client.query('ROLLBACK');
591
+ throw new ExecutionError(
592
+ `Phase "${phaseName}" failed at step "${step.id}": ${error.message}`,
593
+ { phase: { number: phaseNum, name: phaseName }, step, cause: error, pgError }
594
+ );
595
+ }
596
+
597
+ result.warnings.push({
598
+ step: step.id,
599
+ message: `Step failed but continuing: ${pgError.message} (${pgError.code})`,
600
+ severity: 'medium',
601
+ pgCode: pgError.code,
602
+ });
603
+ }
604
+ }
605
+
606
+ if (config.dryRun) {
607
+ await client.query('ROLLBACK');
608
+ this.emitProgress({
609
+ type: 'dry_run_rollback',
610
+ phase: phaseNum,
611
+ phaseName,
612
+ stepsRolledBack: stepsCompleted.length,
613
+ });
614
+ } else {
615
+ await client.query('COMMIT');
616
+ }
617
+
618
+ } catch (error) {
619
+ if (error instanceof ExecutionError) {
620
+ throw error;
621
+ }
622
+ await client.query('ROLLBACK').catch(() => {});
623
+ throw new ExecutionError(
624
+ `Phase "${phaseName}" failed: ${error.message}`,
625
+ { phase: { number: phaseNum, name: phaseName }, cause: error }
626
+ );
627
+ } finally {
628
+ client.release();
629
+ }
630
+
631
+ if (stepsFailed.length > 0 && config.continueOnError) {
632
+ this.emitProgress({
633
+ type: 'partial_completion',
634
+ phase: phaseNum,
635
+ phaseName,
636
+ stepsCompleted: stepsCompleted.length,
637
+ stepsFailed: stepsFailed.length,
638
+ stepsSkipped: stepsSkipped.length,
639
+ });
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Execute a step with retry on transient errors
645
+ */
646
+ async executeStepWithRetry(client, step, phaseNum, phaseName, config, maxRetries = 2) {
647
+ let lastError;
648
+ let attempts = 0;
649
+
650
+ while (attempts <= maxRetries) {
651
+ try {
652
+ await this.executeStep(client, step, phaseNum, phaseName, config);
653
+ return;
654
+ } catch (error) {
655
+ lastError = error;
656
+ const pgError = extractPgError(error);
657
+ const classification = classifyPgError(pgError.code);
658
+
659
+ if (classification.action === 'retry' && attempts < maxRetries) {
660
+ attempts++;
661
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempts));
662
+ continue;
663
+ }
664
+
665
+ if (classification.action === 'wait_retry' && attempts < maxRetries) {
666
+ attempts++;
667
+ await new Promise(resolve => setTimeout(resolve, 3000 * attempts));
668
+ continue;
669
+ }
670
+
671
+ throw error;
672
+ }
673
+ }
674
+
675
+ throw lastError;
676
+ }
677
+
678
+ /**
679
+ * Execute a single non-transactional step
680
+ * @param {Object} step
681
+ * @param {number} phaseNum
682
+ * @param {ExecutionConfig} config
683
+ * @param {Object} result
684
+ */
685
+ async executeNonTransactionalStep(step, phaseNum, config, result) {
686
+ const phaseName = this.getPhaseName(phaseNum);
687
+
688
+ const intent = this._recordIntent(step, 'INTENT');
689
+ result.intents.push(intent);
690
+
691
+ if (config.dryRun) {
692
+ this._updateIntent(intent, {
693
+ status: 'SKIPPED',
694
+ completedAt: new Date().toISOString(),
695
+ skipReason: 'dry_run',
696
+ });
697
+ result.stepsSkipped++;
698
+
699
+ this.emitProgress({
700
+ type: 'dry_run_skip',
701
+ phase: phaseNum,
702
+ phaseName,
703
+ stepId: step.id,
704
+ sql: step.sql,
705
+ reason: 'Non-transactional DDL skipped in dry run (cannot rollback)',
706
+ });
707
+ return;
708
+ }
709
+
710
+ const client = await this.pool.connect();
711
+
712
+ try {
713
+ await client.query(`SET statement_timeout = '${config.statementTimeout}'`);
714
+
715
+ const statements = splitSqlStatements(step.sql);
716
+
717
+ if (statements.length > 1) {
718
+ result.warnings.push({
719
+ step: step.id,
720
+ message: 'Non-transactional step contains multiple statements; only first will be executed',
721
+ severity: 'medium',
722
+ });
723
+ }
724
+
725
+ const sqlToExecute = statements.length > 0 ? statements[0] : step.sql;
726
+ const startTime = Date.now();
727
+ const queryResult = await client.query(sqlToExecute);
728
+ const duration = Date.now() - startTime;
729
+
730
+ this.executedSteps.push({
731
+ stepId: step.id,
732
+ sql: step.sql,
733
+ phase: phaseNum,
734
+ status: 'completed',
735
+ duration,
736
+ rowsAffected: queryResult.rowCount,
737
+ timestamp: new Date().toISOString(),
738
+ isTransactional: false,
739
+ });
740
+
741
+ this._updateIntent(intent, {
742
+ status: 'COMPLETED',
743
+ completedAt: new Date().toISOString(),
744
+ durationMs: duration,
745
+ });
746
+
747
+ result.stepsCompleted++;
748
+
749
+ this.emitProgress({
750
+ type: 'step_completed',
751
+ phase: phaseNum,
752
+ phaseName,
753
+ stepId: step.id,
754
+ sql: step.sql,
755
+ duration,
756
+ rowsAffected: queryResult.rowCount,
757
+ isNonTransactional: true,
758
+ });
759
+
760
+ } catch (error) {
761
+ const pgError = extractPgError(error);
762
+ const classification = classifyPgError(pgError.code);
763
+
764
+ this._updateIntent(intent, {
765
+ status: 'FAILED',
766
+ completedAt: new Date().toISOString(),
767
+ errorCode: pgError.code,
768
+ errorMessage: pgError.message,
769
+ });
770
+
771
+ result.stepsFailed++;
772
+ result.errors.push({
773
+ step: step.id,
774
+ sql: step.sql,
775
+ message: pgError.message,
776
+ code: pgError.code,
777
+ severity: pgError.severity,
778
+ detail: pgError.detail,
779
+ hint: pgError.hint,
780
+ classification: classification.category,
781
+ isNonTransactional: true,
782
+ recoveryHint: step.recoverySql || `Manual recovery may be required`,
783
+ });
784
+
785
+ this.executedSteps.push({
786
+ stepId: step.id,
787
+ sql: step.sql,
788
+ phase: phaseNum,
789
+ status: 'failed',
790
+ error: error.message,
791
+ errorCode: pgError.code,
792
+ isTransactional: false,
793
+ timestamp: new Date().toISOString(),
794
+ recoveryHint: step.recoverySql || null,
795
+ });
796
+
797
+ if (!config.continueOnError) {
798
+ throw new ExecutionError(
799
+ `Non-transactional step "${step.id}" failed: ${error.message}`,
800
+ { phase: { number: phaseNum, name: phaseName }, step, cause: error, isNonTransactional: true, pgError }
801
+ );
802
+ }
803
+
804
+ result.warnings.push({
805
+ step: step.id,
806
+ message: `Non-tx step failed: ${pgError.message} (${pgError.code})`,
807
+ severity: 'high',
808
+ pgCode: pgError.code,
809
+ });
810
+ } finally {
811
+ client.release();
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Execute a single step
817
+ * @param {import('pg').PoolClient} client
818
+ * @param {Object} step
819
+ * @param {number} phaseNum
820
+ * @param {string} phaseName
821
+ * @param {ExecutionConfig} config
822
+ */
823
+ async executeStep(client, step, phaseNum, phaseName, config) {
824
+ if (step.preCheck) {
825
+ const preCheckResult = await client.query(step.preCheck);
826
+ if (step.preCheckExpectEmpty && preCheckResult.rows.length > 0) {
827
+ throw new PreCheckFailedError(
828
+ `Pre-check failed for step ${step.id}: ${step.preCheckMessage || 'Condition not met'}`,
829
+ { step, preCheckResult }
830
+ );
831
+ }
832
+ }
833
+
834
+ const statements = splitSqlStatements(step.sql);
835
+
836
+ if (statements.length === 0) {
837
+ this.executedSteps.push({
838
+ stepId: step.id,
839
+ sql: step.sql,
840
+ phase: phaseNum,
841
+ phaseName,
842
+ status: 'completed',
843
+ duration: 0,
844
+ rowsAffected: 0,
845
+ timestamp: new Date().toISOString(),
846
+ isTransactional: step.isTransactional !== false,
847
+ });
848
+ return;
849
+ }
850
+
851
+ if (statements.length === 1) {
852
+ await this._executeSingleStatement(client, statements[0], step, phaseNum, phaseName);
853
+ return;
854
+ }
855
+
856
+ const subResults = [];
857
+ for (let i = 0; i < statements.length; i++) {
858
+ const stmt = statements[i];
859
+ const subSavepointName = `sp_${sanitizeSavepointName(step.id)}_stmt_${i}`;
860
+
861
+ try {
862
+ await client.query(`SAVEPOINT ${subSavepointName}`);
863
+
864
+ const result = await this._executeSingleStatement(client, stmt, step, phaseNum, phaseName, true);
865
+ subResults.push(result);
866
+
867
+ await client.query(`RELEASE SAVEPOINT ${subSavepointName}`);
868
+
869
+ } catch (error) {
870
+ await client.query(`ROLLBACK TO SAVEPOINT ${subSavepointName}`);
871
+ await client.query(`RELEASE SAVEPOINT ${subSavepointName}`);
872
+
873
+ error.subStatementIndex = i;
874
+ error.subStatementSql = stmt;
875
+ throw error;
876
+ }
877
+ }
878
+
879
+ this.emitProgress({
880
+ type: 'step_completed',
881
+ phase: phaseNum,
882
+ phaseName,
883
+ stepId: step.id,
884
+ sql: step.sql,
885
+ duration: subResults.reduce((sum, r) => sum + r.duration, 0),
886
+ rowsAffected: subResults.reduce((sum, r) => sum + r.rowsAffected, 0),
887
+ subStatements: statements.length,
888
+ });
889
+ }
890
+
891
+ async _executeSingleStatement(client, sql, step, phaseNum, phaseName, isSubStatement = false) {
892
+ const startTime = Date.now();
893
+ const result = await client.query(sql);
894
+ const duration = Date.now() - startTime;
895
+
896
+ if (!isSubStatement) {
897
+ this.executedSteps.push({
898
+ stepId: step.id,
899
+ sql: step.sql,
900
+ phase: phaseNum,
901
+ phaseName,
902
+ status: 'completed',
903
+ duration,
904
+ rowsAffected: result.rowCount,
905
+ timestamp: new Date().toISOString(),
906
+ isTransactional: step.isTransactional !== false,
907
+ });
908
+
909
+ if (this.migrationRecord?.id) {
910
+ await this.storage.updateStepProgress(
911
+ this.migrationRecord.migration_id,
912
+ step.id,
913
+ 'completed',
914
+ duration
915
+ );
916
+ }
917
+
918
+ this.emitProgress({
919
+ type: 'step_completed',
920
+ phase: phaseNum,
921
+ phaseName,
922
+ stepId: step.id,
923
+ sql: step.sql,
924
+ duration,
925
+ rowsAffected: result.rowCount,
926
+ });
927
+
928
+ if (step.postCheck) {
929
+ await client.query(step.postCheck);
930
+ }
931
+ }
932
+
933
+ return {
934
+ success: true,
935
+ rowsAffected: result.rowCount || 0,
936
+ duration,
937
+ };
938
+ }
939
+
940
+ /**
941
+ * Pre-flight checks before execution
942
+ * @param {import('../types/migration.js').MigrationPlan} plan
943
+ * @param {ExecutionConfig} config
944
+ */
945
+ async preflightCheck(plan, config) {
946
+ await this.pool.query('SELECT 1');
947
+
948
+ const versionResult = await this.pool.query('SHOW server_version_num');
949
+ const version = parseInt(versionResult.rows[0].server_version_num);
950
+
951
+ for (const step of plan.steps) {
952
+ if (step.pgVersionMinimum && version < step.pgVersionMinimum * 10000) {
953
+ throw new VersionIncompatibilityError(
954
+ `Step "${step.id}" requires PG ${step.pgVersionMinimum}+ but database is PG ${Math.floor(version / 10000)}`,
955
+ { requiredVersion: step.pgVersionMinimum, currentVersion: Math.floor(version / 10000) }
956
+ );
957
+ }
958
+ }
959
+
960
+ const longQueries = await this.pool.query(`
961
+ SELECT pid, now() - pg_stat_activity.query_start AS duration, query
962
+ FROM pg_stat_activity
963
+ WHERE state = 'active'
964
+ AND now() - query_start > interval '30 seconds'
965
+ AND pid != pg_backend_pid()
966
+ `);
967
+ if (longQueries.rows.length > 0) {
968
+ this.emitProgress({
969
+ type: 'warning',
970
+ message: `${longQueries.rows.length} long-running queries detected. Migration may be blocked.`,
971
+ queries: longQueries.rows,
972
+ connectionId: config.connectionId,
973
+ });
974
+ }
975
+
976
+ await this.storage.ensureTable();
977
+ }
978
+
979
+ /**
980
+ * Acquire advisory lock
981
+ * @param {ExecutionConfig} config
982
+ */
983
+ async acquireAdvisoryLock(config) {
984
+ const lockKey = config.lockKey || this.lockManager.computeLockKey(config.connectionId);
985
+ const acquired = await this.lockManager.acquire(lockKey, config.lockTimeout);
986
+ if (!acquired) {
987
+ throw new MigrationConflictError(
988
+ 'Failed to acquire migration lock. Another migration may be in progress.'
989
+ );
990
+ }
991
+ this.emitProgress({ type: 'lock_acquired', lockKey, connectionId: config.connectionId });
992
+ }
993
+
994
+ /**
995
+ * Release advisory lock
996
+ */
997
+ async releaseAdvisoryLock() {
998
+ await this.lockManager.release().catch(() => {});
999
+ this.emitProgress({ type: 'lock_released', lockKey: this.lockManager.lockId, connectionId: this.connectionId });
1000
+ }
1001
+
1002
+ /**
1003
+ * Post-flight verification
1004
+ * @param {import('../types/migration.js').MigrationPlan} plan
1005
+ * @param {ExecutionConfig} config
1006
+ */
1007
+ async postflightVerify(plan, config) {
1008
+ if (this.snapshots.before && this.snapshots.after) {
1009
+ const drift = this.driftDetector.detect(
1010
+ this.snapshots.before,
1011
+ this.snapshots.after,
1012
+ { changes: this.executedSteps }
1013
+ );
1014
+
1015
+ if (drift.detected) {
1016
+ throw new DriftDetectedError(
1017
+ 'Schema drift detected during migration execution',
1018
+ { drift }
1019
+ );
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Capture a snapshot of current database state
1026
+ * @returns {Promise<Object>}
1027
+ */
1028
+ async captureSnapshot() {
1029
+ const result = await this.pool.query(`
1030
+ SELECT
1031
+ c.oid,
1032
+ n.nspname as schema,
1033
+ c.relname as name,
1034
+ c.relkind as kind,
1035
+ md5(n.nspname || '.' || c.relname || '.' || c.relkind) as checksum
1036
+ FROM pg_class c
1037
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1038
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
1039
+ AND n.nspname NOT LIKE 'pg_temp_%'
1040
+ ORDER BY n.nspname, c.relname
1041
+ `);
1042
+
1043
+ return {
1044
+ timestamp: new Date().toISOString(),
1045
+ objectCount: result.rows.length,
1046
+ checksums: result.rows.map(r => ({
1047
+ schema: r.schema,
1048
+ name: r.name,
1049
+ kind: r.kind,
1050
+ checksum: r.checksum,
1051
+ })),
1052
+ };
1053
+ }
1054
+
1055
+ /**
1056
+ * Handle execution failure
1057
+ * @param {Error} error
1058
+ * @param {import('../types/migration.js').MigrationPlan} plan
1059
+ * @returns {Promise<Object>}
1060
+ */
1061
+ async handleFailure(error, plan) {
1062
+ const executedPhaseNames = [...new Set(
1063
+ this.executedSteps.filter(s => s.status === 'completed').map(s => this.getPhaseName(s.phase))
1064
+ )];
1065
+
1066
+ const nonTransactionalExecuted = this.executedSteps.filter(
1067
+ s => s.status === 'completed' && s.isTransactional === false
1068
+ );
1069
+
1070
+ if (this.migrationRecord?.migration_id) {
1071
+ await this.storage.failRecord(
1072
+ this.migrationRecord.migration_id,
1073
+ error,
1074
+ this.executedSteps
1075
+ );
1076
+ }
1077
+
1078
+ await this.releaseAdvisoryLock();
1079
+
1080
+ return {
1081
+ state: 'failed',
1082
+ error: error.message,
1083
+ executedPhases: executedPhaseNames,
1084
+ nonTransactionalExecuted: nonTransactionalExecuted.map(s => ({
1085
+ stepId: s.stepId,
1086
+ sql: s.sql,
1087
+ })),
1088
+ rollbackStatus: nonTransactionalExecuted.length > 0 ? 'PARTIAL' : 'FULL',
1089
+ manualRecoveryRequired: nonTransactionalExecuted.length > 0,
1090
+ recoverySQL: nonTransactionalExecuted.map(s => this.generateUndoSQL(s)).filter(Boolean),
1091
+ };
1092
+ }
1093
+
1094
+ /**
1095
+ * Generate undo SQL for an executed step
1096
+ * @param {Object} executedStep
1097
+ * @returns {string|null}
1098
+ */
1099
+ generateUndoSQL(executedStep) {
1100
+ const sql = executedStep.sql?.toUpperCase().trim();
1101
+ if (!sql) return null;
1102
+
1103
+ if (sql.startsWith('CREATE INDEX CONCURRENTLY')) {
1104
+ const match = executedStep.sql.match(/CREATE INDEX CONCURRENTLY\s+(?:IF NOT EXISTS\s+)?(?:(\w+)\.)?(\w+)/i);
1105
+ if (match) {
1106
+ return `DROP INDEX IF EXISTS ${match[1] ? match[1] + '.' : ''}${match[2]}`;
1107
+ }
1108
+ }
1109
+
1110
+ return null;
1111
+ }
1112
+
1113
+ /**
1114
+ * Complete the migration record
1115
+ */
1116
+ async completeMigrationRecord() {
1117
+ if (this.migrationRecord?.migration_id) {
1118
+ await this.storage.completeRecord(
1119
+ this.migrationRecord.migration_id,
1120
+ {
1121
+ duration: this.executedSteps.reduce((sum, s) => sum + (s.duration || 0), 0),
1122
+ snapshotBefore: this.snapshots.before,
1123
+ snapshotAfter: this.snapshots.after,
1124
+ executionResults: this.executedSteps,
1125
+ changeCount: this.executedSteps.filter(s => s.status === 'completed').length,
1126
+ }
1127
+ );
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * Build execution result
1133
+ * @param {import('../types/migration.js').MigrationPlan} plan
1134
+ * @param {string} status
1135
+ * @param {number} startTime
1136
+ * @param {Object} [resultObj]
1137
+ * @returns {import('../types/migration.js').MigrationResult}
1138
+ */
1139
+ buildResult(plan, status, startTime, resultObj = {}) {
1140
+ const completed = this.executedSteps.filter(s => s.status === 'completed');
1141
+ const failed = this.executedSteps.filter(s => s.status === 'failed');
1142
+
1143
+ return {
1144
+ success: resultObj.success !== undefined ? resultObj.success : (status === 'completed' && failed.length === 0),
1145
+ migrationId: resultObj.migrationId || this.migrationRecord?.migration_id || plan.id,
1146
+ status: resultObj.status || status,
1147
+ startedAt: new Date(startTime).toISOString(),
1148
+ completedAt: new Date().toISOString(),
1149
+ durationMs: Date.now() - startTime,
1150
+ stepsCompleted: resultObj.stepsCompleted ?? completed.length,
1151
+ stepsSkipped: resultObj.stepsSkipped ?? 0,
1152
+ stepsTotal: plan.steps?.length || 0,
1153
+ stepsFailed: resultObj.stepsFailed ?? failed.length,
1154
+ changesApplied: completed.length,
1155
+ snapshots: {
1156
+ before: this.snapshots.before?.objectCount || 0,
1157
+ after: this.snapshots.after?.objectCount || 0,
1158
+ },
1159
+ executedSteps: this.executedSteps,
1160
+ warnings: resultObj.warnings || [],
1161
+ errors: resultObj.errors?.length > 0 ? resultObj.errors : failed.map(s => ({
1162
+ step: s.stepId,
1163
+ sql: s.sql,
1164
+ message: s.error,
1165
+ code: s.errorCode,
1166
+ subStatementIndex: s.subStatementIndex,
1167
+ subStatementSql: s.subStatementSql,
1168
+ isNonTransactional: s.isTransactional === false,
1169
+ recoveryHint: s.recoveryHint,
1170
+ })),
1171
+ intents: resultObj.intents || [],
1172
+ state: {
1173
+ name: this.state,
1174
+ executedPhaseCount: [...new Set(this.executedSteps.map(s => s.phase))].length,
1175
+ failed: failed.length > 0,
1176
+ },
1177
+ pgVersion: this.pgVersion,
1178
+ };
1179
+ }
1180
+
1181
+ /**
1182
+ * Get phase name from number
1183
+ * @param {number} phase
1184
+ * @returns {string}
1185
+ */
1186
+ getPhaseName(phase) {
1187
+ const phases = {
1188
+ 1: 'pre_check',
1189
+ 2: 'advisory_lock',
1190
+ 3: 'extensions',
1191
+ 4: 'types',
1192
+ 5: 'schemas',
1193
+ 6: 'tables_create',
1194
+ 7: 'columns_add',
1195
+ 8: 'sequences',
1196
+ 9: 'indexes_create',
1197
+ 10: 'constraints_non_fk',
1198
+ 11: 'data_migration',
1199
+ 12: 'constraints_fk',
1200
+ 13: 'validate_constraints',
1201
+ 14: 'views',
1202
+ 15: 'materialized_views',
1203
+ 16: 'functions',
1204
+ 17: 'triggers',
1205
+ 18: 'policies',
1206
+ 19: 'rules',
1207
+ 20: 'behavioral_other',
1208
+ 21: 'grants',
1209
+ 22: 'comments',
1210
+ 23: 'indexes_concurrent',
1211
+ 24: 'cleanup',
1212
+ 25: 'post_check',
1213
+ 26: 'snapshot',
1214
+ };
1215
+ return phases[phase] || `phase_${phase}`;
1216
+ }
1217
+
1218
+ /**
1219
+ * Subscribe to progress events
1220
+ * @param {Function} listener
1221
+ * @returns {Function} Unsubscribe function
1222
+ */
1223
+ onProgress(listener) {
1224
+ return this.progressTracker.subscribe(listener);
1225
+ }
1226
+
1227
+ /**
1228
+ * Emit progress event
1229
+ * @param {Object} event
1230
+ */
1231
+ emitProgress(event) {
1232
+ this.progressTracker.emit({
1233
+ ...event,
1234
+ timestamp: event.timestamp || new Date().toISOString(),
1235
+ state: this.state,
1236
+ executedStepCount: this.executedSteps.length,
1237
+ });
1238
+ }
1239
+
1240
+ /**
1241
+ * Dry run a migration plan
1242
+ * @param {import('../types/migration.js').MigrationPlan} plan
1243
+ * @param {import('../types/execution.js').ExecutionOptions} [options]
1244
+ * @returns {Promise<import('../types/migration.js').MigrationResult>}
1245
+ */
1246
+ async dryRun(plan, options = {}) {
1247
+ return this.execute(plan, { ...options, dryRun: true });
1248
+ }
1249
+ }