turbine-orm 0.37.0 → 0.38.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.
- package/README.md +3 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +262 -60
- package/dist/cjs/errors.js +27 -0
- package/dist/cjs/nested-write.js +7 -7
- package/dist/cjs/query/batched-loader.js +4 -3
- package/dist/cjs/query/builder.js +9 -5
- package/dist/cjs/query/relations.js +7 -6
- package/dist/cjs/query/utils.js +13 -0
- package/dist/cjs/query/where-compile.js +2 -1
- package/dist/cjs/query/where.js +3 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +27 -5
- package/dist/cli/studio.js +262 -60
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +26 -0
- package/dist/nested-write.js +8 -8
- package/dist/query/batched-loader.js +4 -3
- package/dist/query/builder.js +10 -6
- package/dist/query/relations.js +7 -6
- package/dist/query/utils.d.ts +10 -0
- package/dist/query/utils.js +12 -0
- package/dist/query/where-compile.js +2 -1
- package/dist/query/where.js +4 -4
- package/package.json +1 -1
package/dist/cli/studio.d.ts
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* $N params compiled through the query builders
|
|
21
21
|
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
22
22
|
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
23
|
-
* transaction, require a matching Origin header (CSRF), and
|
|
24
|
-
*
|
|
23
|
+
* transaction, require a matching Origin header (CSRF), and address every
|
|
24
|
+
* row by its full primary key (single row, or a capped `rows` array of
|
|
25
|
+
* PK-addressed statements run atomically)
|
|
25
26
|
* • 30s statement timeout via parameterized set_config()
|
|
26
27
|
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
27
28
|
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
@@ -30,9 +31,10 @@
|
|
|
30
31
|
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
31
32
|
* was started with `--show-pii`.
|
|
32
33
|
*
|
|
33
|
-
* Write model (opt-in): update
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Write model (opt-in): update a single row; insert/delete one row or a capped
|
|
35
|
+
* list of PK-addressed rows in one all-or-nothing transaction. DDL and
|
|
36
|
+
* predicate-based (unconditional) writes are deliberately unsupported. Use the
|
|
37
|
+
* CLI or migrate for schema changes and true bulk operations.
|
|
36
38
|
*/
|
|
37
39
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
38
40
|
import type { PgCompatPool } from '../client.js';
|
|
@@ -117,6 +119,12 @@ export interface StudioContext {
|
|
|
117
119
|
* (the default). Set to the SQLite dialect in demo mode.
|
|
118
120
|
*/
|
|
119
121
|
dialect?: Dialect;
|
|
122
|
+
/**
|
|
123
|
+
* Demo mode only: saved queries live here instead of on disk, honoring the
|
|
124
|
+
* "nothing you do here is saved anywhere" promise. Absent in normal mode
|
|
125
|
+
* (saved queries persist to `<stateDir>/studio-queries.json`).
|
|
126
|
+
*/
|
|
127
|
+
memorySavedQueries?: SavedQueriesFile;
|
|
120
128
|
}
|
|
121
129
|
/**
|
|
122
130
|
* Start the Studio server. Returns a handle with the session token, a pre-built
|
|
@@ -136,8 +144,22 @@ export declare function isTextishType(pgType: string): boolean;
|
|
|
136
144
|
export declare function escapeLikePattern(s: string): string;
|
|
137
145
|
export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
138
146
|
export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
|
|
147
|
+
interface SavedQuery {
|
|
148
|
+
id: string;
|
|
149
|
+
table: string;
|
|
150
|
+
name: string;
|
|
151
|
+
/** Studio only saves visual-builder queries — there is no raw-SQL surface. */
|
|
152
|
+
kind: 'builder';
|
|
153
|
+
args?: unknown;
|
|
154
|
+
createdAt: string;
|
|
155
|
+
}
|
|
156
|
+
interface SavedQueriesFile {
|
|
157
|
+
version: 1;
|
|
158
|
+
queries: SavedQuery[];
|
|
159
|
+
}
|
|
139
160
|
export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
|
|
140
161
|
export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
141
162
|
export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
|
|
142
163
|
/** The literal replacement value for a redacted PII cell. */
|
|
143
164
|
export declare const PII_REDACTED = "\u2022\u2022 redacted \u2022\u2022";
|
|
165
|
+
export {};
|
package/dist/cli/studio.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* $N params compiled through the query builders
|
|
21
21
|
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
22
22
|
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
23
|
-
* transaction, require a matching Origin header (CSRF), and
|
|
24
|
-
*
|
|
23
|
+
* transaction, require a matching Origin header (CSRF), and address every
|
|
24
|
+
* row by its full primary key (single row, or a capped `rows` array of
|
|
25
|
+
* PK-addressed statements run atomically)
|
|
25
26
|
* • 30s statement timeout via parameterized set_config()
|
|
26
27
|
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
27
28
|
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
@@ -30,9 +31,10 @@
|
|
|
30
31
|
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
31
32
|
* was started with `--show-pii`.
|
|
32
33
|
*
|
|
33
|
-
* Write model (opt-in): update
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Write model (opt-in): update a single row; insert/delete one row or a capped
|
|
35
|
+
* list of PK-addressed rows in one all-or-nothing transaction. DDL and
|
|
36
|
+
* predicate-based (unconditional) writes are deliberately unsupported. Use the
|
|
37
|
+
* CLI or migrate for schema changes and true bulk operations.
|
|
36
38
|
*/
|
|
37
39
|
import { spawn } from 'node:child_process';
|
|
38
40
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
@@ -122,6 +124,9 @@ export async function startStudio(options) {
|
|
|
122
124
|
showPii: demo ? false : options.showPii === true,
|
|
123
125
|
demo,
|
|
124
126
|
dialect,
|
|
127
|
+
// Demo never touches disk: saved queries live (and die) with the process,
|
|
128
|
+
// and the user's real .turbine/studio-queries.json is never read.
|
|
129
|
+
memorySavedQueries: demo ? { version: 1, queries: [] } : undefined,
|
|
125
130
|
};
|
|
126
131
|
const server = createServer((req, res) => {
|
|
127
132
|
handleRequest(req, res, ctx).catch((err) => {
|
|
@@ -436,6 +441,18 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
436
441
|
.map((c) => c.name);
|
|
437
442
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
438
443
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
444
|
+
// Per-column filters: `filters` is a JSON array of { column, op, value }
|
|
445
|
+
// composed by the Data tab's filter bar. Every column is validated against
|
|
446
|
+
// the metadata, every op against a fixed whitelist, and every value is a
|
|
447
|
+
// parameter — same discipline as the builder route.
|
|
448
|
+
let filters;
|
|
449
|
+
try {
|
|
450
|
+
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
sendJson(res, 400, { error: `[turbine] ${err instanceof Error ? err.message : String(err)}` });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
439
456
|
// Parameter placeholder + case-insensitive LIKE condition differ by engine.
|
|
440
457
|
// Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
|
|
441
458
|
// name from the positional value array, matching Turbine's own SQLite path)
|
|
@@ -445,22 +462,46 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
445
462
|
const likeCond = (col, n) => ctx.demo
|
|
446
463
|
? `LOWER(${quoteIdent(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
|
|
447
464
|
: `${quoteIdent(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
465
|
+
// Build the WHERE conditions (search OR-set + per-column filters) once per
|
|
466
|
+
// query, numbering parameters from `startIndex` so the main query (params
|
|
467
|
+
// begin after limit/offset) and the count query (params begin at 1) each get
|
|
468
|
+
// indices matching their own value arrays.
|
|
469
|
+
const buildWhere = (startIndex) => {
|
|
470
|
+
let n = startIndex;
|
|
471
|
+
const conds = [];
|
|
472
|
+
const values = [];
|
|
473
|
+
if (hasSearch && pattern !== null) {
|
|
474
|
+
values.push(pattern);
|
|
475
|
+
const idx = n++;
|
|
476
|
+
conds.push(`(${textColumns.map((c) => likeCond(c, idx)).join(' OR ')})`);
|
|
477
|
+
}
|
|
478
|
+
for (const f of filters) {
|
|
479
|
+
if (f.op === 'isNull') {
|
|
480
|
+
conds.push(`${quoteIdent(f.column)} IS NULL`);
|
|
481
|
+
}
|
|
482
|
+
else if (f.op === 'notNull') {
|
|
483
|
+
conds.push(`${quoteIdent(f.column)} IS NOT NULL`);
|
|
484
|
+
}
|
|
485
|
+
else if (f.op === 'contains') {
|
|
486
|
+
values.push(`%${escapeLikePattern(String(f.value))}%`);
|
|
487
|
+
conds.push(likeCond(f.column, n++));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
const sqlOp = FILTER_OPS[f.op];
|
|
491
|
+
values.push(f.value);
|
|
492
|
+
conds.push(`${quoteIdent(f.column)} ${sqlOp} ${ph(n++)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', values };
|
|
496
|
+
};
|
|
497
|
+
// Main query: $1 = limit, $2 = offset, then search/filter params.
|
|
498
|
+
const mainW = buildWhere(3);
|
|
499
|
+
const mainValues = [limit, offset, ...mainW.values];
|
|
500
|
+
const mainWhere = mainW.where;
|
|
501
|
+
// Count query: params start at $1.
|
|
502
|
+
const countW = buildWhere(1);
|
|
503
|
+
const countValues = countW.values;
|
|
504
|
+
const countWhere = countW.where;
|
|
464
505
|
// Demo runs against an unqualified in-memory SQLite table (no schemas);
|
|
465
506
|
// Postgres qualifies with the configured `--schema`.
|
|
466
507
|
const qualifiedTable = ctx.demo
|
|
@@ -530,6 +571,78 @@ export function escapeLikePattern(s) {
|
|
|
530
571
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
531
572
|
}
|
|
532
573
|
// ---------------------------------------------------------------------------
|
|
574
|
+
// Data-tab per-column filters
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
/** Scalar comparison ops → SQL operator. `contains`/`isNull`/`notNull` compile separately. */
|
|
577
|
+
const FILTER_OPS = {
|
|
578
|
+
equals: '=',
|
|
579
|
+
not: '<>',
|
|
580
|
+
gt: '>',
|
|
581
|
+
gte: '>=',
|
|
582
|
+
lt: '<',
|
|
583
|
+
lte: '<=',
|
|
584
|
+
};
|
|
585
|
+
const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
|
|
586
|
+
/** Hard cap on filter clauses per request — the UI never composes more. */
|
|
587
|
+
const MAX_TABLE_FILTERS = 10;
|
|
588
|
+
/**
|
|
589
|
+
* Parse + validate the Data tab's `filters` query param. Throws with a clear
|
|
590
|
+
* message on any invalid shape; the caller turns that into a 400. Filters on
|
|
591
|
+
* redacted PII columns are refused outright (a filter is a value-probing
|
|
592
|
+
* oracle, same reason redacted columns are excluded from search and orderBy).
|
|
593
|
+
*/
|
|
594
|
+
function parseTableFilters(raw, table, redactedPii) {
|
|
595
|
+
if (!raw)
|
|
596
|
+
return [];
|
|
597
|
+
let parsed;
|
|
598
|
+
try {
|
|
599
|
+
parsed = JSON.parse(raw);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
throw new Error('`filters` must be a JSON array');
|
|
603
|
+
}
|
|
604
|
+
if (!Array.isArray(parsed))
|
|
605
|
+
throw new Error('`filters` must be a JSON array');
|
|
606
|
+
if (parsed.length > MAX_TABLE_FILTERS) {
|
|
607
|
+
throw new Error(`too many filters (max ${MAX_TABLE_FILTERS})`);
|
|
608
|
+
}
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const item of parsed) {
|
|
611
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
612
|
+
throw new Error('each filter must be an object { column, op, value }');
|
|
613
|
+
}
|
|
614
|
+
const f = item;
|
|
615
|
+
const col = typeof f.column === 'string' ? resolveColumnName(table, f.column) : null;
|
|
616
|
+
if (!col) {
|
|
617
|
+
throw new Error(`unknown filter column "${String(f.column)}" on table "${table.name}"`);
|
|
618
|
+
}
|
|
619
|
+
if (redactedPii.has(col)) {
|
|
620
|
+
throw new Error(`column "${col}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
|
|
621
|
+
}
|
|
622
|
+
const op = typeof f.op === 'string' ? f.op : '';
|
|
623
|
+
if (!FILTER_OP_NAMES.has(op)) {
|
|
624
|
+
throw new Error(`unknown filter op "${op}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
|
|
625
|
+
}
|
|
626
|
+
if (op === 'isNull' || op === 'notNull') {
|
|
627
|
+
out.push({ column: col, op });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const value = f.value;
|
|
631
|
+
const t = typeof value;
|
|
632
|
+
if (value === null || value === undefined || (t !== 'string' && t !== 'number' && t !== 'boolean')) {
|
|
633
|
+
throw new Error(`filter on "${col}" needs a scalar value (use isNull/notNull for null checks)`);
|
|
634
|
+
}
|
|
635
|
+
if (op === 'contains') {
|
|
636
|
+
const colMeta = table.columns.find((c) => c.name === col);
|
|
637
|
+
if (!colMeta || !isTextishType(colMeta.pgType)) {
|
|
638
|
+
throw new Error(`contains only applies to text columns ("${col}" is ${colMeta?.pgType ?? 'unknown'})`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
out.push({ column: col, op, value });
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
533
646
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
534
647
|
// ---------------------------------------------------------------------------
|
|
535
648
|
export async function apiBuilder(req, res, ctx) {
|
|
@@ -604,15 +717,24 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
604
717
|
}
|
|
605
718
|
}
|
|
606
719
|
// ---------------------------------------------------------------------------
|
|
607
|
-
// API: /api/row/update | /api/row/insert | /api/row/delete (
|
|
720
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (PK-addressed writes)
|
|
608
721
|
//
|
|
609
722
|
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
610
723
|
// is validated against the introspected metadata and the statement is compiled
|
|
611
724
|
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
612
725
|
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
613
726
|
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
614
|
-
// from those PK values alone, so a
|
|
727
|
+
// from those PK values alone, so a statement can only ever touch one row.
|
|
728
|
+
//
|
|
729
|
+
// Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
|
|
730
|
+
// — an array of data objects (insert) or PK-where objects (delete). Each entry
|
|
731
|
+
// goes through the exact same per-row validation and compiles to its own
|
|
732
|
+
// single-row statement; all statements run in ONE transaction (all-or-nothing,
|
|
733
|
+
// capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
|
|
734
|
+
// unsupported — every row is still addressed by its full primary key.
|
|
615
735
|
// ---------------------------------------------------------------------------
|
|
736
|
+
/** Hard cap on rows per bulk insert/delete request (matches the max page size). */
|
|
737
|
+
const MAX_BULK_ROWS = 500;
|
|
616
738
|
export async function apiRowWrite(req, res, ctx, op) {
|
|
617
739
|
const body = await readJsonBody(req);
|
|
618
740
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -625,23 +747,51 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
625
747
|
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
626
748
|
return;
|
|
627
749
|
}
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const badKey = firstUnknownColumn(table, data);
|
|
634
|
-
if (badKey) {
|
|
635
|
-
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
750
|
+
// Bulk form: `rows` replaces `data` (insert) / `where` (delete).
|
|
751
|
+
const bulkRows = Array.isArray(body?.rows) ? body.rows : null;
|
|
752
|
+
if (bulkRows) {
|
|
753
|
+
if (op === 'update') {
|
|
754
|
+
sendJson(res, 400, { error: '[turbine] bulk update is not supported; update rows one at a time' });
|
|
636
755
|
return;
|
|
637
756
|
}
|
|
638
|
-
if (
|
|
639
|
-
sendJson(res, 400, { error: '[turbine] `
|
|
757
|
+
if (bulkRows.length === 0) {
|
|
758
|
+
sendJson(res, 400, { error: '[turbine] `rows` must include at least one entry' });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (bulkRows.length > MAX_BULK_ROWS) {
|
|
762
|
+
sendJson(res, 400, { error: `[turbine] too many rows (max ${MAX_BULK_ROWS} per request)` });
|
|
640
763
|
return;
|
|
641
764
|
}
|
|
642
765
|
}
|
|
643
|
-
|
|
644
|
-
|
|
766
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
767
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
768
|
+
// Per-statement inputs, validated up front for a clean typed 400 (the
|
|
769
|
+
// builders would also reject unknowns, but explicit checks keep messages
|
|
770
|
+
// clear and stop before any statement has run).
|
|
771
|
+
const inserts = [];
|
|
772
|
+
const wheres = [];
|
|
773
|
+
if (op === 'insert') {
|
|
774
|
+
const candidates = bulkRows ?? [data];
|
|
775
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
776
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
777
|
+
const rowData = candidates[i];
|
|
778
|
+
if (!rowData || typeof rowData !== 'object' || Array.isArray(rowData)) {
|
|
779
|
+
sendJson(res, 400, { error: `[turbine] each insert row must be an object${rowLabel}` });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const rec = rowData;
|
|
783
|
+
const badKey = firstUnknownColumn(table, rec);
|
|
784
|
+
if (badKey) {
|
|
785
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"${rowLabel}` });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!Object.keys(rec).some((k) => rec[k] !== undefined)) {
|
|
789
|
+
sendJson(res, 400, { error: `[turbine] \`data\` must include at least one column${rowLabel}` });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
inserts.push(rec);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
645
795
|
if (op === 'update' || op === 'delete') {
|
|
646
796
|
if (table.primaryKey.length === 0) {
|
|
647
797
|
sendJson(res, 400, {
|
|
@@ -649,19 +799,39 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
649
799
|
});
|
|
650
800
|
return;
|
|
651
801
|
}
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
802
|
+
const candidates = op === 'delete' && bulkRows ? bulkRows : [rawWhere];
|
|
803
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
804
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
805
|
+
const rowWhere = candidates[i];
|
|
806
|
+
if (!rowWhere || typeof rowWhere !== 'object' || Array.isArray(rowWhere)) {
|
|
807
|
+
sendJson(res, 400, { error: `[turbine] each delete target must be a where object${rowLabel}` });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const pk = extractPkWhere(table, rowWhere);
|
|
811
|
+
if ('error' in pk) {
|
|
812
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}${rowLabel}` });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
816
|
+
if (Object.keys(pk.where).length === 0) {
|
|
817
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
wheres.push(pk.where);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (op === 'update') {
|
|
824
|
+
const badKey = firstUnknownColumn(table, data);
|
|
825
|
+
if (badKey) {
|
|
826
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
655
827
|
return;
|
|
656
828
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
829
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
830
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
660
831
|
return;
|
|
661
832
|
}
|
|
662
|
-
effectiveWhere = pk.where;
|
|
663
833
|
}
|
|
664
|
-
let
|
|
834
|
+
let deferreds;
|
|
665
835
|
try {
|
|
666
836
|
const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
667
837
|
warnOnUnlimited: false,
|
|
@@ -670,13 +840,14 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
670
840
|
dialect: ctx.dialect,
|
|
671
841
|
});
|
|
672
842
|
if (op === 'insert') {
|
|
673
|
-
|
|
843
|
+
deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
|
|
674
844
|
}
|
|
675
845
|
else if (op === 'update') {
|
|
676
|
-
|
|
846
|
+
const where = wheres[0];
|
|
847
|
+
deferreds = [qi.buildUpdate({ where, data })];
|
|
677
848
|
}
|
|
678
849
|
else {
|
|
679
|
-
|
|
850
|
+
deferreds = wheres.map((where) => qi.buildDelete({ where }));
|
|
680
851
|
}
|
|
681
852
|
}
|
|
682
853
|
catch (err) {
|
|
@@ -688,28 +859,48 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
688
859
|
// A real write transaction, NOT `READ ONLY`. Postgres also pins the
|
|
689
860
|
// parameterized statement-timeout + search_path; demo (SQLite) has neither
|
|
690
861
|
// GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
|
|
691
|
-
// supports it) so an in-memory write still applies atomically.
|
|
862
|
+
// supports it) so an in-memory write still applies atomically. Bulk
|
|
863
|
+
// requests are all-or-nothing: any per-row failure rolls back every row.
|
|
692
864
|
await client.query('BEGIN');
|
|
693
865
|
if (!ctx.demo) {
|
|
694
866
|
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
695
867
|
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
696
868
|
}
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
869
|
+
const returnedRows = [];
|
|
870
|
+
let rowCount = 0;
|
|
871
|
+
for (const deferred of deferreds) {
|
|
872
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
873
|
+
const row = result.rows[0];
|
|
874
|
+
if (!row) {
|
|
875
|
+
// A statement that touched nothing (stale PK, vanished row) aborts the
|
|
876
|
+
// whole request so a bulk delete can never half-apply.
|
|
877
|
+
await client.query('ROLLBACK');
|
|
878
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
879
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
returnedRows.push(row);
|
|
883
|
+
rowCount += result.rowCount ?? 1;
|
|
704
884
|
}
|
|
705
|
-
|
|
885
|
+
await client.query('COMMIT');
|
|
886
|
+
// The echoed rows are redacted the same way as any read (unless --show-pii),
|
|
706
887
|
// even though a write to a pii column is allowed.
|
|
707
888
|
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
889
|
+
if (bulkRows) {
|
|
890
|
+
sendJson(res, 200, {
|
|
891
|
+
operation: op,
|
|
892
|
+
rows: returnedRows.map((row) => serializeRow(redactFlatRow(row, piiKeys))),
|
|
893
|
+
rowCount,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
const first = returnedRows[0];
|
|
898
|
+
sendJson(res, 200, {
|
|
899
|
+
operation: op,
|
|
900
|
+
row: serializeRow(redactFlatRow(first, piiKeys)),
|
|
901
|
+
rowCount,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
713
904
|
}
|
|
714
905
|
catch (err) {
|
|
715
906
|
try {
|
|
@@ -771,6 +962,12 @@ function savedQueriesPath(ctx) {
|
|
|
771
962
|
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
772
963
|
let legacyDropNoticeShown = false;
|
|
773
964
|
function loadSavedQueries(ctx) {
|
|
965
|
+
// Demo mode: in-memory only — never read the user's real saved-query file.
|
|
966
|
+
if (ctx.demo) {
|
|
967
|
+
if (!ctx.memorySavedQueries)
|
|
968
|
+
ctx.memorySavedQueries = { version: 1, queries: [] };
|
|
969
|
+
return ctx.memorySavedQueries;
|
|
970
|
+
}
|
|
774
971
|
const file = savedQueriesPath(ctx);
|
|
775
972
|
if (!existsSync(file))
|
|
776
973
|
return { version: 1, queries: [] };
|
|
@@ -796,6 +993,11 @@ function loadSavedQueries(ctx) {
|
|
|
796
993
|
}
|
|
797
994
|
}
|
|
798
995
|
function writeSavedQueries(ctx, data) {
|
|
996
|
+
// Demo mode: in-memory only — nothing is ever written to disk.
|
|
997
|
+
if (ctx.demo) {
|
|
998
|
+
ctx.memorySavedQueries = data;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
799
1001
|
const file = savedQueriesPath(ctx);
|
|
800
1002
|
const dir = dirname(file);
|
|
801
1003
|
if (!existsSync(dir))
|
package/dist/errors.d.ts
CHANGED
|
@@ -57,6 +57,17 @@ export type ErrorMessageMode = 'safe' | 'verbose';
|
|
|
57
57
|
export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
|
|
58
58
|
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
59
59
|
export declare function getErrorMessageMode(): ErrorMessageMode;
|
|
60
|
+
/**
|
|
61
|
+
* Render a user-supplied `where` / `connect` target for a "no row found" error
|
|
62
|
+
* message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
|
|
63
|
+
* default) only the key names are shown (`keys [email, id]`) so that PII values
|
|
64
|
+
* never leak into logs; in 'verbose' mode the full JSON serialization is used.
|
|
65
|
+
*
|
|
66
|
+
* This mirrors {@link NotFoundError}'s redaction so that every "no row found"
|
|
67
|
+
* message in the library follows one convention, including the nested-write
|
|
68
|
+
* connect/update failures which historically embedded the raw values.
|
|
69
|
+
*/
|
|
70
|
+
export declare function describeTargetForMessage(target: unknown): string;
|
|
60
71
|
/**
|
|
61
72
|
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
62
73
|
* update/delete against a non-matching row, etc.)
|
package/dist/errors.js
CHANGED
|
@@ -65,6 +65,32 @@ export function setErrorMessageMode(mode) {
|
|
|
65
65
|
export function getErrorMessageMode() {
|
|
66
66
|
return errorMessageMode;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Render a user-supplied `where` / `connect` target for a "no row found" error
|
|
70
|
+
* message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
|
|
71
|
+
* default) only the key names are shown (`keys [email, id]`) so that PII values
|
|
72
|
+
* never leak into logs; in 'verbose' mode the full JSON serialization is used.
|
|
73
|
+
*
|
|
74
|
+
* This mirrors {@link NotFoundError}'s redaction so that every "no row found"
|
|
75
|
+
* message in the library follows one convention, including the nested-write
|
|
76
|
+
* connect/update failures which historically embedded the raw values.
|
|
77
|
+
*/
|
|
78
|
+
export function describeTargetForMessage(target) {
|
|
79
|
+
if (errorMessageMode === 'verbose') {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.stringify(target);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return '[unserializable]';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// safe mode: key names only
|
|
88
|
+
if (target === null || target === undefined || typeof target !== 'object') {
|
|
89
|
+
return 'keys []';
|
|
90
|
+
}
|
|
91
|
+
const keys = Object.keys(target);
|
|
92
|
+
return `keys [${keys.join(', ')}]`;
|
|
93
|
+
}
|
|
68
94
|
/**
|
|
69
95
|
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
70
96
|
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
package/dist/nested-write.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `client.ts` directly — the transaction handle is passed in via
|
|
12
12
|
* `NestedWriteContext`.
|
|
13
13
|
*/
|
|
14
|
-
import { CircularRelationError, RelationError, ValidationError } from './errors.js';
|
|
14
|
+
import { CircularRelationError, describeTargetForMessage, RelationError, ValidationError } from './errors.js';
|
|
15
15
|
import { normalizeKeyColumns } from './schema.js';
|
|
16
16
|
const MAX_DEPTH = 10;
|
|
17
17
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
@@ -32,7 +32,7 @@ export function extractRelationFields(data, tableMeta) {
|
|
|
32
32
|
const scalars = {};
|
|
33
33
|
const relations = {};
|
|
34
34
|
for (const [key, value] of Object.entries(data)) {
|
|
35
|
-
if (
|
|
35
|
+
if (Object.hasOwn(tableMeta.relations, key) &&
|
|
36
36
|
value !== null &&
|
|
37
37
|
typeof value === 'object' &&
|
|
38
38
|
!Array.isArray(value) &&
|
|
@@ -52,7 +52,7 @@ export function extractRelationFields(data, tableMeta) {
|
|
|
52
52
|
*/
|
|
53
53
|
export function hasRelationFields(data, tableMeta) {
|
|
54
54
|
for (const key of Object.keys(data)) {
|
|
55
|
-
if (
|
|
55
|
+
if (Object.hasOwn(tableMeta.relations, key)) {
|
|
56
56
|
const val = data[key];
|
|
57
57
|
if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
|
|
58
58
|
return true;
|
|
@@ -207,7 +207,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
207
207
|
else {
|
|
208
208
|
parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
|
|
209
209
|
if (!parentRow) {
|
|
210
|
-
throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${
|
|
210
|
+
throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${describeTargetForMessage(where)}.`);
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
// Process each relation
|
|
@@ -290,7 +290,7 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
290
290
|
if (items.length > 0) {
|
|
291
291
|
// Check if any items have nested relations (need per-row recursion)
|
|
292
292
|
const childTable = ctx.schema.tables[rel.to];
|
|
293
|
-
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) =>
|
|
293
|
+
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => Object.hasOwn(childTable.relations ?? {}, k)));
|
|
294
294
|
if (hasNested) {
|
|
295
295
|
// Per-row recursive create for items with nested relations
|
|
296
296
|
for (const item of items) {
|
|
@@ -350,7 +350,7 @@ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path
|
|
|
350
350
|
const target = items[0];
|
|
351
351
|
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
352
352
|
if (!relatedRow) {
|
|
353
|
-
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
353
|
+
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
356
|
}
|
|
@@ -405,7 +405,7 @@ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, dep
|
|
|
405
405
|
const target = items[0];
|
|
406
406
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
407
407
|
if (!existing) {
|
|
408
|
-
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
408
|
+
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
|
|
409
409
|
}
|
|
410
410
|
const updateData = {};
|
|
411
411
|
const relatedTable = ctx.schema.tables[rel.to];
|
|
@@ -435,7 +435,7 @@ async function batchConnect(ctx, rel, items, parentRow) {
|
|
|
435
435
|
for (const target of items) {
|
|
436
436
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
437
437
|
if (!existing) {
|
|
438
|
-
throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${
|
|
438
|
+
throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
// Build FK update data to point children at parent
|