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
@@ -15,6 +15,7 @@ const introspect_js_1 = require("../introspect.js");
15
15
  const index_js_1 = require("../query/index.js");
16
16
  const schema_js_1 = require("../schema.js");
17
17
  const migrate_js_1 = require("./migrate.js");
18
+ const pii_tags_js_1 = require("./pii-tags.js");
18
19
  /**
19
20
  * Walk up from the running script to find turbine-orm's own package.json.
20
21
  * Uses process.argv[1] instead of import.meta.url so the same code compiles
@@ -410,15 +411,27 @@ async function sampleRows(ctx, tableName, limit) {
410
411
  const table = requireTable(metadata, tableName);
411
412
  const qualifiedTable = `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
412
413
  const result = await client.query(`SELECT * FROM ${qualifiedTable} LIMIT $1`, [limit]);
414
+ // Sample rows go straight into an LLM context, so PII-tagged values are
415
+ // replaced before serialization, the same stance Studio's Data tab takes.
416
+ const piiColumns = new Set(table.columns.filter((c) => c.pii).map((c) => c.name));
413
417
  return {
414
418
  table: table.name,
415
419
  limit,
420
+ redactedColumns: [...piiColumns],
416
421
  columns: result.fields.map((field) => ({ name: field.name, dataTypeID: field.dataTypeID })),
417
- rows: result.rows,
422
+ rows: piiColumns.size === 0 ? result.rows : result.rows.map((row) => redactRow(row, piiColumns)),
418
423
  rowCount: result.rowCount ?? result.rows.length,
419
424
  };
420
425
  });
421
426
  }
427
+ /** Replace PII-tagged cells with a fixed marker (never the value, never null). */
428
+ function redactRow(row, piiColumns) {
429
+ const out = {};
430
+ for (const [key, value] of Object.entries(row)) {
431
+ out[key] = piiColumns.has(key) ? '•• redacted ••' : value;
432
+ }
433
+ return out;
434
+ }
422
435
  async function withReadOnly(ctx, fn) {
423
436
  const client = await ctx.pool.connect();
424
437
  try {
@@ -596,7 +609,15 @@ async function loadSchemaMetadata(client, options) {
596
609
  indexes: indexesByTable.get(tableName) ?? [],
597
610
  };
598
611
  }
599
- return { tables, enums };
612
+ const metadata = { tables, enums };
613
+ // Code-first PII tags, layered onto the live catalog. Without this the
614
+ // redaction below has nothing to act on (introspection never infers a tag).
615
+ if (options.metadataDir) {
616
+ const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
617
+ if (source)
618
+ (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags);
619
+ }
620
+ return metadata;
600
621
  }
601
622
  /**
602
623
  * Group raw FK rows into constraint-level entries and delegate relation
@@ -196,7 +196,8 @@ function parseMigrationContent(content) {
196
196
  * Split a SQL script into individual statements on top-level semicolons.
197
197
  *
198
198
  * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
199
- * string, a double-quoted identifier, a dollar-quoted body, a line comment
199
+ * string (including a backslash-escaping `E'...'` string), a double-quoted
200
+ * identifier, a dollar-quoted body, a line comment
200
201
  * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
201
202
  * split. This is the one production-destroying failure mode of no-transaction
202
203
  * migrations (a partial statement executed against production), so the behavior
@@ -247,10 +248,19 @@ function splitSqlStatements(sql) {
247
248
  continue;
248
249
  }
249
250
  // Single-quoted string ('' is an escaped quote, stays inside the string).
251
+ // An E-prefixed string (E'...') additionally honors backslash escapes, so
252
+ // `E'p\'q'` is ONE string: treating the `\'` as a terminator would close the
253
+ // string early and let the next quote swallow a real statement terminator.
250
254
  if (ch === "'") {
255
+ const backslashEscapes = isEscapeStringPrefix(sql, i);
251
256
  let j = i + 1;
252
257
  current += "'";
253
258
  while (j < n) {
259
+ if (backslashEscapes && sql[j] === '\\' && j + 1 < n) {
260
+ current += sql[j] + sql[j + 1];
261
+ j += 2;
262
+ continue;
263
+ }
254
264
  if (sql[j] === "'" && sql[j + 1] === "'") {
255
265
  current += "''";
256
266
  j += 2;
@@ -322,6 +332,23 @@ function splitSqlStatements(sql) {
322
332
  statements.push(tail);
323
333
  return statements.filter((s) => !isCommentOnlyStatement(s));
324
334
  }
335
+ /**
336
+ * True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
337
+ * whose body treats a backslash as an escape character.
338
+ *
339
+ * The `E` must be a standalone token: an identifier that merely ends in `e`
340
+ * (`some_table` cannot be followed by a quote in valid SQL, but the check keeps
341
+ * the tokenizer honest) does not turn the following literal into an E-string.
342
+ * Ordinary literals are left alone on purpose: with the modern
343
+ * `standard_conforming_strings = on` default, `'a\'` IS a complete string.
344
+ */
345
+ function isEscapeStringPrefix(sql, quoteAt) {
346
+ const prev = sql[quoteAt - 1];
347
+ if (prev !== 'E' && prev !== 'e')
348
+ return false;
349
+ const before = sql[quoteAt - 2];
350
+ return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
351
+ }
325
352
  /** True when a fragment contains nothing but comments and whitespace. */
326
353
  function isCommentOnlyStatement(stmt) {
327
354
  const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * PII tags for tools that build their schema from live introspection.
4
+ *
5
+ * `ColumnMetadata.pii` is a CODE-FIRST declaration: it comes from
6
+ * `defineSchema({ pii: true })` or the fluent `.pii()`, and `introspect.ts`
7
+ * never sets it (there is no reliable way to infer "this column holds personal
8
+ * data" from a Postgres catalog, and guessing would be worse than not trying).
9
+ *
10
+ * Studio and the MCP server introspect a live database, so on their own they
11
+ * see NO tags at all and their redaction is inert. This module closes that gap
12
+ * by reading the tags out of the generated `metadata.ts` that `turbine
13
+ * generate` writes, and handing back a table → column-name map the caller
14
+ * layers onto its introspected metadata.
15
+ *
16
+ * The generated file is TypeScript in the user's project, so it cannot simply
17
+ * be imported by a compiled CLI. It is read as TEXT and scanned for the exact
18
+ * shapes `generate.ts` emits (`serializeColumn`: one column object per line,
19
+ * `pii: true` only when tagged). Nothing is executed. A file that does not
20
+ * parse yields no tags, and the caller decides what to say about that.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.parsePiiTags = parsePiiTags;
24
+ exports.loadPiiTags = loadPiiTags;
25
+ exports.applyPiiTags = applyPiiTags;
26
+ const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
28
+ /** ` <table>: {` at the table-entry indentation `generateMetadata` emits. */
29
+ const TABLE_HEAD = /^ {4}(?:'([^']+)'|([A-Za-z_$][\w$]*)): \{$/;
30
+ /** A serialized column object; `pii: true` is emitted only when tagged. */
31
+ const COLUMN_LINE = /^ {8}\{ name: '([^']+)'.*\bpii: true\b/;
32
+ /** ` columns: [` opens the column list; ` ],` closes it. */
33
+ const COLUMNS_OPEN = /^ {6}columns: \[$/;
34
+ const COLUMNS_CLOSE = /^ {6}\],$/;
35
+ /**
36
+ * Scan generated-metadata source text for PII-tagged columns.
37
+ *
38
+ * Exported for testing; callers normally use {@link loadPiiTags}.
39
+ */
40
+ function parsePiiTags(source) {
41
+ const tags = {};
42
+ let table = null;
43
+ let inColumns = false;
44
+ for (const line of source.split('\n')) {
45
+ const head = TABLE_HEAD.exec(line);
46
+ if (head) {
47
+ table = head[1] ?? head[2] ?? null;
48
+ inColumns = false;
49
+ continue;
50
+ }
51
+ if (!table)
52
+ continue;
53
+ if (COLUMNS_OPEN.test(line)) {
54
+ inColumns = true;
55
+ continue;
56
+ }
57
+ if (inColumns && COLUMNS_CLOSE.test(line)) {
58
+ inColumns = false;
59
+ continue;
60
+ }
61
+ if (!inColumns)
62
+ continue;
63
+ const col = COLUMN_LINE.exec(line);
64
+ if (col?.[1]) {
65
+ const list = tags[table] ?? [];
66
+ list.push(col[1]);
67
+ tags[table] = list;
68
+ }
69
+ }
70
+ return tags;
71
+ }
72
+ /**
73
+ * Read PII tags from the generated metadata in `outDir`, or return `null` when
74
+ * there is no readable generated metadata there. Never throws.
75
+ */
76
+ function loadPiiTags(outDir) {
77
+ for (const file of ['metadata.ts', 'metadata.js']) {
78
+ const path = (0, node_path_1.join)(outDir, file);
79
+ let source;
80
+ try {
81
+ source = (0, node_fs_1.readFileSync)(path, 'utf8');
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ const tags = parsePiiTags(source);
87
+ const count = Object.values(tags).reduce((n, cols) => n + cols.length, 0);
88
+ return { path, tags, count };
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Apply `tags` to introspected metadata, in place. Only columns that exist in
94
+ * the live schema are tagged, so a stale generated file can never invent one.
95
+ * Returns the number of columns actually tagged.
96
+ */
97
+ function applyPiiTags(metadata, tags) {
98
+ let applied = 0;
99
+ for (const [tableName, columns] of Object.entries(tags)) {
100
+ const table = Object.hasOwn(metadata.tables, tableName) ? metadata.tables[tableName] : undefined;
101
+ if (!table)
102
+ continue;
103
+ for (const col of table.columns) {
104
+ if (columns.includes(col.name)) {
105
+ col.pii = true;
106
+ applied++;
107
+ }
108
+ }
109
+ }
110
+ return applied;
111
+ }