@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 +127 -34
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +16 -1
- package/dist/orm-request.js +46 -6
- package/dist/postgres/column-rules.d.ts +24 -0
- package/dist/postgres/column-rules.js +25 -0
- package/dist/postgres/postgres-db.d.ts +4 -0
- package/dist/postgres/postgres-db.js +16 -9
- package/package.json +5 -5
- package/src/column-conversion.ts +48 -0
- package/src/index.ts +2 -0
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +71 -6
- package/src/postgres/column-rules.ts +28 -0
- package/src/postgres/postgres-db.ts +20 -9
package/README.md
CHANGED
|
@@ -370,19 +370,23 @@ export default class OwnerAccess {
|
|
|
370
370
|
models = ['owner'];
|
|
371
371
|
|
|
372
372
|
access(request) {
|
|
373
|
-
//
|
|
374
|
-
// `
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
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 `
|
|
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:
|
|
419
|
-
from the client. When it looks numeric the ORM coerces
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
is `
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
|
|
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
|
};
|
package/dist/orm-request.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/orm-request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|