turbine-orm 0.34.0 → 0.36.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 (76) 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/client.js +26 -4
  8. package/dist/cjs/dialect.js +2 -1
  9. package/dist/cjs/errors.js +41 -1
  10. package/dist/cjs/generate.js +23 -2
  11. package/dist/cjs/index.js +4 -2
  12. package/dist/cjs/mssql.js +27 -5
  13. package/dist/cjs/mysql.js +4 -0
  14. package/dist/cjs/powdb.js +197 -25
  15. package/dist/cjs/powql.js +515 -51
  16. package/dist/cjs/query/aggregates.js +683 -0
  17. package/dist/cjs/query/batched-loader.js +2 -0
  18. package/dist/cjs/query/builder.js +361 -4508
  19. package/dist/cjs/query/filters.js +12 -0
  20. package/dist/cjs/query/relations.js +1698 -0
  21. package/dist/cjs/query/where-compile.js +180 -0
  22. package/dist/cjs/query/where.js +1491 -0
  23. package/dist/cjs/query/writes.js +680 -0
  24. package/dist/cjs/schema-builder.js +6 -0
  25. package/dist/cjs/schema-metadata.js +4 -0
  26. package/dist/cjs/schema-sql.js +265 -3
  27. package/dist/cjs/sqlite.js +4 -1
  28. package/dist/cli/index.d.ts +8 -2
  29. package/dist/cli/index.js +111 -18
  30. package/dist/cli/migrate.d.ts +24 -1
  31. package/dist/cli/migrate.js +77 -3
  32. package/dist/cli/studio-ui.generated.js +1 -1
  33. package/dist/cli/studio.d.ts +46 -13
  34. package/dist/cli/studio.js +331 -23
  35. package/dist/cli/ui.js +7 -1
  36. package/dist/client.d.ts +32 -5
  37. package/dist/client.js +26 -4
  38. package/dist/dialect.d.ts +28 -6
  39. package/dist/dialect.js +2 -1
  40. package/dist/errors.d.ts +36 -0
  41. package/dist/errors.js +39 -0
  42. package/dist/generate.js +23 -2
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.js +2 -2
  45. package/dist/mssql.js +27 -5
  46. package/dist/mysql.js +4 -0
  47. package/dist/powdb.d.ts +135 -9
  48. package/dist/powdb.js +197 -25
  49. package/dist/powql.d.ts +166 -4
  50. package/dist/powql.js +516 -52
  51. package/dist/query/aggregates.d.ts +74 -0
  52. package/dist/query/aggregates.js +641 -0
  53. package/dist/query/batched-loader.d.ts +6 -0
  54. package/dist/query/batched-loader.js +2 -0
  55. package/dist/query/builder.d.ts +98 -830
  56. package/dist/query/builder.js +366 -4513
  57. package/dist/query/deferred.d.ts +13 -2
  58. package/dist/query/filters.d.ts +7 -0
  59. package/dist/query/filters.js +11 -0
  60. package/dist/query/relations.d.ts +441 -0
  61. package/dist/query/relations.js +1627 -0
  62. package/dist/query/types.d.ts +25 -6
  63. package/dist/query/where-compile.d.ts +139 -0
  64. package/dist/query/where-compile.js +175 -0
  65. package/dist/query/where.d.ts +494 -0
  66. package/dist/query/where.js +1431 -0
  67. package/dist/query/writes.d.ts +131 -0
  68. package/dist/query/writes.js +626 -0
  69. package/dist/schema-builder.d.ts +18 -3
  70. package/dist/schema-builder.js +6 -0
  71. package/dist/schema-metadata.js +4 -0
  72. package/dist/schema-sql.d.ts +60 -3
  73. package/dist/schema-sql.js +261 -4
  74. package/dist/schema.d.ts +10 -0
  75. package/dist/sqlite.js +4 -1
  76. 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
  }
@@ -111,15 +111,27 @@ class TransactionClient {
111
111
  schema;
112
112
  middlewares;
113
113
  queryOptions;
114
+ sourcePool;
114
115
  tableCache = new Map();
115
116
  savepointCounter = 0;
116
117
  /** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
117
118
  dialect;
118
- constructor(client, schema, middlewares, queryOptions) {
119
+ constructor(client, schema, middlewares, queryOptions,
120
+ /**
121
+ * The parent pool this transaction runs on. Only its `readonly` and
122
+ * `capabilities` are read (both PowDB-only flags), so the transaction-scoped
123
+ * proxy pool built by {@link createTxPool} carries them through: without this
124
+ * a read-only client's `$transaction` writes bypass the E018 guard, and an
125
+ * older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
126
+ * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
127
+ * for a plain pg pool leave the proxy unchanged.
128
+ */
129
+ sourcePool) {
119
130
  this.client = client;
120
131
  this.schema = schema;
121
132
  this.middlewares = middlewares;
122
133
  this.queryOptions = queryOptions;
134
+ this.sourcePool = sourcePool;
123
135
  this.dialect = queryOptions?.dialect ?? dialect_js_1.postgresDialect;
124
136
  // Auto-create typed table accessors for all tables in the schema
125
137
  for (const tableName of Object.keys(schema.tables)) {
@@ -199,7 +211,7 @@ class TransactionClient {
199
211
  const client = this.client;
200
212
  // Return a minimal pool-compatible object that routes queries
201
213
  // through the transaction client
202
- return {
214
+ const txPool = {
203
215
  query: async (textOrConfig, values) => {
204
216
  try {
205
217
  if (typeof textOrConfig === 'string') {
@@ -216,6 +228,14 @@ class TransactionClient {
216
228
  },
217
229
  connect: () => Promise.resolve(client),
218
230
  };
231
+ // Carry the parent pool's PowDB-only flags through so a transaction-scoped
232
+ // PowqlInterface reads the same read-only guard and capabilities it would
233
+ // outside the transaction (a plain pg pool has neither, so nothing changes).
234
+ if (this.sourcePool?.readonly !== undefined)
235
+ txPool.readonly = this.sourcePool.readonly;
236
+ if (this.sourcePool?.capabilities !== undefined)
237
+ txPool.capabilities = this.sourcePool.capabilities;
238
+ return txPool;
219
239
  }
220
240
  }
221
241
  exports.TransactionClient = TransactionClient;
@@ -884,8 +904,10 @@ class TurbineClient {
884
904
  await client.query(cfg.sql, cfg.params);
885
905
  }
886
906
  }
887
- // Create the transaction client with typed table accessors
888
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
907
+ // Create the transaction client with typed table accessors. Pass the
908
+ // parent pool so its read-only guard + PowDB capabilities flow into the
909
+ // transaction-scoped proxy pool (see TransactionClient.createTxPool).
910
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
889
911
  // Dynamically attach table accessors to tx
890
912
  for (const tableName of Object.keys(this.schema.tables)) {
891
913
  const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
@@ -59,6 +59,7 @@ exports.postgresDialect = {
59
59
  supportsRLS: true,
60
60
  supportsAdvisoryLock: true,
61
61
  supportsLateralJoin: true,
62
+ explainQuery: { prefix: 'EXPLAIN' },
62
63
  paramPlaceholder(index) {
63
64
  return `$${index}`;
64
65
  },
@@ -104,7 +105,7 @@ exports.postgresDialect = {
104
105
  return `COALESCE((${subquery}), ${fallback})`;
105
106
  },
106
107
  buildReturningClause(selection = '*') {
107
- return ` RETURNING ${selection}`;
108
+ return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
108
109
  },
109
110
  buildInsertStatement(input) {
110
111
  return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;
@@ -6,7 +6,7 @@
6
6
  * All Turbine errors extend TurbineError which includes a `code` property.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
9
+ exports.ReadOnlyError = exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
10
10
  exports.setErrorMessageMode = setErrorMessageMode;
11
11
  exports.getErrorMessageMode = getErrorMessageMode;
12
12
  exports.wrapPgError = wrapPgError;
@@ -29,6 +29,7 @@ exports.TurbineErrorCode = {
29
29
  OPTIMISTIC_LOCK: 'TURBINE_E015',
30
30
  EXCLUSION_VIOLATION: 'TURBINE_E016',
31
31
  UNSUPPORTED_FEATURE: 'TURBINE_E017',
32
+ READ_ONLY: 'TURBINE_E018',
32
33
  };
33
34
  /**
34
35
  * Prefix a human message with its stable error code so logs are greppable
@@ -503,6 +504,45 @@ class UnsupportedFeatureError extends TurbineError {
503
504
  }
504
505
  }
505
506
  exports.UnsupportedFeatureError = UnsupportedFeatureError;
507
+ /**
508
+ * Thrown when a write or DDL statement is refused because the target is
509
+ * read-only. Two shapes reach here, both on PowDB:
510
+ * - an embedded database opened read-only for snapshot serving refuses a write
511
+ * with `readonly mode: statement requires a writer …`;
512
+ * - a networked read-only role refuses a write with `permission denied: role
513
+ * '<role>' cannot execute write statements` (translated by `wrapPowdbError`).
514
+ * It is also raised locally, before the wire, when a write is issued on a pool
515
+ * the caller marked read-only (fail-fast). The message carries the engine text
516
+ * plus a hint to route writes to a writable primary.
517
+ *
518
+ * NOT retryable: the same write against the same read-only target fails
519
+ * identically; route it to a writable primary instead.
520
+ */
521
+ class ReadOnlyError extends TurbineError {
522
+ /**
523
+ * Why the write was refused. `'snapshot'`: the database itself is read-only
524
+ * (snapshot serving, an embedded `readonly: true` open, or the client-level
525
+ * fail-fast flag), so NOTHING can write here and writes must route to the
526
+ * primary. `'rbac'`: the database is writable but THIS connection's role may
527
+ * not write (per-connection permission), so re-authenticating may suffice.
528
+ */
529
+ reason;
530
+ /**
531
+ * @param detail human-readable description of the refused write (the engine
532
+ * message, or a local fail-fast description). A "route writes to a writable
533
+ * primary" hint is always appended.
534
+ * @param options optional driver `cause` to preserve when wrapping a refusal,
535
+ * and the refusal `reason` (default `'snapshot'`).
536
+ */
537
+ constructor(detail, options) {
538
+ super(exports.TurbineErrorCode.READ_ONLY, `[turbine] ${detail} Route writes to a writable primary.`, {
539
+ cause: options?.cause,
540
+ });
541
+ this.name = 'ReadOnlyError';
542
+ this.reason = options?.reason ?? 'snapshot';
543
+ }
544
+ }
545
+ exports.ReadOnlyError = ReadOnlyError;
506
546
  /**
507
547
  * Parse column names out of a pg `detail` string like:
508
548
  * "Key (email)=(foo@bar) already exists."