@stonyx/orm 0.3.2-alpha.12 → 0.3.2-alpha.121
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 +341 -29
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/dynamodb/connection.d.ts +7 -4
- package/dist/dynamodb/connection.js +4 -4
- package/dist/dynamodb/dynamodb-db.d.ts +11 -0
- package/dist/dynamodb/dynamodb-db.js +54 -14
- package/dist/dynamodb/operation-builder.js +20 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.js +19 -1
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +17 -1
- package/dist/orm-request.js +77 -19
- package/dist/postgres/column-rules.d.ts +33 -0
- package/dist/postgres/column-rules.js +34 -0
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +12 -0
- package/dist/postgres/postgres-db.js +60 -19
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +3 -0
- package/package.json +11 -7
- package/src/column-conversion.ts +53 -0
- package/src/dynamodb/connection.ts +9 -8
- package/src/dynamodb/dynamodb-db.ts +56 -13
- package/src/dynamodb/operation-builder.ts +29 -15
- package/src/index.ts +2 -0
- package/src/main.ts +19 -1
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +109 -18
- package/src/postgres/column-rules.ts +37 -0
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +64 -21
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +3 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
|
|
|
9
9
|
|
|
10
10
|
## Highlights
|
|
11
11
|
|
|
12
|
-
- **Automatic Loading**: Models, serializers, transforms, and access classes are auto-registered from their configured directories.
|
|
12
|
+
- **Automatic Loading**: Models, serializers, transforms, views and access classes are auto-registered from their configured directories. Models, serializers, transforms and views are also loaded from subdirectories and named after their path (`models/admin/user-role.ts` registers `admin/user-role`); access classes are loaded only from the top level of `paths.access`, and one in a subdirectory is silently never loaded.
|
|
13
13
|
- **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
|
|
14
14
|
- **Serializers**: Map raw data into model-friendly structures, including nested properties.
|
|
15
15
|
- **Transforms**: Apply custom transformations on data values automatically.
|
|
@@ -40,6 +40,27 @@ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__se
|
|
|
40
40
|
npm install @stonyx/orm
|
|
41
41
|
````
|
|
42
42
|
|
|
43
|
+
That is the whole install for an ORM-only app. The database drivers and
|
|
44
|
+
`@stonyx/rest-server` are **optional peer dependencies**: a default install does
|
|
45
|
+
not put them on disk, and none of them is loaded unless your configuration asks
|
|
46
|
+
for it. Add only the ones you actually use — see
|
|
47
|
+
[Optional peer dependencies](#optional-peer-dependencies).
|
|
48
|
+
|
|
49
|
+
## Exports
|
|
50
|
+
|
|
51
|
+
| Import path | What it exports |
|
|
52
|
+
| :--- | :--- |
|
|
53
|
+
| `@stonyx/orm` | `Orm` (default), `store`, `relationships`, the base classes `Model`, `View`, `Serializer`, the aggregate helpers `count`, `avg`, `sum`, `min`, `max` (see [docs/views.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/views.md)), the helpers listed in [Exported Helpers](#exported-helpers), and the types `OrmAccessRequest` and `PersistErrorDetail` |
|
|
54
|
+
| `@stonyx/orm/db` | the live DB instance (default), `data` (its record) and `saveDB()`. It reads `Orm.db` when it is imported, so import it only after the ORM has initialized |
|
|
55
|
+
| `@stonyx/orm/standalone-db` | `StandaloneDB` (default): reads and writes the JSON db files directly, without booting Stonyx or the ORM |
|
|
56
|
+
| `@stonyx/orm/migrate` | `fileToDirectory()`, `directoryToFile()`: convert the JSON db between file and directory mode |
|
|
57
|
+
| `@stonyx/orm/commands` | the `db:*` command map (default) that the Stonyx CLI loads; see [Database (DB) Integration](#database-db-integration) |
|
|
58
|
+
| `@stonyx/orm/hooks` | `beforeHook`, `afterHook`, `clearHook`, `clearAllHooks`, `getBeforeHooks`, `getAfterHooks`, and the `HookContext` type |
|
|
59
|
+
|
|
60
|
+
## Samples
|
|
61
|
+
|
|
62
|
+
[`test/sample/`](https://github.com/abofs/stonyx-orm/tree/dev/test/sample) is the canonical usage reference: models, serializers, transforms, views and an access class that the test suite loads as a running app, so they stay correct. It is not included in the npm package.
|
|
63
|
+
|
|
43
64
|
## Usage example
|
|
44
65
|
|
|
45
66
|
This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
|
|
@@ -98,6 +119,7 @@ export default {
|
|
|
98
119
|
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
99
120
|
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
100
121
|
migrationsTable: '__migrations',
|
|
122
|
+
autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
|
|
101
123
|
} : undefined,
|
|
102
124
|
dynamodb: DYNAMODB_REGION ? {
|
|
103
125
|
region: DYNAMODB_REGION,
|
|
@@ -105,13 +127,21 @@ export default {
|
|
|
105
127
|
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
106
128
|
} : undefined,
|
|
107
129
|
restServer: {
|
|
108
|
-
|
|
130
|
+
// 'true' requires @stonyx/rest-server to be installed — see
|
|
131
|
+
// "Optional peer dependencies" below.
|
|
132
|
+
enabled: ORM_USE_REST_SERVER ?? 'false',
|
|
109
133
|
route: ORM_REST_ROUTE ?? '/'
|
|
110
134
|
}
|
|
111
135
|
}
|
|
112
136
|
};
|
|
113
137
|
```
|
|
114
138
|
|
|
139
|
+
> **`route` and JSON:API `links`.** Generated endpoints are mounted under `route`, and every
|
|
140
|
+
> `links.self` / `links.related` the ORM emits is an absolute URL that includes it. With
|
|
141
|
+
> `ORM_REST_ROUTE='/api'` the animal collection is served at `/api/animals` and advertises
|
|
142
|
+
> `http://host/api/animals`, so published links are followable as-is — do not prepend the
|
|
143
|
+
> mount yourself.
|
|
144
|
+
|
|
115
145
|
Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
|
|
116
146
|
|
|
117
147
|
```bash
|
|
@@ -120,6 +150,46 @@ stonyx serve
|
|
|
120
150
|
|
|
121
151
|
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
122
152
|
|
|
153
|
+
## Optional peer dependencies
|
|
154
|
+
|
|
155
|
+
`@stonyx/orm` boots with **none** of its optional peers installed. Each one is
|
|
156
|
+
imported lazily, and only when your configuration selects it:
|
|
157
|
+
|
|
158
|
+
| Configuration | Package you must install |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
|
|
161
|
+
| `orm.postgres` or `orm.timescale` | `pg` |
|
|
162
|
+
| `orm.mysql` | `mysql2` |
|
|
163
|
+
| `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
|
|
164
|
+
|
|
165
|
+
If a configuration key selects one that is not on disk, the failure surfaces
|
|
166
|
+
from `Orm.init()` while the framework boots:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**ORM-only, no REST server.** This is the shape the `environment.js` above is
|
|
173
|
+
written for: keep `orm.restServer.enabled` at `'false'` and install nothing
|
|
174
|
+
beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
|
|
175
|
+
work unchanged; only the generated REST routes are absent.
|
|
176
|
+
|
|
177
|
+
> **The module's own default is the other way round.** The `config/environment.js`
|
|
178
|
+
> that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
|
|
179
|
+
> an app that omits the `restServer` key *entirely* gets REST switched on and
|
|
180
|
+
> needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
|
|
181
|
+
> you want it.
|
|
182
|
+
|
|
183
|
+
**Turning REST on.**
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm install @stonyx/rest-server
|
|
187
|
+
ORM_USE_REST_SERVER=true stonyx serve
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
See [REST Server Integration](#rest-server-integration) for access classes and
|
|
191
|
+
route configuration.
|
|
192
|
+
|
|
123
193
|
## Models
|
|
124
194
|
|
|
125
195
|
Define a model with attributes and relationships:
|
|
@@ -243,7 +313,7 @@ In directory mode, each collection is stored as `{directory}/{collection}.json`
|
|
|
243
313
|
|
|
244
314
|
### MySQL Mode
|
|
245
315
|
|
|
246
|
-
Set the `MYSQL_HOST` environment variable to enable MySQL persistence.
|
|
316
|
+
Set the `MYSQL_HOST` environment variable to enable MySQL persistence. At startup the ORM loads into memory only the records of models declared `static memory = true`; every other model (the default is `memory = false`) is read from MySQL on demand by `store.find()` / `store.findAll()`, and the synchronous `store.get()` does not see it (see [docs/internals.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/internals.md#store-reads-and-the-memory-flag)). A record read that way resolves a `belongsTo` only when the related record is already in memory; otherwise the relationship is `null`. CRUD operations are persisted to MySQL automatically. Supports schema-aware migration generation, apply, rollback, and drift detection.
|
|
247
317
|
|
|
248
318
|
| Command | Description |
|
|
249
319
|
|---------|-------------|
|
|
@@ -255,7 +325,7 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
|
|
|
255
325
|
|
|
256
326
|
### DynamoDB Mode
|
|
257
327
|
|
|
258
|
-
Set the `DYNAMODB_REGION` environment variable to enable DynamoDB persistence. Tables are created with PAY_PER_REQUEST (on-demand) billing. Global Secondary Indexes (GSIs) are auto-provisioned at startup based on model `belongsTo` relationships — each FK column gets a GSI. `findAll()` with conditions routes to a GSI Query when the condition key matches a GSI partition key; non-indexed attribute conditions fall back to Scan + FilterExpression (expensive for large tables). ULID generation replaces auto-increment for numeric-ID models.
|
|
328
|
+
Set the `DYNAMODB_REGION` environment variable to enable DynamoDB persistence. Tables are created with PAY_PER_REQUEST (on-demand) billing. Global Secondary Indexes (GSIs) are auto-provisioned at startup based on model `belongsTo` relationships — each FK column gets a GSI. `findAll()` with conditions routes to a GSI Query when the condition key matches a GSI partition key; non-indexed attribute conditions fall back to Scan + FilterExpression (expensive for large tables). ULID generation replaces auto-increment for numeric-ID models. The `memory` flag works as in [MySQL Mode](#mysql-mode): only `static memory = true` models are loaded at startup, every other model is read on demand by `store.find()` / `store.findAll()` and is not seen by `store.get()`, and a record read that way resolves a `belongsTo` only when the related record is already in memory.
|
|
259
329
|
|
|
260
330
|
```javascript
|
|
261
331
|
dynamodb: {
|
|
@@ -300,27 +370,188 @@ MySQL integration tests run automatically when MySQL is available. In CI (where
|
|
|
300
370
|
|
|
301
371
|
## REST Server Integration
|
|
302
372
|
|
|
303
|
-
The ORM
|
|
373
|
+
The ORM registers REST routes for the models your access classes name. It does this itself during initialization when `restServer.enabled` is `'true'`; there is no setup function to import or call. Configure it in `config/environment.js`:
|
|
304
374
|
|
|
305
375
|
```js
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
376
|
+
export default {
|
|
377
|
+
orm: {
|
|
378
|
+
paths: {
|
|
379
|
+
access: './access' // directory of access classes
|
|
380
|
+
},
|
|
381
|
+
restServer: {
|
|
382
|
+
enabled: 'true', // requires @stonyx/rest-server, see Optional peer dependencies
|
|
383
|
+
route: '/' // mount prefix for the generated routes
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
309
387
|
```
|
|
310
388
|
|
|
311
389
|
Access classes define models and provide custom filtering/authorization logic:
|
|
312
390
|
|
|
313
391
|
```js
|
|
314
|
-
export default class
|
|
315
|
-
models = ['owner'
|
|
392
|
+
export default class OwnerAccess {
|
|
393
|
+
models = ['owner'];
|
|
316
394
|
|
|
317
395
|
access(request) {
|
|
318
|
-
|
|
396
|
+
// Fail closed on a build that predates abofs/stonyx-orm#270: such a build
|
|
397
|
+
// never attaches `recordId`, so the collection branch below would fire on
|
|
398
|
+
// the record route and authorize it outright. Exact no-op on a build that
|
|
399
|
+
// has the change — `recordId` is always assigned, `undefined` included.
|
|
400
|
+
if (!('recordId' in request)) return false;
|
|
401
|
+
|
|
402
|
+
// `request.recordId` is the id the ORM resolves the record by. The ORM
|
|
403
|
+
// computes it before `access()` runs, from `request.params.id`, using the
|
|
404
|
+
// same function the record lookup uses — so your predicate and the lookup
|
|
405
|
+
// cannot disagree. Authorize on it, never on a URL and never on raw
|
|
406
|
+
// `params.id`.
|
|
407
|
+
const { recordId } = request;
|
|
408
|
+
|
|
409
|
+
// Returning false explicitly denies access to this record
|
|
410
|
+
if (recordId === 'angela') return false;
|
|
411
|
+
|
|
412
|
+
// No `recordId` means the collection route. Returning a function plugs it in to
|
|
413
|
+
// the response object as a filter. NOTE: a function return authorizes the
|
|
414
|
+
// request outright — the operations list below is not consulted — so this
|
|
415
|
+
// branch permits POST /owners as well as reads.
|
|
416
|
+
if (recordId === undefined) return record => record.id !== 'angela';
|
|
417
|
+
|
|
418
|
+
// Returning a list of operations allows full access to everything else
|
|
319
419
|
return ['read', 'create', 'update', 'delete'];
|
|
320
420
|
}
|
|
321
421
|
}
|
|
322
422
|
```
|
|
323
423
|
|
|
424
|
+
**Do not authorize on the request URL.** `request.url` is rewritten relative to
|
|
425
|
+
the mount point, so inside the REST server it is `/angela`, not `/owners/angela`
|
|
426
|
+
— a suffix comparison against it never matches and the request falls through to
|
|
427
|
+
whatever the method returns next. `request.originalUrl` keeps the full path but
|
|
428
|
+
is still the raw text the client sent, so it varies with query strings
|
|
429
|
+
(`/owners?x=1`), trailing slashes (`/owners/angela/`), casing (`/OwNeRs/angela`)
|
|
430
|
+
and percent-encoding (`/owners/%61ngela`). Each of those is a plain address-bar
|
|
431
|
+
request. Which of them reach your handler at all depends on how the REST server
|
|
432
|
+
configures Express route matching — that is a deployment detail you should not
|
|
433
|
+
be building an authorization decision on top of. `request.params.id` is
|
|
434
|
+
identical for every spelling that does reach you.
|
|
435
|
+
|
|
436
|
+
**One access class per model when the rules are model-specific.** `access()`
|
|
437
|
+
receives only the request, and the request does not carry the model name
|
|
438
|
+
directly — `request.baseUrl` is the *mount text as the client spelled it*
|
|
439
|
+
(`/OWNERS`), so it must be case-normalised before it is compared, and it is
|
|
440
|
+
still the mount rather than the model. Deriving a model from it means every
|
|
441
|
+
unrecognised spelling falls through to whatever your method returns next, so
|
|
442
|
+
prefer one class per model. A class may still list several models in `models`
|
|
443
|
+
when they share one rule.
|
|
444
|
+
|
|
445
|
+
`access()` covers a model's own routes only: records of that model served
|
|
446
|
+
through another model's `?include=`, `/:id/<relationship>` or
|
|
447
|
+
`/:id/relationships/<relationship>` routes are never
|
|
448
|
+
checked against it. See [docs/security.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/security.md).
|
|
449
|
+
|
|
450
|
+
**Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
|
|
451
|
+
`params.id` is raw text from the client. When it looks numeric the ORM coerces
|
|
452
|
+
it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
|
|
453
|
+
`+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
|
|
454
|
+
all address record `7`, while a `===` against the raw text matches only the one
|
|
455
|
+
spelling you wrote down. Every other spelling would fall through to whatever
|
|
456
|
+
your method returns next — which, in the shape above, is a full CRUD grant. All
|
|
457
|
+
of them are plain address-bar requests.
|
|
458
|
+
|
|
459
|
+
**The ORM does that normalisation for you and hands you the result.** `access()`
|
|
460
|
+
is called with `request.recordId` already set to the value the record will be
|
|
461
|
+
resolved by, so there is no arithmetic to copy into your predicate and nothing
|
|
462
|
+
to keep in sync — abofs/stonyx-orm#270. It is `undefined` on collection routes,
|
|
463
|
+
which is how you tell a collection request from a record request. `params.id` is
|
|
464
|
+
left untouched, so anything that needs the raw client text still has it.
|
|
465
|
+
|
|
466
|
+
**The samples above fail *closed* on a build that predates
|
|
467
|
+
abofs/stonyx-orm#270.** That is what the first line of each `access()` is for. A
|
|
468
|
+
build without this change never attaches `recordId`, so `recordId === undefined`
|
|
469
|
+
— the collection branch — fires on the *record* route, and a function return
|
|
470
|
+
authorizes the request outright.
|
|
471
|
+
|
|
472
|
+
The same normalisation is exported for the places `access()` does not reach —
|
|
473
|
+
hooks, custom handlers, your own lookups:
|
|
474
|
+
|
|
475
|
+
```javascript
|
|
476
|
+
import { normalizeRecordId } from '@stonyx/orm';
|
|
477
|
+
|
|
478
|
+
normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Do not re-implement it. A hand-written copy is correct only for as long as it
|
|
482
|
+
happens to match, and nothing holds the two together.
|
|
483
|
+
|
|
484
|
+
**`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
|
|
485
|
+
section documents side by side do not agree, and the difference is load-bearing:
|
|
486
|
+
`request.recordId` is `undefined` on a collection route, while
|
|
487
|
+
`normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
|
|
488
|
+
`store.get(key, undefined)` returns the whole model Map rather than a record
|
|
489
|
+
(abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
|
|
490
|
+
is **never** true and a collection branch written that way never runs. Branch on
|
|
491
|
+
`request.recordId === undefined`, or compare against `''`.
|
|
492
|
+
|
|
493
|
+
```javascript
|
|
494
|
+
export default class AnimalAccess {
|
|
495
|
+
models = ['animal'];
|
|
496
|
+
|
|
497
|
+
access(request) {
|
|
498
|
+
// Fail closed on a build that predates abofs/stonyx-orm#270: such a build
|
|
499
|
+
// never attaches `recordId`, so the collection branch below would fire on
|
|
500
|
+
// the record route and authorize it outright. Exact no-op on a build that
|
|
501
|
+
// has the change — `recordId` is always assigned, `undefined` included.
|
|
502
|
+
if (!('recordId' in request)) return false;
|
|
503
|
+
|
|
504
|
+
// Already normalised by the ORM, and it agrees with the lookup for every
|
|
505
|
+
// spelling of 7 above. For a numeric-id model it is a NUMBER, not a
|
|
506
|
+
// string, so compare it against a number.
|
|
507
|
+
const { recordId } = request;
|
|
508
|
+
|
|
509
|
+
if (recordId === 7) return false;
|
|
510
|
+
|
|
511
|
+
if (recordId === undefined) return record => record.id !== 7;
|
|
512
|
+
|
|
513
|
+
return ['read', 'create', 'update', 'delete'];
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Both samples above are extracted from this file and executed verbatim against a
|
|
519
|
+
live server on every CI run — see
|
|
520
|
+
[`test/integration/readme-access/`](https://github.com/abofs/stonyx-orm/tree/dev/test/integration/readme-access).
|
|
521
|
+
`DELETE /owners/angela` is asserted to be refused with the record intact, and
|
|
522
|
+
every spelling named above is measured individually, the numeric ones over a raw
|
|
523
|
+
socket. Nothing in this section is prose that was never run.
|
|
524
|
+
|
|
525
|
+
### Upgrading: behaviour changes
|
|
526
|
+
|
|
527
|
+
**Advertised `links.self` / `links.related` now carry the REST mount route.**
|
|
528
|
+
Consumer-visible for any deployment where `orm.restServer.route` is not the
|
|
529
|
+
default `'/'`.
|
|
530
|
+
|
|
531
|
+
Measured on this repo's mounted-route harness at `ORM_REST_ROUTE='/api'`, resource
|
|
532
|
+
`links.self` in the response to `GET /api/animals/1`:
|
|
533
|
+
|
|
534
|
+
| | published `links.self` | fetching that URL |
|
|
535
|
+
|---|---|---|
|
|
536
|
+
| before | `http://host/animals/1` | **404** |
|
|
537
|
+
| after | `http://host/api/animals/1` | **200** |
|
|
538
|
+
|
|
539
|
+
The ORM previously built links from the request origin alone, so at any non-default
|
|
540
|
+
mount every URL it advertised pointed at a route that did not exist
|
|
541
|
+
(abofs/stonyx-orm#254). Links are now built from the path the routes are actually
|
|
542
|
+
mounted at, and are followable verbatim.
|
|
543
|
+
|
|
544
|
+
**⚠️ Breaking if you carry a prepending workaround.** The usual workaround for #254
|
|
545
|
+
was for the client to prepend the mount to every link the ORM published. That now
|
|
546
|
+
double-prefixes: prepending `/api` to the new `http://host/api/animals/1` yields
|
|
547
|
+
`http://host/api/api/animals/1`, measured **404**. Remove the prepending. There is no
|
|
548
|
+
configuration flag that restores the old link shape.
|
|
549
|
+
|
|
550
|
+
**Unaffected.** Deployments on the default `ORM_REST_ROUTE='/'` see byte-identical
|
|
551
|
+
output — the prefix is empty, and this is pinned by a byte-identity test against a
|
|
552
|
+
golden fixture captured *before* the fix. Response structure, field names and the
|
|
553
|
+
public API are unchanged; only the URL value inside `links` changes.
|
|
554
|
+
|
|
324
555
|
### Include Parameter (Sideloading Relationships)
|
|
325
556
|
|
|
326
557
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
|
@@ -413,6 +644,7 @@ GET /animals/1
|
|
|
413
644
|
#### Limitations
|
|
414
645
|
|
|
415
646
|
- Only available on GET endpoints (not POST/PATCH)
|
|
647
|
+
- Included records are authorized only by the requested model's `access()`, not by their own model's, and `include` has no built-in cap on depth or on the number of paths; a long `include` blocks the server while it is walked. [docs/security.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/security.md) has the measurements and an `access()` check that caps both.
|
|
416
648
|
|
|
417
649
|
## Lifecycle Hooks
|
|
418
650
|
|
|
@@ -450,7 +682,7 @@ Each hook receives a context object with comprehensive information:
|
|
|
450
682
|
model: 'animal', // Model name
|
|
451
683
|
operation: 'create', // Operation type
|
|
452
684
|
request, // Express request object
|
|
453
|
-
params, // URL params (e.g., { id: 5 })
|
|
685
|
+
params, // URL params, raw client text (e.g., { id: '5' })
|
|
454
686
|
body, // Request body (POST/PATCH)
|
|
455
687
|
query, // Query parameters
|
|
456
688
|
state, // Request state object
|
|
@@ -467,7 +699,8 @@ Each hook receives a context object with comprehensive information:
|
|
|
467
699
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
468
700
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
469
701
|
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
470
|
-
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous
|
|
702
|
+
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous attribute values. Relationships are not in it: to detect a relationship change, capture the related id in a `before` hook (see Side Effects below)
|
|
703
|
+
- `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`.
|
|
471
704
|
|
|
472
705
|
### Usage Examples
|
|
473
706
|
|
|
@@ -505,9 +738,17 @@ beforeHook('create', 'animal', (context) => {
|
|
|
505
738
|
}
|
|
506
739
|
});
|
|
507
740
|
|
|
508
|
-
// Return an object to send a custom response
|
|
509
|
-
|
|
510
|
-
|
|
741
|
+
// Return an object to send a custom response.
|
|
742
|
+
// Look the record up by `context.request.recordId` — the value the ORM
|
|
743
|
+
// resolved the record by. `context.params.id` is the raw client text and is a
|
|
744
|
+
// different value for every alias of the same id, so a lookup keyed on it
|
|
745
|
+
// finds nothing on a numeric-id model (abofs/stonyx-orm#270).
|
|
746
|
+
// `store.find` rather than `store.get`: with a database adapter (MySQL,
|
|
747
|
+
// Postgres, TimescaleDB or DynamoDB) a model without `static memory = true` is
|
|
748
|
+
// not in memory, and `get` returns undefined.
|
|
749
|
+
// Before hooks are awaited, so an async hook works here.
|
|
750
|
+
beforeHook('delete', 'animal', async (context) => {
|
|
751
|
+
const animal = await store.find('animal', context.request.recordId);
|
|
511
752
|
if (animal.protected) {
|
|
512
753
|
return { errors: [{ detail: 'Cannot delete protected animals' }] };
|
|
513
754
|
}
|
|
@@ -535,22 +776,35 @@ beforeHook('create', 'owner', (context) => {
|
|
|
535
776
|
#### Side Effects
|
|
536
777
|
|
|
537
778
|
```javascript
|
|
538
|
-
// Send notification after animal is adopted
|
|
779
|
+
// Send notification after animal is adopted.
|
|
780
|
+
// `oldState` holds attributes only, and a belongsTo is the related Record, not
|
|
781
|
+
// its id: capture the previous owner's id before the update, compare ids after.
|
|
782
|
+
// With a database adapter (MySQL, Postgres, TimescaleDB or DynamoDB) the owner
|
|
783
|
+
// resolves only when it is in memory (see MySQL Mode).
|
|
784
|
+
beforeHook('update', 'animal', async (context) => {
|
|
785
|
+
context.state.previousOwnerId = (await store.find('animal', context.request.recordId))?.owner?.id;
|
|
786
|
+
});
|
|
787
|
+
|
|
539
788
|
afterHook('update', 'animal', async (context) => {
|
|
540
|
-
|
|
541
|
-
|
|
789
|
+
const previousOwner = context.state.previousOwnerId;
|
|
790
|
+
const newOwner = context.record?.owner?.id;
|
|
791
|
+
|
|
792
|
+
if (previousOwner !== newOwner) {
|
|
542
793
|
await sendNotification({
|
|
543
794
|
type: 'adoption',
|
|
544
795
|
animalId: context.record.id,
|
|
545
|
-
previousOwner
|
|
546
|
-
newOwner
|
|
796
|
+
previousOwner,
|
|
797
|
+
newOwner
|
|
547
798
|
});
|
|
548
799
|
}
|
|
549
800
|
});
|
|
550
801
|
|
|
551
|
-
// Cache invalidation
|
|
802
|
+
// Cache invalidation.
|
|
803
|
+
// Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
|
|
804
|
+
// one record but two strings, so a raw-text key invalidates two entries and
|
|
805
|
+
// misses the one the write used.
|
|
552
806
|
afterHook('delete', 'animal', async (context) => {
|
|
553
|
-
await cache.invalidate(`owner:${context.
|
|
807
|
+
await cache.invalidate(`owner:${context.request.recordId}:pets`);
|
|
554
808
|
});
|
|
555
809
|
```
|
|
556
810
|
|
|
@@ -591,12 +845,25 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
591
845
|
#### Authorization
|
|
592
846
|
|
|
593
847
|
```javascript
|
|
594
|
-
// Additional access control - halt with 403 if unauthorized
|
|
595
|
-
|
|
848
|
+
// Additional access control - halt with 403 if unauthorized.
|
|
849
|
+
//
|
|
850
|
+
// `context.request.recordId` is the id the ORM resolved the record by — the
|
|
851
|
+
// same value `access()` is handed. Authorizing on `context.params.id` instead
|
|
852
|
+
// looks up the raw client text: on a numeric-id model that lookup returns
|
|
853
|
+
// `undefined` for EVERY spelling, `animal.owner` throws, and the check never
|
|
854
|
+
// runs. Measured; abofs/stonyx-orm#270. `store.find`, not `store.get`: with a
|
|
855
|
+
// database adapter (MySQL, Postgres, TimescaleDB or DynamoDB) a model without
|
|
856
|
+
// `static memory = true` is not in memory, and `animal.owner` resolves only when
|
|
857
|
+
// the owner is in memory too, and is `null` otherwise, so this check then
|
|
858
|
+
// refuses the owner as well (see MySQL Mode).
|
|
859
|
+
beforeHook('delete', 'animal', async (context) => {
|
|
596
860
|
const user = context.state.currentUser;
|
|
597
|
-
const animal = store.
|
|
861
|
+
const animal = await store.find('animal', context.request.recordId);
|
|
598
862
|
|
|
599
|
-
|
|
863
|
+
// `animal.owner` is the related Record, so compare its id. Refuse an unowned
|
|
864
|
+
// animal outright: `animal.owner?.id !== user.id` lets a user with no id
|
|
865
|
+
// delete it, because `undefined !== undefined` is false
|
|
866
|
+
if (!user.isAdmin && (!animal.owner || animal.owner.id !== user.id)) {
|
|
600
867
|
return 403; // Forbidden
|
|
601
868
|
}
|
|
602
869
|
});
|
|
@@ -708,7 +975,8 @@ beforeHook('update', 'animal', (context) => {
|
|
|
708
975
|
```javascript
|
|
709
976
|
// Update owner's pet count when animal is created
|
|
710
977
|
afterHook('create', 'animal', async (context) => {
|
|
711
|
-
|
|
978
|
+
// A belongsTo is already the related Record; no store lookup needed
|
|
979
|
+
const { owner } = context.record;
|
|
712
980
|
if (owner) {
|
|
713
981
|
owner.petCount = (owner.petCount || 0) + 1;
|
|
714
982
|
}
|
|
@@ -795,10 +1063,54 @@ test('validation hook rejects negative age', async () => {
|
|
|
795
1063
|
| `afterHook` | Register an after hook for post-operation logic. |
|
|
796
1064
|
| `clearHook` | Clear hooks for a specific operation:model. |
|
|
797
1065
|
| `clearAllHooks` | Clear all registered hooks (useful for testing). |
|
|
1066
|
+
| `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
|
|
1067
|
+
|
|
1068
|
+
### `normalizeRecordId(id)`
|
|
1069
|
+
|
|
1070
|
+
```ts
|
|
1071
|
+
normalizeRecordId(id?: string | null): string | number
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
The **one** implementation of URL-id normalisation in the package
|
|
1075
|
+
(abofs/stonyx-orm#270). `access()` is already handed its result as
|
|
1076
|
+
`request.recordId`; import it for the places `access()` does not reach — hooks,
|
|
1077
|
+
custom handlers, your own lookups. Synchronous, and it must stay synchronous:
|
|
1078
|
+
`auth()` is invoked without `await`.
|
|
1079
|
+
|
|
1080
|
+
| Input | Returns | Note |
|
|
1081
|
+
| --- | --- | --- |
|
|
1082
|
+
| `'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` |
|
|
1083
|
+
| `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
|
|
1084
|
+
| `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
|
|
1085
|
+
| `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
|
|
1086
|
+
| `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
|
|
1087
|
+
|
|
1088
|
+
**The trap.** `request.recordId` is `undefined` on a collection route;
|
|
1089
|
+
`normalizeRecordId(undefined)` is `''`. They are different values and a
|
|
1090
|
+
collection check written against the wrong one silently never fires:
|
|
1091
|
+
|
|
1092
|
+
```javascript
|
|
1093
|
+
// WRONG — never true, so this branch never runs
|
|
1094
|
+
if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
|
|
1095
|
+
|
|
1096
|
+
// Right — the ORM attaches undefined for "this route carries no :id"
|
|
1097
|
+
if (context.request.recordId === undefined) { /* collection route */ }
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
|
|
1101
|
+
rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
|
|
1102
|
+
the empty string this function returns today.
|
|
1103
|
+
|
|
1104
|
+
**A normalised id is not a promise that a record exists.** `0` and `NaN` are both
|
|
1105
|
+
possible returns and neither addresses a record you can rely on — `NaN` is not
|
|
1106
|
+
even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
|
|
1107
|
+
`recordId` as "the key the lookup will use", not as "a record is there".
|
|
798
1108
|
|
|
799
1109
|
## Project Structure
|
|
800
1110
|
|
|
801
|
-
For a full architectural reference, see
|
|
1111
|
+
For a full architectural reference, see
|
|
1112
|
+
[docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
|
|
1113
|
+
That file is repo-only — it is not in the published tarball, so the link is absolute.
|
|
802
1114
|
|
|
803
1115
|
## License
|
|
804
1116
|
|
|
@@ -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,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
|
+
* 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
|
+
// `Object.hasOwn`, not `columns[col]`: a column named `toString` or
|
|
27
|
+
// `constructor` would otherwise reach the rule with a function as its
|
|
28
|
+
// "declared type". Inert for the shipped Postgres rule (strict equality
|
|
29
|
+
// against 'JSONB'), but `ColumnRule` is the extension point other drivers
|
|
30
|
+
// write against, and the obvious `if (columnType)` rule would invert.
|
|
31
|
+
converted[col] = rule(value, Object.hasOwn(columns, col) ? columns[col] : undefined);
|
|
32
|
+
}
|
|
33
|
+
return converted;
|
|
34
|
+
}
|
|
@@ -7,15 +7,18 @@
|
|
|
7
7
|
export interface DynamoDBConfig {
|
|
8
8
|
region?: string;
|
|
9
9
|
endpoint?: string;
|
|
10
|
+
tablePrefix?: string;
|
|
10
11
|
[key: string]: unknown;
|
|
11
12
|
}
|
|
12
13
|
export type DocumentClient = {
|
|
13
14
|
send(command: unknown): Promise<unknown>;
|
|
14
15
|
};
|
|
15
|
-
export type
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
export type
|
|
16
|
+
export type DynamoDBClientConstructor = new (options: unknown) => {
|
|
17
|
+
config: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type DocumentClientFromFn = {
|
|
20
|
+
from(client: unknown): DocumentClient;
|
|
21
|
+
};
|
|
19
22
|
/**
|
|
20
23
|
* Create a DynamoDBDocumentClient from the given config.
|
|
21
24
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
10
10
|
*/
|
|
11
11
|
export async function createDocumentClient(dbConfig) {
|
|
12
|
-
const {
|
|
13
|
-
const {
|
|
12
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
|
|
13
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb');
|
|
14
14
|
const clientOptions = {};
|
|
15
15
|
if (dbConfig.region)
|
|
16
16
|
clientOptions.region = dbConfig.region;
|
|
17
17
|
if (dbConfig.endpoint)
|
|
18
18
|
clientOptions.endpoint = dbConfig.endpoint;
|
|
19
|
-
const rawClient = new
|
|
20
|
-
return
|
|
19
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
20
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
@@ -91,6 +91,7 @@ export default class DynamoDBDB {
|
|
|
91
91
|
private _gsiRegistry;
|
|
92
92
|
constructor(deps?: Partial<DynamoDBDeps>);
|
|
93
93
|
private requireClient;
|
|
94
|
+
private _resolveTableName;
|
|
94
95
|
/** Resolve Orm singleton — falls back to real import in production. */
|
|
95
96
|
private _getOrm;
|
|
96
97
|
init(): Promise<void>;
|
|
@@ -100,6 +101,16 @@ export default class DynamoDBDB {
|
|
|
100
101
|
*/
|
|
101
102
|
startup(): Promise<void>;
|
|
102
103
|
shutdown(): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* DynamoDB does NOT use write serialization (#156).
|
|
106
|
+
*
|
|
107
|
+
* Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
|
|
108
|
+
* constraints and no multi-row transactions in standard single-item
|
|
109
|
+
* operations (PutItem, UpdateItem, DeleteItem). Each operation is
|
|
110
|
+
* atomic at the item level and cannot deadlock against other items.
|
|
111
|
+
* Concurrent fire-and-forget writes therefore cannot produce the
|
|
112
|
+
* cross-row lock contention that causes InnoDB/PG deadlocks.
|
|
113
|
+
*/
|
|
103
114
|
persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void>;
|
|
104
115
|
findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined>;
|
|
105
116
|
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]>;
|