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.
- package/README.md +58 -39
- package/dist/cjs/cli/destructive.js +233 -18
- package/dist/cjs/cli/index.js +56 -12
- package/dist/cjs/cli/mcp.js +23 -2
- package/dist/cjs/cli/migrate.js +28 -1
- package/dist/cjs/cli/pii-tags.js +111 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +158 -0
- package/dist/cjs/cli/ui.js +8 -3
- package/dist/cjs/client.js +21 -1
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +118 -6
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +5 -0
- package/dist/cjs/nested-write.js +248 -18
- package/dist/cjs/observe.js +21 -15
- package/dist/cjs/powdb.js +3 -0
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +9 -0
- package/dist/cjs/query/aggregates.js +41 -1
- package/dist/cjs/query/batched-loader.js +70 -6
- package/dist/cjs/query/builder.js +3 -3
- package/dist/cjs/query/relations.js +12 -2
- package/dist/cjs/query/where.js +36 -1
- package/dist/cjs/sqlite.js +5 -0
- package/dist/cli/destructive.d.ts +9 -3
- package/dist/cli/destructive.js +233 -18
- package/dist/cli/index.js +57 -13
- package/dist/cli/mcp.d.ts +7 -0
- package/dist/cli/mcp.js +23 -2
- package/dist/cli/migrate.d.ts +2 -1
- package/dist/cli/migrate.js +28 -1
- package/dist/cli/pii-tags.d.ts +53 -0
- package/dist/cli/pii-tags.js +106 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +42 -0
- package/dist/cli/studio.js +157 -0
- package/dist/cli/ui.js +8 -3
- package/dist/client.js +21 -1
- package/dist/dialect.d.ts +19 -0
- package/dist/dialect.js +2 -0
- package/dist/index-advisor.d.ts +7 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +52 -1
- package/dist/index-stats.js +117 -5
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +5 -0
- package/dist/nested-write.js +249 -19
- package/dist/observe.d.ts +0 -1
- package/dist/observe.js +21 -15
- package/dist/powdb.js +3 -0
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.js +9 -0
- package/dist/query/aggregates.d.ts +18 -0
- package/dist/query/aggregates.js +40 -1
- package/dist/query/batched-loader.d.ts +29 -1
- package/dist/query/batched-loader.js +69 -6
- package/dist/query/builder.js +4 -4
- package/dist/query/relations.js +12 -2
- package/dist/query/types.d.ts +16 -0
- package/dist/query/where.d.ts +18 -1
- package/dist/query/where.js +34 -1
- package/dist/sqlite.js +5 -0
- 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 ~
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
887
|
-
|
|
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',
|
|
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)** | ~
|
|
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, ~
|
|
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
|
|
15
|
-
* column's type (a potentially lossy cast)
|
|
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
|
-
//
|
|
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 (
|
|
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] === '
|
|
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(
|
|
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[
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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:
|
|
349
|
+
found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
|
|
135
350
|
break;
|
|
136
351
|
}
|
|
137
352
|
}
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
-
|
|
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
|
});
|