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/cjs/cli/studio.js
CHANGED
|
@@ -45,6 +45,7 @@ exports.PII_REDACTED = void 0;
|
|
|
45
45
|
exports.startStudio = startStudio;
|
|
46
46
|
exports.handleRequest = handleRequest;
|
|
47
47
|
exports.apiDemoMode = apiDemoMode;
|
|
48
|
+
exports.relationLinksForTable = relationLinksForTable;
|
|
48
49
|
exports.apiTableRows = apiTableRows;
|
|
49
50
|
exports.resolveColumnName = resolveColumnName;
|
|
50
51
|
exports.isTextishType = isTextishType;
|
|
@@ -61,8 +62,10 @@ const node_http_1 = require("node:http");
|
|
|
61
62
|
const node_os_1 = require("node:os");
|
|
62
63
|
const node_path_1 = require("node:path");
|
|
63
64
|
const pg_1 = __importDefault(require("pg"));
|
|
65
|
+
const errors_js_1 = require("../errors.js");
|
|
64
66
|
const introspect_js_1 = require("../introspect.js");
|
|
65
67
|
const index_js_1 = require("../query/index.js");
|
|
68
|
+
const pii_tags_js_1 = require("./pii-tags.js");
|
|
66
69
|
const studio_demo_js_1 = require("./studio-demo.js");
|
|
67
70
|
const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
|
|
68
71
|
// ---------------------------------------------------------------------------
|
|
@@ -83,6 +86,7 @@ async function startStudio(options) {
|
|
|
83
86
|
let metadata;
|
|
84
87
|
let dialect;
|
|
85
88
|
let statementTimeout;
|
|
89
|
+
let piiTags = null;
|
|
86
90
|
if (demo) {
|
|
87
91
|
// Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
|
|
88
92
|
// starts pristine and nothing is ever persisted.
|
|
@@ -116,6 +120,14 @@ async function startStudio(options) {
|
|
|
116
120
|
include: options.include,
|
|
117
121
|
exclude: options.exclude,
|
|
118
122
|
});
|
|
123
|
+
// PII tags are code-first only, so a live-introspected schema carries none.
|
|
124
|
+
// Layer them on from the generated metadata when there is one, and record
|
|
125
|
+
// what happened so the CLI can be explicit about it at startup.
|
|
126
|
+
if (options.metadataDir) {
|
|
127
|
+
const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
|
|
128
|
+
if (source)
|
|
129
|
+
piiTags = { path: source.path, applied: (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags) };
|
|
130
|
+
}
|
|
119
131
|
statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
120
132
|
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
121
133
|
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
@@ -165,6 +177,7 @@ async function startStudio(options) {
|
|
|
165
177
|
return {
|
|
166
178
|
authToken,
|
|
167
179
|
url,
|
|
180
|
+
piiTags,
|
|
168
181
|
dispose: async () => {
|
|
169
182
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
170
183
|
await pool.end();
|
|
@@ -386,6 +399,8 @@ async function apiSchema(res, ctx) {
|
|
|
386
399
|
foreignKey: rel.foreignKey,
|
|
387
400
|
referenceKey: rel.referenceKey,
|
|
388
401
|
})),
|
|
402
|
+
// Click-through navigation targets, derived from relation metadata only.
|
|
403
|
+
...relationLinksForTable(tbl, ctx.metadata, ctx.showPii === true),
|
|
389
404
|
}));
|
|
390
405
|
// Row counts (cheap enough to fetch inline).
|
|
391
406
|
const counts = new Map();
|
|
@@ -422,6 +437,91 @@ async function apiSchema(res, ctx) {
|
|
|
422
437
|
demo: ctx.demo === true,
|
|
423
438
|
});
|
|
424
439
|
}
|
|
440
|
+
/** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
|
|
441
|
+
function ownLookup(map, key) {
|
|
442
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
443
|
+
}
|
|
444
|
+
/** True when `columnName` on `table` is PII-tagged and currently redacted. */
|
|
445
|
+
function isRedactedColumn(table, columnName, showPii) {
|
|
446
|
+
if (showPii)
|
|
447
|
+
return false;
|
|
448
|
+
const col = table.columns.find((c) => c.name === columnName);
|
|
449
|
+
return col?.pii === true;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Resolve a relation key to a single column name on `table`, or `null`. Composite
|
|
453
|
+
* (array) keys return null: a composite reference has no single cell to click, so
|
|
454
|
+
* those relations simply produce no link rather than a half-working one.
|
|
455
|
+
*/
|
|
456
|
+
function singleRelationColumn(table, key) {
|
|
457
|
+
if (typeof key !== 'string')
|
|
458
|
+
return null;
|
|
459
|
+
return resolveColumnName(table, key);
|
|
460
|
+
}
|
|
461
|
+
function relationLinksForTable(table, metadata, showPii) {
|
|
462
|
+
const foreignKeys = [];
|
|
463
|
+
const referencedBy = [];
|
|
464
|
+
const seenFkColumns = new Set();
|
|
465
|
+
for (const [name, rel] of Object.entries(table.relations)) {
|
|
466
|
+
const target = metadata.tables[rel.to];
|
|
467
|
+
if (!target)
|
|
468
|
+
continue;
|
|
469
|
+
if (rel.type === 'belongsTo') {
|
|
470
|
+
// The FK lives on this table; the reference key on the target.
|
|
471
|
+
const column = singleRelationColumn(table, rel.foreignKey);
|
|
472
|
+
const targetColumn = singleRelationColumn(target, rel.referenceKey);
|
|
473
|
+
if (!column || !targetColumn)
|
|
474
|
+
continue;
|
|
475
|
+
if (seenFkColumns.has(column))
|
|
476
|
+
continue;
|
|
477
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
|
|
478
|
+
continue;
|
|
479
|
+
seenFkColumns.add(column);
|
|
480
|
+
foreignKeys.push({ column, relation: name, targetTable: target.name, targetColumn });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
484
|
+
// The FK lives on the child table; the reference key on this one.
|
|
485
|
+
const column = singleRelationColumn(table, rel.referenceKey);
|
|
486
|
+
const targetColumn = singleRelationColumn(target, rel.foreignKey);
|
|
487
|
+
if (!column || !targetColumn)
|
|
488
|
+
continue;
|
|
489
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
|
|
490
|
+
continue;
|
|
491
|
+
referencedBy.push({ column, relation: name, targetTable: target.name, targetColumn });
|
|
492
|
+
}
|
|
493
|
+
// manyToMany deliberately produces no link: navigating it means traversing a
|
|
494
|
+
// junction table, which the single-column filter path cannot express.
|
|
495
|
+
}
|
|
496
|
+
// Inbound references declared only on the OTHER side. A `defineSchema` author
|
|
497
|
+
// routinely writes `comments.user -> users` without also declaring
|
|
498
|
+
// `users.comments`, and scanning only this table's own hasMany/hasOne made
|
|
499
|
+
// those children unreachable even though the child grid visibly renders the
|
|
500
|
+
// FK. Scan every other table's belongsTo relations that point here.
|
|
501
|
+
const seenInbound = new Set(referencedBy.map((r) => `${r.targetTable}.${r.targetColumn}`));
|
|
502
|
+
for (const other of Object.values(metadata.tables)) {
|
|
503
|
+
if (other.name === table.name)
|
|
504
|
+
continue;
|
|
505
|
+
for (const [name, rel] of Object.entries(other.relations)) {
|
|
506
|
+
if (rel.type !== 'belongsTo' || rel.to !== table.name)
|
|
507
|
+
continue;
|
|
508
|
+
const column = singleRelationColumn(table, rel.referenceKey);
|
|
509
|
+
const targetColumn = singleRelationColumn(other, rel.foreignKey);
|
|
510
|
+
if (!column || !targetColumn)
|
|
511
|
+
continue;
|
|
512
|
+
if (seenInbound.has(`${other.name}.${targetColumn}`))
|
|
513
|
+
continue;
|
|
514
|
+
if (isRedactedColumn(table, column, showPii) || isRedactedColumn(other, targetColumn, showPii))
|
|
515
|
+
continue;
|
|
516
|
+
seenInbound.add(`${other.name}.${targetColumn}`);
|
|
517
|
+
// Named for the direction the user travels: from this row, to the rows of
|
|
518
|
+
// `other` that reference it. `relation` is the child's own relation name,
|
|
519
|
+
// which is what the child table calls this link.
|
|
520
|
+
referencedBy.push({ column, relation: name, targetTable: other.name, targetColumn });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return { foreignKeys, referencedBy };
|
|
524
|
+
}
|
|
425
525
|
// ---------------------------------------------------------------------------
|
|
426
526
|
// API: /api/tables/:name?limit=&offset=&orderBy=&dir=
|
|
427
527
|
// ---------------------------------------------------------------------------
|
|
@@ -663,6 +763,63 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
663
763
|
// ---------------------------------------------------------------------------
|
|
664
764
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
665
765
|
// ---------------------------------------------------------------------------
|
|
766
|
+
/**
|
|
767
|
+
* Refuse a builder query that FILTERS or SORTS on a redacted PII column.
|
|
768
|
+
*
|
|
769
|
+
* Redacting the cells is not enough on its own: `where: { email: { startsWith:
|
|
770
|
+
* 'a' } }` answers a question about the hidden value, and so does an `isNull`,
|
|
771
|
+
* and so does an `orderBy`. The Data tab already refuses all three
|
|
772
|
+
* (`parseTableFilters`); the builder route accepted them, which mattered the
|
|
773
|
+
* moment PII tags actually started reaching Studio's metadata.
|
|
774
|
+
*
|
|
775
|
+
* Walks the whole args tree: top-level `where` / `orderBy`, boolean
|
|
776
|
+
* combinators, and each `with` level against that relation's target table.
|
|
777
|
+
* `select` is NOT refused: it returns values, and those values are redacted on
|
|
778
|
+
* the way out.
|
|
779
|
+
*/
|
|
780
|
+
function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
781
|
+
if (showPii)
|
|
782
|
+
return;
|
|
783
|
+
const visitClause = (node, table, depth) => {
|
|
784
|
+
if (!table || depth > 10 || node === null || typeof node !== 'object')
|
|
785
|
+
return;
|
|
786
|
+
for (const [key, value] of Object.entries(node)) {
|
|
787
|
+
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
788
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
789
|
+
visitClause(item, table, depth + 1);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
|
|
793
|
+
if (relation) {
|
|
794
|
+
// some / none / every / is / isNot wrappers all resolve against the target.
|
|
795
|
+
visitClause(value, metadata.tables[relation.to], depth + 1);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const column = ownLookup(table.columnMap, key) ?? key;
|
|
799
|
+
if (isRedactedColumn(table, column, showPii)) {
|
|
800
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
|
|
801
|
+
`in a where or orderBy: filtering or sorting on a hidden value reveals it. ` +
|
|
802
|
+
`Restart Studio with --show-pii to query it.`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const visitLevel = (level, table, depth) => {
|
|
807
|
+
if (!table || depth > 10)
|
|
808
|
+
return;
|
|
809
|
+
visitClause(level.where, table, depth);
|
|
810
|
+
visitClause(level.orderBy, table, depth);
|
|
811
|
+
const withClause = level.with;
|
|
812
|
+
if (!withClause || typeof withClause !== 'object')
|
|
813
|
+
return;
|
|
814
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
815
|
+
const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
|
|
816
|
+
if (!relation || spec === true || spec === null || typeof spec !== 'object')
|
|
817
|
+
continue;
|
|
818
|
+
visitLevel(spec, metadata.tables[relation.to], depth + 1);
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
visitLevel(args, metadata.tables[tableName], 0);
|
|
822
|
+
}
|
|
666
823
|
async function apiBuilder(req, res, ctx) {
|
|
667
824
|
const body = await readJsonBody(req);
|
|
668
825
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -681,6 +838,7 @@ async function apiBuilder(req, res, ctx) {
|
|
|
681
838
|
// when unset.
|
|
682
839
|
dialect: ctx.dialect,
|
|
683
840
|
});
|
|
841
|
+
assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
|
|
684
842
|
deferred = qi.buildFindMany(args);
|
|
685
843
|
}
|
|
686
844
|
catch (err) {
|
package/dist/cjs/cli/ui.js
CHANGED
|
@@ -230,9 +230,14 @@ function stripAnsi(s) {
|
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
function redactUrl(url) {
|
|
232
232
|
return (url
|
|
233
|
-
// Userinfo credentials
|
|
234
|
-
//
|
|
235
|
-
|
|
233
|
+
// Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
|
|
234
|
+
// to the LAST `@` before the next `/` (or end of authority), because a
|
|
235
|
+
// password may legally contain `:`, `/`, and even `@` in percent-decoded
|
|
236
|
+
// form. The previous `:([^@/:]+)@` could not span any of those, so
|
|
237
|
+
// `postgres://u:pa/ss@host/db` came through completely unredacted and
|
|
238
|
+
// `postgres://u:a@b@host/db` leaked the tail. Global: one string may
|
|
239
|
+
// carry several URLs (a primary + replica pair).
|
|
240
|
+
.replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
|
|
236
241
|
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
237
242
|
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
238
243
|
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
package/dist/cjs/client.js
CHANGED
|
@@ -314,9 +314,29 @@ class TurbineClient {
|
|
|
314
314
|
// constructor with an opaque "Cannot read properties of undefined
|
|
315
315
|
// (reading 'tables')". Fail fast with an actionable message instead.
|
|
316
316
|
if (!schema || typeof schema !== 'object' || !schema.tables) {
|
|
317
|
+
// A `defineSchema()` result is the most common wrong shape here: it is a
|
|
318
|
+
// SchemaDef (`{ tables: { users: { columns: { id: ... } } } }`-ish builder
|
|
319
|
+
// output), not runtime SchemaMetadata, so name the conversion rather than
|
|
320
|
+
// just the requirement.
|
|
321
|
+
const looksLikeSchemaDef = schema !== null && typeof schema === 'object' && Object.hasOwn(schema, 'name') === false;
|
|
317
322
|
throw new errors_js_1.ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
|
|
318
323
|
'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
|
|
319
|
-
'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).'
|
|
324
|
+
'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).' +
|
|
325
|
+
(looksLikeSchemaDef
|
|
326
|
+
? ' If you have a `defineSchema()` result, convert it first with `schemaDefToMetadata(def)`.'
|
|
327
|
+
: ''));
|
|
328
|
+
}
|
|
329
|
+
// A wrong-SHAPED schema (a `defineSchema()` result, whose tables carry no
|
|
330
|
+
// `columns` array) used to survive this check and die later as
|
|
331
|
+
// `TypeError: this.tableMeta.columns is not iterable`, several frames from
|
|
332
|
+
// the cause. Validate one table's shape here, where the fix is obvious.
|
|
333
|
+
for (const [name, meta] of Object.entries(schema.tables)) {
|
|
334
|
+
if (!meta || typeof meta !== 'object' || !Array.isArray(meta.columns)) {
|
|
335
|
+
throw new errors_js_1.ValidationError(`[turbine] Table "${name}" in the schema passed to TurbineClient has no \`columns\` array, so this is ` +
|
|
336
|
+
'not runtime SchemaMetadata. A `defineSchema()` result is a SchemaDef: convert it with ' +
|
|
337
|
+
'`schemaDefToMetadata(def)`, or use the metadata emitted by `npx turbine generate`.');
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
320
340
|
}
|
|
321
341
|
/**
|
|
322
342
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
package/dist/cjs/dialect.js
CHANGED
|
@@ -55,6 +55,8 @@ exports.postgresDialect = {
|
|
|
55
55
|
nullJsonLiteral: 'NULL',
|
|
56
56
|
aggSupportsInlineOrderBy: true,
|
|
57
57
|
supportsVector: true,
|
|
58
|
+
supportsFullTextSearch: true,
|
|
59
|
+
supportsArrayColumns: true,
|
|
58
60
|
supportsListenNotify: true,
|
|
59
61
|
supportsRLS: true,
|
|
60
62
|
supportsAdvisoryLock: true,
|
|
Binary file
|
package/dist/cjs/index-stats.js
CHANGED
|
@@ -60,7 +60,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
60
60
|
};
|
|
61
61
|
})();
|
|
62
62
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
63
|
-
exports.STATS_THRESHOLDS = void 0;
|
|
63
|
+
exports.EXPRESSION_COLUMN = exports.STATS_THRESHOLDS = void 0;
|
|
64
64
|
exports.emptyStatsSnapshot = emptyStatsSnapshot;
|
|
65
65
|
exports.formatBytes = formatBytes;
|
|
66
66
|
exports.scoreMissingIndex = scoreMissingIndex;
|
|
@@ -131,6 +131,14 @@ exports.STATS_THRESHOLDS = {
|
|
|
131
131
|
*/
|
|
132
132
|
heatMinQueriesPerMin: 1,
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Placeholder for an index column that is an EXPRESSION, not a plain column
|
|
136
|
+
* (`pg_index.indkey` stores 0 for those, and no `pg_attribute` row has attnum 0).
|
|
137
|
+
* It keeps expression POSITIONS in `IndexStat.columns` instead of silently
|
|
138
|
+
* collapsing `(tenant_id, lower(email))` to `['tenant_id']`, which would make a
|
|
139
|
+
* functional index look like a droppable prefix of an unrelated plain index.
|
|
140
|
+
*/
|
|
141
|
+
exports.EXPRESSION_COLUMN = '(expression)';
|
|
134
142
|
/** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
|
|
135
143
|
function emptyStatsSnapshot(notices = []) {
|
|
136
144
|
return {
|
|
@@ -335,6 +343,30 @@ function findInvalidIndexes(snapshot) {
|
|
|
335
343
|
function isConstraintBacking(idx) {
|
|
336
344
|
return idx.isPrimary || idx.isUnique || idx.isExclusion === true || idx.isReplicaIdent;
|
|
337
345
|
}
|
|
346
|
+
/** The structured counterpart of {@link describeIndexShape}. */
|
|
347
|
+
function indexShape(idx) {
|
|
348
|
+
const kinds = [];
|
|
349
|
+
if (idx.hasExpressions === true || idx.columns.includes(exports.EXPRESSION_COLUMN))
|
|
350
|
+
kinds.push('expression');
|
|
351
|
+
if (idx.predicate != null)
|
|
352
|
+
kinds.push('partial');
|
|
353
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
354
|
+
kinds.push('non-btree');
|
|
355
|
+
return { kinds, accessMethod: idx.accessMethod ?? null, definition: idx.indexDef ?? null };
|
|
356
|
+
}
|
|
357
|
+
/** Describe an index's non-plain-btree properties, or null when it is plain. */
|
|
358
|
+
function describeIndexShape(idx) {
|
|
359
|
+
const parts = [];
|
|
360
|
+
if (idx.hasExpressions === true || idx.columns.includes(exports.EXPRESSION_COLUMN))
|
|
361
|
+
parts.push('expression index');
|
|
362
|
+
if (idx.predicate != null)
|
|
363
|
+
parts.push(`partial index (WHERE ${idx.predicate})`);
|
|
364
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
365
|
+
parts.push(`${idx.accessMethod} index`);
|
|
366
|
+
if (parts.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
return `${parts.join('; ')}${idx.indexDef ? `: ${idx.indexDef}` : ''}`;
|
|
369
|
+
}
|
|
338
370
|
/**
|
|
339
371
|
* Indexes never (or barely) scanned since the last stats reset. Report-only:
|
|
340
372
|
* counters reset on a crash/reset and REPLICA READS NEVER FEED PRIMARY COUNTERS,
|
|
@@ -344,9 +376,11 @@ function isConstraintBacking(idx) {
|
|
|
344
376
|
*/
|
|
345
377
|
function findUnusedIndexes(snapshot, options = {}) {
|
|
346
378
|
const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
|
|
379
|
+
const probes = options.relationProbes ?? [];
|
|
347
380
|
return snapshot.indexes
|
|
348
381
|
.filter((idx) => idx.isValid && !isConstraintBacking(idx))
|
|
349
382
|
.filter((idx) => idx.idxScan !== undefined && idx.idxScan < minScans)
|
|
383
|
+
.filter((idx) => !servesRelationProbe(idx, probes))
|
|
350
384
|
.map((idx) => ({
|
|
351
385
|
table: idx.table,
|
|
352
386
|
indexName: idx.indexName,
|
|
@@ -354,6 +388,8 @@ function findUnusedIndexes(snapshot, options = {}) {
|
|
|
354
388
|
idxScan: idx.idxScan ?? 0,
|
|
355
389
|
sizeBytes: idx.sizeBytes ?? null,
|
|
356
390
|
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
|
|
391
|
+
caveat: describeIndexShape(idx),
|
|
392
|
+
shape: indexShape(idx),
|
|
357
393
|
}))
|
|
358
394
|
.sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) || a.indexName.localeCompare(b.indexName));
|
|
359
395
|
}
|
|
@@ -363,6 +399,52 @@ function isLeadingPrefix(prefix, columns) {
|
|
|
363
399
|
return false;
|
|
364
400
|
return prefix.every((c, i) => columns[i] === c);
|
|
365
401
|
}
|
|
402
|
+
/** Whether two column lists are identical, in order. */
|
|
403
|
+
function sameColumns(a, b) {
|
|
404
|
+
return a.length === b.length && a.every((c, i) => b[i] === c);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Whether this index answers a relation probe Turbine actually issues: the
|
|
408
|
+
* probe's columns are the index's leading columns (an exact match, or a wider
|
|
409
|
+
* index whose prefix serves the probe). Such an index is never handed a DROP,
|
|
410
|
+
* because the missing-index half of the same report demands it.
|
|
411
|
+
*/
|
|
412
|
+
function servesRelationProbe(idx, probes) {
|
|
413
|
+
if (probes.length === 0)
|
|
414
|
+
return false;
|
|
415
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
416
|
+
return false;
|
|
417
|
+
if (idx.predicate != null)
|
|
418
|
+
return false;
|
|
419
|
+
return probes.some((p) => p.table === idx.table && (sameColumns(p.columns, idx.columns) || isLeadingPrefix(p.columns, idx.columns)));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Whether prefix coverage is even a meaningful question for this pair. Only a
|
|
423
|
+
* plain btree has leading-prefix semantics, and only two indexes of the SAME
|
|
424
|
+
* shape are interchangeable:
|
|
425
|
+
*
|
|
426
|
+
* - access method must be btree on BOTH (a GIN index on a column answers
|
|
427
|
+
* queries a btree cannot, and vice versa);
|
|
428
|
+
* - neither may contain an expression column (`lower(email)` is a different
|
|
429
|
+
* lookup from `email`, and an unresolved expression slot makes the column
|
|
430
|
+
* list an unreliable basis for a drop verdict);
|
|
431
|
+
* - the partial predicates must be IDENTICAL (both absent, or the same text).
|
|
432
|
+
* A full index does technically answer a partial index's lookups, but it is
|
|
433
|
+
* not the same object and the partial one exists to be small.
|
|
434
|
+
*
|
|
435
|
+
* Anything unknown (an access method or expression flag the collector could not
|
|
436
|
+
* read) answers "not comparable": doctor stays silent rather than suggesting a
|
|
437
|
+
* drop it cannot justify.
|
|
438
|
+
*/
|
|
439
|
+
function isCoverageComparable(narrow, wider) {
|
|
440
|
+
if (narrow.accessMethod !== 'btree' || wider.accessMethod !== 'btree')
|
|
441
|
+
return false;
|
|
442
|
+
if (narrow.hasExpressions !== false || wider.hasExpressions !== false)
|
|
443
|
+
return false;
|
|
444
|
+
if (narrow.columns.includes(exports.EXPRESSION_COLUMN) || wider.columns.includes(exports.EXPRESSION_COLUMN))
|
|
445
|
+
return false;
|
|
446
|
+
return (narrow.predicate ?? null) === (wider.predicate ?? null);
|
|
447
|
+
}
|
|
366
448
|
/**
|
|
367
449
|
* Non-unique indexes whose column list is a leading prefix of a WIDER index on
|
|
368
450
|
* the same table. A btree serves any leading-prefix lookup, so the narrow index
|
|
@@ -371,6 +453,9 @@ function isLeadingPrefix(prefix, columns) {
|
|
|
371
453
|
* Uniqueness compatibility: only a NON-unique index is ever reported. A unique
|
|
372
454
|
* or primary-key prefix is load-bearing (it enforces a constraint), so it is
|
|
373
455
|
* never called redundant even when a wider index shares its leading columns.
|
|
456
|
+
*
|
|
457
|
+
* Shape compatibility: see {@link isCoverageComparable}. A functional, partial,
|
|
458
|
+
* or non-btree index is never reported as covered by a plain btree.
|
|
374
459
|
*/
|
|
375
460
|
function findRedundantIndexes(snapshot) {
|
|
376
461
|
const byTable = new Map();
|
|
@@ -393,7 +478,18 @@ function findRedundantIndexes(snapshot) {
|
|
|
393
478
|
continue;
|
|
394
479
|
if (narrow.columns.length === 0)
|
|
395
480
|
continue;
|
|
396
|
-
|
|
481
|
+
// Prefer a genuinely wider index; fall back to an exact duplicate, which
|
|
482
|
+
// is the most obvious index problem there is and which a strict
|
|
483
|
+
// leading-prefix test (prefix.length < columns.length) can never see. For
|
|
484
|
+
// a duplicate pair only ONE side is reported: the later name, so the
|
|
485
|
+
// report never tells you to drop both copies.
|
|
486
|
+
const wider = list.find((w) => w.indexName !== narrow.indexName &&
|
|
487
|
+
isCoverageComparable(narrow, w) &&
|
|
488
|
+
isLeadingPrefix(narrow.columns, w.columns)) ??
|
|
489
|
+
list.find((w) => w.indexName < narrow.indexName &&
|
|
490
|
+
!isConstraintBacking(w) &&
|
|
491
|
+
isCoverageComparable(narrow, w) &&
|
|
492
|
+
sameColumns(narrow.columns, w.columns));
|
|
397
493
|
if (!wider)
|
|
398
494
|
continue;
|
|
399
495
|
out.push({
|
|
@@ -419,6 +515,7 @@ function findRedundantIndexes(snapshot) {
|
|
|
419
515
|
*/
|
|
420
516
|
function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
|
|
421
517
|
const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
|
|
518
|
+
const probes = options.relationProbes ?? [];
|
|
422
519
|
const out = [];
|
|
423
520
|
for (const idx of snapshot.indexes) {
|
|
424
521
|
if (!idx.isValid || isConstraintBacking(idx))
|
|
@@ -428,14 +525,16 @@ function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
|
|
|
428
525
|
continue;
|
|
429
526
|
if (idx.idxScan === undefined || idx.idxScan >= minScans)
|
|
430
527
|
continue;
|
|
528
|
+
const stillProbed = servesRelationProbe(idx, probes);
|
|
431
529
|
out.push({
|
|
432
530
|
table: idx.table,
|
|
433
531
|
indexName: idx.indexName,
|
|
434
532
|
columns: idx.columns,
|
|
435
533
|
idxScan: idx.idxScan,
|
|
436
534
|
sizeBytes: idx.sizeBytes ?? null,
|
|
437
|
-
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
|
|
535
|
+
dropSql: stillProbed ? null : (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
|
|
438
536
|
ambiguous: candidates.length > 1,
|
|
537
|
+
stillProbed,
|
|
439
538
|
});
|
|
440
539
|
}
|
|
441
540
|
return out.sort((a, b) => a.indexName.localeCompare(b.indexName));
|
|
@@ -538,19 +637,28 @@ async function collectStatsSnapshot(options) {
|
|
|
538
637
|
}
|
|
539
638
|
}
|
|
540
639
|
// --- invalid + all indexes (whole schema, for invalid detection) -------
|
|
541
|
-
const indexRows = await run('pg_index',
|
|
640
|
+
const indexRows = await run('pg_index',
|
|
641
|
+
// The column list LEFT JOINs pg_attribute so an EXPRESSION slot (indkey = 0,
|
|
642
|
+
// which no pg_attribute row matches) survives as a marker instead of being
|
|
643
|
+
// dropped by an inner join, which silently shortened functional indexes.
|
|
644
|
+
`SELECT c.relname AS table_name,
|
|
542
645
|
ic.relname AS index_name,
|
|
543
646
|
i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
|
|
544
647
|
EXISTS (SELECT 1 FROM pg_constraint con
|
|
545
648
|
WHERE con.conindid = i.indexrelid AND con.contype = 'x') AS is_exclusion,
|
|
546
649
|
s.idx_scan::text AS idx_scan,
|
|
547
650
|
pg_relation_size(i.indexrelid)::text AS index_size,
|
|
548
|
-
|
|
651
|
+
am.amname AS access_method,
|
|
652
|
+
(i.indexprs IS NOT NULL) AS has_expressions,
|
|
653
|
+
pg_get_expr(i.indpred, i.indrelid) AS predicate,
|
|
654
|
+
pg_get_indexdef(i.indexrelid) AS index_def,
|
|
655
|
+
(SELECT array_agg(coalesce(a.attname::text, '${exports.EXPRESSION_COLUMN}') ORDER BY k.ord)
|
|
549
656
|
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
550
|
-
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
|
|
657
|
+
LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
|
|
551
658
|
FROM pg_index i
|
|
552
659
|
JOIN pg_class c ON c.oid = i.indrelid
|
|
553
660
|
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
661
|
+
JOIN pg_am am ON am.oid = ic.relam
|
|
554
662
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
555
663
|
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.indexrelid
|
|
556
664
|
WHERE n.nspname = $1`, [options.schema]);
|
|
@@ -560,6 +668,10 @@ async function collectStatsSnapshot(options) {
|
|
|
560
668
|
table: row.table_name,
|
|
561
669
|
indexName: row.index_name,
|
|
562
670
|
columns: row.columns ?? [],
|
|
671
|
+
accessMethod: row.access_method,
|
|
672
|
+
hasExpressions: row.has_expressions,
|
|
673
|
+
predicate: row.predicate,
|
|
674
|
+
indexDef: row.index_def ?? undefined,
|
|
563
675
|
idxScan: row.idx_scan == null ? undefined : Number(row.idx_scan),
|
|
564
676
|
isValid: row.indisvalid,
|
|
565
677
|
isUnique: row.indisunique,
|
package/dist/cjs/mssql.js
CHANGED
|
@@ -493,6 +493,11 @@ exports.mssqlDialect = {
|
|
|
493
493
|
supportsReturning: false,
|
|
494
494
|
supportsILike: false,
|
|
495
495
|
supportsVector: false,
|
|
496
|
+
// SQL Server full-text is `CONTAINS`/`FREETEXT` over a full-text catalog: a
|
|
497
|
+
// different surface with different semantics, not the emitted tsvector form.
|
|
498
|
+
supportsFullTextSearch: false,
|
|
499
|
+
// No array column type (a JSON column is not an array column).
|
|
500
|
+
supportsArrayColumns: false,
|
|
496
501
|
supportsListenNotify: false,
|
|
497
502
|
supportsRLS: false,
|
|
498
503
|
// SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
|
package/dist/cjs/mysql.js
CHANGED
|
@@ -377,6 +377,11 @@ exports.mysqlDialect = {
|
|
|
377
377
|
supportsReturning: false,
|
|
378
378
|
supportsILike: false,
|
|
379
379
|
supportsVector: false,
|
|
380
|
+
// MySQL full-text is `MATCH(col) AGAINST(...)` over a FULLTEXT index: a
|
|
381
|
+
// different surface with different semantics, not the emitted tsvector form.
|
|
382
|
+
supportsFullTextSearch: false,
|
|
383
|
+
// No array column type (a JSON column is not an array column).
|
|
384
|
+
supportsArrayColumns: false,
|
|
380
385
|
supportsListenNotify: false,
|
|
381
386
|
supportsRLS: false,
|
|
382
387
|
// MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
|