@stonyx/orm 0.3.2-alpha.73 → 0.3.2-alpha.74

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,36 @@ 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
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
942
954
  (`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
943
955
  filtered-out relationship is **indistinguishable from a genuinely empty one** —
944
956
  an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
945
957
  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,
958
+ own id and never from the related one. On the two **write** surfaces there are
959
+ no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
960
+ genuinely-empty relationship are both a bare `{ "data": … }` there. That is
961
+ pre-existing and deliberate — adding `baseUrl` to the write handlers would be
962
+ an unrelated change to their response shape. Nothing errors and no status changes,
947
963
  because throwing here would be an existence oracle *and* would throw out of
948
964
  the enclosing `JSON.stringify`.
949
965
 
@@ -970,28 +986,32 @@ per-record filter. An input you cannot identify must **deny**.
970
986
  **Not yet covered, and each one still publishes ids the surfaces above
971
987
  withhold:**
972
988
 
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,
979
- [#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
989
  - **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
992
990
  linkage, so filtering it is a **membership** decision —
993
991
  [#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
994
- [#196](https://github.com/abofs/stonyx-orm/issues/196).
992
+ [#196](https://github.com/abofs/stonyx-orm/issues/196). This route builds
993
+ its `{type, id}` objects by hand and never calls `toJSON`, so it does not
994
+ see a `linkage` option no matter who supplies one. Measured:
995
+ `GET /animals/1/relationships/owner` answers
996
+ `{"type":"owner","id":"angela"}` while `GET /owners/angela` is `404`.
997
+ - **Whether a related resource appears in `included` at all** —
998
+ [#233](https://github.com/abofs/stonyx-orm/issues/233). #235 filters what a
999
+ record *already in* `included` may **name**; a hidden record is still a
1000
+ **member** of that array. The two are different questions and neither closes
1001
+ the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
1002
+ `owner.data: null` on every permitted animal it sideloads **and still
1003
+ includes the hidden owner as a resource**.
1004
+ - **A computed attribute that interpolates a related record's id.** This is a
1005
+ **consumer-side** residual and the ORM cannot close it. `relationships.*.data`
1006
+ is a structure this module builds, so it can be filtered; a computed
1007
+ property is arbitrary consumer code returning an arbitrary value, and
1008
+ deciding which substrings of it are identifiers is not something the
1009
+ framework can do. Measured on this repo's own fixture, where the `animal`
1010
+ model has a `get tag()` that interpolates `owner.id`: **every** animal
1011
+ document on **every** surface — including the ones above — carries
1012
+ `attributes.tag: "angela's small dog"` for an owner that answers `404`. If
1013
+ your access rules hide a record, audit your computed properties for its
1014
+ identifiers.
995
1015
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
996
1016
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
997
1017
  request, and the documented `access()` contract permits a predicate to read
@@ -1389,13 +1409,15 @@ GET /animals/1
1389
1409
  #### Limitations
1390
1410
 
1391
1411
  - 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).
1412
+ - **`included` is access-filtered on one of the two questions, not both.** What
1413
+ a record already in `included` may **name** in its own
1414
+ `relationships.*.data` is filtered
1415
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)) `?include=` no
1416
+ longer republishes ids the primary document withholds. Whether a resource
1417
+ appears in `included` **at all** is *membership* and is still unfiltered
1418
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
1419
+ 404 on its own routes is still served as an `included` resource, attributes
1420
+ and all. See [Consumer Contracts](#consumer-contracts).
1399
1421
 
1400
1422
  ## Lifecycle Hooks
1401
1423
 
@@ -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,49 @@ 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 nine PERMITTED animals in `included` each naming
455
+ // `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
456
+ //
457
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
458
+ // already hold one for the primary document, and sharing it is what keeps
459
+ // the per-type verdict cache and the per-(type, id) decision cache alive
460
+ // across the primary document AND the sideload -- one verdict resolution
461
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
462
+ // fresh filter here would resolve the consumer's `access()` once per
463
+ // included record instead.
464
+ //
465
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
466
+ // Stating it precisely because the opposite claim stood here in an earlier
467
+ // draft of this change: BOTH of this function's callers supply a filter
468
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
469
+ // `undefined` branch has no live caller in this module today. It is
470
+ // optional so that omitting it degrades to the PRE-#234 document rather
471
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
472
+ // was supplied" and emits linkage in full.
473
+ //
474
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
475
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
476
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
477
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
478
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
479
+ // So do not "simplify" this to a boolean, and do not make it default to
480
+ // `true`: both spellings look like "allow everything" and mean the exact
481
+ // opposite (abofs/stonyx-orm#224).
482
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
448
483
  }
449
484
  return response;
450
485
  }
@@ -626,7 +661,11 @@ export default class OrmRequest extends Request {
626
661
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
627
662
  return buildResponse(data, request.query?.include, recordsToReturn, {
628
663
  links: { self: `${baseUrl}/${pluralizedModel}` },
629
- baseUrl
664
+ baseUrl,
665
+ // THE SAME filter object the primary documents above were serialized
666
+ // with, deliberately: it carries the caches, and rebuilding one here
667
+ // would re-resolve every type (abofs/stonyx-orm#235).
668
+ linkage
630
669
  });
631
670
  };
632
671
  const getSingleHandler = async (request, { filter }) => {
@@ -642,28 +681,28 @@ export default class OrmRequest extends Request {
642
681
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
643
682
  const baseUrl = getBaseUrl(request);
644
683
  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:
647
- //
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.
684
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
685
+ // is the SAME object the primary document is serialized with -- one
686
+ // verdict per type for the whole response, sideload included.
653
687
  //
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.
688
+ // The boundary that remains, so the next reader does not have to derive
689
+ // it: this closes what a record already in `included` may NAME. WHETHER a
690
+ // resource appears in `included` at all is MEMBERSHIP and it is
691
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
692
+ // Neither question closes the other.
661
693
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
662
694
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
663
- baseUrl
695
+ baseUrl,
696
+ linkage
664
697
  });
665
698
  };
666
- const createHandler = async ({ body, query }, { filter }) => {
699
+ const createHandler = async (request, { filter }) => {
700
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
701
+ // delivered the request as argument one; this handler simply discarded
702
+ // the binding, which is why its response document named ids every read
703
+ // surface withholds. `createLinkageFilter` needs the live request and
704
+ // there is no signature change involved in giving it one.
705
+ const { body, query } = request;
667
706
  const { type, id, attributes, relationships: rels } = (body?.data || {});
668
707
  if (!type)
669
708
  return 400; // Bad request
@@ -902,9 +941,28 @@ export default class OrmRequest extends Request {
902
941
  }
903
942
  return 403;
904
943
  }
905
- return { data: record.toJSON?.({ fields: modelFields }) };
944
+ // The filter is built HERE, per invocation, and never hoisted into the
945
+ // OrmRequest constructor where the other per-mount values live: a verdict
946
+ // cached across requests answers a second caller with the first caller's
947
+ // authorization (src/access-verdict.ts says so at the constructor an
948
+ // implementer would reach for).
949
+ //
950
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
951
+ // AFTER `isDenied`, so the record is in its final form at the call. The
952
+ // filter is lazy per type and per (type, id), so it cannot observe a
953
+ // pre-write state even if it were built earlier.
954
+ //
955
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
956
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
957
+ // scope), and a single copy-pasted wiring would drop it from one of them.
958
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
906
959
  };
907
- const updateHandler = async ({ body, params }, { filter }) => {
960
+ const updateHandler = async (request, { filter }) => {
961
+ // Bound rather than destructured, for the reason given in
962
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
963
+ // returned 200 naming angela seconds after `GET /animals/1` returned
964
+ // `owner.data: null` for the same record -- one HTTP verb apart.
965
+ const { body, params } = request;
908
966
  const found = await store.find(model, getId(params));
909
967
  if (!found || !isOrmRecord(found))
910
968
  return 404;
@@ -961,7 +1019,14 @@ export default class OrmRequest extends Request {
961
1019
  updateRecord(record, relUpdates, { _skipAutoPersist: true });
962
1020
  }
963
1021
  }
964
- return { data: record.toJSON?.() };
1022
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1023
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1024
+ // document that has never carried them -- an unrelated behaviour change.
1025
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1026
+ // surfaces; on these two handlers a filtered relationship and a
1027
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1028
+ // them indistinguishable here too.
1029
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
965
1030
  };
966
1031
  const deleteHandler = async ({ params }, { filter }) => {
967
1032
  // Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
@@ -1267,6 +1332,28 @@ export default class OrmRequest extends Request {
1267
1332
  };
1268
1333
  };
1269
1334
  // Relationship linkage route: GET /:id/relationships/{relationship}
1335
+ //
1336
+ // NO `linkage` FILTER HERE, AND IT IS NOT AN OVERSIGHT --
1337
+ // abofs/stonyx-orm#232 OWNS THIS ROUTE. The three sites that carry the
1338
+ // filter (`buildResponse`'s `included`, the related-resource branch
1339
+ // above, and the two write handlers) all call `record.toJSON()`, which is
1340
+ // where the `linkage` option is applied. This branch builds its
1341
+ // `{ type, id }` objects BY HAND and never calls `toJSON` at all, so it
1342
+ // cannot see a filter no matter who passes one.
1343
+ //
1344
+ // It is also a DIFFERENT QUESTION. Everywhere else, linkage is metadata
1345
+ // ABOUT a document. Here the linkage IS the primary data, so dropping an
1346
+ // entry is a MEMBERSHIP decision about what this route serves -- the same
1347
+ // class as abofs/stonyx-orm#233 and #196, not the class #234/#235 close.
1348
+ // That is why it is absent from #224 §2a's seven-site inventory.
1349
+ //
1350
+ // MEASURED, so the next person does not re-derive it: this route answers
1351
+ // `GET /animals/1/relationships/owner` with
1352
+ // `{"type":"owner","id":"angela"}` while `GET /owners/angela` is 404.
1353
+ // Wiring the filter in here takes the suite to 993/2 and turns the
1354
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1355
+ // test red -- which is #232's own reproduction, not a regression.
1356
+ // Pinned unchanged by `[GUARD] #235 X2` in test/integration/orm-test.ts.
1270
1357
  routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
1271
1358
  const record = await store.find(model, getId(request.params));
1272
1359
  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-alpha.73",
7
+ "version": "0.3.2-alpha.74",
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,49 @@ 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 nine PERMITTED animals in `included` each naming
500
+ // `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
501
+ //
502
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
503
+ // already hold one for the primary document, and sharing it is what keeps
504
+ // the per-type verdict cache and the per-(type, id) decision cache alive
505
+ // across the primary document AND the sideload -- one verdict resolution
506
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
507
+ // fresh filter here would resolve the consumer's `access()` once per
508
+ // included record instead.
509
+ //
510
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
511
+ // Stating it precisely because the opposite claim stood here in an earlier
512
+ // draft of this change: BOTH of this function's callers supply a filter
513
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
514
+ // `undefined` branch has no live caller in this module today. It is
515
+ // optional so that omitting it degrades to the PRE-#234 document rather
516
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
517
+ // was supplied" and emits linkage in full.
518
+ //
519
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
520
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
521
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
522
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
523
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
524
+ // So do not "simplify" this to a boolean, and do not make it default to
525
+ // `true`: both spellings look like "allow everything" and mean the exact
526
+ // opposite (abofs/stonyx-orm#224).
527
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
493
528
  }
494
529
 
495
530
  return response;
@@ -700,7 +735,11 @@ export default class OrmRequest extends Request {
700
735
 
701
736
  return buildResponse(data, request.query?.include, recordsToReturn, {
702
737
  links: { self: `${baseUrl}/${pluralizedModel}` },
703
- baseUrl
738
+ baseUrl,
739
+ // THE SAME filter object the primary documents above were serialized
740
+ // with, deliberately: it carries the caches, and rebuilding one here
741
+ // would re-resolve every type (abofs/stonyx-orm#235).
742
+ linkage
704
743
  });
705
744
  };
706
745
 
@@ -718,29 +757,29 @@ export default class OrmRequest extends Request {
718
757
  const baseUrl = getBaseUrl(request);
719
758
  const linkage = createLinkageFilter(request);
720
759
 
721
- // `buildResponse` is deliberately NOT given the linkage filter, and the
722
- // residual that leaves is NOT the one #233 owns. Two different questions:
723
- //
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.
760
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
761
+ // is the SAME object the primary document is serialized with -- one
762
+ // verdict per type for the whole response, sideload included.
729
763
  //
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.
764
+ // The boundary that remains, so the next reader does not have to derive
765
+ // it: this closes what a record already in `included` may NAME. WHETHER a
766
+ // resource appears in `included` at all is MEMBERSHIP and it is
767
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
768
+ // Neither question closes the other.
737
769
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
738
770
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
739
- baseUrl
771
+ baseUrl,
772
+ linkage
740
773
  });
741
774
  };
742
775
 
743
- const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
776
+ const createHandler: HandlerFn = async (request, { filter }) => {
777
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
778
+ // delivered the request as argument one; this handler simply discarded
779
+ // the binding, which is why its response document named ids every read
780
+ // surface withholds. `createLinkageFilter` needs the live request and
781
+ // there is no signature change involved in giving it one.
782
+ const { body, query } = request;
744
783
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
745
784
  type?: string;
746
785
  id?: string | number;
@@ -995,10 +1034,29 @@ export default class OrmRequest extends Request {
995
1034
  return 403;
996
1035
  }
997
1036
 
998
- return { data: record.toJSON?.({ fields: modelFields }) };
1037
+ // The filter is built HERE, per invocation, and never hoisted into the
1038
+ // OrmRequest constructor where the other per-mount values live: a verdict
1039
+ // cached across requests answers a second caller with the first caller's
1040
+ // authorization (src/access-verdict.ts says so at the constructor an
1041
+ // implementer would reach for).
1042
+ //
1043
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
1044
+ // AFTER `isDenied`, so the record is in its final form at the call. The
1045
+ // filter is lazy per type and per (type, id), so it cannot observe a
1046
+ // pre-write state even if it were built earlier.
1047
+ //
1048
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
1049
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
1050
+ // scope), and a single copy-pasted wiring would drop it from one of them.
1051
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
999
1052
  };
1000
1053
 
1001
- const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
1054
+ const updateHandler: HandlerFn = async (request, { filter }) => {
1055
+ // Bound rather than destructured, for the reason given in
1056
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
1057
+ // returned 200 naming angela seconds after `GET /animals/1` returned
1058
+ // `owner.data: null` for the same record -- one HTTP verb apart.
1059
+ const { body, params } = request;
1002
1060
  const found = await store.find(model, getId(params));
1003
1061
  if (!found || !isOrmRecord(found)) return 404;
1004
1062
  // Checked BEFORE any attribute is applied. 404 rather than 403 for the
@@ -1057,7 +1115,14 @@ export default class OrmRequest extends Request {
1057
1115
  }
1058
1116
  }
1059
1117
 
1060
- return { data: record.toJSON?.() };
1118
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1119
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1120
+ // document that has never carried them -- an unrelated behaviour change.
1121
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1122
+ // surfaces; on these two handlers a filtered relationship and a
1123
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1124
+ // them indistinguishable here too.
1125
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
1061
1126
  };
1062
1127
 
1063
1128
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
@@ -1388,6 +1453,28 @@ export default class OrmRequest extends Request {
1388
1453
  };
1389
1454
 
1390
1455
  // Relationship linkage route: GET /:id/relationships/{relationship}
1456
+ //
1457
+ // NO `linkage` FILTER HERE, AND IT IS NOT AN OVERSIGHT --
1458
+ // abofs/stonyx-orm#232 OWNS THIS ROUTE. The three sites that carry the
1459
+ // filter (`buildResponse`'s `included`, the related-resource branch
1460
+ // above, and the two write handlers) all call `record.toJSON()`, which is
1461
+ // where the `linkage` option is applied. This branch builds its
1462
+ // `{ type, id }` objects BY HAND and never calls `toJSON` at all, so it
1463
+ // cannot see a filter no matter who passes one.
1464
+ //
1465
+ // It is also a DIFFERENT QUESTION. Everywhere else, linkage is metadata
1466
+ // ABOUT a document. Here the linkage IS the primary data, so dropping an
1467
+ // entry is a MEMBERSHIP decision about what this route serves -- the same
1468
+ // class as abofs/stonyx-orm#233 and #196, not the class #234/#235 close.
1469
+ // That is why it is absent from #224 §2a's seven-site inventory.
1470
+ //
1471
+ // MEASURED, so the next person does not re-derive it: this route answers
1472
+ // `GET /animals/1/relationships/owner` with
1473
+ // `{"type":"owner","id":"angela"}` while `GET /owners/angela` is 404.
1474
+ // Wiring the filter in here takes the suite to 993/2 and turns the
1475
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1476
+ // test red -- which is #232's own reproduction, not a regression.
1477
+ // Pinned unchanged by `[GUARD] #235 X2` in test/integration/orm-test.ts.
1391
1478
  routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
1392
1479
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
1393
1480
  if (!record) return 404;