turbine-orm 0.4.0 → 0.7.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 +243 -26
- package/dist/cjs/cli/config.js +151 -0
- package/dist/cjs/cli/index.js +1176 -0
- package/dist/cjs/cli/migrate.js +446 -0
- package/dist/cjs/cli/ui.js +233 -0
- package/dist/cjs/client.js +512 -0
- package/dist/cjs/errors.js +293 -0
- package/dist/cjs/generate.js +321 -0
- package/dist/cjs/index.js +94 -0
- package/dist/cjs/introspect.js +287 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pipeline.js +78 -0
- package/dist/cjs/query.js +1891 -0
- package/dist/cjs/schema-builder.js +238 -0
- package/dist/cjs/schema-sql.js +509 -0
- package/dist/cjs/schema.js +140 -0
- package/dist/cjs/serverless.js +110 -0
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +256 -49
- package/dist/cli/migrate.d.ts +35 -6
- package/dist/cli/migrate.js +124 -76
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +87 -3
- package/dist/client.js +122 -46
- package/dist/errors.d.ts +138 -0
- package/dist/errors.js +278 -0
- package/dist/generate.js +37 -11
- package/dist/index.d.ts +10 -8
- package/dist/index.js +15 -11
- package/dist/introspect.js +3 -5
- package/dist/pipeline.js +8 -1
- package/dist/query.d.ts +310 -45
- package/dist/query.js +565 -237
- package/dist/schema-builder.js +91 -23
- package/dist/schema-sql.d.ts +6 -2
- package/dist/schema-sql.js +180 -26
- package/dist/schema.js +4 -1
- package/dist/serverless.d.ts +91 -139
- package/dist/serverless.js +86 -173
- package/package.json +44 -21
- package/dist/cli/config.d.ts.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/migrate.d.ts.map +0 -1
- package/dist/cli/ui.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/introspect.d.ts.map +0 -1
- package/dist/pipeline.d.ts.map +0 -1
- package/dist/query.d.ts.map +0 -1
- package/dist/schema-builder.d.ts.map +0 -1
- package/dist/schema-sql.d.ts.map +0 -1
- package/dist/schema.d.ts.map +0 -1
- package/dist/serverless.d.ts.map +0 -1
- package/dist/types.d.ts +0 -93
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -126
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm CLI — Migration system
|
|
4
|
+
*
|
|
5
|
+
* SQL-first migrations with UP/DOWN sections, tracked in _turbine_migrations.
|
|
6
|
+
* Migration files are timestamp-prefixed .sql files.
|
|
7
|
+
*
|
|
8
|
+
* File format:
|
|
9
|
+
* -- UP
|
|
10
|
+
* CREATE TABLE users (...);
|
|
11
|
+
*
|
|
12
|
+
* -- DOWN
|
|
13
|
+
* DROP TABLE users;
|
|
14
|
+
*/
|
|
15
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
16
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.parseMigrationFilename = parseMigrationFilename;
|
|
20
|
+
exports.sanitizeName = sanitizeName;
|
|
21
|
+
exports.formatTimestamp = formatTimestamp;
|
|
22
|
+
exports.getPendingMigrations = getPendingMigrations;
|
|
23
|
+
exports.listMigrationFiles = listMigrationFiles;
|
|
24
|
+
exports.parseMigrationContent = parseMigrationContent;
|
|
25
|
+
exports.parseMigrationSQL = parseMigrationSQL;
|
|
26
|
+
exports.createMigration = createMigration;
|
|
27
|
+
exports.migrateUp = migrateUp;
|
|
28
|
+
exports.migrateDown = migrateDown;
|
|
29
|
+
exports.migrateStatus = migrateStatus;
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const node_fs_1 = require("node:fs");
|
|
32
|
+
const node_path_1 = require("node:path");
|
|
33
|
+
const pg_1 = __importDefault(require("pg"));
|
|
34
|
+
const errors_js_1 = require("../errors.js");
|
|
35
|
+
const query_js_1 = require("../query.js");
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Tracking table management
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const TRACKING_TABLE = '_turbine_migrations';
|
|
40
|
+
const QUOTED_TRACKING_TABLE = (0, query_js_1.quoteIdent)(TRACKING_TABLE);
|
|
41
|
+
const CREATE_TRACKING_TABLE = `
|
|
42
|
+
CREATE TABLE IF NOT EXISTS ${QUOTED_TRACKING_TABLE} (
|
|
43
|
+
id SERIAL PRIMARY KEY,
|
|
44
|
+
name TEXT NOT NULL UNIQUE,
|
|
45
|
+
checksum TEXT NOT NULL,
|
|
46
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
47
|
+
);
|
|
48
|
+
`;
|
|
49
|
+
async function ensureTrackingTable(client) {
|
|
50
|
+
await client.query(CREATE_TRACKING_TABLE);
|
|
51
|
+
}
|
|
52
|
+
async function getAppliedMigrations(client) {
|
|
53
|
+
await ensureTrackingTable(client);
|
|
54
|
+
const result = await client.query(`SELECT id, name, applied_at, checksum FROM ${QUOTED_TRACKING_TABLE} ORDER BY id ASC`);
|
|
55
|
+
return result.rows;
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// File operations
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Parse a migration filename into its components.
|
|
62
|
+
* Expected format: YYYYMMDDHHMMSS_description.sql
|
|
63
|
+
*/
|
|
64
|
+
function parseMigrationFilename(filename) {
|
|
65
|
+
const match = filename.match(/^(\d{14})_(.+)\.sql$/);
|
|
66
|
+
if (!match)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
filename,
|
|
70
|
+
path: '', // Set by caller
|
|
71
|
+
name: filename.replace(/\.sql$/, ''),
|
|
72
|
+
timestamp: match[1],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Sanitize a migration name: lowercase, replace non-alnum with _, collapse duplicates, trim.
|
|
77
|
+
*/
|
|
78
|
+
function sanitizeName(name) {
|
|
79
|
+
return name
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
82
|
+
.replace(/_+/g, '_')
|
|
83
|
+
.replace(/^_|_$/g, '');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Generate a YYYYMMDDHHMMSS timestamp string from a Date.
|
|
87
|
+
*/
|
|
88
|
+
function formatTimestamp(date) {
|
|
89
|
+
return [
|
|
90
|
+
date.getFullYear(),
|
|
91
|
+
String(date.getMonth() + 1).padStart(2, '0'),
|
|
92
|
+
String(date.getDate()).padStart(2, '0'),
|
|
93
|
+
String(date.getHours()).padStart(2, '0'),
|
|
94
|
+
String(date.getMinutes()).padStart(2, '0'),
|
|
95
|
+
String(date.getSeconds()).padStart(2, '0'),
|
|
96
|
+
].join('');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get pending migration files — those not yet applied.
|
|
100
|
+
* Returns files sorted by timestamp (ascending).
|
|
101
|
+
*/
|
|
102
|
+
function getPendingMigrations(migrationsDir, applied) {
|
|
103
|
+
const appliedSet = new Set(applied);
|
|
104
|
+
return listMigrationFiles(migrationsDir).filter((f) => !appliedSet.has(f.name));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* List all migration files in the migrations directory, sorted by name.
|
|
108
|
+
*/
|
|
109
|
+
function listMigrationFiles(migrationsDir) {
|
|
110
|
+
if (!(0, node_fs_1.existsSync)(migrationsDir))
|
|
111
|
+
return [];
|
|
112
|
+
const entries = (0, node_fs_1.readdirSync)(migrationsDir)
|
|
113
|
+
.filter((f) => f.endsWith('.sql'))
|
|
114
|
+
.sort();
|
|
115
|
+
const files = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const parsed = parseMigrationFilename(entry);
|
|
118
|
+
if (parsed) {
|
|
119
|
+
parsed.path = (0, node_path_1.join)(migrationsDir, entry);
|
|
120
|
+
files.push(parsed);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return files;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse migration content string into UP and DOWN sections.
|
|
127
|
+
* Exported for unit testing.
|
|
128
|
+
*/
|
|
129
|
+
function parseMigrationContent(content) {
|
|
130
|
+
const lines = content.split('\n');
|
|
131
|
+
let section = 'none';
|
|
132
|
+
const upLines = [];
|
|
133
|
+
const downLines = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const trimmed = line.trim().toUpperCase();
|
|
136
|
+
if (trimmed === '-- UP') {
|
|
137
|
+
section = 'up';
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (trimmed === '-- DOWN') {
|
|
141
|
+
section = 'down';
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (section === 'up')
|
|
145
|
+
upLines.push(line);
|
|
146
|
+
else if (section === 'down')
|
|
147
|
+
downLines.push(line);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
up: upLines.join('\n').trim(),
|
|
151
|
+
down: downLines.join('\n').trim(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Parse a migration file into UP and DOWN sections.
|
|
156
|
+
*/
|
|
157
|
+
function parseMigrationSQL(filePath) {
|
|
158
|
+
const content = (0, node_fs_1.readFileSync)(filePath, 'utf-8');
|
|
159
|
+
return parseMigrationContent(content);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* SHA-256 checksum for migration drift detection.
|
|
163
|
+
* Returns a hex-encoded hash of the file content.
|
|
164
|
+
*/
|
|
165
|
+
function checksum(content) {
|
|
166
|
+
return (0, node_crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
|
|
167
|
+
}
|
|
168
|
+
/** Detect legacy djb2 checksums (short alphanumeric strings, pre-v0.6) */
|
|
169
|
+
function isLegacyChecksum(hash) {
|
|
170
|
+
return hash.length < 64;
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Commands
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/**
|
|
176
|
+
* Create a new migration file.
|
|
177
|
+
* If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
|
|
178
|
+
*/
|
|
179
|
+
function createMigration(migrationsDir, name, autoContent) {
|
|
180
|
+
(0, node_fs_1.mkdirSync)(migrationsDir, { recursive: true });
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const ts = formatTimestamp(now);
|
|
183
|
+
const safeName = sanitizeName(name);
|
|
184
|
+
const filename = `${ts}_${safeName}.sql`;
|
|
185
|
+
const filePath = (0, node_path_1.join)(migrationsDir, filename);
|
|
186
|
+
let template;
|
|
187
|
+
if (autoContent) {
|
|
188
|
+
template = `-- Migration: ${name} (auto-generated from schema diff)
|
|
189
|
+
-- Created: ${now.toISOString()}
|
|
190
|
+
-- Review this file before running: npx turbine migrate up
|
|
191
|
+
|
|
192
|
+
-- UP
|
|
193
|
+
${autoContent.up}
|
|
194
|
+
|
|
195
|
+
-- DOWN
|
|
196
|
+
${autoContent.down}
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
template = `-- Migration: ${name}
|
|
201
|
+
-- Created: ${now.toISOString()}
|
|
202
|
+
|
|
203
|
+
-- UP
|
|
204
|
+
-- Write your migration SQL here
|
|
205
|
+
|
|
206
|
+
-- DOWN
|
|
207
|
+
-- Write your rollback SQL here
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
(0, node_fs_1.writeFileSync)(filePath, template, 'utf-8');
|
|
211
|
+
return {
|
|
212
|
+
filename,
|
|
213
|
+
path: filePath,
|
|
214
|
+
name: filename.replace(/\.sql$/, ''),
|
|
215
|
+
timestamp: ts,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Advisory lock for concurrent migration safety
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
/** Fixed lock ID for Turbine migrations — prevents concurrent migrate runs */
|
|
222
|
+
const MIGRATION_LOCK_ID = 8_347_291; // arbitrary but stable
|
|
223
|
+
async function acquireLock(client) {
|
|
224
|
+
const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [
|
|
225
|
+
MIGRATION_LOCK_ID,
|
|
226
|
+
]);
|
|
227
|
+
return result.rows[0]?.pg_try_advisory_lock ?? false;
|
|
228
|
+
}
|
|
229
|
+
async function releaseLock(client) {
|
|
230
|
+
await client.query(`SELECT pg_advisory_unlock($1)`, [MIGRATION_LOCK_ID]);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Validate that applied migration files have not been modified or deleted since they were run.
|
|
234
|
+
* Returns an array of mismatched migrations (empty if all are clean).
|
|
235
|
+
*/
|
|
236
|
+
async function validateChecksums(client, migrationsDir) {
|
|
237
|
+
const applied = await getAppliedMigrations(client);
|
|
238
|
+
const allFiles = listMigrationFiles(migrationsDir);
|
|
239
|
+
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
240
|
+
const mismatches = [];
|
|
241
|
+
for (const migration of applied) {
|
|
242
|
+
const file = fileMap.get(migration.name);
|
|
243
|
+
if (!file) {
|
|
244
|
+
mismatches.push({
|
|
245
|
+
name: migration.name,
|
|
246
|
+
expected: migration.checksum,
|
|
247
|
+
actual: '',
|
|
248
|
+
type: 'missing',
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
|
|
253
|
+
const currentHash = checksum(content);
|
|
254
|
+
if (currentHash !== migration.checksum) {
|
|
255
|
+
// Auto-upgrade legacy djb2 checksums to SHA-256 without flagging as modified
|
|
256
|
+
if (isLegacyChecksum(migration.checksum)) {
|
|
257
|
+
await client.query(`UPDATE ${QUOTED_TRACKING_TABLE} SET checksum = $1 WHERE name = $2`, [
|
|
258
|
+
currentHash,
|
|
259
|
+
migration.name,
|
|
260
|
+
]);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
mismatches.push({
|
|
264
|
+
name: migration.name,
|
|
265
|
+
expected: migration.checksum,
|
|
266
|
+
actual: currentHash,
|
|
267
|
+
type: 'modified',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return mismatches;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Apply all pending migrations (UP).
|
|
275
|
+
*
|
|
276
|
+
* Features:
|
|
277
|
+
* - Idempotent: running twice is safe (already-applied migrations are skipped)
|
|
278
|
+
* - Advisory lock: prevents concurrent migration runs
|
|
279
|
+
* - Checksum validation: detects modified migration files
|
|
280
|
+
* - Each migration runs in its own transaction
|
|
281
|
+
*/
|
|
282
|
+
async function migrateUp(connectionString, migrationsDir, options) {
|
|
283
|
+
const client = new pg_1.default.Client({ connectionString });
|
|
284
|
+
await client.connect();
|
|
285
|
+
try {
|
|
286
|
+
// Acquire advisory lock to prevent concurrent migrations
|
|
287
|
+
const gotLock = await acquireLock(client);
|
|
288
|
+
if (!gotLock) {
|
|
289
|
+
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await ensureTrackingTable(client);
|
|
293
|
+
// Validate checksums of already-applied migrations (skip with --force)
|
|
294
|
+
if (!options?.force) {
|
|
295
|
+
const mismatches = await validateChecksums(client, migrationsDir);
|
|
296
|
+
if (mismatches.length > 0) {
|
|
297
|
+
const modified = mismatches.filter((m) => m.type === 'modified');
|
|
298
|
+
const missing = mismatches.filter((m) => m.type === 'missing');
|
|
299
|
+
const parts = [];
|
|
300
|
+
if (modified.length > 0)
|
|
301
|
+
parts.push(`modified: ${modified.map((m) => m.name).join(', ')}`);
|
|
302
|
+
if (missing.length > 0)
|
|
303
|
+
parts.push(`deleted: ${missing.map((m) => m.name).join(', ')}`);
|
|
304
|
+
throw new errors_js_1.MigrationError(`[turbine] Migration integrity check failed — ${parts.join('; ')}. Applied migrations should be immutable. Use --force to skip this check.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const applied = await getAppliedMigrations(client);
|
|
308
|
+
const appliedNames = new Set(applied.map((m) => m.name));
|
|
309
|
+
const allFiles = listMigrationFiles(migrationsDir);
|
|
310
|
+
let pending = allFiles.filter((f) => !appliedNames.has(f.name));
|
|
311
|
+
if (options?.step != null && options.step > 0) {
|
|
312
|
+
pending = pending.slice(0, options.step);
|
|
313
|
+
}
|
|
314
|
+
const results = [];
|
|
315
|
+
const errors = [];
|
|
316
|
+
for (const file of pending) {
|
|
317
|
+
const { up } = parseMigrationSQL(file.path);
|
|
318
|
+
if (!up) {
|
|
319
|
+
errors.push({ file, error: 'No UP section found in migration file' });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
|
|
323
|
+
const hash = checksum(content);
|
|
324
|
+
try {
|
|
325
|
+
await client.query('BEGIN');
|
|
326
|
+
await client.query(up);
|
|
327
|
+
await client.query(`INSERT INTO ${QUOTED_TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
|
|
328
|
+
await client.query('COMMIT');
|
|
329
|
+
results.push(file);
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
await client.query('ROLLBACK');
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
errors.push({ file, error: msg });
|
|
335
|
+
// Stop on first error
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { applied: results, errors };
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await releaseLock(client);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await client.end();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Rollback the last N migrations (DOWN).
|
|
351
|
+
*
|
|
352
|
+
* Features:
|
|
353
|
+
* - Advisory lock: prevents concurrent rollback runs
|
|
354
|
+
* - Each rollback runs in its own transaction
|
|
355
|
+
* - Properly reverses changes in reverse application order
|
|
356
|
+
*/
|
|
357
|
+
async function migrateDown(connectionString, migrationsDir, options) {
|
|
358
|
+
const client = new pg_1.default.Client({ connectionString });
|
|
359
|
+
await client.connect();
|
|
360
|
+
try {
|
|
361
|
+
const gotLock = await acquireLock(client);
|
|
362
|
+
if (!gotLock) {
|
|
363
|
+
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
await ensureTrackingTable(client);
|
|
367
|
+
const applied = await getAppliedMigrations(client);
|
|
368
|
+
if (applied.length === 0) {
|
|
369
|
+
return { rolledBack: [], errors: [] };
|
|
370
|
+
}
|
|
371
|
+
const allFiles = listMigrationFiles(migrationsDir);
|
|
372
|
+
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
373
|
+
// Reverse order — rollback most recent first
|
|
374
|
+
const toRollback = applied.reverse().slice(0, options?.step ?? 1);
|
|
375
|
+
const results = [];
|
|
376
|
+
const errors = [];
|
|
377
|
+
for (const migration of toRollback) {
|
|
378
|
+
const file = fileMap.get(migration.name);
|
|
379
|
+
if (!file) {
|
|
380
|
+
errors.push({
|
|
381
|
+
file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
|
|
382
|
+
error: `Migration file not found for "${migration.name}"`,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const { down } = parseMigrationSQL(file.path);
|
|
387
|
+
if (!down) {
|
|
388
|
+
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await client.query('BEGIN');
|
|
393
|
+
await client.query(down);
|
|
394
|
+
await client.query(`DELETE FROM ${QUOTED_TRACKING_TABLE} WHERE name = $1`, [migration.name]);
|
|
395
|
+
await client.query('COMMIT');
|
|
396
|
+
results.push(file);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
await client.query('ROLLBACK');
|
|
400
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
401
|
+
errors.push({ file, error: msg });
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { rolledBack: results, errors };
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
await releaseLock(client);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
await client.end();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get the status of all migrations (applied vs pending).
|
|
417
|
+
* Includes checksum validation for applied migrations.
|
|
418
|
+
*/
|
|
419
|
+
async function migrateStatus(connectionString, migrationsDir) {
|
|
420
|
+
const client = new pg_1.default.Client({ connectionString });
|
|
421
|
+
await client.connect();
|
|
422
|
+
try {
|
|
423
|
+
await ensureTrackingTable(client);
|
|
424
|
+
const applied = await getAppliedMigrations(client);
|
|
425
|
+
const appliedMap = new Map(applied.map((m) => [m.name, m]));
|
|
426
|
+
const allFiles = listMigrationFiles(migrationsDir);
|
|
427
|
+
return allFiles.map((file) => {
|
|
428
|
+
const record = appliedMap.get(file.name);
|
|
429
|
+
let checksumValid;
|
|
430
|
+
if (record) {
|
|
431
|
+
const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
|
|
432
|
+
const currentHash = checksum(content);
|
|
433
|
+
checksumValid = currentHash === record.checksum;
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
file,
|
|
437
|
+
applied: !!record,
|
|
438
|
+
appliedAt: record?.applied_at,
|
|
439
|
+
checksumValid,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
await client.end();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm CLI — UI utilities
|
|
4
|
+
*
|
|
5
|
+
* ANSI colors, spinners, box-drawing, and formatting helpers.
|
|
6
|
+
* Zero dependencies — raw escape codes only.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.Spinner = exports.symbols = exports.bgCyan = exports.bgYellow = exports.bgRed = exports.bgGreen = exports.redBright = exports.yellowBright = exports.cyanBright = exports.greenBright = exports.gray = exports.white = exports.cyan = exports.magenta = exports.blue = exports.yellow = exports.green = exports.red = exports.underline = exports.italic = exports.dim = exports.bold = void 0;
|
|
10
|
+
exports.box = box;
|
|
11
|
+
exports.table = table;
|
|
12
|
+
exports.header = header;
|
|
13
|
+
exports.success = success;
|
|
14
|
+
exports.error = error;
|
|
15
|
+
exports.warn = warn;
|
|
16
|
+
exports.info = info;
|
|
17
|
+
exports.label = label;
|
|
18
|
+
exports.newline = newline;
|
|
19
|
+
exports.divider = divider;
|
|
20
|
+
exports.banner = banner;
|
|
21
|
+
exports.elapsed = elapsed;
|
|
22
|
+
exports.stripAnsi = stripAnsi;
|
|
23
|
+
exports.redactUrl = redactUrl;
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// ANSI escape codes
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const isColorSupported = process.env.NO_COLOR == null && process.env.TERM !== 'dumb' && (process.stdout.isTTY ?? false);
|
|
28
|
+
function code(open, close) {
|
|
29
|
+
if (!isColorSupported)
|
|
30
|
+
return (s) => s;
|
|
31
|
+
return (s) => `\x1b[${open}m${s}\x1b[${close}m`;
|
|
32
|
+
}
|
|
33
|
+
exports.bold = code('1', '22');
|
|
34
|
+
exports.dim = code('2', '22');
|
|
35
|
+
exports.italic = code('3', '23');
|
|
36
|
+
exports.underline = code('4', '24');
|
|
37
|
+
exports.red = code('31', '39');
|
|
38
|
+
exports.green = code('32', '39');
|
|
39
|
+
exports.yellow = code('33', '39');
|
|
40
|
+
exports.blue = code('34', '39');
|
|
41
|
+
exports.magenta = code('35', '39');
|
|
42
|
+
exports.cyan = code('36', '39');
|
|
43
|
+
exports.white = code('37', '39');
|
|
44
|
+
exports.gray = code('90', '39');
|
|
45
|
+
// Bright variants
|
|
46
|
+
exports.greenBright = code('92', '39');
|
|
47
|
+
exports.cyanBright = code('96', '39');
|
|
48
|
+
exports.yellowBright = code('93', '39');
|
|
49
|
+
exports.redBright = code('91', '39');
|
|
50
|
+
// Background
|
|
51
|
+
exports.bgGreen = code('42', '49');
|
|
52
|
+
exports.bgRed = code('41', '49');
|
|
53
|
+
exports.bgYellow = code('43', '49');
|
|
54
|
+
exports.bgCyan = code('46', '49');
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Symbols
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
exports.symbols = {
|
|
59
|
+
check: isColorSupported ? '\u2713' : 'v',
|
|
60
|
+
cross: isColorSupported ? '\u2717' : 'x',
|
|
61
|
+
bullet: isColorSupported ? '\u2022' : '*',
|
|
62
|
+
arrow: isColorSupported ? '\u2192' : '->',
|
|
63
|
+
arrowRight: isColorSupported ? '\u25B8' : '>',
|
|
64
|
+
info: isColorSupported ? '\u2139' : 'i',
|
|
65
|
+
warning: isColorSupported ? '\u26A0' : '!',
|
|
66
|
+
dot: isColorSupported ? '\u2219' : '.',
|
|
67
|
+
line: isColorSupported ? '\u2500' : '-',
|
|
68
|
+
vertLine: isColorSupported ? '\u2502' : '|',
|
|
69
|
+
topLeft: isColorSupported ? '\u256D' : '+',
|
|
70
|
+
topRight: isColorSupported ? '\u256E' : '+',
|
|
71
|
+
bottomLeft: isColorSupported ? '\u2570' : '+',
|
|
72
|
+
bottomRight: isColorSupported ? '\u256F' : '+',
|
|
73
|
+
tee: isColorSupported ? '\u251C' : '|',
|
|
74
|
+
teeEnd: isColorSupported ? '\u2514' : '\\',
|
|
75
|
+
};
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Box drawing
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
function box(content, options) {
|
|
80
|
+
const padding = options?.padding ?? 1;
|
|
81
|
+
const lines = content.split('\n');
|
|
82
|
+
const pad = ' '.repeat(padding);
|
|
83
|
+
// Calculate max width (strip ANSI for measurement)
|
|
84
|
+
const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), options?.title ? stripAnsi(options.title).length + 2 : 0);
|
|
85
|
+
const innerWidth = maxWidth + padding * 2;
|
|
86
|
+
const top = options?.title
|
|
87
|
+
? `${exports.symbols.topLeft}${exports.symbols.line} ${options.title} ${exports.symbols.line.repeat(Math.max(0, innerWidth - stripAnsi(options.title).length - 3))}${exports.symbols.topRight}`
|
|
88
|
+
: `${exports.symbols.topLeft}${exports.symbols.line.repeat(innerWidth)}${exports.symbols.topRight}`;
|
|
89
|
+
const bottom = `${exports.symbols.bottomLeft}${exports.symbols.line.repeat(innerWidth)}${exports.symbols.bottomRight}`;
|
|
90
|
+
const body = lines.map((line) => {
|
|
91
|
+
const stripped = stripAnsi(line);
|
|
92
|
+
const rightPad = ' '.repeat(Math.max(0, maxWidth - stripped.length));
|
|
93
|
+
return `${exports.symbols.vertLine}${pad}${line}${rightPad}${pad}${exports.symbols.vertLine}`;
|
|
94
|
+
});
|
|
95
|
+
return [top, ...body, bottom].join('\n');
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Table formatting
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
function table(headers, rows) {
|
|
101
|
+
const colWidths = headers.map((h, i) => {
|
|
102
|
+
const dataMax = rows.reduce((max, row) => {
|
|
103
|
+
const cell = row[i] ?? '';
|
|
104
|
+
return Math.max(max, stripAnsi(cell).length);
|
|
105
|
+
}, 0);
|
|
106
|
+
return Math.max(stripAnsi(h).length, dataMax);
|
|
107
|
+
});
|
|
108
|
+
const headerLine = headers
|
|
109
|
+
.map((h, i) => {
|
|
110
|
+
const w = colWidths[i];
|
|
111
|
+
return ` ${(0, exports.bold)(h)}${' '.repeat(Math.max(0, w - stripAnsi(h).length))} `;
|
|
112
|
+
})
|
|
113
|
+
.join((0, exports.dim)(exports.symbols.vertLine));
|
|
114
|
+
const separator = colWidths.map((w) => exports.symbols.line.repeat(w + 2)).join((0, exports.dim)(exports.symbols.line));
|
|
115
|
+
const bodyLines = rows.map((row) => row
|
|
116
|
+
.map((cell, i) => {
|
|
117
|
+
const w = colWidths[i];
|
|
118
|
+
return ` ${cell}${' '.repeat(Math.max(0, w - stripAnsi(cell).length))} `;
|
|
119
|
+
})
|
|
120
|
+
.join((0, exports.dim)(exports.symbols.vertLine)));
|
|
121
|
+
return [headerLine, (0, exports.dim)(separator), ...bodyLines].join('\n');
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Spinner (simple dots animation)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
class Spinner {
|
|
127
|
+
frames = [' ', '. ', '.. ', '...', ' ..', ' .'];
|
|
128
|
+
frameIndex = 0;
|
|
129
|
+
interval = null;
|
|
130
|
+
message;
|
|
131
|
+
constructor(message) {
|
|
132
|
+
this.message = message;
|
|
133
|
+
}
|
|
134
|
+
start() {
|
|
135
|
+
if (!isColorSupported || !process.stdout.isTTY) {
|
|
136
|
+
process.stdout.write(` ${this.message}...\n`);
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
this.interval = setInterval(() => {
|
|
140
|
+
const frame = this.frames[this.frameIndex % this.frames.length];
|
|
141
|
+
process.stdout.write(`\r ${(0, exports.cyan)(frame)} ${this.message}`);
|
|
142
|
+
this.frameIndex++;
|
|
143
|
+
}, 120);
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
succeed(msg) {
|
|
147
|
+
this.stop();
|
|
148
|
+
const text = msg ?? this.message;
|
|
149
|
+
process.stdout.write(`\r ${(0, exports.green)(exports.symbols.check)} ${text}\n`);
|
|
150
|
+
}
|
|
151
|
+
fail(msg) {
|
|
152
|
+
this.stop();
|
|
153
|
+
const text = msg ?? this.message;
|
|
154
|
+
process.stdout.write(`\r ${(0, exports.red)(exports.symbols.cross)} ${text}\n`);
|
|
155
|
+
}
|
|
156
|
+
info(msg) {
|
|
157
|
+
this.stop();
|
|
158
|
+
const text = msg ?? this.message;
|
|
159
|
+
process.stdout.write(`\r ${(0, exports.blue)(exports.symbols.info)} ${text}\n`);
|
|
160
|
+
}
|
|
161
|
+
stop() {
|
|
162
|
+
if (this.interval) {
|
|
163
|
+
clearInterval(this.interval);
|
|
164
|
+
this.interval = null;
|
|
165
|
+
// Clear the line
|
|
166
|
+
if (isColorSupported && process.stdout.isTTY) {
|
|
167
|
+
process.stdout.write('\r\x1b[K');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
exports.Spinner = Spinner;
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Logging helpers
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function header(text) {
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(` ${(0, exports.bold)((0, exports.cyan)(text))}`);
|
|
179
|
+
console.log('');
|
|
180
|
+
}
|
|
181
|
+
function success(msg) {
|
|
182
|
+
console.log(` ${(0, exports.green)(exports.symbols.check)} ${msg}`);
|
|
183
|
+
}
|
|
184
|
+
function error(msg) {
|
|
185
|
+
console.log(` ${(0, exports.red)(exports.symbols.cross)} ${msg}`);
|
|
186
|
+
}
|
|
187
|
+
function warn(msg) {
|
|
188
|
+
console.log(` ${(0, exports.yellow)(exports.symbols.warning)} ${msg}`);
|
|
189
|
+
}
|
|
190
|
+
function info(msg) {
|
|
191
|
+
console.log(` ${(0, exports.blue)(exports.symbols.info)} ${msg}`);
|
|
192
|
+
}
|
|
193
|
+
function label(key, value) {
|
|
194
|
+
console.log(` ${(0, exports.dim)(`${key}:`)} ${value}`);
|
|
195
|
+
}
|
|
196
|
+
function newline() {
|
|
197
|
+
console.log('');
|
|
198
|
+
}
|
|
199
|
+
function divider() {
|
|
200
|
+
const width = Math.min(process.stdout.columns ?? 60, 60);
|
|
201
|
+
console.log(` ${(0, exports.dim)(exports.symbols.line.repeat(width - 4))}`);
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Banner
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
function banner() {
|
|
207
|
+
console.log('');
|
|
208
|
+
console.log(` ${(0, exports.bold)((0, exports.cyan)('turbine-orm'))}`);
|
|
209
|
+
console.log(` ${(0, exports.dim)('TypeScript ORM with json_agg nested queries')}`);
|
|
210
|
+
console.log('');
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Elapsed time formatting
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
function elapsed(startMs) {
|
|
216
|
+
const ms = performance.now() - startMs;
|
|
217
|
+
if (ms < 1000)
|
|
218
|
+
return `${Math.round(ms)}ms`;
|
|
219
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
220
|
+
}
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Strip ANSI codes (for width calculation)
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
function stripAnsi(s) {
|
|
225
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
|
|
226
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Redact password from connection URL
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
function redactUrl(url) {
|
|
232
|
+
return url.replace(/:([^@/:]+)@/, ':***@');
|
|
233
|
+
}
|