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.
- package/LICENSE +35 -0
- package/README.md +304 -0
- package/package.json +52 -0
- package/src/behavioral/behavioral-applier.js +374 -0
- package/src/behavioral/behavioral-extractor.js +266 -0
- package/src/behavioral/behavioral-puller.js +35 -0
- package/src/behavioral/index.js +4 -0
- package/src/behavioral/phase-sorter.js +24 -0
- package/src/constants.js +97 -0
- package/src/ddl-generator/alter-generator.js +1512 -0
- package/src/ddl-generator/comment-generator.js +95 -0
- package/src/ddl-generator/create-generator.js +1426 -0
- package/src/ddl-generator/drop-generator.js +194 -0
- package/src/ddl-generator/generator.js +78 -0
- package/src/ddl-generator/grant-generator.js +31 -0
- package/src/ddl-generator/index.js +3 -0
- package/src/ddl-generator/pg-version.js +15 -0
- package/src/ddl-generator/rename-generator.js +88 -0
- package/src/ddl-generator/safe-patterns.js +241 -0
- package/src/differ/change-classifier.js +192 -0
- package/src/differ/dependency-resolver.js +523 -0
- package/src/differ/index.js +12 -0
- package/src/differ/object-matcher.js +510 -0
- package/src/differ/property-differ.js +1134 -0
- package/src/differ/risk-tagger.js +561 -0
- package/src/differ/schema-differ.js +309 -0
- package/src/differ/utils/levenshtein.js +210 -0
- package/src/differ/utils/path-builder.js +198 -0
- package/src/differ/utils/type-compatibility.js +472 -0
- package/src/errors.js +147 -0
- package/src/executor/drift-detector.js +221 -0
- package/src/executor/index.js +7 -0
- package/src/executor/lock-manager.js +308 -0
- package/src/executor/migration-executor.js +1249 -0
- package/src/executor/progress-tracker.js +81 -0
- package/src/executor/recovery-manager.js +47 -0
- package/src/executor/snapshot-manager.js +22 -0
- package/src/executor/sql-splitter.js +120 -0
- package/src/executor/transaction-manager.js +288 -0
- package/src/index.js +483 -0
- package/src/introspection/index.js +4 -0
- package/src/introspection/introspector.js +250 -0
- package/src/introspection/queries/access-methods.js +29 -0
- package/src/introspection/queries/casts.js +38 -0
- package/src/introspection/queries/collations.js +51 -0
- package/src/introspection/queries/comments.js +66 -0
- package/src/introspection/queries/constraints.js +65 -0
- package/src/introspection/queries/conversions.js +39 -0
- package/src/introspection/queries/databases.js +43 -0
- package/src/introspection/queries/default-privileges.js +37 -0
- package/src/introspection/queries/event-triggers.js +43 -0
- package/src/introspection/queries/extensions.js +21 -0
- package/src/introspection/queries/foreign-data.js +136 -0
- package/src/introspection/queries/functions.js +75 -0
- package/src/introspection/queries/grants.js +38 -0
- package/src/introspection/queries/index.js +33 -0
- package/src/introspection/queries/indexes.js +99 -0
- package/src/introspection/queries/inheritance.js +24 -0
- package/src/introspection/queries/multiranges.js +37 -0
- package/src/introspection/queries/operators.js +180 -0
- package/src/introspection/queries/partitions.js +42 -0
- package/src/introspection/queries/pg18-19.js +43 -0
- package/src/introspection/queries/policies.js +35 -0
- package/src/introspection/queries/procedural-languages.js +36 -0
- package/src/introspection/queries/publications.js +114 -0
- package/src/introspection/queries/roles.js +66 -0
- package/src/introspection/queries/rules.js +49 -0
- package/src/introspection/queries/sequences.js +37 -0
- package/src/introspection/queries/statistics.js +74 -0
- package/src/introspection/queries/subscriptions.js +99 -0
- package/src/introspection/queries/tables.js +151 -0
- package/src/introspection/queries/tablespaces.js +36 -0
- package/src/introspection/queries/text-search.js +146 -0
- package/src/introspection/queries/triggers.js +61 -0
- package/src/introspection/queries/types.js +106 -0
- package/src/introspection/queries/views.js +146 -0
- package/src/introspection/translator.js +1283 -0
- package/src/introspection/version-detector.js +25 -0
- package/src/planner/backfill-planner.js +36 -0
- package/src/planner/dry-run.js +21 -0
- package/src/planner/index.js +6 -0
- package/src/planner/migration-planner.js +356 -0
- package/src/planner/smart-migrator.js +82 -0
- package/src/planner/step-sequencer.js +61 -0
- package/src/planner/type-registry.js +47 -0
- package/src/risk/compatibility-checker.js +29 -0
- package/src/risk/data-loss-checker.js +32 -0
- package/src/risk/destructive-checker.js +38 -0
- package/src/risk/index.js +6 -0
- package/src/risk/lock-analyzer.js +39 -0
- package/src/risk/recommendations.js +44 -0
- package/src/risk/risk-engine.js +25 -0
- package/src/storage/index.js +5 -0
- package/src/storage/memory-storage.js +87 -0
- package/src/storage/migration-table.js +423 -0
- package/src/storage/rollback-generator.js +344 -0
- package/src/types/changes.js +136 -0
- package/src/types/connection.js +17 -0
- package/src/types/execution.js +107 -0
- package/src/types/index.js +1 -0
- package/src/types/migration.js +67 -0
- package/src/types/risk.js +32 -0
- package/src/types/schema.d.ts +1004 -0
- 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
|
+
}
|