turbine-orm 0.8.0 → 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,7 +8,7 @@ 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: **deep typed `with` inference** (`users[0].posts[0].comments[0].author` autocompletes after one `findMany`), **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.
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
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
 
@@ -34,7 +34,7 @@ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizz
34
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
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.
36
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 **inferred `with` result types**`users[0].posts[0].comments[0].author.name` autocompletes from a single `findMany` with no manual assertion.
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
38
 
39
39
  > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
40
40
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
@@ -381,6 +381,7 @@ Commands:
381
381
  migrate status Show applied/pending migrations
382
382
  seed Run seed file
383
383
  status Show database schema summary
384
+ studio Launch local read-only Studio web UI
384
385
 
385
386
  Options:
386
387
  --url, -u <url> Postgres connection string
@@ -436,6 +437,32 @@ npx turbine migrate down
436
437
  npx turbine migrate status
437
438
  ```
438
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
+
439
466
  ## Serverless / Edge
440
467
 
441
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.
@@ -533,9 +560,9 @@ Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file
533
560
 
534
561
  ## How It Works
535
562
 
536
- Turbine resolves the entire object graph in a single database round-trip, regardless of nesting depth. The `with` clause is fully type-inferred end-to-end `users[0].posts[0].comments[0].author.name` autocompletes from a single `findMany` call, with no manual type assertions.
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.
537
564
 
538
- 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. One runtime dependency (`pg`), no DSL compiler, no driver adapter shim for edge, and deep `with` type inference without verbose helper types.
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.
539
566
 
540
567
  ## Type Mapping
541
568
 
@@ -573,7 +600,6 @@ Turbine is focused and opinionated. Here's what it doesn't do:
573
600
  - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
574
601
  - **No full-text search operators.** TSVECTOR/TSQUERY are not exposed in the query builder. Use `db.raw` for full-text queries.
575
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.
576
- - **No admin UI.** Turbine Studio is planned but not yet available.
577
603
 
578
604
  ## Examples
579
605
 
@@ -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 {