@stonyx/orm 0.3.2-alpha.87 → 0.3.2-alpha.88
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 +21 -8
- package/dist/orm-request.js +69 -10
- package/package.json +1 -1
- package/src/orm-request.ts +74 -10
package/README.md
CHANGED
|
@@ -1117,6 +1117,27 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1117
1117
|
`404` on the `belongsTo` shape and did contradict this paragraph; that is
|
|
1118
1118
|
measured and closed in the #232 bullet above.
|
|
1119
1119
|
|
|
1120
|
+
**[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1121
|
+
related resource appears in `included` at all, and that question is now
|
|
1122
|
+
answered too.** #235 filters what a record *already in* `included` may
|
|
1123
|
+
**name**; #233 decides **membership**. They remain different questions
|
|
1124
|
+
answered by different mechanisms and neither closes the other — a resource
|
|
1125
|
+
can legitimately be a member while its own linkage is filtered. A related
|
|
1126
|
+
resource is judged by **its own** model's access class at the traversal's
|
|
1127
|
+
push site, so a record hidden on its own route is not a member, and **the
|
|
1128
|
+
subtree beneath it is never traversed**: dropping a parent *after* descending
|
|
1129
|
+
through it would publish that parent's exact child set. Measured on
|
|
1130
|
+
`dev @ c106cf9`, `GET /animals/1?include=owner,owner.pets` returned nine
|
|
1131
|
+
resources — the hidden owner plus her eight animals
|
|
1132
|
+
`[1, 3, 7, 10, 11, 15, 17, 20]`, which *is* her `pets` array, reconstructed
|
|
1133
|
+
for a caller who is `404` on the parent. It now returns no `included` array
|
|
1134
|
+
at all. A model **no access class claims** (`getAccess()` → `undefined`) is
|
|
1135
|
+
denied on this path too, so a collection the consumer never exposed is not
|
|
1136
|
+
reachable as a sideloaded resource either. **Drop, never error:** a pruned
|
|
1137
|
+
sideload is byte-identical to a genuinely empty one — same `200`, the same
|
|
1138
|
+
top-level keys, no `included` member on either, no `errors` — so its absence
|
|
1139
|
+
carries no signal about whether the record exists.
|
|
1140
|
+
|
|
1120
1141
|
**That resolves the right class; it does not guarantee a model-correct
|
|
1121
1142
|
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
1122
1143
|
*reads* `context.model` can answer about the model it was asked about — see
|
|
@@ -1157,14 +1178,6 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1157
1178
|
[#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
|
|
1158
1179
|
this entry**; if it has landed, this route is covered and the bullet #247
|
|
1159
1180
|
adds above supersedes this one.
|
|
1160
|
-
- **Whether a related resource appears in `included` at all.**
|
|
1161
|
-
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1162
|
-
related resource appears in `included`. #235 filters what a record
|
|
1163
|
-
*already in* `included` may **name**; a hidden record is still a
|
|
1164
|
-
**member** of that array. The two are different questions and neither closes
|
|
1165
|
-
the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
|
|
1166
|
-
`owner.data: null` on every permitted animal it sideloads **and still
|
|
1167
|
-
includes the hidden owner as a resource**.
|
|
1168
1181
|
- **A computed attribute that interpolates a related record's id.**
|
|
1169
1182
|
[#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
|
|
1170
1183
|
and **it is open as this is written**. `relationships.*.data` is a structure
|
package/dist/orm-request.js
CHANGED
|
@@ -445,17 +445,27 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
445
445
|
const includes = parseInclude(includeParam);
|
|
446
446
|
if (includes.length === 0)
|
|
447
447
|
return response;
|
|
448
|
-
|
|
448
|
+
// THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
|
|
449
|
+
// BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
|
|
450
|
+
// per-(type, id) decision cache, so the traversal below and the `toJSON`
|
|
451
|
+
// calls beneath it share one resolution of the consumer's `access()` per
|
|
452
|
+
// type for the whole response. Building a second filter here would double
|
|
453
|
+
// every predicate call and, worse, could answer the two questions
|
|
454
|
+
// differently about the same record.
|
|
455
|
+
const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
|
|
449
456
|
if (includedRecords.length > 0) {
|
|
450
457
|
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
451
458
|
// line is one story's and the line above it is another's
|
|
452
459
|
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
453
460
|
//
|
|
454
461
|
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
455
|
-
// `collectIncludedRecords` on the line above. That is MEMBERSHIP
|
|
456
|
-
// #233's
|
|
457
|
-
//
|
|
458
|
-
//
|
|
462
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP and
|
|
463
|
+
// it is #233's. As of #233 that call is given the SAME `linkage`
|
|
464
|
+
// filter, so a hidden owner is no longer a member: she is dropped at
|
|
465
|
+
// the push site and her subtree is never traversed. Pinned by
|
|
466
|
+
// `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
|
|
467
|
+
// `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
|
|
468
|
+
// same test.
|
|
459
469
|
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
460
470
|
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
461
471
|
// for the primary document -- and that is what the `linkage` option
|
|
@@ -498,9 +508,45 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
498
508
|
return response;
|
|
499
509
|
}
|
|
500
510
|
/**
|
|
501
|
-
* Recursively traverse an include path and collect related records
|
|
511
|
+
* Recursively traverse an include path and collect related records.
|
|
512
|
+
*
|
|
513
|
+
* ---------------------------------------------------------------------------
|
|
514
|
+
* THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
|
|
515
|
+
* ---------------------------------------------------------------------------
|
|
516
|
+
* A resource reaches `included` because some record NAMED it, and until #233
|
|
517
|
+
* being named was the whole test. That made `?include=` a restoration of every
|
|
518
|
+
* record the read surfaces withhold: `GET /owners/angela` is 404 and
|
|
519
|
+
* `GET /animals/1?include=owner` returned her document in full, attributes and
|
|
520
|
+
* all. Measured on dev @ 8dda5d6, over the live router.
|
|
521
|
+
*
|
|
522
|
+
* FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
|
|
523
|
+
* let the traversal run and filter `collectIncludedRecords`' RETURN value --
|
|
524
|
+
* closes the membership half and leaves the worse half open: dropping a parent
|
|
525
|
+
* AFTER traversing through it publishes that parent's exact child set. On this
|
|
526
|
+
* repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
|
|
527
|
+
* eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
|
|
528
|
+
* reconstructed from a resource the caller may not read. So a denied record is
|
|
529
|
+
* `continue`d before it is pushed to `included` AND before it is pushed to
|
|
530
|
+
* `nextRecords`, which is what prunes the subtree.
|
|
531
|
+
*
|
|
532
|
+
* A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
|
|
533
|
+
* deduplicator for records that DID enter `included`; putting a denial in it
|
|
534
|
+
* would conflate "already emitted" with "withheld", and the `else if` branch
|
|
535
|
+
* below would then push a denied record into `nextRecords` for deeper
|
|
536
|
+
* traversal -- re-opening the prune this function just closed. Re-asking is
|
|
537
|
+
* free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
|
|
538
|
+
* hit and not a call into the consumer's `access()`.
|
|
539
|
+
*
|
|
540
|
+
* ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
|
|
541
|
+
* for the same reason it is optional on `buildResponse` and on
|
|
542
|
+
* `Record.toJSON`: an absent option means "no verdict was supplied", and the
|
|
543
|
+
* honest degradation is the document that shipped before, not an empty one.
|
|
544
|
+
* Both of `buildResponse`'s callers -- `getCollectionHandler` and
|
|
545
|
+
* `getSingleHandler`, the only two -- supply it, which is pinned by
|
|
546
|
+
* `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
|
|
547
|
+
* guard below is the fail-closed reading of one.
|
|
502
548
|
*/
|
|
503
|
-
function traverseIncludePath(currentRecords, includePath, depth, seen, included) {
|
|
549
|
+
function traverseIncludePath(currentRecords, includePath, depth, seen, included, linkage) {
|
|
504
550
|
if (depth >= includePath.length)
|
|
505
551
|
return; // Reached end of path
|
|
506
552
|
const relationshipName = includePath[depth];
|
|
@@ -524,6 +570,19 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
524
570
|
continue;
|
|
525
571
|
const type = relatedRecord.__model.__name;
|
|
526
572
|
const id = relatedRecord.id;
|
|
573
|
+
// MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
|
|
574
|
+
// pushes below -- the record does not enter `included` and it does not
|
|
575
|
+
// become a parent at the next depth.
|
|
576
|
+
//
|
|
577
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
|
|
578
|
+
// #232's `isLinkable` uses on the relationship routes: `type` is the key
|
|
579
|
+
// the verdict is resolved under, so a missing or empty one means there
|
|
580
|
+
// is no predicate to ask and no way to ask it. Denying is the only safe
|
|
581
|
+
// answer, and it is only reachable while a filter is in force -- with no
|
|
582
|
+
// filter this whole check is skipped and the pre-#233 document is
|
|
583
|
+
// emitted unchanged.
|
|
584
|
+
if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord)))
|
|
585
|
+
continue;
|
|
527
586
|
// Initialize Set for this type if needed
|
|
528
587
|
let seenIds = seen.get(type);
|
|
529
588
|
if (!seenIds) {
|
|
@@ -544,10 +603,10 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
544
603
|
}
|
|
545
604
|
// If there are more segments in the path, recursively process
|
|
546
605
|
if (depth < includePath.length - 1 && nextRecords.length > 0) {
|
|
547
|
-
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
|
|
606
|
+
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
|
|
548
607
|
}
|
|
549
608
|
}
|
|
550
|
-
function collectIncludedRecords(data, includes) {
|
|
609
|
+
function collectIncludedRecords(data, includes, linkage) {
|
|
551
610
|
if (!includes || includes.length === 0)
|
|
552
611
|
return [];
|
|
553
612
|
if (!data)
|
|
@@ -558,7 +617,7 @@ function collectIncludedRecords(data, includes) {
|
|
|
558
617
|
const records = Array.isArray(data) ? data : [data];
|
|
559
618
|
// Process each include path
|
|
560
619
|
for (const includePath of includes) {
|
|
561
|
-
traverseIncludePath(records, includePath, 0, seen, included);
|
|
620
|
+
traverseIncludePath(records, includePath, 0, seen, included, linkage);
|
|
562
621
|
}
|
|
563
622
|
return included;
|
|
564
623
|
}
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -490,17 +490,27 @@ function buildResponse(
|
|
|
490
490
|
const includes = parseInclude(includeParam);
|
|
491
491
|
if (includes.length === 0) return response;
|
|
492
492
|
|
|
493
|
-
|
|
493
|
+
// THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
|
|
494
|
+
// BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
|
|
495
|
+
// per-(type, id) decision cache, so the traversal below and the `toJSON`
|
|
496
|
+
// calls beneath it share one resolution of the consumer's `access()` per
|
|
497
|
+
// type for the whole response. Building a second filter here would double
|
|
498
|
+
// every predicate call and, worse, could answer the two questions
|
|
499
|
+
// differently about the same record.
|
|
500
|
+
const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
|
|
494
501
|
if (includedRecords.length > 0) {
|
|
495
502
|
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
496
503
|
// line is one story's and the line above it is another's
|
|
497
504
|
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
498
505
|
//
|
|
499
506
|
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
500
|
-
// `collectIncludedRecords` on the line above. That is MEMBERSHIP
|
|
501
|
-
// #233's
|
|
502
|
-
//
|
|
503
|
-
//
|
|
507
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP and
|
|
508
|
+
// it is #233's. As of #233 that call is given the SAME `linkage`
|
|
509
|
+
// filter, so a hidden owner is no longer a member: she is dropped at
|
|
510
|
+
// the push site and her subtree is never traversed. Pinned by
|
|
511
|
+
// `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
|
|
512
|
+
// `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
|
|
513
|
+
// same test.
|
|
504
514
|
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
505
515
|
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
506
516
|
// for the primary document -- and that is what the `linkage` option
|
|
@@ -545,14 +555,51 @@ function buildResponse(
|
|
|
545
555
|
}
|
|
546
556
|
|
|
547
557
|
/**
|
|
548
|
-
* Recursively traverse an include path and collect related records
|
|
558
|
+
* Recursively traverse an include path and collect related records.
|
|
559
|
+
*
|
|
560
|
+
* ---------------------------------------------------------------------------
|
|
561
|
+
* THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
|
|
562
|
+
* ---------------------------------------------------------------------------
|
|
563
|
+
* A resource reaches `included` because some record NAMED it, and until #233
|
|
564
|
+
* being named was the whole test. That made `?include=` a restoration of every
|
|
565
|
+
* record the read surfaces withhold: `GET /owners/angela` is 404 and
|
|
566
|
+
* `GET /animals/1?include=owner` returned her document in full, attributes and
|
|
567
|
+
* all. Measured on dev @ 8dda5d6, over the live router.
|
|
568
|
+
*
|
|
569
|
+
* FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
|
|
570
|
+
* let the traversal run and filter `collectIncludedRecords`' RETURN value --
|
|
571
|
+
* closes the membership half and leaves the worse half open: dropping a parent
|
|
572
|
+
* AFTER traversing through it publishes that parent's exact child set. On this
|
|
573
|
+
* repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
|
|
574
|
+
* eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
|
|
575
|
+
* reconstructed from a resource the caller may not read. So a denied record is
|
|
576
|
+
* `continue`d before it is pushed to `included` AND before it is pushed to
|
|
577
|
+
* `nextRecords`, which is what prunes the subtree.
|
|
578
|
+
*
|
|
579
|
+
* A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
|
|
580
|
+
* deduplicator for records that DID enter `included`; putting a denial in it
|
|
581
|
+
* would conflate "already emitted" with "withheld", and the `else if` branch
|
|
582
|
+
* below would then push a denied record into `nextRecords` for deeper
|
|
583
|
+
* traversal -- re-opening the prune this function just closed. Re-asking is
|
|
584
|
+
* free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
|
|
585
|
+
* hit and not a call into the consumer's `access()`.
|
|
586
|
+
*
|
|
587
|
+
* ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
|
|
588
|
+
* for the same reason it is optional on `buildResponse` and on
|
|
589
|
+
* `Record.toJSON`: an absent option means "no verdict was supplied", and the
|
|
590
|
+
* honest degradation is the document that shipped before, not an empty one.
|
|
591
|
+
* Both of `buildResponse`'s callers -- `getCollectionHandler` and
|
|
592
|
+
* `getSingleHandler`, the only two -- supply it, which is pinned by
|
|
593
|
+
* `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
|
|
594
|
+
* guard below is the fail-closed reading of one.
|
|
549
595
|
*/
|
|
550
596
|
function traverseIncludePath(
|
|
551
597
|
currentRecords: OrmRecord[],
|
|
552
598
|
includePath: string[],
|
|
553
599
|
depth: number,
|
|
554
600
|
seen: Map<string, Set<string | number>>,
|
|
555
|
-
included: OrmRecord[]
|
|
601
|
+
included: OrmRecord[],
|
|
602
|
+
linkage?: LinkageFilter
|
|
556
603
|
): void {
|
|
557
604
|
if (depth >= includePath.length) return; // Reached end of path
|
|
558
605
|
|
|
@@ -578,6 +625,19 @@ function traverseIncludePath(
|
|
|
578
625
|
const type = relatedRecord.__model.__name;
|
|
579
626
|
const id = relatedRecord.id as string | number;
|
|
580
627
|
|
|
628
|
+
// MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
|
|
629
|
+
// pushes below -- the record does not enter `included` and it does not
|
|
630
|
+
// become a parent at the next depth.
|
|
631
|
+
//
|
|
632
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
|
|
633
|
+
// #232's `isLinkable` uses on the relationship routes: `type` is the key
|
|
634
|
+
// the verdict is resolved under, so a missing or empty one means there
|
|
635
|
+
// is no predicate to ask and no way to ask it. Denying is the only safe
|
|
636
|
+
// answer, and it is only reachable while a filter is in force -- with no
|
|
637
|
+
// filter this whole check is skipped and the pre-#233 document is
|
|
638
|
+
// emitted unchanged.
|
|
639
|
+
if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord))) continue;
|
|
640
|
+
|
|
581
641
|
// Initialize Set for this type if needed
|
|
582
642
|
let seenIds = seen.get(type);
|
|
583
643
|
if (!seenIds) {
|
|
@@ -599,11 +659,15 @@ function traverseIncludePath(
|
|
|
599
659
|
|
|
600
660
|
// If there are more segments in the path, recursively process
|
|
601
661
|
if (depth < includePath.length - 1 && nextRecords.length > 0) {
|
|
602
|
-
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
|
|
662
|
+
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
|
|
603
663
|
}
|
|
604
664
|
}
|
|
605
665
|
|
|
606
|
-
function collectIncludedRecords(
|
|
666
|
+
function collectIncludedRecords(
|
|
667
|
+
data: OrmRecord | OrmRecord[],
|
|
668
|
+
includes: string[][],
|
|
669
|
+
linkage?: LinkageFilter
|
|
670
|
+
): OrmRecord[] {
|
|
607
671
|
if (!includes || includes.length === 0) return [];
|
|
608
672
|
if (!data) return [];
|
|
609
673
|
|
|
@@ -615,7 +679,7 @@ function collectIncludedRecords(data: OrmRecord | OrmRecord[], includes: string[
|
|
|
615
679
|
|
|
616
680
|
// Process each include path
|
|
617
681
|
for (const includePath of includes) {
|
|
618
|
-
traverseIncludePath(records, includePath, 0, seen, included);
|
|
682
|
+
traverseIncludePath(records, includePath, 0, seen, included, linkage);
|
|
619
683
|
}
|
|
620
684
|
|
|
621
685
|
return included;
|