@stonyx/orm 0.3.2-alpha.106 → 0.3.2-alpha.107

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,43 +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
- The same function is exported for the places `access()` does not reach — hooks,
385
- custom handlers, your own lookups:
386
-
387
- ```javascript
388
- import { normalizeRecordId } from '@stonyx/orm';
389
-
390
- normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
391
- ```
392
-
393
- Do not re-implement it. A hand-written copy is correct only for as long as it
394
- happens to match, and nothing holds the two together.
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:
395
432
 
396
433
  ```javascript
397
434
  export default class AnimalAccess {
398
435
  models = ['animal'];
399
436
 
400
437
  access(request) {
401
- // Already normalised by the ORM, and it agrees with the lookup for every
402
- // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
403
- // string, so compare it against a number.
404
- 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));
405
443
 
406
444
  if (recordId === 7) return false;
407
445
 
@@ -926,7 +964,9 @@ test('validation hook rejects negative age', async () => {
926
964
 
927
965
  ## Project Structure
928
966
 
929
- 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.
930
970
 
931
971
  ## License
932
972
 
package/dist/index.d.ts CHANGED
@@ -12,5 +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
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
@@ -6,7 +6,6 @@ interface OrmRequest$ extends Request {
6
6
  params: {
7
7
  [key: string]: string;
8
8
  };
9
- recordId?: string | number;
10
9
  body?: {
11
10
  [key: string]: unknown;
12
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,13 +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 call sites read `getId(request.params)`, and
70
- // docs/hooks.md documents that spelling. It is now a thin delegate: there is
71
- // exactly ONE normalisation in the repo, and it is the exported one a consumer
72
- // can import. A second implementation here is the defect abofs/stonyx-orm#270
73
- // exists to remove — see src/normalize-record-id.ts.
74
68
  function getId(params) {
75
- 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);
76
75
  }
77
76
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
78
77
  const { links, baseUrl } = options;
@@ -482,35 +481,6 @@ export default class OrmRequest extends Request {
482
481
  return routes;
483
482
  }
484
483
  auth(request, state) {
485
- // abofs/stonyx-orm#270 — hand the predicate the value the record is
486
- // ACTUALLY resolved by, rather than the raw text it was parsed from.
487
- //
488
- // This is the default path precisely because it requires the consumer to
489
- // remember nothing. Exporting `normalizeRecordId` alone would leave the
490
- // framework owning resolution while asking every consumer to call one
491
- // function, with no signal when they forget — the same silent fail-open,
492
- // one step over. The export is the escape hatch for hooks and custom
493
- // handlers; this line is the contract.
494
- //
495
- // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
496
- // sites, the serializer, and consumer hooks (docs/hooks.md documents
497
- // `getId(request.params)`) all read it, and changing `params.id` from
498
- // string to number underneath them is a silent behaviour change on paths
499
- // this issue is not about.
500
- //
501
- // Synchronous by necessity: @stonyx/rest-server calls auth() without
502
- // awaiting it.
503
- //
504
- // The `undefined` branch is a PRESENCE check, not a second normalisation:
505
- // it answers "does this route carry an :id at all", which is how both
506
- // documented samples tell a collection request from a record request. It
507
- // cannot be folded into normalizeRecordId, because that function must keep
508
- // returning '' for a falsy id — `store.get(key, undefined)` returns the
509
- // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
510
- // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
511
- // the '' it returns today.
512
- const rawId = request.params?.id;
513
- request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
514
484
  const access = this.access(request);
515
485
  if (!access)
516
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.106",
7
+ "version": "0.3.2-alpha.107",
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,7 +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
33
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
35
34
 
36
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,7 +7,6 @@ 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
11
  interface OrmRequest$ extends Request {
13
12
  protocol?: string;
@@ -17,10 +16,6 @@ interface OrmRequest$ extends Request {
17
16
  baseUrl?: string;
18
17
  method: string;
19
18
  params: { [key: string]: string };
20
- // Attached by auth() before access() runs — abofs/stonyx-orm#270. This is the
21
- // value the ORM resolves the record by; `params.id` remains the raw client
22
- // text it was parsed from.
23
- recordId?: string | number;
24
19
  body?: { [key: string]: unknown };
25
20
  query?: { [key: string]: string };
26
21
  get(header: string): string;
@@ -112,13 +107,12 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
112
107
  return `${protocol}://${host}${prefix}`;
113
108
  }
114
109
 
115
- // Kept as a name because twelve call sites read `getId(request.params)`, and
116
- // docs/hooks.md documents that spelling. It is now a thin delegate: there is
117
- // exactly ONE normalisation in the repo, and it is the exported one a consumer
118
- // can import. A second implementation here is the defect abofs/stonyx-orm#270
119
- // exists to remove — see src/normalize-record-id.ts.
120
110
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
121
- 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);
122
116
  }
123
117
 
124
118
  function buildResponse(
@@ -604,37 +598,6 @@ export default class OrmRequest extends Request {
604
598
  }
605
599
 
606
600
  auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
607
- // abofs/stonyx-orm#270 — hand the predicate the value the record is
608
- // ACTUALLY resolved by, rather than the raw text it was parsed from.
609
- //
610
- // This is the default path precisely because it requires the consumer to
611
- // remember nothing. Exporting `normalizeRecordId` alone would leave the
612
- // framework owning resolution while asking every consumer to call one
613
- // function, with no signal when they forget — the same silent fail-open,
614
- // one step over. The export is the escape hatch for hooks and custom
615
- // handlers; this line is the contract.
616
- //
617
- // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
618
- // sites, the serializer, and consumer hooks (docs/hooks.md documents
619
- // `getId(request.params)`) all read it, and changing `params.id` from
620
- // string to number underneath them is a silent behaviour change on paths
621
- // this issue is not about.
622
- //
623
- // Synchronous by necessity: @stonyx/rest-server calls auth() without
624
- // awaiting it.
625
- //
626
- // The `undefined` branch is a PRESENCE check, not a second normalisation:
627
- // it answers "does this route carry an :id at all", which is how both
628
- // documented samples tell a collection request from a record request. It
629
- // cannot be folded into normalizeRecordId, because that function must keep
630
- // returning '' for a falsy id — `store.get(key, undefined)` returns the
631
- // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
632
- // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
633
- // the '' it returns today.
634
- const rawId = request.params?.id;
635
-
636
- request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
637
-
638
601
  const access = this.access(request);
639
602
 
640
603
  if (!access) return 403;
@@ -1,53 +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 the same expression was written out seven times: twice in
6
- * README.md, once in docs/usage-patterns.md, and four times inside the
7
- * framework. The framework's copy — `getId()`, module-private in
8
- * orm-request.ts, unreachable through the package `exports` map — was the one
9
- * that decided which record a request addressed, and a consumer's `access()`
10
- * predicate had no way to obtain it. So the framework resolved the record by
11
- * one value and asked the consumer to authorize on a different one.
12
- *
13
- * Measured consequence at the time of filing: with the documented predicate
14
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
15
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
16
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
17
- * those onto `7` while `'007' === '7'` is false.
18
- *
19
- * Two things follow from this being a single exported function, and both are
20
- * the point:
21
- *
22
- * 1. A permissive change here is HARMLESS, because both sides move together.
23
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
24
- * the whole suite green; with one implementation the predicate simply sees
25
- * the same lowercased value and still refuses.
26
- * 2. A divergence — a second, private normaliser at the resolution site — is
27
- * what is now dangerous, and that is what the tests pin, by observing the
28
- * key the resolution path actually used.
29
- *
30
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
31
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
32
- * question, and it is a behaviour change on the record-resolution path for
33
- * every consumer rather than an authorization fix — it belongs in its own
34
- * issue with its own compatibility argument (issue body scope item 4). #270
35
- * preserves today's semantics exactly and makes both sides agree on them.
36
- *
37
- * Two details are load-bearing and are pinned by
38
- * test/unit/normalize-record-id-test.ts:
39
- *
40
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
41
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
42
- * silently changes which record every hex-spelled URL addresses.
43
- * - The coercion applies only when the id LOOKS numeric, so a model with
44
- * string ids is passed through untouched, case included.
45
- *
46
- * Must stay synchronous: `auth()` is invoked without `await` by
47
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
48
- * record id.
49
- *
50
- * @param id the raw, already-URL-decoded id text from `request.params.id`
51
- * @returns the value the ORM resolves the record by
52
- */
53
- export default function normalizeRecordId(id?: string | null): string | number;
@@ -1,74 +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 the same expression was written out seven times: twice in
21
- * README.md, once in docs/usage-patterns.md, and four times inside the
22
- * framework. The framework's copy — `getId()`, module-private in
23
- * orm-request.ts, unreachable through the package `exports` map — was the one
24
- * that decided which record a request addressed, and a consumer's `access()`
25
- * predicate had no way to obtain it. So the framework resolved the record by
26
- * one value and asked the consumer to authorize on a different one.
27
- *
28
- * Measured consequence at the time of filing: with the documented predicate
29
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
30
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
31
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
32
- * those onto `7` while `'007' === '7'` is false.
33
- *
34
- * Two things follow from this being a single exported function, and both are
35
- * the point:
36
- *
37
- * 1. A permissive change here is HARMLESS, because both sides move together.
38
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
39
- * the whole suite green; with one implementation the predicate simply sees
40
- * the same lowercased value and still refuses.
41
- * 2. A divergence — a second, private normaliser at the resolution site — is
42
- * what is now dangerous, and that is what the tests pin, by observing the
43
- * key the resolution path actually used.
44
- *
45
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
46
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
47
- * question, and it is a behaviour change on the record-resolution path for
48
- * every consumer rather than an authorization fix — it belongs in its own
49
- * issue with its own compatibility argument (issue body scope item 4). #270
50
- * preserves today's semantics exactly and makes both sides agree on them.
51
- *
52
- * Two details are load-bearing and are pinned by
53
- * test/unit/normalize-record-id-test.ts:
54
- *
55
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
56
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
57
- * silently changes which record every hex-spelled URL addresses.
58
- * - The coercion applies only when the id LOOKS numeric, so a model with
59
- * string ids is passed through untouched, case included.
60
- *
61
- * Must stay synchronous: `auth()` is invoked without `await` by
62
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
63
- * record id.
64
- *
65
- * @param id the raw, already-URL-decoded id text from `request.params.id`
66
- * @returns the value the ORM resolves the record by
67
- */
68
- export default function normalizeRecordId(id) {
69
- if (!id)
70
- return '';
71
- if (isNaN(id))
72
- return id;
73
- return parseInt(id);
74
- }
@@ -1,74 +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 the same expression was written out seven times: twice in
22
- * README.md, once in docs/usage-patterns.md, and four times inside the
23
- * framework. The framework's copy — `getId()`, module-private in
24
- * orm-request.ts, unreachable through the package `exports` map — was the one
25
- * that decided which record a request addressed, and a consumer's `access()`
26
- * predicate had no way to obtain it. So the framework resolved the record by
27
- * one value and asked the consumer to authorize on a different one.
28
- *
29
- * Measured consequence at the time of filing: with the documented predicate
30
- * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
31
- * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
32
- * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
33
- * those onto `7` while `'007' === '7'` is false.
34
- *
35
- * Two things follow from this being a single exported function, and both are
36
- * the point:
37
- *
38
- * 1. A permissive change here is HARMLESS, because both sides move together.
39
- * Lowercasing string ids used to disclose and destroy `owner:angela` with
40
- * the whole suite green; with one implementation the predicate simply sees
41
- * the same lowercased value and still refuses.
42
- * 2. A divergence — a second, private normaliser at the resolution site — is
43
- * what is now dangerous, and that is what the tests pin, by observing the
44
- * key the resolution path actually used.
45
- *
46
- * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
47
- * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
48
- * question, and it is a behaviour change on the record-resolution path for
49
- * every consumer rather than an authorization fix — it belongs in its own
50
- * issue with its own compatibility argument (issue body scope item 4). #270
51
- * preserves today's semantics exactly and makes both sides agree on them.
52
- *
53
- * Two details are load-bearing and are pinned by
54
- * test/unit/normalize-record-id-test.ts:
55
- *
56
- * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
57
- * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
58
- * silently changes which record every hex-spelled URL addresses.
59
- * - The coercion applies only when the id LOOKS numeric, so a model with
60
- * string ids is passed through untouched, case included.
61
- *
62
- * Must stay synchronous: `auth()` is invoked without `await` by
63
- * @stonyx/rest-server, so a promise here would be handed to `access()` as the
64
- * record id.
65
- *
66
- * @param id the raw, already-URL-decoded id text from `request.params.id`
67
- * @returns the value the ORM resolves the record by
68
- */
69
- export default function normalizeRecordId(id?: string | null): string | number {
70
- if (!id) return '';
71
- if (isNaN(id as unknown as number)) return id;
72
-
73
- return parseInt(id);
74
- }