turbine-orm 0.15.0 → 0.18.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 +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- package/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/cli/studio.js +5 -1
- package/dist/cjs/client.js +218 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +164 -10
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +604 -25
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/cli/studio.js +5 -1
- package/dist/client.d.ts +129 -2
- package/dist/client.js +220 -2
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +164 -10
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +121 -1
- package/dist/query/builder.js +605 -26
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +18 -16
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# turbine-orm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One dependency. No WASM engine. The Postgres ORM that ships light and locks tight.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
npm install turbine-orm
|
|
@@ -10,20 +10,20 @@ npm install turbine-orm
|
|
|
10
10
|
|
|
11
11
|
## Why Turbine?
|
|
12
12
|
|
|
13
|
-
Prisma ships a 1.6 MB WASM engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships **one dependency (`pg`)
|
|
13
|
+
Prisma ships a 1.6 MB WASM query engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships **one dependency (`pg`) and no engine binary**, and bundles six things no other TS ORM has together:
|
|
14
14
|
|
|
15
|
-
1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. ~
|
|
15
|
+
1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~30 KB gzipped (~109 KB minified); the edge entry to ~21 KB gzipped. Prisma's WASM query engine alone is 1.6 MB.
|
|
16
16
|
2. **Built-in read-only Studio.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and a statement-stacking guard. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable.
|
|
17
17
|
3. **PII-safe error messages.** Turbine errors show WHERE keys, not values. A `UniqueConstraintError` says which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed.
|
|
18
18
|
4. **SQL-first migrations with drift detection.** Write real SQL. SHA-256 checksums catch modified migration files. `pg_try_advisory_lock()` prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL.
|
|
19
19
|
5. **Edge-native — one import swap.** `turbineHttp(pool, schema)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
|
|
20
20
|
6. **Pipeline batching via wire protocol.** Real Parse/Bind/Execute pipeline — not queries wrapped in a transaction. N independent queries in one round-trip.
|
|
21
21
|
|
|
22
|
-
Every ORM claims single-query nested loads now (Prisma 7 and Drizzle v2 both use json_agg). Turbine does too — see [How It Works](#how-it-works). The differentiator isn't the query strategy; it's the
|
|
22
|
+
Every ORM claims single-query nested loads now (Prisma 7 and Drizzle v2 both use json_agg). Turbine does too — see [How It Works](#how-it-works). The differentiator isn't the query strategy; it's the one-dependency, no-WASM footprint, the read-only Studio, and the error messages that never leak user data.
|
|
23
23
|
|
|
24
24
|
## Benchmarks
|
|
25
25
|
|
|
26
|
-
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.
|
|
26
|
+
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. _Measured April 2026 on turbine-orm 0.7.1; the core read path these scenarios exercise is unchanged through 0.17.0 — see [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) to reproduce._
|
|
27
27
|
|
|
28
28
|
| Scenario | Turbine | Prisma 7 | Drizzle v2 |
|
|
29
29
|
|---|---|---|---|
|
|
@@ -43,7 +43,7 @@ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizz
|
|
|
43
43
|
- **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.
|
|
44
44
|
- **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.
|
|
45
45
|
|
|
46
|
-
Performance is at parity with Prisma and Drizzle — the real reasons to choose Turbine are elsewhere: **
|
|
46
|
+
Performance is at parity with Prisma and Drizzle — the real reasons to choose Turbine are elsewhere: **one dependency and no WASM engine** (vs Prisma's 1.6 MB WASM query engine), the **only read-only Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes — no manual assertion, no helper annotation.
|
|
47
47
|
|
|
48
48
|
> Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
|
|
49
49
|
> Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
|
|
@@ -118,6 +118,62 @@ const user = await db.users.findUnique({
|
|
|
118
118
|
// user.posts is Post[] -- resolved in the same query
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
### Many-to-many relations
|
|
122
|
+
|
|
123
|
+
Turbine auto-detects pure junction tables during `generate` — a table whose primary key is exactly two single-column foreign keys and which carries no other columns (e.g. `posts_tags(post_id, tag_id)`). Both endpoints gain a many-to-many relation you can load like any other:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
const posts = await db.posts.findMany({
|
|
127
|
+
with: { tags: true }, // each post comes back with its tags array
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Nested where / orderBy / limit work on the m2m target too
|
|
131
|
+
const post = await db.posts.findFirst({
|
|
132
|
+
where: { id: 1 },
|
|
133
|
+
with: { tags: { where: { name: 'sql' }, orderBy: { name: 'asc' }, limit: 5 } },
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A junction table that carries extra columns (a "payload") is treated as a first-class entity, so it stays an ordinary `hasMany` — that's by design. For those, or for any junction you want to wire up by hand, declare the relation explicitly in your code-first schema:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { defineSchema } from 'turbine-orm';
|
|
141
|
+
|
|
142
|
+
export default defineSchema({
|
|
143
|
+
posts: {
|
|
144
|
+
id: { type: 'serial', primaryKey: true },
|
|
145
|
+
title: { type: 'text', notNull: true },
|
|
146
|
+
manyToMany: [
|
|
147
|
+
{ name: 'tags', target: 'tags', through: 'postsTags',
|
|
148
|
+
sourceKey: 'postId', targetKey: 'tagId' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
// ...tags and postsTags table definitions
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`sourceKey`/`targetKey` are the junction columns referencing each side's primary key; add `references` if the source side is keyed on something other than `id`.
|
|
156
|
+
|
|
157
|
+
### Self-relations
|
|
158
|
+
|
|
159
|
+
A self-referencing foreign key (e.g. `categories.parent_id → categories.id`) introspects to a `belongsTo` *and* a `hasMany` on the same table, so parent and child queries just work — including nested trees:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// A category with its parent and its children
|
|
163
|
+
const category = await db.categories.findFirst({
|
|
164
|
+
where: { id: 2 },
|
|
165
|
+
with: { parent: true, children: true },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Walk a level deeper
|
|
169
|
+
const tree = await db.categories.findFirst({
|
|
170
|
+
where: { id: 1 },
|
|
171
|
+
with: { children: { with: { children: true } } },
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
When a table has a single self-referencing FK, Turbine auto-names the relations after the table: the `belongsTo` is named for the singular (`category`) and the `hasMany` for the table (`categories`). Rename them in your code-first schema if you prefer `parent`/`children`.
|
|
176
|
+
|
|
121
177
|
### create
|
|
122
178
|
|
|
123
179
|
```typescript
|
|
@@ -216,6 +272,29 @@ const stats = await db.raw<{ day: Date; count: number }>`
|
|
|
216
272
|
`;
|
|
217
273
|
```
|
|
218
274
|
|
|
275
|
+
### Typed raw SQL (`db.sql<T>`)
|
|
276
|
+
|
|
277
|
+
`db.sql<T>` is the typed escape hatch: you supply the row shape and get a thenable query with `.one()` and `.scalar()` helpers. Every `${value}` is bound as a `$N` parameter — never interpolated — so injection isn't possible even with hostile input.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// Awaiting the query returns T[]
|
|
281
|
+
const users = await db.sql<{ id: number; name: string }>`
|
|
282
|
+
SELECT id, name FROM users WHERE org_id = ${orgId}
|
|
283
|
+
`;
|
|
284
|
+
|
|
285
|
+
// .one() returns T | null
|
|
286
|
+
const user = await db.sql<{ id: number; name: string }>`
|
|
287
|
+
SELECT id, name FROM users WHERE id = ${42}
|
|
288
|
+
`.one();
|
|
289
|
+
|
|
290
|
+
// .scalar() returns the first column of the first row, or null
|
|
291
|
+
const total = await db.sql<{ count: number }>`
|
|
292
|
+
SELECT COUNT(*)::int AS count FROM users
|
|
293
|
+
`.scalar();
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Reach for `db.sql<T>` when you want a hand-written query with a known return type; use `db.raw` when you don't need the typing or the helpers.
|
|
297
|
+
|
|
219
298
|
### Case-insensitive search
|
|
220
299
|
|
|
221
300
|
```typescript
|
|
@@ -305,10 +384,95 @@ try {
|
|
|
305
384
|
}
|
|
306
385
|
```
|
|
307
386
|
|
|
308
|
-
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).
|
|
387
|
+
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), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint).
|
|
309
388
|
|
|
310
389
|
Full reference with `wrapPgError()` translation, retry patterns for `DeadlockError` / `SerializationFailureError`, and safe vs verbose message modes: **[turbineorm.dev/errors](https://turbineorm.dev/errors)**.
|
|
311
390
|
|
|
391
|
+
### groupBy with HAVING
|
|
392
|
+
|
|
393
|
+
`groupBy` aggregates rows by one or more columns. Add a `having` clause to filter the resulting groups by their aggregates. Every comparison value is parameterized.
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
// Users with more than one post
|
|
397
|
+
const prolific = await db.posts.groupBy({
|
|
398
|
+
by: ['userId'],
|
|
399
|
+
_count: true,
|
|
400
|
+
having: { _count: { gt: 1 } },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Groups whose summed view count clears a threshold
|
|
404
|
+
const popular = await db.posts.groupBy({
|
|
405
|
+
by: ['published'],
|
|
406
|
+
_sum: { viewCount: true },
|
|
407
|
+
having: { viewCount: { _sum: { gte: 100 } } },
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Filter on the group count with `_count`, or on a column aggregate with `{ column: { _sum | _avg | _min | _max: { ... } } }`. Operators are `gt`, `gte`, `lt`, `lte`, `in`, and `notIn` (a bare number is shorthand for equality). `having` predicates combine with `AND`, and `where` filters rows *before* grouping while `having` filters groups *after*.
|
|
412
|
+
|
|
413
|
+
### Multi-tenant queries with RLS session context
|
|
414
|
+
|
|
415
|
+
Set transaction-local Postgres settings (GUCs) so PostgreSQL Row-Level Security policies that call `current_setting()` filter rows for you. Pass `sessionContext` to `$transaction`, or use the `$withSession` shorthand.
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
// Postgres policy: USING (tenant_id = current_setting('app.current_tenant')::int)
|
|
419
|
+
const rows = await db.$transaction(
|
|
420
|
+
async (tx) => tx.documents.findMany(),
|
|
421
|
+
{ sessionContext: { 'app.current_tenant': tenantId } },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Shorthand for a single-purpose session
|
|
425
|
+
const rows2 = await db.$withSession(
|
|
426
|
+
{ 'app.current_tenant': tenantId },
|
|
427
|
+
async (tx) => tx.documents.findMany(),
|
|
428
|
+
);
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Each entry is applied as `SELECT set_config(name, value, true)` right after `BEGIN`, so the setting is scoped to the transaction and resets automatically on commit. Values may be strings, numbers, or booleans (coerced to strings). Invalid setting names throw `ValidationError` and roll the transaction back before any query runs.
|
|
432
|
+
|
|
433
|
+
### Realtime with LISTEN/NOTIFY
|
|
434
|
+
|
|
435
|
+
Subscribe to a Postgres channel with `$listen` and publish to it with `$notify`. The handler receives the notification payload as a string.
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const sub = await db.$listen('order_created', (payload) => {
|
|
439
|
+
console.log('new order:', payload);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await db.$notify('order_created', JSON.stringify({ id: 1 }));
|
|
443
|
+
|
|
444
|
+
// Later, when you're done
|
|
445
|
+
await sub.unsubscribe();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`$listen` holds a dedicated connection open for the lifetime of the subscription, so it requires a real persistent pool — it is not available over serverless HTTP drivers. `$notify` is a single round-trip and works everywhere. Channel names are validated as plain identifiers; the payload is always bound as a parameter.
|
|
449
|
+
|
|
450
|
+
## Vector search (pgvector)
|
|
451
|
+
|
|
452
|
+
Query a `vector` column for nearest neighbors. Requires the [pgvector](https://github.com/pgvector/pgvector) extension and a `vector` column on your table.
|
|
453
|
+
|
|
454
|
+
**KNN ranking** — order by distance to a query vector and take the closest rows:
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
const similar = await db.items.findMany({
|
|
458
|
+
orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
|
|
459
|
+
limit: 5,
|
|
460
|
+
});
|
|
461
|
+
// queryVector is a number[]; nearest-first by default (direction: 'desc' to invert)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
**Distance filter** — keep only rows within a distance threshold:
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
const close = await db.items.findMany({
|
|
468
|
+
where: { embedding: { distance: { to: queryVector, metric: 'l2', lt: 0.3 } } },
|
|
469
|
+
});
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`metric` selects the pgvector operator: `'l2'` → `<->` (Euclidean), `'cosine'` → `<=>` (cosine distance), `'ip'` → `<#>` (negative inner product). Distance filters accept `lt`, `lte`, `gt`, and `gte`. The query vector is always bound as `$n::vector` — never interpolated.
|
|
473
|
+
|
|
474
|
+
> **Note:** pg has no built-in parser for the `vector` type, so a fetched `vector` column comes back as a string literal like `'[1,2,3]'` unless you register a parser (e.g. via pgvector's own client helpers). Querying by distance works regardless.
|
|
475
|
+
|
|
312
476
|
## WHERE Operator Reference
|
|
313
477
|
|
|
314
478
|
Every operator supported by the `where` clause. Operators compose freely with `AND`, `OR`, `NOT`, and the relation filters `some` / `every` / `none`.
|
|
@@ -573,7 +737,7 @@ Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file
|
|
|
573
737
|
|
|
574
738
|
Turbine resolves nested relations the same way Prisma 7 and Drizzle v2 do: correlated subqueries with `json_agg` + `json_build_object`, evaluated by PostgreSQL in a single round-trip. No N+1, no client-side stitching, no separate queries per relation. The `with` clause is fully type-inferred end-to-end — write `db.users.findMany({ with: { posts: { with: { comments: { with: { author: true } } } } } })` and `users[0].posts[0].comments[0].author.name` autocompletes with zero manual annotation.
|
|
575
739
|
|
|
576
|
-
The query strategy is table stakes now. What isn't table stakes: the
|
|
740
|
+
The query strategy is table stakes now. What isn't table stakes: the one-dependency, no-WASM footprint, the read-only Studio your DBA will approve, the error messages that never leak PII, and the SQL-first migrations with SHA-256 drift detection. See [Why Turbine?](#why-turbine) for the full breakdown.
|
|
577
741
|
|
|
578
742
|
## Type Mapping
|
|
579
743
|
|
|
@@ -595,25 +759,29 @@ Turbine maps Postgres types to TypeScript:
|
|
|
595
759
|
|
|
596
760
|
| | **Turbine** | **Prisma** | **Drizzle** | **Kysely** |
|
|
597
761
|
|---|---|---|---|---|
|
|
598
|
-
| **
|
|
762
|
+
| **Engine / runtime** | No engine binary (`pg` only) | Client + 1.6 MB WASM engine | No engine | No engine |
|
|
599
763
|
| **Runtime deps** | 1 (`pg`) | `@prisma/client` + adapter | 0 | 0 |
|
|
764
|
+
| **Main bundle (gzip)** | ~30 KB | dominated by 1.6 MB WASM | ~7 KB core | small |
|
|
600
765
|
| **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Paid tier | None |
|
|
601
766
|
| **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
|
|
602
767
|
| **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
|
|
603
|
-
| **Edge runtime** | One import swap,
|
|
768
|
+
| **Edge runtime** | One import swap, ~21 KB gzip | 1.6 MB WASM adapter | Native | Native |
|
|
604
769
|
| **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
|
|
605
770
|
| **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
|
|
606
771
|
| **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
|
|
772
|
+
| **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
|
|
773
|
+
| **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
|
|
774
|
+
| **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
|
|
607
775
|
| **Multi-DB** | PostgreSQL only | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
|
|
608
776
|
|
|
609
|
-
All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators:
|
|
777
|
+
All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators: no engine binary or WASM — just one dependency (`pg`), vs Prisma's 1.6 MB WASM query engine; the only read-only Studio in the ecosystem; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See [Benchmarks](#benchmarks) for performance numbers — most scenarios are within noise over a real pooled database.
|
|
610
778
|
|
|
611
779
|
## Limitations
|
|
612
780
|
|
|
613
781
|
Turbine is focused and opinionated. Here's what it doesn't do:
|
|
614
782
|
|
|
615
783
|
- **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
|
|
616
|
-
- **
|
|
784
|
+
- **Full-text search** is available via a `search` filter — `where: { title: { search: 'hello & world', config: 'english' } }` compiles to a parameterized `to_tsvector(...) @@ to_tsquery(...)`. For advanced ranking (`ts_rank`, weighted vectors) use `db.raw`.
|
|
617
785
|
- **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.
|
|
618
786
|
|
|
619
787
|
## Examples
|
|
@@ -165,8 +165,10 @@ export const cockroachdb = {
|
|
|
165
165
|
rowEstimates: SQL_ROW_ESTIMATES_CRDB,
|
|
166
166
|
},
|
|
167
167
|
statementTimeout(seconds) {
|
|
168
|
-
// CockroachDB v23.1+ supports transaction_timeout
|
|
169
|
-
|
|
168
|
+
// CockroachDB v23.1+ supports transaction_timeout. `SET ... = $1` cannot
|
|
169
|
+
// take a bind parameter, so use the parameterizable set_config() form
|
|
170
|
+
// (is_local=true scopes it to the current transaction).
|
|
171
|
+
return { sql: `SELECT set_config('transaction_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
170
172
|
},
|
|
171
173
|
};
|
|
172
174
|
//# sourceMappingURL=cockroachdb.js.map
|
package/dist/adapters/index.js
CHANGED
|
@@ -31,7 +31,10 @@ export const postgresql = {
|
|
|
31
31
|
await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
|
|
32
32
|
},
|
|
33
33
|
statementTimeout(seconds) {
|
|
34
|
-
|
|
34
|
+
// `SET LOCAL ... = $1` is a Postgres syntax error — SET does not accept
|
|
35
|
+
// bind parameters. `set_config(name, value, is_local=true)` is the
|
|
36
|
+
// parameterizable, transaction-local equivalent.
|
|
37
|
+
return { sql: `SELECT set_config('statement_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
35
38
|
},
|
|
36
39
|
};
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -149,8 +149,10 @@ export const yugabytedb = {
|
|
|
149
149
|
rowEstimates: SQL_ROW_ESTIMATES_YBDB,
|
|
150
150
|
},
|
|
151
151
|
statementTimeout(seconds) {
|
|
152
|
-
// YugabyteDB supports standard PostgreSQL statement_timeout
|
|
153
|
-
|
|
152
|
+
// YugabyteDB supports standard PostgreSQL statement_timeout. `SET LOCAL`
|
|
153
|
+
// cannot take a bind parameter, so use the parameterizable, transaction-
|
|
154
|
+
// local set_config() form.
|
|
155
|
+
return { sql: `SELECT set_config('statement_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
154
156
|
},
|
|
155
157
|
};
|
|
156
158
|
//# sourceMappingURL=yugabytedb.js.map
|
|
@@ -168,7 +168,9 @@ exports.cockroachdb = {
|
|
|
168
168
|
rowEstimates: SQL_ROW_ESTIMATES_CRDB,
|
|
169
169
|
},
|
|
170
170
|
statementTimeout(seconds) {
|
|
171
|
-
// CockroachDB v23.1+ supports transaction_timeout
|
|
172
|
-
|
|
171
|
+
// CockroachDB v23.1+ supports transaction_timeout. `SET ... = $1` cannot
|
|
172
|
+
// take a bind parameter, so use the parameterizable set_config() form
|
|
173
|
+
// (is_local=true scopes it to the current transaction).
|
|
174
|
+
return { sql: `SELECT set_config('transaction_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
173
175
|
},
|
|
174
176
|
};
|
|
@@ -34,7 +34,10 @@ exports.postgresql = {
|
|
|
34
34
|
await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
|
|
35
35
|
},
|
|
36
36
|
statementTimeout(seconds) {
|
|
37
|
-
|
|
37
|
+
// `SET LOCAL ... = $1` is a Postgres syntax error — SET does not accept
|
|
38
|
+
// bind parameters. `set_config(name, value, is_local=true)` is the
|
|
39
|
+
// parameterizable, transaction-local equivalent.
|
|
40
|
+
return { sql: `SELECT set_config('statement_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
38
41
|
},
|
|
39
42
|
};
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
@@ -152,7 +152,9 @@ exports.yugabytedb = {
|
|
|
152
152
|
rowEstimates: SQL_ROW_ESTIMATES_YBDB,
|
|
153
153
|
},
|
|
154
154
|
statementTimeout(seconds) {
|
|
155
|
-
// YugabyteDB supports standard PostgreSQL statement_timeout
|
|
156
|
-
|
|
155
|
+
// YugabyteDB supports standard PostgreSQL statement_timeout. `SET LOCAL`
|
|
156
|
+
// cannot take a bind parameter, so use the parameterizable, transaction-
|
|
157
|
+
// local set_config() form.
|
|
158
|
+
return { sql: `SELECT set_config('statement_timeout', $1, true)`, params: [`${seconds}s`] };
|
|
157
159
|
},
|
|
158
160
|
};
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* turbine seed — Run seed file
|
|
15
15
|
* turbine status — Show schema summary
|
|
16
16
|
* turbine studio — Launch local read-only web UI
|
|
17
|
+
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
17
18
|
*
|
|
18
19
|
* Usage:
|
|
19
20
|
* DATABASE_URL=postgres://... npx turbine generate
|
|
@@ -63,6 +64,7 @@ const schema_sql_js_1 = require("../schema-sql.js");
|
|
|
63
64
|
const config_js_1 = require("./config.js");
|
|
64
65
|
const loader_js_1 = require("./loader.js");
|
|
65
66
|
const migrate_js_1 = require("./migrate.js");
|
|
67
|
+
const observe_js_1 = require("./observe.js");
|
|
66
68
|
const studio_js_1 = require("./studio.js");
|
|
67
69
|
const ui_js_1 = require("./ui.js");
|
|
68
70
|
function parseArgs() {
|
|
@@ -1005,6 +1007,65 @@ async function cmdStudio(args, config) {
|
|
|
1005
1007
|
});
|
|
1006
1008
|
}
|
|
1007
1009
|
// ---------------------------------------------------------------------------
|
|
1010
|
+
// Command: observe
|
|
1011
|
+
// ---------------------------------------------------------------------------
|
|
1012
|
+
async function cmdObserve(args) {
|
|
1013
|
+
(0, ui_js_1.banner)();
|
|
1014
|
+
const url = process.env.TURBINE_OBSERVE_URL;
|
|
1015
|
+
if (!url) {
|
|
1016
|
+
(0, ui_js_1.error)('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
|
|
1017
|
+
(0, ui_js_1.newline)();
|
|
1018
|
+
console.log(` ${(0, ui_js_1.dim)('Set it to the Postgres connection string where metrics are stored.')}`);
|
|
1019
|
+
console.log(` ${(0, ui_js_1.dim)('Example:')} ${(0, ui_js_1.cyan)('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
|
|
1020
|
+
(0, ui_js_1.newline)();
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
const port = args.port ?? 4984;
|
|
1024
|
+
const host = args.host ?? '127.0.0.1';
|
|
1025
|
+
const openBrowser = !args.noOpen;
|
|
1026
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
1027
|
+
console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
1031
|
+
console.log((0, ui_js_1.warn)(`Observe is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
|
|
1032
|
+
`Anyone on your network who can reach this port + guess the session token can read your metrics.`));
|
|
1033
|
+
}
|
|
1034
|
+
const spinner = new ui_js_1.Spinner('Connecting to metrics database').start();
|
|
1035
|
+
let handle;
|
|
1036
|
+
try {
|
|
1037
|
+
handle = await (0, observe_js_1.startObserve)({ url, port, host, openBrowser });
|
|
1038
|
+
spinner.succeed('Observe dashboard is running');
|
|
1039
|
+
}
|
|
1040
|
+
catch (err) {
|
|
1041
|
+
spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
(0, ui_js_1.newline)();
|
|
1045
|
+
console.log((0, ui_js_1.box)([
|
|
1046
|
+
`${(0, ui_js_1.bold)('Turbine Observe')} ${(0, ui_js_1.dim)('— query metrics dashboard')}`,
|
|
1047
|
+
'',
|
|
1048
|
+
` ${(0, ui_js_1.cyan)('URL:')} ${(0, ui_js_1.bold)(handle.url)}`,
|
|
1049
|
+
'',
|
|
1050
|
+
(0, ui_js_1.dim)('Open the URL above in your browser. Press Ctrl+C to stop.'),
|
|
1051
|
+
].join('\n'), { title: (0, ui_js_1.bold)((0, ui_js_1.cyan)('Observe')), padding: 1 }));
|
|
1052
|
+
(0, ui_js_1.newline)();
|
|
1053
|
+
await new Promise((resolve) => {
|
|
1054
|
+
const shutdown = async () => {
|
|
1055
|
+
console.log((0, ui_js_1.dim)('\n shutting down…'));
|
|
1056
|
+
try {
|
|
1057
|
+
await handle.dispose();
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
/* ignore */
|
|
1061
|
+
}
|
|
1062
|
+
resolve();
|
|
1063
|
+
};
|
|
1064
|
+
process.once('SIGINT', shutdown);
|
|
1065
|
+
process.once('SIGTERM', shutdown);
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1008
1069
|
// Subcommand help
|
|
1009
1070
|
// ---------------------------------------------------------------------------
|
|
1010
1071
|
function showSubcommandHelp(command) {
|
|
@@ -1288,6 +1349,9 @@ async function main() {
|
|
|
1288
1349
|
case 'studio':
|
|
1289
1350
|
await cmdStudio(args, config);
|
|
1290
1351
|
break;
|
|
1352
|
+
case 'observe':
|
|
1353
|
+
await cmdObserve(args);
|
|
1354
|
+
break;
|
|
1291
1355
|
default:
|
|
1292
1356
|
(0, ui_js_1.error)(`Unknown command: ${(0, ui_js_1.bold)(args.command)}`);
|
|
1293
1357
|
(0, ui_js_1.newline)();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Embedded HTML for the Turbine Observe dashboard.
|
|
3
|
+
// Same pattern as studio-ui.generated.ts but hand-authored (no build step needed).
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.OBSERVE_HTML = void 0;
|
|
6
|
+
exports.OBSERVE_HTML = `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<meta name="color-scheme" content="dark" />
|
|
12
|
+
<title>Turbine Observe</title>
|
|
13
|
+
<style>
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #0a0a0b;
|
|
16
|
+
--bg-elev: #111113;
|
|
17
|
+
--bg-hover: #1a1a1d;
|
|
18
|
+
--border: #26262b;
|
|
19
|
+
--text: #e6e6ea;
|
|
20
|
+
--text-dim: #8a8a93;
|
|
21
|
+
--accent: #60a5fa;
|
|
22
|
+
--green: #4ade80;
|
|
23
|
+
--red: #f87171;
|
|
24
|
+
--orange: #fb923c;
|
|
25
|
+
--purple: #a78bfa;
|
|
26
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
27
|
+
--sans: system-ui, -apple-system, sans-serif;
|
|
28
|
+
--radius: 6px;
|
|
29
|
+
}
|
|
30
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
31
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }
|
|
32
|
+
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
33
|
+
.subtitle { color: var(--text-dim); margin-bottom: 24px; }
|
|
34
|
+
.controls { display: flex; gap: 8px; margin-bottom: 24px; }
|
|
35
|
+
.controls button {
|
|
36
|
+
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
|
37
|
+
color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;
|
|
38
|
+
}
|
|
39
|
+
.controls button.active { border-color: var(--accent); color: var(--accent); }
|
|
40
|
+
.card {
|
|
41
|
+
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
|
42
|
+
padding: 16px; margin-bottom: 16px;
|
|
43
|
+
}
|
|
44
|
+
.card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
45
|
+
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
|
46
|
+
th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
|
|
47
|
+
td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
|
48
|
+
.num { text-align: right; }
|
|
49
|
+
.error-rate { color: var(--red); }
|
|
50
|
+
.low-error { color: var(--green); }
|
|
51
|
+
svg { width: 100%; height: 200px; }
|
|
52
|
+
.chart-line { fill: none; stroke-width: 1.5; }
|
|
53
|
+
.line-avg { stroke: var(--accent); }
|
|
54
|
+
.line-p95 { stroke: var(--orange); }
|
|
55
|
+
.line-p99 { stroke: var(--red); }
|
|
56
|
+
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }
|
|
57
|
+
.legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }
|
|
58
|
+
.legend .l-avg::before { background: var(--accent); }
|
|
59
|
+
.legend .l-p95::before { background: var(--orange); }
|
|
60
|
+
.legend .l-p99::before { background: var(--red); }
|
|
61
|
+
.empty { color: var(--text-dim); text-align: center; padding: 40px; }
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<h1>Turbine Observe</h1>
|
|
66
|
+
<p class="subtitle">Query performance metrics</p>
|
|
67
|
+
<div class="controls">
|
|
68
|
+
<button data-range="1h" class="active">1h</button>
|
|
69
|
+
<button data-range="6h">6h</button>
|
|
70
|
+
<button data-range="24h">24h</button>
|
|
71
|
+
<button data-range="7d">7d</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card" id="latency-card">
|
|
74
|
+
<h2>Latency over time</h2>
|
|
75
|
+
<div id="chart"></div>
|
|
76
|
+
<div class="legend">
|
|
77
|
+
<span class="l-avg">avg</span>
|
|
78
|
+
<span class="l-p95">p95</span>
|
|
79
|
+
<span class="l-p99">p99</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="card" id="models-card">
|
|
83
|
+
<h2>Top models</h2>
|
|
84
|
+
<div id="models-table"></div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="card" id="errors-card">
|
|
87
|
+
<h2>Error rates</h2>
|
|
88
|
+
<div id="errors-table"></div>
|
|
89
|
+
</div>
|
|
90
|
+
<script>
|
|
91
|
+
let currentRange = '1h';
|
|
92
|
+
const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
|
|
93
|
+
const headers = { 'x-turbine-token': token };
|
|
94
|
+
|
|
95
|
+
document.querySelector('.controls').addEventListener('click', e => {
|
|
96
|
+
if (e.target.tagName !== 'BUTTON') return;
|
|
97
|
+
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
|
|
98
|
+
e.target.classList.add('active');
|
|
99
|
+
currentRange = e.target.dataset.range;
|
|
100
|
+
refresh();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function fetchJson(path) {
|
|
104
|
+
const res = await fetch(path, { headers });
|
|
105
|
+
if (!res.ok) return null;
|
|
106
|
+
return res.json();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildSvgPath(points, width, height, maxY) {
|
|
110
|
+
if (points.length === 0) return '';
|
|
111
|
+
const xStep = width / Math.max(points.length - 1, 1);
|
|
112
|
+
return points.map((y, i) => {
|
|
113
|
+
const px = i * xStep;
|
|
114
|
+
const py = height - (y / maxY) * height;
|
|
115
|
+
return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);
|
|
116
|
+
}).join(' ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderChart(data) {
|
|
120
|
+
const el = document.getElementById('chart');
|
|
121
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
122
|
+
const width = 800; const height = 180;
|
|
123
|
+
const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);
|
|
124
|
+
const maxY = Math.max(...allVals, 1) * 1.1;
|
|
125
|
+
const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);
|
|
126
|
+
const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);
|
|
127
|
+
const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);
|
|
128
|
+
el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none">'
|
|
129
|
+
+ '<path class="chart-line line-avg" d="' + avgPath + '"/>'
|
|
130
|
+
+ '<path class="chart-line line-p95" d="' + p95Path + '"/>'
|
|
131
|
+
+ '<path class="chart-line line-p99" d="' + p99Path + '"/>'
|
|
132
|
+
+ '</svg>';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderModels(data) {
|
|
136
|
+
const el = document.getElementById('models-table');
|
|
137
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
138
|
+
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>';
|
|
139
|
+
for (const row of data) {
|
|
140
|
+
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
141
|
+
+ '<td class="num">' + row.count + '</td>'
|
|
142
|
+
+ '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
|
|
143
|
+
+ '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
|
|
144
|
+
+ '<td class="num">' + row.p99_ms.toFixed(1) + '</td></tr>';
|
|
145
|
+
}
|
|
146
|
+
html += '</tbody></table>';
|
|
147
|
+
el.innerHTML = html;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderErrors(data) {
|
|
151
|
+
const el = document.getElementById('errors-table');
|
|
152
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No errors</p>'; return; }
|
|
153
|
+
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>';
|
|
154
|
+
for (const row of data) {
|
|
155
|
+
const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
|
|
156
|
+
const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
|
|
157
|
+
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
158
|
+
+ '<td class="num">' + row.count + '</td>'
|
|
159
|
+
+ '<td class="num">' + row.error_count + '</td>'
|
|
160
|
+
+ '<td class="num ' + cls + '">' + rate + '%</td></tr>';
|
|
161
|
+
}
|
|
162
|
+
html += '</tbody></table>';
|
|
163
|
+
el.innerHTML = html;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function refresh() {
|
|
167
|
+
const [latency, models] = await Promise.all([
|
|
168
|
+
fetchJson('/api/latency?range=' + currentRange),
|
|
169
|
+
fetchJson('/api/models?range=' + currentRange),
|
|
170
|
+
]);
|
|
171
|
+
renderChart(latency);
|
|
172
|
+
renderModels(models);
|
|
173
|
+
// Derive errors from models data
|
|
174
|
+
const withErrors = (models || []).filter(m => m.error_count > 0);
|
|
175
|
+
renderErrors(withErrors);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
refresh();
|
|
179
|
+
setInterval(refresh, 60000);
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>`;
|