@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 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
@@ -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
- const includedRecords = collectIncludedRecords(recordOrRecords, includes);
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, it is
456
- // #233's, and it is deliberately untouched here: a hidden owner is
457
- // still a member of `included` after this change. Pinned green by
458
- // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
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
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.87",
7
+ "version": "0.3.2-alpha.88",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -490,17 +490,27 @@ function buildResponse(
490
490
  const includes = parseInclude(includeParam);
491
491
  if (includes.length === 0) return response;
492
492
 
493
- const includedRecords = collectIncludedRecords(recordOrRecords, includes);
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, it is
501
- // #233's, and it is deliberately untouched here: a hidden owner is
502
- // still a member of `included` after this change. Pinned green by
503
- // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
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(data: OrmRecord | OrmRecord[], includes: string[][]): OrmRecord[] {
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;