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/dist/cli/studio.d.ts
CHANGED
|
@@ -68,10 +68,26 @@ export interface StudioOptions {
|
|
|
68
68
|
* three-mode switcher (`/api/demo/mode`). Default `false`.
|
|
69
69
|
*/
|
|
70
70
|
demo?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Directory holding generated Turbine metadata (`turbine generate`'s `out`).
|
|
73
|
+
* PII tags are code-first declarations that introspection never sets, so
|
|
74
|
+
* without this Studio's redaction has nothing to redact against a real
|
|
75
|
+
* database. Read as text; nothing from it is executed. See `pii-tags.ts`.
|
|
76
|
+
*/
|
|
77
|
+
metadataDir?: string;
|
|
71
78
|
}
|
|
72
79
|
export interface StudioHandle {
|
|
73
80
|
/** Shut down the server + pool cleanly. */
|
|
74
81
|
dispose: () => Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Where PII tags came from, so the CLI can say so at startup. `null` when no
|
|
84
|
+
* generated metadata was found: redaction is then inert and the user needs to
|
|
85
|
+
* know that rather than assume protection.
|
|
86
|
+
*/
|
|
87
|
+
piiTags: {
|
|
88
|
+
path: string;
|
|
89
|
+
applied: number;
|
|
90
|
+
} | null;
|
|
75
91
|
/** Random per-process session token the UI sends via cookie. */
|
|
76
92
|
authToken: string;
|
|
77
93
|
/** Full URL including `?token=...` — safe to print for the user. */
|
|
@@ -138,6 +154,32 @@ export interface StudioContext {
|
|
|
138
154
|
export declare function startStudio(options: StudioOptions): Promise<StudioHandle>;
|
|
139
155
|
export declare function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
140
156
|
export declare function apiDemoMode(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
157
|
+
/** One clickable reference from a column on this table to a row in another. */
|
|
158
|
+
export interface ForeignKeyLink {
|
|
159
|
+
/** Column on THIS table whose cell becomes the link. */
|
|
160
|
+
column: string;
|
|
161
|
+
/** Relation the link was derived from. */
|
|
162
|
+
relation: string;
|
|
163
|
+
/** Table the reference points at. */
|
|
164
|
+
targetTable: string;
|
|
165
|
+
/** Column on the target table the value is matched against. */
|
|
166
|
+
targetColumn: string;
|
|
167
|
+
}
|
|
168
|
+
/** One reverse jump: rows in a child table that reference this table. */
|
|
169
|
+
export interface ReferencedByLink {
|
|
170
|
+
/** Column on THIS table whose value the child column matches (usually the PK). */
|
|
171
|
+
column: string;
|
|
172
|
+
/** Relation the link was derived from. */
|
|
173
|
+
relation: string;
|
|
174
|
+
/** Child table holding the referencing rows. */
|
|
175
|
+
targetTable: string;
|
|
176
|
+
/** Referencing column on the child table. */
|
|
177
|
+
targetColumn: string;
|
|
178
|
+
}
|
|
179
|
+
export declare function relationLinksForTable(table: TableMetadata, metadata: SchemaMetadata, showPii: boolean): {
|
|
180
|
+
foreignKeys: ForeignKeyLink[];
|
|
181
|
+
referencedBy: ReferencedByLink[];
|
|
182
|
+
};
|
|
141
183
|
export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
|
|
142
184
|
export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
|
|
143
185
|
export declare function isTextishType(pgType: string): boolean;
|
package/dist/cli/studio.js
CHANGED
|
@@ -43,8 +43,10 @@ import { createServer } from 'node:http';
|
|
|
43
43
|
import { platform } from 'node:os';
|
|
44
44
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
45
45
|
import pg from 'pg';
|
|
46
|
+
import { ValidationError } from '../errors.js';
|
|
46
47
|
import { introspect } from '../introspect.js';
|
|
47
48
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
49
|
+
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
48
50
|
import { createDemoContext } from './studio-demo.js';
|
|
49
51
|
import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
50
52
|
// ---------------------------------------------------------------------------
|
|
@@ -65,6 +67,7 @@ export async function startStudio(options) {
|
|
|
65
67
|
let metadata;
|
|
66
68
|
let dialect;
|
|
67
69
|
let statementTimeout;
|
|
70
|
+
let piiTags = null;
|
|
68
71
|
if (demo) {
|
|
69
72
|
// Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
|
|
70
73
|
// starts pristine and nothing is ever persisted.
|
|
@@ -98,6 +101,14 @@ export async function startStudio(options) {
|
|
|
98
101
|
include: options.include,
|
|
99
102
|
exclude: options.exclude,
|
|
100
103
|
});
|
|
104
|
+
// PII tags are code-first only, so a live-introspected schema carries none.
|
|
105
|
+
// Layer them on from the generated metadata when there is one, and record
|
|
106
|
+
// what happened so the CLI can be explicit about it at startup.
|
|
107
|
+
if (options.metadataDir) {
|
|
108
|
+
const source = loadPiiTags(options.metadataDir);
|
|
109
|
+
if (source)
|
|
110
|
+
piiTags = { path: source.path, applied: applyPiiTags(metadata, source.tags) };
|
|
111
|
+
}
|
|
101
112
|
statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
102
113
|
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
103
114
|
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
@@ -147,6 +158,7 @@ export async function startStudio(options) {
|
|
|
147
158
|
return {
|
|
148
159
|
authToken,
|
|
149
160
|
url,
|
|
161
|
+
piiTags,
|
|
150
162
|
dispose: async () => {
|
|
151
163
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
152
164
|
await pool.end();
|
|
@@ -368,6 +380,8 @@ async function apiSchema(res, ctx) {
|
|
|
368
380
|
foreignKey: rel.foreignKey,
|
|
369
381
|
referenceKey: rel.referenceKey,
|
|
370
382
|
})),
|
|
383
|
+
// Click-through navigation targets, derived from relation metadata only.
|
|
384
|
+
...relationLinksForTable(tbl, ctx.metadata, ctx.showPii === true),
|
|
371
385
|
}));
|
|
372
386
|
// Row counts (cheap enough to fetch inline).
|
|
373
387
|
const counts = new Map();
|
|
@@ -404,6 +418,91 @@ async function apiSchema(res, ctx) {
|
|
|
404
418
|
demo: ctx.demo === true,
|
|
405
419
|
});
|
|
406
420
|
}
|
|
421
|
+
/** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
|
|
422
|
+
function ownLookup(map, key) {
|
|
423
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
424
|
+
}
|
|
425
|
+
/** True when `columnName` on `table` is PII-tagged and currently redacted. */
|
|
426
|
+
function isRedactedColumn(table, columnName, showPii) {
|
|
427
|
+
if (showPii)
|
|
428
|
+
return false;
|
|
429
|
+
const col = table.columns.find((c) => c.name === columnName);
|
|
430
|
+
return col?.pii === true;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Resolve a relation key to a single column name on `table`, or `null`. Composite
|
|
434
|
+
* (array) keys return null: a composite reference has no single cell to click, so
|
|
435
|
+
* those relations simply produce no link rather than a half-working one.
|
|
436
|
+
*/
|
|
437
|
+
function singleRelationColumn(table, key) {
|
|
438
|
+
if (typeof key !== 'string')
|
|
439
|
+
return null;
|
|
440
|
+
return resolveColumnName(table, key);
|
|
441
|
+
}
|
|
442
|
+
export function relationLinksForTable(table, metadata, showPii) {
|
|
443
|
+
const foreignKeys = [];
|
|
444
|
+
const referencedBy = [];
|
|
445
|
+
const seenFkColumns = new Set();
|
|
446
|
+
for (const [name, rel] of Object.entries(table.relations)) {
|
|
447
|
+
const target = metadata.tables[rel.to];
|
|
448
|
+
if (!target)
|
|
449
|
+
continue;
|
|
450
|
+
if (rel.type === 'belongsTo') {
|
|
451
|
+
// The FK lives on this table; the reference key on the target.
|
|
452
|
+
const column = singleRelationColumn(table, rel.foreignKey);
|
|
453
|
+
const targetColumn = singleRelationColumn(target, rel.referenceKey);
|
|
454
|
+
if (!column || !targetColumn)
|
|
455
|
+
continue;
|
|
456
|
+
if (seenFkColumns.has(column))
|
|
457
|
+
continue;
|
|
458
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
|
|
459
|
+
continue;
|
|
460
|
+
seenFkColumns.add(column);
|
|
461
|
+
foreignKeys.push({ column, relation: name, targetTable: target.name, targetColumn });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
465
|
+
// The FK lives on the child table; the reference key on this one.
|
|
466
|
+
const column = singleRelationColumn(table, rel.referenceKey);
|
|
467
|
+
const targetColumn = singleRelationColumn(target, rel.foreignKey);
|
|
468
|
+
if (!column || !targetColumn)
|
|
469
|
+
continue;
|
|
470
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
|
|
471
|
+
continue;
|
|
472
|
+
referencedBy.push({ column, relation: name, targetTable: target.name, targetColumn });
|
|
473
|
+
}
|
|
474
|
+
// manyToMany deliberately produces no link: navigating it means traversing a
|
|
475
|
+
// junction table, which the single-column filter path cannot express.
|
|
476
|
+
}
|
|
477
|
+
// Inbound references declared only on the OTHER side. A `defineSchema` author
|
|
478
|
+
// routinely writes `comments.user -> users` without also declaring
|
|
479
|
+
// `users.comments`, and scanning only this table's own hasMany/hasOne made
|
|
480
|
+
// those children unreachable even though the child grid visibly renders the
|
|
481
|
+
// FK. Scan every other table's belongsTo relations that point here.
|
|
482
|
+
const seenInbound = new Set(referencedBy.map((r) => `${r.targetTable}.${r.targetColumn}`));
|
|
483
|
+
for (const other of Object.values(metadata.tables)) {
|
|
484
|
+
if (other.name === table.name)
|
|
485
|
+
continue;
|
|
486
|
+
for (const [name, rel] of Object.entries(other.relations)) {
|
|
487
|
+
if (rel.type !== 'belongsTo' || rel.to !== table.name)
|
|
488
|
+
continue;
|
|
489
|
+
const column = singleRelationColumn(table, rel.referenceKey);
|
|
490
|
+
const targetColumn = singleRelationColumn(other, rel.foreignKey);
|
|
491
|
+
if (!column || !targetColumn)
|
|
492
|
+
continue;
|
|
493
|
+
if (seenInbound.has(`${other.name}.${targetColumn}`))
|
|
494
|
+
continue;
|
|
495
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(other, targetColumn, showPii))
|
|
496
|
+
continue;
|
|
497
|
+
seenInbound.add(`${other.name}.${targetColumn}`);
|
|
498
|
+
// Named for the direction the user travels: from this row, to the rows of
|
|
499
|
+
// `other` that reference it. `relation` is the child's own relation name,
|
|
500
|
+
// which is what the child table calls this link.
|
|
501
|
+
referencedBy.push({ column, relation: name, targetTable: other.name, targetColumn });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { foreignKeys, referencedBy };
|
|
505
|
+
}
|
|
407
506
|
// ---------------------------------------------------------------------------
|
|
408
507
|
// API: /api/tables/:name?limit=&offset=&orderBy=&dir=
|
|
409
508
|
// ---------------------------------------------------------------------------
|
|
@@ -645,6 +744,63 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
645
744
|
// ---------------------------------------------------------------------------
|
|
646
745
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
647
746
|
// ---------------------------------------------------------------------------
|
|
747
|
+
/**
|
|
748
|
+
* Refuse a builder query that FILTERS or SORTS on a redacted PII column.
|
|
749
|
+
*
|
|
750
|
+
* Redacting the cells is not enough on its own: `where: { email: { startsWith:
|
|
751
|
+
* 'a' } }` answers a question about the hidden value, and so does an `isNull`,
|
|
752
|
+
* and so does an `orderBy`. The Data tab already refuses all three
|
|
753
|
+
* (`parseTableFilters`); the builder route accepted them, which mattered the
|
|
754
|
+
* moment PII tags actually started reaching Studio's metadata.
|
|
755
|
+
*
|
|
756
|
+
* Walks the whole args tree: top-level `where` / `orderBy`, boolean
|
|
757
|
+
* combinators, and each `with` level against that relation's target table.
|
|
758
|
+
* `select` is NOT refused: it returns values, and those values are redacted on
|
|
759
|
+
* the way out.
|
|
760
|
+
*/
|
|
761
|
+
function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
762
|
+
if (showPii)
|
|
763
|
+
return;
|
|
764
|
+
const visitClause = (node, table, depth) => {
|
|
765
|
+
if (!table || depth > 10 || node === null || typeof node !== 'object')
|
|
766
|
+
return;
|
|
767
|
+
for (const [key, value] of Object.entries(node)) {
|
|
768
|
+
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
769
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
770
|
+
visitClause(item, table, depth + 1);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
|
|
774
|
+
if (relation) {
|
|
775
|
+
// some / none / every / is / isNot wrappers all resolve against the target.
|
|
776
|
+
visitClause(value, metadata.tables[relation.to], depth + 1);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
const column = ownLookup(table.columnMap, key) ?? key;
|
|
780
|
+
if (isRedactedColumn(table, column, showPii)) {
|
|
781
|
+
throw new ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
|
|
782
|
+
`in a where or orderBy: filtering or sorting on a hidden value reveals it. ` +
|
|
783
|
+
`Restart Studio with --show-pii to query it.`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
const visitLevel = (level, table, depth) => {
|
|
788
|
+
if (!table || depth > 10)
|
|
789
|
+
return;
|
|
790
|
+
visitClause(level.where, table, depth);
|
|
791
|
+
visitClause(level.orderBy, table, depth);
|
|
792
|
+
const withClause = level.with;
|
|
793
|
+
if (!withClause || typeof withClause !== 'object')
|
|
794
|
+
return;
|
|
795
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
796
|
+
const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
|
|
797
|
+
if (!relation || spec === true || spec === null || typeof spec !== 'object')
|
|
798
|
+
continue;
|
|
799
|
+
visitLevel(spec, metadata.tables[relation.to], depth + 1);
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
visitLevel(args, metadata.tables[tableName], 0);
|
|
803
|
+
}
|
|
648
804
|
export async function apiBuilder(req, res, ctx) {
|
|
649
805
|
const body = await readJsonBody(req);
|
|
650
806
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -663,6 +819,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
663
819
|
// when unset.
|
|
664
820
|
dialect: ctx.dialect,
|
|
665
821
|
});
|
|
822
|
+
assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
|
|
666
823
|
deferred = qi.buildFindMany(args);
|
|
667
824
|
}
|
|
668
825
|
catch (err) {
|
package/dist/cli/ui.js
CHANGED
|
@@ -212,9 +212,14 @@ export function stripAnsi(s) {
|
|
|
212
212
|
// ---------------------------------------------------------------------------
|
|
213
213
|
export function redactUrl(url) {
|
|
214
214
|
return (url
|
|
215
|
-
// Userinfo credentials
|
|
216
|
-
//
|
|
217
|
-
|
|
215
|
+
// Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
|
|
216
|
+
// to the LAST `@` before the next `/` (or end of authority), because a
|
|
217
|
+
// password may legally contain `:`, `/`, and even `@` in percent-decoded
|
|
218
|
+
// form. The previous `:([^@/:]+)@` could not span any of those, so
|
|
219
|
+
// `postgres://u:pa/ss@host/db` came through completely unredacted and
|
|
220
|
+
// `postgres://u:a@b@host/db` leaked the tail. Global: one string may
|
|
221
|
+
// carry several URLs (a primary + replica pair).
|
|
222
|
+
.replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
|
|
218
223
|
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
219
224
|
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
220
225
|
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
package/dist/client.js
CHANGED
|
@@ -306,9 +306,29 @@ export class TurbineClient {
|
|
|
306
306
|
// constructor with an opaque "Cannot read properties of undefined
|
|
307
307
|
// (reading 'tables')". Fail fast with an actionable message instead.
|
|
308
308
|
if (!schema || typeof schema !== 'object' || !schema.tables) {
|
|
309
|
+
// A `defineSchema()` result is the most common wrong shape here: it is a
|
|
310
|
+
// SchemaDef (`{ tables: { users: { columns: { id: ... } } } }`-ish builder
|
|
311
|
+
// output), not runtime SchemaMetadata, so name the conversion rather than
|
|
312
|
+
// just the requirement.
|
|
313
|
+
const looksLikeSchemaDef = schema !== null && typeof schema === 'object' && Object.hasOwn(schema, 'name') === false;
|
|
309
314
|
throw new ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
|
|
310
315
|
'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
|
|
311
|
-
'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).'
|
|
316
|
+
'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).' +
|
|
317
|
+
(looksLikeSchemaDef
|
|
318
|
+
? ' If you have a `defineSchema()` result, convert it first with `schemaDefToMetadata(def)`.'
|
|
319
|
+
: ''));
|
|
320
|
+
}
|
|
321
|
+
// A wrong-SHAPED schema (a `defineSchema()` result, whose tables carry no
|
|
322
|
+
// `columns` array) used to survive this check and die later as
|
|
323
|
+
// `TypeError: this.tableMeta.columns is not iterable`, several frames from
|
|
324
|
+
// the cause. Validate one table's shape here, where the fix is obvious.
|
|
325
|
+
for (const [name, meta] of Object.entries(schema.tables)) {
|
|
326
|
+
if (!meta || typeof meta !== 'object' || !Array.isArray(meta.columns)) {
|
|
327
|
+
throw new ValidationError(`[turbine] Table "${name}" in the schema passed to TurbineClient has no \`columns\` array, so this is ` +
|
|
328
|
+
'not runtime SchemaMetadata. A `defineSchema()` result is a SchemaDef: convert it with ' +
|
|
329
|
+
'`schemaDefToMetadata(def)`, or use the metadata emitted by `npx turbine generate`.');
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
312
332
|
}
|
|
313
333
|
/**
|
|
314
334
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
package/dist/dialect.d.ts
CHANGED
|
@@ -298,6 +298,25 @@ export interface Dialect {
|
|
|
298
298
|
readonly supportsUpsertUpdateWhere?: boolean;
|
|
299
299
|
/** Whether this dialect/engine supports pgvector distance ops (KNN / distance WHERE). */
|
|
300
300
|
readonly supportsVector: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* Whether this dialect/engine supports the PostgreSQL full-text `search`
|
|
303
|
+
* filter (`to_tsvector(...) @@ to_tsquery(...)`). Optional: absent is treated
|
|
304
|
+
* as `false`, so only dialects that set it true admit a `search` filter and
|
|
305
|
+
* every other engine throws {@link UnsupportedFeatureError} (E017) instead of
|
|
306
|
+
* emitting SQL its parser cannot read. MySQL `MATCH … AGAINST` and SQL Server
|
|
307
|
+
* `CONTAINS` are different surfaces with different index requirements, so they
|
|
308
|
+
* deliberately stay `false` rather than silently changing the semantics.
|
|
309
|
+
*/
|
|
310
|
+
readonly supportsFullTextSearch?: boolean;
|
|
311
|
+
/**
|
|
312
|
+
* Whether this dialect/engine supports native array columns and the array
|
|
313
|
+
* filter operators built on them (`has` → `= ANY(col)`, `hasEvery` → `@>`,
|
|
314
|
+
* `hasSome` → `&&`, `isEmpty` → `cardinality(col)`). Optional: absent is
|
|
315
|
+
* treated as `false`, so SQLite / MySQL / SQL Server (which have no array
|
|
316
|
+
* column type; a JSON column is not the same thing) throw
|
|
317
|
+
* {@link UnsupportedFeatureError} (E017) instead of emitting `ANY(...)`.
|
|
318
|
+
*/
|
|
319
|
+
readonly supportsArrayColumns?: boolean;
|
|
301
320
|
/** Whether this dialect/engine supports LISTEN/NOTIFY realtime pub/sub. */
|
|
302
321
|
readonly supportsListenNotify: boolean;
|
|
303
322
|
/** Whether this dialect/engine supports row-level-security session GUCs (set_config). */
|
package/dist/dialect.js
CHANGED
|
@@ -19,6 +19,8 @@ export const postgresDialect = {
|
|
|
19
19
|
nullJsonLiteral: 'NULL',
|
|
20
20
|
aggSupportsInlineOrderBy: true,
|
|
21
21
|
supportsVector: true,
|
|
22
|
+
supportsFullTextSearch: true,
|
|
23
|
+
supportsArrayColumns: true,
|
|
22
24
|
supportsListenNotify: true,
|
|
23
25
|
supportsRLS: true,
|
|
24
26
|
supportsAdvisoryLock: true,
|
package/dist/index-advisor.d.ts
CHANGED
|
@@ -98,6 +98,13 @@ export interface DoctorProbeColumns {
|
|
|
98
98
|
* ambiguous rather than issuing a confident drop verdict.
|
|
99
99
|
*/
|
|
100
100
|
export declare function collectDoctorProbeIndexNames(schema: SchemaMetadata): Map<string, DoctorProbeColumns[]>;
|
|
101
|
+
/**
|
|
102
|
+
* Every (table, column set) a relation probe touches, deduplicated. Consumed by
|
|
103
|
+
* the subtraction side of `doctor` so an index that still serves a live relation
|
|
104
|
+
* is never handed a DROP: the same run's top half demands that index, and
|
|
105
|
+
* recommending both in one report is how a tool loses a DBA's trust.
|
|
106
|
+
*/
|
|
107
|
+
export declare function collectRelationProbeColumns(schema: SchemaMetadata): DoctorProbeColumns[];
|
|
101
108
|
/**
|
|
102
109
|
* Scan every relation in the schema and return the probes with no index support,
|
|
103
110
|
* deduplicated by (table, column set) with all contributing relations attached.
|
package/dist/index-advisor.js
CHANGED
|
Binary file
|
package/dist/index-stats.d.ts
CHANGED
|
@@ -101,11 +101,28 @@ export interface TableStats {
|
|
|
101
101
|
/** Count of indexes already on the table (pg_index). */
|
|
102
102
|
existingIndexCount?: number;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Placeholder for an index column that is an EXPRESSION, not a plain column
|
|
106
|
+
* (`pg_index.indkey` stores 0 for those, and no `pg_attribute` row has attnum 0).
|
|
107
|
+
* It keeps expression POSITIONS in `IndexStat.columns` instead of silently
|
|
108
|
+
* collapsing `(tenant_id, lower(email))` to `['tenant_id']`, which would make a
|
|
109
|
+
* functional index look like a droppable prefix of an unrelated plain index.
|
|
110
|
+
*/
|
|
111
|
+
export declare const EXPRESSION_COLUMN = "(expression)";
|
|
104
112
|
/** A single index's identity + validity, from pg_index / pg_stat_user_indexes. */
|
|
105
113
|
export interface IndexStat {
|
|
106
114
|
table: string;
|
|
107
115
|
indexName: string;
|
|
116
|
+
/** Key + INCLUDE columns in order; an expression slot is {@link EXPRESSION_COLUMN}. */
|
|
108
117
|
columns: string[];
|
|
118
|
+
/** pg_am.amname (btree / gin / gist / hash / brin / spgist). Absent when unread. */
|
|
119
|
+
accessMethod?: string;
|
|
120
|
+
/** True when the index has at least one expression column (pg_index.indexprs). */
|
|
121
|
+
hasExpressions?: boolean;
|
|
122
|
+
/** The partial-index predicate text (pg_index.indpred), or null when not partial. */
|
|
123
|
+
predicate?: string | null;
|
|
124
|
+
/** pg_get_indexdef, the authoritative definition (used for honest reporting). */
|
|
125
|
+
indexDef?: string;
|
|
109
126
|
idxScan?: number;
|
|
110
127
|
isValid: boolean;
|
|
111
128
|
isUnique: boolean;
|
|
@@ -215,6 +232,21 @@ export interface UnusedIndex {
|
|
|
215
232
|
sizeBytes: number | null;
|
|
216
233
|
/** `DROP INDEX CONCURRENTLY IF EXISTS`: printed only, never written to a migration. */
|
|
217
234
|
dropSql: string;
|
|
235
|
+
/**
|
|
236
|
+
* What is special about this index (functional / partial / non-btree), or null
|
|
237
|
+
* for a plain btree. `idx_scan = 0` is a fact either way, but the caveat says
|
|
238
|
+
* what is actually being dropped: a rebuild is not always a `CREATE INDEX (col)`.
|
|
239
|
+
*/
|
|
240
|
+
caveat: string | null;
|
|
241
|
+
/**
|
|
242
|
+
* The same information as `caveat`, structured, so a `--json` consumer never
|
|
243
|
+
* has to regex prose. `kinds` is empty for a plain btree index.
|
|
244
|
+
*/
|
|
245
|
+
shape: {
|
|
246
|
+
kinds: Array<'expression' | 'partial' | 'non-btree'>;
|
|
247
|
+
accessMethod: string | null;
|
|
248
|
+
definition: string | null;
|
|
249
|
+
};
|
|
218
250
|
}
|
|
219
251
|
/**
|
|
220
252
|
* Indexes never (or barely) scanned since the last stats reset. Report-only:
|
|
@@ -225,6 +257,10 @@ export interface UnusedIndex {
|
|
|
225
257
|
*/
|
|
226
258
|
export declare function findUnusedIndexes(snapshot: StatsSnapshot, options?: {
|
|
227
259
|
minScans?: number;
|
|
260
|
+
relationProbes?: Array<{
|
|
261
|
+
table: string;
|
|
262
|
+
columns: string[];
|
|
263
|
+
}>;
|
|
228
264
|
}): UnusedIndex[];
|
|
229
265
|
/** A redundant index whose columns are a leading prefix of a wider index. */
|
|
230
266
|
export interface RedundantIndex {
|
|
@@ -245,6 +281,9 @@ export interface RedundantIndex {
|
|
|
245
281
|
* Uniqueness compatibility: only a NON-unique index is ever reported. A unique
|
|
246
282
|
* or primary-key prefix is load-bearing (it enforces a constraint), so it is
|
|
247
283
|
* never called redundant even when a wider index shares its leading columns.
|
|
284
|
+
*
|
|
285
|
+
* Shape compatibility: see {@link isCoverageComparable}. A functional, partial,
|
|
286
|
+
* or non-btree index is never reported as covered by a plain btree.
|
|
248
287
|
*/
|
|
249
288
|
export declare function findRedundantIndexes(snapshot: StatsSnapshot): RedundantIndex[];
|
|
250
289
|
/**
|
|
@@ -257,13 +296,21 @@ export interface DoctorIndexAudit {
|
|
|
257
296
|
columns: string[];
|
|
258
297
|
idxScan: number;
|
|
259
298
|
sizeBytes: number | null;
|
|
260
|
-
|
|
299
|
+
/** `null` when the index still serves a live relation probe (see stillProbed). */
|
|
300
|
+
dropSql: string | null;
|
|
261
301
|
/**
|
|
262
302
|
* True when the truncated (63-byte) name collides across DIFFERENT column sets
|
|
263
303
|
* in the schema's probes, so which suggestion this index realizes is
|
|
264
304
|
* ambiguous. Reported as ambiguous instead of a confident drop verdict.
|
|
265
305
|
*/
|
|
266
306
|
ambiguous: boolean;
|
|
307
|
+
/**
|
|
308
|
+
* True when the schema still declares a relation whose probe this index
|
|
309
|
+
* serves. `dropSql` is null in that case: the index is unused only because
|
|
310
|
+
* this workload has not run those relation queries yet, and dropping it
|
|
311
|
+
* recreates the missing-index finding in the same report's top half.
|
|
312
|
+
*/
|
|
313
|
+
stillProbed: boolean;
|
|
267
314
|
}
|
|
268
315
|
/**
|
|
269
316
|
* The unused-index machinery scoped to doctor's OWN previously-suggested indexes:
|
|
@@ -278,6 +325,10 @@ export declare function auditDoctorIndexes(snapshot: StatsSnapshot, doctorNames:
|
|
|
278
325
|
columns: string[];
|
|
279
326
|
}>>, options?: {
|
|
280
327
|
minScans?: number;
|
|
328
|
+
relationProbes?: Array<{
|
|
329
|
+
table: string;
|
|
330
|
+
columns: string[];
|
|
331
|
+
}>;
|
|
281
332
|
}): DoctorIndexAudit[];
|
|
282
333
|
/**
|
|
283
334
|
* Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
|