@stonyx/orm 0.3.2-beta.32 → 0.3.2-beta.320
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 +375 -30
- package/config/{environment.ts → environment.js} +8 -0
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.js +29 -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 +11 -0
- package/package.json +19 -7
- package/src/column-conversion.ts +53 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +2 -0
- package/src/main.ts +29 -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 +12 -0
- package/src/types/stonyx.d.ts +7 -1
package/README.md
CHANGED
|
@@ -9,11 +9,11 @@ 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.
|
|
16
|
-
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL for production workloads.
|
|
16
|
+
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL/PostgreSQL/TimescaleDB/DynamoDB for production workloads.
|
|
17
17
|
- **REST Server Integration**: Automatic route setup with customizable access control.
|
|
18
18
|
- **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
|
|
19
19
|
|
|
@@ -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:
|
|
@@ -65,13 +86,16 @@ const {
|
|
|
65
86
|
MYSQL_DATABASE,
|
|
66
87
|
MYSQL_CONNECTION_LIMIT,
|
|
67
88
|
MYSQL_MIGRATIONS_DIR,
|
|
89
|
+
DYNAMODB_REGION,
|
|
90
|
+
DYNAMODB_ENDPOINT,
|
|
91
|
+
DYNAMODB_TABLE_PREFIX,
|
|
68
92
|
} = process.env;
|
|
69
93
|
|
|
70
94
|
export default {
|
|
71
95
|
orm: {
|
|
72
96
|
logColor: 'white',
|
|
73
97
|
logMethod: 'db',
|
|
74
|
-
|
|
98
|
+
|
|
75
99
|
db: {
|
|
76
100
|
autosave: DB_AUTO_SAVE ?? 'false',
|
|
77
101
|
file: DB_FILE ?? 'db.json',
|
|
@@ -95,15 +119,29 @@ export default {
|
|
|
95
119
|
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
96
120
|
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
97
121
|
migrationsTable: '__migrations',
|
|
122
|
+
autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
|
|
123
|
+
} : undefined,
|
|
124
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
125
|
+
region: DYNAMODB_REGION,
|
|
126
|
+
endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
|
|
127
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
98
128
|
} : undefined,
|
|
99
129
|
restServer: {
|
|
100
|
-
|
|
130
|
+
// 'true' requires @stonyx/rest-server to be installed — see
|
|
131
|
+
// "Optional peer dependencies" below.
|
|
132
|
+
enabled: ORM_USE_REST_SERVER ?? 'false',
|
|
101
133
|
route: ORM_REST_ROUTE ?? '/'
|
|
102
134
|
}
|
|
103
135
|
}
|
|
104
136
|
};
|
|
105
137
|
```
|
|
106
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
|
+
|
|
107
145
|
Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
|
|
108
146
|
|
|
109
147
|
```bash
|
|
@@ -112,6 +150,46 @@ stonyx serve
|
|
|
112
150
|
|
|
113
151
|
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
114
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
|
+
|
|
115
193
|
## Models
|
|
116
194
|
|
|
117
195
|
Define a model with attributes and relationships:
|
|
@@ -235,7 +313,7 @@ In directory mode, each collection is stored as `{directory}/{collection}.json`
|
|
|
235
313
|
|
|
236
314
|
### MySQL Mode
|
|
237
315
|
|
|
238
|
-
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.
|
|
239
317
|
|
|
240
318
|
| Command | Description |
|
|
241
319
|
|---------|-------------|
|
|
@@ -243,6 +321,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
|
|
|
243
321
|
| `stonyx db:migrate` | Apply pending migrations |
|
|
244
322
|
| `stonyx db:migrate:rollback` | Rollback the most recent migration |
|
|
245
323
|
| `stonyx db:migrate:status` | Show migration status |
|
|
324
|
+
| `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
|
|
325
|
+
|
|
326
|
+
### DynamoDB Mode
|
|
327
|
+
|
|
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.
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
dynamodb: {
|
|
332
|
+
region: 'us-east-1',
|
|
333
|
+
endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
|
|
334
|
+
tablePrefix: 'myapp-', // optional table name prefix
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Environment variables:
|
|
339
|
+
|
|
340
|
+
* `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
|
|
341
|
+
* `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
|
|
342
|
+
* `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
|
|
343
|
+
|
|
344
|
+
**Peer dependencies:** `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` must be installed when using the DynamoDB driver. The AWS SDK is dynamically imported and only loaded when the DynamoDB driver is selected.
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
|
348
|
+
```
|
|
246
349
|
|
|
247
350
|
### Running MySQL Tests
|
|
248
351
|
|
|
@@ -267,27 +370,188 @@ MySQL integration tests run automatically when MySQL is available. In CI (where
|
|
|
267
370
|
|
|
268
371
|
## REST Server Integration
|
|
269
372
|
|
|
270
|
-
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`:
|
|
271
374
|
|
|
272
375
|
```js
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
};
|
|
276
387
|
```
|
|
277
388
|
|
|
278
389
|
Access classes define models and provide custom filtering/authorization logic:
|
|
279
390
|
|
|
280
391
|
```js
|
|
281
|
-
export default class
|
|
282
|
-
models = ['owner'
|
|
392
|
+
export default class OwnerAccess {
|
|
393
|
+
models = ['owner'];
|
|
283
394
|
|
|
284
395
|
access(request) {
|
|
285
|
-
|
|
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
|
|
286
419
|
return ['read', 'create', 'update', 'delete'];
|
|
287
420
|
}
|
|
288
421
|
}
|
|
289
422
|
```
|
|
290
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
|
+
|
|
291
555
|
### Include Parameter (Sideloading Relationships)
|
|
292
556
|
|
|
293
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.
|
|
@@ -380,6 +644,7 @@ GET /animals/1
|
|
|
380
644
|
#### Limitations
|
|
381
645
|
|
|
382
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.
|
|
383
648
|
|
|
384
649
|
## Lifecycle Hooks
|
|
385
650
|
|
|
@@ -417,7 +682,7 @@ Each hook receives a context object with comprehensive information:
|
|
|
417
682
|
model: 'animal', // Model name
|
|
418
683
|
operation: 'create', // Operation type
|
|
419
684
|
request, // Express request object
|
|
420
|
-
params, // URL params (e.g., { id: 5 })
|
|
685
|
+
params, // URL params, raw client text (e.g., { id: '5' })
|
|
421
686
|
body, // Request body (POST/PATCH)
|
|
422
687
|
query, // Query parameters
|
|
423
688
|
state, // Request state object
|
|
@@ -434,7 +699,8 @@ Each hook receives a context object with comprehensive information:
|
|
|
434
699
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
435
700
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
436
701
|
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
437
|
-
- `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`.
|
|
438
704
|
|
|
439
705
|
### Usage Examples
|
|
440
706
|
|
|
@@ -472,9 +738,17 @@ beforeHook('create', 'animal', (context) => {
|
|
|
472
738
|
}
|
|
473
739
|
});
|
|
474
740
|
|
|
475
|
-
// Return an object to send a custom response
|
|
476
|
-
|
|
477
|
-
|
|
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);
|
|
478
752
|
if (animal.protected) {
|
|
479
753
|
return { errors: [{ detail: 'Cannot delete protected animals' }] };
|
|
480
754
|
}
|
|
@@ -502,22 +776,35 @@ beforeHook('create', 'owner', (context) => {
|
|
|
502
776
|
#### Side Effects
|
|
503
777
|
|
|
504
778
|
```javascript
|
|
505
|
-
// 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
|
+
|
|
506
788
|
afterHook('update', 'animal', async (context) => {
|
|
507
|
-
|
|
508
|
-
|
|
789
|
+
const previousOwner = context.state.previousOwnerId;
|
|
790
|
+
const newOwner = context.record?.owner?.id;
|
|
791
|
+
|
|
792
|
+
if (previousOwner !== newOwner) {
|
|
509
793
|
await sendNotification({
|
|
510
794
|
type: 'adoption',
|
|
511
795
|
animalId: context.record.id,
|
|
512
|
-
previousOwner
|
|
513
|
-
newOwner
|
|
796
|
+
previousOwner,
|
|
797
|
+
newOwner
|
|
514
798
|
});
|
|
515
799
|
}
|
|
516
800
|
});
|
|
517
801
|
|
|
518
|
-
// 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.
|
|
519
806
|
afterHook('delete', 'animal', async (context) => {
|
|
520
|
-
await cache.invalidate(`owner:${context.
|
|
807
|
+
await cache.invalidate(`owner:${context.request.recordId}:pets`);
|
|
521
808
|
});
|
|
522
809
|
```
|
|
523
810
|
|
|
@@ -558,12 +845,25 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
558
845
|
#### Authorization
|
|
559
846
|
|
|
560
847
|
```javascript
|
|
561
|
-
// Additional access control - halt with 403 if unauthorized
|
|
562
|
-
|
|
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) => {
|
|
563
860
|
const user = context.state.currentUser;
|
|
564
|
-
const animal = store.
|
|
861
|
+
const animal = await store.find('animal', context.request.recordId);
|
|
565
862
|
|
|
566
|
-
|
|
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)) {
|
|
567
867
|
return 403; // Forbidden
|
|
568
868
|
}
|
|
569
869
|
});
|
|
@@ -675,7 +975,8 @@ beforeHook('update', 'animal', (context) => {
|
|
|
675
975
|
```javascript
|
|
676
976
|
// Update owner's pet count when animal is created
|
|
677
977
|
afterHook('create', 'animal', async (context) => {
|
|
678
|
-
|
|
978
|
+
// A belongsTo is already the related Record; no store lookup needed
|
|
979
|
+
const { owner } = context.record;
|
|
679
980
|
if (owner) {
|
|
680
981
|
owner.petCount = (owner.petCount || 0) + 1;
|
|
681
982
|
}
|
|
@@ -762,10 +1063,54 @@ test('validation hook rejects negative age', async () => {
|
|
|
762
1063
|
| `afterHook` | Register an after hook for post-operation logic. |
|
|
763
1064
|
| `clearHook` | Clear hooks for a specific operation:model. |
|
|
764
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".
|
|
765
1108
|
|
|
766
1109
|
## Project Structure
|
|
767
1110
|
|
|
768
|
-
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.
|
|
769
1114
|
|
|
770
1115
|
## License
|
|
771
1116
|
|
|
@@ -33,6 +33,9 @@ const {
|
|
|
33
33
|
TIMESCALE_DATABASE,
|
|
34
34
|
TIMESCALE_CONNECTION_LIMIT,
|
|
35
35
|
TIMESCALE_MIGRATIONS_DIR,
|
|
36
|
+
DYNAMODB_REGION,
|
|
37
|
+
DYNAMODB_ENDPOINT,
|
|
38
|
+
DYNAMODB_TABLE_PREFIX,
|
|
36
39
|
} = process.env;
|
|
37
40
|
|
|
38
41
|
export default {
|
|
@@ -84,6 +87,11 @@ export default {
|
|
|
84
87
|
migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
|
|
85
88
|
migrationsTable: '__migrations',
|
|
86
89
|
} : undefined,
|
|
90
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
91
|
+
region: DYNAMODB_REGION,
|
|
92
|
+
endpoint: DYNAMODB_ENDPOINT || undefined,
|
|
93
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX || '',
|
|
94
|
+
} : undefined,
|
|
87
95
|
restServer: {
|
|
88
96
|
enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
|
|
89
97
|
route: ORM_REST_ROUTE ?? '/',
|
|
@@ -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
|
+
}
|