@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.
@@ -220,7 +220,8 @@ import type { HookContext } from './hooks.js';
220
220
  import config from 'stonyx/config';
221
221
  import log from 'stonyx/log';
222
222
  import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
223
- import { isOrmRecord } from './utils.js';
223
+ import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
224
+ import { interpretAccess, createLinkageFilter } from './access-verdict.js';
224
225
 
225
226
  interface OrmRequest$ extends Request {
226
227
  protocol?: string;
@@ -633,7 +634,14 @@ export default class OrmRequest extends Request {
633
634
  if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
634
635
 
635
636
  const baseUrl = getBaseUrl(request);
636
- const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
637
+
638
+ // ONE filter per REQUEST, not one per record: it carries the per-type
639
+ // verdict cache and the per-(type, id) decision cache, and both are
640
+ // worthless if it is rebuilt inside the map. Measured on this exact
641
+ // surface with no `include=`: 48 linkage entries collapse to 7 distinct
642
+ // (type, id) pairs.
643
+ const linkage = createLinkageFilter(request);
644
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
637
645
 
638
646
  return buildResponse(data, request.query?.include, recordsToReturn, {
639
647
  links: { self: `${baseUrl}/${pluralizedModel}` },
@@ -653,7 +661,14 @@ export default class OrmRequest extends Request {
653
661
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
654
662
 
655
663
  const baseUrl = getBaseUrl(request);
656
- return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
664
+ const linkage = createLinkageFilter(request);
665
+
666
+ // `buildResponse` is deliberately NOT given the linkage filter. It builds
667
+ // `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
668
+ // which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
669
+ // the ownership boundary in that issue. Only the PRIMARY document's
670
+ // linkage is filtered here.
671
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
657
672
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
658
673
  baseUrl
659
674
  });
@@ -797,7 +812,41 @@ export default class OrmRequest extends Request {
797
812
  // only O(1) signal that distinguishes an insert from an overwrite.
798
813
  const slotsBefore = store.get(model)?.size ?? 0;
799
814
 
800
- const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
815
+ // THE ONE `createRecord` FAILURE THIS ROUTE ANSWERS RATHER THAN
816
+ // PROPAGATES, and it is narrow on purpose.
817
+ //
818
+ // `assignRecordId` throws when it cannot derive a free store key for a
819
+ // server-assigned id. Unguarded that rejection is auto-forwarded -- there
820
+ // is no catch here, none in @stonyx/rest-server's dispatcher
821
+ // (dist/request.js:41-70), and express 5 hands it to its default error
822
+ // handler, which serialises the STACK, with absolute install paths and the
823
+ // internal module graph, to an unauthenticated caller outside
824
+ // NODE_ENV=production. That is the hazard :553-558 already names in this
825
+ // file, and every sibling refusal in this handler returns an integer
826
+ // status instead. So this one returns 409, matching the client-duplicate
827
+ // refusal at :713: the caller asked for a record and the collection has no
828
+ // id to give it.
829
+ //
830
+ // MATCHED ON THE SHARED PREFIX, not on a literal, and NOT by catching
831
+ // everything: `createRecord` also throws for "ORM is not ready", a
832
+ // read-only view and an unregistered model store, and turning any of those
833
+ // into a 409 would report a configuration fault as a conflict. Anything
834
+ // else is re-thrown unchanged.
835
+ let created;
836
+
837
+ try {
838
+ created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
839
+ } catch (error) {
840
+ if (!(error instanceof Error) || !error.message.startsWith(NO_FREE_ID_ERROR)) throw error;
841
+
842
+ // Not silently. A collection that can no longer assign an id is a
843
+ // configuration fault (a non-injective id transform), and a bare 409
844
+ // with no diagnostic is indistinguishable from an ordinary duplicate.
845
+ log.error?.(`[@stonyx/orm] ${error.message}`);
846
+
847
+ return 409; // Conflict
848
+ }
849
+
801
850
  const record = isOrmRecord(created) ? created : null;
802
851
  if (!record) return 500;
803
852
 
@@ -822,11 +871,32 @@ export default class OrmRequest extends Request {
822
871
  //
823
872
  // Both conditions are required and neither implies the other:
824
873
  // createdNewSlot -- the store grew, so this request inserted rather
825
- // than overwrote. Guards `assignRecordId` picking an
826
- // id that is already taken (it returns
827
- // last-INSERTED + 1, not max + 1, so a store whose
828
- // insertion order is not ascending collides) -- see
829
- // abofs/stonyx-orm#203.
874
+ // than overwrote. SURVIVOR AS OF #203, AND THAT IS
875
+ // WHAT THIS NOTE IS FOR. It used to be killable:
876
+ // `assignRecordId` returned last-INSERTED + 1, so a
877
+ // server-assigned id could land on an occupied slot,
878
+ // `createRecord` updated in place, and removing this
879
+ // half turned access-filter-enforcement-test.ts
880
+ // assertion 31 red. #203 closed that: the
881
+ // server-assigned path now walks past occupied keys,
882
+ // so no create reaching here can overwrite. Measured
883
+ // -- delete `createdNewSlot &&` below: `dev` gives
884
+ // 55 pass / 1 fail with assertion 31 RED, this tree
885
+ // gives 56 pass / 0 fail, GREEN.
886
+ // KEPT ANYWAY, AND NOT FOR THE OLD REASON. Without
887
+ // it a denied create becomes `store.remove` on a key
888
+ // the caller may have influenced, which :815-820
889
+ // records as having been an unauthenticated deletion
890
+ // primitive across the whole id space. BECOMES
891
+ // KILLABLE AGAIN the moment any caller-supplied id
892
+ // can reach `createRecord` from this handler --
893
+ // which is exactly what has-many.ts:65 and
894
+ // belongs-to.ts:45 already do for ANOTHER model's
895
+ // store (abofs/stonyx-orm#207), and what a third
896
+ // un-stripped id channel would do for this one
897
+ // (#204). Do not delete it on the strength of #203
898
+ // being closed; that is the reasoning :862-867 warns
899
+ // about, one level up.
830
900
  // identity -- the slot still holds the object we just created,
831
901
  // so nothing between createRecord and here replaced
832
902
  // it. Deleting this half SURVIVES the suite, and it
@@ -1228,14 +1298,21 @@ export default class OrmRequest extends Request {
1228
1298
  const relatedData = record.__relationships[relationshipName];
1229
1299
  const baseUrl = getBaseUrl(request);
1230
1300
 
1301
+ // LINKAGE ONLY. This filter decides which ids the emitted documents may
1302
+ // NAME in their own `relationships.*.data`; it does NOT decide whether
1303
+ // the related records themselves are served -- that is the parent-only
1304
+ // filtering this route has done since #190, and widening it to the
1305
+ // related record is abofs/stonyx-orm#196.
1306
+ const linkage = createLinkageFilter(request);
1307
+
1231
1308
  let data: unknown;
1232
1309
  if (info.isArray) {
1233
1310
  // hasMany - return array
1234
1311
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1235
- data = related.map(r => r.toJSON?.({ baseUrl }));
1312
+ data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1236
1313
  } else {
1237
1314
  // belongsTo - return single or null
1238
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
1315
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1239
1316
  }
1240
1317
 
1241
1318
  return {
@@ -1352,24 +1429,23 @@ export default class OrmRequest extends Request {
1352
1429
  return 403; // Forbidden
1353
1430
  }
1354
1431
 
1355
- if (!access) return 403;
1356
- if (typeof access === 'function') {
1357
- state.filter = access;
1358
- return undefined;
1359
- }
1360
- if (access === true) return undefined;
1361
-
1362
- // `AccessMethod` declares `string` legal and it fell through every branch
1363
- // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
1364
- // is the natural reading of a type that lists `string` first, and it
1365
- // granted DELETE. A bare string is one permission, not a grant of all four.
1366
- const permitted = typeof access === 'string' ? [access] : access;
1367
-
1368
- // Anything that is not a permission array by this point -- an object, a
1369
- // number, a Symbol -- is a consumer mistake, and the only safe reading of a
1370
- // shape the contract does not define is a denial. Fail CLOSED.
1371
- if (!Array.isArray(permitted)) return 403;
1372
- if (!permitted.includes(methodAccessMap[request.method])) return 403;
1432
+ // THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
1433
+ //
1434
+ // It used to be inline here, and it was the only copy, which was fine while
1435
+ // `auth()` was the only thing that had to ask. It is not any more: the
1436
+ // linkage path has to ask model X's predicate about model X's records while
1437
+ // servicing a request routed to model Y, and a second inline copy of these
1438
+ // six branches would be a second authorization vocabulary -- one that can
1439
+ // drift, and that reviewers would have to notice had drifted. The branch
1440
+ // order in `interpretAccess` is this block, moved, not rewritten.
1441
+ const verdict = interpretAccess(access, methodAccessMap[request.method]);
1442
+
1443
+ if (!verdict.granted) return 403;
1444
+
1445
+ // The function return shape is the per-record hook, and `state` is the
1446
+ // whole transport for it: @stonyx/rest-server memoises one state object per
1447
+ // request and hands the same one to `auth()` and to the handler.
1448
+ if (verdict.filter) state.filter = verdict.filter;
1373
1449
 
1374
1450
  return undefined;
1375
1451
  }
package/src/record.ts CHANGED
@@ -7,6 +7,18 @@ import type Serializer from './serializer.js';
7
7
  interface ToJSONOptions {
8
8
  fields?: Set<string>;
9
9
  baseUrl?: string;
10
+ /**
11
+ * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
12
+ * request (abofs/stonyx-orm#234). Returning `false` for a related record
13
+ * drops that record's `{ type, id }` from `relationships.*.data`.
14
+ *
15
+ * This method APPLIES a verdict; it never RESOLVES one -- see
16
+ * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
17
+ * the default and the default is TODAY'S DOCUMENT, unchanged, because
18
+ * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
19
+ * syntactic place to pass this (abofs/stonyx-orm#230).
20
+ */
21
+ linkage?: (type: string, record: unknown) => boolean;
10
22
  }
11
23
 
12
24
  interface SerializeOptions {
@@ -116,7 +128,13 @@ export default class Record {
116
128
  toJSON(options: ToJSONOptions = {}): JSONAPIResult {
117
129
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
118
130
 
119
- const { fields, baseUrl } = options;
131
+ // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
132
+ // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
133
+ // arrives here as `toJSON('data')` -- a STRING in the options slot.
134
+ // Destructuring a string yields `undefined` for every key, which is exactly
135
+ // the no-argument default, so the implicit path keeps working and keeps
136
+ // emitting today's document (abofs/stonyx-orm#230).
137
+ const { fields, baseUrl, linkage } = options;
120
138
  const { __data: data } = this;
121
139
  const modelName = this.__model.__name;
122
140
  const pluralizedModelName = getPluralName(modelName);
@@ -138,9 +156,21 @@ export default class Record {
138
156
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
139
157
  if (fields && !fields.has(key)) continue;
140
158
 
159
+ // The linkage decision is applied HERE, alongside the existing
160
+ // `__model` liveness check, and it produces exactly the shapes that
161
+ // check already produces: a dropped hasMany member leaves `data: []`,
162
+ // a dropped belongsTo leaves `data: null`. Both already ship -- a
163
+ // genuinely-empty hasMany emits `data: []` with links, and a cleaned
164
+ // belongsTo emits `data: null` -- so a filtered relationship is
165
+ // BYTE-IDENTICAL to an empty one and there is no new wire shape and no
166
+ // oracle. It never throws: a throw here escapes the enclosing
167
+ // `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
168
+ // neighbours with it, which is a far worse failure mode than a status.
169
+ const isLinkable = (r: Record) => !linkage || linkage(r.__model.__name, r);
170
+
141
171
  const relationshipData = Array.isArray(childRecord)
142
- ? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
143
- : (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
172
+ ? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
173
+ : (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
144
174
 
145
175
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
146
176
  const dasherizedKey = camelCaseToKebabCase(key);
@@ -8,6 +8,10 @@
8
8
 
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
+ // `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
12
+ // bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
13
+ // dependencies" property above still holds.
14
+ import { maxNumericId } from './utils.js';
11
15
 
12
16
  interface StandaloneDBOptions {
13
17
  dbPath?: string;
@@ -131,12 +135,19 @@ export default class StandaloneDB {
131
135
  const records = await this.readCollection(collection);
132
136
 
133
137
  if (!data.id) {
134
- const maxId = records.reduce((max, r) => {
135
- const rid = typeof r.id === 'number' ? r.id : 0;
136
- return rid > max ? rid : max;
137
- }, 0);
138
-
139
- data.id = maxId + 1;
138
+ // SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
139
+ // place this repo picks a server-assigned id. It was a second copy of the
140
+ // reduce, and nothing here pointed at it — a maintainer editing this
141
+ // method could not discover the other existed. See `maxNumericId` for why
142
+ // it is not `Math.max` (abofs/stonyx-orm#203).
143
+ //
144
+ // THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
145
+ // `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
146
+ // IS its store key; `assignRecordId` has to map the candidate through the
147
+ // model's declared id transform first, and then walk past occupied keys.
148
+ // Transplanting this method's remaining logic into the ORM reproduces
149
+ // #203's landing-key defect exactly — which is what AC4 pins.
150
+ data.id = maxNumericId(records) + 1;
140
151
  }
141
152
 
142
153
  // Check for duplicate id
@@ -89,7 +89,15 @@ export interface OrmRecord {
89
89
  __model?: { __name: string };
90
90
  __data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
91
91
  __relationships: Record<string, unknown>;
92
- toJSON?(options?: { fields?: Set<string>; baseUrl?: string }): Record<string, unknown>;
92
+ /**
93
+ * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
94
+ * the request (abofs/stonyx-orm#234): return `false` for a related record and
95
+ * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
96
+ * the default, and the default is the pre-#234 document unchanged -- this
97
+ * method is also the `JSON.stringify` hook, so an implicit caller has no
98
+ * syntactic place to pass it (abofs/stonyx-orm#230).
99
+ */
100
+ toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: (type: string, record: unknown) => boolean }): Record<string, unknown>;
93
101
  [key: string]: unknown;
94
102
  }
95
103
 
package/src/utils.ts CHANGED
@@ -20,3 +20,53 @@ export function pluralize(word: string): string {
20
20
 
21
21
  return basePluralize(word);
22
22
  }
23
+
24
+ /**
25
+ * The highest NUMERIC id held by a set of records, or `0` when there is none.
26
+ *
27
+ * ONE COPY, and the duplication it replaces is the reason it lives here. Three
28
+ * near-identical reduces existed at once: `assignRecordId` (server-assigned id
29
+ * selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
30
+ * helper. `docs/improvements.md`'s standing WET Code category prescribes
31
+ * exactly this remedy -- extract into the module that already acts as the
32
+ * shared utility -- and `assignRecordId` already imported `isOrmRecord` from
33
+ * here.
34
+ *
35
+ * NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
36
+ * `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
37
+ * `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
38
+ * `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
39
+ * produce IDENTICAL output on every one. In particular `typeof NaN` is
40
+ * `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
41
+ * which is `false`. An earlier revision of this code asserted that the skip was
42
+ * what made the `NaN` case work; it is not, the comparison is, and that claim
43
+ * has been removed rather than left standing.
44
+ *
45
+ * WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
46
+ * returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
47
+ * `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
48
+ * it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
49
+ * test/unit/assign-record-id-test.ts AC2; before that file existed the whole
50
+ * suite scored 951/0 under exactly that fix.
51
+ */
52
+ export function maxNumericId(records: { id?: unknown }[]): number {
53
+ return records.reduce((max: number, record) => {
54
+ const { id } = record;
55
+
56
+ return typeof id === 'number' && id > max ? id : max;
57
+ }, 0);
58
+ }
59
+
60
+ /**
61
+ * The message prefix `assignRecordId` throws with when no free id can be
62
+ * derived for a model, and the ONE string `createHandler` matches on to answer
63
+ * `409` instead of letting the rejection reach express's default handler.
64
+ *
65
+ * It lives here rather than in either file because both need it and neither
66
+ * should own a copy: a literal in two places is how the two id coercions in
67
+ * orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
68
+ * and no custom error classes -- 24 bare `throw new Error` sites across `src/`
69
+ * -- so a shared prefix is the narrowest way to make ONE failure distinguishable
70
+ * without inventing an error taxonomy this codebase does not use.
71
+ */
72
+ export const NO_FREE_ID_ERROR = 'Cannot assign record ID: no free id available';