turbine-orm 0.35.0 → 0.36.1

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 (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. package/package.json +4 -4
@@ -2,36 +2,52 @@
2
2
  /**
3
3
  * turbine-orm CLI — Studio
4
4
  *
5
- * A local, read-only web UI for browsing databases, exploring relations,
6
- * and composing queries visually. ORM-native since v0.19: there is no
7
- * raw-SQL input surface — the Query tab builds `findMany` args that are
8
- * validated against introspected metadata and compiled by QueryInterface
9
- * (`/api/builder`). Pure Node (built-in `http` module), no runtime
10
- * dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
11
- * hosts unless `npx turbine studio --allow-remote` is set.
5
+ * A local web UI for browsing databases, exploring relations, and composing
6
+ * queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
7
+ * The Query tab builds `findMany` args that are validated against introspected
8
+ * metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
9
+ * `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
10
+ * and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
11
+ *
12
+ * Read-only by default. `turbine studio --write` opts in to single-row writes
13
+ * (see the write model below); without the flag the write API routes do not
14
+ * exist (they 404) and the UI renders no write affordances.
12
15
  *
13
16
  * Security model:
14
17
  * • Loopback by default; CLI refuses non-loopback without --allow-remote
15
18
  * • Random auth token generated per process, required in Cookie header
16
- * • No SQL input surface at all every identifier in a builder request is
17
- * validated against the introspected schema; all values are $N params
18
- * Every query runs in a READ ONLY transaction (belt-and-suspenders)
19
+ * • No SQL input surface at all: every identifier in a builder or write
20
+ * request is validated against the introspected schema; all values are
21
+ * $N params compiled through the query builders
22
+ * • Read routes run in a READ ONLY transaction (belt-and-suspenders)
23
+ * • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
24
+ * transaction, require a matching Origin header (CSRF), and target exactly
25
+ * one row by its full primary key
19
26
  * • 30s statement timeout via parameterized set_config()
20
- * • Per-session rate limiting, CSP + security headers, cross-origin refusal
27
+ * • Per-session rate limiting, cross-origin refusal, security headers, and a
28
+ * per-request CSP nonce for the inline script (no `unsafe-inline`)
29
+ *
30
+ * PII: columns tagged `pii` in code-first metadata are redacted server-side in
31
+ * every row-bearing response (the literal `•• redacted ••`) unless the server
32
+ * was started with `--show-pii`.
21
33
  *
22
- * Not implemented (deliberately): row editing, DDL, destructive operations.
23
- * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
34
+ * Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
35
+ * unconditional writes are deliberately unsupported. Use the CLI or migrate for
36
+ * schema changes and bulk operations.
24
37
  */
25
38
  var __importDefault = (this && this.__importDefault) || function (mod) {
26
39
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
40
  };
28
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.PII_REDACTED = void 0;
29
43
  exports.startStudio = startStudio;
44
+ exports.handleRequest = handleRequest;
30
45
  exports.apiTableRows = apiTableRows;
31
46
  exports.resolveColumnName = resolveColumnName;
32
47
  exports.isTextishType = isTextishType;
33
48
  exports.escapeLikePattern = escapeLikePattern;
34
49
  exports.apiBuilder = apiBuilder;
50
+ exports.apiRowWrite = apiRowWrite;
35
51
  exports.apiListSavedQueries = apiListSavedQueries;
36
52
  exports.apiCreateSavedQuery = apiCreateSavedQuery;
37
53
  exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
@@ -88,7 +104,17 @@ async function startStudio(options) {
88
104
  params: ['30s'],
89
105
  };
90
106
  const rateLimiter = new Map();
91
- const ctx = { pool, metadata, options, authToken, stateDir, statementTimeout, rateLimiter };
107
+ const ctx = {
108
+ pool,
109
+ metadata,
110
+ options,
111
+ authToken,
112
+ stateDir,
113
+ statementTimeout,
114
+ rateLimiter,
115
+ writable: options.write === true,
116
+ showPii: options.showPii === true,
117
+ };
92
118
  const server = (0, node_http_1.createServer)((req, res) => {
93
119
  handleRequest(req, res, ctx).catch((err) => {
94
120
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -157,7 +183,7 @@ async function handleRequest(req, res, ctx) {
157
183
  res.end();
158
184
  return;
159
185
  }
160
- sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
186
+ sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
161
187
  return;
162
188
  }
163
189
  // Favicon — answered before the auth gate so the browser's automatic request
@@ -200,6 +226,26 @@ async function handleRequest(req, res, ctx) {
200
226
  const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
201
227
  return apiDeleteSavedQuery(res, ctx, id);
202
228
  }
229
+ // Write routes: ONLY exist in write mode. In read-only mode they fall through
230
+ // to the 404 below (deliberately not 403: a read-only Studio has no such API).
231
+ if (ctx.writable && pathname.startsWith('/api/row/') && req.method === 'POST') {
232
+ // CSRF: a state-changing request MUST carry a same-origin Origin header. The
233
+ // top-of-handler check already rejects a MISMATCHED origin (403); this also
234
+ // rejects an ABSENT one, which read (GET) routes tolerate for curl ergonomics
235
+ // but a browser always sends on a cross-scheme/site POST. `fetch` from the
236
+ // Studio page always sets it for same-origin, so the real UI is unaffected.
237
+ if (origin !== expectedOrigin) {
238
+ sendJson(res, 403, { error: 'a matching Origin header is required for write requests' });
239
+ return;
240
+ }
241
+ const op = pathname.slice('/api/row/'.length);
242
+ if (op === 'update')
243
+ return apiRowWrite(req, res, ctx, 'update');
244
+ if (op === 'insert')
245
+ return apiRowWrite(req, res, ctx, 'insert');
246
+ if (op === 'delete')
247
+ return apiRowWrite(req, res, ctx, 'delete');
248
+ }
203
249
  sendJson(res, 404, { error: 'not found' });
204
250
  }
205
251
  // ---------------------------------------------------------------------------
@@ -269,6 +315,7 @@ async function apiSchema(res, ctx) {
269
315
  nullable: col.nullable,
270
316
  hasDefault: col.hasDefault,
271
317
  isPrimaryKey: tbl.primaryKey.includes(col.name),
318
+ pii: col.pii === true,
272
319
  })),
273
320
  relations: Object.entries(tbl.relations).map(([name, rel]) => ({
274
321
  name,
@@ -294,6 +341,10 @@ async function apiSchema(res, ctx) {
294
341
  schema: ctx.options.schema,
295
342
  tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
296
343
  enums: ctx.metadata.enums,
344
+ // Client-config flags the UI reads to gate write affordances / PII masking.
345
+ // Read-only Studio reports `writable: false` so the UI renders no write UI.
346
+ writable: ctx.writable === true,
347
+ showPii: ctx.showPii === true,
297
348
  });
298
349
  }
299
350
  // ---------------------------------------------------------------------------
@@ -311,10 +362,14 @@ async function apiTableRows(res, ctx, rawTableName, params) {
311
362
  const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
312
363
  // orderBy — accept either the Postgres column name (snake) or the TS field
313
364
  // name (camel). Always emit the Postgres column in the SQL.
365
+ // When redaction is on, PII columns are excluded from orderBy (and from the
366
+ // search OR-set below): a redacted value must not be inferable through sort
367
+ // position or substring probing.
368
+ const redactedPii = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
314
369
  let orderByClause = '';
315
370
  if (orderByRaw) {
316
371
  const col = resolveColumnName(table, orderByRaw);
317
- if (col)
372
+ if (col && !redactedPii.has(col))
318
373
  orderByClause = `ORDER BY ${(0, index_js_1.quoteIdent)(col)} ${dir}`;
319
374
  }
320
375
  if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
@@ -324,7 +379,9 @@ async function apiTableRows(res, ctx, rawTableName, params) {
324
379
  // parameterized so injection is impossible. Each query gets its own
325
380
  // WHERE clause with parameter indices matching that query's param array.
326
381
  const search = params.get('search')?.trim() ?? '';
327
- const textColumns = table.columns.filter((c) => isTextishType(c.pgType)).map((c) => c.name);
382
+ const textColumns = table.columns
383
+ .filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
384
+ .map((c) => c.name);
328
385
  const hasSearch = search.length > 0 && textColumns.length > 0;
329
386
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
330
387
  // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
@@ -356,7 +413,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
356
413
  sendJson(res, 200, {
357
414
  table: table.name,
358
415
  columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
359
- rows: result.rows.map((r) => serializeRow(r)),
416
+ rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
360
417
  total: Number(countResult.rows[0]?.count ?? 0),
361
418
  limit,
362
419
  offset,
@@ -433,10 +490,12 @@ async function apiBuilder(req, res, ctx) {
433
490
  const result = await client.query(deferred.sql, deferred.params);
434
491
  const elapsedMs = Date.now() - started;
435
492
  await client.query('COMMIT');
493
+ const rawRows = result.rows;
494
+ const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
436
495
  sendJson(res, 200, {
437
496
  sql: deferred.sql,
438
497
  columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
439
- rows: result.rows.map((r) => serializeRow(r)),
498
+ rows: redactedRows.map((r) => serializeRow(r)),
440
499
  rowCount: result.rowCount ?? result.rows.length,
441
500
  elapsedMs,
442
501
  });
@@ -454,6 +513,163 @@ async function apiBuilder(req, res, ctx) {
454
513
  client.release();
455
514
  }
456
515
  }
516
+ // ---------------------------------------------------------------------------
517
+ // API: /api/row/update | /api/row/insert | /api/row/delete (single-row writes)
518
+ //
519
+ // Write mode only (the routes do not exist otherwise). Every column identifier
520
+ // is validated against the introspected metadata and the statement is compiled
521
+ // through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
522
+ // values are $N params; there is no raw SQL. update/delete require the caller
523
+ // to supply the FULL primary key in `where`; the effective predicate is rebuilt
524
+ // from those PK values alone, so a write can only ever touch one row.
525
+ // ---------------------------------------------------------------------------
526
+ async function apiRowWrite(req, res, ctx, op) {
527
+ const body = await readJsonBody(req);
528
+ const tableName = typeof body?.table === 'string' ? body.table : '';
529
+ const table = ctx.metadata.tables[tableName];
530
+ if (!table) {
531
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
532
+ return;
533
+ }
534
+ if (table.isView) {
535
+ sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
536
+ return;
537
+ }
538
+ const data = (body?.data && typeof body.data === 'object' ? body.data : {});
539
+ const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
540
+ // Validate every column name up front for a clean typed 400 (the builders
541
+ // would also reject unknowns, but an explicit check keeps the message clear).
542
+ if (op === 'insert' || op === 'update') {
543
+ const badKey = firstUnknownColumn(table, data);
544
+ if (badKey) {
545
+ sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
546
+ return;
547
+ }
548
+ if (!Object.keys(data).some((k) => data[k] !== undefined)) {
549
+ sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
550
+ return;
551
+ }
552
+ }
553
+ // update/delete: require the table to have a PK and the caller to cover it.
554
+ let effectiveWhere = rawWhere;
555
+ if (op === 'update' || op === 'delete') {
556
+ if (table.primaryKey.length === 0) {
557
+ sendJson(res, 400, {
558
+ error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
559
+ });
560
+ return;
561
+ }
562
+ const pk = extractPkWhere(table, rawWhere);
563
+ if ('error' in pk) {
564
+ sendJson(res, 400, { error: `[turbine] ${pk.error}` });
565
+ return;
566
+ }
567
+ // Empty-where can never happen by construction (PK covered above); assert.
568
+ if (Object.keys(pk.where).length === 0) {
569
+ sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
570
+ return;
571
+ }
572
+ effectiveWhere = pk.where;
573
+ }
574
+ let deferred;
575
+ try {
576
+ const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
577
+ warnOnUnlimited: false,
578
+ sqlCache: false,
579
+ preparedStatements: false,
580
+ });
581
+ if (op === 'insert') {
582
+ deferred = qi.buildCreate({ data });
583
+ }
584
+ else if (op === 'update') {
585
+ deferred = qi.buildUpdate({ where: effectiveWhere, data });
586
+ }
587
+ else {
588
+ deferred = qi.buildDelete({ where: effectiveWhere });
589
+ }
590
+ }
591
+ catch (err) {
592
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
593
+ return;
594
+ }
595
+ const client = await ctx.pool.connect();
596
+ try {
597
+ // A real write transaction, NOT `READ ONLY`. Same parameterized
598
+ // statement-timeout + search_path pin as the read paths.
599
+ await client.query('BEGIN');
600
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
601
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
602
+ const result = await client.query(deferred.sql, deferred.params);
603
+ await client.query('COMMIT');
604
+ const row = result.rows[0];
605
+ if (!row) {
606
+ const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
607
+ sendJson(res, 404, { error: `[turbine] ${msg}` });
608
+ return;
609
+ }
610
+ // The echoed row is redacted the same way as any read (unless --show-pii),
611
+ // even though a write to a pii column is allowed.
612
+ const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
613
+ sendJson(res, 200, {
614
+ operation: op,
615
+ row: serializeRow(redactFlatRow(row, piiKeys)),
616
+ rowCount: result.rowCount ?? 1,
617
+ });
618
+ }
619
+ catch (err) {
620
+ try {
621
+ await client.query('ROLLBACK');
622
+ }
623
+ catch {
624
+ /* ignore */
625
+ }
626
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
627
+ }
628
+ finally {
629
+ client.release();
630
+ }
631
+ }
632
+ /**
633
+ * Return the first key in `obj` that does not resolve to a real column on
634
+ * `table` (accepting either the camelCase field or snake_case column name), or
635
+ * `null` when every key is valid. Skips `undefined` values.
636
+ */
637
+ function firstUnknownColumn(table, obj) {
638
+ for (const k of Object.keys(obj)) {
639
+ if (obj[k] === undefined)
640
+ continue;
641
+ if (!resolveColumnName(table, k))
642
+ return k;
643
+ }
644
+ return null;
645
+ }
646
+ /**
647
+ * Build a primary-key-only `where` from the caller's `where`. Every PK column
648
+ * must be present (as its field or column name) with a scalar value; anything
649
+ * else is rejected so a write can only ever target one row. Keys are emitted as
650
+ * the camelCase field name (the query builder accepts field or column names).
651
+ */
652
+ function extractPkWhere(table, where) {
653
+ const resolved = new Map();
654
+ for (const [k, v] of Object.entries(where)) {
655
+ const col = resolveColumnName(table, k);
656
+ if (col)
657
+ resolved.set(col, v);
658
+ }
659
+ const pkWhere = {};
660
+ for (const pkCol of table.primaryKey) {
661
+ if (!resolved.has(pkCol)) {
662
+ return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
663
+ }
664
+ const v = resolved.get(pkCol);
665
+ if (v === undefined || v === null || typeof v === 'object') {
666
+ return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
667
+ }
668
+ const field = table.reverseColumnMap[pkCol] ?? pkCol;
669
+ pkWhere[field] = v;
670
+ }
671
+ return { where: pkWhere };
672
+ }
457
673
  function savedQueriesPath(ctx) {
458
674
  return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
459
675
  }
@@ -541,6 +757,94 @@ function apiDeleteSavedQuery(res, ctx, id) {
541
757
  // ---------------------------------------------------------------------------
542
758
  // Helpers
543
759
  // ---------------------------------------------------------------------------
760
+ // ---------------------------------------------------------------------------
761
+ // PII redaction
762
+ // ---------------------------------------------------------------------------
763
+ /** The literal replacement value for a redacted PII cell. */
764
+ exports.PII_REDACTED = '•• redacted ••';
765
+ /** Shared empty key set for the `--show-pii` fast path (no redaction). */
766
+ const NO_PII_KEYS = new Set();
767
+ /**
768
+ * The set of keys (both snake_case column and camelCase field names) for the
769
+ * table's PII-tagged columns. Covering both spellings means the same set works
770
+ * for `SELECT *` rows (snake keys) and for json_build_object relation rows
771
+ * (camel keys).
772
+ */
773
+ function piiKeysForTable(table) {
774
+ const keys = new Set();
775
+ for (const col of table.columns) {
776
+ if (col.pii === true) {
777
+ keys.add(col.name);
778
+ keys.add(col.field);
779
+ }
780
+ }
781
+ return keys;
782
+ }
783
+ /**
784
+ * Redact PII keys in a single flat row. Returns the row unchanged when there is
785
+ * nothing to redact (no allocation); otherwise a shallow copy with each present,
786
+ * non-null PII value replaced by {@link PII_REDACTED}. A null/undefined value
787
+ * carries no PII, so it is left as-is.
788
+ */
789
+ function redactFlatRow(row, piiKeys) {
790
+ if (piiKeys.size === 0)
791
+ return row;
792
+ let out = null;
793
+ for (const k of Object.keys(row)) {
794
+ if (piiKeys.has(k) && row[k] !== null && row[k] !== undefined) {
795
+ if (!out)
796
+ out = { ...row };
797
+ out[k] = exports.PII_REDACTED;
798
+ }
799
+ }
800
+ return out ?? row;
801
+ }
802
+ /**
803
+ * Redact PII in builder result rows, walking the `with` tree so nested relation
804
+ * rows are redacted against THEIR target table's PII columns (relation rows
805
+ * arrive as parsed json objects keyed by camelCase field names).
806
+ */
807
+ function redactBuilderRows(rows, tableName, withClause, metadata) {
808
+ const table = metadata.tables[tableName];
809
+ if (!table)
810
+ return rows;
811
+ const piiKeys = piiKeysForTable(table);
812
+ const relEntries = withClause && typeof withClause === 'object'
813
+ ? Object.entries(withClause).filter(([, v]) => v)
814
+ : [];
815
+ // Nothing to do at this level or below → return as-is.
816
+ if (piiKeys.size === 0 && relEntries.length === 0)
817
+ return rows;
818
+ return rows.map((row) => {
819
+ const out = { ...row };
820
+ for (const k of piiKeys) {
821
+ if (k in out && out[k] !== null && out[k] !== undefined)
822
+ out[k] = exports.PII_REDACTED;
823
+ }
824
+ for (const [relName, relVal] of relEntries) {
825
+ const rel = table.relations[relName];
826
+ if (!rel)
827
+ continue;
828
+ const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
829
+ const child = out[relName];
830
+ if (Array.isArray(child)) {
831
+ out[relName] = redactBuilderRows(child, rel.to, nestedWith, metadata);
832
+ }
833
+ else if (child && typeof child === 'object') {
834
+ out[relName] = redactBuilderRows([child], rel.to, nestedWith, metadata)[0];
835
+ }
836
+ }
837
+ return out;
838
+ });
839
+ }
840
+ /**
841
+ * A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
842
+ * is stamped into both the `Content-Security-Policy` header and the inline
843
+ * `<script nonce="...">` tag(s) so `unsafe-inline` can be dropped from script-src.
844
+ */
845
+ function cspNonce() {
846
+ return (0, node_crypto_1.randomBytes)(16).toString('base64');
847
+ }
544
848
  function clampInt(value, fallback, min, max) {
545
849
  if (value == null)
546
850
  return fallback;
@@ -596,7 +900,9 @@ function sendJson(res, status, body) {
596
900
  'Cache-Control': 'no-store',
597
901
  'X-Content-Type-Options': 'nosniff',
598
902
  'Referrer-Policy': 'no-referrer',
599
- 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
903
+ // JSON responses render no document; no inline script is needed, so keep
904
+ // script-src to 'self' with no 'unsafe-inline'.
905
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
600
906
  });
601
907
  res.end(payload);
602
908
  }
@@ -607,7 +913,10 @@ function sendText(res, status, body) {
607
913
  });
608
914
  res.end(body);
609
915
  }
610
- function sendHtml(res, status, body) {
916
+ function sendHtml(res, status, template, nonce) {
917
+ // Stamp the per-request nonce into the inline <script nonce="__CSP_NONCE__">
918
+ // tag(s) so the CSP can use a nonce instead of 'unsafe-inline'.
919
+ const body = template.replaceAll('__CSP_NONCE__', nonce);
611
920
  res.writeHead(status, {
612
921
  'Content-Type': 'text/html; charset=utf-8',
613
922
  'Content-Length': Buffer.byteLength(body),
@@ -615,7 +924,9 @@ function sendHtml(res, status, body) {
615
924
  'X-Content-Type-Options': 'nosniff',
616
925
  'X-Frame-Options': 'DENY',
617
926
  'Referrer-Policy': 'no-referrer',
618
- 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
927
+ // style-src keeps 'unsafe-inline' (nonces don't cover style="" attributes,
928
+ // which the UI relies on); script-src moves to the per-request nonce.
929
+ 'Content-Security-Policy': `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'`,
619
930
  });
620
931
  res.end(body);
621
932
  }
@@ -229,5 +229,11 @@ function stripAnsi(s) {
229
229
  // Redact password from connection URL
230
230
  // ---------------------------------------------------------------------------
231
231
  function redactUrl(url) {
232
- return url.replace(/:([^@/:]+)@/, ':***@');
232
+ return (url
233
+ // Userinfo credentials: `:secret@` in any authority (global: a string may
234
+ // carry more than one URL, e.g. a primary + replica connection pair).
235
+ .replace(/:([^@/:]+)@/g, ':***@')
236
+ // Query-string password params: `password=`, `sslpassword=`, and similar,
237
+ // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
238
+ .replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
233
239
  }
@@ -105,7 +105,7 @@ exports.postgresDialect = {
105
105
  return `COALESCE((${subquery}), ${fallback})`;
106
106
  },
107
107
  buildReturningClause(selection = '*') {
108
- return ` RETURNING ${selection}`;
108
+ return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
109
109
  },
110
110
  buildInsertStatement(input) {
111
111
  return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;
@@ -168,8 +168,13 @@ function generateTypes(schema, options) {
168
168
  for (const col of table.columns) {
169
169
  const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
170
170
  const nullNote = col.nullable ? ' (nullable)' : '';
171
- lines.push(` /** Column: ${col.name} ${col.pgType}${pkNote}${nullNote} */`);
172
- lines.push(` ${col.field}: ${columnTsType(col, schema.enums)};`);
171
+ // PII columns are excluded from default projections, so the field is
172
+ // absent unless the query names it in `select` or passes `includePii`.
173
+ // The emitted type marks it optional so it tells the truth about absence.
174
+ const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
175
+ const optional = col.pii ? '?' : '';
176
+ lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
177
+ lines.push(` ${col.field}${optional}: ${columnTsType(col, schema.enums)};`);
173
178
  }
174
179
  lines.push('}');
175
180
  lines.push('');
@@ -531,6 +536,17 @@ function generateMetadata(schema, options) {
531
536
  lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}, definition: ${JSON.stringify(idx.definition)} },`);
532
537
  }
533
538
  lines.push(' ],');
539
+ // checks: introspected named CHECK constraints. Emitted only when present
540
+ // (byte-stable for check-less tables) and sorted by name so `--no-timestamp`
541
+ // output is deterministic regardless of catalog row order.
542
+ if (table.checks && table.checks.length > 0) {
543
+ const sortedChecks = [...table.checks].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
544
+ lines.push(' checks: [');
545
+ for (const chk of sortedChecks) {
546
+ lines.push(` { name: '${escSQ(chk.name)}', expression: ${JSON.stringify(chk.expression)} },`);
547
+ }
548
+ lines.push(' ],');
549
+ }
534
550
  // isView — read-only marker; the runtime write guard reads it.
535
551
  if (table.isView)
536
552
  lines.push(' isView: true,');
@@ -734,6 +750,11 @@ function serializeColumn(col) {
734
750
  if (col.generationExpression !== undefined) {
735
751
  parts.push(`generationExpression: '${escSQ(col.generationExpression)}'`);
736
752
  }
753
+ // PII marker: emitted only when set, so untagged schemas stay byte-identical.
754
+ // Introspection never sets this (code-first declaration), but a metadata
755
+ // object built from `defineSchema` (pii: true) carries it through codegen.
756
+ if (col.pii)
757
+ parts.push(`pii: true`);
737
758
  if (col.maxLength !== undefined)
738
759
  parts.push(`maxLength: ${col.maxLength}`);
739
760
  return `{ ${parts.join(', ')} }`;
package/dist/cjs/index.js CHANGED
@@ -35,7 +35,7 @@
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -119,6 +119,7 @@ var schema_metadata_js_1 = require("./schema-metadata.js");
119
119
  Object.defineProperty(exports, "schemaDefToMetadata", { enumerable: true, get: function () { return schema_metadata_js_1.schemaDefToMetadata; } });
120
120
  // Schema SQL — generate DDL, diff, and push
121
121
  var schema_sql_js_1 = require("./schema-sql.js");
122
+ Object.defineProperty(exports, "DestructivePushRefusal", { enumerable: true, get: function () { return schema_sql_js_1.DestructivePushRefusal; } });
122
123
  Object.defineProperty(exports, "schemaDiff", { enumerable: true, get: function () { return schema_sql_js_1.schemaDiff; } });
123
124
  Object.defineProperty(exports, "schemaPush", { enumerable: true, get: function () { return schema_sql_js_1.schemaPush; } });
124
125
  Object.defineProperty(exports, "schemaToSQL", { enumerable: true, get: function () { return schema_sql_js_1.schemaToSQL; } });
package/dist/cjs/mssql.js CHANGED
@@ -459,6 +459,23 @@ function mssqlColumnType(type, maxLength) {
459
459
  // ---------------------------------------------------------------------------
460
460
  // mssqlDialect — the full Dialect contract for SQL Server 2016+
461
461
  // ---------------------------------------------------------------------------
462
+ /**
463
+ * Render the SQL Server `OUTPUT` clause for a write's returning selection.
464
+ * `'*'` → ` OUTPUT INSERTED.*` (byte-identical to the historical default); a
465
+ * quoted column list → ` OUTPUT INSERTED.[c1], INSERTED.[c2]`; each column
466
+ * carries its own `INSERTED.`/`DELETED.` prefix (a bare comma list is invalid
467
+ * T-SQL). Used to exclude PII columns from a write's returned row. Empty
468
+ * selection → no clause.
469
+ */
470
+ function mssqlOutput(returning, alias) {
471
+ if (!returning)
472
+ return '';
473
+ if (returning === '*')
474
+ return ` OUTPUT ${alias}.*`;
475
+ if (returning.length === 0)
476
+ return '';
477
+ return ` OUTPUT ${returning.map((col) => `${alias}.${col}`).join(', ')}`;
478
+ }
462
479
  /**
463
480
  * SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
464
481
  * identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
@@ -522,7 +539,7 @@ exports.mssqlDialect = {
522
539
  return '';
523
540
  },
524
541
  buildInsertStatement(input) {
525
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
542
+ const out = mssqlOutput(input.returning, 'INSERTED');
526
543
  return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
527
544
  },
528
545
  buildBulkInsertStatement(input) {
@@ -542,7 +559,7 @@ exports.mssqlDialect = {
542
559
  const placeholders = input.rowValues
543
560
  .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
544
561
  .join(', ');
545
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
562
+ const out = mssqlOutput(input.returning, 'INSERTED');
546
563
  // skipDuplicates has no single-statement equivalent here; ignored (documented).
547
564
  return {
548
565
  sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
@@ -557,7 +574,7 @@ exports.mssqlDialect = {
557
574
  const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
558
575
  const insertCols = input.insertColumns.join(', ');
559
576
  const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
560
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
577
+ const out = mssqlOutput(input.returning, 'INSERTED');
561
578
  return (`MERGE INTO ${input.table} AS T ` +
562
579
  `USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
563
580
  `ON (${on}) ` +
@@ -568,11 +585,11 @@ exports.mssqlDialect = {
568
585
  // UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
569
586
  // WHERE) — a trailing clause would be invalid T-SQL.
570
587
  buildUpdateStatement(input) {
571
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
588
+ const out = mssqlOutput(input.returning, 'INSERTED');
572
589
  return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
573
590
  },
574
591
  buildDeleteStatement(input) {
575
- const out = input.returning ? ` OUTPUT DELETED.${input.returning}` : '';
592
+ const out = mssqlOutput(input.returning, 'DELETED');
576
593
  return `DELETE FROM ${input.table}${out}${input.whereSql}`;
577
594
  },
578
595
  // SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when