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/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { tmpdir } from 'node:os';
|
|
|
29
29
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
30
30
|
import { pathToFileURL } from 'node:url';
|
|
31
31
|
import { generate, generatePrismaMap } from '../generate.js';
|
|
32
|
-
import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
32
|
+
import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
33
33
|
import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
|
|
34
34
|
import { introspect } from '../introspect.js';
|
|
35
35
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
@@ -2125,9 +2125,14 @@ async function cmdDoctor(args, config) {
|
|
|
2125
2125
|
const unusedRan = args.unused === true;
|
|
2126
2126
|
const auditRan = args.audit === true;
|
|
2127
2127
|
const minScans = args.minScans;
|
|
2128
|
-
|
|
2128
|
+
// Never suggest dropping an index that still serves a relation probe: the
|
|
2129
|
+
// missing-index half of this very report demands it.
|
|
2130
|
+
const relationProbes = collectRelationProbeColumns(schema);
|
|
2131
|
+
const unused = unusedRan ? findUnusedIndexes(snapshot, { minScans, relationProbes }) : [];
|
|
2129
2132
|
const redundant = unusedRan ? findRedundantIndexes(snapshot) : [];
|
|
2130
|
-
const audit = auditRan
|
|
2133
|
+
const audit = auditRan
|
|
2134
|
+
? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans, relationProbes })
|
|
2135
|
+
: [];
|
|
2131
2136
|
const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
|
|
2132
2137
|
if (jsonMode) {
|
|
2133
2138
|
spinner?.stop();
|
|
@@ -2197,15 +2202,19 @@ function buildDoctorJson(ctx) {
|
|
|
2197
2202
|
})),
|
|
2198
2203
|
invalidIndexes: ctx.invalid,
|
|
2199
2204
|
};
|
|
2200
|
-
//
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
}
|
|
2205
|
+
// The keys below are ALWAYS present under `schemaVersion: 1`: a declared
|
|
2206
|
+
// schema version that changes shape by flag forces every consumer to write
|
|
2207
|
+
// `json.unused ?? []`. Which subtraction scans actually ran is reported as
|
|
2208
|
+
// data (`ran`), not as the presence or absence of a key.
|
|
2209
|
+
out.subtraction = {
|
|
2210
|
+
unusedRan: ctx.subtract.unusedRan,
|
|
2211
|
+
auditRan: ctx.subtract.auditRan,
|
|
2212
|
+
minScans: ctx.subtract.minScans ?? null,
|
|
2213
|
+
};
|
|
2214
|
+
out.unused = ctx.subtract.unusedRan ? ctx.subtract.unused : [];
|
|
2215
|
+
out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
|
|
2216
|
+
out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
|
|
2217
|
+
out.invalid = ctx.invalid;
|
|
2209
2218
|
return out;
|
|
2210
2219
|
}
|
|
2211
2220
|
async function renderDoctorHuman(ctx) {
|
|
@@ -2269,6 +2278,15 @@ function renderUnusedCaveats(minScans, snapshot) {
|
|
|
2269
2278
|
console.log(` ${dim(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
|
|
2270
2279
|
console.log(` ${dim(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
|
|
2271
2280
|
console.log(` ${dim('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
|
|
2281
|
+
console.log(` ${dim('Indexes that still serve a relation Turbine probes are withheld: this report demands those.')}`);
|
|
2282
|
+
// The cost section refuses to SCORE on stats this young; prescribing drops off
|
|
2283
|
+
// the same counters in the next section would be the report contradicting
|
|
2284
|
+
// itself. Say so where the advice is, not only where the scoring is.
|
|
2285
|
+
if (snapshot.statsAgeDays !== null && snapshot.statsAgeDays < STATS_THRESHOLDS.minStatsAgeDays) {
|
|
2286
|
+
console.log(` ${yellow(`Statistics are only ${ageLabel} old, below the ${STATS_THRESHOLDS.minStatsAgeDays}d floor this report uses to score cost.`)}`);
|
|
2287
|
+
console.log(` ${yellow('Treat everything below as a list to investigate, not advice to act on: an index your')}`);
|
|
2288
|
+
console.log(` ${yellow('workload simply has not reached yet looks identical to a dead one.')}`);
|
|
2289
|
+
}
|
|
2272
2290
|
newline();
|
|
2273
2291
|
}
|
|
2274
2292
|
/** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
|
|
@@ -2280,6 +2298,10 @@ function renderUnusedIndexes(unused, minScans, snapshot) {
|
|
|
2280
2298
|
renderUnusedCaveats(minScans, snapshot);
|
|
2281
2299
|
for (const u of unused) {
|
|
2282
2300
|
console.log(` ${yellow(symbols.warning)} ${bold(cyan(u.table))} ${dim(`(${u.columns.join(', ') || '?'})`)} ${gray(`${u.indexName} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
|
|
2301
|
+
// A functional / partial / non-btree index is not a plain `(col)` rebuild:
|
|
2302
|
+
// say what it actually is before anyone runs the DROP.
|
|
2303
|
+
if (u.caveat)
|
|
2304
|
+
console.log(` ${dim(symbols.tee)} ${yellow(u.caveat)}`);
|
|
2283
2305
|
console.log(` ${dim(symbols.teeEnd)} ${green(u.dropSql)}`);
|
|
2284
2306
|
newline();
|
|
2285
2307
|
}
|
|
@@ -2314,7 +2336,13 @@ function renderDoctorAudit(audit, minScans, snapshot) {
|
|
|
2314
2336
|
if (a.ambiguous) {
|
|
2315
2337
|
console.log(` ${dim(symbols.tee)} ${dim('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
|
|
2316
2338
|
}
|
|
2317
|
-
|
|
2339
|
+
if (a.stillProbed) {
|
|
2340
|
+
console.log(` ${dim(symbols.teeEnd)} ${dim('kept: a relation in your schema still probes these columns, so dropping it would')}`);
|
|
2341
|
+
console.log(` ${dim('reappear as a missing-index finding in this same report.')}`);
|
|
2342
|
+
}
|
|
2343
|
+
else if (a.dropSql) {
|
|
2344
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
|
|
2345
|
+
}
|
|
2318
2346
|
newline();
|
|
2319
2347
|
}
|
|
2320
2348
|
console.log(` ${dim('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
|
|
@@ -2472,6 +2500,7 @@ async function cmdStudio(args, config) {
|
|
|
2472
2500
|
write: args.write === true,
|
|
2473
2501
|
showPii: args.showPii === true,
|
|
2474
2502
|
demo,
|
|
2503
|
+
metadataDir: config.out,
|
|
2475
2504
|
});
|
|
2476
2505
|
spinner.succeed(demo ? 'Demo Studio is running' : 'Studio is running');
|
|
2477
2506
|
}
|
|
@@ -2506,6 +2535,20 @@ async function cmdStudio(args, config) {
|
|
|
2506
2535
|
newline();
|
|
2507
2536
|
console.log(warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
|
|
2508
2537
|
}
|
|
2538
|
+
// PII tags are a code-first declaration; introspection never infers them.
|
|
2539
|
+
// Say plainly whether any reached this session, so nobody assumes a
|
|
2540
|
+
// redaction guarantee that has nothing to act on.
|
|
2541
|
+
if (!args.showPii) {
|
|
2542
|
+
newline();
|
|
2543
|
+
if (studio.piiTags && studio.piiTags.applied > 0) {
|
|
2544
|
+
console.log(` ${dim('PII redaction:')} ${studio.piiTags.applied} tagged column(s) from ${dim(studio.piiTags.path)}`);
|
|
2545
|
+
}
|
|
2546
|
+
else {
|
|
2547
|
+
console.log(warn('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
|
|
2548
|
+
`(defineSchema \`pii: true\`) and read from generated metadata in ${config.out}; ` +
|
|
2549
|
+
'introspection alone never infers them. Run `turbine generate` after tagging.'));
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2509
2552
|
newline();
|
|
2510
2553
|
console.log(box([
|
|
2511
2554
|
`${bold('Turbine Studio')} ${dim(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
|
|
@@ -2546,6 +2589,7 @@ async function cmdMcp(_args, config) {
|
|
|
2546
2589
|
url,
|
|
2547
2590
|
schema: config.schema,
|
|
2548
2591
|
migrationsDir: config.migrationsDir,
|
|
2592
|
+
metadataDir: config.out,
|
|
2549
2593
|
include: config.include.length ? config.include : undefined,
|
|
2550
2594
|
exclude: config.exclude.length ? config.exclude : undefined,
|
|
2551
2595
|
});
|
package/dist/cli/mcp.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface McpServerOptions {
|
|
|
6
6
|
migrationsDir: string;
|
|
7
7
|
include?: string[];
|
|
8
8
|
exclude?: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Directory holding generated Turbine metadata (`turbine generate`'s `out`).
|
|
11
|
+
* PII tags are code-first declarations that introspection never sets, so
|
|
12
|
+
* without this the server has nothing to redact against. Read as text;
|
|
13
|
+
* nothing from it is executed. See `pii-tags.ts`.
|
|
14
|
+
*/
|
|
15
|
+
metadataDir?: string;
|
|
9
16
|
}
|
|
10
17
|
export interface McpTransport {
|
|
11
18
|
input?: Readable;
|
package/dist/cli/mcp.js
CHANGED
|
@@ -7,6 +7,7 @@ import { addAutoManyToManyRelations, buildRelationsFromForeignKeys, isUnknownTsT
|
|
|
7
7
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
8
8
|
import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
|
|
9
9
|
import { listMigrationFiles } from './migrate.js';
|
|
10
|
+
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
10
11
|
/**
|
|
11
12
|
* Walk up from the running script to find turbine-orm's own package.json.
|
|
12
13
|
* Uses process.argv[1] instead of import.meta.url so the same code compiles
|
|
@@ -402,15 +403,27 @@ async function sampleRows(ctx, tableName, limit) {
|
|
|
402
403
|
const table = requireTable(metadata, tableName);
|
|
403
404
|
const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
|
|
404
405
|
const result = await client.query(`SELECT * FROM ${qualifiedTable} LIMIT $1`, [limit]);
|
|
406
|
+
// Sample rows go straight into an LLM context, so PII-tagged values are
|
|
407
|
+
// replaced before serialization, the same stance Studio's Data tab takes.
|
|
408
|
+
const piiColumns = new Set(table.columns.filter((c) => c.pii).map((c) => c.name));
|
|
405
409
|
return {
|
|
406
410
|
table: table.name,
|
|
407
411
|
limit,
|
|
412
|
+
redactedColumns: [...piiColumns],
|
|
408
413
|
columns: result.fields.map((field) => ({ name: field.name, dataTypeID: field.dataTypeID })),
|
|
409
|
-
rows: result.rows,
|
|
414
|
+
rows: piiColumns.size === 0 ? result.rows : result.rows.map((row) => redactRow(row, piiColumns)),
|
|
410
415
|
rowCount: result.rowCount ?? result.rows.length,
|
|
411
416
|
};
|
|
412
417
|
});
|
|
413
418
|
}
|
|
419
|
+
/** Replace PII-tagged cells with a fixed marker (never the value, never null). */
|
|
420
|
+
function redactRow(row, piiColumns) {
|
|
421
|
+
const out = {};
|
|
422
|
+
for (const [key, value] of Object.entries(row)) {
|
|
423
|
+
out[key] = piiColumns.has(key) ? '•• redacted ••' : value;
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
414
427
|
async function withReadOnly(ctx, fn) {
|
|
415
428
|
const client = await ctx.pool.connect();
|
|
416
429
|
try {
|
|
@@ -588,7 +601,15 @@ async function loadSchemaMetadata(client, options) {
|
|
|
588
601
|
indexes: indexesByTable.get(tableName) ?? [],
|
|
589
602
|
};
|
|
590
603
|
}
|
|
591
|
-
|
|
604
|
+
const metadata = { tables, enums };
|
|
605
|
+
// Code-first PII tags, layered onto the live catalog. Without this the
|
|
606
|
+
// redaction below has nothing to act on (introspection never infers a tag).
|
|
607
|
+
if (options.metadataDir) {
|
|
608
|
+
const source = loadPiiTags(options.metadataDir);
|
|
609
|
+
if (source)
|
|
610
|
+
applyPiiTags(metadata, source.tags);
|
|
611
|
+
}
|
|
612
|
+
return metadata;
|
|
592
613
|
}
|
|
593
614
|
/**
|
|
594
615
|
* Group raw FK rows into constraint-level entries and delegate relation
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -118,7 +118,8 @@ export declare function parseMigrationContent(content: string): ParsedMigration;
|
|
|
118
118
|
* Split a SQL script into individual statements on top-level semicolons.
|
|
119
119
|
*
|
|
120
120
|
* A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
|
|
121
|
-
* string
|
|
121
|
+
* string (including a backslash-escaping `E'...'` string), a double-quoted
|
|
122
|
+
* identifier, a dollar-quoted body, a line comment
|
|
122
123
|
* (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
|
|
123
124
|
* split. This is the one production-destroying failure mode of no-transaction
|
|
124
125
|
* migrations (a partial statement executed against production), so the behavior
|
package/dist/cli/migrate.js
CHANGED
|
@@ -170,7 +170,8 @@ export function parseMigrationContent(content) {
|
|
|
170
170
|
* Split a SQL script into individual statements on top-level semicolons.
|
|
171
171
|
*
|
|
172
172
|
* A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
|
|
173
|
-
* string
|
|
173
|
+
* string (including a backslash-escaping `E'...'` string), a double-quoted
|
|
174
|
+
* identifier, a dollar-quoted body, a line comment
|
|
174
175
|
* (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
|
|
175
176
|
* split. This is the one production-destroying failure mode of no-transaction
|
|
176
177
|
* migrations (a partial statement executed against production), so the behavior
|
|
@@ -221,10 +222,19 @@ export function splitSqlStatements(sql) {
|
|
|
221
222
|
continue;
|
|
222
223
|
}
|
|
223
224
|
// Single-quoted string ('' is an escaped quote, stays inside the string).
|
|
225
|
+
// An E-prefixed string (E'...') additionally honors backslash escapes, so
|
|
226
|
+
// `E'p\'q'` is ONE string: treating the `\'` as a terminator would close the
|
|
227
|
+
// string early and let the next quote swallow a real statement terminator.
|
|
224
228
|
if (ch === "'") {
|
|
229
|
+
const backslashEscapes = isEscapeStringPrefix(sql, i);
|
|
225
230
|
let j = i + 1;
|
|
226
231
|
current += "'";
|
|
227
232
|
while (j < n) {
|
|
233
|
+
if (backslashEscapes && sql[j] === '\\' && j + 1 < n) {
|
|
234
|
+
current += sql[j] + sql[j + 1];
|
|
235
|
+
j += 2;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
228
238
|
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
229
239
|
current += "''";
|
|
230
240
|
j += 2;
|
|
@@ -296,6 +306,23 @@ export function splitSqlStatements(sql) {
|
|
|
296
306
|
statements.push(tail);
|
|
297
307
|
return statements.filter((s) => !isCommentOnlyStatement(s));
|
|
298
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
|
|
311
|
+
* whose body treats a backslash as an escape character.
|
|
312
|
+
*
|
|
313
|
+
* The `E` must be a standalone token: an identifier that merely ends in `e`
|
|
314
|
+
* (`some_table` cannot be followed by a quote in valid SQL, but the check keeps
|
|
315
|
+
* the tokenizer honest) does not turn the following literal into an E-string.
|
|
316
|
+
* Ordinary literals are left alone on purpose: with the modern
|
|
317
|
+
* `standard_conforming_strings = on` default, `'a\'` IS a complete string.
|
|
318
|
+
*/
|
|
319
|
+
function isEscapeStringPrefix(sql, quoteAt) {
|
|
320
|
+
const prev = sql[quoteAt - 1];
|
|
321
|
+
if (prev !== 'E' && prev !== 'e')
|
|
322
|
+
return false;
|
|
323
|
+
const before = sql[quoteAt - 2];
|
|
324
|
+
return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
|
|
325
|
+
}
|
|
299
326
|
/** True when a fragment contains nothing but comments and whitespace. */
|
|
300
327
|
function isCommentOnlyStatement(stmt) {
|
|
301
328
|
const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PII tags for tools that build their schema from live introspection.
|
|
3
|
+
*
|
|
4
|
+
* `ColumnMetadata.pii` is a CODE-FIRST declaration: it comes from
|
|
5
|
+
* `defineSchema({ pii: true })` or the fluent `.pii()`, and `introspect.ts`
|
|
6
|
+
* never sets it (there is no reliable way to infer "this column holds personal
|
|
7
|
+
* data" from a Postgres catalog, and guessing would be worse than not trying).
|
|
8
|
+
*
|
|
9
|
+
* Studio and the MCP server introspect a live database, so on their own they
|
|
10
|
+
* see NO tags at all and their redaction is inert. This module closes that gap
|
|
11
|
+
* by reading the tags out of the generated `metadata.ts` that `turbine
|
|
12
|
+
* generate` writes, and handing back a table → column-name map the caller
|
|
13
|
+
* layers onto its introspected metadata.
|
|
14
|
+
*
|
|
15
|
+
* The generated file is TypeScript in the user's project, so it cannot simply
|
|
16
|
+
* be imported by a compiled CLI. It is read as TEXT and scanned for the exact
|
|
17
|
+
* shapes `generate.ts` emits (`serializeColumn`: one column object per line,
|
|
18
|
+
* `pii: true` only when tagged). Nothing is executed. A file that does not
|
|
19
|
+
* parse yields no tags, and the caller decides what to say about that.
|
|
20
|
+
*/
|
|
21
|
+
/** Table name → the snake_case column names tagged `pii: true`. */
|
|
22
|
+
export type PiiTagMap = Record<string, string[]>;
|
|
23
|
+
export interface PiiTagSource {
|
|
24
|
+
/** Absolute path that was read. */
|
|
25
|
+
path: string;
|
|
26
|
+
tags: PiiTagMap;
|
|
27
|
+
/** Total tagged columns, across tables. */
|
|
28
|
+
count: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Scan generated-metadata source text for PII-tagged columns.
|
|
32
|
+
*
|
|
33
|
+
* Exported for testing; callers normally use {@link loadPiiTags}.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parsePiiTags(source: string): PiiTagMap;
|
|
36
|
+
/**
|
|
37
|
+
* Read PII tags from the generated metadata in `outDir`, or return `null` when
|
|
38
|
+
* there is no readable generated metadata there. Never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadPiiTags(outDir: string): PiiTagSource | null;
|
|
41
|
+
/**
|
|
42
|
+
* Apply `tags` to introspected metadata, in place. Only columns that exist in
|
|
43
|
+
* the live schema are tagged, so a stale generated file can never invent one.
|
|
44
|
+
* Returns the number of columns actually tagged.
|
|
45
|
+
*/
|
|
46
|
+
export declare function applyPiiTags(metadata: {
|
|
47
|
+
tables: Record<string, {
|
|
48
|
+
columns: {
|
|
49
|
+
name: string;
|
|
50
|
+
pii?: boolean;
|
|
51
|
+
}[];
|
|
52
|
+
}>;
|
|
53
|
+
}, tags: PiiTagMap): number;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PII tags for tools that build their schema from live introspection.
|
|
3
|
+
*
|
|
4
|
+
* `ColumnMetadata.pii` is a CODE-FIRST declaration: it comes from
|
|
5
|
+
* `defineSchema({ pii: true })` or the fluent `.pii()`, and `introspect.ts`
|
|
6
|
+
* never sets it (there is no reliable way to infer "this column holds personal
|
|
7
|
+
* data" from a Postgres catalog, and guessing would be worse than not trying).
|
|
8
|
+
*
|
|
9
|
+
* Studio and the MCP server introspect a live database, so on their own they
|
|
10
|
+
* see NO tags at all and their redaction is inert. This module closes that gap
|
|
11
|
+
* by reading the tags out of the generated `metadata.ts` that `turbine
|
|
12
|
+
* generate` writes, and handing back a table → column-name map the caller
|
|
13
|
+
* layers onto its introspected metadata.
|
|
14
|
+
*
|
|
15
|
+
* The generated file is TypeScript in the user's project, so it cannot simply
|
|
16
|
+
* be imported by a compiled CLI. It is read as TEXT and scanned for the exact
|
|
17
|
+
* shapes `generate.ts` emits (`serializeColumn`: one column object per line,
|
|
18
|
+
* `pii: true` only when tagged). Nothing is executed. A file that does not
|
|
19
|
+
* parse yields no tags, and the caller decides what to say about that.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
/** ` <table>: {` at the table-entry indentation `generateMetadata` emits. */
|
|
24
|
+
const TABLE_HEAD = /^ {4}(?:'([^']+)'|([A-Za-z_$][\w$]*)): \{$/;
|
|
25
|
+
/** A serialized column object; `pii: true` is emitted only when tagged. */
|
|
26
|
+
const COLUMN_LINE = /^ {8}\{ name: '([^']+)'.*\bpii: true\b/;
|
|
27
|
+
/** ` columns: [` opens the column list; ` ],` closes it. */
|
|
28
|
+
const COLUMNS_OPEN = /^ {6}columns: \[$/;
|
|
29
|
+
const COLUMNS_CLOSE = /^ {6}\],$/;
|
|
30
|
+
/**
|
|
31
|
+
* Scan generated-metadata source text for PII-tagged columns.
|
|
32
|
+
*
|
|
33
|
+
* Exported for testing; callers normally use {@link loadPiiTags}.
|
|
34
|
+
*/
|
|
35
|
+
export function parsePiiTags(source) {
|
|
36
|
+
const tags = {};
|
|
37
|
+
let table = null;
|
|
38
|
+
let inColumns = false;
|
|
39
|
+
for (const line of source.split('\n')) {
|
|
40
|
+
const head = TABLE_HEAD.exec(line);
|
|
41
|
+
if (head) {
|
|
42
|
+
table = head[1] ?? head[2] ?? null;
|
|
43
|
+
inColumns = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!table)
|
|
47
|
+
continue;
|
|
48
|
+
if (COLUMNS_OPEN.test(line)) {
|
|
49
|
+
inColumns = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (inColumns && COLUMNS_CLOSE.test(line)) {
|
|
53
|
+
inColumns = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!inColumns)
|
|
57
|
+
continue;
|
|
58
|
+
const col = COLUMN_LINE.exec(line);
|
|
59
|
+
if (col?.[1]) {
|
|
60
|
+
const list = tags[table] ?? [];
|
|
61
|
+
list.push(col[1]);
|
|
62
|
+
tags[table] = list;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return tags;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read PII tags from the generated metadata in `outDir`, or return `null` when
|
|
69
|
+
* there is no readable generated metadata there. Never throws.
|
|
70
|
+
*/
|
|
71
|
+
export function loadPiiTags(outDir) {
|
|
72
|
+
for (const file of ['metadata.ts', 'metadata.js']) {
|
|
73
|
+
const path = join(outDir, file);
|
|
74
|
+
let source;
|
|
75
|
+
try {
|
|
76
|
+
source = readFileSync(path, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const tags = parsePiiTags(source);
|
|
82
|
+
const count = Object.values(tags).reduce((n, cols) => n + cols.length, 0);
|
|
83
|
+
return { path, tags, count };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Apply `tags` to introspected metadata, in place. Only columns that exist in
|
|
89
|
+
* the live schema are tagged, so a stale generated file can never invent one.
|
|
90
|
+
* Returns the number of columns actually tagged.
|
|
91
|
+
*/
|
|
92
|
+
export function applyPiiTags(metadata, tags) {
|
|
93
|
+
let applied = 0;
|
|
94
|
+
for (const [tableName, columns] of Object.entries(tags)) {
|
|
95
|
+
const table = Object.hasOwn(metadata.tables, tableName) ? metadata.tables[tableName] : undefined;
|
|
96
|
+
if (!table)
|
|
97
|
+
continue;
|
|
98
|
+
for (const col of table.columns) {
|
|
99
|
+
if (columns.includes(col.name)) {
|
|
100
|
+
col.pii = true;
|
|
101
|
+
applied++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return applied;
|
|
106
|
+
}
|