turbine-orm 0.48.0 → 0.49.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.
Files changed (65) hide show
  1. package/README.md +58 -39
  2. package/dist/cjs/cli/destructive.js +233 -18
  3. package/dist/cjs/cli/index.js +56 -12
  4. package/dist/cjs/cli/mcp.js +23 -2
  5. package/dist/cjs/cli/migrate.js +28 -1
  6. package/dist/cjs/cli/pii-tags.js +111 -0
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +158 -0
  9. package/dist/cjs/cli/ui.js +8 -3
  10. package/dist/cjs/client.js +21 -1
  11. package/dist/cjs/dialect.js +2 -0
  12. package/dist/cjs/index-advisor.js +0 -0
  13. package/dist/cjs/index-stats.js +118 -6
  14. package/dist/cjs/mssql.js +5 -0
  15. package/dist/cjs/mysql.js +5 -0
  16. package/dist/cjs/nested-write.js +248 -18
  17. package/dist/cjs/observe.js +21 -15
  18. package/dist/cjs/powdb.js +3 -0
  19. package/dist/cjs/powql.js +13 -0
  20. package/dist/cjs/prisma-compat.js +9 -0
  21. package/dist/cjs/query/aggregates.js +41 -1
  22. package/dist/cjs/query/batched-loader.js +70 -6
  23. package/dist/cjs/query/builder.js +3 -3
  24. package/dist/cjs/query/relations.js +12 -2
  25. package/dist/cjs/query/where.js +36 -1
  26. package/dist/cjs/sqlite.js +5 -0
  27. package/dist/cli/destructive.d.ts +9 -3
  28. package/dist/cli/destructive.js +233 -18
  29. package/dist/cli/index.js +57 -13
  30. package/dist/cli/mcp.d.ts +7 -0
  31. package/dist/cli/mcp.js +23 -2
  32. package/dist/cli/migrate.d.ts +2 -1
  33. package/dist/cli/migrate.js +28 -1
  34. package/dist/cli/pii-tags.d.ts +53 -0
  35. package/dist/cli/pii-tags.js +106 -0
  36. package/dist/cli/studio-ui.generated.js +1 -1
  37. package/dist/cli/studio.d.ts +42 -0
  38. package/dist/cli/studio.js +157 -0
  39. package/dist/cli/ui.js +8 -3
  40. package/dist/client.js +21 -1
  41. package/dist/dialect.d.ts +19 -0
  42. package/dist/dialect.js +2 -0
  43. package/dist/index-advisor.d.ts +7 -0
  44. package/dist/index-advisor.js +0 -0
  45. package/dist/index-stats.d.ts +52 -1
  46. package/dist/index-stats.js +117 -5
  47. package/dist/mssql.js +5 -0
  48. package/dist/mysql.js +5 -0
  49. package/dist/nested-write.js +249 -19
  50. package/dist/observe.d.ts +0 -1
  51. package/dist/observe.js +21 -15
  52. package/dist/powdb.js +3 -0
  53. package/dist/powql.js +13 -0
  54. package/dist/prisma-compat.js +9 -0
  55. package/dist/query/aggregates.d.ts +18 -0
  56. package/dist/query/aggregates.js +40 -1
  57. package/dist/query/batched-loader.d.ts +29 -1
  58. package/dist/query/batched-loader.js +69 -6
  59. package/dist/query/builder.js +4 -4
  60. package/dist/query/relations.js +12 -2
  61. package/dist/query/types.d.ts +16 -0
  62. package/dist/query/where.d.ts +18 -1
  63. package/dist/query/where.js +34 -1
  64. package/dist/sqlite.js +5 -0
  65. package/package.json +3 -2
package/README.md CHANGED
@@ -14,39 +14,44 @@ Every TS ORM now resolves nested relations in a single `json_agg` query — Pris
14
14
 
15
15
  1. **Read-only-by-default Studio your DBA will approve.** `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. In the default mode the write endpoints do not exist and every transaction is read-only at the database level; edits require an explicit `--write` opt-in per launch, and every edit is addressed by its full primary key (never a predicate).
16
16
  2. **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.
17
- 3. **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages to keep in lockstep. The main entry's **import graph** is ~53 kB brotli (edge ~40 kB) with `pg` external (that is the client footprint your bundler sees, not the dual ESM+CJS install size on disk, ~3 MB). Prisma 7 dropped its Rust query engine, but its client still ships a TypeScript/WASM query compiler: a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
17
+ 3. **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages to keep in lockstep. The main entry's **import graph** is ~59 kB brotli (edge ~44 kB) with `pg` external (that is the client footprint your bundler sees, not the dual ESM+CJS install size on disk, ~3 MB). Prisma 7 dropped its Rust query engine, but its client still ships a TypeScript/WASM query compiler: a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
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
22
  See [How It Works](#how-it-works) for the `json_agg` query strategy itself, but the query strategy isn't why you'd pick Turbine. The safety bundle above is: a Studio that is read-only unless you explicitly opt in to writes, PII columns that stay out of results until asked for, errors that never leak data, one dependency, and checksummed migrations.
23
23
 
24
- **New in 0.28.0:** [global filters](https://turbineorm.dev/global-filters) for soft-delete and multi-tenancy · [read replicas](https://turbineorm.dev/read-replicas) with a `$primary()` escape hatch · a read-only [MCP server](https://turbineorm.dev/mcp) for AI agents · [seed-as-code](https://turbineorm.dev/seeding) and a non-interactive `migrate deploy` for CI · [Zod generation](https://turbineorm.dev/zod) · read-only [views & generated columns](https://turbineorm.dev/views) · `NULLS FIRST/LAST` ordering, relation `_count`, and ordering by a relation · schema referential actions, enums, array, `vector`, and check constraints.
24
+ **Beyond the safety bundle, what ships today:** [global filters](https://turbineorm.dev/global-filters) for soft-delete and multi-tenancy · [read replicas](https://turbineorm.dev/read-replicas) with a `$primary()` escape hatch · a read-only [MCP server](https://turbineorm.dev/mcp) for AI agents · [seed-as-code](https://turbineorm.dev/seeding) and a non-interactive `migrate deploy` for CI · [Zod generation](https://turbineorm.dev/zod) · read-only [views & generated columns](https://turbineorm.dev/views) · [optional SQLite / MySQL / SQL Server / PowDB engines](https://turbineorm.dev/engines) behind subpath exports · a [Prisma migration toolkit](https://turbineorm.dev/migrate-from-prisma) (schema mapper plus a runtime compat adapter) · a cost-aware index advisor in [`turbine doctor`](https://turbineorm.dev/cli#turbine-doctor).
25
25
 
26
- ## Benchmarks
27
-
28
- Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizzle 0.45** (relational queries) on a **local PostgreSQL 17.9** database over a Unix socket. 200 iterations, 20 warmup, Node v24. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config. _Measured 2026-07-14 on turbine-orm 0.32.0 (Apple Silicon MacBook Pro, macOS). A local socket has no network round-trip, so these numbers are sub-millisecond and are **not** comparable to the earlier pooled-Neon table: they isolate per-query overhead instead of hiding it behind ~35 ms of network latency. See [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) to reproduce._
26
+ Per-release detail lives in the [CHANGELOG](./CHANGELOG.md) and at [turbineorm.dev/changelog](https://turbineorm.dev/changelog).
29
27
 
30
- | Scenario | Turbine | Prisma 7 | Drizzle 0.45 |
31
- |---|---|---|---|
32
- | findMany, 100 users (flat) | **0.22 ms** | 0.53 ms | 0.34 ms |
33
- | findMany, 50 users + posts (L2) | 2.41 ms | 4.63 ms | **1.82 ms** |
34
- | findMany, 10 users → posts → comments (L3) | 1.13 ms | 3.69 ms | **1.01 ms** |
35
- | findUnique, single user by PK | **0.06 ms** | 0.11 ms | 0.09 ms |
36
- | findUnique, user + posts + comments (L3) | **0.18 ms** | 0.43 ms | 0.30 ms |
37
- | count, all users | **0.06 ms** | 0.08 ms | 0.07 ms |
38
- | stream, iterate 50K rows (batch 1000) | 58.6 ms | 69.7 ms | **48.9 ms** |
39
- | atomic increment, `view_count + 1` | 0.13 ms | 0.23 ms | **0.11 ms** |
40
- | pipeline, 5-query batch | **0.20 ms** | 0.61 ms | 0.58 ms |
41
- | hot findUnique, 500x same shape | **0.05 ms** | 0.09 ms | 0.10 ms |
28
+ ## Benchmarks
42
29
 
43
- **Over a local socket the network floor disappears, so per-query overhead becomes the whole signal.** The picture that emerges (stable across two full runs):
30
+ Tested against **Prisma 7.9.0** (`@prisma/adapter-pg`) and **Drizzle 0.45.2** (relational queries) on a **local PostgreSQL 17.9** database over a Unix socket. 200 iterations, 20 warmup, Node v24.18.0. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config. _Measured 2026-07-21 on turbine-orm 0.39.0 (Apple Silicon MacBook Pro, macOS); the harness has not been re-run since, so these are not 0.48.0 numbers. A local socket has no network round-trip, so these numbers are sub-millisecond and are **not** comparable to the earlier pooled-Neon table: they isolate per-query overhead instead of hiding it behind ~35 ms of network latency. See [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) to reproduce._
44
31
 
45
- - **Turbine leads flat reads, findUnique, count, pipeline, and the hot path.** SQL template caching and prepared statements keep its per-call overhead lowest on simple and repeated-shape queries, and its real Postgres pipeline protocol (one TCP flush for 5 queries) runs the dashboard batch ~3x faster than Prisma's or Drizzle's sequential transaction.
46
- - **Drizzle leads nested reads (L2), streaming, and atomic increment.** Its relational query builder emits tighter SQL for the posts/comments joins, and its keyset pagination drains 50K rows fastest. Turbine's `json_agg` nesting is close behind and still 1.9x to 3.3x ahead of Prisma on the same L2/L3 shapes. L3 is a genuine Turbine/Drizzle near-tie that flips between runs.
47
- - **Prisma trails on every scenario here.** Its engine-less client's per-query work is no longer masked by network latency; on a pooled remote database (the regime we measured previously) these same deltas compress back into the noise floor.
32
+ Prisma's nested scenarios run on its **`join`** load strategy: `benchmarks/prisma/schema.prisma` enables the `relationJoins` preview feature, which (per Prisma's docs) makes `join` the client-wide default, and the harness never overrides `relationLoadStrategy` on a query. Without that flag Prisma would fall back to its per-relation `query` strategy and lose the nested scenarios by a wider margin.
48
33
 
49
- Net: on a local socket Turbine wins 6 of 10 scenarios, loses L2 / streaming / atomic increment to Drizzle, and trades the L3 lead run-to-run. It is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only-by-default 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, with no manual assertion and no helper annotation.
34
+ | Scenario | Turbine | Prisma 7.9 | Drizzle 0.45 |
35
+ |---|---|---|---|
36
+ | findMany, 100 users (flat) | **0.29 ms** | 0.37 ms | 0.39 ms |
37
+ | findMany, 50 users + posts (L2) | 2.86 ms | 4.64 ms | **2.39 ms** |
38
+ | findMany, 10 users → posts → comments (L3) *(near-tie)* | 1.55 ms | 4.04 ms | **1.32 ms** |
39
+ | findUnique, single user by PK | **0.06 ms** | 0.12 ms | 0.10 ms |
40
+ | findUnique, user + posts + comments (L3) | **0.18 ms** | 0.45 ms | 0.31 ms |
41
+ | count, all users *(near-tie)* | **0.05 ms** | 0.08 ms | 0.06 ms |
42
+ | stream, iterate 50K rows (batch 1000) *(near-tie)* | **60.7 ms** | 68.8 ms | 65.8 ms |
43
+ | atomic increment, `view_count + 1` *(near-tie)* | **0.14 ms** | 0.19 ms | 0.19 ms |
44
+ | pipeline, 5-query batch | **0.20 ms** | 0.45 ms | 0.41 ms |
45
+ | hot findUnique, 500x same shape | **0.03 ms** | 0.06 ms | 0.08 ms |
46
+
47
+ **Over a local socket the network floor disappears, so per-query overhead becomes the whole signal.** The picture that emerges across two full runs:
48
+
49
+ - **Turbine leads flat reads, both findUnique shapes, pipeline, and the hot path.** SQL template caching and prepared statements keep its per-call overhead lowest on simple and repeated-shape queries, and its real Postgres pipeline protocol (one TCP flush for 5 queries) runs the dashboard batch ~2x faster than Prisma's or Drizzle's sequential transaction.
50
+ - **Drizzle leads nested reads (L2).** Its relational query builder emits tighter SQL for the posts/comments joins. Turbine's `json_agg` nesting is close behind and still 1.6x to 2.6x ahead of Prisma on the same L2/L3 shapes on this run.
51
+ - **Four scenarios are near-ties.** L3 nested, count, streaming, and atomic increment each flipped winner between the two runs. Treat them as within noise on this host rather than a lead for either side.
52
+ - **Prisma trails Turbine on every scenario here.** It edges out Drizzle on the flat read and ties it on the atomic increment, but is behind Turbine on all ten. Its engine-less client's per-query work is no longer masked by network latency; on a pooled remote database (the regime we measured previously) these same deltas compress back into the noise floor. Prisma 7.9 is a real improvement on 7.6 (flat reads ~30% faster, the pipeline batch ~26%).
53
+
54
+ Net: on a local socket Turbine takes five scenarios outright, loses L2 to Drizzle, and trades four more run-to-run. It is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only-by-default 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, with no manual assertion and no helper annotation.
50
55
 
51
56
  > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
52
57
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
@@ -404,21 +409,30 @@ db.$use(async (params, next) => {
404
409
 
405
410
  > **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.
406
411
 
407
- 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:
412
+ Middleware can't rewrite queries, so cross-cutting predicates like **soft deletes** and **multi-tenancy** belong to [global filters](https://turbineorm.dev/global-filters) instead. A global filter is a `WhereClause` that Turbine `AND`-merges into the compiled `WHERE` of every query on a table: reads, the relation subqueries that target it, and the predicate of `update` / `delete` / `upsert`. `create` and `createMany` are never filtered, since a new row has nothing to scope.
408
413
 
409
414
  ```typescript
410
- import type { WhereClause } from 'turbine-orm';
411
-
412
- // Explicit filter
413
- const users = await db.users.findMany({ where: { deletedAt: null } });
415
+ const db = turbine({
416
+ connectionString: process.env.DATABASE_URL,
417
+ globalFilters: {
418
+ // Soft delete (static)
419
+ posts: { deletedAt: null },
420
+ users: { deletedAt: null },
421
+ // Multi-tenancy: a function is evaluated every time a query is built,
422
+ // so a closure over per-request state gives you request-scoped isolation
423
+ orders: () => ({ tenantId: currentTenant() }),
424
+ },
425
+ });
414
426
 
415
- // Scoped helper that always applies the filter
416
- const activeUsers = (where: WhereClause<User> = {}) =>
417
- db.users.findMany({ where: { ...where, deletedAt: null } });
427
+ await db.posts.findMany();
428
+ // SELECT ... FROM "posts" WHERE "deleted_at" IS NULL
418
429
 
419
- const rows = await activeUsers({ orgId: 1 });
430
+ await db.users.findMany({ where: { role: 'admin' } });
431
+ // SELECT ... FROM "users" WHERE "role" = $1 AND "deleted_at" IS NULL
420
432
  ```
421
433
 
434
+ Values are always parameterized. Opt a single query out with `skipGlobalFilters: true` (or a table-name array). Note that a global filter does **not** satisfy the empty-`where` guard: an `update` or `delete` with no `where` of its own still throws unless you pass `allowFullTableScan: true`.
435
+
422
436
  ### Error handling
423
437
 
424
438
  Turbine throws typed errors you can catch programmatically:
@@ -709,7 +723,7 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
709
723
  - **Saved queries.** Named builder queries persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
710
724
  - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
711
725
  - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
712
- - **PII redaction.** Columns tagged `pii: true` in the schema render as a redaction placeholder in every tab. `--show-pii` reveals them, with a loud startup warning.
726
+ - **PII redaction.** Columns tagged `pii: true` in the schema render as a redaction placeholder in every tab. `--show-pii` reveals them, with a loud startup warning. Tags are a code-first declaration (introspection never infers one), so Studio reads them from the generated metadata in your `out` directory; if it finds none it says so at startup rather than implying a protection it cannot apply.
713
727
  - **Opt-in write mode.** `--write` enables insert/update/delete from the Data tab (single rows, multi-select delete, and paste-to-insert batches), gated per row by the full primary key, compiled by the same validated builders, and flagged with a persistent WRITE MODE banner. Read-only stays the default on every launch.
714
728
 
715
729
  **Security posture (read-only by default)**
@@ -881,12 +895,17 @@ const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA)
881
895
  ```ts
882
896
  // PowDB — async; embedded (in-process) or networked. Schema is code-defined.
883
897
  import { turbinePowDB } from 'turbine-orm/powdb';
898
+ import { schemaDefToMetadata } from 'turbine-orm';
884
899
  import { schema } from './schema.js'; // defineSchema({...})
885
900
 
886
- // Embedded, in-process syncMode 'normal' makes writes beat SQLite:
887
- const db = await turbinePowDB({ embedded: './data', syncMode: 'normal' }, schema);
901
+ // PowDB has no introspection-driven `turbine generate`, so derive the runtime
902
+ // SchemaMetadata from the code-first definition.
903
+ const SCHEMA = schemaDefToMetadata(schema);
904
+
905
+ // Embedded, in-process. syncMode 'normal' moves fsync off the commit path:
906
+ const db = await turbinePowDB({ embedded: './data', syncMode: 'normal' }, SCHEMA);
888
907
  // …or networked against a running powdb-server:
889
- // const db = await turbinePowDB('powdb://127.0.0.1:7070', schema);
908
+ // const db = await turbinePowDB('powdb://127.0.0.1:7070', SCHEMA);
890
909
  ```
891
910
 
892
911
  Migrating from Prisma? `turbine migrate-from-prisma` emits a typed `PRISMA_MAP`, and the
@@ -976,11 +995,11 @@ Turbine maps Postgres types to TypeScript:
976
995
  |---|---|---|---|---|
977
996
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
978
997
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
979
- | **Main bundle (brotli)** | ~53 kB | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
998
+ | **Main bundle (brotli)** | ~59 kB | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
980
999
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
981
1000
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
982
1001
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
983
- | **Edge runtime** | One import swap, ~40 kB brotli | Driver adapter + WASM compiler | Native | Native |
1002
+ | **Edge runtime** | One import swap, ~44 kB brotli | Driver adapter + WASM compiler | Native | Native |
984
1003
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
985
1004
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
986
1005
  | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
@@ -997,8 +1016,8 @@ All three ORMs now do single-query nested loads — that's table stakes. Turbine
997
1016
 
998
1017
  Turbine is focused and opinionated. Here's what it doesn't do:
999
1018
 
1000
- - **Postgres-first.** PostgreSQL is the default and primary target — going deep on one database is what enables the safety bundle and the edge-runtime story. SQLite, MySQL 8, and SQL Server engines are available as additive subpath exports (see [Database engines](#database-engines)), but several flagship features (pgvector, LISTEN/NOTIFY, RLS `sessionContext`) are Postgres-only and throw `UnsupportedFeatureError` elsewhere.
1001
- - **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`.
1019
+ - **Postgres-first.** PostgreSQL is the default and primary target — going deep on one database is what enables the safety bundle and the edge-runtime story. SQLite, MySQL 8, and SQL Server engines are available as additive subpath exports (see [Database engines](#database-engines)), but several flagship features (pgvector, LISTEN/NOTIFY, RLS `sessionContext`, full-text `search`, array-column filters, `groupBy({ distinctOn })`) are Postgres-only and throw `UnsupportedFeatureError` elsewhere.
1020
+ - **Full-text search** is available via a `search` filter — `where: { title: { search: 'hello & world', config: 'english' } }` compiles to a parameterized `to_tsvector(...) @@ to_tsquery(...)`. PostgreSQL only: the other engines throw `UnsupportedFeatureError` (`TURBINE_E017`) rather than degrade to a `LIKE`. For advanced ranking (`ts_rank`, weighted vectors) use `db.raw`.
1002
1021
  - **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.
1003
1022
 
1004
1023
  ## Examples
@@ -11,10 +11,16 @@
11
11
  * Deliberately conservative in BOTH directions:
12
12
  * - comments and string literals are stripped first, so `-- DROP TABLE foo`
13
13
  * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
14
- * - anything that removes rows, columns, tables, or schemas or rewrites a
15
- * column's type (a potentially lossy cast) is flagged. `DROP INDEX`,
14
+ * - anything that removes rows, columns, tables, or schemas, or rewrites a
15
+ * column's type (a potentially lossy cast), is flagged. `DROP INDEX`,
16
16
  * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
17
17
  * structures; no row data lost).
18
+ *
19
+ * Row removal hides in more than a leading `DELETE`, so the scan also covers:
20
+ * the optional-`COLUMN` shorthand (`ALTER TABLE t DROP email`), data-modifying
21
+ * CTEs (`WITH d AS (DELETE ...) SELECT ...`), `MERGE ... THEN DELETE`, dynamic
22
+ * SQL inside a `DO`/function body, and an `UPDATE` whose only WHERE sits inside
23
+ * a subquery (which restricts nothing).
18
24
  */
19
25
  Object.defineProperty(exports, "__esModule", { value: true });
20
26
  exports.DESTRUCTIVE_KIND_LABEL = void 0;
@@ -23,14 +29,25 @@ exports.scanDestructiveSql = scanDestructiveSql;
23
29
  exports.DESTRUCTIVE_KIND_LABEL = {
24
30
  'drop-table': 'drops a table and ALL its rows',
25
31
  'drop-schema': 'drops an entire schema',
32
+ 'drop-database': 'drops an entire database and everything in it',
33
+ 'drop-owned': 'drops every object owned by a role, and their rows',
34
+ 'drop-matview': 'drops a materialized view and its stored rows',
26
35
  'drop-column': 'drops a column and its data in every row',
27
36
  truncate: 'deletes every row',
28
37
  delete: 'deletes rows',
29
38
  'update-without-where': 'rewrites every row (no WHERE clause)',
30
39
  'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
40
+ 'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
31
41
  };
42
+ /**
43
+ * A dollar-quote tag. Postgres allows digits after the first character
44
+ * (`$do1$`), so a tag regex that stops at letters reads the body as code and
45
+ * misses everything inside it. Same shape as the splitter in `migrate.ts`.
46
+ */
47
+ const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/;
32
48
  /** Strip -- line comments, C-style block comments, and quoted literals. */
33
49
  function stripCommentsAndStrings(sql) {
50
+ const blocks = [];
34
51
  let out = '';
35
52
  let i = 0;
36
53
  while (i < sql.length) {
@@ -45,10 +62,17 @@ function stripCommentsAndStrings(sql) {
45
62
  out += ' ';
46
63
  }
47
64
  else if (sql[i] === "'") {
48
- // single-quoted literal ('' escapes a quote)
65
+ // Single-quoted literal. `''` always escapes a quote; inside an E-string
66
+ // (`E'...'`) a backslash escapes the next character too, so `E'a\'b'` is
67
+ // ONE literal. Without the E-string case the scan ends the literal at the
68
+ // backslash-quote and treats the rest of the file as code, which
69
+ // (worse) then hides every following statement from the guard.
70
+ const escapes = isEscapeStringPrefix(sql, i);
49
71
  let j = i + 1;
50
72
  while (j < sql.length) {
51
- if (sql[j] === "'" && sql[j + 1] === "'")
73
+ if (escapes && sql[j] === '\\')
74
+ j += 2;
75
+ else if (sql[j] === "'" && sql[j + 1] === "'")
52
76
  j += 2;
53
77
  else if (sql[j] === "'")
54
78
  break;
@@ -58,10 +82,28 @@ function stripCommentsAndStrings(sql) {
58
82
  i = j + 1;
59
83
  out += "''";
60
84
  }
61
- else if (sql[i] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(i))) {
85
+ else if (sql[i] === '"') {
86
+ // Quoted identifier. Kept VERBATIM (rules match on identifiers), but it
87
+ // has to be consumed as one token: an apostrophe inside a quoted name
88
+ // (`"customer's_orders"`) would otherwise open a string literal and hide
89
+ // every statement after it from the scan.
90
+ let j = i + 1;
91
+ while (j < sql.length) {
92
+ if (sql[j] === '"' && sql[j + 1] === '"')
93
+ j += 2;
94
+ else if (sql[j] === '"')
95
+ break;
96
+ else
97
+ j++;
98
+ }
99
+ out += sql.slice(i, Math.min(j + 1, sql.length));
100
+ i = j + 1;
101
+ }
102
+ else if (sql[i] === '$' && DOLLAR_TAG.test(sql.slice(i))) {
62
103
  // dollar-quoted literal ($$...$$ / $tag$...$tag$)
63
- const tag = sql.slice(i).match(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
104
+ const tag = sql.slice(i).match(DOLLAR_TAG)?.[0] ?? '$$';
64
105
  const end = sql.indexOf(tag, i + tag.length);
106
+ blocks.push({ at: out.length, body: sql.slice(i + tag.length, end === -1 ? sql.length : end) });
65
107
  i = end === -1 ? sql.length : end + tag.length;
66
108
  out += "''";
67
109
  }
@@ -70,7 +112,23 @@ function stripCommentsAndStrings(sql) {
70
112
  i++;
71
113
  }
72
114
  }
73
- return out;
115
+ return { text: out, blocks };
116
+ }
117
+ /**
118
+ * True when the quote at `quoteAt` opens an E-string (`E'...'`), where a
119
+ * backslash escapes the next character. The preceding `E` must not itself be
120
+ * part of an identifier, so `some_table'` never turns the following literal
121
+ * into an E-string. Ordinary literals are left alone on purpose: with the
122
+ * modern `standard_conforming_strings = on` default, `'a\'` IS a complete
123
+ * string. Mirrors the same-named helper in `migrate.ts`; kept local so this
124
+ * module stays a pure leaf with no CLI imports of its own.
125
+ */
126
+ function isEscapeStringPrefix(sql, quoteAt) {
127
+ const prev = sql[quoteAt - 1];
128
+ if (prev !== 'E' && prev !== 'e')
129
+ return false;
130
+ const before = sql[quoteAt - 2];
131
+ return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
74
132
  }
75
133
  /** Unquote a "quoted" identifier for display. */
76
134
  const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
@@ -87,15 +145,34 @@ const RULES = [
87
145
  regex: new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
88
146
  target: (m) => ident(m[2]),
89
147
  },
148
+ {
149
+ kind: 'drop-matview',
150
+ regex: new RegExp(String.raw `^DROP\s+MATERIALIZED\s+VIEW\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
151
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
152
+ },
153
+ {
154
+ kind: 'drop-database',
155
+ regex: new RegExp(String.raw `^DROP\s+DATABASE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
156
+ target: (m) => ident(m[2]),
157
+ },
158
+ {
159
+ // `DROP OWNED BY role` removes every object that role owns, rows included.
160
+ kind: 'drop-owned',
161
+ regex: new RegExp(String.raw `^DROP\s+OWNED\s+BY\s+${IDENT}`, 'i'),
162
+ target: (m) => ident(m[1]),
163
+ },
90
164
  {
91
165
  kind: 'truncate',
92
166
  regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
93
167
  target: (m) => (m[5] ? `${ident(m[3])}.${ident(m[5])}` : ident(m[3])),
94
168
  },
95
169
  {
170
+ // `COLUMN` is OPTIONAL in Postgres: `ALTER TABLE t DROP email` drops the
171
+ // column and its data exactly like the spelled-out form. The lookahead
172
+ // excludes the other `DROP <thing>` sub-actions, none of which lose rows.
96
173
  kind: 'drop-column',
97
- regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+COLUMN\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
98
- target: (m) => `${ident(m[3])}.${ident(m[7])}`,
174
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(COLUMN\s+)?(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
175
+ target: (m) => `${ident(m[3])}.${ident(m[8])}`,
99
176
  },
100
177
  {
101
178
  kind: 'alter-column-type',
@@ -105,15 +182,130 @@ const RULES = [
105
182
  {
106
183
  kind: 'delete',
107
184
  regex: new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i'),
108
- target: (m) => ident(m[2]),
185
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
186
+ },
187
+ {
188
+ // MERGE's DELETE action removes rows from the target table.
189
+ kind: 'merge-delete',
190
+ regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b[\s\S]*?\bTHEN\s+DELETE\b`, 'i'),
191
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
109
192
  },
110
193
  {
194
+ // A WHERE inside a scalar subquery (`SET x = (SELECT ... WHERE ...)`) does
195
+ // NOT restrict the rows updated, so the guard tests only the TOP level.
111
196
  kind: 'update-without-where',
112
197
  regex: new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'),
113
- target: (m) => ident(m[2]),
114
- also: (stmt) => !/\bWHERE\b/i.test(stmt),
198
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
199
+ also: (stmt) => !hasTopLevelWhere(stmt),
115
200
  },
116
201
  ];
202
+ /** True when the statement has a `WHERE` outside every parenthesized group. */
203
+ function hasTopLevelWhere(stmt) {
204
+ const re = /[()]|\bWHERE\b/gi;
205
+ let depth = 0;
206
+ let m = re.exec(stmt);
207
+ while (m !== null) {
208
+ if (m[0] === '(')
209
+ depth++;
210
+ else if (m[0] === ')')
211
+ depth = Math.max(0, depth - 1);
212
+ else if (depth === 0)
213
+ return true;
214
+ m = re.exec(stmt);
215
+ }
216
+ return false;
217
+ }
218
+ /**
219
+ * A leading CTE list is a prefix, not a statement: `WITH c AS (SELECT 1) DELETE
220
+ * FROM users` is a plain DELETE that the anchored rules would otherwise skip.
221
+ * Strip balanced `WITH name AS ( ... )` groups (and their comma-separated
222
+ * siblings) so the real statement head is what gets matched. The CTE bodies
223
+ * themselves are handled separately by {@link cteSubstatements}.
224
+ */
225
+ function stripLeadingCtes(stmt) {
226
+ if (!/^WITH\b/i.test(stmt))
227
+ return stmt;
228
+ let rest = stmt.replace(/^WITH\s+(RECURSIVE\s+)?/i, '');
229
+ for (;;) {
230
+ const open = rest.indexOf('(');
231
+ if (open === -1)
232
+ return stmt;
233
+ const close = closingParenIndex(rest, open);
234
+ rest = rest.slice(close + 1).trimStart();
235
+ if (rest.startsWith(',')) {
236
+ rest = rest.slice(1).trimStart();
237
+ continue;
238
+ }
239
+ return rest;
240
+ }
241
+ }
242
+ /** First matching rule for one candidate fragment, or null. */
243
+ function matchRules(candidate) {
244
+ for (const rule of RULES) {
245
+ const m = candidate.match(rule.regex);
246
+ if (!m)
247
+ continue;
248
+ if (rule.also && !rule.also(candidate))
249
+ continue;
250
+ return { kind: rule.kind, target: rule.target(m) };
251
+ }
252
+ return null;
253
+ }
254
+ /**
255
+ * Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
256
+ * real DELETE even though the statement reads as a SELECT. Each candidate is cut
257
+ * at the paren that closes its CTE, so the outer query's WHERE cannot mask a
258
+ * `WITH u AS (UPDATE t SET ...) SELECT ... WHERE ...`.
259
+ */
260
+ function cteSubstatements(stmt) {
261
+ if (!/^WITH\b/i.test(stmt))
262
+ return [];
263
+ const out = [];
264
+ const re = /\(\s*(?=(?:DELETE|UPDATE|INSERT|TRUNCATE|DROP|ALTER|MERGE)\b)/gi;
265
+ let m = re.exec(stmt);
266
+ while (m !== null) {
267
+ const start = m.index + m[0].length;
268
+ out.push(stmt.slice(start, closingParenIndex(stmt, m.index)));
269
+ m = re.exec(stmt);
270
+ }
271
+ return out;
272
+ }
273
+ /** Index of the `)` closing the `(` at `openAt`, or the end of the string. */
274
+ function closingParenIndex(stmt, openAt) {
275
+ let depth = 0;
276
+ for (let i = openAt; i < stmt.length; i++) {
277
+ if (stmt[i] === '(')
278
+ depth++;
279
+ else if (stmt[i] === ')') {
280
+ depth--;
281
+ if (depth === 0)
282
+ return i;
283
+ }
284
+ }
285
+ return stmt.length;
286
+ }
287
+ /** Statements whose dollar-quoted body is executable SQL rather than data. */
288
+ const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
289
+ /**
290
+ * Candidate fragments inside a procedural body (a `DO $$ ... $$` block or a
291
+ * function source). The body's own string literals are NOT stripped here: the
292
+ * whole point is dynamic SQL, whose payload lives in a literal
293
+ * (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so every keyword-leading
294
+ * position in the body is offered as its own candidate. This deliberately
295
+ * over-reports (a body that merely mentions "drop table" in a message string is
296
+ * flagged) in keeping with the module's false-positives-only asymmetry.
297
+ */
298
+ function proceduralCandidates(body) {
299
+ const withoutComments = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
300
+ const out = [];
301
+ const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
302
+ let m = re.exec(withoutComments);
303
+ while (m !== null) {
304
+ out.push(withoutComments.slice(m.index));
305
+ m = re.exec(withoutComments);
306
+ }
307
+ return out;
308
+ }
117
309
  /**
118
310
  * Scan SQL (one file's worth; may contain many `;`-separated statements) and
119
311
  * return every statement that can destroy data.
@@ -121,17 +313,40 @@ const RULES = [
121
313
  function scanDestructiveSql(sql) {
122
314
  const found = [];
123
315
  const cleaned = stripCommentsAndStrings(sql);
124
- for (const rawStmt of cleaned.split(';')) {
316
+ let offset = 0;
317
+ for (const rawStmt of cleaned.text.split(';')) {
318
+ const start = offset;
319
+ const end = offset + rawStmt.length;
320
+ offset = end + 1; // the ';' consumed by split
125
321
  const stmt = rawStmt.trim();
126
322
  if (!stmt)
127
323
  continue;
128
- for (const rule of RULES) {
129
- const m = stmt.match(rule.regex);
130
- if (!m)
324
+ // Top level, then data-modifying CTEs, then any procedural body this
325
+ // statement blanked. First match per statement wins, as before.
326
+ const display = stmt.replace(/\s+/g, ' ');
327
+ const candidates = [
328
+ stripLeadingCtes(stmt),
329
+ ...cteSubstatements(stmt),
330
+ ].map((text) => ({
331
+ text,
332
+ display,
333
+ }));
334
+ // Only a DO block / routine body is procedural SQL. A dollar-quoted literal
335
+ // used as DATA (`INSERT ... VALUES ($$DELETE FROM x$$)`) stays a literal.
336
+ const procedural = PROCEDURAL_STATEMENT.test(stmt);
337
+ for (const block of procedural ? cleaned.blocks : []) {
338
+ if (block.at < start || block.at >= end)
131
339
  continue;
132
- if (rule.also && !rule.also(stmt))
340
+ for (const text of proceduralCandidates(block.body)) {
341
+ // The body was blanked in `display`, so name the fragment that matched.
342
+ candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
343
+ }
344
+ }
345
+ for (const candidate of candidates) {
346
+ const hit = matchRules(candidate.text);
347
+ if (!hit)
133
348
  continue;
134
- found.push({ statement: stmt.replace(/\s+/g, ' '), kind: rule.kind, target: rule.target(m) });
349
+ found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
135
350
  break;
136
351
  }
137
352
  }
@@ -2168,9 +2168,14 @@ async function cmdDoctor(args, config) {
2168
2168
  const unusedRan = args.unused === true;
2169
2169
  const auditRan = args.audit === true;
2170
2170
  const minScans = args.minScans;
2171
- const unused = unusedRan ? (0, index_stats_js_1.findUnusedIndexes)(snapshot, { minScans }) : [];
2171
+ // Never suggest dropping an index that still serves a relation probe: the
2172
+ // missing-index half of this very report demands it.
2173
+ const relationProbes = (0, index_advisor_js_1.collectRelationProbeColumns)(schema);
2174
+ const unused = unusedRan ? (0, index_stats_js_1.findUnusedIndexes)(snapshot, { minScans, relationProbes }) : [];
2172
2175
  const redundant = unusedRan ? (0, index_stats_js_1.findRedundantIndexes)(snapshot) : [];
2173
- const audit = auditRan ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans }) : [];
2176
+ const audit = auditRan
2177
+ ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans, relationProbes })
2178
+ : [];
2174
2179
  const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
2175
2180
  if (jsonMode) {
2176
2181
  spinner?.stop();
@@ -2240,15 +2245,19 @@ function buildDoctorJson(ctx) {
2240
2245
  })),
2241
2246
  invalidIndexes: ctx.invalid,
2242
2247
  };
2243
- // Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
2244
- if (ctx.subtract.unusedRan) {
2245
- out.unused = ctx.subtract.unused;
2246
- out.redundant = ctx.subtract.redundant;
2247
- out.invalid = ctx.invalid;
2248
- }
2249
- if (ctx.subtract.auditRan) {
2250
- out.audit = ctx.subtract.audit;
2251
- }
2248
+ // The keys below are ALWAYS present under `schemaVersion: 1`: a declared
2249
+ // schema version that changes shape by flag forces every consumer to write
2250
+ // `json.unused ?? []`. Which subtraction scans actually ran is reported as
2251
+ // data (`ran`), not as the presence or absence of a key.
2252
+ out.subtraction = {
2253
+ unusedRan: ctx.subtract.unusedRan,
2254
+ auditRan: ctx.subtract.auditRan,
2255
+ minScans: ctx.subtract.minScans ?? null,
2256
+ };
2257
+ out.unused = ctx.subtract.unusedRan ? ctx.subtract.unused : [];
2258
+ out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
2259
+ out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
2260
+ out.invalid = ctx.invalid;
2252
2261
  return out;
2253
2262
  }
2254
2263
  async function renderDoctorHuman(ctx) {
@@ -2312,6 +2321,15 @@ function renderUnusedCaveats(minScans, snapshot) {
2312
2321
  console.log(` ${(0, ui_js_1.dim)(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
2313
2322
  console.log(` ${(0, ui_js_1.dim)(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
2314
2323
  console.log(` ${(0, ui_js_1.dim)('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
2324
+ console.log(` ${(0, ui_js_1.dim)('Indexes that still serve a relation Turbine probes are withheld: this report demands those.')}`);
2325
+ // The cost section refuses to SCORE on stats this young; prescribing drops off
2326
+ // the same counters in the next section would be the report contradicting
2327
+ // itself. Say so where the advice is, not only where the scoring is.
2328
+ if (snapshot.statsAgeDays !== null && snapshot.statsAgeDays < index_stats_js_1.STATS_THRESHOLDS.minStatsAgeDays) {
2329
+ console.log(` ${(0, ui_js_1.yellow)(`Statistics are only ${ageLabel} old, below the ${index_stats_js_1.STATS_THRESHOLDS.minStatsAgeDays}d floor this report uses to score cost.`)}`);
2330
+ console.log(` ${(0, ui_js_1.yellow)('Treat everything below as a list to investigate, not advice to act on: an index your')}`);
2331
+ console.log(` ${(0, ui_js_1.yellow)('workload simply has not reached yet looks identical to a dead one.')}`);
2332
+ }
2315
2333
  (0, ui_js_1.newline)();
2316
2334
  }
2317
2335
  /** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
@@ -2323,6 +2341,10 @@ function renderUnusedIndexes(unused, minScans, snapshot) {
2323
2341
  renderUnusedCaveats(minScans, snapshot);
2324
2342
  for (const u of unused) {
2325
2343
  console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(u.table))} ${(0, ui_js_1.dim)(`(${u.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(`${u.indexName} · ${u.idxScan} scans · ${(0, index_stats_js_1.formatBytes)(u.sizeBytes)}`)}`);
2344
+ // A functional / partial / non-btree index is not a plain `(col)` rebuild:
2345
+ // say what it actually is before anyone runs the DROP.
2346
+ if (u.caveat)
2347
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.yellow)(u.caveat)}`);
2326
2348
  console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(u.dropSql)}`);
2327
2349
  (0, ui_js_1.newline)();
2328
2350
  }
@@ -2357,7 +2379,13 @@ function renderDoctorAudit(audit, minScans, snapshot) {
2357
2379
  if (a.ambiguous) {
2358
2380
  console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
2359
2381
  }
2360
- console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(a.dropSql)}`);
2382
+ if (a.stillProbed) {
2383
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.dim)('kept: a relation in your schema still probes these columns, so dropping it would')}`);
2384
+ console.log(` ${(0, ui_js_1.dim)('reappear as a missing-index finding in this same report.')}`);
2385
+ }
2386
+ else if (a.dropSql) {
2387
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(a.dropSql)}`);
2388
+ }
2361
2389
  (0, ui_js_1.newline)();
2362
2390
  }
2363
2391
  console.log(` ${(0, ui_js_1.dim)('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
@@ -2515,6 +2543,7 @@ async function cmdStudio(args, config) {
2515
2543
  write: args.write === true,
2516
2544
  showPii: args.showPii === true,
2517
2545
  demo,
2546
+ metadataDir: config.out,
2518
2547
  });
2519
2548
  spinner.succeed(demo ? 'Demo Studio is running' : 'Studio is running');
2520
2549
  }
@@ -2549,6 +2578,20 @@ async function cmdStudio(args, config) {
2549
2578
  (0, ui_js_1.newline)();
2550
2579
  console.log((0, ui_js_1.warn)('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
2551
2580
  }
2581
+ // PII tags are a code-first declaration; introspection never infers them.
2582
+ // Say plainly whether any reached this session, so nobody assumes a
2583
+ // redaction guarantee that has nothing to act on.
2584
+ if (!args.showPii) {
2585
+ (0, ui_js_1.newline)();
2586
+ if (studio.piiTags && studio.piiTags.applied > 0) {
2587
+ console.log(` ${(0, ui_js_1.dim)('PII redaction:')} ${studio.piiTags.applied} tagged column(s) from ${(0, ui_js_1.dim)(studio.piiTags.path)}`);
2588
+ }
2589
+ else {
2590
+ console.log((0, ui_js_1.warn)('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
2591
+ `(defineSchema \`pii: true\`) and read from generated metadata in ${config.out}; ` +
2592
+ 'introspection alone never infers them. Run `turbine generate` after tagging.'));
2593
+ }
2594
+ }
2552
2595
  (0, ui_js_1.newline)();
2553
2596
  console.log((0, ui_js_1.box)([
2554
2597
  `${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
@@ -2589,6 +2632,7 @@ async function cmdMcp(_args, config) {
2589
2632
  url,
2590
2633
  schema: config.schema,
2591
2634
  migrationsDir: config.migrationsDir,
2635
+ metadataDir: config.out,
2592
2636
  include: config.include.length ? config.include : undefined,
2593
2637
  exclude: config.exclude.length ? config.exclude : undefined,
2594
2638
  });