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/cjs/cli/studio.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* $N params compiled through the query builders
|
|
22
22
|
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
23
23
|
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
24
|
-
* transaction, require a matching Origin header (CSRF), and
|
|
25
|
-
*
|
|
24
|
+
* transaction, require a matching Origin header (CSRF), and address every
|
|
25
|
+
* row by its full primary key (single row, or a capped `rows` array of
|
|
26
|
+
* PK-addressed statements run atomically)
|
|
26
27
|
* • 30s statement timeout via parameterized set_config()
|
|
27
28
|
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
28
29
|
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
@@ -31,9 +32,10 @@
|
|
|
31
32
|
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
32
33
|
* was started with `--show-pii`.
|
|
33
34
|
*
|
|
34
|
-
* Write model (opt-in): update
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Write model (opt-in): update a single row; insert/delete one row or a capped
|
|
36
|
+
* list of PK-addressed rows in one all-or-nothing transaction. DDL and
|
|
37
|
+
* predicate-based (unconditional) writes are deliberately unsupported. Use the
|
|
38
|
+
* CLI or migrate for schema changes and true bulk operations.
|
|
37
39
|
*/
|
|
38
40
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
39
41
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -140,6 +142,9 @@ async function startStudio(options) {
|
|
|
140
142
|
showPii: demo ? false : options.showPii === true,
|
|
141
143
|
demo,
|
|
142
144
|
dialect,
|
|
145
|
+
// Demo never touches disk: saved queries live (and die) with the process,
|
|
146
|
+
// and the user's real .turbine/studio-queries.json is never read.
|
|
147
|
+
memorySavedQueries: demo ? { version: 1, queries: [] } : undefined,
|
|
143
148
|
};
|
|
144
149
|
const server = (0, node_http_1.createServer)((req, res) => {
|
|
145
150
|
handleRequest(req, res, ctx).catch((err) => {
|
|
@@ -454,6 +459,18 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
454
459
|
.map((c) => c.name);
|
|
455
460
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
456
461
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
462
|
+
// Per-column filters: `filters` is a JSON array of { column, op, value }
|
|
463
|
+
// composed by the Data tab's filter bar. Every column is validated against
|
|
464
|
+
// the metadata, every op against a fixed whitelist, and every value is a
|
|
465
|
+
// parameter — same discipline as the builder route.
|
|
466
|
+
let filters;
|
|
467
|
+
try {
|
|
468
|
+
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
sendJson(res, 400, { error: `[turbine] ${err instanceof Error ? err.message : String(err)}` });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
457
474
|
// Parameter placeholder + case-insensitive LIKE condition differ by engine.
|
|
458
475
|
// Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
|
|
459
476
|
// name from the positional value array, matching Turbine's own SQLite path)
|
|
@@ -463,22 +480,46 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
463
480
|
const likeCond = (col, n) => ctx.demo
|
|
464
481
|
? `LOWER(${(0, index_js_1.quoteIdent)(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
|
|
465
482
|
: `${(0, index_js_1.quoteIdent)(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
483
|
+
// Build the WHERE conditions (search OR-set + per-column filters) once per
|
|
484
|
+
// query, numbering parameters from `startIndex` so the main query (params
|
|
485
|
+
// begin after limit/offset) and the count query (params begin at 1) each get
|
|
486
|
+
// indices matching their own value arrays.
|
|
487
|
+
const buildWhere = (startIndex) => {
|
|
488
|
+
let n = startIndex;
|
|
489
|
+
const conds = [];
|
|
490
|
+
const values = [];
|
|
491
|
+
if (hasSearch && pattern !== null) {
|
|
492
|
+
values.push(pattern);
|
|
493
|
+
const idx = n++;
|
|
494
|
+
conds.push(`(${textColumns.map((c) => likeCond(c, idx)).join(' OR ')})`);
|
|
495
|
+
}
|
|
496
|
+
for (const f of filters) {
|
|
497
|
+
if (f.op === 'isNull') {
|
|
498
|
+
conds.push(`${(0, index_js_1.quoteIdent)(f.column)} IS NULL`);
|
|
499
|
+
}
|
|
500
|
+
else if (f.op === 'notNull') {
|
|
501
|
+
conds.push(`${(0, index_js_1.quoteIdent)(f.column)} IS NOT NULL`);
|
|
502
|
+
}
|
|
503
|
+
else if (f.op === 'contains') {
|
|
504
|
+
values.push(`%${escapeLikePattern(String(f.value))}%`);
|
|
505
|
+
conds.push(likeCond(f.column, n++));
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
const sqlOp = FILTER_OPS[f.op];
|
|
509
|
+
values.push(f.value);
|
|
510
|
+
conds.push(`${(0, index_js_1.quoteIdent)(f.column)} ${sqlOp} ${ph(n++)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', values };
|
|
514
|
+
};
|
|
515
|
+
// Main query: $1 = limit, $2 = offset, then search/filter params.
|
|
516
|
+
const mainW = buildWhere(3);
|
|
517
|
+
const mainValues = [limit, offset, ...mainW.values];
|
|
518
|
+
const mainWhere = mainW.where;
|
|
519
|
+
// Count query: params start at $1.
|
|
520
|
+
const countW = buildWhere(1);
|
|
521
|
+
const countValues = countW.values;
|
|
522
|
+
const countWhere = countW.where;
|
|
482
523
|
// Demo runs against an unqualified in-memory SQLite table (no schemas);
|
|
483
524
|
// Postgres qualifies with the configured `--schema`.
|
|
484
525
|
const qualifiedTable = ctx.demo
|
|
@@ -548,6 +589,78 @@ function escapeLikePattern(s) {
|
|
|
548
589
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
549
590
|
}
|
|
550
591
|
// ---------------------------------------------------------------------------
|
|
592
|
+
// Data-tab per-column filters
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
/** Scalar comparison ops → SQL operator. `contains`/`isNull`/`notNull` compile separately. */
|
|
595
|
+
const FILTER_OPS = {
|
|
596
|
+
equals: '=',
|
|
597
|
+
not: '<>',
|
|
598
|
+
gt: '>',
|
|
599
|
+
gte: '>=',
|
|
600
|
+
lt: '<',
|
|
601
|
+
lte: '<=',
|
|
602
|
+
};
|
|
603
|
+
const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
|
|
604
|
+
/** Hard cap on filter clauses per request — the UI never composes more. */
|
|
605
|
+
const MAX_TABLE_FILTERS = 10;
|
|
606
|
+
/**
|
|
607
|
+
* Parse + validate the Data tab's `filters` query param. Throws with a clear
|
|
608
|
+
* message on any invalid shape; the caller turns that into a 400. Filters on
|
|
609
|
+
* redacted PII columns are refused outright (a filter is a value-probing
|
|
610
|
+
* oracle, same reason redacted columns are excluded from search and orderBy).
|
|
611
|
+
*/
|
|
612
|
+
function parseTableFilters(raw, table, redactedPii) {
|
|
613
|
+
if (!raw)
|
|
614
|
+
return [];
|
|
615
|
+
let parsed;
|
|
616
|
+
try {
|
|
617
|
+
parsed = JSON.parse(raw);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
throw new Error('`filters` must be a JSON array');
|
|
621
|
+
}
|
|
622
|
+
if (!Array.isArray(parsed))
|
|
623
|
+
throw new Error('`filters` must be a JSON array');
|
|
624
|
+
if (parsed.length > MAX_TABLE_FILTERS) {
|
|
625
|
+
throw new Error(`too many filters (max ${MAX_TABLE_FILTERS})`);
|
|
626
|
+
}
|
|
627
|
+
const out = [];
|
|
628
|
+
for (const item of parsed) {
|
|
629
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
630
|
+
throw new Error('each filter must be an object { column, op, value }');
|
|
631
|
+
}
|
|
632
|
+
const f = item;
|
|
633
|
+
const col = typeof f.column === 'string' ? resolveColumnName(table, f.column) : null;
|
|
634
|
+
if (!col) {
|
|
635
|
+
throw new Error(`unknown filter column "${String(f.column)}" on table "${table.name}"`);
|
|
636
|
+
}
|
|
637
|
+
if (redactedPii.has(col)) {
|
|
638
|
+
throw new Error(`column "${col}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
|
|
639
|
+
}
|
|
640
|
+
const op = typeof f.op === 'string' ? f.op : '';
|
|
641
|
+
if (!FILTER_OP_NAMES.has(op)) {
|
|
642
|
+
throw new Error(`unknown filter op "${op}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
|
|
643
|
+
}
|
|
644
|
+
if (op === 'isNull' || op === 'notNull') {
|
|
645
|
+
out.push({ column: col, op });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const value = f.value;
|
|
649
|
+
const t = typeof value;
|
|
650
|
+
if (value === null || value === undefined || (t !== 'string' && t !== 'number' && t !== 'boolean')) {
|
|
651
|
+
throw new Error(`filter on "${col}" needs a scalar value (use isNull/notNull for null checks)`);
|
|
652
|
+
}
|
|
653
|
+
if (op === 'contains') {
|
|
654
|
+
const colMeta = table.columns.find((c) => c.name === col);
|
|
655
|
+
if (!colMeta || !isTextishType(colMeta.pgType)) {
|
|
656
|
+
throw new Error(`contains only applies to text columns ("${col}" is ${colMeta?.pgType ?? 'unknown'})`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
out.push({ column: col, op, value });
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
551
664
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
552
665
|
// ---------------------------------------------------------------------------
|
|
553
666
|
async function apiBuilder(req, res, ctx) {
|
|
@@ -622,15 +735,24 @@ async function apiBuilder(req, res, ctx) {
|
|
|
622
735
|
}
|
|
623
736
|
}
|
|
624
737
|
// ---------------------------------------------------------------------------
|
|
625
|
-
// API: /api/row/update | /api/row/insert | /api/row/delete (
|
|
738
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (PK-addressed writes)
|
|
626
739
|
//
|
|
627
740
|
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
628
741
|
// is validated against the introspected metadata and the statement is compiled
|
|
629
742
|
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
630
743
|
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
631
744
|
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
632
|
-
// from those PK values alone, so a
|
|
745
|
+
// from those PK values alone, so a statement can only ever touch one row.
|
|
746
|
+
//
|
|
747
|
+
// Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
|
|
748
|
+
// — an array of data objects (insert) or PK-where objects (delete). Each entry
|
|
749
|
+
// goes through the exact same per-row validation and compiles to its own
|
|
750
|
+
// single-row statement; all statements run in ONE transaction (all-or-nothing,
|
|
751
|
+
// capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
|
|
752
|
+
// unsupported — every row is still addressed by its full primary key.
|
|
633
753
|
// ---------------------------------------------------------------------------
|
|
754
|
+
/** Hard cap on rows per bulk insert/delete request (matches the max page size). */
|
|
755
|
+
const MAX_BULK_ROWS = 500;
|
|
634
756
|
async function apiRowWrite(req, res, ctx, op) {
|
|
635
757
|
const body = await readJsonBody(req);
|
|
636
758
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -643,23 +765,51 @@ async function apiRowWrite(req, res, ctx, op) {
|
|
|
643
765
|
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
644
766
|
return;
|
|
645
767
|
}
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const badKey = firstUnknownColumn(table, data);
|
|
652
|
-
if (badKey) {
|
|
653
|
-
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
768
|
+
// Bulk form: `rows` replaces `data` (insert) / `where` (delete).
|
|
769
|
+
const bulkRows = Array.isArray(body?.rows) ? body.rows : null;
|
|
770
|
+
if (bulkRows) {
|
|
771
|
+
if (op === 'update') {
|
|
772
|
+
sendJson(res, 400, { error: '[turbine] bulk update is not supported; update rows one at a time' });
|
|
654
773
|
return;
|
|
655
774
|
}
|
|
656
|
-
if (
|
|
657
|
-
sendJson(res, 400, { error: '[turbine] `
|
|
775
|
+
if (bulkRows.length === 0) {
|
|
776
|
+
sendJson(res, 400, { error: '[turbine] `rows` must include at least one entry' });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (bulkRows.length > MAX_BULK_ROWS) {
|
|
780
|
+
sendJson(res, 400, { error: `[turbine] too many rows (max ${MAX_BULK_ROWS} per request)` });
|
|
658
781
|
return;
|
|
659
782
|
}
|
|
660
783
|
}
|
|
661
|
-
|
|
662
|
-
|
|
784
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
785
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
786
|
+
// Per-statement inputs, validated up front for a clean typed 400 (the
|
|
787
|
+
// builders would also reject unknowns, but explicit checks keep messages
|
|
788
|
+
// clear and stop before any statement has run).
|
|
789
|
+
const inserts = [];
|
|
790
|
+
const wheres = [];
|
|
791
|
+
if (op === 'insert') {
|
|
792
|
+
const candidates = bulkRows ?? [data];
|
|
793
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
794
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
795
|
+
const rowData = candidates[i];
|
|
796
|
+
if (!rowData || typeof rowData !== 'object' || Array.isArray(rowData)) {
|
|
797
|
+
sendJson(res, 400, { error: `[turbine] each insert row must be an object${rowLabel}` });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const rec = rowData;
|
|
801
|
+
const badKey = firstUnknownColumn(table, rec);
|
|
802
|
+
if (badKey) {
|
|
803
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"${rowLabel}` });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!Object.keys(rec).some((k) => rec[k] !== undefined)) {
|
|
807
|
+
sendJson(res, 400, { error: `[turbine] \`data\` must include at least one column${rowLabel}` });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
inserts.push(rec);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
663
813
|
if (op === 'update' || op === 'delete') {
|
|
664
814
|
if (table.primaryKey.length === 0) {
|
|
665
815
|
sendJson(res, 400, {
|
|
@@ -667,19 +817,39 @@ async function apiRowWrite(req, res, ctx, op) {
|
|
|
667
817
|
});
|
|
668
818
|
return;
|
|
669
819
|
}
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
820
|
+
const candidates = op === 'delete' && bulkRows ? bulkRows : [rawWhere];
|
|
821
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
822
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
823
|
+
const rowWhere = candidates[i];
|
|
824
|
+
if (!rowWhere || typeof rowWhere !== 'object' || Array.isArray(rowWhere)) {
|
|
825
|
+
sendJson(res, 400, { error: `[turbine] each delete target must be a where object${rowLabel}` });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const pk = extractPkWhere(table, rowWhere);
|
|
829
|
+
if ('error' in pk) {
|
|
830
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}${rowLabel}` });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
834
|
+
if (Object.keys(pk.where).length === 0) {
|
|
835
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
wheres.push(pk.where);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (op === 'update') {
|
|
842
|
+
const badKey = firstUnknownColumn(table, data);
|
|
843
|
+
if (badKey) {
|
|
844
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
673
845
|
return;
|
|
674
846
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
847
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
848
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
678
849
|
return;
|
|
679
850
|
}
|
|
680
|
-
effectiveWhere = pk.where;
|
|
681
851
|
}
|
|
682
|
-
let
|
|
852
|
+
let deferreds;
|
|
683
853
|
try {
|
|
684
854
|
const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
685
855
|
warnOnUnlimited: false,
|
|
@@ -688,13 +858,14 @@ async function apiRowWrite(req, res, ctx, op) {
|
|
|
688
858
|
dialect: ctx.dialect,
|
|
689
859
|
});
|
|
690
860
|
if (op === 'insert') {
|
|
691
|
-
|
|
861
|
+
deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
|
|
692
862
|
}
|
|
693
863
|
else if (op === 'update') {
|
|
694
|
-
|
|
864
|
+
const where = wheres[0];
|
|
865
|
+
deferreds = [qi.buildUpdate({ where, data })];
|
|
695
866
|
}
|
|
696
867
|
else {
|
|
697
|
-
|
|
868
|
+
deferreds = wheres.map((where) => qi.buildDelete({ where }));
|
|
698
869
|
}
|
|
699
870
|
}
|
|
700
871
|
catch (err) {
|
|
@@ -706,28 +877,48 @@ async function apiRowWrite(req, res, ctx, op) {
|
|
|
706
877
|
// A real write transaction, NOT `READ ONLY`. Postgres also pins the
|
|
707
878
|
// parameterized statement-timeout + search_path; demo (SQLite) has neither
|
|
708
879
|
// GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
|
|
709
|
-
// supports it) so an in-memory write still applies atomically.
|
|
880
|
+
// supports it) so an in-memory write still applies atomically. Bulk
|
|
881
|
+
// requests are all-or-nothing: any per-row failure rolls back every row.
|
|
710
882
|
await client.query('BEGIN');
|
|
711
883
|
if (!ctx.demo) {
|
|
712
884
|
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
713
885
|
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
714
886
|
}
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
887
|
+
const returnedRows = [];
|
|
888
|
+
let rowCount = 0;
|
|
889
|
+
for (const deferred of deferreds) {
|
|
890
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
891
|
+
const row = result.rows[0];
|
|
892
|
+
if (!row) {
|
|
893
|
+
// A statement that touched nothing (stale PK, vanished row) aborts the
|
|
894
|
+
// whole request so a bulk delete can never half-apply.
|
|
895
|
+
await client.query('ROLLBACK');
|
|
896
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
897
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
returnedRows.push(row);
|
|
901
|
+
rowCount += result.rowCount ?? 1;
|
|
722
902
|
}
|
|
723
|
-
|
|
903
|
+
await client.query('COMMIT');
|
|
904
|
+
// The echoed rows are redacted the same way as any read (unless --show-pii),
|
|
724
905
|
// even though a write to a pii column is allowed.
|
|
725
906
|
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
907
|
+
if (bulkRows) {
|
|
908
|
+
sendJson(res, 200, {
|
|
909
|
+
operation: op,
|
|
910
|
+
rows: returnedRows.map((row) => serializeRow(redactFlatRow(row, piiKeys))),
|
|
911
|
+
rowCount,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
const first = returnedRows[0];
|
|
916
|
+
sendJson(res, 200, {
|
|
917
|
+
operation: op,
|
|
918
|
+
row: serializeRow(redactFlatRow(first, piiKeys)),
|
|
919
|
+
rowCount,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
731
922
|
}
|
|
732
923
|
catch (err) {
|
|
733
924
|
try {
|
|
@@ -789,6 +980,12 @@ function savedQueriesPath(ctx) {
|
|
|
789
980
|
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
790
981
|
let legacyDropNoticeShown = false;
|
|
791
982
|
function loadSavedQueries(ctx) {
|
|
983
|
+
// Demo mode: in-memory only — never read the user's real saved-query file.
|
|
984
|
+
if (ctx.demo) {
|
|
985
|
+
if (!ctx.memorySavedQueries)
|
|
986
|
+
ctx.memorySavedQueries = { version: 1, queries: [] };
|
|
987
|
+
return ctx.memorySavedQueries;
|
|
988
|
+
}
|
|
792
989
|
const file = savedQueriesPath(ctx);
|
|
793
990
|
if (!(0, node_fs_1.existsSync)(file))
|
|
794
991
|
return { version: 1, queries: [] };
|
|
@@ -814,6 +1011,11 @@ function loadSavedQueries(ctx) {
|
|
|
814
1011
|
}
|
|
815
1012
|
}
|
|
816
1013
|
function writeSavedQueries(ctx, data) {
|
|
1014
|
+
// Demo mode: in-memory only — nothing is ever written to disk.
|
|
1015
|
+
if (ctx.demo) {
|
|
1016
|
+
ctx.memorySavedQueries = data;
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
817
1019
|
const file = savedQueriesPath(ctx);
|
|
818
1020
|
const dir = (0, node_path_1.dirname)(file);
|
|
819
1021
|
if (!(0, node_fs_1.existsSync)(dir))
|
package/dist/cjs/errors.js
CHANGED
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
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
|
+
exports.describeTargetForMessage = describeTargetForMessage;
|
|
12
13
|
exports.wrapPgError = wrapPgError;
|
|
13
14
|
/** Error codes for all Turbine errors */
|
|
14
15
|
exports.TurbineErrorCode = {
|
|
@@ -72,6 +73,32 @@ function setErrorMessageMode(mode) {
|
|
|
72
73
|
function getErrorMessageMode() {
|
|
73
74
|
return errorMessageMode;
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Render a user-supplied `where` / `connect` target for a "no row found" error
|
|
78
|
+
* message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
|
|
79
|
+
* default) only the key names are shown (`keys [email, id]`) so that PII values
|
|
80
|
+
* never leak into logs; in 'verbose' mode the full JSON serialization is used.
|
|
81
|
+
*
|
|
82
|
+
* This mirrors {@link NotFoundError}'s redaction so that every "no row found"
|
|
83
|
+
* message in the library follows one convention, including the nested-write
|
|
84
|
+
* connect/update failures which historically embedded the raw values.
|
|
85
|
+
*/
|
|
86
|
+
function describeTargetForMessage(target) {
|
|
87
|
+
if (errorMessageMode === 'verbose') {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.stringify(target);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return '[unserializable]';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// safe mode: key names only
|
|
96
|
+
if (target === null || target === undefined || typeof target !== 'object') {
|
|
97
|
+
return 'keys []';
|
|
98
|
+
}
|
|
99
|
+
const keys = Object.keys(target);
|
|
100
|
+
return `keys [${keys.join(', ')}]`;
|
|
101
|
+
}
|
|
75
102
|
/**
|
|
76
103
|
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
77
104
|
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
package/dist/cjs/nested-write.js
CHANGED
|
@@ -39,7 +39,7 @@ function extractRelationFields(data, tableMeta) {
|
|
|
39
39
|
const scalars = {};
|
|
40
40
|
const relations = {};
|
|
41
41
|
for (const [key, value] of Object.entries(data)) {
|
|
42
|
-
if (
|
|
42
|
+
if (Object.hasOwn(tableMeta.relations, key) &&
|
|
43
43
|
value !== null &&
|
|
44
44
|
typeof value === 'object' &&
|
|
45
45
|
!Array.isArray(value) &&
|
|
@@ -59,7 +59,7 @@ function extractRelationFields(data, tableMeta) {
|
|
|
59
59
|
*/
|
|
60
60
|
function hasRelationFields(data, tableMeta) {
|
|
61
61
|
for (const key of Object.keys(data)) {
|
|
62
|
-
if (
|
|
62
|
+
if (Object.hasOwn(tableMeta.relations, key)) {
|
|
63
63
|
const val = data[key];
|
|
64
64
|
if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
|
|
65
65
|
return true;
|
|
@@ -214,7 +214,7 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
|
|
|
214
214
|
else {
|
|
215
215
|
parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
|
|
216
216
|
if (!parentRow) {
|
|
217
|
-
throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${
|
|
217
|
+
throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${(0, errors_js_1.describeTargetForMessage)(where)}.`);
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
// Process each relation
|
|
@@ -297,7 +297,7 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
297
297
|
if (items.length > 0) {
|
|
298
298
|
// Check if any items have nested relations (need per-row recursion)
|
|
299
299
|
const childTable = ctx.schema.tables[rel.to];
|
|
300
|
-
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) =>
|
|
300
|
+
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => Object.hasOwn(childTable.relations ?? {}, k)));
|
|
301
301
|
if (hasNested) {
|
|
302
302
|
// Per-row recursive create for items with nested relations
|
|
303
303
|
for (const item of items) {
|
|
@@ -357,7 +357,7 @@ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path
|
|
|
357
357
|
const target = items[0];
|
|
358
358
|
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
359
359
|
if (!relatedRow) {
|
|
360
|
-
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
360
|
+
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
}
|
|
@@ -412,7 +412,7 @@ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, dep
|
|
|
412
412
|
const target = items[0];
|
|
413
413
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
414
414
|
if (!existing) {
|
|
415
|
-
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
415
|
+
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
416
416
|
}
|
|
417
417
|
const updateData = {};
|
|
418
418
|
const relatedTable = ctx.schema.tables[rel.to];
|
|
@@ -442,7 +442,7 @@ async function batchConnect(ctx, rel, items, parentRow) {
|
|
|
442
442
|
for (const target of items) {
|
|
443
443
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
444
444
|
if (!existing) {
|
|
445
|
-
throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${
|
|
445
|
+
throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
448
|
// Build FK update data to point children at parent
|
|
@@ -57,6 +57,7 @@ exports.loadRelationsBatched = loadRelationsBatched;
|
|
|
57
57
|
const errors_js_1 = require("../errors.js");
|
|
58
58
|
const schema_js_1 = require("../schema.js");
|
|
59
59
|
const filters_js_1 = require("./filters.js");
|
|
60
|
+
const utils_js_1 = require("./utils.js");
|
|
60
61
|
/**
|
|
61
62
|
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
62
63
|
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
|
|
@@ -131,7 +132,7 @@ function neededParentKeyFields(parentMeta, withClause) {
|
|
|
131
132
|
}
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
|
-
const rel = parentMeta.relations
|
|
135
|
+
const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
|
|
135
136
|
if (!rel)
|
|
136
137
|
continue; // unknown relation — the join path throws; let the loader surface it
|
|
137
138
|
for (const col of localKeyColumns(rel)) {
|
|
@@ -169,7 +170,7 @@ function resolveCountRelations(parentMeta, countSpec) {
|
|
|
169
170
|
for (const [relName, enabled] of Object.entries(countSpec)) {
|
|
170
171
|
if (!enabled)
|
|
171
172
|
continue;
|
|
172
|
-
const rel = parentMeta.relations
|
|
173
|
+
const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
|
|
173
174
|
if (!rel) {
|
|
174
175
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
|
|
175
176
|
`Available: ${Object.keys(parentMeta.relations).join(', ')}`);
|
|
@@ -240,7 +241,7 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
|
|
|
240
241
|
loads.push(loadCounts(ctx, parents, spec));
|
|
241
242
|
continue;
|
|
242
243
|
}
|
|
243
|
-
const rel = ctx.parentMeta.relations
|
|
244
|
+
const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
|
|
244
245
|
if (!rel) {
|
|
245
246
|
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
|
|
246
247
|
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
|
|
@@ -892,7 +892,7 @@ class QueryInterface {
|
|
|
892
892
|
!whereObj.NOT &&
|
|
893
893
|
whereKeys.every((k) => {
|
|
894
894
|
const v = whereObj[k];
|
|
895
|
-
return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !this.tableMeta.relations
|
|
895
|
+
return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.ownLookup)(this.tableMeta.relations, k);
|
|
896
896
|
});
|
|
897
897
|
// Simple path: plain equality, no operators/null/OR
|
|
898
898
|
if (!args.with && isSimpleWhere) {
|
|
@@ -1131,7 +1131,7 @@ class QueryInterface {
|
|
|
1131
1131
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
1132
1132
|
const orderFp = args?.orderBy
|
|
1133
1133
|
? Object.entries(args.orderBy)
|
|
1134
|
-
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations
|
|
1134
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, (0, utils_js_1.ownLookup)(this.tableMeta.relations, k)?.to)}`)
|
|
1135
1135
|
.join(',')
|
|
1136
1136
|
: '';
|
|
1137
1137
|
const cursorFp = args?.cursor
|
|
@@ -1717,7 +1717,11 @@ class QueryInterface {
|
|
|
1717
1717
|
}
|
|
1718
1718
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
1719
1719
|
toColumn(field) {
|
|
1720
|
-
|
|
1720
|
+
// Prototype-safe lookup: a plain-object `columnMap` would otherwise return
|
|
1721
|
+
// an inherited member (e.g. Object.prototype.constructor) for a field named
|
|
1722
|
+
// "constructor" / "toString" / "__proto__", bypassing the unknown-field
|
|
1723
|
+
// check below and returning a non-string as the column name.
|
|
1724
|
+
const mapped = (0, utils_js_1.ownLookup)(this.tableMeta.columnMap, field);
|
|
1721
1725
|
if (mapped)
|
|
1722
1726
|
return mapped;
|
|
1723
1727
|
// Fall back to camelToSnake ONLY if that snake_cased name also exists as a
|
|
@@ -1727,7 +1731,7 @@ class QueryInterface {
|
|
|
1727
1731
|
// SQL injection and catching typos like `where: { emial: 'x' }` with a
|
|
1728
1732
|
// clear error instead of a cryptic Postgres "column does not exist".
|
|
1729
1733
|
const snake = (0, schema_js_1.camelToSnake)(field);
|
|
1730
|
-
if (this.tableMeta.reverseColumnMap
|
|
1734
|
+
if (this.tableMeta.reverseColumnMap && (0, utils_js_1.ownLookup)(this.tableMeta.reverseColumnMap, snake)) {
|
|
1731
1735
|
return snake;
|
|
1732
1736
|
}
|
|
1733
1737
|
if (this.tableMeta.allColumns?.includes(snake)) {
|
|
@@ -1822,7 +1826,7 @@ class QueryInterface {
|
|
|
1822
1826
|
// pick.where / pick.orderBy paths). To-one relation orderBy carries the
|
|
1823
1827
|
// target's global filter once per ordered column.
|
|
1824
1828
|
if (this.isRelationOrderByValue(dir)) {
|
|
1825
|
-
const relDef = this.tableMeta.relations
|
|
1829
|
+
const relDef = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
|
|
1826
1830
|
if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
|
|
1827
1831
|
this.collectRelationPickOrderParams(key, relDef, dir, params);
|
|
1828
1832
|
}
|