@stonyx/orm 0.3.2-alpha.66 → 0.3.2-alpha.68

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
@@ -641,7 +641,7 @@ a write to a *different* collection can still re-parent one. See
641
641
  | `GET /:models/:id/relationships/{relationship}` | `404` — same |
642
642
  | `PATCH /:models/:id` | `404`, no attribute is applied |
643
643
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
644
- | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
644
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
645
645
 
646
646
  **Denied record-level requests return 404, not 403.** This is deliberate and it
647
647
  is the property most easily "improved" away. 403 would confirm that the record
@@ -675,9 +675,64 @@ chooses the id and learns whether the create succeeded — so under a filter the
675
675
  caller does not choose the id. The refusal happens before any store lookup, so
676
676
  neither the status nor the response time depends on whether the id exists.
677
677
 
678
- Let the server assign the id and read it back from the response. Callers with no
679
- function-style filter are unaffected: `409` on a duplicate id and `200` on a free
680
- one both behave exactly as before.
678
+ Let the server assign the id and read it back from the response and read it
679
+ back rather than predicting it, because the value it returns is documented but
680
+ not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
681
+ numeric ceiling, the lowest free integer), and a string-id model gets
682
+ `<model>-<n>`. See breaking change 8.
683
+
684
+ **What a server-assigned id is not.** It is not a secret. On a string-id
685
+ collection it is dense and enumerable from `1`, where previously it inherited
686
+ whatever entropy the last-inserted id happened to carry — a UUID-seeded store
687
+ answered a UUID-derived key. If a collection has **no** `access` config its
688
+ record-level routes are ungated, so the id was the only thing standing between
689
+ an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
690
+ a control and must not become one; configure `access`.
691
+
692
+ **And the id itself is an occupancy signal — on both model kinds.**
693
+ `assignRecordId` reads the whole store, not the caller's filtered view — it
694
+ never sees `state.filter` — so the id it returns is a function of records the
695
+ caller may not be permitted to read. **This applies to numeric-id collections
696
+ as well as string-id ones**, and the conditions differ, so read both:
697
+
698
+ - **String-id collections, always.** The assigned `n` is the smallest positive
699
+ integer whose landing key is free, which tells the caller that every key
700
+ below it is taken, hidden or not.
701
+ - **Numeric-id collections, once one record sits at the numeric ceiling.** The
702
+ normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
703
+ is not representable at or above 2^53, so the walk restarts from `1` (see
704
+ breaking change 8) and the assigned id becomes the smallest free integer —
705
+ the same occupancy predicate, now over arbitrary low keys. Each subsequent
706
+ no-id `POST` names the next free one, so a caller can enumerate the holes in
707
+ a range it cannot read.
708
+
709
+ **A ceiling record reaches a filter-protected collection even though `POST`
710
+ refuses caller ids on one.** Breaking change 3 makes
711
+ `POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
712
+ through a *relationship write on another collection* —
713
+ `POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
714
+ creates the animal under that key
715
+ ([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
716
+ **Known limitations** re-parenting note describes). So the precondition is
717
+ reachable by an unauthenticated caller on exactly the collections `access`
718
+ exists to protect. Measured on the sample fixture, with every animal hidden by
719
+ the `/animals` predicate and keys 4 and 7 deleted:
720
+
721
+ ```
722
+ GET /animals -> 200 [] (nothing visible)
723
+ GET /animals/4 -> 404 (free — indistinguishable from hidden)
724
+ POST /animals {"id":4} -> 403 (breaking change 3)
725
+ POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
726
+ POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
727
+ POST /animals (no id) -> 200 id=7 <- and the other one
728
+ POST /animals (no id) -> 200 id=13
729
+ POST /animals (no id) -> 200 id=14
730
+ ```
731
+
732
+ Closing this requires the assignment to be filter-aware, which is a change to
733
+ the `access` contract rather than a fix; it is stated here rather than left to
734
+ be discovered. Callers with no function-style filter are unaffected — there are
735
+ no hidden records to disclose.
681
736
 
682
737
  ### Identifying the collection
683
738
 
@@ -809,7 +864,50 @@ per-record filter. An input you cannot identify must **deny**.
809
864
  related record without resolving that model's own access class, so a filter on
810
865
  `/owners` does not hide an owner reached through `/animals`. Tracked as
811
866
  [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
812
- `include=`, related-resource routes and relationship-linkage routes.
867
+ `include=`, related-resource routes and relationship-linkage routes. This is
868
+ **membership** — whether the related resource is served at all — and it is a
869
+ different question from which ids a document may *name*, immediately below.
870
+ - **Relationship linkage is filtered on the four request-bound read surfaces,
871
+ and only there.** A document's `relationships.*.data` used to publish the id
872
+ of every related record unconditionally, so a record hidden on every one of
873
+ its own surfaces was still named inside another model's document — with no
874
+ `include=`, no relationship route and no query string
875
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234)). It is now filtered
876
+ through the related model's own access class on `GET /:models`,
877
+ `GET /:models/:id` and both `GET /:models/:id/{relationship}` shapes. A
878
+ filtered-out relationship is **indistinguishable from a genuinely empty one** —
879
+ an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
880
+ both **keeping their `links`**, which are built from the serialized record's
881
+ own id and never from the related one. Nothing errors and no status changes,
882
+ because throwing here would be an existence oracle *and* would throw out of
883
+ the enclosing `JSON.stringify`. **Not yet covered:** `included`
884
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
885
+ resource appears there at all), the `POST`/`PATCH` response documents, and
886
+ `GET /:models/:id/relationships/{relationship}`, whose *primary data* is
887
+ linkage ([#196](https://github.com/abofs/stonyx-orm/issues/196)).
888
+ - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
889
+ `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
890
+ request, and the documented `access()` contract permits a predicate to read
891
+ one — the sample in this README does, for its sub-path rule — so a filter
892
+ resolved inside `toJSON()` denies *permitted* records rather than hidden ones
893
+ (measured: 967 → 964, all three failures over-denials). `toJSON` is also the
894
+ `JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
895
+ `console.log(JSON.stringify(record))` reach it with a **string** in the
896
+ options slot and have no syntactic place to pass a verdict. The no-argument
897
+ call therefore returns the pre-#234 document unchanged. Fail-closed by default
898
+ is not available either: `Orm.instance.accessFunctions` is `{}` in any process
899
+ that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
900
+ it would empty every relationship on every document in processes with no REST
901
+ surface to protect. Closing the residual means moving JSON:API serialization
902
+ **off** the `toJSON` name, tracked as
903
+ [#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
904
+ to an untrusted consumer, serialize it through the REST layer or pass your own
905
+ resolved `linkage` option.
906
+ - **`format()` and `serialize()` are deliberately not filtered, and must stay
907
+ that way.** `format()` is the **persistence** path — its output is what
908
+ `Orm.db.save()` writes to disk — so applying an access filter there would
909
+ write a truncated database. That is **data loss**, not disclosure prevention.
910
+ Neither method appears anywhere in the REST response path.
813
911
  - **A before-hook that returns a value short-circuits the request.** On write
814
912
  operations addressed to a record the filter is consulted first, so a hook
815
913
  cannot answer for a record the caller may not see. On reads it is not, so a
@@ -842,9 +940,10 @@ per-record filter. An input you cannot identify must **deny**.
842
940
  transform output differs from its lookup key is the same defect. Filtered
843
941
  collections are unaffected — breaking change 3 refuses any client-supplied id
844
942
  — so this reaches consumers with **no** function-style filter. Tracked as
845
- [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
846
- [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
847
- a create can land on an id nobody named.
943
+ [#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
944
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) a **server-assigned**
945
+ id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
946
+ the client-supplied half and is still open.
848
947
  - **`context.record` is `undefined` for an after-`create` hook when a string-id
849
948
  model is given a numeric-looking id.** The post-create lookup uses the same id
850
949
  coercion as every other surface, which resolves `'9107'` to the number `9107`,
@@ -854,15 +953,18 @@ per-record filter. An input you cannot identify must **deny**.
854
953
  [#209](https://github.com/abofs/stonyx-orm/issues/209).
855
954
  - **A denied `POST` rolls back only a record it *inserted*.** The rollback
856
955
  requires the store to have grown, because removing by id alone is a write
857
- primitive keyed by a caller-supplied value. When `assignRecordId` lands a
858
- **server-assigned** id on an occupied slot
859
- ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
860
- last-*inserted* + 1, not max + 1, so a store whose insertion order is not
861
- ascending collides), `createRecord` updates that record **in place**: the map
862
- does not grow, the rollback correctly declines to remove a record this request
863
- did not create, and the `403` leaves the caller's attributes on someone else's
864
- record. Narrow it needs a non-ascending insertion order — but it is the
865
- reachability condition, so it is stated rather than implied.
956
+ primitive keyed by a caller-supplied value. **The reachability condition this
957
+ bullet used to state is gone**: it was
958
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
959
+ returned last-*inserted* + 1, so a server-assigned id could land on an
960
+ occupied slot and `createRecord` would update it in place and #203 is fixed
961
+ (breaking change 8). A server-assigned create can no longer overwrite, so on a
962
+ collection whose only id channel is `createHandler` this guard has no
963
+ observable effect today. It is kept because a caller-supplied id reaching
964
+ `createRecord` from another route a relationship write,
965
+ [#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
966
+ back, and without the guard a denied `403` would delete a record the request
967
+ did not create.
866
968
 
867
969
  ### Breaking changes
868
970
 
@@ -923,6 +1025,64 @@ they are recorded here.
923
1025
  population breaking changes 3 and 4 explicitly exempt. If you were relying on
924
1026
  a hex-shaped or whitespace-padded id creating a second record, it never did.
925
1027
 
1028
+ 8. **Server-assigned ids change value on string-id models, numeric ids stop
1029
+ being monotonic at the numeric ceiling, and the create route gains a
1030
+ `409`.** Three consumer-visible changes from
1031
+ [#203](https://github.com/abofs/stonyx-orm/issues/203).
1032
+
1033
+ **The value.** A `POST` with no `id` against a model declaring
1034
+ `id = attr('string')` previously produced the *last-inserted* id with `1`
1035
+ concatenated onto it — an owner store holding `['gina', 'bob']` answered
1036
+ `'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
1037
+ lowest positive integer whose landing key is free. **No test in this repo
1038
+ pinned the old value**, so a consumer relying on it gets no failing test, no
1039
+ deprecation and no other signal — which is why it is recorded here. Numeric
1040
+ id models (`id = attr('number')`, the default) are unaffected in shape: they
1041
+ still get an integer, but it is now the **maximum** existing id plus one
1042
+ rather than the last-inserted id plus one, which is the defect #203 is about.
1043
+ They are **not** unaffected in *sequence* — see the monotonicity half below.
1044
+
1045
+ The value is deliberately **not** numeric-looking, and that is not cosmetic.
1046
+ Every id-bearing surface resolves a numeric-looking string id to a **number**
1047
+ (`GET /owners/1` looks up `1`), while a string-id model files its records
1048
+ under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
1049
+ record that was created successfully and could not be fetched, updated or
1050
+ deleted by id, and whose after-`create` hook received
1051
+ `context.record === undefined`
1052
+ ([#209](https://github.com/abofs/stonyx-orm/issues/209)).
1053
+
1054
+ **Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
1055
+ The precondition is narrow but it is reachable, and there is no signal when
1056
+ it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
1057
+ + 1` is not representable there, so assignment restarts from `1` and walks
1058
+ up to the lowest free key — which means the id of a *deleted* record is
1059
+ handed to the next `POST`. Both `dev` and every prior release were strictly
1060
+ monotonic and never re-issued a numeric id, so a consumer that relied on
1061
+ that — audit rows, cursors, cached authorization decisions, external
1062
+ references keyed on the id — now has a stale reference that silently points
1063
+ at a **different record, created by a different caller**, rather than at a
1064
+ deleted one. Nothing fails; the reference simply resolves to the wrong
1065
+ record.
1066
+
1067
+ The restart is deliberate and is not itself optional: without it, one record
1068
+ at the ceiling made every subsequent server-assigned create on that
1069
+ collection fail permanently. Re-use is the cost of keeping the collection
1070
+ writable. **If you need monotonic ids, assign them yourself** rather than
1071
+ letting the server assign, and note that a ceiling record can be planted by
1072
+ an unauthenticated caller — see *And the id itself is an occupancy signal*
1073
+ under [Filter functions](#filter-functions) for the reachability path.
1074
+ String-id models are unaffected by this half: their keys are
1075
+ `<model>-<n>` and were never monotonic over an integer sequence.
1076
+
1077
+ **The status.** `POST /{collection}` can now answer `409` for a reason other
1078
+ than a duplicate id: the server could not derive a free id. That requires a
1079
+ **non-injective** id transform — one that maps distinct candidates onto the
1080
+ same store key, such as `boolean`, or anything you registered on
1081
+ `Orm.instance.transforms` and named as an id type. It is a configuration
1082
+ fault rather than a request fault; the message is logged through
1083
+ `stonyx/log`. Previously this case threw out of the handler and express
1084
+ answered `500` with a stack trace.
1085
+
926
1086
  ### Include Parameter (Sideloading Relationships)
927
1087
 
928
1088
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -0,0 +1,57 @@
1
+ import type { AccessMethod, AccessOperation } from './types/orm-types.js';
2
+ /**
3
+ * The classified reading of one `access()` return value.
4
+ *
5
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
6
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
7
+ * this per-record predicate" -- the function return shape, which is the
8
+ * per-record hook `AccessContext` deliberately does not provide.
9
+ */
10
+ export interface AccessVerdict {
11
+ granted: boolean;
12
+ filter?: (record: unknown) => boolean;
13
+ }
14
+ /**
15
+ * A resolved, request-scoped linkage decision: may `record` of model `type` be
16
+ * NAMED, by id, inside another model's document?
17
+ *
18
+ * Arity is `(type, record)` and not `(type, id)` because the per-record filter
19
+ * the consumer returns is handed the RECORD -- this repo's own fixture reads
20
+ * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
21
+ * key, not the input.
22
+ */
23
+ export type LinkageFilter = (type: string, record: unknown) => boolean;
24
+ /**
25
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
26
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
27
+ *
28
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
29
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
30
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
31
+ * the same answer `auth()` gave before the extraction.
32
+ */
33
+ export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
34
+ /**
35
+ * Build a request-scoped linkage filter.
36
+ *
37
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
38
+ *
39
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
40
+ * which is arbitrary code with arbitrary cost and which the module has
41
+ * already had to guard for throwing.
42
+ * - one decision per `(type, id)`. `included` is deduplicated by
43
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
44
+ * per record. Measured on a bare `GET /animals` with no `include=`:
45
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
46
+ * a 6.9x reduction and 41 predicate calls saved.
47
+ *
48
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
49
+ * template-string composite: `Map` compares with SameValueZero, so the numeric
50
+ * id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
51
+ * would collapse them and let one model's verdict answer for another record.
52
+ *
53
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
54
+ * it -- a verdict cached across requests would answer a second caller with the
55
+ * first caller's authorization.
56
+ */
57
+ export declare function createLinkageFilter(request: unknown): LinkageFilter;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The shared access-verdict primitive (abofs/stonyx-orm#234).
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
6
+ * ---------------------------------------------------------------------------
7
+ * A consumer `access()` may return six differently-shaped things -- `false`, a
8
+ * bare permission string, a permission array, `true`, a per-record function, or
9
+ * something the contract does not define at all -- and the reading of each one
10
+ * is a security decision. `auth()` has held that reading inline since #190.
11
+ * Every surface that needs to ask "may this caller see model X's record?" needs
12
+ * the SAME reading, or the second copy becomes an unreviewed second
13
+ * authorization vocabulary that answers differently about the same value.
14
+ *
15
+ * So `interpretAccess` is extracted here and `auth()` now calls it. It is the
16
+ * only place a return shape is classified, and abofs/stonyx-orm#232 and #233
17
+ * rebase onto it rather than re-deriving it.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
21
+ * ---------------------------------------------------------------------------
22
+ * `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
23
+ * style choice, it is forced, and it was measured before it was decided:
24
+ *
25
+ * INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
26
+ * INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
27
+ *
28
+ * and all three reds were over-denial of PERMITTED records, not the leak. Two
29
+ * independent reasons:
30
+ *
31
+ * 1. `toJSON()` has no request. The shipped, documented sample reads
32
+ * `request.path` for its `/archived` sub-path rule -- the one read of
33
+ * argument one the README sanctions -- and fail-closes when it is absent.
34
+ * Measured against the live registry:
35
+ *
36
+ * getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
37
+ * getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
38
+ *
39
+ * Same predicate object, two models, two different degradation modes,
40
+ * chosen by the consumer. Without a request there is no trustworthy
41
+ * answer to get.
42
+ *
43
+ * 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
44
+ * record})` calls `record.toJSON('data')` -- a STRING in the options slot.
45
+ * An implicit caller has no syntactic place to pass anything
46
+ * (abofs/stonyx-orm#230). The no-argument document must therefore stay
47
+ * byte-identical to what shipped, which also rules out fail-closed by
48
+ * default: `Orm.instance.accessFunctions` is `{}` in any process that
49
+ * never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
50
+ * fail-closed default would empty every relationship on every document in
51
+ * processes that have no REST surface to protect.
52
+ *
53
+ * The caller -- which still holds the request -- resolves the predicate,
54
+ * interprets it here, caches the answer, and hands `toJSON()` an already-decided
55
+ * `(type, record) => boolean`.
56
+ */
57
+ import Orm from '@stonyx/orm';
58
+ import log from 'stonyx/log';
59
+ const DENIED = Object.freeze({ granted: false });
60
+ const GRANTED = Object.freeze({ granted: true });
61
+ /**
62
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
63
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
64
+ *
65
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
66
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
67
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
68
+ * the same answer `auth()` gave before the extraction.
69
+ */
70
+ export function interpretAccess(access, operation) {
71
+ if (!access)
72
+ return DENIED;
73
+ // The function return shape IS the per-record hook. Grant the request and
74
+ // carry the predicate; the caller applies it per record.
75
+ if (typeof access === 'function')
76
+ return { granted: true, filter: access };
77
+ if (access === true)
78
+ return GRANTED;
79
+ // `AccessMethod` declares `string` legal and it fell through every branch
80
+ // above. A bare string is ONE permission, not a grant of all four -- reading
81
+ // it as a full grant is what once let `return 'read'` authorise DELETE.
82
+ const permitted = typeof access === 'string' ? [access] : access;
83
+ // Anything that is not a permission array by this point -- an object, a
84
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
85
+ // shape the contract does not define is a denial. Fail CLOSED.
86
+ if (!Array.isArray(permitted))
87
+ return DENIED;
88
+ if (!permitted.includes(operation))
89
+ return DENIED;
90
+ return GRANTED;
91
+ }
92
+ /**
93
+ * Resolve model `type`'s verdict for a read, against the live `request`.
94
+ *
95
+ * Fails closed on both ambiguous inputs:
96
+ *
97
+ * - `getAccess(type)` -> `undefined`. That is NOT "this model is
98
+ * unrestricted". `setup-rest-server` catches an access-class load failure,
99
+ * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
100
+ * both "no access class claims this model" and "the class that claims it
101
+ * failed to load" -- and the caller cannot tell them apart. Deny.
102
+ * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
103
+ * a throw is a denial, logged, never a 500 and never a grant.
104
+ *
105
+ * NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
106
+ * request in hand was dispatched to a DIFFERENT model's route. Since #222 this
107
+ * repo's fixture reads `context.model` and answers correctly; a consumer's
108
+ * arity-1 predicate does not, and there is no supported way to tell which kind
109
+ * was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
110
+ * consequence to expect rather than debug: the fixture's surviving `request.path`
111
+ * read means asking the OWNER predicate on a request dispatched to
112
+ * `GET /animals/archived` returns a bare `false`. That is a whole-request deny
113
+ * bleeding across models -- harmless, because it is the fail-closed direction,
114
+ * and it is treated as "deny this linkage", not as an error.
115
+ */
116
+ function resolveVerdict(request, type) {
117
+ const predicate = Orm.instance?.getAccess?.(type);
118
+ if (typeof predicate !== 'function')
119
+ return DENIED;
120
+ let access;
121
+ try {
122
+ access = predicate(request, { model: type, operation: 'read' });
123
+ }
124
+ catch (error) {
125
+ log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
126
+ return DENIED;
127
+ }
128
+ return interpretAccess(access, 'read');
129
+ }
130
+ /**
131
+ * Build a request-scoped linkage filter.
132
+ *
133
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
134
+ *
135
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
136
+ * which is arbitrary code with arbitrary cost and which the module has
137
+ * already had to guard for throwing.
138
+ * - one decision per `(type, id)`. `included` is deduplicated by
139
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
140
+ * per record. Measured on a bare `GET /animals` with no `include=`:
141
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
142
+ * a 6.9x reduction and 41 predicate calls saved.
143
+ *
144
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
145
+ * template-string composite: `Map` compares with SameValueZero, so the numeric
146
+ * id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
147
+ * would collapse them and let one model's verdict answer for another record.
148
+ *
149
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
150
+ * it -- a verdict cached across requests would answer a second caller with the
151
+ * first caller's authorization.
152
+ */
153
+ export function createLinkageFilter(request) {
154
+ const byType = new Map();
155
+ return function isLinkable(type, record) {
156
+ let entry = byType.get(type);
157
+ if (!entry) {
158
+ entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
159
+ byType.set(type, entry);
160
+ }
161
+ const { verdict, decisions } = entry;
162
+ if (!verdict.granted)
163
+ return false;
164
+ if (!verdict.filter)
165
+ return true;
166
+ const id = record?.id;
167
+ const cached = decisions.get(id);
168
+ if (cached !== undefined)
169
+ return cached;
170
+ let allowed;
171
+ try {
172
+ allowed = Boolean(verdict.filter(record));
173
+ }
174
+ catch (error) {
175
+ // A predicate that throws is a denial -- the same reading `isDenied` uses
176
+ // one layer down. Logged, because a predicate that throws on every record
177
+ // empties every relationship and, silently, that is indistinguishable
178
+ // from a database with no relationships in it.
179
+ log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
180
+ allowed = false;
181
+ }
182
+ decisions.set(id, allowed);
183
+ return allowed;
184
+ };
185
+ }