@stonyx/orm 0.3.2-alpha.87 → 0.3.2-alpha.89
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 +112 -32
- package/dist/orm-request.d.ts +12 -2
- package/dist/orm-request.js +91 -16
- package/package.json +1 -1
- package/src/orm-request.ts +96 -16
package/README.md
CHANGED
|
@@ -985,10 +985,13 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
985
985
|
"indistinguishable" as a property of the response — it is a property of the
|
|
986
986
|
relationship member alone. The other channels in the same class are
|
|
987
987
|
[#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
|
|
988
|
-
which is the one measured above)
|
|
989
|
-
[#
|
|
990
|
-
|
|
991
|
-
|
|
988
|
+
which is the one measured above) and
|
|
989
|
+
[#246](https://github.com/abofs/stonyx-orm/issues/246) (the
|
|
990
|
+
`attributes.<fk>` echo of an unresolved `belongsTo` target) — **both still
|
|
991
|
+
open**. `included` membership was the third,
|
|
992
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233), and it is **closed**:
|
|
993
|
+
a related record its own model's access class rejects is dropped at the
|
|
994
|
+
traversal's push site and is no longer a member. **Audit your
|
|
992
995
|
computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
|
|
993
996
|
status on these routes belongs to the **parent**, and `data` carries the
|
|
994
997
|
answer about the related record. The `/relationships/` family built its `{type, id}` by
|
|
@@ -1046,24 +1049,35 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1046
1049
|
true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
|
|
1047
1050
|
related-resource route.
|
|
1048
1051
|
|
|
1049
|
-
- **`?include=` records are
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1052
|
+
- **`?include=` records are filtered on both questions now, and so are the
|
|
1053
|
+
relationship routes.** *Re-specified twice, each time by the story that
|
|
1054
|
+
falsified it, and recorded here rather than deleted. The original said all
|
|
1055
|
+
three surfaces were unfiltered.
|
|
1056
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) made two of them
|
|
1057
|
+
filtered and the bullet became "**`?include=` records are still not
|
|
1058
|
+
filtered — the relationship routes now are**", with the body "**`?include=owner`
|
|
1059
|
+
still does not**: it serializes the related record without resolving that
|
|
1060
|
+
class, so a filter on `/owners` does not hide an owner reached through
|
|
1061
|
+
`?include=` on `/animals`."
|
|
1062
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) falsified that half
|
|
1063
|
+
too.* `GET /animals/1/owner` and
|
|
1053
1064
|
`GET /animals/1/relationships/owner` resolve the related model's own access
|
|
1054
|
-
class (see the bullet above). **`?include=owner`
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1065
|
+
class (see the bullet above). **`?include=owner` now resolves it as well**,
|
|
1066
|
+
at the traversal's push site, so a filter on `/owners` *does* hide an owner
|
|
1067
|
+
reached through `?include=` on `/animals`: measured on this branch,
|
|
1068
|
+
`GET /animals/1?include=owner` answers `200` with **no `included` array at
|
|
1069
|
+
all**, where before it served the hidden owner's full document.
|
|
1070
|
+
There were **two** questions here and they were owned separately — #233, the
|
|
1071
|
+
remaining child of
|
|
1059
1072
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
|
|
1060
1073
|
resource enters `included` **at all** (membership), and
|
|
1061
1074
|
[#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
|
|
1062
1075
|
`relationships.*.data` emitted **inside** a record that is already there
|
|
1063
|
-
(linkage).
|
|
1064
|
-
|
|
1065
|
-
a
|
|
1066
|
-
|
|
1076
|
+
(linkage). **Both have now landed, and neither closed the other** — they are
|
|
1077
|
+
still answered by different mechanisms at different sites, and a resource can
|
|
1078
|
+
legitimately be a member while its own linkage is filtered. Membership —
|
|
1079
|
+
whether the related resource is served at all — remains a different question
|
|
1080
|
+
from which ids a document may *name*, immediately below.
|
|
1067
1081
|
- **Relationship linkage is filtered on every request-bound surface that
|
|
1068
1082
|
serializes a record — the reads, the two writes, and `included`.** A
|
|
1069
1083
|
document's `relationships.*.data` used to publish the id of every related
|
|
@@ -1117,6 +1131,59 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1117
1131
|
`404` on the `belongsTo` shape and did contradict this paragraph; that is
|
|
1118
1132
|
measured and closed in the #232 bullet above.
|
|
1119
1133
|
|
|
1134
|
+
**[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1135
|
+
related resource appears in `included` at all, and that question is now
|
|
1136
|
+
answered too.** #235 filters what a record *already in* `included` may
|
|
1137
|
+
**name**; #233 decides **membership**. They remain different questions
|
|
1138
|
+
answered by different mechanisms and neither closes the other — a resource
|
|
1139
|
+
can legitimately be a member while its own linkage is filtered. A related
|
|
1140
|
+
resource is judged by **its own** model's access class at the traversal's
|
|
1141
|
+
push site, so a record that class's **per-record filter** rejects is not a
|
|
1142
|
+
member, and **the
|
|
1143
|
+
subtree beneath it is never traversed**: dropping a parent *after* descending
|
|
1144
|
+
through it would publish that parent's exact child set. Measured on
|
|
1145
|
+
`dev @ c106cf9`, `GET /animals/1?include=owner,owner.pets` returned nine
|
|
1146
|
+
resources — the hidden owner plus her eight animals
|
|
1147
|
+
`[1, 3, 7, 10, 11, 15, 17, 20]`, which *is* her `pets` array, reconstructed
|
|
1148
|
+
for a caller who is `404` on the parent. It now returns no `included` array
|
|
1149
|
+
at all. A model **no access class claims** (`getAccess()` → `undefined`) is
|
|
1150
|
+
denied on this path too, so a collection the consumer never exposed is not
|
|
1151
|
+
reachable as a sideloaded resource either. **Drop, never error:** a pruned
|
|
1152
|
+
sideload is byte-identical to a genuinely empty one — same `200`, the same
|
|
1153
|
+
top-level keys, no `included` member on either, no `errors` — so its absence
|
|
1154
|
+
carries no signal about whether the record exists.
|
|
1155
|
+
|
|
1156
|
+
**Read "per-record filter" literally — it is not "everything the
|
|
1157
|
+
record-addressed route refuses", and the difference is measurable.** The
|
|
1158
|
+
linkage ask carries `recordId: null`, so a deny expressed as a
|
|
1159
|
+
request-scoped `return false` cannot fire on this path at all. The shipped
|
|
1160
|
+
sample expresses the `archived` owner's deny that way, and measured
|
|
1161
|
+
unauthenticated on this branch — byte-identically on `dev @ b23cfec`, so this
|
|
1162
|
+
is not a regression this change introduces —
|
|
1163
|
+
`GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
|
|
1164
|
+
answers `200` with her full document. That is
|
|
1165
|
+
[#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, and #243
|
|
1166
|
+
records it on `GET /owners` only; it reaches `included` membership and the
|
|
1167
|
+
`#232` related-resource route the same way. **#233 does not close it, and a
|
|
1168
|
+
consumer who needs `?include=` to honour a per-record deny must express that
|
|
1169
|
+
deny as a filter.**
|
|
1170
|
+
|
|
1171
|
+
**And read "carries no signal" as a property of the `included` member, the
|
|
1172
|
+
same way [Known limitations](#known-limitations) already asks you to read
|
|
1173
|
+
"indistinguishable" — not as a property of the whole response.** The prune is
|
|
1174
|
+
unobservable in `included`; the *rest* of the document is a separate
|
|
1175
|
+
question, and the same computed-and-serialized-attribute channels recorded
|
|
1176
|
+
above still apply to it. Measured counter-example on this very fixture pair:
|
|
1177
|
+
`GET /traits/2?include=tag` prunes the unclaimed `tag` from `included` and
|
|
1178
|
+
still serves `attributes.tag: "never-mounted"`, while `GET /traits/1` has no
|
|
1179
|
+
`tag` key at all — an existence oracle in `attributes`, from
|
|
1180
|
+
`src/serializer.ts` echoing the raw foreign key of a `belongsTo` target that
|
|
1181
|
+
did not resolve. That is
|
|
1182
|
+
[#246](https://github.com/abofs/stonyx-orm/issues/246)'s mechanism reached on
|
|
1183
|
+
a **read**, with [#248](https://github.com/abofs/stonyx-orm/issues/248) as
|
|
1184
|
+
its precondition — **audit your attributes before you treat a pruned
|
|
1185
|
+
sideload as unobservable.**
|
|
1186
|
+
|
|
1120
1187
|
**That resolves the right class; it does not guarantee a model-correct
|
|
1121
1188
|
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
1122
1189
|
*reads* `context.model` can answer about the model it was asked about — see
|
|
@@ -1157,14 +1224,6 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1157
1224
|
[#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
|
|
1158
1225
|
this entry**; if it has landed, this route is covered and the bullet #247
|
|
1159
1226
|
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
1227
|
- **A computed attribute that interpolates a related record's id.**
|
|
1169
1228
|
[#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
|
|
1170
1229
|
and **it is open as this is written**. `relationships.*.data` is a structure
|
|
@@ -1720,20 +1779,41 @@ GET /animals/1
|
|
|
1720
1779
|
2. Recursively traverses relationships depth-first
|
|
1721
1780
|
3. Deduplication still by type+id (no duplicates in included array)
|
|
1722
1781
|
4. Gracefully handles null/missing relationships at any depth
|
|
1723
|
-
5. Each
|
|
1782
|
+
5. Each related record is judged by **its own** model's access class at the
|
|
1783
|
+
push site before it is added, so a denied record neither enters `included`
|
|
1784
|
+
nor becomes a parent at the next depth
|
|
1785
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)) — see the
|
|
1786
|
+
Limitations below for what "denied" does and does not cover
|
|
1787
|
+
6. Each included record gets full `toJSON()` representation, with its
|
|
1788
|
+
**linkage filtered** by the same verdict object the primary document was
|
|
1789
|
+
serialized with ([#235](https://github.com/abofs/stonyx-orm/issues/235))
|
|
1724
1790
|
|
|
1725
1791
|
#### Limitations
|
|
1726
1792
|
|
|
1727
1793
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1728
|
-
- **`included` is access-filtered on
|
|
1729
|
-
|
|
1794
|
+
- **`included` is access-filtered on both of the two questions.** *Re-specified
|
|
1795
|
+
by [#233](https://github.com/abofs/stonyx-orm/issues/233). The sentence this
|
|
1796
|
+
replaces said membership "is still unfiltered (#233): a record that is 404 on
|
|
1797
|
+
its own routes is still served as an `included` resource, attributes and
|
|
1798
|
+
all."* What a record already in `included` may **name** in its own
|
|
1730
1799
|
`relationships.*.data` is filtered
|
|
1731
1800
|
([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
|
|
1732
1801
|
longer republishes ids the primary document withholds. Whether a resource
|
|
1733
|
-
appears in `included` **at all** is *membership
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
and
|
|
1802
|
+
appears in `included` **at all** is *membership*, and #233 filters it: a
|
|
1803
|
+
related resource is judged by **its own** model's access class at the
|
|
1804
|
+
traversal's push site, so a record that class's **per-record filter** rejects
|
|
1805
|
+
is not a member, and the subtree beneath it is never traversed.
|
|
1806
|
+
- **Membership is filtered by the per-record filter, not by everything a
|
|
1807
|
+
record-addressed route refuses.** A deny expressed as a request-scoped
|
|
1808
|
+
`return false` — the shape the shipped sample uses for the `archived`
|
|
1809
|
+
owner — is **not** expressible on this path, because the linkage ask carries
|
|
1810
|
+
`recordId: null`. Measured on this branch and byte-identically on `dev`:
|
|
1811
|
+
`GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
|
|
1812
|
+
serves her document in full. That is
|
|
1813
|
+
[#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, not
|
|
1814
|
+
#233's, and it reaches every linkage surface rather than only `GET /owners`.
|
|
1815
|
+
Express a per-record deny as a **filter** if you need `?include=` to honour
|
|
1816
|
+
it. See [Consumer Contracts](#consumer-contracts).
|
|
1737
1817
|
|
|
1738
1818
|
## Lifecycle Hooks
|
|
1739
1819
|
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -76,8 +76,18 @@
|
|
|
76
76
|
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
77
|
* `null` there and `request.params` names a record of a different model. See
|
|
78
78
|
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
-
* that limit.
|
|
80
|
-
*
|
|
79
|
+
* that limit.
|
|
80
|
+
*
|
|
81
|
+
* AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
|
|
82
|
+
* read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
|
|
83
|
+
* Both have landed. #235 filters what a record already in `included` may NAME,
|
|
84
|
+
* and #233 filters MEMBERSHIP at the traversal's push site -- see
|
|
85
|
+
* `traverseIncludePath` below. The ask is the same shape as the
|
|
86
|
+
* related-resource one and carries the same limit: `recordId` is `null`, so a
|
|
87
|
+
* deny expressible only as a request-scoped `return false` -- which is how the
|
|
88
|
+
* shipped sample spells `/archived` -- still cannot fire on this path. That
|
|
89
|
+
* residual is abofs/stonyx-orm#243's, not #233's, and it is measured
|
|
90
|
+
* byte-identical on `dev`.
|
|
81
91
|
*
|
|
82
92
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
83
93
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
package/dist/orm-request.js
CHANGED
|
@@ -76,8 +76,18 @@
|
|
|
76
76
|
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
77
|
* `null` there and `request.params` names a record of a different model. See
|
|
78
78
|
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
-
* that limit.
|
|
80
|
-
*
|
|
79
|
+
* that limit.
|
|
80
|
+
*
|
|
81
|
+
* AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
|
|
82
|
+
* read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
|
|
83
|
+
* Both have landed. #235 filters what a record already in `included` may NAME,
|
|
84
|
+
* and #233 filters MEMBERSHIP at the traversal's push site -- see
|
|
85
|
+
* `traverseIncludePath` below. The ask is the same shape as the
|
|
86
|
+
* related-resource one and carries the same limit: `recordId` is `null`, so a
|
|
87
|
+
* deny expressible only as a request-scoped `return false` -- which is how the
|
|
88
|
+
* shipped sample spells `/archived` -- still cannot fire on this path. That
|
|
89
|
+
* residual is abofs/stonyx-orm#243's, not #233's, and it is measured
|
|
90
|
+
* byte-identical on `dev`.
|
|
81
91
|
*
|
|
82
92
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
83
93
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
@@ -445,17 +455,27 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
445
455
|
const includes = parseInclude(includeParam);
|
|
446
456
|
if (includes.length === 0)
|
|
447
457
|
return response;
|
|
448
|
-
|
|
458
|
+
// THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
|
|
459
|
+
// BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
|
|
460
|
+
// per-(type, id) decision cache, so the traversal below and the `toJSON`
|
|
461
|
+
// calls beneath it share one resolution of the consumer's `access()` per
|
|
462
|
+
// type for the whole response. Building a second filter here would double
|
|
463
|
+
// every predicate call and, worse, could answer the two questions
|
|
464
|
+
// differently about the same record.
|
|
465
|
+
const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
|
|
449
466
|
if (includedRecords.length > 0) {
|
|
450
467
|
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
451
468
|
// line is one story's and the line above it is another's
|
|
452
469
|
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
453
470
|
//
|
|
454
471
|
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
455
|
-
// `collectIncludedRecords` on the line above. That is MEMBERSHIP
|
|
456
|
-
// #233's
|
|
457
|
-
//
|
|
458
|
-
//
|
|
472
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP and
|
|
473
|
+
// it is #233's. As of #233 that call is given the SAME `linkage`
|
|
474
|
+
// filter, so a hidden owner is no longer a member: she is dropped at
|
|
475
|
+
// the push site and her subtree is never traversed. Pinned by
|
|
476
|
+
// `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
|
|
477
|
+
// `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
|
|
478
|
+
// same test.
|
|
459
479
|
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
460
480
|
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
461
481
|
// for the primary document -- and that is what the `linkage` option
|
|
@@ -498,9 +518,45 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
498
518
|
return response;
|
|
499
519
|
}
|
|
500
520
|
/**
|
|
501
|
-
* Recursively traverse an include path and collect related records
|
|
521
|
+
* Recursively traverse an include path and collect related records.
|
|
522
|
+
*
|
|
523
|
+
* ---------------------------------------------------------------------------
|
|
524
|
+
* THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
|
|
525
|
+
* ---------------------------------------------------------------------------
|
|
526
|
+
* A resource reaches `included` because some record NAMED it, and until #233
|
|
527
|
+
* being named was the whole test. That made `?include=` a restoration of every
|
|
528
|
+
* record the read surfaces withhold: `GET /owners/angela` is 404 and
|
|
529
|
+
* `GET /animals/1?include=owner` returned her document in full, attributes and
|
|
530
|
+
* all. Measured on dev @ 8dda5d6, over the live router.
|
|
531
|
+
*
|
|
532
|
+
* FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
|
|
533
|
+
* let the traversal run and filter `collectIncludedRecords`' RETURN value --
|
|
534
|
+
* closes the membership half and leaves the worse half open: dropping a parent
|
|
535
|
+
* AFTER traversing through it publishes that parent's exact child set. On this
|
|
536
|
+
* repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
|
|
537
|
+
* eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
|
|
538
|
+
* reconstructed from a resource the caller may not read. So a denied record is
|
|
539
|
+
* `continue`d before it is pushed to `included` AND before it is pushed to
|
|
540
|
+
* `nextRecords`, which is what prunes the subtree.
|
|
541
|
+
*
|
|
542
|
+
* A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
|
|
543
|
+
* deduplicator for records that DID enter `included`; putting a denial in it
|
|
544
|
+
* would conflate "already emitted" with "withheld", and the `else if` branch
|
|
545
|
+
* below would then push a denied record into `nextRecords` for deeper
|
|
546
|
+
* traversal -- re-opening the prune this function just closed. Re-asking is
|
|
547
|
+
* free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
|
|
548
|
+
* hit and not a call into the consumer's `access()`.
|
|
549
|
+
*
|
|
550
|
+
* ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
|
|
551
|
+
* for the same reason it is optional on `buildResponse` and on
|
|
552
|
+
* `Record.toJSON`: an absent option means "no verdict was supplied", and the
|
|
553
|
+
* honest degradation is the document that shipped before, not an empty one.
|
|
554
|
+
* Both of `buildResponse`'s callers -- `getCollectionHandler` and
|
|
555
|
+
* `getSingleHandler`, the only two -- supply it, which is pinned by
|
|
556
|
+
* `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
|
|
557
|
+
* guard below is the fail-closed reading of one.
|
|
502
558
|
*/
|
|
503
|
-
function traverseIncludePath(currentRecords, includePath, depth, seen, included) {
|
|
559
|
+
function traverseIncludePath(currentRecords, includePath, depth, seen, included, linkage) {
|
|
504
560
|
if (depth >= includePath.length)
|
|
505
561
|
return; // Reached end of path
|
|
506
562
|
const relationshipName = includePath[depth];
|
|
@@ -524,6 +580,19 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
524
580
|
continue;
|
|
525
581
|
const type = relatedRecord.__model.__name;
|
|
526
582
|
const id = relatedRecord.id;
|
|
583
|
+
// MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
|
|
584
|
+
// pushes below -- the record does not enter `included` and it does not
|
|
585
|
+
// become a parent at the next depth.
|
|
586
|
+
//
|
|
587
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
|
|
588
|
+
// #232's `isLinkable` uses on the relationship routes: `type` is the key
|
|
589
|
+
// the verdict is resolved under, so a missing or empty one means there
|
|
590
|
+
// is no predicate to ask and no way to ask it. Denying is the only safe
|
|
591
|
+
// answer, and it is only reachable while a filter is in force -- with no
|
|
592
|
+
// filter this whole check is skipped and the pre-#233 document is
|
|
593
|
+
// emitted unchanged.
|
|
594
|
+
if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord)))
|
|
595
|
+
continue;
|
|
527
596
|
// Initialize Set for this type if needed
|
|
528
597
|
let seenIds = seen.get(type);
|
|
529
598
|
if (!seenIds) {
|
|
@@ -544,10 +613,10 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
544
613
|
}
|
|
545
614
|
// If there are more segments in the path, recursively process
|
|
546
615
|
if (depth < includePath.length - 1 && nextRecords.length > 0) {
|
|
547
|
-
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
|
|
616
|
+
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
|
|
548
617
|
}
|
|
549
618
|
}
|
|
550
|
-
function collectIncludedRecords(data, includes) {
|
|
619
|
+
function collectIncludedRecords(data, includes, linkage) {
|
|
551
620
|
if (!includes || includes.length === 0)
|
|
552
621
|
return [];
|
|
553
622
|
if (!data)
|
|
@@ -558,7 +627,7 @@ function collectIncludedRecords(data, includes) {
|
|
|
558
627
|
const records = Array.isArray(data) ? data : [data];
|
|
559
628
|
// Process each include path
|
|
560
629
|
for (const includePath of includes) {
|
|
561
|
-
traverseIncludePath(records, includePath, 0, seen, included);
|
|
630
|
+
traverseIncludePath(records, includePath, 0, seen, included, linkage);
|
|
562
631
|
}
|
|
563
632
|
return included;
|
|
564
633
|
}
|
|
@@ -699,11 +768,17 @@ export default class OrmRequest extends Request {
|
|
|
699
768
|
// is the SAME object the primary document is serialized with -- one
|
|
700
769
|
// verdict per type for the whole response, sideload included.
|
|
701
770
|
//
|
|
702
|
-
// The boundary
|
|
703
|
-
//
|
|
771
|
+
// The boundary, so the next reader does not have to derive it: this
|
|
772
|
+
// closes what a record already in `included` may NAME. WHETHER a
|
|
704
773
|
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
705
|
-
// abofs/stonyx-orm#233's
|
|
706
|
-
//
|
|
774
|
+
// abofs/stonyx-orm#233's. THAT IS NOW CLOSED TOO, and the same `linkage`
|
|
775
|
+
// object closes it: `buildResponse` hands this filter to
|
|
776
|
+
// `collectIncludedRecords`, which denies at the push site. Corrected
|
|
777
|
+
// rather than deleted -- this comment read "a hidden owner is still a
|
|
778
|
+
// member here", which is the sentence the identical copy in
|
|
779
|
+
// `buildResponse` carried and which #233 falsified in both places. They
|
|
780
|
+
// are still two questions and neither closes the other: a record can be
|
|
781
|
+
// a member while its own linkage is filtered.
|
|
707
782
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
708
783
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
709
784
|
baseUrl,
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -76,8 +76,18 @@
|
|
|
76
76
|
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
77
|
* `null` there and `request.params` names a record of a different model. See
|
|
78
78
|
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
-
* that limit.
|
|
80
|
-
*
|
|
79
|
+
* that limit.
|
|
80
|
+
*
|
|
81
|
+
* AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
|
|
82
|
+
* read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
|
|
83
|
+
* Both have landed. #235 filters what a record already in `included` may NAME,
|
|
84
|
+
* and #233 filters MEMBERSHIP at the traversal's push site -- see
|
|
85
|
+
* `traverseIncludePath` below. The ask is the same shape as the
|
|
86
|
+
* related-resource one and carries the same limit: `recordId` is `null`, so a
|
|
87
|
+
* deny expressible only as a request-scoped `return false` -- which is how the
|
|
88
|
+
* shipped sample spells `/archived` -- still cannot fire on this path. That
|
|
89
|
+
* residual is abofs/stonyx-orm#243's, not #233's, and it is measured
|
|
90
|
+
* byte-identical on `dev`.
|
|
81
91
|
*
|
|
82
92
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
83
93
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
@@ -490,17 +500,27 @@ function buildResponse(
|
|
|
490
500
|
const includes = parseInclude(includeParam);
|
|
491
501
|
if (includes.length === 0) return response;
|
|
492
502
|
|
|
493
|
-
|
|
503
|
+
// THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
|
|
504
|
+
// BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
|
|
505
|
+
// per-(type, id) decision cache, so the traversal below and the `toJSON`
|
|
506
|
+
// calls beneath it share one resolution of the consumer's `access()` per
|
|
507
|
+
// type for the whole response. Building a second filter here would double
|
|
508
|
+
// every predicate call and, worse, could answer the two questions
|
|
509
|
+
// differently about the same record.
|
|
510
|
+
const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
|
|
494
511
|
if (includedRecords.length > 0) {
|
|
495
512
|
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
496
513
|
// line is one story's and the line above it is another's
|
|
497
514
|
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
498
515
|
//
|
|
499
516
|
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
500
|
-
// `collectIncludedRecords` on the line above. That is MEMBERSHIP
|
|
501
|
-
// #233's
|
|
502
|
-
//
|
|
503
|
-
//
|
|
517
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP and
|
|
518
|
+
// it is #233's. As of #233 that call is given the SAME `linkage`
|
|
519
|
+
// filter, so a hidden owner is no longer a member: she is dropped at
|
|
520
|
+
// the push site and her subtree is never traversed. Pinned by
|
|
521
|
+
// `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
|
|
522
|
+
// `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
|
|
523
|
+
// same test.
|
|
504
524
|
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
505
525
|
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
506
526
|
// for the primary document -- and that is what the `linkage` option
|
|
@@ -545,14 +565,51 @@ function buildResponse(
|
|
|
545
565
|
}
|
|
546
566
|
|
|
547
567
|
/**
|
|
548
|
-
* Recursively traverse an include path and collect related records
|
|
568
|
+
* Recursively traverse an include path and collect related records.
|
|
569
|
+
*
|
|
570
|
+
* ---------------------------------------------------------------------------
|
|
571
|
+
* THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
|
|
572
|
+
* ---------------------------------------------------------------------------
|
|
573
|
+
* A resource reaches `included` because some record NAMED it, and until #233
|
|
574
|
+
* being named was the whole test. That made `?include=` a restoration of every
|
|
575
|
+
* record the read surfaces withhold: `GET /owners/angela` is 404 and
|
|
576
|
+
* `GET /animals/1?include=owner` returned her document in full, attributes and
|
|
577
|
+
* all. Measured on dev @ 8dda5d6, over the live router.
|
|
578
|
+
*
|
|
579
|
+
* FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
|
|
580
|
+
* let the traversal run and filter `collectIncludedRecords`' RETURN value --
|
|
581
|
+
* closes the membership half and leaves the worse half open: dropping a parent
|
|
582
|
+
* AFTER traversing through it publishes that parent's exact child set. On this
|
|
583
|
+
* repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
|
|
584
|
+
* eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
|
|
585
|
+
* reconstructed from a resource the caller may not read. So a denied record is
|
|
586
|
+
* `continue`d before it is pushed to `included` AND before it is pushed to
|
|
587
|
+
* `nextRecords`, which is what prunes the subtree.
|
|
588
|
+
*
|
|
589
|
+
* A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
|
|
590
|
+
* deduplicator for records that DID enter `included`; putting a denial in it
|
|
591
|
+
* would conflate "already emitted" with "withheld", and the `else if` branch
|
|
592
|
+
* below would then push a denied record into `nextRecords` for deeper
|
|
593
|
+
* traversal -- re-opening the prune this function just closed. Re-asking is
|
|
594
|
+
* free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
|
|
595
|
+
* hit and not a call into the consumer's `access()`.
|
|
596
|
+
*
|
|
597
|
+
* ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
|
|
598
|
+
* for the same reason it is optional on `buildResponse` and on
|
|
599
|
+
* `Record.toJSON`: an absent option means "no verdict was supplied", and the
|
|
600
|
+
* honest degradation is the document that shipped before, not an empty one.
|
|
601
|
+
* Both of `buildResponse`'s callers -- `getCollectionHandler` and
|
|
602
|
+
* `getSingleHandler`, the only two -- supply it, which is pinned by
|
|
603
|
+
* `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
|
|
604
|
+
* guard below is the fail-closed reading of one.
|
|
549
605
|
*/
|
|
550
606
|
function traverseIncludePath(
|
|
551
607
|
currentRecords: OrmRecord[],
|
|
552
608
|
includePath: string[],
|
|
553
609
|
depth: number,
|
|
554
610
|
seen: Map<string, Set<string | number>>,
|
|
555
|
-
included: OrmRecord[]
|
|
611
|
+
included: OrmRecord[],
|
|
612
|
+
linkage?: LinkageFilter
|
|
556
613
|
): void {
|
|
557
614
|
if (depth >= includePath.length) return; // Reached end of path
|
|
558
615
|
|
|
@@ -578,6 +635,19 @@ function traverseIncludePath(
|
|
|
578
635
|
const type = relatedRecord.__model.__name;
|
|
579
636
|
const id = relatedRecord.id as string | number;
|
|
580
637
|
|
|
638
|
+
// MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
|
|
639
|
+
// pushes below -- the record does not enter `included` and it does not
|
|
640
|
+
// become a parent at the next depth.
|
|
641
|
+
//
|
|
642
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
|
|
643
|
+
// #232's `isLinkable` uses on the relationship routes: `type` is the key
|
|
644
|
+
// the verdict is resolved under, so a missing or empty one means there
|
|
645
|
+
// is no predicate to ask and no way to ask it. Denying is the only safe
|
|
646
|
+
// answer, and it is only reachable while a filter is in force -- with no
|
|
647
|
+
// filter this whole check is skipped and the pre-#233 document is
|
|
648
|
+
// emitted unchanged.
|
|
649
|
+
if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord))) continue;
|
|
650
|
+
|
|
581
651
|
// Initialize Set for this type if needed
|
|
582
652
|
let seenIds = seen.get(type);
|
|
583
653
|
if (!seenIds) {
|
|
@@ -599,11 +669,15 @@ function traverseIncludePath(
|
|
|
599
669
|
|
|
600
670
|
// If there are more segments in the path, recursively process
|
|
601
671
|
if (depth < includePath.length - 1 && nextRecords.length > 0) {
|
|
602
|
-
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
|
|
672
|
+
traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
|
|
603
673
|
}
|
|
604
674
|
}
|
|
605
675
|
|
|
606
|
-
function collectIncludedRecords(
|
|
676
|
+
function collectIncludedRecords(
|
|
677
|
+
data: OrmRecord | OrmRecord[],
|
|
678
|
+
includes: string[][],
|
|
679
|
+
linkage?: LinkageFilter
|
|
680
|
+
): OrmRecord[] {
|
|
607
681
|
if (!includes || includes.length === 0) return [];
|
|
608
682
|
if (!data) return [];
|
|
609
683
|
|
|
@@ -615,7 +689,7 @@ function collectIncludedRecords(data: OrmRecord | OrmRecord[], includes: string[
|
|
|
615
689
|
|
|
616
690
|
// Process each include path
|
|
617
691
|
for (const includePath of includes) {
|
|
618
|
-
traverseIncludePath(records, includePath, 0, seen, included);
|
|
692
|
+
traverseIncludePath(records, includePath, 0, seen, included, linkage);
|
|
619
693
|
}
|
|
620
694
|
|
|
621
695
|
return included;
|
|
@@ -775,11 +849,17 @@ export default class OrmRequest extends Request {
|
|
|
775
849
|
// is the SAME object the primary document is serialized with -- one
|
|
776
850
|
// verdict per type for the whole response, sideload included.
|
|
777
851
|
//
|
|
778
|
-
// The boundary
|
|
779
|
-
//
|
|
852
|
+
// The boundary, so the next reader does not have to derive it: this
|
|
853
|
+
// closes what a record already in `included` may NAME. WHETHER a
|
|
780
854
|
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
781
|
-
// abofs/stonyx-orm#233's
|
|
782
|
-
//
|
|
855
|
+
// abofs/stonyx-orm#233's. THAT IS NOW CLOSED TOO, and the same `linkage`
|
|
856
|
+
// object closes it: `buildResponse` hands this filter to
|
|
857
|
+
// `collectIncludedRecords`, which denies at the push site. Corrected
|
|
858
|
+
// rather than deleted -- this comment read "a hidden owner is still a
|
|
859
|
+
// member here", which is the sentence the identical copy in
|
|
860
|
+
// `buildResponse` carried and which #233 falsified in both places. They
|
|
861
|
+
// are still two questions and neither closes the other: a record can be
|
|
862
|
+
// a member while its own linkage is filtered.
|
|
783
863
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
784
864
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
785
865
|
baseUrl,
|