turbine-orm 0.7.1 → 0.9.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.
@@ -261,15 +261,35 @@ class ColumnBuilder {
261
261
  }
262
262
  }
263
263
  exports.ColumnBuilder = ColumnBuilder;
264
+ /** Type guard: is `prop` a known nullary ColumnBuilder type method? */
265
+ function isNullaryColumnType(prop) {
266
+ return (prop === 'serial' ||
267
+ prop === 'bigint' ||
268
+ prop === 'integer' ||
269
+ prop === 'smallint' ||
270
+ prop === 'text' ||
271
+ prop === 'boolean' ||
272
+ prop === 'timestamp' ||
273
+ prop === 'date' ||
274
+ prop === 'json' ||
275
+ prop === 'uuid' ||
276
+ prop === 'real' ||
277
+ prop === 'doublePrecision' ||
278
+ prop === 'numeric' ||
279
+ prop === 'bytea');
280
+ }
264
281
  /** @deprecated Use defineSchema() with plain objects instead */
265
282
  exports.column = new Proxy({}, {
266
283
  get(_target, prop) {
267
284
  if (prop === 'varchar')
268
285
  return (length) => new ColumnBuilder().varchar(length);
286
+ if (isNullaryColumnType(prop)) {
287
+ return () => {
288
+ const builder = new ColumnBuilder();
289
+ return builder[prop]();
290
+ };
291
+ }
269
292
  return () => {
270
- const builder = new ColumnBuilder();
271
- if (typeof builder[prop] === 'function')
272
- return builder[prop].call(builder);
273
293
  throw new Error(`Unknown column type: ${prop}`);
274
294
  };
275
295
  },
@@ -12,7 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
- * turbine studio — Launch web UI (coming soon)
15
+ * turbine studio — Launch local read-only web UI
16
16
  *
17
17
  * Usage:
18
18
  * DATABASE_URL=postgres://... npx turbine generate
package/dist/cli/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
- * turbine studio — Launch web UI (coming soon)
15
+ * turbine studio — Launch local read-only web UI
16
16
  *
17
17
  * Usage:
18
18
  * DATABASE_URL=postgres://... npx turbine generate
@@ -20,7 +20,7 @@
20
20
  * npx turbine migrate create add_users_table
21
21
  */
22
22
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
- import { relative, resolve } from 'node:path';
23
+ import { dirname, relative, resolve } from 'node:path';
24
24
  import { pathToFileURL } from 'node:url';
25
25
  import { generate } from '../generate.js';
26
26
  import { introspect } from '../introspect.js';
@@ -28,6 +28,7 @@ import { schemaDiff, schemaPush } from '../schema-sql.js';
28
28
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
29
29
  import { needsTsLoader, registerTsLoader } from './loader.js';
30
30
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
31
+ import { startStudio } from './studio.js';
31
32
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
32
33
  function parseArgs() {
33
34
  const args = process.argv.slice(2);
@@ -94,6 +95,17 @@ function parseArgs() {
94
95
  case '-h':
95
96
  result.help = true;
96
97
  break;
98
+ case '--port':
99
+ result.port = next ? Number.parseInt(next, 10) : undefined;
100
+ i++;
101
+ break;
102
+ case '--host':
103
+ result.host = next;
104
+ i++;
105
+ break;
106
+ case '--no-open':
107
+ result.noOpen = true;
108
+ break;
97
109
  default:
98
110
  if (!arg.startsWith('-')) {
99
111
  result.positional.push(arg);
@@ -890,19 +902,71 @@ async function cmdStatus(_args, config) {
890
902
  }
891
903
  }
892
904
  // ---------------------------------------------------------------------------
893
- // Command: studio (scaffold)
905
+ // Command: studio — local read-only web UI
894
906
  // ---------------------------------------------------------------------------
895
- async function cmdStudio(_args, _config) {
907
+ async function cmdStudio(args, config) {
896
908
  banner();
909
+ const url = requireUrl(config);
910
+ const port = args.port ?? 4983;
911
+ const host = args.host ?? '127.0.0.1';
912
+ const openBrowser = !args.noOpen;
913
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
914
+ console.log(red(`✗ invalid port: ${args.port}`));
915
+ process.exit(1);
916
+ }
917
+ // Refuse to bind anything other than loopback unless explicitly overridden.
918
+ // This is deliberate: Studio has no real authentication beyond a random
919
+ // session token, so exposing it on a LAN interface is foot-gun territory.
920
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
921
+ console.log(warn(`Studio is binding to ${yellow(host)} — this is NOT loopback. ` +
922
+ `Anyone on your network who can reach this port + guess the session token can read your database.`));
923
+ }
924
+ const spinner = new Spinner('Introspecting database').start();
925
+ let studio;
926
+ try {
927
+ studio = await startStudio({
928
+ url,
929
+ schema: config.schema,
930
+ port,
931
+ host,
932
+ openBrowser,
933
+ include: config.include.length ? config.include : undefined,
934
+ exclude: config.exclude.length ? config.exclude : undefined,
935
+ });
936
+ spinner.succeed(`Studio is running`);
937
+ }
938
+ catch (err) {
939
+ spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
940
+ process.exit(1);
941
+ }
942
+ newline();
897
943
  console.log(box([
898
- `${bold('Turbine Studio')} ${dim('— coming soon')}`,
944
+ `${bold('Turbine Studio')} ${dim('— local read-only UI')}`,
899
945
  '',
900
- 'A local web UI for browsing your database,',
901
- 'exploring relations, and managing data.',
946
+ ` ${cyan('URL:')} ${bold(studio.url)}`,
947
+ ` ${cyan('Schema:')} ${config.schema}`,
948
+ ` ${cyan('DB:')} ${redactUrl(url)}`,
902
949
  '',
903
- `Follow ${cyan('@turbineorm')} for updates.`,
904
- ].join('\n'), { title: bold(cyan('Studio')), padding: 2 }));
950
+ dim('Open the URL above in your browser. It includes a one-time session'),
951
+ dim('token that gets set as an HttpOnly cookie on first load.'),
952
+ dim('Press Ctrl+C to stop.'),
953
+ ].join('\n'), { title: bold(cyan('Studio')), padding: 1 }));
905
954
  newline();
955
+ // Wait forever until SIGINT/SIGTERM, then dispose cleanly.
956
+ await new Promise((resolve) => {
957
+ const shutdown = async () => {
958
+ console.log(dim('\n shutting down…'));
959
+ try {
960
+ await studio.dispose();
961
+ }
962
+ catch {
963
+ /* ignore */
964
+ }
965
+ resolve();
966
+ };
967
+ process.once('SIGINT', shutdown);
968
+ process.once('SIGTERM', shutdown);
969
+ });
906
970
  }
907
971
  // ---------------------------------------------------------------------------
908
972
  // Subcommand help
@@ -1051,7 +1115,7 @@ function showHelp() {
1051
1115
  console.log(` ${dim('status')} Show applied/pending migrations`);
1052
1116
  console.log(` ${cyan('seed')} Run seed file`);
1053
1117
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1054
- console.log(` ${cyan('studio')} Launch web UI (coming soon)`);
1118
+ console.log(` ${cyan('studio')} Launch local read-only web UI`);
1055
1119
  newline();
1056
1120
  console.log(` ${bold('Options:')}`);
1057
1121
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
@@ -1063,6 +1127,11 @@ function showHelp() {
1063
1127
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1064
1128
  console.log(` ${cyan('--force, -f')} Overwrite existing files`);
1065
1129
  newline();
1130
+ console.log(` ${bold('Studio options:')}`);
1131
+ console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983)')}`);
1132
+ console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
1133
+ console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
1134
+ newline();
1066
1135
  console.log(` ${bold('Config file:')}`);
1067
1136
  console.log(` ${dim('Create')} ${cyan('turbine.config.ts')} ${dim('with')} ${cyan('npx turbine init')}`);
1068
1137
  console.log(` ${dim('CLI flags override config file values.')}`);
@@ -1079,7 +1148,30 @@ function showHelp() {
1079
1148
  // Version
1080
1149
  // ---------------------------------------------------------------------------
1081
1150
  function showVersion() {
1082
- console.log(`turbine-orm v0.5.0`);
1151
+ // Walk up from the running script to find the turbine-orm package.json.
1152
+ // Using process.argv[1] instead of import.meta.url so the same code compiles
1153
+ // cleanly for both the ESM and CJS builds.
1154
+ try {
1155
+ let dir = dirname(process.argv[1] ?? '');
1156
+ for (let i = 0; i < 6; i++) {
1157
+ const candidate = resolve(dir, 'package.json');
1158
+ if (existsSync(candidate)) {
1159
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
1160
+ if (pkg.name === 'turbine-orm') {
1161
+ console.log(`turbine-orm v${pkg.version ?? '?'}`);
1162
+ return;
1163
+ }
1164
+ }
1165
+ const parent = dirname(dir);
1166
+ if (parent === dir)
1167
+ break;
1168
+ dir = parent;
1169
+ }
1170
+ console.log(`turbine-orm`);
1171
+ }
1172
+ catch {
1173
+ console.log(`turbine-orm`);
1174
+ }
1083
1175
  }
1084
1176
  // ---------------------------------------------------------------------------
1085
1177
  // Main
@@ -79,6 +79,22 @@ export declare function createMigration(migrationsDir: string, name: string, aut
79
79
  up: string;
80
80
  down: string;
81
81
  }): MigrationFile;
82
+ /**
83
+ * Derive a Postgres advisory lock ID (positive int4) from the database name.
84
+ *
85
+ * Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
86
+ * excellent distribution over short strings (database names are typically <64
87
+ * chars). Chosen over alternatives because it's:
88
+ * - deterministic (same input → same output, across processes/machines)
89
+ * - tiny (two lines, no allocations, no imports)
90
+ * - well-distributed (low collision rate for typical DB-name distributions)
91
+ *
92
+ * The top bit is cleared so the result fits in a positive int4, which is the
93
+ * range `pg_advisory_lock` expects for the single-argument form. Two databases
94
+ * in the same Postgres cluster can now run `turbine migrate` concurrently
95
+ * without contending on a single hardcoded lock ID.
96
+ */
97
+ export declare function deriveLockId(databaseName: string): number;
82
98
  /**
83
99
  * Apply all pending migrations (UP).
84
100
  *
@@ -202,16 +202,44 @@ ${autoContent.down}
202
202
  // ---------------------------------------------------------------------------
203
203
  // Advisory lock for concurrent migration safety
204
204
  // ---------------------------------------------------------------------------
205
- /** Fixed lock ID for Turbine migrations — prevents concurrent migrate runs */
206
- const MIGRATION_LOCK_ID = 8_347_291; // arbitrary but stable
207
- async function acquireLock(client) {
208
- const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [
209
- MIGRATION_LOCK_ID,
210
- ]);
211
- return result.rows[0]?.pg_try_advisory_lock ?? false;
205
+ /**
206
+ * Derive a Postgres advisory lock ID (positive int4) from the database name.
207
+ *
208
+ * Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
209
+ * excellent distribution over short strings (database names are typically <64
210
+ * chars). Chosen over alternatives because it's:
211
+ * - deterministic (same input → same output, across processes/machines)
212
+ * - tiny (two lines, no allocations, no imports)
213
+ * - well-distributed (low collision rate for typical DB-name distributions)
214
+ *
215
+ * The top bit is cleared so the result fits in a positive int4, which is the
216
+ * range `pg_advisory_lock` expects for the single-argument form. Two databases
217
+ * in the same Postgres cluster can now run `turbine migrate` concurrently
218
+ * without contending on a single hardcoded lock ID.
219
+ */
220
+ export function deriveLockId(databaseName) {
221
+ let hash = 0x811c9dc5;
222
+ for (let i = 0; i < databaseName.length; i++) {
223
+ hash ^= databaseName.charCodeAt(i);
224
+ hash = Math.imul(hash, 0x01000193);
225
+ }
226
+ return hash >>> 1; // positive int4 (top bit cleared)
227
+ }
228
+ /**
229
+ * Fetch the current database name from the connected client. Used to derive
230
+ * the advisory lock ID so concurrent migrations in sibling databases do not
231
+ * contend on one another.
232
+ */
233
+ async function getCurrentDatabaseName(client) {
234
+ const result = await client.query(`SELECT current_database()`);
235
+ return result.rows[0]?.current_database ?? '';
236
+ }
237
+ async function acquireLock(client, lockId) {
238
+ const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
239
+ return result.rows[0]?.locked ?? false;
212
240
  }
213
- async function releaseLock(client) {
214
- await client.query(`SELECT pg_advisory_unlock($1)`, [MIGRATION_LOCK_ID]);
241
+ async function releaseLock(client, lockId) {
242
+ await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
215
243
  }
216
244
  /**
217
245
  * Validate that applied migration files have not been modified or deleted since they were run.
@@ -274,8 +302,12 @@ export async function migrateUp(connectionString, migrationsDir, options) {
274
302
  // Treat `force` as an alias for `allowDrift` for backwards compatibility.
275
303
  const allowDrift = options?.allowDrift === true || options?.force === true;
276
304
  try {
305
+ // Derive an advisory lock ID per-database so concurrent migrations in
306
+ // sibling databases on the same Postgres cluster do not contend.
307
+ const dbName = await getCurrentDatabaseName(client);
308
+ const lockId = deriveLockId(dbName);
277
309
  // Acquire advisory lock to prevent concurrent migrations
278
- const gotLock = await acquireLock(client);
310
+ const gotLock = await acquireLock(client, lockId);
279
311
  if (!gotLock) {
280
312
  throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
281
313
  }
@@ -347,7 +379,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
347
379
  return { applied: results, errors };
348
380
  }
349
381
  finally {
350
- await releaseLock(client);
382
+ await releaseLock(client, lockId);
351
383
  }
352
384
  }
353
385
  finally {
@@ -366,7 +398,11 @@ export async function migrateDown(connectionString, migrationsDir, options) {
366
398
  const client = new pg.Client({ connectionString });
367
399
  await client.connect();
368
400
  try {
369
- const gotLock = await acquireLock(client);
401
+ // Derive a per-database advisory lock ID so concurrent migrations in
402
+ // sibling databases on the same cluster do not contend.
403
+ const dbName = await getCurrentDatabaseName(client);
404
+ const lockId = deriveLockId(dbName);
405
+ const gotLock = await acquireLock(client, lockId);
370
406
  if (!gotLock) {
371
407
  throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
372
408
  }
@@ -413,7 +449,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
413
449
  return { rolledBack: results, errors };
414
450
  }
415
451
  finally {
416
- await releaseLock(client);
452
+ await releaseLock(client, lockId);
417
453
  }
418
454
  }
419
455
  finally {
@@ -0,0 +1,2 @@
1
+ export declare const STUDIO_HTML: string;
2
+ //# sourceMappingURL=studio-ui.generated.d.ts.map