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,32 @@
1
+ /**
2
+ * @param {import('../types/changes.js').SchemaChange} change
3
+ * @returns {import('../types/risk.js').RiskFinding[]}
4
+ */
5
+ export function checkDataLoss(change) {
6
+ const findings = [];
7
+ if (change.type === 'ALTER_COLUMN' && change.before?.dataType !== change.after?.dataType) {
8
+ const from = change.before.dataType;
9
+ const to = change.after.dataType;
10
+ if (isNarrowingCast(from, to)) {
11
+ findings.push({
12
+ category: 'data_loss',
13
+ severity: 'high',
14
+ changeId: change.id,
15
+ message: `Narrowing type from ${from} to ${to} may truncate data`,
16
+ recommendation: 'Add a CHECK constraint first to validate, then alter type',
17
+ safeAlternative: `ALTER TABLE ${change.objectKey.split('.').slice(0, -1).join('.')} ALTER COLUMN ${change.after.name} TYPE ${to} USING ${change.after.name}::${to};`,
18
+ autoFixable: true,
19
+ });
20
+ }
21
+ }
22
+ return findings;
23
+ }
24
+
25
+ function isNarrowingCast(from, to) {
26
+ const narrowing = [
27
+ ['bigint', 'integer'], ['bigint', 'smallint'], ['integer', 'smallint'],
28
+ ['numeric', 'integer'], ['numeric', 'bigint'],
29
+ ['character varying', 'character'], ['text', 'character varying'],
30
+ ];
31
+ return narrowing.some(([f, t]) => from.toLowerCase().includes(f) && to.toLowerCase().includes(t));
32
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @param {import('../types/changes.js').SchemaChange} change
3
+ * @returns {import('../types/risk.js').RiskFinding[]}
4
+ */
5
+ export function checkDestructive(change) {
6
+ const findings = [];
7
+ if (change.type === 'DROP' && change.objectType === 'TABLE') {
8
+ findings.push({
9
+ category: 'destructive',
10
+ severity: 'critical',
11
+ changeId: change.id,
12
+ message: `Dropping table ${change.objectKey} will permanently delete all data`,
13
+ recommendation: 'Consider renaming instead of dropping, or ensure data is backed up',
14
+ autoFixable: false,
15
+ });
16
+ }
17
+ if (change.type === 'DROP_COLUMN') {
18
+ findings.push({
19
+ category: 'destructive',
20
+ severity: 'high',
21
+ changeId: change.id,
22
+ message: `Dropping column ${change.name} from ${change.objectKey} will delete data`,
23
+ recommendation: 'Stage the column drop: deprecate -> stop using -> drop',
24
+ autoFixable: false,
25
+ });
26
+ }
27
+ if (change.type === 'DROP' && change.objectType === 'VIEW') {
28
+ findings.push({
29
+ category: 'destructive',
30
+ severity: 'medium',
31
+ changeId: change.id,
32
+ message: `Dropping view ${change.objectKey}`,
33
+ recommendation: 'Ensure no applications depend on this view',
34
+ autoFixable: false,
35
+ });
36
+ }
37
+ return findings;
38
+ }
@@ -0,0 +1,6 @@
1
+ export { RiskEngine } from './risk-engine.js';
2
+ export { checkDestructive } from './destructive-checker.js';
3
+ export { checkDataLoss } from './data-loss-checker.js';
4
+ export { checkLockRisk } from './lock-analyzer.js';
5
+ export { checkCompatibility } from './compatibility-checker.js';
6
+ export { computeOverallRisk } from './recommendations.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @param {import('../types/changes.js').SchemaChange} change
3
+ * @returns {import('../types/risk.js').RiskFinding[]}
4
+ */
5
+ export function checkLockRisk(change) {
6
+ const findings = [];
7
+ if (change.type === 'ADD_INDEX' && !change.sql?.includes('CONCURRENTLY')) {
8
+ findings.push({
9
+ category: 'lock_risk',
10
+ severity: 'medium',
11
+ changeId: change.id,
12
+ message: `Creating index without CONCURRENTLY will lock ${change.objectKey} for writes`,
13
+ recommendation: 'Use CREATE INDEX CONCURRENTLY for zero-downtime index creation',
14
+ safeAlternative: change.sql?.replace('CREATE INDEX', 'CREATE INDEX CONCURRENTLY'),
15
+ autoFixable: true,
16
+ });
17
+ }
18
+ if (change.type === 'ADD_CONSTRAINT' && change.after?.type === 'FOREIGN_KEY' && !change.sql?.includes('NOT VALID')) {
19
+ findings.push({
20
+ category: 'lock_risk',
21
+ severity: 'medium',
22
+ changeId: change.id,
23
+ message: `Adding FK constraint directly will scan and lock the table`,
24
+ recommendation: 'Add constraint NOT VALID first, then VALIDATE',
25
+ autoFixable: true,
26
+ });
27
+ }
28
+ if (change.type === 'ALTER_COLUMN' && change.before?.isNullable !== change.after?.isNullable && !change.after?.isNullable) {
29
+ findings.push({
30
+ category: 'lock_risk',
31
+ severity: 'medium',
32
+ changeId: change.id,
33
+ message: `Setting NOT NULL directly will lock the table for a full scan`,
34
+ recommendation: 'Use 3-step pattern: ADD CHECK NOT NULL NOT VALID -> VALIDATE -> SET NOT NULL',
35
+ autoFixable: true,
36
+ });
37
+ }
38
+ return findings;
39
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @param {import('../types/risk.js').RiskFinding} finding
3
+ * @returns {string}
4
+ */
5
+ export function generateRecommendation(finding) {
6
+ if (finding.safeAlternative) {
7
+ return `${finding.recommendation}\nSafe alternative:\n${finding.safeAlternative}`;
8
+ }
9
+ return finding.recommendation;
10
+ }
11
+
12
+ /**
13
+ * @param {import('../types/risk.js').RiskFinding[]} findings
14
+ * @returns {import('../types/risk.js').RiskAssessment}
15
+ */
16
+ export function computeOverallRisk(findings) {
17
+ const destructive = findings.filter(f => f.category === 'destructive').length;
18
+ const dataLoss = findings.filter(f => f.category === 'data_loss').length;
19
+ const lockRisk = findings.filter(f => f.category === 'lock_risk').length;
20
+ const canAutoFix = findings.filter(f => f.autoFixable).length;
21
+ const requiresReview = findings.filter(f => !f.autoFixable).length;
22
+
23
+ let overallRisk = 'none';
24
+ if (findings.some(f => f.severity === 'critical')) overallRisk = 'critical';
25
+ else if (findings.some(f => f.severity === 'high')) overallRisk = 'high';
26
+ else if (findings.some(f => f.severity === 'medium')) overallRisk = 'medium';
27
+ else if (findings.some(f => f.severity === 'low')) overallRisk = 'low';
28
+
29
+ let estimatedDowntime = 'none';
30
+ if (destructive > 0 || dataLoss > 0) estimatedDowntime = 'significant';
31
+ else if (lockRisk > 2) estimatedDowntime = 'moderate';
32
+ else if (lockRisk > 0) estimatedDowntime = 'minimal';
33
+
34
+ return {
35
+ overallRisk,
36
+ findings,
37
+ destructiveChanges: destructive,
38
+ dataLossChanges: dataLoss,
39
+ lockRiskChanges: lockRisk,
40
+ canAutoFix,
41
+ requiresReview,
42
+ estimatedDowntime,
43
+ };
44
+ }
@@ -0,0 +1,25 @@
1
+ import { checkDestructive } from './destructive-checker.js';
2
+ import { checkDataLoss } from './data-loss-checker.js';
3
+ import { checkLockRisk } from './lock-analyzer.js';
4
+ import { checkCompatibility } from './compatibility-checker.js';
5
+ import { computeOverallRisk } from './recommendations.js';
6
+
7
+ export class RiskEngine {
8
+ /**
9
+ * @param {import('../types/changes.js').SchemaChange[]} changes
10
+ * @param {number} [pgVersion]
11
+ * @returns {import('../types/risk.js').RiskAssessment}
12
+ */
13
+ assess(changes, pgVersion = 150000) {
14
+ const findings = [];
15
+
16
+ for (const change of changes) {
17
+ findings.push(...checkDestructive(change));
18
+ findings.push(...checkDataLoss(change));
19
+ findings.push(...checkLockRisk(change));
20
+ findings.push(...checkCompatibility(change, pgVersion));
21
+ }
22
+
23
+ return computeOverallRisk(findings);
24
+ }
25
+ }
@@ -0,0 +1,5 @@
1
+ export { InMemoryStorageProvider } from './memory-storage.js';
2
+ // FileStorageProvider and GitHubStorageProvider removed - not integrated into pipeline
3
+ // Schema files are stored in user's GitHub repo or browser cache, not storage layer
4
+ export { MigrationTable } from './migration-table.js';
5
+ export { RollbackGenerator } from './rollback-generator.js';
@@ -0,0 +1,87 @@
1
+ export class InMemoryStorageProvider {
2
+ constructor() {
3
+ this.snapshots = new Map();
4
+ this.migrations = new Map();
5
+ this.files = new Map();
6
+ }
7
+
8
+ async saveSnapshot(checksum, schema) {
9
+ this.snapshots.set(checksum, { schema, timestamp: new Date().toISOString() });
10
+ }
11
+
12
+ async loadSnapshot(checksum) {
13
+ const entry = this.snapshots.get(checksum);
14
+ if (!entry) throw new Error(`Snapshot not found: ${checksum}`);
15
+ return entry.schema;
16
+ }
17
+
18
+ async listSnapshots() {
19
+ return Array.from(this.snapshots.entries()).map(([checksum, entry]) => ({
20
+ checksum,
21
+ timestamp: entry.timestamp,
22
+ }));
23
+ }
24
+
25
+ async saveMigration(migration) {
26
+ const connectionId = migration.connectionId || 'default';
27
+ if (!this.migrations.has(connectionId)) {
28
+ this.migrations.set(connectionId, []);
29
+ }
30
+ this.migrations.get(connectionId).push(migration);
31
+ return migration;
32
+ }
33
+
34
+ async getMigration(id, connectionId) {
35
+ const cid = connectionId || 'default';
36
+ const migrations = this.migrations.get(cid) || [];
37
+ return migrations.find(m => m.id === id) || null;
38
+ }
39
+
40
+ async getLatestMigration(connectionId) {
41
+ const cid = connectionId || 'default';
42
+ const migrations = this.migrations.get(cid) || [];
43
+ if (migrations.length === 0) return null;
44
+ return migrations[migrations.length - 1];
45
+ }
46
+
47
+ async getMigrationHistory(connectionId) {
48
+ const cid = connectionId || 'default';
49
+ return this.migrations.get(cid) || [];
50
+ }
51
+
52
+ async getStats(connectionId) {
53
+ const cid = connectionId || 'default';
54
+ const migrations = this.migrations.get(cid) || [];
55
+ return {
56
+ totalMigrations: migrations.length,
57
+ total_migrations: migrations.length,
58
+ completed: migrations.filter(m => m.status === 'completed').length,
59
+ failed: migrations.filter(m => m.status === 'failed').length,
60
+ running: migrations.filter(m => m.status === 'running').length,
61
+ };
62
+ }
63
+
64
+ async saveMigrationFile(name, sql) {
65
+ this.files.set(name, sql);
66
+ }
67
+
68
+ async loadMigrationFile(name) {
69
+ const sql = this.files.get(name);
70
+ if (!sql) throw new Error(`Migration file not found: ${name}`);
71
+ return sql;
72
+ }
73
+
74
+ async listMigrationFiles() {
75
+ return Array.from(this.files.keys());
76
+ }
77
+
78
+ async clearConnection(connectionId) {
79
+ this.migrations.delete(connectionId);
80
+ }
81
+
82
+ async clearAll() {
83
+ this.migrations.clear();
84
+ this.snapshots.clear();
85
+ this.files.clear();
86
+ }
87
+ }