updebe 0.1.2 → 0.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.
- package/dist/commands/upgradeHead.js +11 -2
- package/dist/db/connection.js +24 -0
- package/dist/db/migrationState.js +48 -0
- package/dist/migrations/files.js +66 -21
- package/dist/migrations/runner.js +124 -0
- package/package.json +6 -5
|
@@ -4,9 +4,18 @@ exports.runUpgradeHead = runUpgradeHead;
|
|
|
4
4
|
const database_1 = require("../config/database");
|
|
5
5
|
const connection_1 = require("../db/connection");
|
|
6
6
|
const files_1 = require("../migrations/files");
|
|
7
|
+
const runner_1 = require("../migrations/runner");
|
|
7
8
|
async function runUpgradeHead() {
|
|
8
|
-
(0, files_1.loadMigrationDefinitions)();
|
|
9
|
+
const migrations = (0, files_1.loadMigrationDefinitions)();
|
|
9
10
|
const dbConfig = (0, database_1.resolveDatabaseConfig)();
|
|
10
|
-
await (0, connection_1.
|
|
11
|
+
await (0, connection_1.withDatabaseClient)(dbConfig, async (client) => {
|
|
12
|
+
try {
|
|
13
|
+
await (0, runner_1.runUpgradeToHead)(client, dbConfig, migrations);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.error((0, runner_1.formatMigrationError)(error));
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
11
20
|
console.log("Hey! I'm working");
|
|
12
21
|
}
|
package/dist/db/connection.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.testDatabaseConnection = testDatabaseConnection;
|
|
4
|
+
exports.withDatabaseClient = withDatabaseClient;
|
|
4
5
|
const pg_1 = require("pg");
|
|
5
6
|
const ANSI_GREEN = "\x1b[32m";
|
|
6
7
|
const ANSI_RED = "\x1b[31m";
|
|
@@ -28,3 +29,26 @@ async function testDatabaseConnection(config) {
|
|
|
28
29
|
await client.end().catch(() => undefined);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
async function withDatabaseClient(config, action) {
|
|
33
|
+
const client = new pg_1.Client({ connectionString: config.databaseUrl });
|
|
34
|
+
let connected = false;
|
|
35
|
+
try {
|
|
36
|
+
await client.connect();
|
|
37
|
+
connected = true;
|
|
38
|
+
console.log(`${ANSI_GREEN}Database connected successfully (schema: ${config.schema})${ANSI_RESET}`);
|
|
39
|
+
return await action(client);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (connected) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
const message = formatSafeError(error);
|
|
46
|
+
console.error(`${ANSI_RED}Database connection error: ${message}${ANSI_RESET}`);
|
|
47
|
+
throw new Error("Database connection failed");
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
if (connected) {
|
|
51
|
+
await client.end().catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ensureMigrationState = ensureMigrationState;
|
|
4
|
+
exports.getCurrentRevisionId = getCurrentRevisionId;
|
|
5
|
+
exports.setCurrentRevisionId = setCurrentRevisionId;
|
|
6
|
+
const ANSI_GREEN = "\x1b[32m";
|
|
7
|
+
const ANSI_RESET = "\x1b[0m";
|
|
8
|
+
const STATE_KEY = "current";
|
|
9
|
+
const TABLE_NAME = "updebe_migrations";
|
|
10
|
+
function quoteIdentifier(identifier) {
|
|
11
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
12
|
+
throw new Error(`Invalid schema name: ${identifier}`);
|
|
13
|
+
}
|
|
14
|
+
return `"${identifier}"`;
|
|
15
|
+
}
|
|
16
|
+
function getStateTableReference(schema) {
|
|
17
|
+
return `${quoteIdentifier(schema)}."${TABLE_NAME}"`;
|
|
18
|
+
}
|
|
19
|
+
async function ensureMigrationState(client, schema) {
|
|
20
|
+
const quotedSchema = quoteIdentifier(schema);
|
|
21
|
+
const tableRef = getStateTableReference(schema);
|
|
22
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema}`);
|
|
23
|
+
await client.query(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS ${tableRef} (
|
|
25
|
+
singleton_key TEXT PRIMARY KEY,
|
|
26
|
+
revision_id TEXT NOT NULL,
|
|
27
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
console.log(`${ANSI_GREEN}Migration state ready at ${schema}.${TABLE_NAME}${ANSI_RESET}`);
|
|
31
|
+
}
|
|
32
|
+
async function getCurrentRevisionId(client, schema) {
|
|
33
|
+
const tableRef = getStateTableReference(schema);
|
|
34
|
+
const result = await client.query(`SELECT revision_id FROM ${tableRef} WHERE singleton_key = $1 LIMIT 1`, [STATE_KEY]);
|
|
35
|
+
if (result.rowCount === 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return result.rows[0].revision_id;
|
|
39
|
+
}
|
|
40
|
+
async function setCurrentRevisionId(client, schema, revisionId) {
|
|
41
|
+
const tableRef = getStateTableReference(schema);
|
|
42
|
+
await client.query(`
|
|
43
|
+
INSERT INTO ${tableRef} (singleton_key, revision_id, updated_at)
|
|
44
|
+
VALUES ($1, $2, NOW())
|
|
45
|
+
ON CONFLICT (singleton_key)
|
|
46
|
+
DO UPDATE SET revision_id = EXCLUDED.revision_id, updated_at = NOW()
|
|
47
|
+
`, [STATE_KEY, revisionId]);
|
|
48
|
+
}
|
package/dist/migrations/files.js
CHANGED
|
@@ -4,47 +4,92 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.loadMigrationDefinitions = loadMigrationDefinitions;
|
|
7
|
+
exports.loadMigrationModule = loadMigrationModule;
|
|
7
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_module_1 = require("node:module");
|
|
8
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!
|
|
14
|
-
throw new Error(`${filePath}: migration must be an object`);
|
|
15
|
-
}
|
|
16
|
-
const revision = migration.revision;
|
|
17
|
-
if (!revision || typeof revision !== "object") {
|
|
11
|
+
const node_url_1 = require("node:url");
|
|
12
|
+
let tsNodeRegistered = false;
|
|
13
|
+
function extractRevision(content, filePath) {
|
|
14
|
+
const revisionBlockMatch = content.match(/export\s+const\s+revision\s*=\s*\{([\s\S]*?)\}/m);
|
|
15
|
+
if (!revisionBlockMatch) {
|
|
18
16
|
throw new Error(`${filePath}: missing required key revision`);
|
|
19
17
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
18
|
+
const revisionBlock = revisionBlockMatch[1];
|
|
19
|
+
const idMatch = revisionBlock.match(/\b_id\s*:\s*(["'`])(.*?)\1/m);
|
|
20
|
+
const revisedIdMatch = revisionBlock.match(/\brevised_id\s*:\s*(["'`])(.*?)\1/m);
|
|
21
|
+
if (!idMatch) {
|
|
22
22
|
throw new Error(`${filePath}: missing required key revision._id`);
|
|
23
23
|
}
|
|
24
|
-
if (!
|
|
24
|
+
if (!revisedIdMatch) {
|
|
25
25
|
throw new Error(`${filePath}: missing required key revision.revised_id`);
|
|
26
26
|
}
|
|
27
|
+
return {
|
|
28
|
+
_id: idMatch[2],
|
|
29
|
+
revised_id: revisedIdMatch[2]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function validateMigrationFunctions(content, filePath) {
|
|
33
|
+
if (!/export\s+(?:async\s+)?function\s+upgrade\s*\(/m.test(content)) {
|
|
34
|
+
throw new Error(`${filePath}: missing required function upgrade`);
|
|
35
|
+
}
|
|
36
|
+
if (!/export\s+(?:async\s+)?function\s+downgrade\s*\(/m.test(content)) {
|
|
37
|
+
throw new Error(`${filePath}: missing required function downgrade`);
|
|
38
|
+
}
|
|
27
39
|
}
|
|
28
40
|
function loadMigrationDefinitions(baseDir = process.cwd()) {
|
|
29
|
-
const migrationsDir = node_path_1.default.join(baseDir, "
|
|
41
|
+
const migrationsDir = node_path_1.default.join(baseDir, "updebe", "Migrations");
|
|
30
42
|
if (!node_fs_1.default.existsSync(migrationsDir)) {
|
|
31
43
|
return [];
|
|
32
44
|
}
|
|
33
45
|
const fileNames = node_fs_1.default.readdirSync(migrationsDir)
|
|
34
|
-
.filter((fileName) => fileName.endsWith(".
|
|
46
|
+
.filter((fileName) => fileName.endsWith(".ts"))
|
|
35
47
|
.sort();
|
|
36
48
|
return fileNames.map((fileName) => {
|
|
37
|
-
const relativePath = `
|
|
49
|
+
const relativePath = `updebe/Migrations/${fileName}`;
|
|
38
50
|
const fullPath = node_path_1.default.join(migrationsDir, fileName);
|
|
39
|
-
let
|
|
51
|
+
let content;
|
|
40
52
|
try {
|
|
41
|
-
|
|
42
|
-
parsedMigration = JSON.parse(content);
|
|
53
|
+
content = node_fs_1.default.readFileSync(fullPath, "utf8");
|
|
43
54
|
}
|
|
44
55
|
catch {
|
|
45
|
-
throw new Error(`${relativePath}:
|
|
56
|
+
throw new Error(`${relativePath}: unable to read file`);
|
|
46
57
|
}
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
const revision = extractRevision(content, relativePath);
|
|
59
|
+
validateMigrationFunctions(content, relativePath);
|
|
60
|
+
return {
|
|
61
|
+
fullPath,
|
|
62
|
+
filePath: relativePath,
|
|
63
|
+
revision
|
|
64
|
+
};
|
|
49
65
|
});
|
|
50
66
|
}
|
|
67
|
+
function ensureTsNodeRuntime() {
|
|
68
|
+
if (tsNodeRegistered) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const runtimeRequire = (0, node_module_1.createRequire)((0, node_url_1.pathToFileURL)(__filename).href);
|
|
72
|
+
runtimeRequire("ts-node/register/transpile-only");
|
|
73
|
+
tsNodeRegistered = true;
|
|
74
|
+
}
|
|
75
|
+
function loadCompiledModule(fullPath) {
|
|
76
|
+
ensureTsNodeRuntime();
|
|
77
|
+
const localRequire = (0, node_module_1.createRequire)((0, node_url_1.pathToFileURL)(fullPath).href);
|
|
78
|
+
const resolvedPath = localRequire.resolve(fullPath);
|
|
79
|
+
delete localRequire.cache[resolvedPath];
|
|
80
|
+
return localRequire(resolvedPath);
|
|
81
|
+
}
|
|
82
|
+
function loadMigrationModule(definition) {
|
|
83
|
+
const rawModule = loadCompiledModule(definition.fullPath);
|
|
84
|
+
if (typeof rawModule.upgrade !== "function") {
|
|
85
|
+
throw new Error(`${definition.filePath}: export upgrade must be a function`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof rawModule.downgrade !== "function") {
|
|
88
|
+
throw new Error(`${definition.filePath}: export downgrade must be a function`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...definition,
|
|
92
|
+
upgrade: rawModule.upgrade,
|
|
93
|
+
downgrade: rawModule.downgrade
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runUpgradeToHead = runUpgradeToHead;
|
|
7
|
+
exports.formatMigrationError = formatMigrationError;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const migrationState_1 = require("../db/migrationState");
|
|
11
|
+
const files_1 = require("./files");
|
|
12
|
+
const ANSI_GREEN = "\x1b[32m";
|
|
13
|
+
const ANSI_YELLOW = "\x1b[33m";
|
|
14
|
+
const ANSI_RED = "\x1b[31m";
|
|
15
|
+
const ANSI_RESET = "\x1b[0m";
|
|
16
|
+
const INIT_DB_FILE = node_path_1.default.join("updebe", "_init_db.sql");
|
|
17
|
+
function buildPendingMigrations(migrations, currentRevisionId) {
|
|
18
|
+
const byPreviousRevision = new Map();
|
|
19
|
+
for (const migration of migrations) {
|
|
20
|
+
const key = migration.revision.revised_id;
|
|
21
|
+
const list = byPreviousRevision.get(key) ?? [];
|
|
22
|
+
list.push(migration);
|
|
23
|
+
byPreviousRevision.set(key, list);
|
|
24
|
+
}
|
|
25
|
+
for (const [key, list] of byPreviousRevision.entries()) {
|
|
26
|
+
if (list.length > 1) {
|
|
27
|
+
const files = list.map((item) => item.filePath).join(", ");
|
|
28
|
+
throw new Error(`Branching migrations detected after '${key}': ${files}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const pending = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
let pointer = currentRevisionId ?? "";
|
|
34
|
+
while (true) {
|
|
35
|
+
const next = byPreviousRevision.get(pointer)?.[0];
|
|
36
|
+
if (!next) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (seen.has(next.revision._id)) {
|
|
40
|
+
throw new Error(`Cycle detected at revision '${next.revision._id}'`);
|
|
41
|
+
}
|
|
42
|
+
pending.push(next);
|
|
43
|
+
seen.add(next.revision._id);
|
|
44
|
+
pointer = next.revision._id;
|
|
45
|
+
}
|
|
46
|
+
if (currentRevisionId && pending.length === 0) {
|
|
47
|
+
const isKnownRevision = migrations.some((migration) => migration.revision._id === currentRevisionId);
|
|
48
|
+
const canContinueFromCurrent = migrations.some((migration) => migration.revision.revised_id === currentRevisionId);
|
|
49
|
+
if (!isKnownRevision && !canContinueFromCurrent) {
|
|
50
|
+
throw new Error(`Current revision '${currentRevisionId}' is not present in migration chain`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
async function applyMigration(client, schema, migration) {
|
|
56
|
+
const loaded = (0, files_1.loadMigrationModule)(migration);
|
|
57
|
+
await client.query("BEGIN");
|
|
58
|
+
try {
|
|
59
|
+
await loaded.upgrade({
|
|
60
|
+
query: async (sql, params) => client.query(sql, params)
|
|
61
|
+
});
|
|
62
|
+
await (0, migrationState_1.setCurrentRevisionId)(client, schema, loaded.revision._id);
|
|
63
|
+
await client.query("COMMIT");
|
|
64
|
+
console.log(`${ANSI_GREEN}Applied migration ${loaded.revision._id}${ANSI_RESET}`);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await client.query("ROLLBACK");
|
|
68
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
69
|
+
throw new Error(`Failed migration ${loaded.filePath}: ${message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function sanitizeInitSql(rawSql) {
|
|
73
|
+
return rawSql
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.filter((line) => !line.trimStart().startsWith("\\"))
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
async function applyInitialSchemaIfPresent(client, baseDir = process.cwd()) {
|
|
79
|
+
const initFilePath = node_path_1.default.join(baseDir, INIT_DB_FILE);
|
|
80
|
+
if (!node_fs_1.default.existsSync(initFilePath)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const rawSql = node_fs_1.default.readFileSync(initFilePath, "utf8");
|
|
84
|
+
const sanitizedSql = sanitizeInitSql(rawSql).trim();
|
|
85
|
+
if (!sanitizedSql) {
|
|
86
|
+
throw new Error(`${INIT_DB_FILE}: file is empty or only contains unsupported psql commands`);
|
|
87
|
+
}
|
|
88
|
+
await client.query("BEGIN");
|
|
89
|
+
try {
|
|
90
|
+
await client.query(sanitizedSql);
|
|
91
|
+
await client.query("COMMIT");
|
|
92
|
+
console.log(`${ANSI_GREEN}Initial schema loaded from ${INIT_DB_FILE}${ANSI_RESET}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await client.query("ROLLBACK");
|
|
96
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
97
|
+
throw new Error(`Failed to apply ${INIT_DB_FILE}: ${message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function runUpgradeToHead(client, config, migrations) {
|
|
101
|
+
await (0, migrationState_1.ensureMigrationState)(client, config.schema);
|
|
102
|
+
let currentRevisionId = await (0, migrationState_1.getCurrentRevisionId)(client, config.schema);
|
|
103
|
+
if (currentRevisionId === null) {
|
|
104
|
+
await applyInitialSchemaIfPresent(client);
|
|
105
|
+
currentRevisionId = await (0, migrationState_1.getCurrentRevisionId)(client, config.schema);
|
|
106
|
+
}
|
|
107
|
+
const pending = buildPendingMigrations(migrations, currentRevisionId);
|
|
108
|
+
if (pending.length === 0) {
|
|
109
|
+
console.log(`${ANSI_YELLOW}No pending migrations. Already at head.${ANSI_RESET}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(`${ANSI_YELLOW}Applying ${pending.length} migration(s)...${ANSI_RESET}`);
|
|
113
|
+
for (const migration of pending) {
|
|
114
|
+
await applyMigration(client, config.schema, migration);
|
|
115
|
+
}
|
|
116
|
+
const nextHead = pending[pending.length - 1].revision._id;
|
|
117
|
+
console.log(`${ANSI_GREEN}Upgrade completed. Current revision: ${nextHead}${ANSI_RESET}`);
|
|
118
|
+
}
|
|
119
|
+
function formatMigrationError(error) {
|
|
120
|
+
if (error instanceof Error) {
|
|
121
|
+
return `${ANSI_RED}${error.message}${ANSI_RESET}`;
|
|
122
|
+
}
|
|
123
|
+
return `${ANSI_RED}Unknown migration error${ANSI_RESET}`;
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "updebe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Schema migration tool",
|
|
6
6
|
"main": "dist/updebe.js",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
"updebe": "node dist/updebe.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"dotenv": "^
|
|
21
|
-
"pg": "^8.
|
|
20
|
+
"dotenv": "^17.4.2",
|
|
21
|
+
"pg": "^8.23.0",
|
|
22
|
+
"ts-node": "^10.9.2",
|
|
23
|
+
"typescript": "^7.0.2"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"@types/node": "^24.13.3",
|
|
25
|
-
"@types/pg": "^8.23.1"
|
|
26
|
-
"typescript": "^7.0.2"
|
|
27
|
+
"@types/pg": "^8.23.1"
|
|
27
28
|
}
|
|
28
29
|
}
|