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,25 @@
1
+ /**
2
+ * @param {import('pg').Pool} pool
3
+ * @returns {Promise<number>}
4
+ */
5
+ export async function detectPgVersion(pool) {
6
+ const result = await pool.query("SELECT current_setting('server_version_num')::int AS version_num");
7
+ return result.rows[0].version_num;
8
+ }
9
+
10
+ /**
11
+ * @param {import('pg').Pool} pool
12
+ * @returns {Promise<string>}
13
+ */
14
+ export async function detectPgVersionString(pool) {
15
+ const result = await pool.query("SELECT current_setting('server_version') AS version");
16
+ return result.rows[0].version;
17
+ }
18
+
19
+ /**
20
+ * @param {number} versionNum
21
+ * @returns {number}
22
+ */
23
+ export function majorVersion(versionNum) {
24
+ return Math.floor(versionNum / 10000);
25
+ }
@@ -0,0 +1,36 @@
1
+ export class BackfillPlanner {
2
+ /**
3
+ * @param {import('../types/execution.js').BackfillOptions} options
4
+ * @returns {import('../types/migration.js').MigrationStep[]}
5
+ */
6
+ createBackfillPlan(options) {
7
+ const { table, fromColumn, toColumn, transform, batchSize = 5000, rateLimitMs = 50, pkColumn = 'id' } = options;
8
+ const steps = [];
9
+
10
+ steps.push({
11
+ id: `backfill_${Date.now()}_1`,
12
+ type: 'data_migration',
13
+ phase: 5,
14
+ description: `Create sync trigger for ${table}`,
15
+ sql: `-- Sync trigger: keep ${fromColumn} and ${toColumn} in sync during backfill`,
16
+ isTransactional: true,
17
+ riskLevel: 'low',
18
+ dependencies: [],
19
+ });
20
+
21
+ steps.push({
22
+ id: `backfill_${Date.now()}_2`,
23
+ type: 'data_migration',
24
+ phase: 5,
25
+ description: `Batched backfill: ${table}.${fromColumn} -> ${toColumn}`,
26
+ sql: `-- Batched backfill: UPDATE ${table} SET ${toColumn} = ${transform} WHERE ${pkColumn} BETWEEN ? AND ? AND ${toColumn} IS NULL;`,
27
+ isTransactional: true,
28
+ riskLevel: 'medium',
29
+ dependencies: [`backfill_${Date.now()}_1`],
30
+ estimatedRows: 100000,
31
+ metadata: { batchSize, rateLimitMs },
32
+ });
33
+
34
+ return steps;
35
+ }
36
+ }
@@ -0,0 +1,21 @@
1
+ export class DryRun {
2
+ /**
3
+ * @param {import('pg').Pool} pool
4
+ * @param {import('../types/migration.js').MigrationStep} step
5
+ * @returns {Promise<{success: boolean, error?: string}>}
6
+ */
7
+ async execute(pool, step) {
8
+ const client = await pool.connect();
9
+ try {
10
+ await client.query('BEGIN');
11
+ await client.query(step.sql);
12
+ await client.query('ROLLBACK');
13
+ return { success: true };
14
+ } catch (error) {
15
+ await client.query('ROLLBACK');
16
+ return { success: false, error: error.message };
17
+ } finally {
18
+ client.release();
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ export { MigrationPlanner } from './migration-planner.js';
2
+ export { SmartMigrator } from './smart-migrator.js';
3
+ export { BackfillPlanner } from './backfill-planner.js';
4
+ export { StepSequencer } from './step-sequencer.js';
5
+ export { TypeRegistry } from './type-registry.js';
6
+ export { DryRun } from './dry-run.js';
@@ -0,0 +1,356 @@
1
+ import { SmartMigrator } from './smart-migrator.js';
2
+ import { BackfillPlanner } from './backfill-planner.js';
3
+ import { StepSequencer } from './step-sequencer.js';
4
+
5
+ const DROP_PHASES = {
6
+ behavioral: 27,
7
+ constraints: 28,
8
+ indexes: 29,
9
+ columns: 30,
10
+ sequences: 31,
11
+ structural: 32,
12
+ };
13
+
14
+ function escapeRegex(str) {
15
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ }
17
+
18
+ export class MigrationPlanner {
19
+ constructor() {
20
+ this.smartMigrator = new SmartMigrator();
21
+ this.backfillPlanner = new BackfillPlanner();
22
+ this.sequencer = new StepSequencer();
23
+ }
24
+
25
+ correlateRenames(changes) {
26
+ const renameMap = new Map();
27
+ for (const change of changes) {
28
+ if (change.changeType === 'RENAME') {
29
+ const oldKey = change.objectKey;
30
+ const schema = change.schema || oldKey.split('.')[0] || 'public';
31
+ const renameTo = change.renameTo || change.after?.name;
32
+ if (renameTo) {
33
+ const newKey = this.buildObjectKey(change.objectType, renameTo, schema);
34
+ renameMap.set(oldKey, {
35
+ newKey,
36
+ renameChange: change,
37
+ objectType: change.objectType,
38
+ oldName: change.before?.name || oldKey.split('.').pop(),
39
+ newName: renameTo,
40
+ });
41
+ }
42
+ }
43
+ }
44
+ for (const [oldKey, renameInfo] of renameMap) {
45
+ for (const [otherOldKey, otherRenameInfo] of renameMap) {
46
+ if (renameInfo.newKey === otherOldKey && oldKey !== otherOldKey) {
47
+ const secondRename = otherRenameInfo.renameChange;
48
+ const firstRename = renameInfo.renameChange;
49
+ if (!secondRename.dependencies) secondRename.dependencies = [];
50
+ if (firstRename.id && !secondRename.dependencies.includes(firstRename.id)) {
51
+ secondRename.dependencies.push(firstRename.id);
52
+ }
53
+ }
54
+ }
55
+ }
56
+ for (const change of changes) {
57
+ const changeType = change.changeType?.toUpperCase();
58
+ if (changeType === 'ALTER') {
59
+ for (const [oldKey, renameInfo] of renameMap) {
60
+ if (change.objectKey === renameInfo.newKey) {
61
+ if (!change.dependencies) change.dependencies = [];
62
+ const renameId = renameInfo.renameChange.id;
63
+ if (renameId && !change.dependencies.includes(renameId)) {
64
+ change.dependencies.push(renameId);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ for (const change of changes) {
71
+ if (change.changeType === 'RENAME' && change.objectType === 'column') {
72
+ const renameInfo = renameMap.get(change.objectKey);
73
+ if (renameInfo) {
74
+ const tableKey = change.objectKey.substring(0, change.objectKey.lastIndexOf('.'));
75
+ const oldColName = renameInfo.oldName;
76
+ const newColName = renameInfo.newName;
77
+ if (oldColName && newColName) {
78
+ const newColumnKey = `${tableKey}.${newColName}`;
79
+ for (const alterChange of changes) {
80
+ const alterChangeType = alterChange.changeType?.toUpperCase();
81
+ if (alterChangeType === 'ALTER' && alterChange.objectKey === newColumnKey) {
82
+ if (!alterChange.dependencies) alterChange.dependencies = [];
83
+ const renameId = change.id;
84
+ if (renameId && !alterChange.dependencies.includes(renameId)) {
85
+ alterChange.dependencies.push(renameId);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ buildObjectKey(objectType, name, schema) {
96
+ return `${schema}.${name}`;
97
+ }
98
+
99
+ /**
100
+ * @param {import('../types/changes.js').SchemaChange[]|import('../types/changes.js').SchemaDiff} changes
101
+ * @param {import('../types/execution.js').PlanOptions} options
102
+ * @returns {import('../types/migration.js').MigrationPlan}
103
+ */
104
+ createPlan(changes, options) {
105
+ const changesArray = Array.isArray(changes) ? changes : (changes.changes || []);
106
+ const warnings = Array.isArray(changes) ? [] : (changes.warnings || []);
107
+
108
+ this.correlateRenames(changesArray);
109
+
110
+ const steps = [];
111
+ let stepId = 1;
112
+
113
+ steps.push({
114
+ id: `step_${String(stepId++).padStart(3, '0')}`,
115
+ type: 'pre_check',
116
+ phase: 0,
117
+ description: 'Pre-flight validation',
118
+ sql: 'SELECT 1;',
119
+ isTransactional: true,
120
+ riskLevel: 'none',
121
+ dependencies: [],
122
+ });
123
+
124
+ steps.push({
125
+ id: `step_${String(stepId++).padStart(3, '0')}`,
126
+ type: 'advisory_lock',
127
+ phase: 0,
128
+ description: 'Acquire migration advisory lock',
129
+ sql: 'SELECT pg_try_advisory_xact_lock(12345);',
130
+ isTransactional: true,
131
+ riskLevel: 'none',
132
+ dependencies: [`step_001`],
133
+ });
134
+
135
+ const phases = {};
136
+ for (const change of changesArray) {
137
+ const phase = change.phase || this.mapChangeToPhase(change);
138
+ if (!phases[phase]) phases[phase] = [];
139
+ phases[phase].push(change);
140
+ }
141
+
142
+ const phaseOrder = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
143
+
144
+ for (const phaseNum of phaseOrder) {
145
+ const phaseChanges = phases[phaseNum] || [];
146
+ for (const change of phaseChanges) {
147
+ const smartSteps = this.smartMigrator.analyze(change);
148
+ const changeId = change.id || `change_${stepId}`;
149
+
150
+ if (smartSteps) {
151
+ for (const s of smartSteps) {
152
+ s.id = `step_${String(stepId++).padStart(3, '0')}`;
153
+ s.phase = phaseNum;
154
+ s.changeId = changeId;
155
+ steps.push(s);
156
+ }
157
+ } else {
158
+ const isConcurrentIndex = change.objectType === 'index' &&
159
+ change.changeType === 'CREATE' &&
160
+ (change.isConcurrent || change.isNonTransactional || phaseNum === 23);
161
+
162
+ if (isConcurrentIndex) {
163
+ const indexName = change.after?.name || change.name || change.objectKey?.split('.').pop();
164
+ const tableName = change.after?.table || change.tableName;
165
+ const schema = change.schema || change.objectKey?.split('.')[0] || 'public';
166
+
167
+ steps.push({
168
+ id: `step_${String(stepId++).padStart(3, '0')}`,
169
+ type: 'pre_check',
170
+ phase: 22,
171
+ changeId,
172
+ description: `Pre-check: Verify index ${indexName} does not already exist`,
173
+ sql: `SELECT 1 FROM pg_indexes WHERE indexname = '${indexName}' AND schemaname = '${schema}'${tableName ? ` AND tablename = '${tableName}'` : ''}`,
174
+ isTransactional: true,
175
+ riskLevel: 'none',
176
+ dependencies: [],
177
+ preCheck: true,
178
+ preCheckExpectEmpty: true,
179
+ preCheckMessage: `Index ${indexName} already exists`,
180
+ });
181
+ }
182
+
183
+ const step = {
184
+ id: `step_${String(stepId++).padStart(3, '0')}`,
185
+ type: this.mapChangeToStepType(change),
186
+ phase: phaseNum,
187
+ changeId,
188
+ description: `${change.changeType} ${change.objectType} ${change.objectKey}`,
189
+ sql: change.sql || `-- ${change.changeType} ${change.objectType} ${change.objectKey}`,
190
+ isTransactional: change.isNonTransactional !== true,
191
+ riskLevel: change.risk?.level || 'none',
192
+ dependencies: (change.dependencies || []).map(d => `change_${d}`),
193
+ track: change.track,
194
+ };
195
+
196
+ if (isConcurrentIndex) {
197
+ const indexName = change.after?.name || change.name || change.objectKey?.split('.').pop();
198
+ const schema = change.schema || change.objectKey?.split('.')[0] || 'public';
199
+ step.recoverySql = `DROP INDEX IF EXISTS ${schema}.${indexName}`;
200
+ }
201
+
202
+ steps.push(step);
203
+ }
204
+ }
205
+ }
206
+
207
+ steps.push({
208
+ id: `step_${String(stepId++).padStart(3, '0')}`,
209
+ type: 'snapshot',
210
+ phase: 26,
211
+ description: 'Capture post-migration snapshot',
212
+ sql: '-- Snapshot capture',
213
+ isTransactional: false,
214
+ riskLevel: 'none',
215
+ dependencies: [`step_${String(stepId - 2).padStart(3, '0')}`],
216
+ });
217
+
218
+ steps.push({
219
+ id: `step_${String(stepId++).padStart(3, '0')}`,
220
+ type: 'verify',
221
+ phase: 26,
222
+ description: 'Post-migration verification',
223
+ sql: '-- Verify schema matches target',
224
+ isTransactional: false,
225
+ riskLevel: 'none',
226
+ dependencies: [`step_${String(stepId - 2).padStart(3, '0')}`],
227
+ });
228
+
229
+ const sequencedSteps = this.sequencer.sequence(changesArray, steps);
230
+ const summary = Array.isArray(changes) ? this.buildSummary(changes) : changes.summary;
231
+
232
+ return {
233
+ id: `migration_${Date.now()}`,
234
+ name: options.name || `migration_${Date.now()}`,
235
+ description: options.description,
236
+ createdAt: new Date().toISOString(),
237
+ sourceChecksum: options.sourceChecksum,
238
+ targetChecksum: options.targetChecksum,
239
+ changes: changesArray,
240
+ warnings,
241
+ steps: sequencedSteps,
242
+ riskAssessment: {
243
+ overallRisk: summary?.riskSummary ? this.getOverallRisk(summary.riskSummary) : 'none',
244
+ findings: [],
245
+ destructiveChanges: summary?.riskSummary?.categories?.data_loss || 0,
246
+ dataLossChanges: summary?.riskSummary?.categories?.data_loss || 0,
247
+ lockRiskChanges: summary?.riskSummary?.categories?.lock_hazard || 0,
248
+ canAutoFix: Array.isArray(summary?.riskSummary?.categories)
249
+ ? summary.riskSummary.categories.map(c => c.safePatternAvailable).filter(Boolean).length
250
+ : 0,
251
+ requiresReview: summary?.riskSummary?.categories?.rename_unconfirmed || 0,
252
+ estimatedDowntime: summary?.requiresDowntime ? 'POTENTIAL' : 'NONE',
253
+ },
254
+ isReversible: this.checkReversibility(changesArray),
255
+ summary,
256
+ };
257
+ }
258
+
259
+ buildSummary(changes) {
260
+ return {
261
+ totalChanges: changes.length,
262
+ creates: changes.filter(c => c.changeType === 'CREATE').length,
263
+ drops: changes.filter(c => c.changeType === 'DROP').length,
264
+ alters: changes.filter(c => c.changeType === 'ALTER').length,
265
+ renames: changes.filter(c => c.changeType === 'RENAME').length,
266
+ };
267
+ }
268
+
269
+ getOverallRisk(riskSummary) {
270
+ if (riskSummary.critical > 0) return 'critical';
271
+ if (riskSummary.high > 0) return 'high';
272
+ if (riskSummary.medium > 0) return 'medium';
273
+ if (riskSummary.low > 0) return 'low';
274
+ return 'none';
275
+ }
276
+
277
+ mapChangeToStepType(change) {
278
+ const objType = change.objectType;
279
+ if (objType === 'index') return 'index';
280
+ if (objType === 'constraint') return 'constraint';
281
+ if (objType === 'trigger') return 'post_structural';
282
+ if (objType === 'policy') return 'policy';
283
+ if (objType === 'view' || objType === 'materializedView') return 'view';
284
+ if (objType === 'function' || objType === 'procedure') return 'function';
285
+ return 'structural';
286
+ }
287
+
288
+ mapChangeToPhase(change) {
289
+ const objType = change.objectType;
290
+ const changeType = change.changeType?.toUpperCase();
291
+
292
+ if (changeType === 'DROP' || changeType?.startsWith('REMOVE')) {
293
+ if (['trigger', 'policy', 'rule', 'eventTrigger'].includes(objType)) return DROP_PHASES.behavioral;
294
+ if (['view', 'materializedView'].includes(objType)) return DROP_PHASES.behavioral;
295
+ if (['function', 'procedure', 'aggregate'].includes(objType)) return DROP_PHASES.behavioral;
296
+ if (['textSearchConfig', 'textSearchDict', 'textSearchParser', 'textSearchTemplate'].includes(objType)) return DROP_PHASES.behavioral;
297
+ if (['conversion', 'language'].includes(objType)) return DROP_PHASES.behavioral;
298
+ if (['foreignDataWrapper', 'foreignServer', 'userMapping'].includes(objType)) return DROP_PHASES.behavioral;
299
+ if (['publication', 'subscription'].includes(objType)) return DROP_PHASES.behavioral;
300
+
301
+ if (objType === 'constraint') return DROP_PHASES.constraints;
302
+
303
+ if (objType === 'index') return DROP_PHASES.indexes;
304
+
305
+ if (objType === 'column') return DROP_PHASES.columns;
306
+
307
+ if (objType === 'sequence') return DROP_PHASES.sequences;
308
+
309
+ if (['table', 'foreignTable'].includes(objType)) return DROP_PHASES.structural;
310
+ if (['type', 'collation', 'operator', 'operatorClass', 'operatorFamily', 'cast'].includes(objType)) return DROP_PHASES.structural;
311
+ if (['extension', 'schema', 'accessMethod', 'defaultPrivileges'].includes(objType)) return DROP_PHASES.structural;
312
+ if (['statistics'].includes(objType)) return DROP_PHASES.structural;
313
+
314
+ return DROP_PHASES.behavioral;
315
+ }
316
+
317
+ if (changeType === 'CREATE' || changeType?.startsWith('ADD')) {
318
+ if (objType === 'extension') return 3;
319
+ if (objType === 'schema') return 5;
320
+ if (objType === 'type') return 4;
321
+ if (objType === 'table') return 6;
322
+ if (objType === 'foreignTable') return 6;
323
+ if (objType === 'column') return 7;
324
+ if (objType === 'sequence') return 8;
325
+ if (objType === 'index') return change.isNonTransactional || change.isConcurrent ? 23 : 9;
326
+ if (objType === 'constraint' && change.constraintType !== 'FOREIGN_KEY') return 10;
327
+ if (objType === 'view') return 14;
328
+ if (objType === 'materializedView') return 15;
329
+ if (objType === 'function' || objType === 'procedure') return 16;
330
+ if (objType === 'trigger') return 17;
331
+ if (objType === 'eventTrigger') return 17;
332
+ if (objType === 'policy') return 18;
333
+ if (objType === 'rule') return 19;
334
+ if (objType === 'operator') return 20;
335
+ if (objType === 'operatorClass') return 20;
336
+ if (objType === 'operatorFamily') return 20;
337
+ return 10;
338
+ }
339
+
340
+ if (changeType === 'ALTER') {
341
+ if (objType === 'column') return 11;
342
+ if (objType === 'constraint') return change.constraintType === 'FOREIGN_KEY' ? 12 : 10;
343
+ if (objType === 'function' || objType === 'procedure') return 16;
344
+ if (objType === 'view') return 14;
345
+ if (objType === 'sequence') return 8;
346
+ if (objType === 'policy') return 18;
347
+ return 20;
348
+ }
349
+
350
+ return 10;
351
+ }
352
+
353
+ checkReversibility(changes) {
354
+ return !changes.some(c => c.changeType === 'DROP' && c.objectType === 'table');
355
+ }
356
+ }
@@ -0,0 +1,82 @@
1
+ import { TypeRegistry } from './type-registry.js';
2
+
3
+ const PHASES = {
4
+ ADD_COLUMN: 7,
5
+ DATA_MIGRATION: 11,
6
+ DROP_COLUMN: 30,
7
+ };
8
+
9
+ export class SmartMigrator {
10
+ constructor() {
11
+ this.typeRegistry = new TypeRegistry();
12
+ }
13
+
14
+ /**
15
+ * @param {import('../types/changes.js').SchemaChange} change
16
+ * @returns {import('../types/migration.js').MigrationStep[] | null}
17
+ */
18
+ analyze(change) {
19
+ const isAlterColumn = change.changeType === 'ALTER' &&
20
+ change.objectType === 'column' &&
21
+ change.changedProperties?.includes('dataType');
22
+
23
+ if (!isAlterColumn) return null;
24
+
25
+ const fromType = change.before?.dataType;
26
+ const toType = change.after?.dataType;
27
+ if (!fromType || !toType) return null;
28
+
29
+ if (this.typeRegistry.isImpossibleCast(fromType, toType)) {
30
+ return this.createMultiStepPlan(change, fromType, toType);
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * @param {import('../types/changes.js').SchemaChange} change
38
+ * @param {string} fromType
39
+ * @param {string} toType
40
+ * @returns {import('../types/migration.js').MigrationStep[]}
41
+ */
42
+ createMultiStepPlan(change, fromType, toType) {
43
+ const table = change.objectKey.split('.').slice(0, -1).join('.');
44
+ const col = change.after?.name || change.name;
45
+ const tempCol = `${col}_new`;
46
+ const stepBase = `smart_${Date.now()}`;
47
+
48
+ return [
49
+ {
50
+ id: `${stepBase}_1`,
51
+ type: 'structural',
52
+ phase: PHASES.ADD_COLUMN,
53
+ description: `Add new column ${tempCol} with type ${toType}`,
54
+ sql: `ALTER TABLE ${table} ADD COLUMN ${tempCol} ${toType};`,
55
+ isTransactional: true,
56
+ riskLevel: 'low',
57
+ dependencies: [],
58
+ },
59
+ {
60
+ id: `${stepBase}_2`,
61
+ type: 'data_migration',
62
+ phase: PHASES.DATA_MIGRATION,
63
+ description: `Backfill data from ${col} to ${tempCol}`,
64
+ sql: `-- Batched backfill: UPDATE ${table} SET ${tempCol} = ${col}::${toType} WHERE ${tempCol} IS NULL;`,
65
+ isTransactional: true,
66
+ riskLevel: 'medium',
67
+ dependencies: [`${stepBase}_1`],
68
+ estimatedRows: 10000,
69
+ },
70
+ {
71
+ id: `${stepBase}_3`,
72
+ type: 'structural',
73
+ phase: PHASES.DROP_COLUMN,
74
+ description: `Drop old column and rename new column`,
75
+ sql: `ALTER TABLE ${table} DROP COLUMN ${col}; ALTER TABLE ${table} RENAME COLUMN ${tempCol} TO ${col};`,
76
+ isTransactional: true,
77
+ riskLevel: 'high',
78
+ dependencies: [`${stepBase}_2`],
79
+ },
80
+ ];
81
+ }
82
+ }
@@ -0,0 +1,61 @@
1
+ export class StepSequencer {
2
+ /**
3
+ * @param {import('../types/changes.js').SchemaChange[]} changes
4
+ * @param {import('../types/migration.js').MigrationStep[]} steps
5
+ * @returns {import('../types/migration.js').MigrationStep[]}
6
+ */
7
+ sequence(changes, steps) {
8
+ const sequenced = [];
9
+ const stepMap = new Map(steps.map(s => [s.id, s]));
10
+
11
+ const sorted = this.topologicalSort(steps);
12
+ let idCounter = 1;
13
+
14
+ for (const step of sorted) {
15
+ sequenced.push({
16
+ ...step,
17
+ id: `step_${String(idCounter).padStart(3, '0')}`,
18
+ });
19
+ idCounter++;
20
+ }
21
+
22
+ return sequenced;
23
+ }
24
+
25
+ /**
26
+ * @param {import('../types/migration.js').MigrationStep[]} steps
27
+ * @returns {import('../types/migration.js').MigrationStep[]}
28
+ */
29
+ topologicalSort(steps) {
30
+ const stepMap = new Map(steps.map(s => [s.id, s]));
31
+ const inDegree = new Map(steps.map(s => [s.id, 0]));
32
+ const adj = new Map(steps.map(s => [s.id, []]));
33
+
34
+ for (const step of steps) {
35
+ for (const depId of step.dependencies || []) {
36
+ if (adj.has(depId)) {
37
+ adj.get(depId).push(step.id);
38
+ inDegree.set(step.id, (inDegree.get(step.id) || 0) + 1);
39
+ }
40
+ }
41
+ }
42
+
43
+ const queue = [];
44
+ for (const [id, degree] of inDegree) {
45
+ if (degree === 0) queue.push(id);
46
+ }
47
+
48
+ const sorted = [];
49
+ while (queue.length > 0) {
50
+ const current = queue.shift();
51
+ sorted.push(stepMap.get(current));
52
+
53
+ for (const neighbor of adj.get(current) || []) {
54
+ inDegree.set(neighbor, inDegree.get(neighbor) - 1);
55
+ if (inDegree.get(neighbor) === 0) queue.push(neighbor);
56
+ }
57
+ }
58
+
59
+ return sorted;
60
+ }
61
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Type cast compatibility matrix
3
+ */
4
+ export class TypeRegistry {
5
+ constructor() {
6
+ this.implicitCasts = new Map([
7
+ ['smallint', new Set(['integer', 'bigint', 'real', 'double precision', 'numeric', 'decimal'])],
8
+ ['integer', new Set(['bigint', 'real', 'double precision', 'numeric', 'decimal'])],
9
+ ['bigint', new Set(['real', 'double precision', 'numeric', 'decimal'])],
10
+ ['real', new Set(['double precision', 'numeric'])],
11
+ ['numeric', new Set(['numeric'])],
12
+ ['character varying', new Set(['text', 'character varying'])],
13
+ ['character', new Set(['text', 'character varying', 'character'])],
14
+ ['text', new Set(['character varying'])],
15
+ ['date', new Set(['timestamp', 'timestamptz'])],
16
+ ['timestamp', new Set(['timestamptz'])],
17
+ ]);
18
+
19
+ this.impossibleCasts = new Set([
20
+ 'text->integer', 'text->bigint', 'text->numeric',
21
+ 'integer->boolean', 'boolean->integer',
22
+ 'text->date', 'text->timestamp', 'text->uuid',
23
+ 'jsonb->json', 'json->jsonb',
24
+ ]);
25
+ }
26
+
27
+ canCastImplicitly(from, to) {
28
+ return this.implicitCasts.get(from.toLowerCase())?.has(to.toLowerCase()) || false;
29
+ }
30
+
31
+ isImpossibleCast(from, to) {
32
+ return this.impossibleCasts.has(`${from.toLowerCase()}->${to.toLowerCase()}`);
33
+ }
34
+
35
+ requiresUsingClause(from, to) {
36
+ return !this.canCastImplicitly(from, to) && !this.isImpossibleCast(from, to);
37
+ }
38
+
39
+ generateUsingClause(from, to, column) {
40
+ if (to.toLowerCase() === 'text' || to.toLowerCase().startsWith('character')) return `${column}::text`;
41
+ if (to.toLowerCase() === 'integer') return `${column}::integer`;
42
+ if (to.toLowerCase() === 'numeric') return `${column}::numeric`;
43
+ if (to.toLowerCase() === 'uuid') return `${column}::uuid`;
44
+ if (to.toLowerCase() === 'timestamptz') return `${column}::timestamptz`;
45
+ return `${column}::${to}`;
46
+ }
47
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @param {import('../types/changes.js').SchemaChange} change
3
+ * @param {number} pgVersion
4
+ * @returns {import('../types/risk.js').RiskFinding[]}
5
+ */
6
+ export function checkCompatibility(change, pgVersion) {
7
+ const findings = [];
8
+ if (pgVersion < 150000 && change.after?.nullsNotDistinct) {
9
+ findings.push({
10
+ category: 'compatibility',
11
+ severity: 'high',
12
+ changeId: change.id,
13
+ message: 'NULLS NOT DISTINCT requires PostgreSQL 15+',
14
+ recommendation: 'Remove NULLS NOT DISTINCT or upgrade PostgreSQL',
15
+ autoFixable: false,
16
+ });
17
+ }
18
+ if (pgVersion < 180000 && change.after?.generatedStorage === 'VIRTUAL') {
19
+ findings.push({
20
+ category: 'compatibility',
21
+ severity: 'high',
22
+ changeId: change.id,
23
+ message: 'VIRTUAL generated columns require PostgreSQL 18+',
24
+ recommendation: 'Use STORED generated columns or upgrade PostgreSQL',
25
+ autoFixable: true,
26
+ });
27
+ }
28
+ return findings;
29
+ }