@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/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
- let client = {};
8
-
9
- module.exports = { client, connect, logout, Schema, Model, ModelInstance, sqlTypeMap }
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",
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.23.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.8.1",
74
+ "eslint": "^10.10.0",
75
75
  "eslint-plugin-security": "^4.0.1",
76
- "jest": "30.4.2"
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
  }
@@ -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 };