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.
package/README.md CHANGED
@@ -8,34 +8,35 @@ npm install turbine-orm
8
8
 
9
9
  ## Why Turbine?
10
10
 
11
- Turbine is a PostgreSQL-native TypeScript ORM with features no other ORM offers together: **cursor-based streaming** through nested relations, **typed error classes** with PostgreSQL constraint mapping, **pipeline batching** (N queries, 1 round-trip), **middleware**, and a driver-agnostic core that plugs into any pg-compatible pool so it runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. It resolves nested relations in a single SQL query using `json_agg` — an approach now shared by Prisma 7+ and Drizzle v2, but Turbine does it with 1 runtime dependency (`pg`) and ~110KB on npm.
11
+ Turbine is a PostgreSQL-native TypeScript ORM built around features no other ORM bundles together: a **read-only, DBA-approvable Studio web UI** (the only one in the TS ORM ecosystem — `BEGIN READ ONLY` + statement-stacking guard + loopback-only + 24-byte auth token), **cursor-based streaming** through nested relations, **typed error classes** with PostgreSQL constraint mapping, **pipeline batching** (N queries, 1 round-trip), **middleware**, and a driver-agnostic core that plugs into any pg-compatible pool so it runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. 1 runtime dependency (`pg`), ~110KB on npm.
12
12
 
13
- **One query for nested relations.** When you write `db.users.findMany({ with: { posts: { with: { comments: true } } } })`, Turbine generates a single SQL statement using correlated subqueries with `json_agg`. Modern ORMs like Prisma 7+ and Drizzle v2 use similar single-query approaches (LATERAL JOINs). Turbine's advantage is architectural simplicity: 1 dependency, no code generation DSL, and PostgreSQL-native depth.
13
+ **One round-trip for nested relations.** `db.users.findMany({ with: { posts: { with: { comments: true } } } })` resolves the entire object graph in a single database round-trip, regardless of nesting depth. Prisma 7+ and Drizzle v2 also do single-query nested loads Turbine's advantage is architectural simplicity: 1 dependency, no code generation DSL, no query plan compiler.
14
14
 
15
15
  ## Benchmarks
16
16
 
17
- Tested against **Prisma 7.6** (adapter-pg, relationJoins) and **Drizzle 0.45** (relational queries) on the same PostgreSQL database. 200 iterations, 20 warmup, Node v22.
17
+ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizzle 0.45** (relational queries) on a **Neon** PostgreSQL database (pooled endpoint, US-East, PostgreSQL 17.8). 100 iterations, 20 warmup, Node v22. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config.
18
18
 
19
19
  | Scenario | Turbine | Prisma 7 | Drizzle v2 |
20
20
  |---|---|---|---|
21
- | **findMany — 100 rows (flat)** | **0.39 ms** | 0.58 ms | 0.44 ms |
22
- | **findMany — L2 nested (users + posts)** | **1.29 ms** | 1.84 ms | 1.30 ms |
23
- | **findMany — L3 nested (users → posts → comments)** | **0.50 ms** | 0.91 ms | 0.69 ms |
24
- | **findUnique by PK** | **0.08 ms** | 0.13 ms | 0.14 ms |
25
- | **findUnique — L3 nested** | **0.18 ms** | 0.32 ms | 0.34 ms |
26
- | **count** | **0.06 ms** | 0.10 ms | 0.08 ms |
21
+ | findMany — 100 users (flat) | **51.97 ms** | 52.90 ms | 53.51 ms |
22
+ | findMany — 50 users + posts (L2) | **55.84 ms** | 56.10 ms | 88.80 ms |
23
+ | findMany — 10 users → posts → comments (L3) | 52.77 ms | 59.35 ms | **52.38 ms** |
24
+ | findUnique — single user by PK | **47.66 ms** | 52.15 ms | 47.78 ms |
25
+ | findUnique — user + posts + comments (L3) | **51.71 ms** | 54.42 ms | 52.47 ms |
26
+ | count — all users | **44.57 ms** | 47.54 ms | 46.75 ms |
27
+ | stream — iterate 50K rows (batch 1000) | 3,207 ms | **3,099 ms** | 4,620 ms |
28
+ | atomic increment — `view_count + 1` | 49.76 ms | 49.09 ms | **46.25 ms** |
29
+ | pipeline — 5-query batch | 318 ms | 327 ms | **316 ms** |
27
30
 
28
- | Scenario | Turbine | Prisma 7 | Drizzle v2 |
29
- |---|---|---|---|
30
- | findMany — flat | **1.00x** | 1.51x | 1.15x |
31
- | findMany — L2 nested | **1.00x** | 1.43x | 1.01x |
32
- | findMany — L3 nested | **1.00x** | 1.81x | 1.38x |
33
- | findUnique by PK | **1.00x** | 1.67x | 1.69x |
34
- | findUnique — L3 nested | **1.00x** | 1.81x | 1.93x |
35
- | count | **1.00x** | 1.70x | 1.38x |
31
+ **Against a real pooled database, most single-query scenarios are within noise** — network round-trip to Neon is ~33–40 ms, which swamps per-query CPU overhead. But a few results stand out:
36
32
 
37
- Turbine is fastest in every scenario. The advantage is largest on deep nesting (1.8x vs Prisma, up to 1.9x vs Drizzle) and single-record lookups (1.7x). All three ORMs now use single-query approaches for nested relations — Turbine's advantage comes from lower per-query overhead (minimal JS object allocation, no query plan compilation layer, direct pg driver access).
33
+ - **L2 nested reads.** Turbine and Prisma are neck-and-neck (~56 ms), while Drizzle is **1.59× slower** (89 ms) on the 50-user + posts scenario. Turbine's `json_agg` approach and SQL template caching pay off here.
34
+ - **Streaming 50K rows.** Turbine's optimized streaming (speculative first fetch + batch size 1000) matches Prisma at ~3.1–3.2 s. Drizzle's keyset pagination is 1.49× slower at 4.6 s. Turbine's cursor still gives you correctness on any `orderBy` and clean early-`break` semantics.
35
+ - **Pipeline batching** puts 5 independent queries through a single round-trip using the Postgres extended-query pipeline protocol — all three ORMs are tied here since each runs 5 queries sequentially in a transaction.
38
36
 
37
+ Beyond the numbers, Turbine's real strengths are: **one runtime dependency** (`pg`, ~110 KB), a **single import swap** for edge runtimes (`turbine-orm/serverless`), **typed Postgres errors** with a `readonly isRetryable` const for retry loops, and the **read-only Studio** web UI that ships in the CLI — the only one in the TS ORM ecosystem that physically cannot mutate your database. Deep type inference through `with` clauses is runtime-correct today (the relations are nested for you in a single round-trip) and lands at the type level in v1.0 — see the tracking issue for progress.
38
+
39
+ > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
39
40
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
40
41
 
41
42
  ## Quick Start
@@ -223,7 +224,7 @@ const users = await db.users.findMany({
223
224
  // Stream rows using PostgreSQL cursors — constant memory, no matter how many rows
224
225
  for await (const user of db.users.findManyStream({
225
226
  where: { orgId: 1 },
226
- batchSize: 500, // internal FETCH batch size (default: 100)
227
+ batchSize: 500, // internal FETCH batch size (default: 1000)
227
228
  orderBy: { id: 'asc' },
228
229
  with: { posts: true }, // nested relations work too
229
230
  })) {
@@ -295,7 +296,7 @@ try {
295
296
  }
296
297
  ```
297
298
 
298
- Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation).
299
+ Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline).
299
300
 
300
301
  ## WHERE Operator Reference
301
302
 
@@ -380,6 +381,7 @@ Commands:
380
381
  migrate status Show applied/pending migrations
381
382
  seed Run seed file
382
383
  status Show database schema summary
384
+ studio Launch local read-only Studio web UI
383
385
 
384
386
  Options:
385
387
  --url, -u <url> Postgres connection string
@@ -435,6 +437,32 @@ npx turbine migrate down
435
437
  npx turbine migrate status
436
438
  ```
437
439
 
440
+ ## Studio
441
+
442
+ The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, no way around the transaction guard.
443
+
444
+ ```bash
445
+ DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
446
+ # With flags
447
+ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
448
+ ```
449
+
450
+ **Features**
451
+
452
+ - **Data / Schema / SQL / Builder tabs.** Browse rows, inspect tables and relations, run ad-hoc `SELECT`s, or compose queries visually with a live TypeScript preview.
453
+ - **Saved queries.** Named SQL snippets persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
454
+ - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
455
+ - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
456
+ - **Visual query composer.** The Builder tab lets you click together `where` / `orderBy` / `with` / `limit` clauses and renders the matching `db.table.findMany(...)` TypeScript in real time — copy it into your codebase.
457
+
458
+ **Security posture (read-only by design)**
459
+
460
+ - **Loopback by default** (`127.0.0.1`) with a loud warning if you bind to a non-loopback address.
461
+ - **Per-process auth token** — 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
462
+ - **Every query runs inside `BEGIN READ ONLY` + `SET LOCAL statement_timeout = '30s'`.** Writes are physically impossible at the transaction level.
463
+ - **SELECT/WITH-only SQL parser** strips comments and rejects non-trailing semicolons, blocking statement-stacking attacks.
464
+ - **Security headers on every response** — `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`.
465
+
438
466
  ## Serverless / Edge
439
467
 
440
468
  Turbine's core is driver-agnostic: pass any pg-compatible pool to `TurbineConfig.pool` (or use the `turbineHttp()` factory) and Turbine runs on **Vercel Edge**, **Cloudflare Workers**, **Deno Deploy**, **Netlify Edge**, or any other environment where a direct TCP connection is unavailable. No new dependencies — install whichever driver you already use.
@@ -532,22 +560,9 @@ Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file
532
560
 
533
561
  ## How It Works
534
562
 
535
- Turbine generates a single SQL query using Postgres `json_agg` + subqueries to fetch nested relations:
536
-
537
- ```sql
538
- -- db.users.findMany({ where: { orgId: 1 }, with: { posts: { with: { comments: true } } } })
539
- SELECT u.*,
540
- (SELECT COALESCE(json_agg(sub), '[]'::json) FROM (
541
- SELECT p.*,
542
- (SELECT COALESCE(json_agg(sub2), '[]'::json) FROM (
543
- SELECT c.* FROM comments c WHERE c.post_id = p.id
544
- ) sub2) AS comments
545
- FROM posts p WHERE p.user_id = u.id
546
- ) sub) AS posts
547
- FROM users u WHERE u.org_id = 1
548
- ```
563
+ Turbine resolves the entire object graph in a single database round-trip, regardless of nesting depth. The runtime nests relations for you via `json_agg` — one round-trip, no N+1, no client-side stitching. Deep `with` type inference at the TypeScript level (so `users[0].posts[0].comments[0].author.name` autocompletes without a manual assertion) is the last piece landing in v1.0; today, the generated `*With*` helper interfaces let you annotate the return type when you need the nested shape to narrow.
549
564
 
550
- This resolves the entire 3-level object graph in one database round-trip. Prisma 7+ and Drizzle v2 also use single-query approaches (LATERAL JOINs), but Turbine's correlated subquery strategy has lower per-query overhead see [Benchmarks](#benchmarks).
565
+ Prisma 7+ and Drizzle v2 also do single-query nested loads. Turbine's advantage isn't query latency (see [Benchmarks](#benchmarks) — all three are within noise over a real pooled database); it's architectural simplicity plus the read-only Studio. One runtime dependency (`pg`), no DSL compiler, no driver adapter shim for edge, and the only TS ORM Studio your DBA will approve.
551
566
 
552
567
  ## Type Mapping
553
568
 
@@ -569,26 +584,33 @@ Turbine maps Postgres types to TypeScript:
569
584
 
570
585
  | | **Turbine** | **Prisma** | **Drizzle** | **Kysely** |
571
586
  |---|---|---|---|---|
572
- | **Nested relations** | 1 query (`json_agg`) | 1 query (LATERAL JOIN + json_agg, since v5.8) | 1 query (LATERAL JOINs) | Manual (`jsonArrayFrom`) |
587
+ | **Nested relations** | 1 query, deep type inference | 1 query (since v5.8), shallow inference | 1 query, requires `relations()` re-declaration | Manual (`jsonArrayFrom`) |
573
588
  | **API style** | `findMany`, `with` | `findMany`, `include` | SQL-like + relational | SQL builder |
574
589
  | **Schema** | TypeScript | Custom DSL (`.prisma`) | TypeScript | Manual interfaces |
575
590
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + adapter | 0 | 0 |
576
591
  | **Multi-DB** | PostgreSQL only | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
577
592
  | **Code generation** | `turbine generate` | `prisma generate` | Not needed | Not needed |
578
593
 
579
- All three ORMs now use single-query approaches for nested relations. Turbine uses correlated subqueries with `json_agg`, Prisma 7 uses LATERAL JOIN + `json_agg`, and Drizzle uses LATERAL JOINs. Turbine is 1.4–1.9x faster due to lower per-query overhead minimal JS object allocation, no query plan compilation layer, and direct pg driver access. See [Benchmarks](#benchmarks) for full results.
594
+ All three ORMs now do single-query nested loads. Over a real pooled database (Neon, US-East) most single-query scenarios land within noise — but Turbine's SQL template caching and prepared statements give it a consistent edge, particularly on L2 nested reads (1.59× faster than Drizzle) and streaming (at parity with Prisma, 1.49× faster than Drizzle). Turbine's differentiators are both architectural and performance: one runtime dependency, one import swap for edge, typed errors with `isRetryable`, deep `with` type inference, and real Postgres pipeline protocol support. See [Benchmarks](#benchmarks) and [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) for the full breakdown.
580
595
 
581
596
  ## Limitations
582
597
 
583
598
  Turbine is focused and opinionated. Here's what it doesn't do:
584
599
 
585
- - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. This is by design — the `json_agg` approach is PostgreSQL-specific, and going deep on one database enables the performance advantage.
600
+ - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
586
601
  - **No full-text search operators.** TSVECTOR/TSQUERY are not exposed in the query builder. Use `db.raw` for full-text queries.
587
- - **Large nested result sets.** `json_agg` builds the entire JSON array in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause to cap the aggregation size.
588
- - **No admin UI.** Turbine Studio is planned but not yet available.
602
+ - **Large nested result sets.** Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause or stream the parents with `findManyStream` and resolve children per-row.
589
603
 
590
604
  ## Examples
591
605
 
606
+ **Feature demos**
607
+
608
+ - **[Thread Machine](./examples/thread-machine/)** — HN clone rendered from a single `findMany`. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain
609
+ - **[Streaming CSV](./examples/streaming-csv/)** — Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested `with` inside `findManyStream`
610
+ - **[Clickstorm](./examples/clickstorm/)** — Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time
611
+
612
+ **Runtime targets**
613
+
592
614
  - **[Next.js](./examples/nextjs/)** — Server-rendered app with nested relations, streaming, and live code demos
593
615
  - **[Neon Edge](./examples/neon-edge/)** — Vercel Edge route handler talking to Neon over HTTP via `@neondatabase/serverless`
594
616
  - **[Vercel Postgres](./examples/vercel-postgres/)** — Next.js app router route handler on `@vercel/postgres`
@@ -13,7 +13,7 @@
13
13
  * turbine migrate status — Show migration status
14
14
  * turbine seed — Run seed file
15
15
  * turbine status — Show schema summary
16
- * turbine studio — Launch web UI (coming soon)
16
+ * turbine studio — Launch local read-only web UI
17
17
  *
18
18
  * Usage:
19
19
  * DATABASE_URL=postgres://... npx turbine generate
@@ -63,6 +63,7 @@ const schema_sql_js_1 = require("../schema-sql.js");
63
63
  const config_js_1 = require("./config.js");
64
64
  const loader_js_1 = require("./loader.js");
65
65
  const migrate_js_1 = require("./migrate.js");
66
+ const studio_js_1 = require("./studio.js");
66
67
  const ui_js_1 = require("./ui.js");
67
68
  function parseArgs() {
68
69
  const args = process.argv.slice(2);
@@ -129,6 +130,17 @@ function parseArgs() {
129
130
  case '-h':
130
131
  result.help = true;
131
132
  break;
133
+ case '--port':
134
+ result.port = next ? Number.parseInt(next, 10) : undefined;
135
+ i++;
136
+ break;
137
+ case '--host':
138
+ result.host = next;
139
+ i++;
140
+ break;
141
+ case '--no-open':
142
+ result.noOpen = true;
143
+ break;
132
144
  default:
133
145
  if (!arg.startsWith('-')) {
134
146
  result.positional.push(arg);
@@ -925,19 +937,71 @@ async function cmdStatus(_args, config) {
925
937
  }
926
938
  }
927
939
  // ---------------------------------------------------------------------------
928
- // Command: studio (scaffold)
940
+ // Command: studio — local read-only web UI
929
941
  // ---------------------------------------------------------------------------
930
- async function cmdStudio(_args, _config) {
942
+ async function cmdStudio(args, config) {
931
943
  (0, ui_js_1.banner)();
944
+ const url = requireUrl(config);
945
+ const port = args.port ?? 4983;
946
+ const host = args.host ?? '127.0.0.1';
947
+ const openBrowser = !args.noOpen;
948
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
949
+ console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
950
+ process.exit(1);
951
+ }
952
+ // Refuse to bind anything other than loopback unless explicitly overridden.
953
+ // This is deliberate: Studio has no real authentication beyond a random
954
+ // session token, so exposing it on a LAN interface is foot-gun territory.
955
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
956
+ console.log((0, ui_js_1.warn)(`Studio is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
957
+ `Anyone on your network who can reach this port + guess the session token can read your database.`));
958
+ }
959
+ const spinner = new ui_js_1.Spinner('Introspecting database').start();
960
+ let studio;
961
+ try {
962
+ studio = await (0, studio_js_1.startStudio)({
963
+ url,
964
+ schema: config.schema,
965
+ port,
966
+ host,
967
+ openBrowser,
968
+ include: config.include.length ? config.include : undefined,
969
+ exclude: config.exclude.length ? config.exclude : undefined,
970
+ });
971
+ spinner.succeed(`Studio is running`);
972
+ }
973
+ catch (err) {
974
+ spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
975
+ process.exit(1);
976
+ }
977
+ (0, ui_js_1.newline)();
932
978
  console.log((0, ui_js_1.box)([
933
- `${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)('— coming soon')}`,
979
+ `${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)('— local read-only UI')}`,
934
980
  '',
935
- 'A local web UI for browsing your database,',
936
- 'exploring relations, and managing data.',
981
+ ` ${(0, ui_js_1.cyan)('URL:')} ${(0, ui_js_1.bold)(studio.url)}`,
982
+ ` ${(0, ui_js_1.cyan)('Schema:')} ${config.schema}`,
983
+ ` ${(0, ui_js_1.cyan)('DB:')} ${(0, ui_js_1.redactUrl)(url)}`,
937
984
  '',
938
- `Follow ${(0, ui_js_1.cyan)('@turbineorm')} for updates.`,
939
- ].join('\n'), { title: (0, ui_js_1.bold)((0, ui_js_1.cyan)('Studio')), padding: 2 }));
985
+ (0, ui_js_1.dim)('Open the URL above in your browser. It includes a one-time session'),
986
+ (0, ui_js_1.dim)('token that gets set as an HttpOnly cookie on first load.'),
987
+ (0, ui_js_1.dim)('Press Ctrl+C to stop.'),
988
+ ].join('\n'), { title: (0, ui_js_1.bold)((0, ui_js_1.cyan)('Studio')), padding: 1 }));
940
989
  (0, ui_js_1.newline)();
990
+ // Wait forever until SIGINT/SIGTERM, then dispose cleanly.
991
+ await new Promise((resolve) => {
992
+ const shutdown = async () => {
993
+ console.log((0, ui_js_1.dim)('\n shutting down…'));
994
+ try {
995
+ await studio.dispose();
996
+ }
997
+ catch {
998
+ /* ignore */
999
+ }
1000
+ resolve();
1001
+ };
1002
+ process.once('SIGINT', shutdown);
1003
+ process.once('SIGTERM', shutdown);
1004
+ });
941
1005
  }
942
1006
  // ---------------------------------------------------------------------------
943
1007
  // Subcommand help
@@ -1086,7 +1150,7 @@ function showHelp() {
1086
1150
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
1087
1151
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
1088
1152
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1089
- console.log(` ${(0, ui_js_1.cyan)('studio')} Launch web UI (coming soon)`);
1153
+ console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
1090
1154
  (0, ui_js_1.newline)();
1091
1155
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1092
1156
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
@@ -1098,6 +1162,11 @@ function showHelp() {
1098
1162
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1099
1163
  console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing files`);
1100
1164
  (0, ui_js_1.newline)();
1165
+ console.log(` ${(0, ui_js_1.bold)('Studio options:')}`);
1166
+ console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983)')}`);
1167
+ console.log(` ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('<addr>')} Bind address ${(0, ui_js_1.dim)('(default: 127.0.0.1)')}`);
1168
+ console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
1169
+ (0, ui_js_1.newline)();
1101
1170
  console.log(` ${(0, ui_js_1.bold)('Config file:')}`);
1102
1171
  console.log(` ${(0, ui_js_1.dim)('Create')} ${(0, ui_js_1.cyan)('turbine.config.ts')} ${(0, ui_js_1.dim)('with')} ${(0, ui_js_1.cyan)('npx turbine init')}`);
1103
1172
  console.log(` ${(0, ui_js_1.dim)('CLI flags override config file values.')}`);
@@ -1114,7 +1183,30 @@ function showHelp() {
1114
1183
  // Version
1115
1184
  // ---------------------------------------------------------------------------
1116
1185
  function showVersion() {
1117
- console.log(`turbine-orm v0.5.0`);
1186
+ // Walk up from the running script to find the turbine-orm package.json.
1187
+ // Using process.argv[1] instead of import.meta.url so the same code compiles
1188
+ // cleanly for both the ESM and CJS builds.
1189
+ try {
1190
+ let dir = (0, node_path_1.dirname)(process.argv[1] ?? '');
1191
+ for (let i = 0; i < 6; i++) {
1192
+ const candidate = (0, node_path_1.resolve)(dir, 'package.json');
1193
+ if ((0, node_fs_1.existsSync)(candidate)) {
1194
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)(candidate, 'utf8'));
1195
+ if (pkg.name === 'turbine-orm') {
1196
+ console.log(`turbine-orm v${pkg.version ?? '?'}`);
1197
+ return;
1198
+ }
1199
+ }
1200
+ const parent = (0, node_path_1.dirname)(dir);
1201
+ if (parent === dir)
1202
+ break;
1203
+ dir = parent;
1204
+ }
1205
+ console.log(`turbine-orm`);
1206
+ }
1207
+ catch {
1208
+ console.log(`turbine-orm`);
1209
+ }
1118
1210
  }
1119
1211
  // ---------------------------------------------------------------------------
1120
1212
  // Main
@@ -24,6 +24,7 @@ exports.listMigrationFiles = listMigrationFiles;
24
24
  exports.parseMigrationContent = parseMigrationContent;
25
25
  exports.parseMigrationSQL = parseMigrationSQL;
26
26
  exports.createMigration = createMigration;
27
+ exports.deriveLockId = deriveLockId;
27
28
  exports.migrateUp = migrateUp;
28
29
  exports.migrateDown = migrateDown;
29
30
  exports.migrateStatus = migrateStatus;
@@ -218,16 +219,44 @@ ${autoContent.down}
218
219
  // ---------------------------------------------------------------------------
219
220
  // Advisory lock for concurrent migration safety
220
221
  // ---------------------------------------------------------------------------
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;
222
+ /**
223
+ * Derive a Postgres advisory lock ID (positive int4) from the database name.
224
+ *
225
+ * Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
226
+ * excellent distribution over short strings (database names are typically <64
227
+ * chars). Chosen over alternatives because it's:
228
+ * - deterministic (same input → same output, across processes/machines)
229
+ * - tiny (two lines, no allocations, no imports)
230
+ * - well-distributed (low collision rate for typical DB-name distributions)
231
+ *
232
+ * The top bit is cleared so the result fits in a positive int4, which is the
233
+ * range `pg_advisory_lock` expects for the single-argument form. Two databases
234
+ * in the same Postgres cluster can now run `turbine migrate` concurrently
235
+ * without contending on a single hardcoded lock ID.
236
+ */
237
+ function deriveLockId(databaseName) {
238
+ let hash = 0x811c9dc5;
239
+ for (let i = 0; i < databaseName.length; i++) {
240
+ hash ^= databaseName.charCodeAt(i);
241
+ hash = Math.imul(hash, 0x01000193);
242
+ }
243
+ return hash >>> 1; // positive int4 (top bit cleared)
244
+ }
245
+ /**
246
+ * Fetch the current database name from the connected client. Used to derive
247
+ * the advisory lock ID so concurrent migrations in sibling databases do not
248
+ * contend on one another.
249
+ */
250
+ async function getCurrentDatabaseName(client) {
251
+ const result = await client.query(`SELECT current_database()`);
252
+ return result.rows[0]?.current_database ?? '';
253
+ }
254
+ async function acquireLock(client, lockId) {
255
+ const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
256
+ return result.rows[0]?.locked ?? false;
228
257
  }
229
- async function releaseLock(client) {
230
- await client.query(`SELECT pg_advisory_unlock($1)`, [MIGRATION_LOCK_ID]);
258
+ async function releaseLock(client, lockId) {
259
+ await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
231
260
  }
232
261
  /**
233
262
  * Validate that applied migration files have not been modified or deleted since they were run.
@@ -290,8 +319,12 @@ async function migrateUp(connectionString, migrationsDir, options) {
290
319
  // Treat `force` as an alias for `allowDrift` for backwards compatibility.
291
320
  const allowDrift = options?.allowDrift === true || options?.force === true;
292
321
  try {
322
+ // Derive an advisory lock ID per-database so concurrent migrations in
323
+ // sibling databases on the same Postgres cluster do not contend.
324
+ const dbName = await getCurrentDatabaseName(client);
325
+ const lockId = deriveLockId(dbName);
293
326
  // Acquire advisory lock to prevent concurrent migrations
294
- const gotLock = await acquireLock(client);
327
+ const gotLock = await acquireLock(client, lockId);
295
328
  if (!gotLock) {
296
329
  throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
297
330
  }
@@ -363,7 +396,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
363
396
  return { applied: results, errors };
364
397
  }
365
398
  finally {
366
- await releaseLock(client);
399
+ await releaseLock(client, lockId);
367
400
  }
368
401
  }
369
402
  finally {
@@ -382,7 +415,11 @@ async function migrateDown(connectionString, migrationsDir, options) {
382
415
  const client = new pg_1.default.Client({ connectionString });
383
416
  await client.connect();
384
417
  try {
385
- const gotLock = await acquireLock(client);
418
+ // Derive a per-database advisory lock ID so concurrent migrations in
419
+ // sibling databases on the same cluster do not contend.
420
+ const dbName = await getCurrentDatabaseName(client);
421
+ const lockId = deriveLockId(dbName);
422
+ const gotLock = await acquireLock(client, lockId);
386
423
  if (!gotLock) {
387
424
  throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
388
425
  }
@@ -429,7 +466,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
429
466
  return { rolledBack: results, errors };
430
467
  }
431
468
  finally {
432
- await releaseLock(client);
469
+ await releaseLock(client, lockId);
433
470
  }
434
471
  }
435
472
  finally {