@stonyx/orm 0.3.2-beta.158 → 0.3.2-beta.159

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
@@ -930,20 +930,47 @@ per-record filter. An input you cannot identify must **deny**.
930
930
  `include=`, related-resource routes and relationship-linkage routes. This is
931
931
  **membership** — whether the related resource is served at all — and it is a
932
932
  different question from which ids a document may *name*, immediately below.
933
- - **Relationship linkage is filtered on the four request-bound read surfaces,
934
- and only there.** A document's `relationships.*.data` used to publish the id
935
- of every related record unconditionally, so a record hidden on every one of
936
- its own surfaces was still named inside another model's document with no
937
- `include=`, no relationship route and no query string
933
+ - **Relationship linkage is filtered on every request-bound surface that
934
+ serializes a record the reads, the two writes, and `included`.** A
935
+ document's `relationships.*.data` used to publish the id of every related
936
+ record unconditionally, so a record hidden on every one of its own surfaces
937
+ was still named inside another model's document — with no `include=`, no
938
+ relationship route and no query string
938
939
  ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
939
- the **related** model's own access class on `GET /:models`, `GET /:models/:id`
940
- and both `GET /:models/:id/{relationship}` shapes, and asks it
941
- `{ model: <related>, operation: 'read' }`. An unresolvable class
942
- (`getAccess()` `undefined`) and a predicate that throws both **deny**. A
940
+ the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
941
+ both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
942
+ `PATCH /:models/:id` **response documents**, and every record inside an
943
+ `?include=` **`included`** array
944
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
945
+ `{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
946
+ write route, and that is correct rather than an oversight** — the question
947
+ asked of the *related* model is "may this caller **read** this id", not "may
948
+ they update it". An access class that grants `['create']` but not `['read']`
949
+ on the related model therefore denies that linkage on its own `POST`
950
+ response; that is the fail-closed direction. Do **not** wire these handlers to
951
+ `methodAccessMap[request.method]`: it would ask a different question on a
952
+ write route than on a read route, which is the two-vocabularies failure
953
+ `createLinkageFilter` exists to prevent. An unresolvable class
954
+ (`getAccess()` → `undefined`) and a predicate that throws both **deny**.
955
+
956
+ **What the two write surfaces cost before #235, measured rather than
957
+ described:** one HTTP verb defeated the filter on the same record. On
958
+ `dev @ 8dda5d6`, seconds apart, with no query string and no relationship
959
+ route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
960
+ returned **200 naming angela**. Any caller who could read a record could also
961
+ write it and be handed the id the read withheld. That consequence is kept here
962
+ after the fix, and stated as a measurement, because **naming the two handlers
963
+ is not a substitute for it** — a reader who is told only that `POST` and
964
+ `PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
965
+ whether the fix addressed it. A
943
966
  filtered-out relationship is **indistinguishable from a genuinely empty one** —
944
967
  an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
945
968
  both **keeping their `links`**, which are built from the serialized record's
946
- own id and never from the related one. Nothing errors and no status changes,
969
+ own id and never from the related one. On the two **write** surfaces there are
970
+ no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
971
+ genuinely-empty relationship are both a bare `{ "data": … }` there. That is
972
+ pre-existing and deliberate — adding `baseUrl` to the write handlers would be
973
+ an unrelated change to their response shape. Nothing errors and no status changes,
947
974
  because throwing here would be an existence oracle *and* would throw out of
948
975
  the enclosing `JSON.stringify`.
949
976
 
@@ -967,31 +994,50 @@ per-record filter. An input you cannot identify must **deny**.
967
994
  *permitted* related record, which is recorded in the release notes as a
968
995
  breaking change.
969
996
 
970
- **Not yet covered, and each one still publishes ids the surfaces above
971
- withhold:**
972
-
973
- - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
974
- permitted record sideloaded by `?include=` emits its **own**
975
- `relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
976
- returns `owner.data: null` on the primary document and then names angela in
977
- `included`, along with eight permitted animals that each name
978
- `{"type":"owner","id":"angela"}`. Separately,
997
+ **Not yet covered by #235. Each still publishes ids the surfaces above
998
+ withhold, except where its own owning issue has since closed it — the first
999
+ entry names an issue that is in flight as this is written:**
1000
+
1001
+ - **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
1002
+ to report rather than this entry's.**
1003
+ [#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
1004
+ relationships-linkage route. Its *primary data* is linkage,
1005
+ so filtering it is a **membership** decision — which is why it is the filed
1006
+ child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
1007
+ #234. The route builds its `{type, id}` objects by hand and never calls
1008
+ `toJSON`, so the `linkage` **option** never reaches it; whatever that route
1009
+ filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
1010
+ #235 branched from: `GET /animals/1/relationships/owner` answered
1011
+ `{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
1012
+ measurement is pinned to a commit on purpose, so that it does not quietly
1013
+ become a false claim about `dev`. **PR
1014
+ [#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
1015
+ this entry**; if it has landed, this route is covered and the bullet #247
1016
+ adds above supersedes this one.
1017
+ - **Whether a related resource appears in `included` at all.**
979
1018
  [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
980
- resource appears in `included` **at all** that is membership, a different
981
- question, and following it will not lead you to this residual.
982
- - **The `POST`/`PATCH` response documents**
983
- [#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
984
- `updateHandler` destructure the request rather than binding it, so wiring
985
- them needs a signature change rather than an argument. Until then **one HTTP
986
- verb defeats the filter on the same record**: measured, `GET /animals/1`
987
- returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
988
- angela**, seconds apart, with no query string and no relationship route. Any
989
- caller who can read a record can also write it and be handed the id the read
990
- withheld.
991
- - **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
992
- linkage, so filtering it is a **membership** decision
993
- [#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
994
- [#196](https://github.com/abofs/stonyx-orm/issues/196).
1019
+ related resource appears in `included`. #235 filters what a record
1020
+ *already in* `included` may **name**; a hidden record is still a
1021
+ **member** of that array. The two are different questions and neither closes
1022
+ the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
1023
+ `owner.data: null` on every permitted animal it sideloads **and still
1024
+ includes the hidden owner as a resource**.
1025
+ - **A computed attribute that interpolates a related record's id.**
1026
+ [#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
1027
+ and **it is open as this is written**. `relationships.*.data` is a structure
1028
+ this module builds, so it can be filtered; a computed property is arbitrary
1029
+ consumer code returning an arbitrary value. Whether that makes the channel a
1030
+ **framework defect** the ORM should close — by handing computed getters a
1031
+ verdict, or by refusing to run them while a filter is in force or a
1032
+ **consumer contract** the ORM should only document, is the question #245
1033
+ must decide. **This README does not decide it; neither reading should be
1034
+ read out of the text here.** Measured on this repo's own fixture, where the
1035
+ `animal` model has a `get tag()` that interpolates `owner.id`: **every**
1036
+ animal document on **every** surface — including the ones above — carries
1037
+ `attributes.tag: "angela's small dog"` for an owner that answers `404`. That
1038
+ measurement is where #245 starts, and it holds whichever way the decision
1039
+ lands. Until it lands, if your access rules hide a record, audit your
1040
+ computed properties for its identifiers.
995
1041
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
996
1042
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
997
1043
  request, and the documented `access()` contract permits a predicate to read
@@ -1085,11 +1131,29 @@ read all of these.
1085
1131
 
1086
1132
  #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1087
1133
 
1088
- **The framework owns this on four surfaces. You own it everywhere else.**
1089
-
1090
- `GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
1091
- shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
1092
- path to a document `JSON.stringify(record)`, `res.json(record)`,
1134
+ **The framework resolves a verdict for you on every request-bound surface that
1135
+ serializes a record through `toJSON()`. You own it everywhere else.**
1136
+
1137
+ Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
1138
+ `GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
1139
+ **response documents**, and every record inside an `?include=` **`included`**
1140
+ array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
1141
+ reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
1142
+ writes and `included`). Each resolves a linkage verdict and passes it to
1143
+ `toJSON()` for you.
1144
+
1145
+ **`GET /:models/:id/relationships/{relationship}` is not on that list, and its
1146
+ state is not this section's to report.** It builds its `{ type, id }` objects by
1147
+ hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
1148
+ — whatever that route filters, it filters itself. And because its linkage *is*
1149
+ its primary data, filtering it is a **membership** decision rather than a
1150
+ linkage one. Membership on both relationship route families is owned by
1151
+ [#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
1152
+ [#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
1153
+ written); read that issue for its state rather than inferring it here, because
1154
+ this section describes only what `toJSON()` filters.
1155
+
1156
+ Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
1093
1157
  `console.log(record)`, a custom route, a queue payload, a websocket frame —
1094
1158
  calls `toJSON()` with no verdict, and **the no-verdict document names every
1095
1159
  related id, including records hidden on every one of their own surfaces**
@@ -1389,13 +1453,15 @@ GET /animals/1
1389
1453
  #### Limitations
1390
1454
 
1391
1455
  - Only available on GET endpoints (not POST/PATCH)
1392
- - **`included` records are not access-filtered, on either question.** Whether a
1393
- resource appears in `included` at all is
1394
- [#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
1395
- permitted still emits its **own** `relationships.*.data` unfiltered, so
1396
- `?include=` republishes ids the primary document withholds
1397
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
1398
- [Consumer Contracts](#consumer-contracts).
1456
+ - **`included` is access-filtered on one of the two questions, not both.** What
1457
+ a record already in `included` may **name** in its own
1458
+ `relationships.*.data` is filtered
1459
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)) `?include=` no
1460
+ longer republishes ids the primary document withholds. Whether a resource
1461
+ appears in `included` **at all** is *membership* and is still unfiltered
1462
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
1463
+ 404 on its own routes is still served as an `included` resource, attributes
1464
+ and all. See [Consumer Contracts](#consumer-contracts).
1399
1465
 
1400
1466
  ## Lifecycle Hooks
1401
1467
 
@@ -424,7 +424,7 @@ function normalizeBodyId(id) {
424
424
  return coerceId(id);
425
425
  }
426
426
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
427
- const { links, baseUrl } = options;
427
+ const { links, baseUrl, linkage } = options;
428
428
  const response = { data };
429
429
  // Add top-level links
430
430
  if (links) {
@@ -437,14 +437,53 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
437
437
  return response;
438
438
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
439
439
  if (includedRecords.length > 0) {
440
- // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
441
- // one. Until it does, a PERMITTED record here emits the full pre-#234
442
- // document: `GET /animals/1?include=owner` filters the primary document's
443
- // `owner.data` to `null` and then names `owner:angela` in `included`.
444
- // Whether a resource reaches this array at all is a different question
445
- // (membership, abofs/stonyx-orm#233) and closing that one does not close
446
- // this one.
447
- response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
440
+ // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
441
+ // line is one story's and the line above it is another's
442
+ // (abofs/stonyx-orm#235 and #233 respectively).
443
+ //
444
+ // - WHICH RESOURCES REACH THIS ARRAY is decided by
445
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
446
+ // #233's, and it is deliberately untouched here: a hidden owner is
447
+ // still a member of `included` after this change. Pinned green by
448
+ // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
449
+ // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
450
+ // `relationships.*.data` is LINKAGE -- the same question #234 answers
451
+ // for the primary document -- and that is what the `linkage` option
452
+ // below decides. Before it, `GET /animals/1?include=owner,owner.pets`
453
+ // filtered the primary document's `owner.data` to `null` and then
454
+ // handed back eight PERMITTED animals in `included` each naming
455
+ // `{"type":"owner","id":"angela"}` -- angela's whole `pets` set,
456
+ // `[1, 3, 7, 10, 11, 15, 17, 20]`. `included` itself is NINE
457
+ // resources there: those eight animals plus the hidden owner, whose
458
+ // membership is #233's and not an animal. Neither #233 nor #234
459
+ // closes that.
460
+ //
461
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
462
+ // already hold one for the primary document, and sharing it is what keeps
463
+ // the per-type verdict cache and the per-(type, id) decision cache alive
464
+ // across the primary document AND the sideload -- one verdict resolution
465
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
466
+ // fresh filter here would resolve the consumer's `access()` once per
467
+ // included record instead.
468
+ //
469
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
470
+ // Stating it precisely because the opposite claim stood here in an earlier
471
+ // draft of this change: BOTH of this function's callers supply a filter
472
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
473
+ // `undefined` branch has no live caller in this module today. It is
474
+ // optional so that omitting it degrades to the PRE-#234 document rather
475
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
476
+ // was supplied" and emits linkage in full.
477
+ //
478
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
479
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
480
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
481
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
482
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
483
+ // So do not "simplify" this to a boolean, and do not make it default to
484
+ // `true`: both spellings look like "allow everything" and mean the exact
485
+ // opposite (abofs/stonyx-orm#224).
486
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
448
487
  }
449
488
  return response;
450
489
  }
@@ -626,7 +665,11 @@ export default class OrmRequest extends Request {
626
665
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
627
666
  return buildResponse(data, request.query?.include, recordsToReturn, {
628
667
  links: { self: `${baseUrl}/${pluralizedModel}` },
629
- baseUrl
668
+ baseUrl,
669
+ // THE SAME filter object the primary documents above were serialized
670
+ // with, deliberately: it carries the caches, and rebuilding one here
671
+ // would re-resolve every type (abofs/stonyx-orm#235).
672
+ linkage
630
673
  });
631
674
  };
632
675
  const getSingleHandler = async (request, { filter }) => {
@@ -642,28 +685,28 @@ export default class OrmRequest extends Request {
642
685
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
643
686
  const baseUrl = getBaseUrl(request);
644
687
  const linkage = createLinkageFilter(request);
645
- // `buildResponse` is deliberately NOT given the linkage filter, and the
646
- // residual that leaves is NOT the one #233 owns. Two different questions:
688
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
689
+ // is the SAME object the primary document is serialized with -- one
690
+ // verdict per type for the whole response, sideload included.
647
691
  //
648
- // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
649
- // abofs/stonyx-orm#233.
650
- // - What a record already IN `included` may NAME is LINKAGE -- the same
651
- // question #234 answers for the primary document -- and it is
652
- // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
653
- //
654
- // The residual, stated so the next reader does not have to derive it:
655
- // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
656
- // argument, so a PERMITTED record in `included` emits the full pre-#234
657
- // document. Measured: `GET /animals/1?include=owner` returns
658
- // `owner.data: null` on the primary document and `owner:angela` in
659
- // `included`. One query parameter deep. Only the PRIMARY document's
660
- // linkage is filtered here.
692
+ // The boundary that remains, so the next reader does not have to derive
693
+ // it: this closes what a record already in `included` may NAME. WHETHER a
694
+ // resource appears in `included` at all is MEMBERSHIP and it is
695
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
696
+ // Neither question closes the other.
661
697
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
662
698
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
663
- baseUrl
699
+ baseUrl,
700
+ linkage
664
701
  });
665
702
  };
666
- const createHandler = async ({ body, query }, { filter }) => {
703
+ const createHandler = async (request, { filter }) => {
704
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
705
+ // delivered the request as argument one; this handler simply discarded
706
+ // the binding, which is why its response document named ids every read
707
+ // surface withholds. `createLinkageFilter` needs the live request and
708
+ // there is no signature change involved in giving it one.
709
+ const { body, query } = request;
667
710
  const { type, id, attributes, relationships: rels } = (body?.data || {});
668
711
  if (!type)
669
712
  return 400; // Bad request
@@ -902,9 +945,28 @@ export default class OrmRequest extends Request {
902
945
  }
903
946
  return 403;
904
947
  }
905
- return { data: record.toJSON?.({ fields: modelFields }) };
948
+ // The filter is built HERE, per invocation, and never hoisted into the
949
+ // OrmRequest constructor where the other per-mount values live: a verdict
950
+ // cached across requests answers a second caller with the first caller's
951
+ // authorization (src/access-verdict.ts says so at the constructor an
952
+ // implementer would reach for).
953
+ //
954
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
955
+ // AFTER `isDenied`, so the record is in its final form at the call. The
956
+ // filter is lazy per type and per (type, id), so it cannot observe a
957
+ // pre-write state even if it were built earlier.
958
+ //
959
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
960
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
961
+ // scope), and a single copy-pasted wiring would drop it from one of them.
962
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
906
963
  };
907
- const updateHandler = async ({ body, params }, { filter }) => {
964
+ const updateHandler = async (request, { filter }) => {
965
+ // Bound rather than destructured, for the reason given in
966
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
967
+ // returned 200 naming angela seconds after `GET /animals/1` returned
968
+ // `owner.data: null` for the same record -- one HTTP verb apart.
969
+ const { body, params } = request;
908
970
  const found = await store.find(model, getId(params));
909
971
  if (!found || !isOrmRecord(found))
910
972
  return 404;
@@ -961,7 +1023,14 @@ export default class OrmRequest extends Request {
961
1023
  updateRecord(record, relUpdates, { _skipAutoPersist: true });
962
1024
  }
963
1025
  }
964
- return { data: record.toJSON?.() };
1026
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1027
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1028
+ // document that has never carried them -- an unrelated behaviour change.
1029
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1030
+ // surfaces; on these two handlers a filtered relationship and a
1031
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1032
+ // them indistinguishable here too.
1033
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
965
1034
  };
966
1035
  const deleteHandler = async ({ params }, { filter }) => {
967
1036
  // Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
@@ -1267,6 +1336,54 @@ export default class OrmRequest extends Request {
1267
1336
  };
1268
1337
  };
1269
1338
  // Relationship linkage route: GET /:id/relationships/{relationship}
1339
+ //
1340
+ // NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
1341
+ // BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
1342
+ // ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
1343
+ // are reading this after #247 landed, the filtering below is #232's and
1344
+ // this note records why it was never #235's to add.
1345
+ //
1346
+ // The three sites #235 does own -- `buildResponse`'s `included`, and the
1347
+ // two write handlers, `POST /:models` and `PATCH /:models/:id` -- all
1348
+ // reach the filter through `record.toJSON()`, which is where the
1349
+ // `linkage` OPTION is applied.
1350
+ //
1351
+ // The related-resource branch above ALSO passes a `linkage` filter, and
1352
+ // it is NOT one of those three: it is abofs/stonyx-orm#234's code and
1353
+ // predates this change. `git diff 8dda5d6..HEAD -- src/orm-request.ts`
1354
+ // leaves that branch byte-unchanged.
1355
+ //
1356
+ // This branch builds its `{ type, id }` objects BY
1357
+ // HAND and never calls `toJSON` at all, so the `linkage` option cannot
1358
+ // reach it -- whatever this route filters, it has to filter itself, which
1359
+ // is precisely why doing so is a separate change with a separate owner.
1360
+ //
1361
+ // It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
1362
+ // metadata ABOUT a document. Here the linkage IS the primary data, so
1363
+ // dropping an entry is a MEMBERSHIP decision about what this route
1364
+ // serves -- the same class as abofs/stonyx-orm#233 and #196, not the
1365
+ // class #234/#235 close. That is why it is absent from #224 §2a's
1366
+ // seven-site inventory.
1367
+ //
1368
+ // MEASURED, so the next person does not re-derive it. Against this
1369
+ // branch's baseline of 1011/0, wiring `createLinkageFilter` into the
1370
+ // belongsTo branch below takes the suite to 1009/2, reddening
1371
+ // `[GUARD] #235 X2` and the
1372
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1373
+ // test -- the latter is #232's own reproduction, not a regression.
1374
+ //
1375
+ // THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
1376
+ // THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
1377
+ // `node_modules`, so a wrong number here is a wrong number in the
1378
+ // published package. 993+2 = 995 is the DEV baseline, carried over from
1379
+ // a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
1380
+ // with no baseline beside it cannot be checked by reading, which is how
1381
+ // it survived three artifacts and a review; the qualitative claim was
1382
+ // right the whole time and only the count was wrong.
1383
+ //
1384
+ // `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
1385
+ // BOUNDARY here rather than this route's current answer, so that it
1386
+ // survives #247 landing. Read its comment before changing it.
1270
1387
  routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
1271
1388
  const record = await store.find(model, getId(request.params));
1272
1389
  if (!record)
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.158",
7
+ "version": "0.3.2-beta.159",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -267,7 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
267
267
  import type { HookContext } from './hooks.js';
268
268
  import config from 'stonyx/config';
269
269
  import log from 'stonyx/log';
270
- import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
270
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
271
271
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
272
272
  import { interpretAccess, createLinkageFilter } from './access-verdict.js';
273
273
 
@@ -465,9 +465,9 @@ function buildResponse(
465
465
  data: unknown,
466
466
  includeParam: string | undefined,
467
467
  recordOrRecords: OrmRecord | OrmRecord[],
468
- options: { links?: { [key: string]: string }; baseUrl?: string } = {}
468
+ options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
469
469
  ): JsonApiResponse {
470
- const { links, baseUrl } = options;
470
+ const { links, baseUrl, linkage } = options;
471
471
  const response: JsonApiResponse = { data };
472
472
 
473
473
  // Add top-level links
@@ -482,14 +482,53 @@ function buildResponse(
482
482
 
483
483
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
484
484
  if (includedRecords.length > 0) {
485
- // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
486
- // one. Until it does, a PERMITTED record here emits the full pre-#234
487
- // document: `GET /animals/1?include=owner` filters the primary document's
488
- // `owner.data` to `null` and then names `owner:angela` in `included`.
489
- // Whether a resource reaches this array at all is a different question
490
- // (membership, abofs/stonyx-orm#233) and closing that one does not close
491
- // this one.
492
- response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
485
+ // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
486
+ // line is one story's and the line above it is another's
487
+ // (abofs/stonyx-orm#235 and #233 respectively).
488
+ //
489
+ // - WHICH RESOURCES REACH THIS ARRAY is decided by
490
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
491
+ // #233's, and it is deliberately untouched here: a hidden owner is
492
+ // still a member of `included` after this change. Pinned green by
493
+ // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
494
+ // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
495
+ // `relationships.*.data` is LINKAGE -- the same question #234 answers
496
+ // for the primary document -- and that is what the `linkage` option
497
+ // below decides. Before it, `GET /animals/1?include=owner,owner.pets`
498
+ // filtered the primary document's `owner.data` to `null` and then
499
+ // handed back eight PERMITTED animals in `included` each naming
500
+ // `{"type":"owner","id":"angela"}` -- angela's whole `pets` set,
501
+ // `[1, 3, 7, 10, 11, 15, 17, 20]`. `included` itself is NINE
502
+ // resources there: those eight animals plus the hidden owner, whose
503
+ // membership is #233's and not an animal. Neither #233 nor #234
504
+ // closes that.
505
+ //
506
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
507
+ // already hold one for the primary document, and sharing it is what keeps
508
+ // the per-type verdict cache and the per-(type, id) decision cache alive
509
+ // across the primary document AND the sideload -- one verdict resolution
510
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
511
+ // fresh filter here would resolve the consumer's `access()` once per
512
+ // included record instead.
513
+ //
514
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
515
+ // Stating it precisely because the opposite claim stood here in an earlier
516
+ // draft of this change: BOTH of this function's callers supply a filter
517
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
518
+ // `undefined` branch has no live caller in this module today. It is
519
+ // optional so that omitting it degrades to the PRE-#234 document rather
520
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
521
+ // was supplied" and emits linkage in full.
522
+ //
523
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
524
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
525
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
526
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
527
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
528
+ // So do not "simplify" this to a boolean, and do not make it default to
529
+ // `true`: both spellings look like "allow everything" and mean the exact
530
+ // opposite (abofs/stonyx-orm#224).
531
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
493
532
  }
494
533
 
495
534
  return response;
@@ -700,7 +739,11 @@ export default class OrmRequest extends Request {
700
739
 
701
740
  return buildResponse(data, request.query?.include, recordsToReturn, {
702
741
  links: { self: `${baseUrl}/${pluralizedModel}` },
703
- baseUrl
742
+ baseUrl,
743
+ // THE SAME filter object the primary documents above were serialized
744
+ // with, deliberately: it carries the caches, and rebuilding one here
745
+ // would re-resolve every type (abofs/stonyx-orm#235).
746
+ linkage
704
747
  });
705
748
  };
706
749
 
@@ -718,29 +761,29 @@ export default class OrmRequest extends Request {
718
761
  const baseUrl = getBaseUrl(request);
719
762
  const linkage = createLinkageFilter(request);
720
763
 
721
- // `buildResponse` is deliberately NOT given the linkage filter, and the
722
- // residual that leaves is NOT the one #233 owns. Two different questions:
764
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
765
+ // is the SAME object the primary document is serialized with -- one
766
+ // verdict per type for the whole response, sideload included.
723
767
  //
724
- // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
725
- // abofs/stonyx-orm#233.
726
- // - What a record already IN `included` may NAME is LINKAGE -- the same
727
- // question #234 answers for the primary document -- and it is
728
- // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
729
- //
730
- // The residual, stated so the next reader does not have to derive it:
731
- // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
732
- // argument, so a PERMITTED record in `included` emits the full pre-#234
733
- // document. Measured: `GET /animals/1?include=owner` returns
734
- // `owner.data: null` on the primary document and `owner:angela` in
735
- // `included`. One query parameter deep. Only the PRIMARY document's
736
- // linkage is filtered here.
768
+ // The boundary that remains, so the next reader does not have to derive
769
+ // it: this closes what a record already in `included` may NAME. WHETHER a
770
+ // resource appears in `included` at all is MEMBERSHIP and it is
771
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
772
+ // Neither question closes the other.
737
773
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
738
774
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
739
- baseUrl
775
+ baseUrl,
776
+ linkage
740
777
  });
741
778
  };
742
779
 
743
- const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
780
+ const createHandler: HandlerFn = async (request, { filter }) => {
781
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
782
+ // delivered the request as argument one; this handler simply discarded
783
+ // the binding, which is why its response document named ids every read
784
+ // surface withholds. `createLinkageFilter` needs the live request and
785
+ // there is no signature change involved in giving it one.
786
+ const { body, query } = request;
744
787
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
745
788
  type?: string;
746
789
  id?: string | number;
@@ -995,10 +1038,29 @@ export default class OrmRequest extends Request {
995
1038
  return 403;
996
1039
  }
997
1040
 
998
- return { data: record.toJSON?.({ fields: modelFields }) };
1041
+ // The filter is built HERE, per invocation, and never hoisted into the
1042
+ // OrmRequest constructor where the other per-mount values live: a verdict
1043
+ // cached across requests answers a second caller with the first caller's
1044
+ // authorization (src/access-verdict.ts says so at the constructor an
1045
+ // implementer would reach for).
1046
+ //
1047
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
1048
+ // AFTER `isDenied`, so the record is in its final form at the call. The
1049
+ // filter is lazy per type and per (type, id), so it cannot observe a
1050
+ // pre-write state even if it were built earlier.
1051
+ //
1052
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
1053
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
1054
+ // scope), and a single copy-pasted wiring would drop it from one of them.
1055
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
999
1056
  };
1000
1057
 
1001
- const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
1058
+ const updateHandler: HandlerFn = async (request, { filter }) => {
1059
+ // Bound rather than destructured, for the reason given in
1060
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
1061
+ // returned 200 naming angela seconds after `GET /animals/1` returned
1062
+ // `owner.data: null` for the same record -- one HTTP verb apart.
1063
+ const { body, params } = request;
1002
1064
  const found = await store.find(model, getId(params));
1003
1065
  if (!found || !isOrmRecord(found)) return 404;
1004
1066
  // Checked BEFORE any attribute is applied. 404 rather than 403 for the
@@ -1057,7 +1119,14 @@ export default class OrmRequest extends Request {
1057
1119
  }
1058
1120
  }
1059
1121
 
1060
- return { data: record.toJSON?.() };
1122
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1123
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1124
+ // document that has never carried them -- an unrelated behaviour change.
1125
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1126
+ // surfaces; on these two handlers a filtered relationship and a
1127
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1128
+ // them indistinguishable here too.
1129
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
1061
1130
  };
1062
1131
 
1063
1132
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
@@ -1388,6 +1457,54 @@ export default class OrmRequest extends Request {
1388
1457
  };
1389
1458
 
1390
1459
  // Relationship linkage route: GET /:id/relationships/{relationship}
1460
+ //
1461
+ // NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
1462
+ // BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
1463
+ // ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
1464
+ // are reading this after #247 landed, the filtering below is #232's and
1465
+ // this note records why it was never #235's to add.
1466
+ //
1467
+ // The three sites #235 does own -- `buildResponse`'s `included`, and the
1468
+ // two write handlers, `POST /:models` and `PATCH /:models/:id` -- all
1469
+ // reach the filter through `record.toJSON()`, which is where the
1470
+ // `linkage` OPTION is applied.
1471
+ //
1472
+ // The related-resource branch above ALSO passes a `linkage` filter, and
1473
+ // it is NOT one of those three: it is abofs/stonyx-orm#234's code and
1474
+ // predates this change. `git diff 8dda5d6..HEAD -- src/orm-request.ts`
1475
+ // leaves that branch byte-unchanged.
1476
+ //
1477
+ // This branch builds its `{ type, id }` objects BY
1478
+ // HAND and never calls `toJSON` at all, so the `linkage` option cannot
1479
+ // reach it -- whatever this route filters, it has to filter itself, which
1480
+ // is precisely why doing so is a separate change with a separate owner.
1481
+ //
1482
+ // It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
1483
+ // metadata ABOUT a document. Here the linkage IS the primary data, so
1484
+ // dropping an entry is a MEMBERSHIP decision about what this route
1485
+ // serves -- the same class as abofs/stonyx-orm#233 and #196, not the
1486
+ // class #234/#235 close. That is why it is absent from #224 §2a's
1487
+ // seven-site inventory.
1488
+ //
1489
+ // MEASURED, so the next person does not re-derive it. Against this
1490
+ // branch's baseline of 1011/0, wiring `createLinkageFilter` into the
1491
+ // belongsTo branch below takes the suite to 1009/2, reddening
1492
+ // `[GUARD] #235 X2` and the
1493
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1494
+ // test -- the latter is #232's own reproduction, not a regression.
1495
+ //
1496
+ // THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
1497
+ // THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
1498
+ // `node_modules`, so a wrong number here is a wrong number in the
1499
+ // published package. 993+2 = 995 is the DEV baseline, carried over from
1500
+ // a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
1501
+ // with no baseline beside it cannot be checked by reading, which is how
1502
+ // it survived three artifacts and a review; the qualitative claim was
1503
+ // right the whole time and only the count was wrong.
1504
+ //
1505
+ // `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
1506
+ // BOUNDARY here rather than this route's current answer, so that it
1507
+ // survives #247 landing. Read its comment before changing it.
1391
1508
  routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
1392
1509
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
1393
1510
  if (!record) return 404;