@stonyx/orm 0.3.2-alpha.108 → 0.3.2-alpha.109

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
@@ -40,6 +40,12 @@ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__se
40
40
  npm install @stonyx/orm
41
41
  ````
42
42
 
43
+ That is the whole install for an ORM-only app. The database drivers and
44
+ `@stonyx/rest-server` are **optional peer dependencies**: a default install does
45
+ not put them on disk, and none of them is loaded unless your configuration asks
46
+ for it. Add only the ones you actually use — see
47
+ [Optional peer dependencies](#optional-peer-dependencies).
48
+
43
49
  ## Usage example
44
50
 
45
51
  This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
@@ -106,7 +112,9 @@ export default {
106
112
  tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
107
113
  } : undefined,
108
114
  restServer: {
109
- enabled: ORM_USE_REST_SERVER ?? 'true',
115
+ // 'true' requires @stonyx/rest-server to be installed — see
116
+ // "Optional peer dependencies" below.
117
+ enabled: ORM_USE_REST_SERVER ?? 'false',
110
118
  route: ORM_REST_ROUTE ?? '/'
111
119
  }
112
120
  }
@@ -127,6 +135,46 @@ stonyx serve
127
135
 
128
136
  For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
129
137
 
138
+ ## Optional peer dependencies
139
+
140
+ `@stonyx/orm` boots with **none** of its optional peers installed. Each one is
141
+ imported lazily, and only when your configuration selects it:
142
+
143
+ | Configuration | Package you must install |
144
+ |---|---|
145
+ | `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
146
+ | `orm.postgres` or `orm.timescale` | `pg` |
147
+ | `orm.mysql` | `mysql2` |
148
+ | `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
149
+
150
+ If a configuration key selects one that is not on disk, the failure surfaces
151
+ from `Orm.init()` while the framework boots:
152
+
153
+ ```
154
+ Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
155
+ ```
156
+
157
+ **ORM-only, no REST server.** This is the shape the `environment.js` above is
158
+ written for: keep `orm.restServer.enabled` at `'false'` and install nothing
159
+ beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
160
+ work unchanged; only the generated REST routes are absent.
161
+
162
+ > **The module's own default is the other way round.** The `config/environment.js`
163
+ > that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
164
+ > an app that omits the `restServer` key *entirely* gets REST switched on and
165
+ > needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
166
+ > you want it.
167
+
168
+ **Turning REST on.**
169
+
170
+ ```bash
171
+ npm install @stonyx/rest-server
172
+ ORM_USE_REST_SERVER=true stonyx serve
173
+ ```
174
+
175
+ See [REST Server Integration](#rest-server-integration) for access classes and
176
+ route configuration.
177
+
130
178
  ## Models
131
179
 
132
180
  Define a model with attributes and relationships:
@@ -322,17 +370,19 @@ export default class OwnerAccess {
322
370
  models = ['owner'];
323
371
 
324
372
  access(request) {
325
- // `request.recordId` is the id the ORM resolves the record by. The ORM
326
- // computes it before `access()` runs, from `request.params.id`, using the
327
- // same function the record lookup uses — so your predicate and the lookup
328
- // cannot disagree. Authorize on it, never on a URL and never on raw
329
- // `params.id`.
330
- const { recordId } = request;
373
+ // `access` runs after route matching, so `request.params` is populated and
374
+ // `id` has already been URL-decoded. Authorize on it, never on a URL.
375
+ const { id } = request.params;
376
+
377
+ // `id` is still raw client text. Normalise it the way the record lookup
378
+ // does, or your predicate and the lookup disagree — see "Numeric ids" below.
379
+ // No radix on parseInt: that is deliberate, and it must stay that way.
380
+ const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
331
381
 
332
382
  // Returning false explicitly denies access to this record
333
383
  if (recordId === 'angela') return false;
334
384
 
335
- // No `recordId` means the collection route. Returning a function plugs it in to
385
+ // No `id` means the collection route. Returning a function plugs it in to
336
386
  // the response object as a filter. NOTE: a function return authorizes the
337
387
  // request outright — the operations list below is not consulted — so this
338
388
  // branch permits POST /owners as well as reads.
@@ -365,77 +415,31 @@ unrecognised spelling falls through to whatever your method returns next, so
365
415
  prefer one class per model. A class may still list several models in `models`
366
416
  when they share one rule.
367
417
 
368
- **Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
369
- `params.id` is raw text from the client. When it looks numeric the ORM coerces
370
- it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
371
- `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
372
- all address record `7`, while a `===` against the raw text matches only the one
373
- spelling you wrote down. Every other spelling would fall through to whatever
374
- your method returns next — which, in the shape above, is a full CRUD grant. All
375
- of them are plain address-bar requests.
376
-
377
- **The ORM does that normalisation for you and hands you the result.** `access()`
378
- is called with `request.recordId` already set to the value the record will be
379
- resolved by, so there is no arithmetic to copy into your predicate and nothing
380
- to keep in sync abofs/stonyx-orm#270. It is `undefined` on collection routes,
381
- which is how you tell a collection request from a record request. `params.id` is
382
- left untouched, so anything that needs the raw client text still has it.
383
-
384
- **Version floor: `request.recordId` requires the release that carries
385
- abofs/stonyx-orm#270.** Measured on the published tarballs: `0.3.2-alpha.106`
386
- (this change's alpha) has `request.recordId` and exports `normalizeRecordId`;
387
- `0.3.2-beta.231`, the newest build on the `beta` tag at the time of writing, has
388
- neither, and `latest` is `0.3.1`. **On any earlier build the samples above fail
389
- open, completely** — measured against `origin/dev`: `recordId` is `undefined` on
390
- every route, so `if (recordId === undefined) return record => …` fires on the
391
- *record* route, and a function return authorizes the request outright.
392
- `GET /owners/angela` and `GET /animals/7` both returned 200 and served the
393
- protected record, and `DELETE` on both returned 204 and destroyed it.
394
-
395
- If you cannot pin the version, make the samples fail *closed* instead — one line,
396
- a no-op on a new build and a refusal on an old one:
397
-
398
- ```javascript
399
- access(request) {
400
- // Old builds do not attach recordId. Refuse rather than fall through to the
401
- // collection branch, which would authorize the record route outright.
402
- if (!('recordId' in request)) return false;
403
-
404
- const { recordId } = request;
405
- // …
406
- }
407
- ```
408
-
409
- The same normalisation is exported for the places `access()` does not reach —
410
- hooks, custom handlers, your own lookups:
411
-
412
- ```javascript
413
- import { normalizeRecordId } from '@stonyx/orm';
414
-
415
- normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
416
- ```
417
-
418
- Do not re-implement it. A hand-written copy is correct only for as long as it
419
- happens to match, and nothing holds the two together.
420
-
421
- **`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
422
- section documents side by side do not agree, and the difference is load-bearing:
423
- `request.recordId` is `undefined` on a collection route, while
424
- `normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
425
- `store.get(key, undefined)` returns the whole model Map rather than a record
426
- (abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
427
- is **never** true and a collection branch written that way never runs. Branch on
428
- `request.recordId === undefined`, or compare against `''`.
418
+ **Numeric ids: normalise before you compare.** `request.params.id` is raw text
419
+ from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
420
+ parseInt(id)` *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
421
+ `7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
422
+ trailing newline) all address record `7`, while a `===` against the raw text
423
+ matches only the one spelling you wrote down. Every other spelling falls through
424
+ to whatever your method returns next — which, in the shape above, is a full CRUD
425
+ grant. All of them are plain address-bar requests.
426
+
427
+ Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
428
+ is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
429
+ hex spelling. And the coercion applies only when the id looks numeric, so a
430
+ model with string ids (like `owner` above) is unaffected which is exactly why
431
+ this is easy to miss. Normalise the same way the lookup does:
429
432
 
430
433
  ```javascript
431
434
  export default class AnimalAccess {
432
435
  models = ['animal'];
433
436
 
434
437
  access(request) {
435
- // Already normalised by the ORM, and it agrees with the lookup for every
436
- // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
437
- // string, so compare it against a number.
438
- const { recordId } = request;
438
+ const { id } = request.params;
439
+
440
+ // Agrees with the lookup for every spelling of 7 above. Compare the
441
+ // coerced value, which for a numeric-id model is a number, not a string.
442
+ const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
439
443
 
440
444
  if (recordId === 7) return false;
441
445
 
@@ -667,13 +671,9 @@ beforeHook('create', 'animal', (context) => {
667
671
  }
668
672
  });
669
673
 
670
- // Return an object to send a custom response.
671
- // Look the record up by `context.request.recordId` — the value the ORM
672
- // resolved the record by. `context.params.id` is the raw client text and is a
673
- // different value for every alias of the same id, so a lookup keyed on it
674
- // finds nothing on a numeric-id model (abofs/stonyx-orm#270).
674
+ // Return an object to send a custom response
675
675
  beforeHook('delete', 'animal', (context) => {
676
- const animal = store.get('animal', context.request.recordId);
676
+ const animal = store.get('animal', context.params.id);
677
677
  if (animal.protected) {
678
678
  return { errors: [{ detail: 'Cannot delete protected animals' }] };
679
679
  }
@@ -714,12 +714,9 @@ afterHook('update', 'animal', async (context) => {
714
714
  }
715
715
  });
716
716
 
717
- // Cache invalidation.
718
- // Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
719
- // one record but two strings, so a raw-text key invalidates two entries and
720
- // misses the one the write used.
717
+ // Cache invalidation
721
718
  afterHook('delete', 'animal', async (context) => {
722
- await cache.invalidate(`owner:${context.request.recordId}:pets`);
719
+ await cache.invalidate(`owner:${context.params.id}:pets`);
723
720
  });
724
721
  ```
725
722
 
@@ -760,16 +757,10 @@ afterHook('delete', 'animal', async (context) => {
760
757
  #### Authorization
761
758
 
762
759
  ```javascript
763
- // Additional access control - halt with 403 if unauthorized.
764
- //
765
- // `context.request.recordId` is the id the ORM resolved the record by — the
766
- // same value `access()` is handed. Authorizing on `context.params.id` instead
767
- // looks up the raw client text: on a numeric-id model that lookup returns
768
- // `undefined` for EVERY spelling, `animal.owner` throws, and the check never
769
- // runs. Measured; abofs/stonyx-orm#270.
760
+ // Additional access control - halt with 403 if unauthorized
770
761
  beforeHook('delete', 'animal', (context) => {
771
762
  const user = context.state.currentUser;
772
- const animal = store.get('animal', context.request.recordId);
763
+ const animal = store.get('animal', context.params.id);
773
764
 
774
765
  if (animal.owner !== user.id && !user.isAdmin) {
775
766
  return 403; // Forbidden
@@ -970,52 +961,12 @@ test('validation hook rejects negative age', async () => {
970
961
  | `afterHook` | Register an after hook for post-operation logic. |
971
962
  | `clearHook` | Clear hooks for a specific operation:model. |
972
963
  | `clearAllHooks` | Clear all registered hooks (useful for testing). |
973
- | `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
974
-
975
- ### `normalizeRecordId(id)`
976
-
977
- ```ts
978
- normalizeRecordId(id?: string | null): string | number
979
- ```
980
-
981
- The **one** implementation of URL-id normalisation in the package
982
- (abofs/stonyx-orm#270). `access()` is already handed its result as
983
- `request.recordId`; import it for the places `access()` does not reach — hooks,
984
- custom handlers, your own lookups. Synchronous, and it must stay synchronous:
985
- `auth()` is invoked without `await`.
986
-
987
- | Input | Returns | Note |
988
- | --- | --- | --- |
989
- | `'7'`, `'007'`, `'7.0'`, `'7.9'`, `'7e0'`, `'0x7'`, `'+7'`, `' 7'` | `7` (number) | `parseInt` with **no radix**; passing a radix of 10 would make this `0` |
990
- | `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
991
- | `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
992
- | `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
993
- | `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
994
-
995
- **The trap.** `request.recordId` is `undefined` on a collection route;
996
- `normalizeRecordId(undefined)` is `''`. They are different values and a
997
- collection check written against the wrong one silently never fires:
998
-
999
- ```javascript
1000
- // WRONG — never true, so this branch never runs
1001
- if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
1002
-
1003
- // Right — the ORM attaches undefined for "this route carries no :id"
1004
- if (context.request.recordId === undefined) { /* collection route */ }
1005
- ```
1006
-
1007
- The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
1008
- rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
1009
- the empty string this function returns today.
1010
-
1011
- **A normalised id is not a promise that a record exists.** `0` and `NaN` are both
1012
- possible returns and neither addresses a record you can rely on — `NaN` is not
1013
- even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
1014
- `recordId` as "the key the lookup will use", not as "a record is there".
1015
964
 
1016
965
  ## Project Structure
1017
966
 
1018
- For a full architectural reference, see [project-structure.md](project-structure.md).
967
+ For a full architectural reference, see
968
+ [docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
969
+ That file is repo-only — it is not in the published tarball, so the link is absolute.
1019
970
 
1020
971
  ## License
1021
972
 
package/dist/index.d.ts CHANGED
@@ -12,6 +12,4 @@ export type { PersistErrorDetail } from './main.js';
12
12
  export { Model, View, Serializer };
13
13
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
14
14
  export { count, avg, sum, min, max };
15
- export { default as normalizeRecordId } from './normalize-record-id.js';
16
- export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js';
17
15
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js';
package/dist/index.js CHANGED
@@ -26,7 +26,6 @@ export { store, relationships } from './main.js';
26
26
  export { Model, View, Serializer }; // base classes
27
27
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
28
28
  export { count, avg, sum, min, max }; // aggregate helpers
29
- export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
30
29
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
31
30
  // Store API:
32
31
  // store.get(model, id) -- sync, memory-only
package/dist/main.js CHANGED
@@ -19,7 +19,6 @@ import log from 'stonyx/log';
19
19
  import { forEachFileImport } from '@stonyx/utils/file';
20
20
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
21
21
  import { registerPluralName } from './plural-registry.js';
22
- import setupRestServer from './setup-rest-server.js';
23
22
  import baseTransforms from './transforms.js';
24
23
  import Store from './store.js';
25
24
  import Serializer from './serializer.js';
@@ -131,6 +130,23 @@ export default class Orm {
131
130
  promises.push(db.init());
132
131
  }
133
132
  if (restServer.enabled === 'true') {
133
+ // MUST stay dynamic. setup-rest-server.js names the optional
134
+ // '@stonyx/rest-server' peer in its own static graph — directly, and
135
+ // through orm-request.ts / meta-request.ts, which import `Request` at
136
+ // module scope because they extend it (correctly: an `extends` base
137
+ // class cannot be awaited). Node links a module's entire static graph
138
+ // before evaluating any of it, so a static import here puts that
139
+ // specifier on the entry graph and `import('@stonyx/orm')` throws
140
+ // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
141
+ // optional peer.
142
+ //
143
+ // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
144
+ // modules carry no static peer specifier at all, so the `await import()`
145
+ // there is not what isolates pg / mysql2 / @aws-sdk — that happens one
146
+ // layer down, in src/*/connection.ts (and src/dynamodb/dynamodb-db.ts).
147
+ // setup-rest-server.js is the only dist module whose laziness is
148
+ // load-bearing for peer resolution. (#280)
149
+ const { default: setupRestServer } = await import('./setup-rest-server.js');
134
150
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
135
151
  }
136
152
  // Wire up memory resolver so store.find() can check model memory flags
@@ -1,26 +1,11 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- /**
3
- * The request object a consumer's `access()` predicate receives.
4
- *
5
- * Exported because `recordId` is public API — README's `access()` samples
6
- * destructure it — and a public runtime field with an unreachable type asks the
7
- * consumer to re-declare something the framework already knows, which is
8
- * abofs/stonyx-orm#270's own defect shape one layer over into the type surface.
9
- * `HookContext` (src/hooks.ts) is this repo's precedent for exporting the
10
- * interface a consumer is handed. Re-exported from the root barrel as
11
- * `OrmAccessRequest` (src/index.ts) — NOT as `OrmRequest`, which is already the
12
- * default-exported CLASS in this file and means something else. The
13
- * `./orm-request` subpath is not in the `exports` map, so the barrel is the
14
- * only reachable spelling.
15
- */
16
- export interface OrmRequest$ extends Request {
2
+ interface OrmRequest$ extends Request {
17
3
  protocol?: string;
18
4
  baseUrl?: string;
19
5
  method: string;
20
6
  params: {
21
7
  [key: string]: string;
22
8
  };
23
- recordId?: string | number | undefined;
24
9
  body?: {
25
10
  [key: string]: unknown;
26
11
  };
@@ -5,7 +5,6 @@ import { getPluralName } from './plural-registry.js';
5
5
  import { getBeforeHooks, getAfterHooks } from './hooks.js';
6
6
  import config from 'stonyx/config';
7
7
  import { isOrmRecord } from './utils.js';
8
- import normalizeRecordId from './normalize-record-id.js';
9
8
  const methodAccessMap = {
10
9
  GET: 'read',
11
10
  POST: 'create',
@@ -66,23 +65,13 @@ function getBaseUrl(request, pluralizedModel) {
66
65
  const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
67
66
  return `${protocol}://${host}${prefix}`;
68
67
  }
69
- // Kept as a name because twelve `getId(...)` call sites read it. It is now a
70
- // thin delegate: there is exactly ONE normalisation of a URL id in the repo,
71
- // and it is the exported one a consumer can import.
72
- //
73
- // "of a URL id" is the load-bearing qualifier, and it is the same one
74
- // src/normalize-record-id.ts:18 carries. Three copies of the coercion survive
75
- // at this head — src/orm-request.ts (the create-response path),
76
- // src/postgres/postgres-db.ts and src/mysql/mysql-db.ts — but each normalises a
77
- // RESPONSE id (`response?.data?.id`), not a URL id, and each omits this
78
- // function's `if (!id) return ''` guard. They are tracked as
79
- // abofs/stonyx-orm#282 and enumerated by name in AC-5's allowlist
80
- // (test/integration/readme-sample-test.ts).
81
- //
82
- // A second implementation of the URL-id normalisation here is the defect
83
- // abofs/stonyx-orm#270 exists to remove — see src/normalize-record-id.ts.
84
68
  function getId(params) {
85
- return normalizeRecordId(params.id);
69
+ const id = params.id;
70
+ if (!id)
71
+ return '';
72
+ if (isNaN(id))
73
+ return id;
74
+ return parseInt(id);
86
75
  }
87
76
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
88
77
  const { links, baseUrl } = options;
@@ -492,35 +481,6 @@ export default class OrmRequest extends Request {
492
481
  return routes;
493
482
  }
494
483
  auth(request, state) {
495
- // abofs/stonyx-orm#270 — hand the predicate the value the record is
496
- // ACTUALLY resolved by, rather than the raw text it was parsed from.
497
- //
498
- // This is the default path precisely because it requires the consumer to
499
- // remember nothing. Exporting `normalizeRecordId` alone would leave the
500
- // framework owning resolution while asking every consumer to call one
501
- // function, with no signal when they forget — the same silent fail-open,
502
- // one step over. The export is the escape hatch for hooks and custom
503
- // handlers; this line is the contract.
504
- //
505
- // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
506
- // sites read it, and `_withHooks` assigns `params: request.params` onto the
507
- // hook context, so every consumer hook reads the same object. Changing
508
- // `params.id` from string to number underneath them is a silent behaviour
509
- // change on paths this issue is not about.
510
- //
511
- // Synchronous by necessity: @stonyx/rest-server calls auth() without
512
- // awaiting it.
513
- //
514
- // The `undefined` branch is a PRESENCE check, not a second normalisation:
515
- // it answers "does this route carry an :id at all", which is how both
516
- // documented samples tell a collection request from a record request. It
517
- // cannot be folded into normalizeRecordId, because that function must keep
518
- // returning '' for a falsy id — `store.get(key, undefined)` returns the
519
- // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
520
- // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
521
- // the '' it returns today.
522
- const rawId = request.params?.id;
523
- request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
524
484
  const access = this.access(request);
525
485
  if (!access)
526
486
  return 403;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.108",
7
+ "version": "0.3.2-alpha.109",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -106,7 +106,7 @@
106
106
  "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts' && ORM_TEST_ROUTE=/ pnpm test:mounted && ORM_TEST_ROUTE=/api pnpm test:mounted && ORM_TEST_ROUTE=api pnpm test:mounted && ORM_TEST_ROUTE=/api/v1 pnpm test:mounted && ORM_TEST_ROUTE=/api/ pnpm test:mounted && pnpm test:readme && pnpm test:reference",
107
107
  "test:mounted": "node --import tsx/esm --import ./test/integration/mounted-route/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/mounted-route/links-mounted.ts' 'test/zz-exit-test.ts'",
108
108
  "test:readme": "pnpm build && node --import tsx/esm --import ./test/integration/readme-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/readme-access/readme-sample.ts' 'test/zz-exit-test.ts'",
109
- "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/integration/reference-access/record-id-resolution.ts' 'test/zz-exit-test.ts'",
109
+ "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/zz-exit-test.ts'",
110
110
  "test:dynamodb": "pnpm build && node --import tsx/esm --import ./test/integration/dynamodb/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/dynamodb/**/*-test.ts'"
111
111
  }
112
112
  }
package/src/index.ts CHANGED
@@ -30,8 +30,6 @@ export type { PersistErrorDetail } from './main.js';
30
30
  export { Model, View, Serializer }; // base classes
31
31
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
32
32
  export { count, avg, sum, min, max }; // aggregate helpers
33
- export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
34
- export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js'; // the request access() is handed, incl. recordId (#270)
35
33
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
36
34
 
37
35
  // Store API:
package/src/main.ts CHANGED
@@ -20,7 +20,6 @@ import log from 'stonyx/log';
20
20
  import { forEachFileImport } from '@stonyx/utils/file';
21
21
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
22
22
  import { registerPluralName } from './plural-registry.js';
23
- import setupRestServer from './setup-rest-server.js';
24
23
  import baseTransforms from './transforms.js';
25
24
  import Store from './store.js';
26
25
  import Serializer from './serializer.js';
@@ -177,6 +176,23 @@ export default class Orm {
177
176
  }
178
177
 
179
178
  if (restServer.enabled === 'true') {
179
+ // MUST stay dynamic. setup-rest-server.js names the optional
180
+ // '@stonyx/rest-server' peer in its own static graph — directly, and
181
+ // through orm-request.ts / meta-request.ts, which import `Request` at
182
+ // module scope because they extend it (correctly: an `extends` base
183
+ // class cannot be awaited). Node links a module's entire static graph
184
+ // before evaluating any of it, so a static import here puts that
185
+ // specifier on the entry graph and `import('@stonyx/orm')` throws
186
+ // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
187
+ // optional peer.
188
+ //
189
+ // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
190
+ // modules carry no static peer specifier at all, so the `await import()`
191
+ // there is not what isolates pg / mysql2 / @aws-sdk — that happens one
192
+ // layer down, in src/*/connection.ts (and src/dynamodb/dynamodb-db.ts).
193
+ // setup-rest-server.js is the only dist module whose laziness is
194
+ // load-bearing for peer resolution. (#280)
195
+ const { default: setupRestServer } = await import('./setup-rest-server.js');
180
196
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
181
197
  }
182
198
 
@@ -7,23 +7,8 @@ import type { HookContext } from './hooks.js';
7
7
  import config from 'stonyx/config';
8
8
  import type { OrmRecord } from './types/orm-types.js';
9
9
  import { isOrmRecord } from './utils.js';
10
- import normalizeRecordId from './normalize-record-id.js';
11
10
 
12
- /**
13
- * The request object a consumer's `access()` predicate receives.
14
- *
15
- * Exported because `recordId` is public API — README's `access()` samples
16
- * destructure it — and a public runtime field with an unreachable type asks the
17
- * consumer to re-declare something the framework already knows, which is
18
- * abofs/stonyx-orm#270's own defect shape one layer over into the type surface.
19
- * `HookContext` (src/hooks.ts) is this repo's precedent for exporting the
20
- * interface a consumer is handed. Re-exported from the root barrel as
21
- * `OrmAccessRequest` (src/index.ts) — NOT as `OrmRequest`, which is already the
22
- * default-exported CLASS in this file and means something else. The
23
- * `./orm-request` subpath is not in the `exports` map, so the barrel is the
24
- * only reachable spelling.
25
- */
26
- export interface OrmRequest$ extends Request {
11
+ interface OrmRequest$ extends Request {
27
12
  protocol?: string;
28
13
  // Express sets this to the path the router was mounted at, e.g. '/api/animals'
29
14
  // when orm.restServer.route is '/api'. Optional because non-Express callers
@@ -31,14 +16,6 @@ export interface OrmRequest$ extends Request {
31
16
  baseUrl?: string;
32
17
  method: string;
33
18
  params: { [key: string]: string };
34
- // Attached by auth() before access() runs — abofs/stonyx-orm#270. This is the
35
- // value the ORM resolves the record by; `params.id` remains the raw client
36
- // text it was parsed from.
37
- //
38
- // `undefined` is part of the contract, not an absence: it is how a collection
39
- // route is told from a record route, and both documented samples branch on
40
- // it. Spelled out rather than left to `?:` for that reason.
41
- recordId?: string | number | undefined;
42
19
  body?: { [key: string]: unknown };
43
20
  query?: { [key: string]: string };
44
21
  get(header: string): string;
@@ -130,23 +107,12 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
130
107
  return `${protocol}://${host}${prefix}`;
131
108
  }
132
109
 
133
- // Kept as a name because twelve `getId(...)` call sites read it. It is now a
134
- // thin delegate: there is exactly ONE normalisation of a URL id in the repo,
135
- // and it is the exported one a consumer can import.
136
- //
137
- // "of a URL id" is the load-bearing qualifier, and it is the same one
138
- // src/normalize-record-id.ts:18 carries. Three copies of the coercion survive
139
- // at this head — src/orm-request.ts (the create-response path),
140
- // src/postgres/postgres-db.ts and src/mysql/mysql-db.ts — but each normalises a
141
- // RESPONSE id (`response?.data?.id`), not a URL id, and each omits this
142
- // function's `if (!id) return ''` guard. They are tracked as
143
- // abofs/stonyx-orm#282 and enumerated by name in AC-5's allowlist
144
- // (test/integration/readme-sample-test.ts).
145
- //
146
- // A second implementation of the URL-id normalisation here is the defect
147
- // abofs/stonyx-orm#270 exists to remove — see src/normalize-record-id.ts.
148
110
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
149
- return normalizeRecordId(params.id);
111
+ const id = params.id;
112
+ if (!id) return '';
113
+ if (isNaN(id as unknown as number)) return id;
114
+
115
+ return parseInt(id);
150
116
  }
151
117
 
152
118
  function buildResponse(
@@ -632,37 +598,6 @@ export default class OrmRequest extends Request {
632
598
  }
633
599
 
634
600
  auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
635
- // abofs/stonyx-orm#270 — hand the predicate the value the record is
636
- // ACTUALLY resolved by, rather than the raw text it was parsed from.
637
- //
638
- // This is the default path precisely because it requires the consumer to
639
- // remember nothing. Exporting `normalizeRecordId` alone would leave the
640
- // framework owning resolution while asking every consumer to call one
641
- // function, with no signal when they forget — the same silent fail-open,
642
- // one step over. The export is the escape hatch for hooks and custom
643
- // handlers; this line is the contract.
644
- //
645
- // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
646
- // sites read it, and `_withHooks` assigns `params: request.params` onto the
647
- // hook context, so every consumer hook reads the same object. Changing
648
- // `params.id` from string to number underneath them is a silent behaviour
649
- // change on paths this issue is not about.
650
- //
651
- // Synchronous by necessity: @stonyx/rest-server calls auth() without
652
- // awaiting it.
653
- //
654
- // The `undefined` branch is a PRESENCE check, not a second normalisation:
655
- // it answers "does this route carry an :id at all", which is how both
656
- // documented samples tell a collection request from a record request. It
657
- // cannot be folded into normalizeRecordId, because that function must keep
658
- // returning '' for a falsy id — `store.get(key, undefined)` returns the
659
- // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
660
- // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
661
- // the '' it returns today.
662
- const rawId = request.params?.id;
663
-
664
- request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
665
-
666
601
  const access = this.access(request);
667
602
 
668
603
  if (!access) return 403;
@@ -1,67 +0,0 @@
1
- /**
2
- * The ONE place a URL id is turned into the value a record is resolved by —
3
- * abofs/stonyx-orm#270.
4
- *
5
- * Before this existed this coercion was written out seven times: twice in
6
- * README.md, once in docs/usage-patterns.md, and four times inside the
7
- * framework. Seven copies, not seven identical copies — the three persistence
8
- * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
9
- * is the point: nothing held them together, so they had already drifted.
10
- * The framework's copy — `getId()`, module-private in
11
- * orm-request.ts, unreachable through the package `exports` map — was the one
12
- * that decided which record a request addressed, and a consumer's `access()`
13
- * predicate had no way to obtain it. So the framework resolved the record by
14
- * one value and asked the consumer to authorize on a different one.
15
- *
16
- * Measured consequence at the time of filing: with the documented predicate
17
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
18
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
19
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
20
- * those onto `7` while `'007' === '7'` is false.
21
- *
22
- * Two things follow from this being a single exported function, and both are
23
- * the point:
24
- *
25
- * 1. A permissive change here is HARMLESS, because both sides move together.
26
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
27
- * the whole suite green; with one implementation the predicate simply sees
28
- * the same lowercased value and still refuses.
29
- * 2. A divergence — a second, private normaliser at the resolution site — is
30
- * what is now dangerous, and that is what the tests pin, by observing the
31
- * key the resolution path actually used.
32
- *
33
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
34
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
35
- * question, and it is a behaviour change on the record-resolution path for
36
- * every consumer rather than an authorization fix — it belongs in its own
37
- * issue with its own compatibility argument (issue body scope item 4). #270
38
- * preserves today's semantics exactly and makes both sides agree on them.
39
- *
40
- * Two details are load-bearing and are pinned by
41
- * test/unit/normalize-record-id-test.ts:
42
- *
43
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
44
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
45
- * silently changes which record every hex-spelled URL addresses.
46
- * - The coercion applies only when the id LOOKS numeric, so a model with
47
- * string ids is passed through untouched, case included.
48
- *
49
- * Must stay synchronous: `auth()` is invoked without `await` by
50
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
51
- * record id.
52
- *
53
- * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
54
- * `''` is returned for any falsy id because `store.get(key, undefined)` returns
55
- * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
56
- * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
57
- * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
58
- * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
59
- * `store.remove(key, id)` branches on truthiness, so those spellings reach a
60
- * fall-through this function does not own. Tracked as #287; every row is pinned
61
- * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
62
- * boundary #287 is measured against.
63
- *
64
- * @param id the raw, already-URL-decoded id text from `request.params.id`
65
- * @returns the value the ORM resolves the record by
66
- */
67
- export default function normalizeRecordId(id?: string | null): string | number;
@@ -1,88 +0,0 @@
1
- /*
2
- * Copyright 2025 Stone Costa
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the 'License');
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an 'AS IS' BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
- /**
17
- * The ONE place a URL id is turned into the value a record is resolved by —
18
- * abofs/stonyx-orm#270.
19
- *
20
- * Before this existed this coercion was written out seven times: twice in
21
- * README.md, once in docs/usage-patterns.md, and four times inside the
22
- * framework. Seven copies, not seven identical copies — the three persistence
23
- * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
24
- * is the point: nothing held them together, so they had already drifted.
25
- * The framework's copy — `getId()`, module-private in
26
- * orm-request.ts, unreachable through the package `exports` map — was the one
27
- * that decided which record a request addressed, and a consumer's `access()`
28
- * predicate had no way to obtain it. So the framework resolved the record by
29
- * one value and asked the consumer to authorize on a different one.
30
- *
31
- * Measured consequence at the time of filing: with the documented predicate
32
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
33
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
34
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
35
- * those onto `7` while `'007' === '7'` is false.
36
- *
37
- * Two things follow from this being a single exported function, and both are
38
- * the point:
39
- *
40
- * 1. A permissive change here is HARMLESS, because both sides move together.
41
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
42
- * the whole suite green; with one implementation the predicate simply sees
43
- * the same lowercased value and still refuses.
44
- * 2. A divergence — a second, private normaliser at the resolution site — is
45
- * what is now dangerous, and that is what the tests pin, by observing the
46
- * key the resolution path actually used.
47
- *
48
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
49
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
50
- * question, and it is a behaviour change on the record-resolution path for
51
- * every consumer rather than an authorization fix — it belongs in its own
52
- * issue with its own compatibility argument (issue body scope item 4). #270
53
- * preserves today's semantics exactly and makes both sides agree on them.
54
- *
55
- * Two details are load-bearing and are pinned by
56
- * test/unit/normalize-record-id-test.ts:
57
- *
58
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
59
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
60
- * silently changes which record every hex-spelled URL addresses.
61
- * - The coercion applies only when the id LOOKS numeric, so a model with
62
- * string ids is passed through untouched, case included.
63
- *
64
- * Must stay synchronous: `auth()` is invoked without `await` by
65
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
66
- * record id.
67
- *
68
- * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
69
- * `''` is returned for any falsy id because `store.get(key, undefined)` returns
70
- * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
71
- * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
72
- * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
73
- * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
74
- * `store.remove(key, id)` branches on truthiness, so those spellings reach a
75
- * fall-through this function does not own. Tracked as #287; every row is pinned
76
- * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
77
- * boundary #287 is measured against.
78
- *
79
- * @param id the raw, already-URL-decoded id text from `request.params.id`
80
- * @returns the value the ORM resolves the record by
81
- */
82
- export default function normalizeRecordId(id) {
83
- if (!id)
84
- return '';
85
- if (isNaN(id))
86
- return id;
87
- return parseInt(id);
88
- }
@@ -1,88 +0,0 @@
1
- /*
2
- * Copyright 2025 Stone Costa
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the 'License');
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an 'AS IS' BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * The ONE place a URL id is turned into the value a record is resolved by —
19
- * abofs/stonyx-orm#270.
20
- *
21
- * Before this existed this coercion was written out seven times: twice in
22
- * README.md, once in docs/usage-patterns.md, and four times inside the
23
- * framework. Seven copies, not seven identical copies — the three persistence
24
- * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
25
- * is the point: nothing held them together, so they had already drifted.
26
- * The framework's copy — `getId()`, module-private in
27
- * orm-request.ts, unreachable through the package `exports` map — was the one
28
- * that decided which record a request addressed, and a consumer's `access()`
29
- * predicate had no way to obtain it. So the framework resolved the record by
30
- * one value and asked the consumer to authorize on a different one.
31
- *
32
- * Measured consequence at the time of filing: with the documented predicate
33
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
34
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
35
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
36
- * those onto `7` while `'007' === '7'` is false.
37
- *
38
- * Two things follow from this being a single exported function, and both are
39
- * the point:
40
- *
41
- * 1. A permissive change here is HARMLESS, because both sides move together.
42
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
43
- * the whole suite green; with one implementation the predicate simply sees
44
- * the same lowercased value and still refuses.
45
- * 2. A divergence — a second, private normaliser at the resolution site — is
46
- * what is now dangerous, and that is what the tests pin, by observing the
47
- * key the resolution path actually used.
48
- *
49
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
50
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
51
- * question, and it is a behaviour change on the record-resolution path for
52
- * every consumer rather than an authorization fix — it belongs in its own
53
- * issue with its own compatibility argument (issue body scope item 4). #270
54
- * preserves today's semantics exactly and makes both sides agree on them.
55
- *
56
- * Two details are load-bearing and are pinned by
57
- * test/unit/normalize-record-id-test.ts:
58
- *
59
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
60
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
61
- * silently changes which record every hex-spelled URL addresses.
62
- * - The coercion applies only when the id LOOKS numeric, so a model with
63
- * string ids is passed through untouched, case included.
64
- *
65
- * Must stay synchronous: `auth()` is invoked without `await` by
66
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
67
- * record id.
68
- *
69
- * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
70
- * `''` is returned for any falsy id because `store.get(key, undefined)` returns
71
- * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
72
- * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
73
- * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
74
- * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
75
- * `store.remove(key, id)` branches on truthiness, so those spellings reach a
76
- * fall-through this function does not own. Tracked as #287; every row is pinned
77
- * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
78
- * boundary #287 is measured against.
79
- *
80
- * @param id the raw, already-URL-decoded id text from `request.params.id`
81
- * @returns the value the ORM resolves the record by
82
- */
83
- export default function normalizeRecordId(id?: string | null): string | number {
84
- if (!id) return '';
85
- if (isNaN(id as unknown as number)) return id;
86
-
87
- return parseInt(id);
88
- }