turbine-orm 0.35.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 (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 +2 -2
@@ -1,25 +1,38 @@
1
1
  /**
2
2
  * turbine-orm CLI — Studio
3
3
  *
4
- * A local, read-only web UI for browsing databases, exploring relations,
5
- * and composing queries visually. ORM-native since v0.19: there is no
6
- * raw-SQL input surface — the Query tab builds `findMany` args that are
7
- * validated against introspected metadata and compiled by QueryInterface
8
- * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
- * dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
10
- * hosts unless `npx turbine studio --allow-remote` is set.
4
+ * A local web UI for browsing databases, exploring relations, and composing
5
+ * queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
6
+ * The Query tab builds `findMany` args that are validated against introspected
7
+ * metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
8
+ * `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
9
+ * and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
10
+ *
11
+ * Read-only by default. `turbine studio --write` opts in to single-row writes
12
+ * (see the write model below); without the flag the write API routes do not
13
+ * exist (they 404) and the UI renders no write affordances.
11
14
  *
12
15
  * Security model:
13
16
  * • Loopback by default; CLI refuses non-loopback without --allow-remote
14
17
  * • Random auth token generated per process, required in Cookie header
15
- * • No SQL input surface at all every identifier in a builder request is
16
- * validated against the introspected schema; all values are $N params
17
- * Every query runs in a READ ONLY transaction (belt-and-suspenders)
18
+ * • No SQL input surface at all: every identifier in a builder or write
19
+ * request is validated against the introspected schema; all values are
20
+ * $N params compiled through the query builders
21
+ * • Read routes run in a READ ONLY transaction (belt-and-suspenders)
22
+ * • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
23
+ * transaction, require a matching Origin header (CSRF), and target exactly
24
+ * one row by its full primary key
18
25
  * • 30s statement timeout via parameterized set_config()
19
- * • Per-session rate limiting, CSP + security headers, cross-origin refusal
26
+ * • Per-session rate limiting, cross-origin refusal, security headers, and a
27
+ * per-request CSP nonce for the inline script (no `unsafe-inline`)
28
+ *
29
+ * PII: columns tagged `pii` in code-first metadata are redacted server-side in
30
+ * every row-bearing response (the literal `•• redacted ••`) unless the server
31
+ * was started with `--show-pii`.
20
32
  *
21
- * Not implemented (deliberately): row editing, DDL, destructive operations.
22
- * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
33
+ * Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
34
+ * unconditional writes are deliberately unsupported. Use the CLI or migrate for
35
+ * schema changes and bulk operations.
23
36
  */
24
37
  import { type IncomingMessage, type ServerResponse } from 'node:http';
25
38
  import pg from 'pg';
@@ -36,6 +49,15 @@ export interface StudioOptions {
36
49
  stateDir?: string;
37
50
  /** Database adapter for dialect-specific behavior (e.g. statement timeout syntax). */
38
51
  adapter?: import('../adapters/index.js').DatabaseAdapter;
52
+ /**
53
+ * Opt in to single-row write routes (`/api/row/update|insert|delete`) and the
54
+ * write UI. Default `false`: read-only, with the write routes absent (404).
55
+ */
56
+ write?: boolean;
57
+ /**
58
+ * Reveal PII-tagged column values instead of redacting them. Default `false`.
59
+ */
60
+ showPii?: boolean;
39
61
  }
40
62
  export interface StudioHandle {
41
63
  /** Shut down the server + pool cleanly. */
@@ -61,6 +83,13 @@ export interface StudioContext {
61
83
  count: number;
62
84
  resetAt: number;
63
85
  }>;
86
+ /**
87
+ * True when write mode is enabled (`--write`): the `/api/row/*` routes exist
88
+ * and the UI renders write affordances. Absent/false → read-only.
89
+ */
90
+ writable?: boolean;
91
+ /** True when PII redaction is disabled (`--show-pii`). Absent/false → redact. */
92
+ showPii?: boolean;
64
93
  }
65
94
  /**
66
95
  * Start the Studio server. Returns a handle with the session token, a pre-built
@@ -72,11 +101,15 @@ export interface StudioContext {
72
101
  * process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
73
102
  */
74
103
  export declare function startStudio(options: StudioOptions): Promise<StudioHandle>;
104
+ export declare function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
75
105
  export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
76
106
  export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
77
107
  export declare function isTextishType(pgType: string): boolean;
78
108
  export declare function escapeLikePattern(s: string): string;
79
109
  export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
110
+ export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
80
111
  export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
81
112
  export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
82
113
  export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
114
+ /** The literal replacement value for a redacted PII cell. */
115
+ export declare const PII_REDACTED = "\u2022\u2022 redacted \u2022\u2022";
@@ -1,25 +1,38 @@
1
1
  /**
2
2
  * turbine-orm CLI — Studio
3
3
  *
4
- * A local, read-only web UI for browsing databases, exploring relations,
5
- * and composing queries visually. ORM-native since v0.19: there is no
6
- * raw-SQL input surface — the Query tab builds `findMany` args that are
7
- * validated against introspected metadata and compiled by QueryInterface
8
- * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
- * dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
10
- * hosts unless `npx turbine studio --allow-remote` is set.
4
+ * A local web UI for browsing databases, exploring relations, and composing
5
+ * queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
6
+ * The Query tab builds `findMany` args that are validated against introspected
7
+ * metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
8
+ * `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
9
+ * and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
10
+ *
11
+ * Read-only by default. `turbine studio --write` opts in to single-row writes
12
+ * (see the write model below); without the flag the write API routes do not
13
+ * exist (they 404) and the UI renders no write affordances.
11
14
  *
12
15
  * Security model:
13
16
  * • Loopback by default; CLI refuses non-loopback without --allow-remote
14
17
  * • Random auth token generated per process, required in Cookie header
15
- * • No SQL input surface at all every identifier in a builder request is
16
- * validated against the introspected schema; all values are $N params
17
- * Every query runs in a READ ONLY transaction (belt-and-suspenders)
18
+ * • No SQL input surface at all: every identifier in a builder or write
19
+ * request is validated against the introspected schema; all values are
20
+ * $N params compiled through the query builders
21
+ * • Read routes run in a READ ONLY transaction (belt-and-suspenders)
22
+ * • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
23
+ * transaction, require a matching Origin header (CSRF), and target exactly
24
+ * one row by its full primary key
18
25
  * • 30s statement timeout via parameterized set_config()
19
- * • Per-session rate limiting, CSP + security headers, cross-origin refusal
26
+ * • Per-session rate limiting, cross-origin refusal, security headers, and a
27
+ * per-request CSP nonce for the inline script (no `unsafe-inline`)
28
+ *
29
+ * PII: columns tagged `pii` in code-first metadata are redacted server-side in
30
+ * every row-bearing response (the literal `•• redacted ••`) unless the server
31
+ * was started with `--show-pii`.
20
32
  *
21
- * Not implemented (deliberately): row editing, DDL, destructive operations.
22
- * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
33
+ * Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
34
+ * unconditional writes are deliberately unsupported. Use the CLI or migrate for
35
+ * schema changes and bulk operations.
23
36
  */
24
37
  import { spawn } from 'node:child_process';
25
38
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
@@ -74,7 +87,17 @@ export async function startStudio(options) {
74
87
  params: ['30s'],
75
88
  };
76
89
  const rateLimiter = new Map();
77
- const ctx = { pool, metadata, options, authToken, stateDir, statementTimeout, rateLimiter };
90
+ const ctx = {
91
+ pool,
92
+ metadata,
93
+ options,
94
+ authToken,
95
+ stateDir,
96
+ statementTimeout,
97
+ rateLimiter,
98
+ writable: options.write === true,
99
+ showPii: options.showPii === true,
100
+ };
78
101
  const server = createServer((req, res) => {
79
102
  handleRequest(req, res, ctx).catch((err) => {
80
103
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -115,7 +138,7 @@ function originFor(host, port) {
115
138
  // ---------------------------------------------------------------------------
116
139
  // Request routing
117
140
  // ---------------------------------------------------------------------------
118
- async function handleRequest(req, res, ctx) {
141
+ export async function handleRequest(req, res, ctx) {
119
142
  const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
120
143
  // CORS: not needed — same-origin only. Explicitly refuse cross-origin.
121
144
  const origin = req.headers.origin;
@@ -143,7 +166,7 @@ async function handleRequest(req, res, ctx) {
143
166
  res.end();
144
167
  return;
145
168
  }
146
- sendHtml(res, 200, STUDIO_HTML);
169
+ sendHtml(res, 200, STUDIO_HTML, cspNonce());
147
170
  return;
148
171
  }
149
172
  // Favicon — answered before the auth gate so the browser's automatic request
@@ -186,6 +209,26 @@ async function handleRequest(req, res, ctx) {
186
209
  const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
187
210
  return apiDeleteSavedQuery(res, ctx, id);
188
211
  }
212
+ // Write routes: ONLY exist in write mode. In read-only mode they fall through
213
+ // to the 404 below (deliberately not 403: a read-only Studio has no such API).
214
+ if (ctx.writable && pathname.startsWith('/api/row/') && req.method === 'POST') {
215
+ // CSRF: a state-changing request MUST carry a same-origin Origin header. The
216
+ // top-of-handler check already rejects a MISMATCHED origin (403); this also
217
+ // rejects an ABSENT one, which read (GET) routes tolerate for curl ergonomics
218
+ // but a browser always sends on a cross-scheme/site POST. `fetch` from the
219
+ // Studio page always sets it for same-origin, so the real UI is unaffected.
220
+ if (origin !== expectedOrigin) {
221
+ sendJson(res, 403, { error: 'a matching Origin header is required for write requests' });
222
+ return;
223
+ }
224
+ const op = pathname.slice('/api/row/'.length);
225
+ if (op === 'update')
226
+ return apiRowWrite(req, res, ctx, 'update');
227
+ if (op === 'insert')
228
+ return apiRowWrite(req, res, ctx, 'insert');
229
+ if (op === 'delete')
230
+ return apiRowWrite(req, res, ctx, 'delete');
231
+ }
189
232
  sendJson(res, 404, { error: 'not found' });
190
233
  }
191
234
  // ---------------------------------------------------------------------------
@@ -255,6 +298,7 @@ async function apiSchema(res, ctx) {
255
298
  nullable: col.nullable,
256
299
  hasDefault: col.hasDefault,
257
300
  isPrimaryKey: tbl.primaryKey.includes(col.name),
301
+ pii: col.pii === true,
258
302
  })),
259
303
  relations: Object.entries(tbl.relations).map(([name, rel]) => ({
260
304
  name,
@@ -280,6 +324,10 @@ async function apiSchema(res, ctx) {
280
324
  schema: ctx.options.schema,
281
325
  tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
282
326
  enums: ctx.metadata.enums,
327
+ // Client-config flags the UI reads to gate write affordances / PII masking.
328
+ // Read-only Studio reports `writable: false` so the UI renders no write UI.
329
+ writable: ctx.writable === true,
330
+ showPii: ctx.showPii === true,
283
331
  });
284
332
  }
285
333
  // ---------------------------------------------------------------------------
@@ -297,10 +345,14 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
297
345
  const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
298
346
  // orderBy — accept either the Postgres column name (snake) or the TS field
299
347
  // name (camel). Always emit the Postgres column in the SQL.
348
+ // When redaction is on, PII columns are excluded from orderBy (and from the
349
+ // search OR-set below): a redacted value must not be inferable through sort
350
+ // position or substring probing.
351
+ const redactedPii = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
300
352
  let orderByClause = '';
301
353
  if (orderByRaw) {
302
354
  const col = resolveColumnName(table, orderByRaw);
303
- if (col)
355
+ if (col && !redactedPii.has(col))
304
356
  orderByClause = `ORDER BY ${quoteIdent(col)} ${dir}`;
305
357
  }
306
358
  if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
@@ -310,7 +362,9 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
310
362
  // parameterized so injection is impossible. Each query gets its own
311
363
  // WHERE clause with parameter indices matching that query's param array.
312
364
  const search = params.get('search')?.trim() ?? '';
313
- const textColumns = table.columns.filter((c) => isTextishType(c.pgType)).map((c) => c.name);
365
+ const textColumns = table.columns
366
+ .filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
367
+ .map((c) => c.name);
314
368
  const hasSearch = search.length > 0 && textColumns.length > 0;
315
369
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
316
370
  // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
@@ -342,7 +396,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
342
396
  sendJson(res, 200, {
343
397
  table: table.name,
344
398
  columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
345
- rows: result.rows.map((r) => serializeRow(r)),
399
+ rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
346
400
  total: Number(countResult.rows[0]?.count ?? 0),
347
401
  limit,
348
402
  offset,
@@ -419,10 +473,12 @@ export async function apiBuilder(req, res, ctx) {
419
473
  const result = await client.query(deferred.sql, deferred.params);
420
474
  const elapsedMs = Date.now() - started;
421
475
  await client.query('COMMIT');
476
+ const rawRows = result.rows;
477
+ const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
422
478
  sendJson(res, 200, {
423
479
  sql: deferred.sql,
424
480
  columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
425
- rows: result.rows.map((r) => serializeRow(r)),
481
+ rows: redactedRows.map((r) => serializeRow(r)),
426
482
  rowCount: result.rowCount ?? result.rows.length,
427
483
  elapsedMs,
428
484
  });
@@ -440,6 +496,163 @@ export async function apiBuilder(req, res, ctx) {
440
496
  client.release();
441
497
  }
442
498
  }
499
+ // ---------------------------------------------------------------------------
500
+ // API: /api/row/update | /api/row/insert | /api/row/delete (single-row writes)
501
+ //
502
+ // Write mode only (the routes do not exist otherwise). Every column identifier
503
+ // is validated against the introspected metadata and the statement is compiled
504
+ // through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
505
+ // values are $N params; there is no raw SQL. update/delete require the caller
506
+ // to supply the FULL primary key in `where`; the effective predicate is rebuilt
507
+ // from those PK values alone, so a write can only ever touch one row.
508
+ // ---------------------------------------------------------------------------
509
+ export async function apiRowWrite(req, res, ctx, op) {
510
+ const body = await readJsonBody(req);
511
+ const tableName = typeof body?.table === 'string' ? body.table : '';
512
+ const table = ctx.metadata.tables[tableName];
513
+ if (!table) {
514
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
515
+ return;
516
+ }
517
+ if (table.isView) {
518
+ sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
519
+ return;
520
+ }
521
+ const data = (body?.data && typeof body.data === 'object' ? body.data : {});
522
+ const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
523
+ // Validate every column name up front for a clean typed 400 (the builders
524
+ // would also reject unknowns, but an explicit check keeps the message clear).
525
+ if (op === 'insert' || op === 'update') {
526
+ const badKey = firstUnknownColumn(table, data);
527
+ if (badKey) {
528
+ sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
529
+ return;
530
+ }
531
+ if (!Object.keys(data).some((k) => data[k] !== undefined)) {
532
+ sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
533
+ return;
534
+ }
535
+ }
536
+ // update/delete: require the table to have a PK and the caller to cover it.
537
+ let effectiveWhere = rawWhere;
538
+ if (op === 'update' || op === 'delete') {
539
+ if (table.primaryKey.length === 0) {
540
+ sendJson(res, 400, {
541
+ error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
542
+ });
543
+ return;
544
+ }
545
+ const pk = extractPkWhere(table, rawWhere);
546
+ if ('error' in pk) {
547
+ sendJson(res, 400, { error: `[turbine] ${pk.error}` });
548
+ return;
549
+ }
550
+ // Empty-where can never happen by construction (PK covered above); assert.
551
+ if (Object.keys(pk.where).length === 0) {
552
+ sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
553
+ return;
554
+ }
555
+ effectiveWhere = pk.where;
556
+ }
557
+ let deferred;
558
+ try {
559
+ const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
560
+ warnOnUnlimited: false,
561
+ sqlCache: false,
562
+ preparedStatements: false,
563
+ });
564
+ if (op === 'insert') {
565
+ deferred = qi.buildCreate({ data });
566
+ }
567
+ else if (op === 'update') {
568
+ deferred = qi.buildUpdate({ where: effectiveWhere, data });
569
+ }
570
+ else {
571
+ deferred = qi.buildDelete({ where: effectiveWhere });
572
+ }
573
+ }
574
+ catch (err) {
575
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
576
+ return;
577
+ }
578
+ const client = await ctx.pool.connect();
579
+ try {
580
+ // A real write transaction, NOT `READ ONLY`. Same parameterized
581
+ // statement-timeout + search_path pin as the read paths.
582
+ await client.query('BEGIN');
583
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
584
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
585
+ const result = await client.query(deferred.sql, deferred.params);
586
+ await client.query('COMMIT');
587
+ const row = result.rows[0];
588
+ if (!row) {
589
+ const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
590
+ sendJson(res, 404, { error: `[turbine] ${msg}` });
591
+ return;
592
+ }
593
+ // The echoed row is redacted the same way as any read (unless --show-pii),
594
+ // even though a write to a pii column is allowed.
595
+ const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
596
+ sendJson(res, 200, {
597
+ operation: op,
598
+ row: serializeRow(redactFlatRow(row, piiKeys)),
599
+ rowCount: result.rowCount ?? 1,
600
+ });
601
+ }
602
+ catch (err) {
603
+ try {
604
+ await client.query('ROLLBACK');
605
+ }
606
+ catch {
607
+ /* ignore */
608
+ }
609
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
610
+ }
611
+ finally {
612
+ client.release();
613
+ }
614
+ }
615
+ /**
616
+ * Return the first key in `obj` that does not resolve to a real column on
617
+ * `table` (accepting either the camelCase field or snake_case column name), or
618
+ * `null` when every key is valid. Skips `undefined` values.
619
+ */
620
+ function firstUnknownColumn(table, obj) {
621
+ for (const k of Object.keys(obj)) {
622
+ if (obj[k] === undefined)
623
+ continue;
624
+ if (!resolveColumnName(table, k))
625
+ return k;
626
+ }
627
+ return null;
628
+ }
629
+ /**
630
+ * Build a primary-key-only `where` from the caller's `where`. Every PK column
631
+ * must be present (as its field or column name) with a scalar value; anything
632
+ * else is rejected so a write can only ever target one row. Keys are emitted as
633
+ * the camelCase field name (the query builder accepts field or column names).
634
+ */
635
+ function extractPkWhere(table, where) {
636
+ const resolved = new Map();
637
+ for (const [k, v] of Object.entries(where)) {
638
+ const col = resolveColumnName(table, k);
639
+ if (col)
640
+ resolved.set(col, v);
641
+ }
642
+ const pkWhere = {};
643
+ for (const pkCol of table.primaryKey) {
644
+ if (!resolved.has(pkCol)) {
645
+ return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
646
+ }
647
+ const v = resolved.get(pkCol);
648
+ if (v === undefined || v === null || typeof v === 'object') {
649
+ return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
650
+ }
651
+ const field = table.reverseColumnMap[pkCol] ?? pkCol;
652
+ pkWhere[field] = v;
653
+ }
654
+ return { where: pkWhere };
655
+ }
443
656
  function savedQueriesPath(ctx) {
444
657
  return pathResolve(ctx.stateDir, 'studio-queries.json');
445
658
  }
@@ -527,6 +740,94 @@ export function apiDeleteSavedQuery(res, ctx, id) {
527
740
  // ---------------------------------------------------------------------------
528
741
  // Helpers
529
742
  // ---------------------------------------------------------------------------
743
+ // ---------------------------------------------------------------------------
744
+ // PII redaction
745
+ // ---------------------------------------------------------------------------
746
+ /** The literal replacement value for a redacted PII cell. */
747
+ export const PII_REDACTED = '•• redacted ••';
748
+ /** Shared empty key set for the `--show-pii` fast path (no redaction). */
749
+ const NO_PII_KEYS = new Set();
750
+ /**
751
+ * The set of keys (both snake_case column and camelCase field names) for the
752
+ * table's PII-tagged columns. Covering both spellings means the same set works
753
+ * for `SELECT *` rows (snake keys) and for json_build_object relation rows
754
+ * (camel keys).
755
+ */
756
+ function piiKeysForTable(table) {
757
+ const keys = new Set();
758
+ for (const col of table.columns) {
759
+ if (col.pii === true) {
760
+ keys.add(col.name);
761
+ keys.add(col.field);
762
+ }
763
+ }
764
+ return keys;
765
+ }
766
+ /**
767
+ * Redact PII keys in a single flat row. Returns the row unchanged when there is
768
+ * nothing to redact (no allocation); otherwise a shallow copy with each present,
769
+ * non-null PII value replaced by {@link PII_REDACTED}. A null/undefined value
770
+ * carries no PII, so it is left as-is.
771
+ */
772
+ function redactFlatRow(row, piiKeys) {
773
+ if (piiKeys.size === 0)
774
+ return row;
775
+ let out = null;
776
+ for (const k of Object.keys(row)) {
777
+ if (piiKeys.has(k) && row[k] !== null && row[k] !== undefined) {
778
+ if (!out)
779
+ out = { ...row };
780
+ out[k] = PII_REDACTED;
781
+ }
782
+ }
783
+ return out ?? row;
784
+ }
785
+ /**
786
+ * Redact PII in builder result rows, walking the `with` tree so nested relation
787
+ * rows are redacted against THEIR target table's PII columns (relation rows
788
+ * arrive as parsed json objects keyed by camelCase field names).
789
+ */
790
+ function redactBuilderRows(rows, tableName, withClause, metadata) {
791
+ const table = metadata.tables[tableName];
792
+ if (!table)
793
+ return rows;
794
+ const piiKeys = piiKeysForTable(table);
795
+ const relEntries = withClause && typeof withClause === 'object'
796
+ ? Object.entries(withClause).filter(([, v]) => v)
797
+ : [];
798
+ // Nothing to do at this level or below → return as-is.
799
+ if (piiKeys.size === 0 && relEntries.length === 0)
800
+ return rows;
801
+ return rows.map((row) => {
802
+ const out = { ...row };
803
+ for (const k of piiKeys) {
804
+ if (k in out && out[k] !== null && out[k] !== undefined)
805
+ out[k] = PII_REDACTED;
806
+ }
807
+ for (const [relName, relVal] of relEntries) {
808
+ const rel = table.relations[relName];
809
+ if (!rel)
810
+ continue;
811
+ const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
812
+ const child = out[relName];
813
+ if (Array.isArray(child)) {
814
+ out[relName] = redactBuilderRows(child, rel.to, nestedWith, metadata);
815
+ }
816
+ else if (child && typeof child === 'object') {
817
+ out[relName] = redactBuilderRows([child], rel.to, nestedWith, metadata)[0];
818
+ }
819
+ }
820
+ return out;
821
+ });
822
+ }
823
+ /**
824
+ * A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
825
+ * is stamped into both the `Content-Security-Policy` header and the inline
826
+ * `<script nonce="...">` tag(s) so `unsafe-inline` can be dropped from script-src.
827
+ */
828
+ function cspNonce() {
829
+ return randomBytes(16).toString('base64');
830
+ }
530
831
  function clampInt(value, fallback, min, max) {
531
832
  if (value == null)
532
833
  return fallback;
@@ -582,7 +883,9 @@ function sendJson(res, status, body) {
582
883
  'Cache-Control': 'no-store',
583
884
  'X-Content-Type-Options': 'nosniff',
584
885
  'Referrer-Policy': 'no-referrer',
585
- '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'",
886
+ // JSON responses render no document; no inline script is needed, so keep
887
+ // script-src to 'self' with no 'unsafe-inline'.
888
+ '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'",
586
889
  });
587
890
  res.end(payload);
588
891
  }
@@ -593,7 +896,10 @@ function sendText(res, status, body) {
593
896
  });
594
897
  res.end(body);
595
898
  }
596
- function sendHtml(res, status, body) {
899
+ function sendHtml(res, status, template, nonce) {
900
+ // Stamp the per-request nonce into the inline <script nonce="__CSP_NONCE__">
901
+ // tag(s) so the CSP can use a nonce instead of 'unsafe-inline'.
902
+ const body = template.replaceAll('__CSP_NONCE__', nonce);
597
903
  res.writeHead(status, {
598
904
  'Content-Type': 'text/html; charset=utf-8',
599
905
  'Content-Length': Buffer.byteLength(body),
@@ -601,7 +907,9 @@ function sendHtml(res, status, body) {
601
907
  'X-Content-Type-Options': 'nosniff',
602
908
  'X-Frame-Options': 'DENY',
603
909
  'Referrer-Policy': 'no-referrer',
604
- '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'",
910
+ // style-src keeps 'unsafe-inline' (nonces don't cover style="" attributes,
911
+ // which the UI relies on); script-src moves to the per-request nonce.
912
+ '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'`,
605
913
  });
606
914
  res.end(body);
607
915
  }
package/dist/cli/ui.js CHANGED
@@ -211,5 +211,11 @@ export function stripAnsi(s) {
211
211
  // Redact password from connection URL
212
212
  // ---------------------------------------------------------------------------
213
213
  export function redactUrl(url) {
214
- return url.replace(/:([^@/:]+)@/, ':***@');
214
+ return (url
215
+ // Userinfo credentials: `:secret@` in any authority (global: a string may
216
+ // carry more than one URL, e.g. a primary + replica connection pair).
217
+ .replace(/:([^@/:]+)@/g, ':***@')
218
+ // Query-string password params: `password=`, `sslpassword=`, and similar,
219
+ // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
220
+ .replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
215
221
  }
package/dist/dialect.d.ts CHANGED
@@ -8,6 +8,15 @@
8
8
  import type { WithOptions } from './query/types.js';
9
9
  import { type RelationDef, type SchemaMetadata, type TableMetadata } from './schema.js';
10
10
  export type DialectName = 'postgresql' | 'mysql' | 'sqlite' | (string & {});
11
+ /**
12
+ * A write statement's returning/output selection. `'*'` returns every column
13
+ * (the historical default, byte-identical SQL). A `string[]` is an explicit,
14
+ * SQL-ready quoted column list used to exclude PII-tagged columns from a
15
+ * write's returned row at the SQL level, so PII never crosses the wire
16
+ * unrequested. Each dialect renders it into its own returning surface
17
+ * (`RETURNING …`, `OUTPUT INSERTED.…`).
18
+ */
19
+ export type ReturningSelection = '*' | readonly string[];
11
20
  export interface InsertStatementInput {
12
21
  /** SQL-ready quoted table name. */
13
22
  table: string;
@@ -16,7 +25,7 @@ export interface InsertStatementInput {
16
25
  /** SQL-ready parameter placeholders/expressions for VALUES. */
17
26
  valuePlaceholders: string[];
18
27
  /** Optional SQL-ready RETURNING selection. */
19
- returning?: string;
28
+ returning?: ReturningSelection;
20
29
  }
21
30
  export interface BulkInsertStatementInput {
22
31
  /** SQL-ready quoted table name. */
@@ -30,7 +39,7 @@ export interface BulkInsertStatementInput {
30
39
  /** Skip duplicate rows when supported by the dialect. */
31
40
  skipDuplicates?: boolean;
32
41
  /** Optional SQL-ready RETURNING selection. */
33
- returning?: string;
42
+ returning?: ReturningSelection;
34
43
  }
35
44
  export interface BuiltStatement {
36
45
  sql: string;
@@ -56,7 +65,7 @@ export interface UpsertStatementInput {
56
65
  */
57
66
  updateWhere?: string;
58
67
  /** Optional SQL-ready RETURNING selection. */
59
- returning?: string;
68
+ returning?: ReturningSelection;
60
69
  }
61
70
  export interface ColumnTypeInput {
62
71
  /** Schema-builder column type name (PostgreSQL-native in the root package). */
@@ -136,7 +145,7 @@ export interface UpdateStatementInput {
136
145
  /** SQL-ready WHERE fragment INCLUDING the leading ` WHERE ` (or '' for none). */
137
146
  whereSql: string;
138
147
  /** SQL-ready returning selection (default `*`). */
139
- returning?: string;
148
+ returning?: ReturningSelection;
140
149
  }
141
150
  /**
142
151
  * Inputs for {@link Dialect.buildDeleteStatement} — full DELETE assembly. SQL Server
@@ -148,7 +157,7 @@ export interface DeleteStatementInput {
148
157
  /** SQL-ready WHERE fragment INCLUDING the leading ` WHERE ` (or '' for none). */
149
158
  whereSql: string;
150
159
  /** SQL-ready returning selection (default `*`). */
151
- returning?: string;
160
+ returning?: ReturningSelection;
152
161
  }
153
162
  /**
154
163
  * Inputs for {@link Dialect.buildLimitOffset} — the trailing pagination clause of an
@@ -316,7 +325,7 @@ export interface Dialect {
316
325
  prefix: string;
317
326
  };
318
327
  /** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
319
- buildReturningClause(selection?: string): string;
328
+ buildReturningClause(selection?: ReturningSelection): string;
320
329
  /** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
321
330
  buildInsertStatement(input: InsertStatementInput): string;
322
331
  /** Build a multi-row bulk INSERT statement and its dialect-shaped params. */
package/dist/dialect.js CHANGED
@@ -69,7 +69,7 @@ export const postgresDialect = {
69
69
  return `COALESCE((${subquery}), ${fallback})`;
70
70
  },
71
71
  buildReturningClause(selection = '*') {
72
- return ` RETURNING ${selection}`;
72
+ return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
73
73
  },
74
74
  buildInsertStatement(input) {
75
75
  return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;