@stonyx/orm 0.3.2-alpha.77 → 0.3.2-alpha.78
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 +132 -241
- package/dist/orm-request.d.ts +3 -13
- package/dist/orm-request.js +150 -161
- package/dist/types/orm-types.d.ts +4 -30
- package/package.json +1 -1
- package/src/orm-request.ts +152 -164
- package/src/types/orm-types.ts +4 -30
package/src/orm-request.ts
CHANGED
|
@@ -65,19 +65,9 @@
|
|
|
65
65
|
* warning below sanctions. This repo's own fixture has such a rule: its
|
|
66
66
|
* `/archived` deny cannot be expressed from the context alone, and a predicate
|
|
67
67
|
* migrated to context-only would silently drop it, turning a deny into an
|
|
68
|
-
* allow.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
|
|
72
|
-
* the RELATED model's own access class and ask it
|
|
73
|
-
* `{ model: <related>, operation: 'read', recordId: null }`
|
|
74
|
-
* (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
|
|
75
|
-
* records under `model: 'owner'` unexamined. What the context still gives no
|
|
76
|
-
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
|
-
* `null` there and `request.params` names a record of a different model. See
|
|
78
|
-
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
-
* that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
|
|
80
|
-
* #235.
|
|
68
|
+
* allow. The related-resource and `?include=` surfaces serve ANOTHER model's
|
|
69
|
+
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
|
+
* (abofs/stonyx-orm#196).
|
|
81
71
|
*
|
|
82
72
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
83
73
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
@@ -277,7 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
277
267
|
import type { HookContext } from './hooks.js';
|
|
278
268
|
import config from 'stonyx/config';
|
|
279
269
|
import log from 'stonyx/log';
|
|
280
|
-
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';
|
|
281
271
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
282
272
|
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
283
273
|
|
|
@@ -475,9 +465,9 @@ function buildResponse(
|
|
|
475
465
|
data: unknown,
|
|
476
466
|
includeParam: string | undefined,
|
|
477
467
|
recordOrRecords: OrmRecord | OrmRecord[],
|
|
478
|
-
options: { links?: { [key: string]: string }; baseUrl?: string } = {}
|
|
468
|
+
options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
|
|
479
469
|
): JsonApiResponse {
|
|
480
|
-
const { links, baseUrl } = options;
|
|
470
|
+
const { links, baseUrl, linkage } = options;
|
|
481
471
|
const response: JsonApiResponse = { data };
|
|
482
472
|
|
|
483
473
|
// Add top-level links
|
|
@@ -492,14 +482,49 @@ function buildResponse(
|
|
|
492
482
|
|
|
493
483
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
494
484
|
if (includedRecords.length > 0) {
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
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 }));
|
|
503
528
|
}
|
|
504
529
|
|
|
505
530
|
return response;
|
|
@@ -710,7 +735,11 @@ export default class OrmRequest extends Request {
|
|
|
710
735
|
|
|
711
736
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
712
737
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
713
|
-
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
|
|
714
743
|
});
|
|
715
744
|
};
|
|
716
745
|
|
|
@@ -728,29 +757,29 @@ export default class OrmRequest extends Request {
|
|
|
728
757
|
const baseUrl = getBaseUrl(request);
|
|
729
758
|
const linkage = createLinkageFilter(request);
|
|
730
759
|
|
|
731
|
-
// `buildResponse`
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
735
|
-
// abofs/stonyx-orm#233.
|
|
736
|
-
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
737
|
-
// question #234 answers for the primary document -- and it is
|
|
738
|
-
// 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.
|
|
739
763
|
//
|
|
740
|
-
// The
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
746
|
-
// 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.
|
|
747
769
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
748
770
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
749
|
-
baseUrl
|
|
771
|
+
baseUrl,
|
|
772
|
+
linkage
|
|
750
773
|
});
|
|
751
774
|
};
|
|
752
775
|
|
|
753
|
-
const createHandler: HandlerFn = async (
|
|
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;
|
|
754
783
|
const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
|
|
755
784
|
type?: string;
|
|
756
785
|
id?: string | number;
|
|
@@ -1005,10 +1034,29 @@ export default class OrmRequest extends Request {
|
|
|
1005
1034
|
return 403;
|
|
1006
1035
|
}
|
|
1007
1036
|
|
|
1008
|
-
|
|
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) }) };
|
|
1009
1052
|
};
|
|
1010
1053
|
|
|
1011
|
-
const updateHandler: HandlerFn = async (
|
|
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;
|
|
1012
1060
|
const found = await store.find(model, getId(params));
|
|
1013
1061
|
if (!found || !isOrmRecord(found)) return 404;
|
|
1014
1062
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
@@ -1067,7 +1115,14 @@ export default class OrmRequest extends Request {
|
|
|
1067
1115
|
}
|
|
1068
1116
|
}
|
|
1069
1117
|
|
|
1070
|
-
|
|
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) }) };
|
|
1071
1126
|
};
|
|
1072
1127
|
|
|
1073
1128
|
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
@@ -1374,101 +1429,21 @@ export default class OrmRequest extends Request {
|
|
|
1374
1429
|
const relatedData = record.__relationships[relationshipName];
|
|
1375
1430
|
const baseUrl = getBaseUrl(request);
|
|
1376
1431
|
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
// related resource is PRIMARY data on this route, so there is no
|
|
1383
|
-
// linkage-consistency question to answer separately.
|
|
1384
|
-
//
|
|
1385
|
-
// Until #232 this route filtered only the PARENT, so a record its own
|
|
1386
|
-
// model's predicate hides was served in full from another model's
|
|
1387
|
-
// route, at ZERO query parameters. Measured on dev @ 8dda5d6:
|
|
1388
|
-
//
|
|
1389
|
-
// GET /owners/angela -> 404
|
|
1390
|
-
// GET /animals/1/owner -> 200, owner:angela, full attributes
|
|
1391
|
-
// GET /traits/2/tag -> 200, a model NO access class
|
|
1392
|
-
// claims, on a collection that has
|
|
1393
|
-
// no mounted route at all
|
|
1394
|
-
//
|
|
1395
|
-
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1396
|
-
// request addressing the RELATED resource was the original design and
|
|
1397
|
-
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1398
|
-
// one, so a fabricated value changes nothing it could observe.
|
|
1399
|
-
// `createLinkageFilter` is also a published public export
|
|
1400
|
-
// (src/index.ts) whose resolution granularity is per TYPE; supplying a
|
|
1401
|
-
// per-RECORD request would mean widening it, which takes a consumer
|
|
1402
|
-
// `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
|
|
1403
|
-
// separate, consumer-visible story.
|
|
1404
|
-
//
|
|
1405
|
-
// GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
|
|
1406
|
-
// (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
|
|
1407
|
-
// READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
|
|
1408
|
-
// function's pass-through and constrains no call site -- an earlier
|
|
1409
|
-
// revision of this comment cited it for this decision and was wrong.
|
|
1410
|
-
// `Object.create(request)` here measured 1015 / 0 with nothing red.
|
|
1411
|
-
// test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
|
|
1412
|
-
// the predicate is handed OWNS `params` (`Object.hasOwn`) and has
|
|
1413
|
-
// nothing request-shaped behind it on the prototype chain. A derived
|
|
1414
|
-
// request inherits `params` -- so it satisfies every value assertion
|
|
1415
|
-
// there -- and reds on those two. Measured: with the derived request in
|
|
1416
|
-
// place, 1014 / 1, and that one is this guard.
|
|
1417
|
-
//
|
|
1418
|
-
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1419
|
-
// `recordId` is `null` here and the request names a record of a
|
|
1420
|
-
// DIFFERENT model, so a consumer predicate can express a model-level or
|
|
1421
|
-
// a request-level deny for a related resource, but NOT a per-record
|
|
1422
|
-
// one. README.md and docs/usage-patterns.md say so; a ledger assertion
|
|
1423
|
-
// in test/unit/relationship-route-access-test.ts keeps them saying it.
|
|
1432
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1433
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1434
|
+
// the related records themselves are served -- that is the parent-only
|
|
1435
|
+
// filtering this route has done since #190, and widening it to the
|
|
1436
|
+
// related record is abofs/stonyx-orm#196.
|
|
1424
1437
|
const linkage = createLinkageFilter(request);
|
|
1425
1438
|
|
|
1426
|
-
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
|
|
1427
|
-
// keyed on the model name; without one there is no predicate to ask,
|
|
1428
|
-
// and an unidentifiable input must never be the permissive path.
|
|
1429
|
-
const isLinkable = (r: OrmRecord) => {
|
|
1430
|
-
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1431
|
-
|
|
1432
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1433
|
-
};
|
|
1434
|
-
|
|
1435
1439
|
let data: unknown;
|
|
1436
1440
|
if (info.isArray) {
|
|
1437
|
-
// hasMany - return array
|
|
1438
|
-
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1439
|
-
// empty relationship, so this route is not an existence oracle.
|
|
1441
|
+
// hasMany - return array
|
|
1440
1442
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1441
|
-
data = related.
|
|
1443
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1442
1444
|
} else {
|
|
1443
|
-
// belongsTo - return single or null
|
|
1444
|
-
|
|
1445
|
-
// same reason the hasMany branch above drops rather than errors: this
|
|
1446
|
-
// route must not be an existence oracle for the RELATED record.
|
|
1447
|
-
//
|
|
1448
|
-
// THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
|
|
1449
|
-
// Unauthenticated, zero query parameters, one request each, on `tag`
|
|
1450
|
-
// -- the model with no route mounted at all, which is exactly what
|
|
1451
|
-
// #240 AC5 exists to protect:
|
|
1452
|
-
//
|
|
1453
|
-
// GET /traits/1/tag [ABSENT] -> 200 application/json len 68
|
|
1454
|
-
// GET /traits/2/tag [DENIED] -> 404 text/plain len 9
|
|
1455
|
-
//
|
|
1456
|
-
// and `GET /traits/1` and `GET /traits/2` both report
|
|
1457
|
-
// `relationships.tag = {"data":null}` byte-identical modulo the id,
|
|
1458
|
-
// because #234 closed THAT oracle deliberately. A 404 here would let
|
|
1459
|
-
// a caller ask which of those two nulls was a denial. Under
|
|
1460
|
-
// `data: null` the pair closes completely: 200/200, same
|
|
1461
|
-
// content-type, same content-length, bodies identical modulo the
|
|
1462
|
-
// parent id the caller put in the URL. It opens nothing -- `links`
|
|
1463
|
-
// are entirely parent-derived, there is no `meta` and no counts.
|
|
1464
|
-
//
|
|
1465
|
-
// This is also what README.md's module-wide rule already demanded:
|
|
1466
|
-
// every status on a record route must be identical for filtered-out
|
|
1467
|
-
// and does-not-exist. The route now CONFORMS to that rule rather than
|
|
1468
|
-
// carving an exception out of it.
|
|
1469
|
-
if (!isOrmRecord(relatedData)) data = null;
|
|
1470
|
-
else if (!isLinkable(relatedData)) data = null;
|
|
1471
|
-
else data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1445
|
+
// belongsTo - return single or null
|
|
1446
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1472
1447
|
}
|
|
1473
1448
|
|
|
1474
1449
|
return {
|
|
@@ -1478,6 +1453,47 @@ export default class OrmRequest extends Request {
|
|
|
1478
1453
|
};
|
|
1479
1454
|
|
|
1480
1455
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1456
|
+
//
|
|
1457
|
+
// NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
|
|
1458
|
+
// BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
|
|
1459
|
+
// ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
|
|
1460
|
+
// are reading this after #247 landed, the filtering below is #232's and
|
|
1461
|
+
// this note records why it was never #235's to add.
|
|
1462
|
+
//
|
|
1463
|
+
// The three sites #235 does own (`buildResponse`'s `included`, the
|
|
1464
|
+
// related-resource branch above, and the two write handlers) all reach
|
|
1465
|
+
// the filter through `record.toJSON()`, which is where the `linkage`
|
|
1466
|
+
// OPTION is applied. This branch builds its `{ type, id }` objects BY
|
|
1467
|
+
// HAND and never calls `toJSON` at all, so the `linkage` option cannot
|
|
1468
|
+
// reach it -- whatever this route filters, it has to filter itself, which
|
|
1469
|
+
// is precisely why doing so is a separate change with a separate owner.
|
|
1470
|
+
//
|
|
1471
|
+
// It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
|
|
1472
|
+
// metadata ABOUT a document. Here the linkage IS the primary data, so
|
|
1473
|
+
// dropping an entry is a MEMBERSHIP decision about what this route
|
|
1474
|
+
// serves -- the same class as abofs/stonyx-orm#233 and #196, not the
|
|
1475
|
+
// class #234/#235 close. That is why it is absent from #224 §2a's
|
|
1476
|
+
// seven-site inventory.
|
|
1477
|
+
//
|
|
1478
|
+
// MEASURED, so the next person does not re-derive it. Against this
|
|
1479
|
+
// branch's baseline of 1011/0, wiring `createLinkageFilter` into the
|
|
1480
|
+
// belongsTo branch below takes the suite to 1009/2, reddening
|
|
1481
|
+
// `[GUARD] #235 X2` and the
|
|
1482
|
+
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1483
|
+
// test -- the latter is #232's own reproduction, not a regression.
|
|
1484
|
+
//
|
|
1485
|
+
// THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
|
|
1486
|
+
// THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
|
|
1487
|
+
// `node_modules`, so a wrong number here is a wrong number in the
|
|
1488
|
+
// published package. 993+2 = 995 is the DEV baseline, carried over from
|
|
1489
|
+
// a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
|
|
1490
|
+
// with no baseline beside it cannot be checked by reading, which is how
|
|
1491
|
+
// it survived three artifacts and a review; the qualitative claim was
|
|
1492
|
+
// right the whole time and only the count was wrong.
|
|
1493
|
+
//
|
|
1494
|
+
// `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
|
|
1495
|
+
// BOUNDARY here rather than this route's current answer, so that it
|
|
1496
|
+
// survives #247 landing. Read its comment before changing it.
|
|
1481
1497
|
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
1482
1498
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
1483
1499
|
if (!record) return 404;
|
|
@@ -1486,44 +1502,16 @@ export default class OrmRequest extends Request {
|
|
|
1486
1502
|
const relatedData = record.__relationships[relationshipName];
|
|
1487
1503
|
const baseUrl = getBaseUrl(request);
|
|
1488
1504
|
|
|
1489
|
-
// THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
|
|
1490
|
-
// `{ type, id }` BY HAND, which is why #234's linkage filter never
|
|
1491
|
-
// reached it and why this half belongs to abofs/stonyx-orm#232 rather
|
|
1492
|
-
// than to #234: on this route the linkage IS the primary data of an
|
|
1493
|
-
// opt-in request, so filtering it changes the route's MEMBERSHIP
|
|
1494
|
-
// semantics, not the ids named inside somebody else's document.
|
|
1495
|
-
//
|
|
1496
|
-
// DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
|
|
1497
|
-
// section enumerates the surfaces on which the framework resolves a
|
|
1498
|
-
// verdict and hands it to `toJSON()`, and that enumeration GROWS --
|
|
1499
|
-
// abofs/stonyx-orm#235 adds the two write handlers and the `included`
|
|
1500
|
-
// records. This route is not on that list under any count, because it
|
|
1501
|
-
// never calls `toJSON()`: whatever it filters, it filters here. A
|
|
1502
|
-
// number written into this comment would be false the next time that
|
|
1503
|
-
// list changes, and the README already carries the enumeration.
|
|
1504
|
-
//
|
|
1505
|
-
// Same filter, same argument-one decision, same residual as
|
|
1506
|
-
// `/:id/{relationship}` above -- read the block there.
|
|
1507
|
-
const linkage = createLinkageFilter(request);
|
|
1508
|
-
const isLinkable = (r: OrmRecord) => {
|
|
1509
|
-
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1510
|
-
|
|
1511
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1512
|
-
};
|
|
1513
|
-
|
|
1514
1505
|
let data: unknown;
|
|
1515
1506
|
if (info.isArray) {
|
|
1516
1507
|
// hasMany - return array of linkage objects
|
|
1517
1508
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1518
1509
|
data = related
|
|
1519
1510
|
.filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
|
|
1520
|
-
.filter(isLinkable)
|
|
1521
1511
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1522
1512
|
} else {
|
|
1523
|
-
// belongsTo - return single linkage or null
|
|
1524
|
-
|
|
1525
|
-
// the measured oracle in the `/:id/{relationship}` block above.
|
|
1526
|
-
if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
|
|
1513
|
+
// belongsTo - return single linkage or null
|
|
1514
|
+
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
1527
1515
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1528
1516
|
} else {
|
|
1529
1517
|
data = null;
|
package/src/types/orm-types.ts
CHANGED
|
@@ -350,36 +350,10 @@ export interface AccessContext {
|
|
|
350
350
|
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
351
351
|
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
352
352
|
*
|
|
353
|
-
* IT NAMES WHICH RECORD
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
|
|
358
|
-
* `recordId: 'gina'` -- `auth()` reads it off `request.params`.
|
|
359
|
-
*
|
|
360
|
-
* FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
|
|
361
|
-
* WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
|
|
362
|
-
* route families resolve the RELATED model's own predicate -- `animal` on
|
|
363
|
-
* `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
|
|
364
|
-
* carries `recordId: null` while `request.params` names a record of a
|
|
365
|
-
* DIFFERENT model. So a predicate answering about a related model gets the
|
|
366
|
-
* model name, the operation and the request, and CANNOT branch on which
|
|
367
|
-
* related record it is being asked about.
|
|
368
|
-
*
|
|
369
|
-
* The rule, so it is not re-derived wrong: `recordId` may name a record only
|
|
370
|
-
* where the route addresses exactly one record OF THE MODEL BEING ASKED
|
|
371
|
-
* ABOUT. A `hasMany` related-resource route returns many records of one type
|
|
372
|
-
* and the verdict is resolved ONCE PER TYPE, before any record is examined --
|
|
373
|
-
* seeding it from a record would let the first one decide for all of them.
|
|
374
|
-
*
|
|
375
|
-
* What still works, and what does not, is pinned as behaviour by `#232 AC10`
|
|
376
|
-
* in test/integration/orm-test.ts and stated for consumers in README.md:
|
|
377
|
-
* model-level denies work, request-level denies work, and the per-record
|
|
378
|
-
* FILTER shape works because `access()` may return a function and that
|
|
379
|
-
* function receives the whole record. Branching on identity BEFORE returning
|
|
380
|
-
* does not.
|
|
381
|
-
*
|
|
382
|
-
* `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
|
|
353
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
354
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
355
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
356
|
+
* and is untouched by this key.
|
|
383
357
|
*/
|
|
384
358
|
recordId: string | number | null;
|
|
385
359
|
}
|