@mlagie/sql-connector 3.0.3 → 3.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/README.md +985 -316
- package/docs/API.md +240 -0
- package/docs/DIALECTS.md +56 -0
- package/docs/MIGRATIONS.md +277 -0
- package/docs/QUERY.md +281 -0
- package/docs/README.md +20 -0
- package/docs/SCHEMA.md +167 -0
- package/docs/fr/README_FR.md +549 -305
- package/index.d.ts +2002 -137
- package/index.js +39 -9
- package/package.json +7 -6
- package/src/db/dialects.js +1 -1
- package/src/migration/BackupManager.js +42 -0
- package/src/migration/Migration.js +163 -0
- package/src/migration/MigrationPlan.js +62 -0
- package/src/migration/MigrationRunner.js +194 -0
- package/src/migration/MigrationStore.js +137 -0
- package/src/migration/SchemaDiffer.js +261 -0
- package/src/migration/SchemaInspector.js +44 -0
- package/src/migration/SchemaSnapshot.js +53 -0
- package/src/migration/crypto.js +79 -0
- package/src/migration/dialects/Dialect.js +112 -0
- package/src/migration/dialects/MySQLDialect.js +205 -0
- package/src/migration/dialects/PostgreSQLDialect.js +233 -0
- package/src/migration/dialects/index.js +21 -0
- package/src/migration/errors.js +28 -0
- package/src/migration/index.js +18 -0
- package/src/migration/utils.js +89 -0
- package/src/models/Model.js +8 -2
- package/src/models/Schema.js +20 -20
- package/src/utils/buildQuery/buildQuery.js +51 -11
- package/src/utils/sqlTypeMap.js +8 -0
package/index.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
|
-
const { Model } = require("./src/models/Model");
|
|
2
|
-
const { ModelInstance } = require("./src/models/ModelInstance");
|
|
3
|
-
const { connect, logout } = require("./src/db/connect");
|
|
4
|
-
const {Schema} = require("./src/models/Schema");
|
|
5
|
-
const { sqlTypeMap } = require("./src/utils/sqlTypeMap");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
const { Model } = require("./src/models/Model");
|
|
2
|
+
const { ModelInstance } = require("./src/models/ModelInstance");
|
|
3
|
+
const { connect, logout } = require("./src/db/connect");
|
|
4
|
+
const {Schema} = require("./src/models/Schema");
|
|
5
|
+
const { sqlTypeMap } = require("./src/utils/sqlTypeMap");
|
|
6
|
+
const {
|
|
7
|
+
Migration,
|
|
8
|
+
MigrationPlan,
|
|
9
|
+
MigrationStore,
|
|
10
|
+
MigrationRunner,
|
|
11
|
+
SchemaDiffer,
|
|
12
|
+
SchemaInspector,
|
|
13
|
+
MySQLDialect,
|
|
14
|
+
PostgreSQLDialect
|
|
15
|
+
} = require("./src/migration");
|
|
16
|
+
const { MigrationError, DestructiveMigrationError, MigrationChecksumError } = require("./src/migration/errors");
|
|
17
|
+
|
|
18
|
+
let client = {};
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
client,
|
|
22
|
+
connect,
|
|
23
|
+
logout,
|
|
24
|
+
Schema,
|
|
25
|
+
Model,
|
|
26
|
+
ModelInstance,
|
|
27
|
+
sqlTypeMap,
|
|
28
|
+
Migration,
|
|
29
|
+
MigrationPlan,
|
|
30
|
+
MigrationStore,
|
|
31
|
+
MigrationRunner,
|
|
32
|
+
SchemaDiffer,
|
|
33
|
+
SchemaInspector,
|
|
34
|
+
MySQLDialect,
|
|
35
|
+
PostgreSQLDialect,
|
|
36
|
+
MigrationError,
|
|
37
|
+
DestructiveMigrationError,
|
|
38
|
+
MigrationChecksumError
|
|
39
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlagie/sql-connector",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "The sql-connector module allows you to manage connections to a MySQL database, define table schemas, and interact with data in a simple and efficient way.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "rm -r coverage ; node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand",
|
|
21
21
|
"test:publish": "npm publish --dry-run",
|
|
22
22
|
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
|
|
23
|
-
"test:coverage": "rm -r coverage ; node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
23
|
+
"test:coverage": "rm -r coverage ; node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage && ./scripts/coverage-report.sh",
|
|
24
24
|
"coverage:report": "./scripts/coverage-report.sh",
|
|
25
25
|
"security": "npx eslint . --max-warnings 0 --ignore-pattern './tests/*'"
|
|
26
26
|
},
|
|
@@ -66,14 +66,14 @@
|
|
|
66
66
|
"private": false,
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@mlagie/logger": "1.0.5",
|
|
69
|
-
"mysql2": "3.
|
|
69
|
+
"mysql2": "3.24.4",
|
|
70
70
|
"pg": "^8.23.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@eslint/js": "^10.0.1",
|
|
74
|
-
"eslint": "^10.
|
|
74
|
+
"eslint": "^10.10.0",
|
|
75
75
|
"eslint-plugin-security": "^4.0.1",
|
|
76
|
-
"jest": "30.
|
|
76
|
+
"jest": "30.5.1"
|
|
77
77
|
},
|
|
78
78
|
"overrides": {
|
|
79
79
|
"test-exclude": "^8.0.0",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"brace-expansion": "^5.0.9"
|
|
83
83
|
},
|
|
84
84
|
"allowScripts": {
|
|
85
|
-
"unrs-resolver@1.12.2": true
|
|
85
|
+
"unrs-resolver@1.12.2": true,
|
|
86
|
+
"@parcel/watcher@2.6.0": true
|
|
86
87
|
}
|
|
87
88
|
}
|
package/src/db/dialects.js
CHANGED
|
@@ -33,7 +33,7 @@ function escapeIdentifierList(identifiers) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function isDateLikeType(fieldType) {
|
|
36
|
-
return ["date", "datetime", "timestamp", "now"].includes(String(fieldType ?? "").toLowerCase());
|
|
36
|
+
return ["date", "datetime", "timestamp", "now", "time", "year"].includes(String(fieldType ?? "").toLowerCase());
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function isSqlTemporalDefault(defaultValue) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { setSafe } = require("../utils/security/safe");
|
|
2
|
+
|
|
3
|
+
class BackupManager {
|
|
4
|
+
constructor(connection, dialect, store) {
|
|
5
|
+
this.connection = connection;
|
|
6
|
+
this.dialect = dialect;
|
|
7
|
+
this.store = store;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async backupTables(tables, migrationId) {
|
|
11
|
+
if (!this.store.retention || tables.length === 0) return null;
|
|
12
|
+
|
|
13
|
+
const backup = {
|
|
14
|
+
version: 1,
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
migrationId,
|
|
17
|
+
tables: {}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const existing = await this.dialect.execute(
|
|
21
|
+
this.connection,
|
|
22
|
+
this.dialect.introspectionQueries().tables
|
|
23
|
+
);
|
|
24
|
+
const existingNames = new Set(existing.map(row => row.table_name));
|
|
25
|
+
|
|
26
|
+
for (const table of tables) {
|
|
27
|
+
// A table created by the migration does not exist yet and therefore
|
|
28
|
+
// cannot be backed up. Rollback metadata will remove it if needed.
|
|
29
|
+
if (!existingNames.has(table)) continue;
|
|
30
|
+
|
|
31
|
+
const rows = await this.dialect.execute(
|
|
32
|
+
this.connection,
|
|
33
|
+
`SELECT * FROM ${this.dialect.escape(table)}`
|
|
34
|
+
);
|
|
35
|
+
setSafe(backup.tables, table, rows);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return this.store.writeBackup(migrationId, backup);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { BackupManager };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const { getConnexion } = require("../db/connexion");
|
|
2
|
+
const { getDialect: getConnectorDialect } = require("../db/dialects");
|
|
3
|
+
const { modelsToSnapshot } = require("./SchemaSnapshot");
|
|
4
|
+
const { SchemaInspector } = require("./SchemaInspector");
|
|
5
|
+
const { SchemaDiffer } = require("./SchemaDiffer");
|
|
6
|
+
const { MigrationPlan } = require("./MigrationPlan");
|
|
7
|
+
const { MigrationStore } = require("./MigrationStore");
|
|
8
|
+
const { BackupManager } = require("./BackupManager");
|
|
9
|
+
const { MigrationRunner } = require("./MigrationRunner");
|
|
10
|
+
const { createDialect } = require("./dialects");
|
|
11
|
+
const { migrationId, stableStringify, sha256 } = require("./utils");
|
|
12
|
+
|
|
13
|
+
function resolveDialect(name) {
|
|
14
|
+
if (name) return createDialect(name);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const connectorDialect = getConnectorDialect();
|
|
18
|
+
if (connectorDialect?.name) return createDialect(connectorDialect.name);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.warn("Failed to resolve connector dialect:", error);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return createDialect("mysql");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveModels(models) {
|
|
27
|
+
if (Array.isArray(models)) return models;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { Model } = require("../models/Model");
|
|
31
|
+
return Model.pendingModels || [];
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class Migration {
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.connection = options.connection || getConnexion();
|
|
40
|
+
if (!this.connection) {
|
|
41
|
+
throw new Error("No database connection. Call connect() before using migrations.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.dialect = options.dialect && typeof options.dialect.execute === "function"
|
|
45
|
+
? options.dialect
|
|
46
|
+
: resolveDialect(options.dialect);
|
|
47
|
+
|
|
48
|
+
this.models = resolveModels(options.models);
|
|
49
|
+
this.store = options.store || new MigrationStore({
|
|
50
|
+
directory: options.directory || "migrations",
|
|
51
|
+
backupDirectory: options.backupDirectory,
|
|
52
|
+
encryptionKey: options.encryptionKey,
|
|
53
|
+
retention: options.retention
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.inspector = new SchemaInspector(this.connection, this.dialect);
|
|
57
|
+
this.differ = new SchemaDiffer({
|
|
58
|
+
allowDestructiveChanges: options.allowDestructiveChanges,
|
|
59
|
+
allowDropTables: options.allowDropTables
|
|
60
|
+
});
|
|
61
|
+
this.backupManager = new BackupManager(
|
|
62
|
+
this.connection,
|
|
63
|
+
this.dialect,
|
|
64
|
+
this.store
|
|
65
|
+
);
|
|
66
|
+
this.runner = new MigrationRunner({
|
|
67
|
+
connection: this.connection,
|
|
68
|
+
dialect: this.dialect,
|
|
69
|
+
store: this.store,
|
|
70
|
+
backupManager: this.backupManager
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async plan(options = {}) {
|
|
75
|
+
if (options.models) this.models = options.models;
|
|
76
|
+
|
|
77
|
+
const desired = modelsToSnapshot(this.models);
|
|
78
|
+
const current = await this.inspector.inspect();
|
|
79
|
+
|
|
80
|
+
const previous = await this.store.readSnapshot();
|
|
81
|
+
const baseline = previous || current;
|
|
82
|
+
|
|
83
|
+
const differ = new SchemaDiffer({
|
|
84
|
+
allowDestructiveChanges: options.allowDestructiveChanges ?? false,
|
|
85
|
+
allowDropTables: options.allowDropTables ?? false
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// The DB is the safety check; the snapshot is used as history.
|
|
89
|
+
const operations = differ.diff(desired, current);
|
|
90
|
+
|
|
91
|
+
const sql = operations
|
|
92
|
+
.map(operation => this.runner.sqlForOperation(operation))
|
|
93
|
+
.join("\n");
|
|
94
|
+
|
|
95
|
+
const id = options.id || migrationId(options.name || "schema");
|
|
96
|
+
const checksum = sha256(stableStringify({
|
|
97
|
+
id,
|
|
98
|
+
dialect: this.dialect.name,
|
|
99
|
+
operations,
|
|
100
|
+
sql
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
return new MigrationPlan({
|
|
104
|
+
id,
|
|
105
|
+
operations,
|
|
106
|
+
sql,
|
|
107
|
+
snapshot: desired,
|
|
108
|
+
previousSnapshot: baseline,
|
|
109
|
+
dialect: this.dialect,
|
|
110
|
+
checksum,
|
|
111
|
+
executor: (migrationPlan, executeOptions = {}) => this.runner.execute(
|
|
112
|
+
migrationPlan,
|
|
113
|
+
executeOptions
|
|
114
|
+
)
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async migrate(options = {}) {
|
|
119
|
+
const plan = await this.plan(options);
|
|
120
|
+
|
|
121
|
+
if (!plan.hasChanges()) {
|
|
122
|
+
return { applied: false, plan };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (options.approve !== false) plan.approve();
|
|
126
|
+
|
|
127
|
+
return this.runner.execute(plan, {
|
|
128
|
+
requireApproval: options.requireApproval ?? true,
|
|
129
|
+
allowDestructiveChanges: options.allowDestructiveChanges ?? false
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async status() {
|
|
134
|
+
return this.runner.applied();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async rollback(id, options = {}) {
|
|
138
|
+
if (!id) throw new Error("A migration id is required for rollback.");
|
|
139
|
+
return this.runner.rollback(id, options);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async cleanup() {
|
|
143
|
+
return this.store.cleanup();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static async plan(options = {}) {
|
|
147
|
+
return new Migration(options).plan(options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async migrate(options = {}) {
|
|
151
|
+
return new Migration(options).migrate(options);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static async status(options = {}) {
|
|
155
|
+
return new Migration(options).status();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static async rollback(id, options = {}) {
|
|
159
|
+
return new Migration(options).rollback(id, options);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { Migration };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { sha256, stableStringify, migrationId, clone } = require("./utils");
|
|
2
|
+
|
|
3
|
+
class MigrationPlan {
|
|
4
|
+
constructor({
|
|
5
|
+
id,
|
|
6
|
+
operations,
|
|
7
|
+
sql,
|
|
8
|
+
snapshot,
|
|
9
|
+
previousSnapshot,
|
|
10
|
+
dialect,
|
|
11
|
+
checksum = sha256(stableStringify({ id, operations, sql })),
|
|
12
|
+
executor = null
|
|
13
|
+
}) {
|
|
14
|
+
this.id = id || migrationId();
|
|
15
|
+
this.operations = operations;
|
|
16
|
+
this.sql = sql;
|
|
17
|
+
this.snapshot = clone(snapshot);
|
|
18
|
+
this.previousSnapshot = clone(previousSnapshot);
|
|
19
|
+
this.dialect = dialect.name;
|
|
20
|
+
this.checksum = checksum;
|
|
21
|
+
this.approved = false;
|
|
22
|
+
this._executor = executor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
hasChanges() {
|
|
26
|
+
return this.operations.length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
hasDestructiveChanges() {
|
|
30
|
+
return this.operations.some(operation => operation.destructive);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
destructiveChanges() {
|
|
34
|
+
return this.operations.filter(operation => operation.destructive);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
approve() {
|
|
38
|
+
this.approved = true;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async execute(options = {}) {
|
|
43
|
+
if (!this._executor) {
|
|
44
|
+
throw new Error("This migration plan is not attached to a Migration runner.");
|
|
45
|
+
}
|
|
46
|
+
return this._executor(this, options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toJSON() {
|
|
50
|
+
return {
|
|
51
|
+
id: this.id,
|
|
52
|
+
dialect: this.dialect,
|
|
53
|
+
checksum: this.checksum,
|
|
54
|
+
operations: this.operations,
|
|
55
|
+
sql: this.sql,
|
|
56
|
+
hasChanges: this.hasChanges(),
|
|
57
|
+
destructive: this.hasDestructiveChanges()
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { MigrationPlan };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const { getSafe } = require("../utils/security/safe");
|
|
2
|
+
|
|
3
|
+
class MigrationRunner {
|
|
4
|
+
constructor({ connection, dialect, store, backupManager }) {
|
|
5
|
+
this.connection = connection;
|
|
6
|
+
this.dialect = dialect;
|
|
7
|
+
this.store = store;
|
|
8
|
+
this.backupManager = backupManager;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async ensureMigrationTable() {
|
|
12
|
+
await this.dialect.execute(
|
|
13
|
+
this.connection,
|
|
14
|
+
this.dialect.createMigrationTable()
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async applied() {
|
|
19
|
+
await this.ensureMigrationTable();
|
|
20
|
+
return this.dialect.execute(
|
|
21
|
+
this.connection,
|
|
22
|
+
this.dialect.selectMigrations()
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async execute(plan, options = {}) {
|
|
27
|
+
if (!plan.hasChanges()) {
|
|
28
|
+
return { applied: false, reason: "no-changes", plan };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!plan.approved && options.requireApproval !== false) {
|
|
32
|
+
throw new Error("Migration must be approved with plan.approve() before execution.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (plan.hasDestructiveChanges() && !options.allowDestructiveChanges) {
|
|
36
|
+
throw new Error("Destructive migration requires allowDestructiveChanges=true.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await this.ensureMigrationTable();
|
|
40
|
+
|
|
41
|
+
const applied = await this.applied();
|
|
42
|
+
const existing = applied.find(row => row.id === plan.id);
|
|
43
|
+
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (existing.checksum !== plan.checksum) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Migration '${plan.id}' is already applied with a different checksum.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return { applied: false, reason: "already-applied", plan };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const affectedTables = [...new Set(
|
|
54
|
+
plan.operations
|
|
55
|
+
.filter(operation => operation.table)
|
|
56
|
+
.map(operation => operation.table)
|
|
57
|
+
)];
|
|
58
|
+
|
|
59
|
+
const backupPath = await this.backupManager.backupTables(
|
|
60
|
+
affectedTables,
|
|
61
|
+
plan.id
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const run = async () => {
|
|
65
|
+
for (const operation of plan.operations) {
|
|
66
|
+
const sql = this.sqlForOperation(operation);
|
|
67
|
+
await this.dialect.execute(this.connection, sql);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
await this.dialect.execute(
|
|
72
|
+
this.connection,
|
|
73
|
+
this.dialect.insertMigration(),
|
|
74
|
+
[plan.id, plan.checksum, now, now]
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await this.dialect.transaction(this.connection, run);
|
|
79
|
+
await this.store.writeMigration(plan);
|
|
80
|
+
await this.store.writeSnapshot(plan.snapshot);
|
|
81
|
+
await this.store.cleanup();
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
applied: true,
|
|
85
|
+
plan,
|
|
86
|
+
backupPath
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async rollback(id, options = {}) {
|
|
91
|
+
await this.ensureMigrationTable();
|
|
92
|
+
const applied = await this.applied();
|
|
93
|
+
const existing = applied.find(row => row.id === id);
|
|
94
|
+
if (!existing) throw new Error(`Migration '${id}' is not applied.`);
|
|
95
|
+
|
|
96
|
+
// Safety: only the most recently applied migration can be rolled back.
|
|
97
|
+
// This prevents restoring an old snapshot over newer schema/data changes.
|
|
98
|
+
if (!options.allowOutOfOrder) {
|
|
99
|
+
const last = applied[applied.length - 1];
|
|
100
|
+
if (!last || last.id !== id) {
|
|
101
|
+
throw new Error(`Migration '${id}' is not the latest applied migration. Roll back newer migrations first.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const metadata = await this.store.readMigration(id);
|
|
106
|
+
if (!metadata) throw new Error(`Rollback metadata for migration '${id}' was not found.`);
|
|
107
|
+
this.store.validateChecksum(metadata.checksum, existing.checksum);
|
|
108
|
+
|
|
109
|
+
const backup = await this.store.readBackup(id);
|
|
110
|
+
if (!backup) throw new Error(`Backup for migration '${id}' was not found. Rollback aborted.`);
|
|
111
|
+
|
|
112
|
+
const operations = this.buildRollbackOperations(metadata);
|
|
113
|
+
const run = async () => {
|
|
114
|
+
for (const operation of operations) {
|
|
115
|
+
await this.dialect.execute(this.connection, this.sqlForOperation(operation));
|
|
116
|
+
}
|
|
117
|
+
await this.restoreBackup(backup);
|
|
118
|
+
await this.dialect.execute(this.connection, this.dialect.deleteMigration(), [id]);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await this.dialect.transaction(this.connection, run);
|
|
122
|
+
await this.store.writeSnapshot(metadata.previousSnapshot || { tables: {} });
|
|
123
|
+
|
|
124
|
+
return { rolledBack: true, migrationId: id, operations, backupPath: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
buildRollbackOperations(metadata) {
|
|
128
|
+
const previousTables = metadata.previousSnapshot?.tables || {};
|
|
129
|
+
return [...metadata.operations].reverse().map(operation => {
|
|
130
|
+
switch (operation.type) {
|
|
131
|
+
case "createTable":
|
|
132
|
+
return { type: "dropTable", table: operation.table, destructive: true };
|
|
133
|
+
case "addColumn":
|
|
134
|
+
return { type: "dropColumn", table: operation.table, column: operation.column, destructive: true };
|
|
135
|
+
case "renameColumn":
|
|
136
|
+
return { type: "renameColumn", table: operation.table, oldName: operation.newName, newName: operation.oldName };
|
|
137
|
+
case "alterColumn": {
|
|
138
|
+
const previous = operation.previous || previousTables[operation.table]?.columns?.[operation.column];
|
|
139
|
+
if (!previous) throw new Error(`Cannot rollback alterColumn '${operation.table}.${operation.column}': previous definition is missing.`);
|
|
140
|
+
return { type: "alterColumn", table: operation.table, column: operation.column, field: previous };
|
|
141
|
+
}
|
|
142
|
+
case "dropColumn": {
|
|
143
|
+
const field = previousTables[operation.table]?.columns?.[operation.column];
|
|
144
|
+
if (!field) throw new Error(`Cannot rollback dropped column '${operation.table}.${operation.column}': previous schema is missing.`);
|
|
145
|
+
return { type: "addColumn", table: operation.table, column: operation.column, field };
|
|
146
|
+
}
|
|
147
|
+
case "dropTable": {
|
|
148
|
+
const schema = previousTables[operation.table]?.columns || previousTables[operation.table];
|
|
149
|
+
if (!schema) throw new Error(`Cannot rollback dropped table '${operation.table}': previous schema is missing.`);
|
|
150
|
+
return { type: "createTable", table: operation.table, schema };
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
throw new Error(`Unsupported rollback operation: ${operation.type}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async restoreBackup(backup) {
|
|
159
|
+
for (const [table, rows] of Object.entries(backup.tables || {})) {
|
|
160
|
+
if (!Array.isArray(rows)) throw new Error(`Invalid backup rows for table '${table}'.`);
|
|
161
|
+
await this.dialect.execute(this.connection, `DELETE FROM ${this.dialect.escape(table)};`);
|
|
162
|
+
if (rows.length === 0) continue;
|
|
163
|
+
|
|
164
|
+
const columns = Object.keys(rows[0]);
|
|
165
|
+
const escapedColumns = columns.map(column => this.dialect.escape(column)).join(", ");
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
const values = columns.map(column => getSafe(row, column));
|
|
168
|
+
const sql = `INSERT INTO ${this.dialect.escape(table)} (${escapedColumns}) VALUES (${this.dialect.placeholders(values.length)});`;
|
|
169
|
+
await this.dialect.execute(this.connection, sql, values);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sqlForOperation(operation) {
|
|
175
|
+
switch (operation.type) {
|
|
176
|
+
case "createTable":
|
|
177
|
+
return this.dialect.createTable(operation.table, operation.schema);
|
|
178
|
+
case "addColumn":
|
|
179
|
+
return this.dialect.addColumn(operation.table, operation.column, operation.field);
|
|
180
|
+
case "renameColumn":
|
|
181
|
+
return this.dialect.renameColumn(operation.table, operation.oldName, operation.newName);
|
|
182
|
+
case "alterColumn":
|
|
183
|
+
return this.dialect.alterColumn(operation.table, operation.column, operation.field);
|
|
184
|
+
case "dropColumn":
|
|
185
|
+
return this.dialect.dropColumn(operation.table, operation.column);
|
|
186
|
+
case "dropTable":
|
|
187
|
+
return this.dialect.dropTable(operation.table);
|
|
188
|
+
default:
|
|
189
|
+
throw new Error(`Unsupported migration operation: ${operation.type}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { MigrationRunner };
|