@stonyx/orm 0.3.2-alpha.111 → 0.3.2-alpha.113

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
@@ -370,19 +370,23 @@ export default class OwnerAccess {
370
370
  models = ['owner'];
371
371
 
372
372
  access(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));
373
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
374
+ // never attaches `recordId`, so the collection branch below would fire on
375
+ // the record route and authorize it outright. Exact no-op on a build that
376
+ // has the change — `recordId` is always assigned, `undefined` included.
377
+ if (!('recordId' in request)) return false;
378
+
379
+ // `request.recordId` is the id the ORM resolves the record by. The ORM
380
+ // computes it before `access()` runs, from `request.params.id`, using the
381
+ // same function the record lookup uses — so your predicate and the lookup
382
+ // cannot disagree. Authorize on it, never on a URL and never on raw
383
+ // `params.id`.
384
+ const { recordId } = request;
381
385
 
382
386
  // Returning false explicitly denies access to this record
383
387
  if (recordId === 'angela') return false;
384
388
 
385
- // No `id` means the collection route. Returning a function plugs it in to
389
+ // No `recordId` means the collection route. Returning a function plugs it in to
386
390
  // the response object as a filter. NOTE: a function return authorizes the
387
391
  // request outright — the operations list below is not consulted — so this
388
392
  // branch permits POST /owners as well as reads.
@@ -415,31 +419,64 @@ unrecognised spelling falls through to whatever your method returns next, so
415
419
  prefer one class per model. A class may still list several models in `models`
416
420
  when they share one rule.
417
421
 
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:
422
+ **Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
423
+ `params.id` is raw text from the client. When it looks numeric the ORM coerces
424
+ it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
425
+ `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
426
+ all address record `7`, while a `===` against the raw text matches only the one
427
+ spelling you wrote down. Every other spelling would fall through to whatever
428
+ your method returns next — which, in the shape above, is a full CRUD grant. All
429
+ of them are plain address-bar requests.
430
+
431
+ **The ORM does that normalisation for you and hands you the result.** `access()`
432
+ is called with `request.recordId` already set to the value the record will be
433
+ resolved by, so there is no arithmetic to copy into your predicate and nothing
434
+ to keep in sync — abofs/stonyx-orm#270. It is `undefined` on collection routes,
435
+ which is how you tell a collection request from a record request. `params.id` is
436
+ left untouched, so anything that needs the raw client text still has it.
437
+
438
+ **The samples above fail *closed* on a build that predates
439
+ abofs/stonyx-orm#270.** That is what the first line of each `access()` is for. A
440
+ build without this change never attaches `recordId`, so `recordId === undefined`
441
+ — the collection branch — fires on the *record* route, and a function return
442
+ authorizes the request outright.
443
+
444
+ The same normalisation is exported for the places `access()` does not reach —
445
+ hooks, custom handlers, your own lookups:
446
+
447
+ ```javascript
448
+ import { normalizeRecordId } from '@stonyx/orm';
449
+
450
+ normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
451
+ ```
452
+
453
+ Do not re-implement it. A hand-written copy is correct only for as long as it
454
+ happens to match, and nothing holds the two together.
455
+
456
+ **`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
457
+ section documents side by side do not agree, and the difference is load-bearing:
458
+ `request.recordId` is `undefined` on a collection route, while
459
+ `normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
460
+ `store.get(key, undefined)` returns the whole model Map rather than a record
461
+ (abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
462
+ is **never** true and a collection branch written that way never runs. Branch on
463
+ `request.recordId === undefined`, or compare against `''`.
432
464
 
433
465
  ```javascript
434
466
  export default class AnimalAccess {
435
467
  models = ['animal'];
436
468
 
437
469
  access(request) {
438
- const { id } = request.params;
470
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
471
+ // never attaches `recordId`, so the collection branch below would fire on
472
+ // the record route and authorize it outright. Exact no-op on a build that
473
+ // has the change — `recordId` is always assigned, `undefined` included.
474
+ if (!('recordId' in request)) return false;
439
475
 
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));
476
+ // Already normalised by the ORM, and it agrees with the lookup for every
477
+ // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
478
+ // string, so compare it against a number.
479
+ const { recordId } = request;
443
480
 
444
481
  if (recordId === 7) return false;
445
482
 
@@ -616,7 +653,7 @@ Each hook receives a context object with comprehensive information:
616
653
  model: 'animal', // Model name
617
654
  operation: 'create', // Operation type
618
655
  request, // Express request object
619
- params, // URL params (e.g., { id: 5 })
656
+ params, // URL params, raw client text (e.g., { id: '5' })
620
657
  body, // Request body (POST/PATCH)
621
658
  query, // Query parameters
622
659
  state, // Request state object
@@ -634,6 +671,7 @@ Each hook receives a context object with comprehensive information:
634
671
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
635
672
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
636
673
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
674
+ - `params.id` is the raw client text as a STRING (`/animals/05` gives `{ id: '05' }`). The id the ORM resolved the record by is `request.recordId`, and for a numeric-id model it is a NUMBER (`5` for both `/animals/5` and `/animals/05`) — abofs/stonyx-orm#270. Key lookups on `request.recordId`, never on `params.id`.
637
675
 
638
676
  ### Usage Examples
639
677
 
@@ -671,9 +709,13 @@ beforeHook('create', 'animal', (context) => {
671
709
  }
672
710
  });
673
711
 
674
- // Return an object to send a custom response
712
+ // Return an object to send a custom response.
713
+ // Look the record up by `context.request.recordId` — the value the ORM
714
+ // resolved the record by. `context.params.id` is the raw client text and is a
715
+ // different value for every alias of the same id, so a lookup keyed on it
716
+ // finds nothing on a numeric-id model (abofs/stonyx-orm#270).
675
717
  beforeHook('delete', 'animal', (context) => {
676
- const animal = store.get('animal', context.params.id);
718
+ const animal = store.get('animal', context.request.recordId);
677
719
  if (animal.protected) {
678
720
  return { errors: [{ detail: 'Cannot delete protected animals' }] };
679
721
  }
@@ -714,9 +756,12 @@ afterHook('update', 'animal', async (context) => {
714
756
  }
715
757
  });
716
758
 
717
- // Cache invalidation
759
+ // Cache invalidation.
760
+ // Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
761
+ // one record but two strings, so a raw-text key invalidates two entries and
762
+ // misses the one the write used.
718
763
  afterHook('delete', 'animal', async (context) => {
719
- await cache.invalidate(`owner:${context.params.id}:pets`);
764
+ await cache.invalidate(`owner:${context.request.recordId}:pets`);
720
765
  });
721
766
  ```
722
767
 
@@ -757,10 +802,16 @@ afterHook('delete', 'animal', async (context) => {
757
802
  #### Authorization
758
803
 
759
804
  ```javascript
760
- // Additional access control - halt with 403 if unauthorized
805
+ // Additional access control - halt with 403 if unauthorized.
806
+ //
807
+ // `context.request.recordId` is the id the ORM resolved the record by — the
808
+ // same value `access()` is handed. Authorizing on `context.params.id` instead
809
+ // looks up the raw client text: on a numeric-id model that lookup returns
810
+ // `undefined` for EVERY spelling, `animal.owner` throws, and the check never
811
+ // runs. Measured; abofs/stonyx-orm#270.
761
812
  beforeHook('delete', 'animal', (context) => {
762
813
  const user = context.state.currentUser;
763
- const animal = store.get('animal', context.params.id);
814
+ const animal = store.get('animal', context.request.recordId);
764
815
 
765
816
  if (animal.owner !== user.id && !user.isAdmin) {
766
817
  return 403; // Forbidden
@@ -961,6 +1012,48 @@ test('validation hook rejects negative age', async () => {
961
1012
  | `afterHook` | Register an after hook for post-operation logic. |
962
1013
  | `clearHook` | Clear hooks for a specific operation:model. |
963
1014
  | `clearAllHooks` | Clear all registered hooks (useful for testing). |
1015
+ | `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
1016
+
1017
+ ### `normalizeRecordId(id)`
1018
+
1019
+ ```ts
1020
+ normalizeRecordId(id?: string | null): string | number
1021
+ ```
1022
+
1023
+ The **one** implementation of URL-id normalisation in the package
1024
+ (abofs/stonyx-orm#270). `access()` is already handed its result as
1025
+ `request.recordId`; import it for the places `access()` does not reach — hooks,
1026
+ custom handlers, your own lookups. Synchronous, and it must stay synchronous:
1027
+ `auth()` is invoked without `await`.
1028
+
1029
+ | Input | Returns | Note |
1030
+ | --- | --- | --- |
1031
+ | `'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` |
1032
+ | `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
1033
+ | `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
1034
+ | `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
1035
+ | `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
1036
+
1037
+ **The trap.** `request.recordId` is `undefined` on a collection route;
1038
+ `normalizeRecordId(undefined)` is `''`. They are different values and a
1039
+ collection check written against the wrong one silently never fires:
1040
+
1041
+ ```javascript
1042
+ // WRONG — never true, so this branch never runs
1043
+ if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
1044
+
1045
+ // Right — the ORM attaches undefined for "this route carries no :id"
1046
+ if (context.request.recordId === undefined) { /* collection route */ }
1047
+ ```
1048
+
1049
+ The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
1050
+ rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
1051
+ the empty string this function returns today.
1052
+
1053
+ **A normalised id is not a promise that a record exists.** `0` and `NaN` are both
1054
+ possible returns and neither addresses a record you can rely on — `NaN` is not
1055
+ even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
1056
+ `recordId` as "the key the lookup will use", not as "a record is there".
964
1057
 
965
1058
  ## Project Structure
966
1059
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared write-path column conversion (#292).
3
+ *
4
+ * This module owns the *iteration* only — the single place where a write
5
+ * payload is walked column-by-column and each value is handed to the driver's
6
+ * own conversion rule. The rules themselves are genuinely disjoint per driver
7
+ * (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
8
+ * and arrays natively and must NOT be given stringified values) and therefore
9
+ * deliberately live with their drivers rather than being merged here.
10
+ *
11
+ * The duplicated create/update iteration was the drift vector that produced
12
+ * #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
13
+ * through `convertRow` removes the opportunity for the two to diverge again.
14
+ */
15
+ /**
16
+ * A driver-supplied per-column conversion rule.
17
+ *
18
+ * @param value the raw value about to be bound into the query
19
+ * @param columnType the column's declared type from `schema.columns`, or
20
+ * `undefined` when the column is not an attribute column
21
+ * (e.g. `updated_at`, FK columns and `id`, which are added
22
+ * outside the attribute loop). Rules MUST be a no-op for
23
+ * `undefined`.
24
+ */
25
+ export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
26
+ /**
27
+ * Applies `rule` to every column of a complete write payload.
28
+ *
29
+ * Call this on the *finished* payload immediately before handing it to the
30
+ * query builder — not inside a payload-building loop. Columns appended after
31
+ * such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
32
+ * the same class of defect #292 reports.
33
+ */
34
+ export declare function convertRow(row: Record<string, unknown>, columns: Record<string, string>, rule: ColumnRule): Record<string, unknown>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared write-path column conversion (#292).
3
+ *
4
+ * This module owns the *iteration* only — the single place where a write
5
+ * payload is walked column-by-column and each value is handed to the driver's
6
+ * own conversion rule. The rules themselves are genuinely disjoint per driver
7
+ * (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
8
+ * and arrays natively and must NOT be given stringified values) and therefore
9
+ * deliberately live with their drivers rather than being merged here.
10
+ *
11
+ * The duplicated create/update iteration was the drift vector that produced
12
+ * #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
13
+ * through `convertRow` removes the opportunity for the two to diverge again.
14
+ */
15
+ /**
16
+ * Applies `rule` to every column of a complete write payload.
17
+ *
18
+ * Call this on the *finished* payload immediately before handing it to the
19
+ * query builder — not inside a payload-building loop. Columns appended after
20
+ * such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
21
+ * the same class of defect #292 reports.
22
+ */
23
+ export function convertRow(row, columns, rule) {
24
+ const converted = {};
25
+ for (const [col, value] of Object.entries(row)) {
26
+ converted[col] = rule(value, columns[col]);
27
+ }
28
+ return converted;
29
+ }
package/dist/index.d.ts CHANGED
@@ -12,4 +12,6 @@ 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';
15
17
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js';
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ 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)
29
30
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
30
31
  // Store API:
31
32
  // store.get(model, id) -- sync, memory-only
@@ -0,0 +1,67 @@
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;
@@ -0,0 +1,88 @@
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,11 +1,26 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- interface OrmRequest$ extends Request {
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 {
3
17
  protocol?: string;
4
18
  baseUrl?: string;
5
19
  method: string;
6
20
  params: {
7
21
  [key: string]: string;
8
22
  };
23
+ recordId?: string | number | undefined;
9
24
  body?: {
10
25
  [key: string]: unknown;
11
26
  };
@@ -5,6 +5,7 @@ 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';
8
9
  const methodAccessMap = {
9
10
  GET: 'read',
10
11
  POST: 'create',
@@ -65,13 +66,23 @@ function getBaseUrl(request, pluralizedModel) {
65
66
  const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
66
67
  return `${protocol}://${host}${prefix}`;
67
68
  }
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.
68
84
  function getId(params) {
69
- const id = params.id;
70
- if (!id)
71
- return '';
72
- if (isNaN(id))
73
- return id;
74
- return parseInt(id);
85
+ return normalizeRecordId(params.id);
75
86
  }
76
87
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
77
88
  const { links, baseUrl } = options;
@@ -481,6 +492,35 @@ export default class OrmRequest extends Request {
481
492
  return routes;
482
493
  }
483
494
  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);
484
524
  const access = this.access(request);
485
525
  if (!access)
486
526
  return 403;
@@ -0,0 +1,24 @@
1
+ import type { ColumnRule } from '../column-conversion.js';
2
+ /**
3
+ * PostgreSQL's write-path conversion rule (#292).
4
+ *
5
+ * JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
6
+ * (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
7
+ * raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
8
+ * than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
9
+ * corrupted with no error.
10
+ *
11
+ * Two behaviours this rule must keep:
12
+ *
13
+ * - `typeof value === 'string'` passes through untouched. A JSONB column
14
+ * already holding a JSON string (`'{"a":1}'`) must store the object it
15
+ * describes; an unconditional `JSON.stringify` would double-encode it.
16
+ * - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
17
+ * are added outside the attribute loop and are absent from
18
+ * `schema.columns`.
19
+ *
20
+ * The `'JSONB'` sentinel is the exact literal emitted by
21
+ * `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
22
+ * producer of the values in `schema.columns`.
23
+ */
24
+ export declare const postgresJsonRule: ColumnRule;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * PostgreSQL's write-path conversion rule (#292).
3
+ *
4
+ * JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
5
+ * (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
6
+ * raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
7
+ * than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
8
+ * corrupted with no error.
9
+ *
10
+ * Two behaviours this rule must keep:
11
+ *
12
+ * - `typeof value === 'string'` passes through untouched. A JSONB column
13
+ * already holding a JSON string (`'{"a":1}'`) must store the object it
14
+ * describes; an unconditional `JSON.stringify` would double-encode it.
15
+ * - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
16
+ * are added outside the attribute loop and are absent from
17
+ * `schema.columns`.
18
+ *
19
+ * The `'JSONB'` sentinel is the exact literal emitted by
20
+ * `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
21
+ * producer of the values in `schema.columns`.
22
+ */
23
+ export const postgresJsonRule = (value, columnType) => columnType === 'JSONB' && typeof value !== 'string'
24
+ ? JSON.stringify(value)
25
+ : value;
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
8
8
  import { confirm } from '@stonyx/utils/prompt';
9
9
  import { readFile } from '@stonyx/utils/file';
10
10
  import { getPluralName } from '../plural-registry.js';
11
+ import { convertRow } from '../column-conversion.js';
12
+ import { postgresJsonRule } from './column-rules.js';
11
13
  import config from 'stonyx/config';
12
14
  import log from 'stonyx/log';
13
15
  import path from 'path';
@@ -57,6 +59,8 @@ interface PostgresDeps {
57
59
  confirm: typeof confirm;
58
60
  readFile: typeof readFile;
59
61
  getPluralName: typeof getPluralName;
62
+ convertRow: typeof convertRow;
63
+ postgresJsonRule: typeof postgresJsonRule;
60
64
  config: typeof config;
61
65
  log: typeof log;
62
66
  path: typeof path;
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
8
8
  import { confirm } from '@stonyx/utils/prompt';
9
9
  import { readFile } from '@stonyx/utils/file';
10
10
  import { getPluralName } from '../plural-registry.js';
11
+ import { convertRow } from '../column-conversion.js';
12
+ import { postgresJsonRule } from './column-rules.js';
11
13
  import { isDbError } from '../utils.js';
12
14
  import config from 'stonyx/config';
13
15
  import log from 'stonyx/log';
@@ -18,7 +20,8 @@ const defaultDeps = {
18
20
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
19
21
  loadLatestSnapshot, detectSchemaDrift,
20
22
  buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
21
- createRecord, store, confirm, readFile, getPluralName, config, log, path
23
+ createRecord, store, confirm, readFile, getPluralName, config, log, path,
24
+ convertRow, postgresJsonRule
22
25
  };
23
26
  export default class PostgresDB {
24
27
  /** PostgreSQL extensions to enable on pool init. Subclasses can override. */
@@ -417,7 +420,9 @@ export default class PostgresDB {
417
420
  if (isPendingId) {
418
421
  delete insertData.id;
419
422
  }
420
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
423
+ // Convert the *complete* payload (attributes + id + FKs) in one place (#292).
424
+ const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
425
+ const { sql, values } = this.deps.buildInsert(schema.table, row);
421
426
  const result = await this.requirePool().query(sql, values);
422
427
  // Re-key the record in the store if PostgreSQL generated the ID (via RETURNING)
423
428
  if (isPendingId && result.rows.length > 0) {
@@ -469,7 +474,11 @@ export default class PostgresDB {
469
474
  return;
470
475
  // PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
471
476
  changedData.updated_at = new Date();
472
- const { sql, values } = this.deps.buildUpdate(schema.table, id, changedData);
477
+ // Same conversion, same choke point as the create path (#292). Columns
478
+ // appended after the attribute loop (FKs, updated_at) are absent from
479
+ // schema.columns and pass through untouched.
480
+ const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
481
+ const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
473
482
  await this.requirePool().query(sql, values);
474
483
  }
475
484
  async _persistDelete(modelName, context) {
@@ -490,13 +499,11 @@ export default class PostgresDB {
490
499
  if (data.id !== undefined) {
491
500
  row.id = data.id;
492
501
  }
493
- // Attribute columns
494
- for (const [col, pgType] of Object.entries(schema.columns)) {
502
+ // Attribute columns. Type conversion is NOT applied here -- it is applied
503
+ // once to the finished payload by the caller, via convertRow (#292).
504
+ for (const col of Object.keys(schema.columns)) {
495
505
  if (data[col] !== undefined) {
496
- // JSONB columns: stringify non-string values for PostgreSQL JSONB storage
497
- row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
498
- ? JSON.stringify(data[col])
499
- : data[col];
506
+ row[col] = data[col];
500
507
  }
501
508
  }
502
509
  // FK columns from relationships
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.111",
7
+ "version": "0.3.2-alpha.113",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,10 +61,10 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.122",
64
+ "@stonyx/cron": "0.2.1-beta.146",
65
65
  "@stonyx/events": "0.1.1-beta.64",
66
66
  "@stonyx/utils": "0.2.3-beta.27",
67
- "stonyx": "0.2.3-beta.94"
67
+ "stonyx": "0.2.3-beta.98"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-dynamodb": "^3.0.0",
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "devDependencies": {
94
- "@stonyx/rest-server": "0.2.1-beta.123",
94
+ "@stonyx/rest-server": "0.2.1-beta.147",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -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/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'",
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
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared write-path column conversion (#292).
3
+ *
4
+ * This module owns the *iteration* only — the single place where a write
5
+ * payload is walked column-by-column and each value is handed to the driver's
6
+ * own conversion rule. The rules themselves are genuinely disjoint per driver
7
+ * (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
8
+ * and arrays natively and must NOT be given stringified values) and therefore
9
+ * deliberately live with their drivers rather than being merged here.
10
+ *
11
+ * The duplicated create/update iteration was the drift vector that produced
12
+ * #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
13
+ * through `convertRow` removes the opportunity for the two to diverge again.
14
+ */
15
+
16
+ /**
17
+ * A driver-supplied per-column conversion rule.
18
+ *
19
+ * @param value the raw value about to be bound into the query
20
+ * @param columnType the column's declared type from `schema.columns`, or
21
+ * `undefined` when the column is not an attribute column
22
+ * (e.g. `updated_at`, FK columns and `id`, which are added
23
+ * outside the attribute loop). Rules MUST be a no-op for
24
+ * `undefined`.
25
+ */
26
+ export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
27
+
28
+ /**
29
+ * Applies `rule` to every column of a complete write payload.
30
+ *
31
+ * Call this on the *finished* payload immediately before handing it to the
32
+ * query builder — not inside a payload-building loop. Columns appended after
33
+ * such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
34
+ * the same class of defect #292 reports.
35
+ */
36
+ export function convertRow(
37
+ row: Record<string, unknown>,
38
+ columns: Record<string, string>,
39
+ rule: ColumnRule
40
+ ): Record<string, unknown> {
41
+ const converted: Record<string, unknown> = {};
42
+
43
+ for (const [col, value] of Object.entries(row)) {
44
+ converted[col] = rule(value, columns[col]);
45
+ }
46
+
47
+ return converted;
48
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ 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)
33
35
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
34
36
 
35
37
  // Store API:
@@ -0,0 +1,88 @@
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
+ }
@@ -7,8 +7,23 @@ 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';
10
11
 
11
- interface OrmRequest$ extends Request {
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 {
12
27
  protocol?: string;
13
28
  // Express sets this to the path the router was mounted at, e.g. '/api/animals'
14
29
  // when orm.restServer.route is '/api'. Optional because non-Express callers
@@ -16,6 +31,14 @@ interface OrmRequest$ extends Request {
16
31
  baseUrl?: string;
17
32
  method: string;
18
33
  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;
19
42
  body?: { [key: string]: unknown };
20
43
  query?: { [key: string]: string };
21
44
  get(header: string): string;
@@ -107,12 +130,23 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
107
130
  return `${protocol}://${host}${prefix}`;
108
131
  }
109
132
 
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.
110
148
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
111
- const id = params.id;
112
- if (!id) return '';
113
- if (isNaN(id as unknown as number)) return id;
114
-
115
- return parseInt(id);
149
+ return normalizeRecordId(params.id);
116
150
  }
117
151
 
118
152
  function buildResponse(
@@ -598,6 +632,37 @@ export default class OrmRequest extends Request {
598
632
  }
599
633
 
600
634
  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
+
601
666
  const access = this.access(request);
602
667
 
603
668
  if (!access) return 403;
@@ -0,0 +1,28 @@
1
+ import type { ColumnRule } from '../column-conversion.js';
2
+
3
+ /**
4
+ * PostgreSQL's write-path conversion rule (#292).
5
+ *
6
+ * JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
7
+ * (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
8
+ * raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
9
+ * than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
10
+ * corrupted with no error.
11
+ *
12
+ * Two behaviours this rule must keep:
13
+ *
14
+ * - `typeof value === 'string'` passes through untouched. A JSONB column
15
+ * already holding a JSON string (`'{"a":1}'`) must store the object it
16
+ * describes; an unconditional `JSON.stringify` would double-encode it.
17
+ * - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
18
+ * are added outside the attribute loop and are absent from
19
+ * `schema.columns`.
20
+ *
21
+ * The `'JSONB'` sentinel is the exact literal emitted by
22
+ * `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
23
+ * producer of the values in `schema.columns`.
24
+ */
25
+ export const postgresJsonRule: ColumnRule = (value, columnType) =>
26
+ columnType === 'JSONB' && typeof value !== 'string'
27
+ ? JSON.stringify(value)
28
+ : value;
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
8
8
  import { confirm } from '@stonyx/utils/prompt';
9
9
  import { readFile } from '@stonyx/utils/file';
10
10
  import { getPluralName } from '../plural-registry.js';
11
+ import { convertRow } from '../column-conversion.js';
12
+ import { postgresJsonRule } from './column-rules.js';
11
13
  import { isDbError } from '../utils.js';
12
14
  import config from 'stonyx/config';
13
15
  import log from 'stonyx/log';
@@ -61,6 +63,8 @@ interface PostgresDeps {
61
63
  confirm: typeof confirm;
62
64
  readFile: typeof readFile;
63
65
  getPluralName: typeof getPluralName;
66
+ convertRow: typeof convertRow;
67
+ postgresJsonRule: typeof postgresJsonRule;
64
68
  config: typeof config;
65
69
  log: typeof log;
66
70
  path: typeof path;
@@ -73,7 +77,8 @@ const defaultDeps: PostgresDeps = {
73
77
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
74
78
  loadLatestSnapshot, detectSchemaDrift,
75
79
  buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
76
- createRecord, store, confirm, readFile, getPluralName, config, log, path
80
+ createRecord, store, confirm, readFile, getPluralName, config, log, path,
81
+ convertRow, postgresJsonRule
77
82
  };
78
83
 
79
84
  export default class PostgresDB {
@@ -535,7 +540,10 @@ export default class PostgresDB {
535
540
  delete insertData.id;
536
541
  }
537
542
 
538
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
543
+ // Convert the *complete* payload (attributes + id + FKs) in one place (#292).
544
+ const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
545
+
546
+ const { sql, values } = this.deps.buildInsert(schema.table, row);
539
547
 
540
548
  const result = await this.requirePool().query(sql, values);
541
549
 
@@ -600,7 +608,12 @@ export default class PostgresDB {
600
608
  // PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
601
609
  changedData.updated_at = new Date();
602
610
 
603
- const { sql, values } = this.deps.buildUpdate(schema.table, id, changedData);
611
+ // Same conversion, same choke point as the create path (#292). Columns
612
+ // appended after the attribute loop (FKs, updated_at) are absent from
613
+ // schema.columns and pass through untouched.
614
+ const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
615
+
616
+ const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
604
617
  await this.requirePool().query(sql, values);
605
618
  }
606
619
 
@@ -626,13 +639,11 @@ export default class PostgresDB {
626
639
  row.id = data.id;
627
640
  }
628
641
 
629
- // Attribute columns
630
- for (const [col, pgType] of Object.entries(schema.columns)) {
642
+ // Attribute columns. Type conversion is NOT applied here -- it is applied
643
+ // once to the finished payload by the caller, via convertRow (#292).
644
+ for (const col of Object.keys(schema.columns)) {
631
645
  if (data[col] !== undefined) {
632
- // JSONB columns: stringify non-string values for PostgreSQL JSONB storage
633
- row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
634
- ? JSON.stringify(data[col])
635
- : data[col];
646
+ row[col] = data[col];
636
647
  }
637
648
  }
638
649