turbine-orm 0.19.1 → 0.19.2

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
@@ -12,7 +12,7 @@ npm install turbine-orm
12
12
 
13
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. 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.
15
+ 1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~31 kB brotli (~109 KB minified); the edge entry to ~22 kB brotli. 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 — since v0.19 — no raw-SQL surface at all: queries are composed in the ORM's own validated builder. 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.
@@ -343,6 +343,8 @@ const db = turbine({
343
343
 
344
344
  ### Middleware
345
345
 
346
+ Middleware wraps every query. It runs **after SQL generation**, so it can observe what's about to execute (`params.model`, `params.action`, `params.args`), measure timing, and transform the result returned by `next()` — but it cannot change the query itself.
347
+
346
348
  ```typescript
347
349
  // Query timing
348
350
  db.$use(async (params, next) => {
@@ -352,15 +354,33 @@ db.$use(async (params, next) => {
352
354
  return result;
353
355
  });
354
356
 
355
- // Soft-delete filter
357
+ // Result transformation — redact a field on the way out
356
358
  db.$use(async (params, next) => {
357
- if (params.action === 'findMany' || params.action === 'findUnique') {
358
- params.args.where = { ...params.args.where, deletedAt: null };
359
+ const result = await next(params);
360
+ if (params.model === 'users' && Array.isArray(result)) {
361
+ for (const row of result as { email?: string }[]) row.email = '[redacted]';
359
362
  }
360
- return next(params);
363
+ return result;
361
364
  });
362
365
  ```
363
366
 
367
+ > **Warning:** `params.args` is a read-only snapshot — mutating it does not change the executed SQL. The query is fully built and parameterized before middleware runs.
368
+
369
+ Because middleware can't rewrite queries, cross-cutting filters like **soft deletes** belong in the query itself — either explicitly or via a small scoped helper:
370
+
371
+ ```typescript
372
+ import type { WhereClause } from 'turbine-orm';
373
+
374
+ // Explicit filter
375
+ const users = await db.users.findMany({ where: { deletedAt: null } });
376
+
377
+ // Scoped helper that always applies the filter
378
+ const activeUsers = (where: WhereClause<User> = {}) =>
379
+ db.users.findMany({ where: { ...where, deletedAt: null } });
380
+
381
+ const rows = await activeUsers({ orgId: 1 });
382
+ ```
383
+
364
384
  ### Error handling
365
385
 
366
386
  Turbine throws typed errors you can catch programmatically:
@@ -557,6 +577,7 @@ Commands:
557
577
  seed Run seed file
558
578
  status Show database schema summary
559
579
  studio Launch local read-only Studio web UI
580
+ observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
560
581
 
561
582
  Options:
562
583
  --url, -u <url> Postgres connection string
@@ -638,6 +659,42 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
638
659
  - **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Writes are physically impossible at the transaction level.
639
660
  - **Security headers on every response** — CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` — plus per-session rate limiting and cross-origin refusal.
640
661
 
662
+ ## Observability
663
+
664
+ Built-in query metrics with zero new dependencies. `$observe` buffers per-query timings in memory and flushes **per-minute aggregates** — count, avg, p50, p95, p99, and error count per `model:action` — to a `_turbine_metrics` table in a **separate database**, over its own 1-connection pool so metrics writes never contend with your application pool.
665
+
666
+ ```typescript
667
+ const handle = await db.$observe({
668
+ connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
669
+ flushIntervalMs: 60_000, // default: 60s
670
+ retentionDays: 30, // default: 30 — older buckets are pruned on flush
671
+ });
672
+
673
+ // Later, to flush remaining metrics and close the metrics pool
674
+ await handle.stop();
675
+ ```
676
+
677
+ `$observe` creates the `_turbine_metrics` table if it doesn't exist. Flushes are fire-and-forget (`INSERT ... ON CONFLICT` additive merge) and never throw into your application. If the `TURBINE_OBSERVE_URL` environment variable is set, the client starts observing automatically on construction — no code needed.
678
+
679
+ For your own instrumentation, subscribe to query events with `$on('query')` — each event carries `sql`, `params`, `duration` (ms), `model`, `action`, `rows`, `timestamp`, and `error` (if the query failed):
680
+
681
+ ```typescript
682
+ db.$on('query', (e) => {
683
+ if (e.duration > 200) {
684
+ console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
685
+ }
686
+ });
687
+ ```
688
+
689
+ View the collected metrics in a local dashboard:
690
+
691
+ ```bash
692
+ TURBINE_OBSERVE_URL=postgres://... npx turbine observe
693
+ # Flags: --port (default 4984), --host (default 127.0.0.1), --no-open
694
+ ```
695
+
696
+ Same security model as Studio: loopback binding by default, per-process random auth token in an `HttpOnly` cookie, CSP headers, and read-only access to the metrics table.
697
+
641
698
  ## Serverless / Edge
642
699
 
643
700
  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.
@@ -761,11 +818,11 @@ Turbine maps Postgres types to TypeScript:
761
818
  |---|---|---|---|---|
762
819
  | **Engine / runtime** | No engine binary (`pg` only) | Client + 1.6 MB WASM engine | No engine | No engine |
763
820
  | **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 |
821
+ | **Main bundle (brotli)** | ~31 kB | dominated by 1.6 MB WASM | ~7 KB core | small |
765
822
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Paid tier | None |
766
823
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
767
824
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
768
- | **Edge runtime** | One import swap, ~21 KB gzip | 1.6 MB WASM adapter | Native | Native |
825
+ | **Edge runtime** | One import swap, ~22 kB brotli | 1.6 MB WASM adapter | Native | Native |
769
826
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
770
827
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
771
828
  | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
@@ -818,6 +875,17 @@ Turbine is focused and opinionated. Here's what it doesn't do:
818
875
  - PostgreSQL >= 14
819
876
  - Works with both ESM (`import`) and CommonJS (`require`)
820
877
 
878
+ ## Contributing
879
+
880
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the test strategy, and the PR checklist. The unit suite runs without a database:
881
+
882
+ ```bash
883
+ npm install
884
+ npm run test:unit
885
+ ```
886
+
887
+ Integration tests need a PostgreSQL instance via `DATABASE_URL` (see CONTRIBUTING.md for a one-command seeded setup).
888
+
821
889
  ## License
822
890
 
823
891
  MIT
@@ -1164,13 +1164,15 @@ function showMigrateHelp() {
1164
1164
  (0, ui_js_1.newline)();
1165
1165
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1166
1166
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
1167
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create only)')}`);
1167
1168
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1168
- console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1169
- console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1170
- console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1169
+ console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1170
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1171
+ console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1171
1172
  (0, ui_js_1.newline)();
1172
1173
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
1173
1174
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_users_table`);
1175
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_email_index --auto`);
1174
1176
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate up`);
1175
1177
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate down --step 2`);
1176
1178
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate status`);
@@ -1215,16 +1217,17 @@ function showHelp() {
1215
1217
  (0, ui_js_1.newline)();
1216
1218
  console.log(` ${(0, ui_js_1.bold)('Commands:')}`);
1217
1219
  console.log(` ${(0, ui_js_1.cyan)('init')} Initialize a Turbine project`);
1218
- console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')} Introspect database ${ui_js_1.symbols.arrow} generate types`);
1220
+ console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')} Introspect database ${ui_js_1.symbols.arrow} generate types`);
1219
1221
  console.log(` ${(0, ui_js_1.cyan)('push')} Apply schema definitions to database`);
1220
- console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')} SQL migration management`);
1221
- console.log(` ${(0, ui_js_1.dim)('create <name>')} Create a new migration file`);
1222
+ console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')} SQL migration management`);
1223
+ console.log(` ${(0, ui_js_1.dim)('create <name>')} Create a new migration file`);
1222
1224
  console.log(` ${(0, ui_js_1.dim)('up')} Apply pending migrations`);
1223
1225
  console.log(` ${(0, ui_js_1.dim)('down')} Rollback last migration`);
1224
1226
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
1225
1227
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
1226
- console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1228
+ console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1227
1229
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
1230
+ console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
1228
1231
  (0, ui_js_1.newline)();
1229
1232
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1230
1233
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
@@ -1236,8 +1239,13 @@ function showHelp() {
1236
1239
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1237
1240
  console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing files`);
1238
1241
  (0, ui_js_1.newline)();
1239
- console.log(` ${(0, ui_js_1.bold)('Studio options:')}`);
1240
- console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983)')}`);
1242
+ console.log(` ${(0, ui_js_1.bold)('Migrate options:')}`);
1243
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create)')}`);
1244
+ console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1245
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation on ${(0, ui_js_1.cyan)('migrate up')} ${(0, ui_js_1.dim)('(advanced)')}`);
1246
+ (0, ui_js_1.newline)();
1247
+ console.log(` ${(0, ui_js_1.bold)('Studio / observe options:')}`);
1248
+ console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983 studio, 4984 observe)')}`);
1241
1249
  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)')}`);
1242
1250
  console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
1243
1251
  (0, ui_js_1.newline)();