turbine-orm 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +58 -39
  2. package/dist/cjs/cli/destructive.js +233 -18
  3. package/dist/cjs/cli/index.js +56 -12
  4. package/dist/cjs/cli/mcp.js +23 -2
  5. package/dist/cjs/cli/migrate.js +28 -1
  6. package/dist/cjs/cli/pii-tags.js +111 -0
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +158 -0
  9. package/dist/cjs/cli/ui.js +8 -3
  10. package/dist/cjs/client.js +21 -1
  11. package/dist/cjs/dialect.js +2 -0
  12. package/dist/cjs/index-advisor.js +0 -0
  13. package/dist/cjs/index-stats.js +118 -6
  14. package/dist/cjs/mssql.js +5 -0
  15. package/dist/cjs/mysql.js +5 -0
  16. package/dist/cjs/nested-write.js +248 -18
  17. package/dist/cjs/observe.js +21 -15
  18. package/dist/cjs/powdb.js +3 -0
  19. package/dist/cjs/powql.js +13 -0
  20. package/dist/cjs/prisma-compat.js +9 -0
  21. package/dist/cjs/query/aggregates.js +41 -1
  22. package/dist/cjs/query/batched-loader.js +70 -6
  23. package/dist/cjs/query/builder.js +3 -3
  24. package/dist/cjs/query/relations.js +12 -2
  25. package/dist/cjs/query/where.js +36 -1
  26. package/dist/cjs/sqlite.js +5 -0
  27. package/dist/cli/destructive.d.ts +9 -3
  28. package/dist/cli/destructive.js +233 -18
  29. package/dist/cli/index.js +57 -13
  30. package/dist/cli/mcp.d.ts +7 -0
  31. package/dist/cli/mcp.js +23 -2
  32. package/dist/cli/migrate.d.ts +2 -1
  33. package/dist/cli/migrate.js +28 -1
  34. package/dist/cli/pii-tags.d.ts +53 -0
  35. package/dist/cli/pii-tags.js +106 -0
  36. package/dist/cli/studio-ui.generated.js +1 -1
  37. package/dist/cli/studio.d.ts +42 -0
  38. package/dist/cli/studio.js +157 -0
  39. package/dist/cli/ui.js +8 -3
  40. package/dist/client.js +21 -1
  41. package/dist/dialect.d.ts +19 -0
  42. package/dist/dialect.js +2 -0
  43. package/dist/index-advisor.d.ts +7 -0
  44. package/dist/index-advisor.js +0 -0
  45. package/dist/index-stats.d.ts +52 -1
  46. package/dist/index-stats.js +117 -5
  47. package/dist/mssql.js +5 -0
  48. package/dist/mysql.js +5 -0
  49. package/dist/nested-write.js +249 -19
  50. package/dist/observe.d.ts +0 -1
  51. package/dist/observe.js +21 -15
  52. package/dist/powdb.js +3 -0
  53. package/dist/powql.js +13 -0
  54. package/dist/prisma-compat.js +9 -0
  55. package/dist/query/aggregates.d.ts +18 -0
  56. package/dist/query/aggregates.js +40 -1
  57. package/dist/query/batched-loader.d.ts +29 -1
  58. package/dist/query/batched-loader.js +69 -6
  59. package/dist/query/builder.js +4 -4
  60. package/dist/query/relations.js +12 -2
  61. package/dist/query/types.d.ts +16 -0
  62. package/dist/query/where.d.ts +18 -1
  63. package/dist/query/where.js +34 -1
  64. package/dist/sqlite.js +5 -0
  65. package/package.json +3 -2
package/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
- const unused = unusedRan ? findUnusedIndexes(snapshot, { minScans }) : [];
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 ? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans }) : [];
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
- // Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
2201
- if (ctx.subtract.unusedRan) {
2202
- out.unused = ctx.subtract.unused;
2203
- out.redundant = ctx.subtract.redundant;
2204
- out.invalid = ctx.invalid;
2205
- }
2206
- if (ctx.subtract.auditRan) {
2207
- out.audit = ctx.subtract.audit;
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
- console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
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
- return { tables, enums };
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
@@ -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, a double-quoted identifier, a dollar-quoted body, a line comment
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
@@ -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, a double-quoted identifier, a dollar-quoted body, a line comment
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
+ }