turbine-orm 0.61.0 → 0.62.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 (82) hide show
  1. package/README.md +65 -21
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +323 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +186 -3
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline.js +15 -2
  21. package/dist/cjs/powql.d.ts +12 -0
  22. package/dist/cjs/powql.js +46 -21
  23. package/dist/cjs/prisma-compat.d.ts +15 -5
  24. package/dist/cjs/prisma-compat.js +273 -78
  25. package/dist/cjs/query/aggregates.d.ts +1 -1
  26. package/dist/cjs/query/aggregates.js +24 -10
  27. package/dist/cjs/query/batched-loader.d.ts +9 -4
  28. package/dist/cjs/query/batched-loader.js +4 -1
  29. package/dist/cjs/query/builder.d.ts +47 -0
  30. package/dist/cjs/query/builder.js +125 -21
  31. package/dist/cjs/query/index.d.ts +3 -1
  32. package/dist/cjs/query/index.js +7 -1
  33. package/dist/cjs/query/option-surface.d.ts +11 -0
  34. package/dist/cjs/query/option-surface.js +13 -0
  35. package/dist/cjs/query/relations.d.ts +8 -0
  36. package/dist/cjs/query/relations.js +21 -1
  37. package/dist/cjs/query/types.d.ts +152 -18
  38. package/dist/cjs/query/types.js +212 -1
  39. package/dist/cjs/query/where.d.ts +3 -3
  40. package/dist/cjs/query/where.js +8 -2
  41. package/dist/cjs/query/writes.js +10 -9
  42. package/dist/cli/config.d.ts +40 -0
  43. package/dist/cli/config.js +73 -2
  44. package/dist/cli/index.d.ts +85 -1
  45. package/dist/cli/index.js +321 -26
  46. package/dist/cli/mcp.d.ts +8 -0
  47. package/dist/cli/mcp.js +448 -29
  48. package/dist/cli/pii-tags.d.ts +64 -9
  49. package/dist/cli/pii-tags.js +217 -39
  50. package/dist/cli/studio-ui.generated.js +1 -1
  51. package/dist/cli/studio.d.ts +23 -0
  52. package/dist/cli/studio.js +125 -53
  53. package/dist/cli/ui.d.ts +15 -1
  54. package/dist/cli/ui.js +18 -4
  55. package/dist/client.js +187 -4
  56. package/dist/errors.d.ts +38 -1
  57. package/dist/errors.js +234 -23
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +5 -2
  60. package/dist/pipeline.js +15 -2
  61. package/dist/powql.d.ts +12 -0
  62. package/dist/powql.js +46 -21
  63. package/dist/prisma-compat.d.ts +15 -5
  64. package/dist/prisma-compat.js +274 -79
  65. package/dist/query/aggregates.d.ts +1 -1
  66. package/dist/query/aggregates.js +24 -10
  67. package/dist/query/batched-loader.d.ts +9 -4
  68. package/dist/query/batched-loader.js +4 -1
  69. package/dist/query/builder.d.ts +47 -0
  70. package/dist/query/builder.js +124 -21
  71. package/dist/query/index.d.ts +3 -1
  72. package/dist/query/index.js +2 -0
  73. package/dist/query/option-surface.d.ts +11 -0
  74. package/dist/query/option-surface.js +13 -0
  75. package/dist/query/relations.d.ts +8 -0
  76. package/dist/query/relations.js +21 -1
  77. package/dist/query/types.d.ts +152 -18
  78. package/dist/query/types.js +207 -2
  79. package/dist/query/where.d.ts +3 -3
  80. package/dist/query/where.js +8 -2
  81. package/dist/query/writes.js +10 -9
  82. package/package.json +13 -3
@@ -9,33 +9,88 @@
9
9
  * Studio and the MCP server introspect a live database, so on their own they
10
10
  * see NO tags at all and their redaction is inert. This module closes that gap
11
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
12
+ * generate` writes, and handing back a table -> column-name map the caller
13
13
  * layers onto its introspected metadata.
14
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.
15
+ * The generated file is TypeScript in the user's project, so a compiled CLI
16
+ * cannot import it (there is no TS loader in the published `dist`, and running
17
+ * user code to learn which columns to hide would be a worse trade than reading
18
+ * it). It is read as TEXT and scanned STRUCTURALLY. Nothing is executed.
19
+ *
20
+ * WHY STRUCTURALLY, and not with line-anchored regexes (which is what this did
21
+ * until it was found to fail open): the old scan required exactly 8 leading
22
+ * spaces, single-quoted keys, and the whole column object on ONE line. Real
23
+ * emitted column lines run past 120 characters, which is this repo's own Biome
24
+ * `lineWidth`, so ANY formatter run over a user's generated directory rewraps
25
+ * them and every tag silently disappears. Redaction then reported success with
26
+ * an empty column list, indistinguishable from "this table has no PII". The
27
+ * scanner below is whitespace-, quote-, and line-break-insensitive, and when it
28
+ * cannot find the structure at all it says so (`PiiScan.ok === false`) instead
29
+ * of returning an empty map that reads like a clean bill of health. Callers are
30
+ * expected to FAIL CLOSED on `ok === false`.
31
+ *
32
+ * "Cannot find the structure" includes a file that STOPS PART-WAY. A tag is
33
+ * recorded only when its column object CLOSES, so a metadata.ts cut short by an
34
+ * interrupted `turbine generate`, a full disk, or a merge conflict yields fewer
35
+ * tags than the file declares, and every scan-level signal (a `tables:` map, a
36
+ * column object) is already present by then. The scan therefore also requires
37
+ * that the tokenizer reached the end of the file cleanly and that every `{`/`[`
38
+ * it opened was closed; a leftover frame means the tail was lost, and the tags
39
+ * from the lost tail with it.
20
40
  */
21
- /** Table name the snake_case column names tagged `pii: true`. */
41
+ /** Table name -> the snake_case column names tagged `pii: true`. */
22
42
  export type PiiTagMap = Record<string, string[]>;
43
+ /**
44
+ * Outcome of scanning one generated-metadata file.
45
+ *
46
+ * `ok: false` means the scan could not prove anything about this file: it did
47
+ * not look like `generateMetadata` output (no `tables:` map, or not a single
48
+ * parsable column object), or it did not parse to completion (it ends inside a
49
+ * string, a comment, or an unclosed object). An empty `tags` with `ok: true` is
50
+ * a real "no tagged columns"; an empty `tags` with `ok: false` is "we do not
51
+ * know", and the two must never be treated the same way. Nor is a NON-empty
52
+ * `tags` with `ok: false` usable: a truncated file's surviving tags are a
53
+ * prefix of the real set, so trusting them still under-redacts.
54
+ */
55
+ export interface PiiScan {
56
+ tags: PiiTagMap;
57
+ ok: boolean;
58
+ /** Human-readable cause, present only when `ok` is false. */
59
+ reason?: string;
60
+ /** Table entries seen under `tables:`. */
61
+ tablesSeen: number;
62
+ /** Column objects seen under any table's `columns:` list, tagged or not. */
63
+ columnsSeen: number;
64
+ }
23
65
  export interface PiiTagSource {
24
66
  /** Absolute path that was read. */
25
67
  path: string;
26
68
  tags: PiiTagMap;
27
69
  /** Total tagged columns, across tables. */
28
70
  count: number;
71
+ /** Full scan outcome. Callers that can fail closed should read `scan.ok`. */
72
+ scan: PiiScan;
29
73
  }
30
74
  /**
31
- * Scan generated-metadata source text for PII-tagged columns.
75
+ * Scan generated-metadata source text for PII-tagged columns, reporting whether
76
+ * the scan actually understood the file.
32
77
  *
33
- * Exported for testing; callers normally use {@link loadPiiTags}.
78
+ * Exported for testing and for callers that need to fail closed; the plain-map
79
+ * convenience wrapper is {@link parsePiiTags}.
80
+ */
81
+ export declare function scanPiiTags(source: string): PiiScan;
82
+ /**
83
+ * Tag map only. Kept for callers that already treat "no tags" as safe; anything
84
+ * that redacts should prefer {@link scanPiiTags} so it can see a failed scan.
34
85
  */
35
86
  export declare function parsePiiTags(source: string): PiiTagMap;
36
87
  /**
37
88
  * Read PII tags from the generated metadata in `outDir`, or return `null` when
38
89
  * there is no readable generated metadata there. Never throws.
90
+ *
91
+ * A returned source with `scan.ok === false` means a file WAS found and could
92
+ * not be understood: that is strictly worse than no file at all, because the
93
+ * user believes their tags are in force. Callers must not treat it as "no PII".
39
94
  */
40
95
  export declare function loadPiiTags(outDir: string): PiiTagSource | null;
41
96
  /**
@@ -10,68 +10,247 @@
10
10
  * Studio and the MCP server introspect a live database, so on their own they
11
11
  * see NO tags at all and their redaction is inert. This module closes that gap
12
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
13
+ * generate` writes, and handing back a table -> column-name map the caller
14
14
  * layers onto its introspected metadata.
15
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.
16
+ * The generated file is TypeScript in the user's project, so a compiled CLI
17
+ * cannot import it (there is no TS loader in the published `dist`, and running
18
+ * user code to learn which columns to hide would be a worse trade than reading
19
+ * it). It is read as TEXT and scanned STRUCTURALLY. Nothing is executed.
20
+ *
21
+ * WHY STRUCTURALLY, and not with line-anchored regexes (which is what this did
22
+ * until it was found to fail open): the old scan required exactly 8 leading
23
+ * spaces, single-quoted keys, and the whole column object on ONE line. Real
24
+ * emitted column lines run past 120 characters, which is this repo's own Biome
25
+ * `lineWidth`, so ANY formatter run over a user's generated directory rewraps
26
+ * them and every tag silently disappears. Redaction then reported success with
27
+ * an empty column list, indistinguishable from "this table has no PII". The
28
+ * scanner below is whitespace-, quote-, and line-break-insensitive, and when it
29
+ * cannot find the structure at all it says so (`PiiScan.ok === false`) instead
30
+ * of returning an empty map that reads like a clean bill of health. Callers are
31
+ * expected to FAIL CLOSED on `ok === false`.
32
+ *
33
+ * "Cannot find the structure" includes a file that STOPS PART-WAY. A tag is
34
+ * recorded only when its column object CLOSES, so a metadata.ts cut short by an
35
+ * interrupted `turbine generate`, a full disk, or a merge conflict yields fewer
36
+ * tags than the file declares, and every scan-level signal (a `tables:` map, a
37
+ * column object) is already present by then. The scan therefore also requires
38
+ * that the tokenizer reached the end of the file cleanly and that every `{`/`[`
39
+ * it opened was closed; a leftover frame means the tail was lost, and the tags
40
+ * from the lost tail with it.
21
41
  */
22
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.scanPiiTags = scanPiiTags;
23
44
  exports.parsePiiTags = parsePiiTags;
24
45
  exports.loadPiiTags = loadPiiTags;
25
46
  exports.applyPiiTags = applyPiiTags;
26
47
  const node_fs_1 = require("node:fs");
27
48
  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}\],$/;
49
+ const WORD_CHAR = /[A-Za-z0-9_$.]/;
35
50
  /**
36
- * Scan generated-metadata source text for PII-tagged columns.
51
+ * Tokenize generated-metadata source far enough to walk its object structure:
52
+ * strings (all three quote styles, backslash escapes honoured), bare words, and
53
+ * single-character punctuation. Comments are skipped.
54
+ *
55
+ * Strings are a distinct token kind on purpose: a column's `definition` field
56
+ * carries raw index DDL, which contains braces and parentheses. Treating that
57
+ * text as structure would desynchronize the brace depth and silently drop every
58
+ * later table.
37
59
  *
38
- * Exported for testing; callers normally use {@link loadPiiTags}.
60
+ * A string or block comment that never closes is REPORTED rather than accepted:
61
+ * that is a half-written file, and the tokens before the cut are a partial view
62
+ * of the tags in it.
39
63
  */
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;
64
+ function tokenize(source) {
65
+ const tokens = [];
66
+ let truncated = null;
67
+ let i = 0;
68
+ const n = source.length;
69
+ while (i < n) {
70
+ const ch = source[i];
71
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
72
+ i++;
49
73
  continue;
50
74
  }
51
- if (!table)
75
+ if (ch === '/' && source[i + 1] === '/') {
76
+ const nl = source.indexOf('\n', i);
77
+ i = nl === -1 ? n : nl + 1;
52
78
  continue;
53
- if (COLUMNS_OPEN.test(line)) {
54
- inColumns = true;
79
+ }
80
+ if (ch === '/' && source[i + 1] === '*') {
81
+ const end = source.indexOf('*/', i + 2);
82
+ if (end === -1)
83
+ truncated = 'the file ends inside an unterminated block comment';
84
+ i = end === -1 ? n : end + 2;
55
85
  continue;
56
86
  }
57
- if (inColumns && COLUMNS_CLOSE.test(line)) {
58
- inColumns = false;
87
+ if (ch === "'" || ch === '"' || ch === '`') {
88
+ const quote = ch;
89
+ let out = '';
90
+ let closed = false;
91
+ i++;
92
+ while (i < n) {
93
+ const c = source[i];
94
+ if (c === '\\') {
95
+ // Only the escapes `escSQ` and JSON.stringify can emit matter here;
96
+ // anything else is passed through as its literal second character.
97
+ const next = source[i + 1];
98
+ if (next === undefined)
99
+ break;
100
+ out += next === 'n' ? '\n' : next === 't' ? '\t' : next;
101
+ i += 2;
102
+ continue;
103
+ }
104
+ if (c === quote) {
105
+ i++;
106
+ closed = true;
107
+ break;
108
+ }
109
+ out += c;
110
+ i++;
111
+ }
112
+ if (!closed)
113
+ truncated = 'the file ends inside an unterminated string literal';
114
+ tokens.push({ kind: 'string', value: out });
59
115
  continue;
60
116
  }
61
- if (!inColumns)
117
+ if (WORD_CHAR.test(ch)) {
118
+ let out = '';
119
+ while (i < n && WORD_CHAR.test(source[i])) {
120
+ out += source[i];
121
+ i++;
122
+ }
123
+ tokens.push({ kind: 'word', value: out });
62
124
  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
125
  }
126
+ tokens.push({ kind: 'punct', value: ch });
127
+ i++;
69
128
  }
70
- return tags;
129
+ return { tokens, truncated };
130
+ }
131
+ /**
132
+ * True when `stack`'s innermost frame is a column object, i.e. the path ends
133
+ * `... tables -> <tableName> -> columns -> <array element>`.
134
+ *
135
+ * Checking the TAIL rather than an absolute depth is what makes this survive
136
+ * the wrapper the generator puts around the map (`export const SCHEMA: ... = {`)
137
+ * and any future nesting change above `tables`. It also keeps unrelated
138
+ * `columns:` lists out: an index entry's `columns` sits under
139
+ * `... -> indexes -> <element> -> columns`, whose great-grandparent is
140
+ * `indexes`, not `tables`.
141
+ */
142
+ function isColumnFrame(stack) {
143
+ const len = stack.length;
144
+ if (len < 4)
145
+ return false;
146
+ return (stack[len - 1].key === null && stack[len - 2].key === 'columns' && stack[len - 4].key === 'tables'
147
+ // stack[len - 3] is the table name; any name is valid, so it is not checked.
148
+ );
149
+ }
150
+ /**
151
+ * Scan generated-metadata source text for PII-tagged columns, reporting whether
152
+ * the scan actually understood the file.
153
+ *
154
+ * Exported for testing and for callers that need to fail closed; the plain-map
155
+ * convenience wrapper is {@link parsePiiTags}.
156
+ */
157
+ function scanPiiTags(source) {
158
+ const tags = {};
159
+ const stack = [];
160
+ const tableNames = new Set();
161
+ let columnsSeen = 0;
162
+ let sawTablesMap = false;
163
+ let unbalancedClose = false;
164
+ let pendingKey = null;
165
+ const { tokens, truncated } = tokenize(source);
166
+ for (let i = 0; i < tokens.length; i++) {
167
+ const token = tokens[i];
168
+ if (token.kind === 'punct' && (token.value === '{' || token.value === '[')) {
169
+ const frame = { key: pendingKey };
170
+ pendingKey = null;
171
+ stack.push(frame);
172
+ if (frame.key === 'tables')
173
+ sawTablesMap = true;
174
+ if (token.value === '{' && isColumnFrame(stack)) {
175
+ frame.column = {};
176
+ columnsSeen++;
177
+ const tableName = stack[stack.length - 3].key;
178
+ if (tableName !== null)
179
+ tableNames.add(tableName);
180
+ }
181
+ continue;
182
+ }
183
+ if (token.kind === 'punct' && (token.value === '}' || token.value === ']')) {
184
+ const frame = stack.pop();
185
+ // More closers than openers: the walk is no longer tracking real depth,
186
+ // so `isColumnFrame` can no longer be trusted for the rest of the file.
187
+ if (frame === undefined)
188
+ unbalancedClose = true;
189
+ pendingKey = null;
190
+ const column = frame?.column;
191
+ if (column?.pii === true && column.name !== undefined) {
192
+ // stack was already popped, so the table name is now the last-but-one.
193
+ const tableName = stack[stack.length - 2]?.key;
194
+ if (tableName) {
195
+ const list = tags[tableName] ?? [];
196
+ list.push(column.name);
197
+ tags[tableName] = list;
198
+ }
199
+ }
200
+ continue;
201
+ }
202
+ // `<key>:` opens a member. Two things happen: the key becomes the label for
203
+ // whatever container may follow, and, inside a column object, a scalar value
204
+ // on the same member is captured directly.
205
+ const next = tokens[i + 1];
206
+ if ((token.kind === 'string' || token.kind === 'word') && next?.kind === 'punct' && next.value === ':') {
207
+ pendingKey = token.value;
208
+ const column = stack[stack.length - 1]?.column;
209
+ const value = tokens[i + 2];
210
+ if (column && value && !(value.kind === 'punct' && (value.value === '{' || value.value === '['))) {
211
+ if (token.value === 'name' && value.kind === 'string')
212
+ column.name = value.value;
213
+ else if (token.value === 'pii' && value.kind === 'word' && value.value === 'true')
214
+ column.pii = true;
215
+ }
216
+ }
217
+ }
218
+ const tablesSeen = tableNames.size;
219
+ let reason;
220
+ // Truncation is checked FIRST and independently of what was found. A file cut
221
+ // short mid-write still contains a `tables:` map and parsable columns, so the
222
+ // "did this look like generated metadata" questions all answer yes while the
223
+ // tags after the cut are simply missing. That is the shape this scan used to
224
+ // report as `ok: true` with a short tag list.
225
+ if (truncated)
226
+ reason = `${truncated}; the generated metadata file looks truncated`;
227
+ else if (stack.length > 0) {
228
+ reason =
229
+ `the file ends with ${stack.length} unclosed object/array level(s); the generated metadata file looks ` +
230
+ `truncated, so any tags after the cut were not seen`;
231
+ }
232
+ else if (unbalancedClose)
233
+ reason = 'unbalanced `}`/`]` in the file, so its object structure could not be tracked';
234
+ else if (!sawTablesMap)
235
+ reason = 'no `tables:` map found; this does not look like generated Turbine metadata';
236
+ else if (columnsSeen === 0)
237
+ reason = 'found a `tables:` map but no parsable column objects inside it';
238
+ return { tags, ok: reason === undefined, reason, tablesSeen, columnsSeen };
239
+ }
240
+ /**
241
+ * Tag map only. Kept for callers that already treat "no tags" as safe; anything
242
+ * that redacts should prefer {@link scanPiiTags} so it can see a failed scan.
243
+ */
244
+ function parsePiiTags(source) {
245
+ return scanPiiTags(source).tags;
71
246
  }
72
247
  /**
73
248
  * Read PII tags from the generated metadata in `outDir`, or return `null` when
74
249
  * there is no readable generated metadata there. Never throws.
250
+ *
251
+ * A returned source with `scan.ok === false` means a file WAS found and could
252
+ * not be understood: that is strictly worse than no file at all, because the
253
+ * user believes their tags are in force. Callers must not treat it as "no PII".
75
254
  */
76
255
  function loadPiiTags(outDir) {
77
256
  for (const file of ['metadata.ts', 'metadata.js']) {
@@ -83,9 +262,9 @@ function loadPiiTags(outDir) {
83
262
  catch {
84
263
  continue;
85
264
  }
86
- const tags = parsePiiTags(source);
87
- const count = Object.values(tags).reduce((n, cols) => n + cols.length, 0);
88
- return { path, tags, count };
265
+ const scan = scanPiiTags(source);
266
+ const count = Object.values(scan.tags).reduce((n, cols) => n + cols.length, 0);
267
+ return { path, tags: scan.tags, count, scan };
89
268
  }
90
269
  return null;
91
270
  }