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.
Files changed (57) hide show
  1. package/README.md +243 -26
  2. package/dist/cjs/cli/config.js +151 -0
  3. package/dist/cjs/cli/index.js +1176 -0
  4. package/dist/cjs/cli/migrate.js +446 -0
  5. package/dist/cjs/cli/ui.js +233 -0
  6. package/dist/cjs/client.js +512 -0
  7. package/dist/cjs/errors.js +293 -0
  8. package/dist/cjs/generate.js +321 -0
  9. package/dist/cjs/index.js +94 -0
  10. package/dist/cjs/introspect.js +287 -0
  11. package/dist/cjs/package.json +1 -0
  12. package/dist/cjs/pipeline.js +78 -0
  13. package/dist/cjs/query.js +1891 -0
  14. package/dist/cjs/schema-builder.js +238 -0
  15. package/dist/cjs/schema-sql.js +509 -0
  16. package/dist/cjs/schema.js +140 -0
  17. package/dist/cjs/serverless.js +110 -0
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +256 -49
  20. package/dist/cli/migrate.d.ts +35 -6
  21. package/dist/cli/migrate.js +124 -76
  22. package/dist/cli/ui.js +5 -9
  23. package/dist/client.d.ts +87 -3
  24. package/dist/client.js +122 -46
  25. package/dist/errors.d.ts +138 -0
  26. package/dist/errors.js +278 -0
  27. package/dist/generate.js +37 -11
  28. package/dist/index.d.ts +10 -8
  29. package/dist/index.js +15 -11
  30. package/dist/introspect.js +3 -5
  31. package/dist/pipeline.js +8 -1
  32. package/dist/query.d.ts +310 -45
  33. package/dist/query.js +565 -237
  34. package/dist/schema-builder.js +91 -23
  35. package/dist/schema-sql.d.ts +6 -2
  36. package/dist/schema-sql.js +180 -26
  37. package/dist/schema.js +4 -1
  38. package/dist/serverless.d.ts +91 -139
  39. package/dist/serverless.js +86 -173
  40. package/package.json +44 -21
  41. package/dist/cli/config.d.ts.map +0 -1
  42. package/dist/cli/index.d.ts.map +0 -1
  43. package/dist/cli/migrate.d.ts.map +0 -1
  44. package/dist/cli/ui.d.ts.map +0 -1
  45. package/dist/client.d.ts.map +0 -1
  46. package/dist/generate.d.ts.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/introspect.d.ts.map +0 -1
  49. package/dist/pipeline.d.ts.map +0 -1
  50. package/dist/query.d.ts.map +0 -1
  51. package/dist/schema-builder.d.ts.map +0 -1
  52. package/dist/schema-sql.d.ts.map +0 -1
  53. package/dist/schema.d.ts.map +0 -1
  54. package/dist/serverless.d.ts.map +0 -1
  55. package/dist/types.d.ts +0 -93
  56. package/dist/types.d.ts.map +0 -1
  57. 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
+ }