@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.
@@ -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 };
@@ -0,0 +1,261 @@
1
+ const { getSafe } = require("../utils/security/safe");
2
+ const { DestructiveMigrationError } = require("./errors");
3
+
4
+ function normalizeType(type) {
5
+ const value = String(type ?? "").toLowerCase();
6
+
7
+ const aliases = {
8
+ string: "varchar",
9
+ varchar: "varchar",
10
+ character_varying: "varchar",
11
+ "character varying": "varchar",
12
+
13
+ integer: "integer",
14
+ int: "integer",
15
+ int4: "integer",
16
+
17
+ bigint: "bigint",
18
+ int8: "bigint",
19
+
20
+ boolean: "boolean",
21
+ bool: "boolean",
22
+
23
+ float: "float",
24
+ double: "float",
25
+ double_precision: "float",
26
+
27
+ date: "date",
28
+ datetime: "datetime",
29
+ timestamp: "timestamp",
30
+
31
+ text: "text"
32
+ };
33
+
34
+ return getSafe(aliases, value) || value;
35
+ }
36
+
37
+ function normalizeField(field) {
38
+ return {
39
+ type: normalizeType(field.type),
40
+ length: field.length ?? null,
41
+ required: Boolean(field.required),
42
+ default: field.default ?? null,
43
+ unique: Boolean(field.unique),
44
+ auto_increment: Boolean(field.auto_increment),
45
+ primary_key: Boolean(field.primary_key)
46
+ };
47
+ }
48
+
49
+ function equivalentField(a, b) {
50
+ return JSON.stringify(normalizeField(a)) === JSON.stringify(normalizeField(b));
51
+ }
52
+
53
+ class SchemaDiffer {
54
+ constructor(options = {}) {
55
+ this.allowDestructiveChanges = Boolean(
56
+ options.allowDestructiveChanges
57
+ );
58
+
59
+ this.allowDropTables = Boolean(
60
+ options.allowDropTables
61
+ );
62
+ }
63
+
64
+ diff(desired, current) {
65
+ const operations = [];
66
+
67
+ const desiredTables = desired.tables || {};
68
+ const currentTables = current.tables || {};
69
+
70
+ for (const [tableName, desiredTable] of Object.entries(desiredTables)) {
71
+ const desiredColumns = desiredTable.columns || {};
72
+
73
+ // Table does not exist yet.
74
+ if (!getSafe(currentTables, tableName)) {
75
+ operations.push({
76
+ type: "createTable",
77
+ table: tableName,
78
+ schema: desiredColumns
79
+ });
80
+
81
+ continue;
82
+ }
83
+
84
+ const currentTable = getSafe(currentTables, tableName);
85
+ const currentColumns = getSafe(currentTable, "columns") || {};
86
+
87
+ /*
88
+ * First pass:
89
+ * detect explicit renames using `oldname`.
90
+ *
91
+ * We deliberately do not guess renames.
92
+ */
93
+ const renameOperations = [];
94
+ const renamedFrom = new Set();
95
+ const renamedTo = new Set();
96
+
97
+ for (const [newName, field] of Object.entries(desiredColumns)) {
98
+ const oldName = field?.oldname;
99
+
100
+ if (!oldName) {
101
+ continue;
102
+ }
103
+
104
+ if (oldName === newName) {
105
+ throw new Error(
106
+ `Column '${newName}' cannot have oldname equal to itself.`
107
+ );
108
+ }
109
+
110
+ if (!getSafe(currentColumns, oldName)) {
111
+ throw new Error(
112
+ `Column '${oldName}' specified by oldname for '${tableName}.${newName}' does not exist.`
113
+ );
114
+ }
115
+
116
+ if (getSafe(currentColumns, newName)) {
117
+ throw new Error(
118
+ `Cannot rename '${tableName}.${oldName}' to '${newName}' because '${newName}' already exists.`
119
+ );
120
+ }
121
+
122
+ if (renamedFrom.has(oldName)) {
123
+ throw new Error(
124
+ `Column '${tableName}.${oldName}' is used by multiple oldname declarations.`
125
+ );
126
+ }
127
+ const operation = {
128
+ type: "renameColumn",
129
+ table: tableName,
130
+ oldName,
131
+ newName
132
+ };
133
+
134
+ renameOperations.push(operation);
135
+ renamedFrom.add(oldName);
136
+ renamedTo.add(newName);
137
+ }
138
+
139
+ /*
140
+ * Add rename operations first.
141
+ */
142
+ operations.push(...renameOperations);
143
+
144
+ /*
145
+ * Build a virtual representation of the current schema
146
+ * after applying the renames.
147
+ *
148
+ * This is important because:
149
+ *
150
+ * email -> email_address
151
+ *
152
+ * followed by:
153
+ *
154
+ * email_address VARCHAR(255) -> VARCHAR(512)
155
+ *
156
+ * must generate:
157
+ *
158
+ * RENAME COLUMN
159
+ * ALTER COLUMN
160
+ */
161
+ const virtualCurrentColumns = {
162
+ ...currentColumns
163
+ };
164
+
165
+ for (const operation of renameOperations) {
166
+ virtualCurrentColumns[operation.newName] =
167
+ virtualCurrentColumns[operation.oldName];
168
+
169
+ delete virtualCurrentColumns[operation.oldName];
170
+ }
171
+
172
+ /*
173
+ * Second pass:
174
+ * detect additions and modifications.
175
+ */
176
+ for (const [columnName, field] of Object.entries(desiredColumns)) {
177
+ const currentField = getSafe(virtualCurrentColumns, columnName);
178
+
179
+ if (!currentField) {
180
+ operations.push({
181
+ type: "addColumn",
182
+ table: tableName,
183
+ column: columnName,
184
+ field
185
+ });
186
+
187
+ continue;
188
+ }
189
+
190
+ if (!equivalentField(field, currentField)) {
191
+ operations.push({
192
+ type: "alterColumn",
193
+ table: tableName,
194
+ column: columnName,
195
+ field,
196
+ previous: currentField
197
+ });
198
+ }
199
+ }
200
+
201
+ /*
202
+ * Third pass:
203
+ * detect removed columns.
204
+ *
205
+ * Renamed columns are excluded because their old name
206
+ * intentionally disappears from the desired schema.
207
+ */
208
+ for (const currentName of Object.keys(currentColumns)) {
209
+ if (renamedFrom.has(currentName)) {
210
+ continue;
211
+ }
212
+
213
+ if (!getSafe(desiredColumns, currentName)) {
214
+ const operation = {
215
+ type: "dropColumn",
216
+ table: tableName,
217
+ column: currentName,
218
+ destructive: true
219
+ };
220
+
221
+ if (!this.allowDestructiveChanges) {
222
+ throw new DestructiveMigrationError(
223
+ `Dropping column '${tableName}.${currentName}' requires allowDestructiveChanges=true.`,
224
+ operation
225
+ );
226
+ }
227
+
228
+ operations.push(operation);
229
+ }
230
+ }
231
+ }
232
+
233
+ /*
234
+ * Detect removed tables.
235
+ */
236
+ for (const tableName of Object.keys(currentTables)) {
237
+ if (!getSafe(desiredTables, tableName)) {
238
+ const operation = {
239
+ type: "dropTable",
240
+ table: tableName,
241
+ destructive: true
242
+ };
243
+
244
+ if (!this.allowDropTables) {
245
+ throw new DestructiveMigrationError(
246
+ `Dropping table '${tableName}' requires allowDropTables=true.`,
247
+ operation
248
+ );
249
+ }
250
+
251
+ operations.push(operation);
252
+ }
253
+ }
254
+
255
+ return operations;
256
+ }
257
+ }
258
+
259
+ module.exports = {
260
+ SchemaDiffer
261
+ };
@@ -0,0 +1,44 @@
1
+ const { setSafe } = require("../utils/security/safe");
2
+
3
+ class SchemaInspector {
4
+ constructor(connection, dialect) {
5
+ this.connection = connection;
6
+ this.dialect = dialect;
7
+ }
8
+
9
+ async query(sql, values = []) {
10
+ return this.dialect.execute(this.connection, sql, values);
11
+ }
12
+
13
+ async inspect() {
14
+ const queries = this.dialect.introspectionQueries();
15
+ const tableRows = await this.query(queries.tables);
16
+
17
+ const tables = {};
18
+
19
+ for (const row of tableRows || []) {
20
+ const tableName = row.table_name ?? row.TABLE_NAME;
21
+ if (!tableName || tableName === "sql_connector_migrations") continue;
22
+
23
+ const columnRows = await this.query(
24
+ queries.columns,
25
+ this.dialect.name === "postgres" ? [tableName] : [tableName]
26
+ );
27
+ setSafe(tables, tableName, {
28
+ columns: Object.fromEntries(
29
+ (columnRows || []).map(row => {
30
+ const name = row.column_name ?? row.COLUMN_NAME;
31
+ return [name, this.dialect.normalizeDatabaseColumn(row)];
32
+ })
33
+ )
34
+ });
35
+ }
36
+
37
+ return {
38
+ version: 1,
39
+ tables
40
+ };
41
+ }
42
+ }
43
+
44
+ module.exports = { SchemaInspector };
@@ -0,0 +1,53 @@
1
+ const { clone, normalizeType, normalizeDefault, stableStringify, sha256 } = require("./utils");
2
+
3
+ function fieldToSnapshot(field) {
4
+ return {
5
+ type: normalizeType(field),
6
+ length: field.length ?? null,
7
+ required: Boolean(field.required),
8
+ default: normalizeDefault(field.default),
9
+ unique: Boolean(field.unique),
10
+ auto_increment: Boolean(field.auto_increment),
11
+ foreignKey: field.foreignKey ?? null,
12
+ enum: Array.isArray(field.enum) ? [...field.enum] : null,
13
+ primary_key: Boolean(field.primary_key),
14
+ customize: field.customize ?? null,
15
+ oldname: field.oldname ?? null
16
+ };
17
+ }
18
+
19
+ function modelsToSnapshot(models) {
20
+ const tables = {};
21
+
22
+ for (const model of models) {
23
+ if (!model || typeof model.name !== "string" || !model.schema?.schemaDict) {
24
+ throw new Error("Migration received an invalid Model.");
25
+ }
26
+
27
+ tables[model.name] = {
28
+ columns: Object.fromEntries(
29
+ Object.entries(model.schema.schemaDict).map(([name, field]) => [name, fieldToSnapshot(field)])
30
+ )
31
+ };
32
+ }
33
+
34
+ return {
35
+ version: 1,
36
+ tables
37
+ };
38
+ }
39
+
40
+ function snapshotChecksum(snapshot) {
41
+ return sha256(stableStringify(snapshot));
42
+ }
43
+
44
+ function cloneSnapshot(snapshot) {
45
+ return clone(snapshot);
46
+ }
47
+
48
+ module.exports = {
49
+ fieldToSnapshot,
50
+ modelsToSnapshot,
51
+ snapshotChecksum,
52
+ cloneSnapshot
53
+ };
@@ -0,0 +1,79 @@
1
+ const crypto = require("crypto");
2
+ const { Buffer } = require("node:buffer");
3
+ const fs = require("fs/promises");
4
+
5
+ const FORMAT = "sql-connector-migration-v1";
6
+ const ALGORITHM = "aes-256-gcm";
7
+ const KEY_LENGTH = 32;
8
+ const IV_LENGTH = 12;
9
+ const SALT_LENGTH = 16;
10
+ const TAG_LENGTH = 16;
11
+
12
+ function deriveKey(secret, salt) {
13
+ if (!secret || typeof secret !== "string") {
14
+ throw new Error(
15
+ "Migration encryption key is missing. Provide encryption.key or SQL_CONNECTOR_MIGRATION_KEY."
16
+ );
17
+ }
18
+
19
+ return crypto.scryptSync(secret, salt, KEY_LENGTH);
20
+ }
21
+
22
+ function encrypt(text, secret) {
23
+ const salt = crypto.randomBytes(SALT_LENGTH);
24
+ const iv = crypto.randomBytes(IV_LENGTH);
25
+ const key = deriveKey(secret, salt);
26
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
27
+ const ciphertext = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]);
28
+ const tag = cipher.getAuthTag();
29
+
30
+ return [
31
+ FORMAT,
32
+ `salt=${salt.toString("base64url")}`,
33
+ `iv=${iv.toString("base64url")}`,
34
+ `tag=${tag.toString("base64url")}`,
35
+ `data=${ciphertext.toString("base64url")}`
36
+ ].join("\n") + "\n";
37
+ }
38
+
39
+ function decrypt(payload, secret) {
40
+ const lines = String(payload).trim().split("\n");
41
+ if (lines[0] !== FORMAT) throw new Error("Unsupported encrypted migration format.");
42
+
43
+ const values = Object.fromEntries(lines.slice(1).map(line => {
44
+ const index = line.indexOf("=");
45
+ return [line.slice(0, index), line.slice(index + 1)];
46
+ }));
47
+
48
+ const salt = Buffer.from(values.salt, "base64url");
49
+ const iv = Buffer.from(values.iv, "base64url");
50
+ const tag = Buffer.from(values.tag, "base64url");
51
+ const ciphertext = Buffer.from(values.data, "base64url");
52
+
53
+ if (salt.length !== SALT_LENGTH || iv.length !== IV_LENGTH || tag.length !== TAG_LENGTH) {
54
+ throw new Error("Invalid encrypted migration payload.");
55
+ }
56
+
57
+ const key = deriveKey(secret, salt);
58
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
59
+ decipher.setAuthTag(tag);
60
+
61
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
62
+ }
63
+
64
+ async function encryptFile(inputPath, outputPath, secret) {
65
+ const content = await fs.readFile(inputPath, "utf8");
66
+ await fs.writeFile(outputPath, encrypt(content, secret), { mode: 0o600 });
67
+ }
68
+
69
+ async function decryptFile(inputPath, secret) {
70
+ return decrypt(await fs.readFile(inputPath, "utf8"), secret);
71
+ }
72
+
73
+ module.exports = {
74
+ FORMAT,
75
+ encrypt,
76
+ decrypt,
77
+ encryptFile,
78
+ decryptFile
79
+ };
@@ -0,0 +1,112 @@
1
+ const { quoteIdentifier } = require("../utils");
2
+
3
+ class Dialect {
4
+ constructor(name, options = {}) {
5
+ this.name = name;
6
+ this.quote = options.quote;
7
+ this.capabilities = {
8
+ transactionalDDL: false,
9
+ renameColumn: true,
10
+ alterColumn: true,
11
+ ...options.capabilities
12
+ };
13
+ }
14
+
15
+ escape(name) {
16
+ return quoteIdentifier(name, this.quote);
17
+ }
18
+
19
+ placeholder() {
20
+ return "?";
21
+ }
22
+
23
+ placeholders(count) {
24
+ return Array.from({ length: count }, (_, i) => this.placeholder(i)).join(", ");
25
+ }
26
+
27
+ execute() {
28
+ throw new Error(`${this.name} dialect must implement execute().`);
29
+ }
30
+
31
+ async begin() {}
32
+ async commit() {}
33
+ async rollback() {}
34
+
35
+ mapType() {
36
+ throw new Error(`${this.name} dialect must implement mapType().`);
37
+ }
38
+
39
+ columnDefinition() {
40
+ throw new Error(`${this.name} dialect must implement columnDefinition().`);
41
+ }
42
+
43
+ createTable() {
44
+ throw new Error(`${this.name} dialect must implement createTable().`);
45
+ }
46
+
47
+ addColumn(table, name, field) {
48
+ return `ALTER TABLE ${this.escape(table)} ADD COLUMN ${this.columnDefinition(name, field)};`;
49
+ }
50
+
51
+ renameColumn(table, oldName, newName) {
52
+ return `ALTER TABLE ${this.escape(table)} RENAME COLUMN ${this.escape(oldName)} TO ${this.escape(newName)};`;
53
+ }
54
+
55
+ alterColumn() {
56
+ throw new Error(`${this.name} does not implement alterColumn().`);
57
+ }
58
+
59
+ dropColumn(table, name) {
60
+ return `ALTER TABLE ${this.escape(table)} DROP COLUMN ${this.escape(name)};`;
61
+ }
62
+
63
+ dropTable(table) {
64
+ return `DROP TABLE ${this.escape(table)};`;
65
+ }
66
+
67
+ introspectionQueries() {
68
+ throw new Error(`${this.name} dialect must implement introspectionQueries().`);
69
+ }
70
+
71
+ normalizeDatabaseColumn(row) {
72
+ return row;
73
+ }
74
+
75
+ normalizeDatabaseTable(row) {
76
+ return row;
77
+ }
78
+
79
+ createMigrationTable() {
80
+ throw new Error(`${this.name} dialect must implement createMigrationTable().`);
81
+ }
82
+
83
+ insertMigration() {
84
+ throw new Error(`${this.name} dialect must implement insertMigration().`);
85
+ }
86
+
87
+ selectMigrations() {
88
+ throw new Error(`${this.name} dialect must implement selectMigrations().`);
89
+ }
90
+
91
+ deleteMigration() {
92
+ throw new Error(`${this.name} dialect must implement deleteMigration().`);
93
+ }
94
+
95
+ async transaction(connection, callback) {
96
+ if (!this.capabilities.transactionalDDL) return callback();
97
+
98
+ await this.begin(connection);
99
+ try {
100
+ const result = await callback();
101
+ await this.commit(connection);
102
+ return result;
103
+ } catch (error) {
104
+ try {
105
+ await this.rollback(connection);
106
+ } catch {}
107
+ throw error;
108
+ }
109
+ }
110
+ }
111
+
112
+ module.exports = { Dialect };