@mlagie/sql-connector 3.0.3 → 3.2.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.
@@ -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,162 @@
1
+ const { getConnexion } = require("../db/connexion");
2
+ const { getDialect: getConnectorDialect, createDialect } = 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 { migrationId, stableStringify, sha256 } = require("./utils");
11
+
12
+ function resolveDialect(name) {
13
+ if (name) return createDialect(name);
14
+
15
+ try {
16
+ const connectorDialect = getConnectorDialect();
17
+ if (connectorDialect?.name) return createDialect(connectorDialect.name);
18
+ } catch (error) {
19
+ console.warn("Failed to resolve connector dialect:", error);
20
+ }
21
+
22
+ return createDialect("mysql");
23
+ }
24
+
25
+ function resolveModels(models) {
26
+ if (Array.isArray(models)) return models;
27
+
28
+ try {
29
+ const { Model } = require("../models/Model");
30
+ return Model.pendingModels || [];
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ class Migration {
37
+ constructor(options = {}) {
38
+ this.connection = options.connection || getConnexion();
39
+ if (!this.connection) {
40
+ throw new Error("No database connection. Call connect() before using migrations.");
41
+ }
42
+
43
+ this.dialect = options.dialect && typeof options.dialect.execute === "function"
44
+ ? options.dialect
45
+ : resolveDialect(options.dialect);
46
+
47
+ this.models = resolveModels(options.models);
48
+ this.store = options.store || new MigrationStore({
49
+ directory: options.directory || "migrations",
50
+ backupDirectory: options.backupDirectory,
51
+ encryptionKey: options.encryptionKey,
52
+ retention: options.retention
53
+ });
54
+
55
+ this.inspector = new SchemaInspector(this.connection, this.dialect);
56
+ this.differ = new SchemaDiffer({
57
+ allowDestructiveChanges: options.allowDestructiveChanges,
58
+ allowDropTables: options.allowDropTables
59
+ });
60
+ this.backupManager = new BackupManager(
61
+ this.connection,
62
+ this.dialect,
63
+ this.store
64
+ );
65
+ this.runner = new MigrationRunner({
66
+ connection: this.connection,
67
+ dialect: this.dialect,
68
+ store: this.store,
69
+ backupManager: this.backupManager
70
+ });
71
+ }
72
+
73
+ async plan(options = {}) {
74
+ if (options.models) this.models = options.models;
75
+
76
+ const desired = modelsToSnapshot(this.models);
77
+ const current = await this.inspector.inspect();
78
+
79
+ const previous = await this.store.readSnapshot();
80
+ const baseline = previous || current;
81
+
82
+ const differ = new SchemaDiffer({
83
+ allowDestructiveChanges: options.allowDestructiveChanges ?? false,
84
+ allowDropTables: options.allowDropTables ?? false
85
+ });
86
+
87
+ // The DB is the safety check; the snapshot is used as history.
88
+ const operations = differ.diff(desired, current);
89
+
90
+ const sql = operations
91
+ .map(operation => this.runner.sqlForOperation(operation))
92
+ .join("\n");
93
+
94
+ const id = options.id || migrationId(options.name || "schema");
95
+ const checksum = sha256(stableStringify({
96
+ id,
97
+ dialect: this.dialect.name,
98
+ operations,
99
+ sql
100
+ }));
101
+
102
+ return new MigrationPlan({
103
+ id,
104
+ operations,
105
+ sql,
106
+ snapshot: desired,
107
+ previousSnapshot: baseline,
108
+ dialect: this.dialect,
109
+ checksum,
110
+ executor: (migrationPlan, executeOptions = {}) => this.runner.execute(
111
+ migrationPlan,
112
+ executeOptions
113
+ )
114
+ });
115
+ }
116
+
117
+ async migrate(options = {}) {
118
+ const plan = await this.plan(options);
119
+
120
+ if (!plan.hasChanges()) {
121
+ return { applied: false, plan };
122
+ }
123
+
124
+ if (options.approve !== false) plan.approve();
125
+
126
+ return this.runner.execute(plan, {
127
+ requireApproval: options.requireApproval ?? true,
128
+ allowDestructiveChanges: options.allowDestructiveChanges ?? false
129
+ });
130
+ }
131
+
132
+ async status() {
133
+ return this.runner.applied();
134
+ }
135
+
136
+ async rollback(id, options = {}) {
137
+ if (!id) throw new Error("A migration id is required for rollback.");
138
+ return this.runner.rollback(id, options);
139
+ }
140
+
141
+ async cleanup() {
142
+ return this.store.cleanup();
143
+ }
144
+
145
+ static async plan(options = {}) {
146
+ return new Migration(options).plan(options);
147
+ }
148
+
149
+ static async migrate(options = {}) {
150
+ return new Migration(options).migrate(options);
151
+ }
152
+
153
+ static async status(options = {}) {
154
+ return new Migration(options).status();
155
+ }
156
+
157
+ static async rollback(id, options = {}) {
158
+ return new Migration(options).rollback(id, options);
159
+ }
160
+ }
161
+
162
+ 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 };
@@ -0,0 +1,137 @@
1
+ const fs = require("fs/promises");
2
+ const path = require("path");
3
+ const { encrypt, decrypt } = require("./crypto");
4
+ const { parseDuration } = require("./utils");
5
+ const { MigrationChecksumError } = require("./errors");
6
+
7
+ class MigrationStore {
8
+ constructor(options = {}) {
9
+ this.directory = path.resolve(options.directory || "migrations");
10
+ this.backupDirectory = path.resolve(options.backupDirectory || path.join(this.directory, "backups"));
11
+ this.encryptionKey = options.encryptionKey || process.env.SQL_CONNECTOR_MIGRATION_KEY;
12
+ this.retention = parseDuration(options.retention ?? null);
13
+ }
14
+
15
+ async ensureDirectories() {
16
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
17
+ await fs.mkdir(this.backupDirectory, { recursive: true, mode: 0o700 });
18
+ }
19
+
20
+ async writeMigration(plan) {
21
+ await this.ensureDirectories();
22
+ if (!this.encryptionKey) {
23
+ throw new Error("An encryption key is required to write migration files.");
24
+ }
25
+ const outputPath = path.join(this.directory, `${plan.id}.sql.enc`);
26
+ const payload = encrypt(plan.sql, this.encryptionKey);
27
+ await fs.writeFile(outputPath, payload, { encoding: "utf8", mode: 0o600 });
28
+
29
+ const metadataPath = path.join(this.directory, `${plan.id}.meta.json.enc`);
30
+ const metadata = {
31
+ version: 1,
32
+ id: plan.id,
33
+ dialect: plan.dialect,
34
+ checksum: plan.checksum,
35
+ operations: plan.operations,
36
+ previousSnapshot: plan.previousSnapshot,
37
+ snapshot: plan.snapshot
38
+ };
39
+ await fs.writeFile(metadataPath, encrypt(JSON.stringify(metadata, null, 2), this.encryptionKey), {
40
+ encoding: "utf8",
41
+ mode: 0o600
42
+ });
43
+ return outputPath;
44
+ }
45
+
46
+ async readMigration(id) {
47
+ await this.ensureDirectories();
48
+ if (!this.encryptionKey) throw new Error("An encryption key is required to read migration files.");
49
+ const file = path.join(this.directory, `${id}.meta.json.enc`);
50
+ try {
51
+ return JSON.parse(decrypt(await fs.readFile(file, "utf8"), this.encryptionKey));
52
+ } catch (error) {
53
+ if (error.code === "ENOENT") return null;
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ async readBackup(id) {
59
+ await this.ensureDirectories();
60
+ if (!this.encryptionKey) throw new Error("An encryption key is required to read backups.");
61
+ const file = path.join(this.backupDirectory, `${id}.json.enc`);
62
+ try {
63
+ return JSON.parse(decrypt(await fs.readFile(file, "utf8"), this.encryptionKey));
64
+ } catch (error) {
65
+ if (error.code === "ENOENT") return null;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ async writeBackup(name, content) {
71
+ await this.ensureDirectories();
72
+ if (!this.retention) return null;
73
+ if (!this.encryptionKey) {
74
+ throw new Error("An encryption key is required to write backups.");
75
+ }
76
+ const outputPath = path.join(this.backupDirectory, `${name}.json.enc`);
77
+ await fs.writeFile(outputPath, encrypt(JSON.stringify(content, null, 2), this.encryptionKey), {
78
+ encoding: "utf8",
79
+ mode: 0o600
80
+ });
81
+ return outputPath;
82
+ }
83
+
84
+ async cleanup(now = Date.now()) {
85
+ if (!this.retention) return 0;
86
+ await this.ensureDirectories();
87
+
88
+ const files = await fs.readdir(this.backupDirectory);
89
+ let removed = 0;
90
+ for (const file of files) {
91
+ const fullPath = path.join(this.backupDirectory, file);
92
+ const stat = await fs.stat(fullPath);
93
+ if (now - stat.mtimeMs >= this.retention) {
94
+ await fs.rm(fullPath, { force: true });
95
+ removed++;
96
+ }
97
+ }
98
+ return removed;
99
+ }
100
+
101
+ async writeSnapshot(snapshot) {
102
+ await this.ensureDirectories();
103
+ const file = path.join(this.directory, "schema.snapshot.json");
104
+ await fs.writeFile(file, JSON.stringify(snapshot, null, 2) + "\n", {
105
+ encoding: "utf8",
106
+ mode: 0o600
107
+ });
108
+ return file;
109
+ }
110
+
111
+ async readSnapshot() {
112
+ try {
113
+ const file = path.join(this.directory, "schema.snapshot.json");
114
+ return JSON.parse(await fs.readFile(file, "utf8"));
115
+ } catch (error) {
116
+ if (error.code === "ENOENT") return null;
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ async listMigrationFiles() {
122
+ await this.ensureDirectories();
123
+ return (await fs.readdir(this.directory))
124
+ .filter(file => file.endsWith(".sql.enc"))
125
+ .sort();
126
+ }
127
+
128
+ validateChecksum(actual, expected) {
129
+ if (actual !== expected) {
130
+ throw new MigrationChecksumError(
131
+ `Migration checksum mismatch. Expected ${expected}, got ${actual}.`
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ module.exports = { MigrationStore };