turbine-orm 0.91.0 → 0.92.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
@@ -21,39 +21,38 @@ Six reasons, each with the mechanism that makes it true:
21
21
  1. **One dependency.** `dependencies` is `{ "pg": "^8.15.0" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB, all tiered Experimental in [STABILITY.md](STABILITY.md)) are peer dependencies or Node builtins you install only if you use them.
22
22
  2. **Written from scratch.** Turbine is not a layer over Knex or a query-builder library. Query compilation is plain string building with an FNV-1a shape fingerprint into a bounded LRU of SQL templates, so there is no plan cache to size and no compiler running on your event loop.
23
23
  3. **Nested relations in one statement.** A `with` clause compiles to correlated `json_agg` subqueries, so users with posts with comments is one round trip, typed end to end: `users[0].posts[0].comments[0].author.name` autocompletes with no annotation.
24
- 4. **Close to raw SQL.** In the last published run, Turbine's overhead over a hand-written `pg` control was 1.08x by geometric mean. The table is below; the losses are stated with the wins.
24
+ 4. **Faster than Drizzle and Prisma.** In the 0.91.0 run Drizzle took 1.4x as long as Turbine and Prisma 2.0x, by geometric mean, on a dedicated benchmark machine. The table is below; the losses are stated with the wins.
25
25
  5. **Agents get a skill and typed tools, not a SQL prompt.** `npx turbine skill` installs a query-writing skill whose every claim is executed against a live database before release; `npx turbine mcp` exposes eleven read-only MCP tools, including a relation graph and a join-path finder that returns the `with` clause to write. Every tool runs inside `BEGIN READ ONLY`, and PII-tagged columns are redacted before rows reach a model.
26
26
  6. **The dangerous operations ask first.** Destructive migration statements refuse to run without typed consent. `update`/`delete` with an empty `where` throws. Columns tagged `pii: true` are excluded from the emitted SQL's projections. `turbine doctor` reports missing FK indexes offline, no account, no telemetry.
27
27
 
28
28
  ## Benchmarks
29
29
 
30
- Measured **2026-08-15 against turbine-orm 0.71.0**, versus **Prisma 7.9.1** (`@prisma/adapter-pg`, `relationJoins` on) and **Drizzle 0.45.2** (relational queries), on local **PostgreSQL 17.9** over a Unix socket, with a hand-written `pg` control arm. Node v24.18.0, Apple Silicon (M5 Max). Same schema, same data (1K users, 10K posts, 50K comments), same pool config. Each figure is the median over 200 rounds with arm order rotated per round, taken as the median of three full runs.
30
+ Measured **2026-10-15 against turbine-orm 0.91.0**, versus **Drizzle 0.45.3** (relational queries) and **Prisma 7.10.0** (`@prisma/adapter-pg`, `relationJoins` on), with a hand-written `pg` control arm. Run by the repository's [`benchmarks.yml`](.github/workflows/benchmarks.yml) workflow on a dedicated machine: AMD Ryzen 9 7950X host, 4-vCPU 16 GB Firecracker VM, client and PostgreSQL 17 (Docker) on the same VM over a Unix socket. PostgreSQL 17.11, Node v24.18.0, `pg` 8.23.1 under every arm. Same schema, same data (1K users, 10K posts, 50K comments), same pool config. Each figure is the median over 200 rounds with arm order rotated per round, taken as the median of 3 full runs.
31
31
 
32
- | Scenario | Turbine 0.71 | Prisma 7.9 | Drizzle 0.45 | raw pg |
32
+ | Scenario | Turbine 0.91.0 | Drizzle 0.45.3 | Prisma 7.10.0 | raw pg |
33
33
  |---|---|---|---|---|
34
- | findMany, 100 users (flat) | **0.181 ms** | 0.241 ms | 0.242 ms | 0.164 ms |
35
- | findMany, 50 users + posts (L2) *(contested)* | **1.670 ms** | 4.302 ms | 1.711 ms | 1.847 ms |
36
- | findMany, 10 users → posts → comments (L3) *(contested)* | **1.082 ms** | 4.204 ms | 1.166 ms | n/a |
37
- | findUnique, single user by PK | **0.035 ms** | 0.078 ms | 0.080 ms | 0.036 ms |
38
- | findUnique, user + posts + comments (L3) | **0.146 ms** | 0.374 ms | 0.285 ms | n/a |
39
- | count, all users | **0.038 ms** | 0.070 ms | 0.053 ms | 0.041 ms |
40
- | stream, iterate 50K rows (batch 1000) | 50.39 ms | 54.81 ms | **39.49 ms** | 42.33 ms |
41
- | atomic increment, `view_count + 1` *(contested)* | **0.067 ms** | 0.106 ms | 0.073 ms | 0.055 ms |
42
- | pipeline, 5-query batch | **0.165 ms** | 0.338 ms | 0.341 ms | 0.177 ms |
43
- | hot findUnique, 500x same shape | **0.026 ms** | 0.059 ms | 0.069 ms | 0.030 ms |
34
+ | findMany, 100 users | **0.204 ms** | 0.307 ms | 0.349 ms | 0.198 ms |
35
+ | findMany, 50 users + posts *(tie)* | 1.470 ms | 1.488 ms | 2.930 ms | 0.791 ms |
36
+ | findMany, 10 users + posts + comments | **0.795 ms** | 0.899 ms | 2.303 ms | n/a |
37
+ | findUnique by primary key | **0.090 ms** | 0.151 ms | 0.179 ms | 0.074 ms |
38
+ | findUnique, user + posts + comments | **0.278 ms** | 0.467 ms | 0.599 ms | n/a |
39
+ | count, all users | **0.080 ms** | 0.115 ms | 0.170 ms | 0.077 ms |
40
+ | stream 50,000 comments, per row *(tie)* | 55.27 ms | 57.33 ms | 85.99 ms | 56.38 ms |
41
+ | atomic increment | **0.175 ms** | 0.185 ms | 0.274 ms | 0.143 ms |
42
+ | pipeline, 5-query batch | **0.333 ms** | 0.707 ms | 0.782 ms | 0.365 ms |
43
+ | hot findUnique, rotating ids | **0.078 ms** | 0.143 ms | 0.159 ms | 0.072 ms |
44
44
 
45
45
  What that run says:
46
46
 
47
- - **Turbine ran at 1.08x hand-written `pg`**, where Drizzle ran at 1.47x and Prisma at 1.81x (geometric mean over the eight scenarios with a raw control). That 1.08x is the conservative reading: the raw L2 control statement still uses the `json_build_object` encoding Turbine has moved off, which is worth 1.83x on its own, so the figure the harness records unadjusted is 1.00x. The adjusted one is published instead.
48
- - Across all ten scenarios Turbine was **2.02x faster than Prisma 7.9** and **1.46x faster than Drizzle 0.45** by geometric mean. Turbine took nine scenarios, Drizzle one, Prisma none. The one Drizzle took was streaming, and 0.72.0 takes it back (below).
49
- - **Streaming was Drizzle's one win, by 22%** (39.49 ms vs 50.39 ms) on 0.71.0. **0.72.0 closes it**: the remaining gap was temporal decoding rather than the cursor, and a direct scan for the canonical ISO wire shape (declining anything else back to `pg`'s own parser) takes `findManyStream` from 52.44 ms to **37.06 ms** against Drizzle's 42.09 ms re-measured in the same rotation, and `findManyStreamBatches` to **31.65 ms**. Scope, because it matters: that is a targeted re-measurement of one scenario on a paired one-process harness, not a re-run of the ten above, so the table's stream row still shows 0.71.0 and the absolute values are not comparable. Method and controls in [`benchmarks/RESULTS-0.72.0.md`](benchmarks/RESULTS-0.72.0.md).
50
- - **Pipelining is Turbine's clearest win**: one TCP flush for 5 queries runs 2.04x faster than Prisma and 2.07x faster than Drizzle, level with raw `pg`.
51
- - Three scenarios (L2, L3, atomic increment) are marked *contested*: the contiguous cross-check harness disagrees with itself across runs of the same configuration there, so neither side claims them. Both nested reads were losses in the previous run and the improvement behind them reproduces on both harnesses; whether it is enough to pass Drizzle is what is unsettled.
52
- - The sub-0.15 ms scenarios carry roughly one third uncertainty in their absolute values. Orderings held across runs; margins should not be quoted, and that includes L2's 2.5%.
47
+ - **Across all ten scenarios Drizzle took 1.40x as long as Turbine and Prisma 2.00x**, by geometric mean. Turbine won or tied every one against Drizzle (2 ties) and won all ten against Prisma.
48
+ - **Against hand-written `pg`, Turbine ran at 1.14x, Drizzle at 1.61x and Prisma at 2.17x** (geometric mean over the 8 scenarios with a control). Where a query returns nested rows, the control decodes them into objects with `Date` timestamps as the ORMs do. The 0.71.0 figure of 1.08x was adjusted by hand against a control that did more work than Turbine; this one is not adjusted.
49
+ - **Pipelining is the clearest win over a network**: five dashboard queries 25 ms from the database take 25 ms as one batch against 171 ms as a transaction.
50
+ - **Where Turbine loses:** deep `OFFSET` pagination unless `forceCustomPlan` is set, and per-row streaming against the Drizzle 1.0 release candidate (`findManyStreamBatches` ties it).
51
+ - Figures under 0.15 ms carry roughly one third uncertainty in their absolute values. Orderings held across runs; those margins should not be quoted.
53
52
 
54
- Full method, the drift-floor measurement, and the harness-disagreement table: [`benchmarks/RESULTS-0.71.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.71.0.md).
53
+ Full method, the extra scenarios, streaming, latency and the losses: [`benchmarks/RESULTS-0.91.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.91.0.md), or the charts at [turbineorm.dev/benchmarks](https://turbineorm.dev/benchmarks).
55
54
 
56
- Reproduce it: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench-interleaved.ts`
55
+ Reproduce it on a fresh machine with `gh workflow run benchmarks.yml`, or locally with `benchmarks/run-round.sh` (see [`benchmarks/README.md`](benchmarks/README.md)).
57
56
 
58
57
  ## Quick Start
59
58
 
@@ -272,7 +271,7 @@ Going deep on one database means the parts other ORMs push to raw SQL are typed
272
271
 
273
272
  ## Serverless and edge
274
273
 
275
- The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **148 kB brotli** (edge entry under **116 kB**) with `pg` external, enforced by `size-limit` in CI at those exact numbers; run `npm run size` for the current figure.
274
+ The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **149 kB brotli** (edge entry under **117 kB**) with `pg` external, enforced by `size-limit` in CI at those exact numbers; run `npm run size` for the current figure.
276
275
 
277
276
  ```typescript
278
277
  import { Pool } from '@neondatabase/serverless';
@@ -359,11 +358,11 @@ It is also built to be extended rather than wrapped. All SQL generation routes t
359
358
  |---|---|---|---|---|---|
360
359
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine | Kysely as query runner; SQL built by MikroORM (v7) |
361
360
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 | `@mikro-orm/core` (0 deps) + a driver package |
362
- | **Main bundle (brotli)** | under 148 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small | |
361
+ | **Main bundle (brotli)** | under 149 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small | |
363
362
  | **Studio** | Read-only by default | Full CRUD, cloud-hosted | Full CRUD; [Gateway](https://gateway.drizzle.team/) self-hosted, free | None | AdminJS integration guide (CRUD panel) |
364
363
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors | |
365
364
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None | Generated by diffing entities against a snapshot or the database; TS/JS classes |
366
- | **Edge runtime** | One import swap, under 116 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native | Cloudflare Workers and Deno (v7) |
365
+ | **Edge runtime** | One import swap, under 117 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native | Cloudflare Workers and Deno (v7) |
367
366
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual | `flush()` runs queued changes in one transaction |
368
367
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None | `DriverException` subclasses (`UniqueConstraintViolationException`, ...) |
369
368
  | **Nested relations** | 1 query, deep type inference | 1 query per relation by default; single-query `relationJoins` is Preview | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) | `populate`; default `balanced`: joins to-one, one query per to-many relation |
@@ -189,6 +189,11 @@ export interface ResolvedConfig {
189
189
  relationNames?: Record<string, Record<string, string>>;
190
190
  /** Column type overrides for the generator, or undefined when none is declared. */
191
191
  columnTypes?: Record<string, string>;
192
+ /**
193
+ * The config file's `adapter`, passed to `migrate up` / `deploy` / `down` so a
194
+ * CockroachDB or YugabyteDB project takes the migration lock its engine supports.
195
+ */
196
+ adapter?: import('../adapters/index.js').DatabaseAdapter;
192
197
  }
193
198
  export interface CliOverrides {
194
199
  url?: string;
@@ -231,6 +231,7 @@ function resolveConfig(fileConfig, overrides) {
231
231
  importExtension: overrides.importExtension ?? fileConfig.importExtension ?? 'auto',
232
232
  keepColumnNames: overrides.keepColumnNames ?? fileConfig.keepColumnNames ?? false,
233
233
  legacyToManyUniques: overrides.legacyToManyUniques ?? fileConfig.legacyToManyUniques ?? false,
234
+ adapter: fileConfig.adapter,
234
235
  };
235
236
  }
236
237
  /**
@@ -2,23 +2,23 @@
2
2
  /**
3
3
  * turbine-orm CLI
4
4
  *
5
- * Commands:
6
- * turbine init , Initialize a Turbine project
7
- * turbine generate | pull , Introspect database and generate TypeScript types
8
- * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
- * turbine push - Apply schema-builder definitions to database (--schema-file, destructive ops gated)
10
- * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
11
- * turbine migrate up , Apply pending migrations
12
- * turbine migrate deploy , Apply pending migrations without prompts
13
- * turbine migrate down , Rollback last migration
14
- * turbine migrate status , Show migration status
15
- * turbine seed , Run seed file
16
- * turbine status , Show schema summary
17
- * turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence, --allow-pooler)
18
- * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
19
- * turbine mcp , Start read-only MCP server over JSON-RPC stdio
20
- * turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
21
- * turbine skill - Install the agent query skill (--print, --agents, --dir <path>)
5
+ * Commands (each has its own `--help`, which is the full flag list):
6
+ * turbine init Initialize a Turbine project
7
+ * turbine generate | pull Introspect the database and generate TypeScript types
8
+ * turbine migrate-from-prisma Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
+ * turbine push Apply defineSchema() definitions to the database (destructive ops gated)
10
+ * turbine migrate create <name> Create a SQL migration file (--auto | --from-diff | --recipe <name>)
11
+ * turbine migrate up Apply pending migrations
12
+ * turbine migrate deploy Apply pending migrations without prompts
13
+ * turbine migrate down Roll back the last migration
14
+ * turbine migrate status Show migration status
15
+ * turbine seed Run the seed file
16
+ * turbine status Show a schema summary
17
+ * turbine doctor Index + cached-plan triage
18
+ * turbine studio Launch the local read-only web UI (--demo for a seeded sample DB)
19
+ * turbine mcp Start the read-only MCP server over JSON-RPC stdio
20
+ * turbine observe Launch the metrics dashboard (--metrics-url or TURBINE_OBSERVE_URL)
21
+ * turbine skill Install the agent query skill
22
22
  *
23
23
  * Usage:
24
24
  * DATABASE_URL=postgres://... npx turbine generate
@@ -3,23 +3,23 @@
3
3
  /**
4
4
  * turbine-orm CLI
5
5
  *
6
- * Commands:
7
- * turbine init , Initialize a Turbine project
8
- * turbine generate | pull , Introspect database and generate TypeScript types
9
- * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
10
- * turbine push - Apply schema-builder definitions to database (--schema-file, destructive ops gated)
11
- * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
12
- * turbine migrate up , Apply pending migrations
13
- * turbine migrate deploy , Apply pending migrations without prompts
14
- * turbine migrate down , Rollback last migration
15
- * turbine migrate status , Show migration status
16
- * turbine seed , Run seed file
17
- * turbine status , Show schema summary
18
- * turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence, --allow-pooler)
19
- * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
20
- * turbine mcp , Start read-only MCP server over JSON-RPC stdio
21
- * turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
22
- * turbine skill - Install the agent query skill (--print, --agents, --dir <path>)
6
+ * Commands (each has its own `--help`, which is the full flag list):
7
+ * turbine init Initialize a Turbine project
8
+ * turbine generate | pull Introspect the database and generate TypeScript types
9
+ * turbine migrate-from-prisma Parse a schema.prisma and emit a Prisma->Turbine name map + report
10
+ * turbine push Apply defineSchema() definitions to the database (destructive ops gated)
11
+ * turbine migrate create <name> Create a SQL migration file (--auto | --from-diff | --recipe <name>)
12
+ * turbine migrate up Apply pending migrations
13
+ * turbine migrate deploy Apply pending migrations without prompts
14
+ * turbine migrate down Roll back the last migration
15
+ * turbine migrate status Show migration status
16
+ * turbine seed Run the seed file
17
+ * turbine status Show a schema summary
18
+ * turbine doctor Index + cached-plan triage
19
+ * turbine studio Launch the local read-only web UI (--demo for a seeded sample DB)
20
+ * turbine mcp Start the read-only MCP server over JSON-RPC stdio
21
+ * turbine observe Launch the metrics dashboard (--metrics-url or TURBINE_OBSERVE_URL)
22
+ * turbine skill Install the agent query skill
23
23
  *
24
24
  * Usage:
25
25
  * DATABASE_URL=postgres://... npx turbine generate
@@ -2973,6 +2973,7 @@ async function cmdMigrateUp(args, config) {
2973
2973
  allowDestructive: args.allowDestructive,
2974
2974
  onNoTransaction,
2975
2975
  schema: config.schema,
2976
+ adapter: config.adapter,
2976
2977
  });
2977
2978
  }
2978
2979
  catch (err) {
@@ -2991,6 +2992,7 @@ async function cmdMigrateUp(args, config) {
2991
2992
  allowDestructive: true,
2992
2993
  onNoTransaction,
2993
2994
  schema: config.schema,
2995
+ adapter: config.adapter,
2994
2996
  });
2995
2997
  }
2996
2998
  if (result.applied.length > 0) {
@@ -3153,6 +3155,7 @@ async function cmdMigrateDeploy(args, config) {
3153
3155
  const result = await (0, migrate_js_1.migrateDeploy)(url, config.migrationsDir, {
3154
3156
  allowDrift: args.allowDrift,
3155
3157
  schema: config.schema,
3158
+ adapter: config.adapter,
3156
3159
  lockWaitMs: args.wait !== undefined ? args.wait * 1000 : undefined,
3157
3160
  onLockWait: () => {
3158
3161
  runSpinner.stop();
@@ -3264,6 +3267,7 @@ async function cmdMigrateDown(args, config) {
3264
3267
  allowDestructive: args.allowDestructive,
3265
3268
  allowDrift: args.allowDrift,
3266
3269
  schema: config.schema,
3270
+ adapter: config.adapter,
3267
3271
  });
3268
3272
  }
3269
3273
  catch (err) {
@@ -3281,6 +3285,7 @@ async function cmdMigrateDown(args, config) {
3281
3285
  allowDestructive: true,
3282
3286
  allowDrift: args.allowDrift,
3283
3287
  schema: config.schema,
3288
+ adapter: config.adapter,
3284
3289
  });
3285
3290
  }
3286
3291
  if (result.rolledBack.length > 0) {
@@ -4636,12 +4641,12 @@ async function cmdMcp(_args, config) {
4636
4641
  // ---------------------------------------------------------------------------
4637
4642
  async function cmdObserve(args) {
4638
4643
  (0, ui_js_1.banner)();
4639
- const url = process.env.TURBINE_OBSERVE_URL;
4644
+ const url = (0, config_js_1.firstNonEmpty)(args.metricsUrl, process.env.TURBINE_OBSERVE_URL);
4640
4645
  if (!url) {
4641
- (0, ui_js_1.error)('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
4646
+ (0, ui_js_1.error)('The observe command needs the metrics database: pass --metrics-url or set TURBINE_OBSERVE_URL.');
4642
4647
  (0, ui_js_1.errorLine)();
4643
- (0, ui_js_1.errorLine)(`${(0, ui_js_1.dim)('Set it to the Postgres connection string where metrics are stored.')}`);
4644
- (0, ui_js_1.errorLine)(`${(0, ui_js_1.dim)('Example:')} ${(0, ui_js_1.cyan)('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
4648
+ (0, ui_js_1.errorLine)(`${(0, ui_js_1.dim)('Point it at the Postgres database where metrics are stored.')}`);
4649
+ (0, ui_js_1.errorLine)(`${(0, ui_js_1.dim)('Example:')} ${(0, ui_js_1.cyan)('npx turbine observe --metrics-url postgres://...')}`);
4645
4650
  (0, ui_js_1.errorLine)();
4646
4651
  process.exit(1);
4647
4652
  }
@@ -4957,7 +4962,7 @@ function showDoctorHelp() {
4957
4962
  console.log(` ${(0, ui_js_1.cyan)('--allow-pooler')} Run against a connection pooler anyway ${(0, ui_js_1.dim)('(refused by default)')}`);
4958
4963
  (0, ui_js_1.newline)();
4959
4964
  console.log(` ${(0, ui_js_1.dim)('doctor refuses a transaction-pooling endpoint (a')} ${(0, ui_js_1.cyan)('-pooler')} ${(0, ui_js_1.dim)('or')} ${(0, ui_js_1.cyan)('pgbouncer')}`);
4960
- console.log(` ${(0, ui_js_1.dim)('host, or port 6543). Point it at the direct endpoint instead: a pooler')}`);
4965
+ console.log(` ${(0, ui_js_1.dim)('host, or port 6543 or 6432). Point it at the direct endpoint instead: a pooler')}`);
4961
4966
  console.log(` ${(0, ui_js_1.dim)('shares one server backend between clients, so neither the session state')}`);
4962
4967
  console.log(` ${(0, ui_js_1.dim)('doctor needs nor the session-scoped views it cites belong to it.')}`);
4963
4968
  (0, ui_js_1.newline)();
@@ -5015,9 +5020,10 @@ function showMcpHelp() {
5015
5020
  console.log(` ${(0, ui_js_1.bold)('Usage:')}`);
5016
5021
  console.log(` npx turbine mcp ${(0, ui_js_1.dim)('[options]')}`);
5017
5022
  (0, ui_js_1.newline)();
5018
- console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes ten`);
5019
- console.log(` read-only tools: schema, relation graph, join paths, migration status,`);
5020
- console.log(` doctor, EXPLAIN, table stats, sample rows, and error lookup.`);
5023
+ console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes eleven`);
5024
+ console.log(` read-only tools: schema overview, table detail, relation graph, join paths,`);
5025
+ console.log(` migration status, doctor, EXPLAIN, query compile, table stats, sample rows`);
5026
+ console.log(` and error lookup.`);
5021
5027
  (0, ui_js_1.newline)();
5022
5028
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
5023
5029
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
@@ -5031,16 +5037,17 @@ function showObserveHelp() {
5031
5037
  console.log(` ${(0, ui_js_1.bold)('turbine observe')}, Query metrics dashboard`);
5032
5038
  (0, ui_js_1.newline)();
5033
5039
  console.log(` ${(0, ui_js_1.bold)('Usage:')}`);
5034
- console.log(` TURBINE_OBSERVE_URL=postgres://... npx turbine observe ${(0, ui_js_1.dim)('[options]')}`);
5040
+ console.log(` npx turbine observe --metrics-url postgres://... ${(0, ui_js_1.dim)('[options]')}`);
5035
5041
  (0, ui_js_1.newline)();
5036
5042
  console.log(` Reads the ${(0, ui_js_1.cyan)('_turbine_metrics')} table written by ${(0, ui_js_1.cyan)('db.$observe()')} and serves`);
5037
5043
  console.log(` per-minute count / avg / p50 / p95 / p99 / error aggregates. Read-only,`);
5038
5044
  console.log(` loopback-bound, behind a random per-session token, same model as Studio.`);
5039
5045
  (0, ui_js_1.newline)();
5040
- console.log(` ${(0, ui_js_1.dim)('The connection string comes from')} ${(0, ui_js_1.cyan)('TURBINE_OBSERVE_URL')}${(0, ui_js_1.dim)(', not')} ${(0, ui_js_1.cyan)('DATABASE_URL')}${(0, ui_js_1.dim)(':')}`);
5046
+ console.log(` ${(0, ui_js_1.dim)('The connection string comes from')} ${(0, ui_js_1.cyan)('--metrics-url')} ${(0, ui_js_1.dim)('or')} ${(0, ui_js_1.cyan)('TURBINE_OBSERVE_URL')}${(0, ui_js_1.dim)(', not')} ${(0, ui_js_1.cyan)('DATABASE_URL')}${(0, ui_js_1.dim)(':')}`);
5041
5047
  console.log(` ${(0, ui_js_1.dim)('metrics are meant to live in a different database from the one they measure.')}`);
5042
5048
  (0, ui_js_1.newline)();
5043
5049
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
5050
+ console.log(` ${(0, ui_js_1.cyan)('--metrics-url')} ${(0, ui_js_1.dim)('<url>')} Metrics database ${(0, ui_js_1.dim)('(default: TURBINE_OBSERVE_URL)')}`);
5044
5051
  console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4984)')}`);
5045
5052
  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)')}`);
5046
5053
  console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
@@ -1 +1 @@
1
- export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script nonce=\"__CSP_NONCE__\">\n let currentRange = '1h';\n // Anchored on a cookie boundary so a decoy cookie whose name ends with this\n // one cannot shadow the real token.\n const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
1
+ export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #6f9bf7;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script nonce=\"__CSP_NONCE__\">\n let currentRange = '1h';\n // Anchored on a cookie boundary so a decoy cookie whose name ends with this\n // one cannot shadow the real token.\n const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
@@ -18,7 +18,7 @@ exports.OBSERVE_HTML = `<!doctype html>
18
18
  --border: #26262b;
19
19
  --text: #e6e6ea;
20
20
  --text-dim: #8a8a93;
21
- --accent: #60a5fa;
21
+ --accent: #6f9bf7;
22
22
  --green: #4ade80;
23
23
  --red: #f87171;
24
24
  --orange: #fb923c;