@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.
Files changed (59) hide show
  1. package/README.md +375 -30
  2. package/config/{environment.ts → environment.js} +8 -0
  3. package/dist/column-conversion.d.ts +34 -0
  4. package/dist/column-conversion.js +34 -0
  5. package/dist/commands.js +34 -0
  6. package/dist/dynamodb/connection.d.ts +31 -0
  7. package/dist/dynamodb/connection.js +28 -0
  8. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  9. package/dist/dynamodb/dynamodb-db.js +596 -0
  10. package/dist/dynamodb/operation-builder.d.ts +76 -0
  11. package/dist/dynamodb/operation-builder.js +116 -0
  12. package/dist/dynamodb/type-map.d.ts +31 -0
  13. package/dist/dynamodb/type-map.js +48 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +1 -0
  16. package/dist/main.js +29 -1
  17. package/dist/manage-record.js +34 -3
  18. package/dist/mysql/connection.d.ts +1 -0
  19. package/dist/mysql/mysql-db.d.ts +8 -0
  20. package/dist/mysql/mysql-db.js +44 -10
  21. package/dist/normalize-record-id.d.ts +67 -0
  22. package/dist/normalize-record-id.js +88 -0
  23. package/dist/orm-request.d.ts +17 -1
  24. package/dist/orm-request.js +77 -19
  25. package/dist/postgres/column-rules.d.ts +33 -0
  26. package/dist/postgres/column-rules.js +34 -0
  27. package/dist/postgres/connection.d.ts +1 -0
  28. package/dist/postgres/connection.js +8 -6
  29. package/dist/postgres/postgres-db.d.ts +12 -0
  30. package/dist/postgres/postgres-db.js +60 -19
  31. package/dist/record.js +7 -5
  32. package/dist/relationships.js +1 -1
  33. package/dist/serializer.js +38 -2
  34. package/dist/store.d.ts +13 -1
  35. package/dist/store.js +65 -6
  36. package/dist/types/orm-types.d.ts +11 -0
  37. package/package.json +19 -7
  38. package/src/column-conversion.ts +53 -0
  39. package/src/commands.ts +43 -0
  40. package/src/dynamodb/connection.ts +50 -0
  41. package/src/dynamodb/dynamodb-db.ts +811 -0
  42. package/src/dynamodb/operation-builder.ts +202 -0
  43. package/src/dynamodb/type-map.ts +54 -0
  44. package/src/index.ts +2 -0
  45. package/src/main.ts +29 -1
  46. package/src/manage-record.ts +41 -9
  47. package/src/mysql/connection.ts +1 -0
  48. package/src/mysql/mysql-db.ts +44 -12
  49. package/src/normalize-record-id.ts +88 -0
  50. package/src/orm-request.ts +109 -18
  51. package/src/postgres/column-rules.ts +37 -0
  52. package/src/postgres/connection.ts +10 -6
  53. package/src/postgres/postgres-db.ts +64 -21
  54. package/src/record.ts +8 -5
  55. package/src/relationships.ts +1 -1
  56. package/src/serializer.ts +39 -2
  57. package/src/store.ts +68 -6
  58. package/src/types/orm-types.ts +12 -0
  59. 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
- enabled: ORM_USE_REST_SERVER ?? 'true',
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. The ORM loads all records into memory on startup and persists CRUD operations to MySQL automatically. Supports schema-aware migration generation, apply, rollback, and drift detection.
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 can automatically register REST routes using your access classes.
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
- import setupRestServer from '@stonyx/orm/setup-rest-server';
274
-
275
- await setupRestServer('/', './access');
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 GlobalAccess {
282
- models = ['owner', 'animal'];
392
+ export default class OwnerAccess {
393
+ models = ['owner'];
283
394
 
284
395
  access(request) {
285
- if (request.url.endsWith('/owner/angela')) return false;
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 field values
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
- beforeHook('delete', 'animal', (context) => {
477
- const animal = store.get('animal', context.params.id);
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 (using oldState to detect changes)
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
- // Use oldState to compare before/after values
508
- if (context.oldState && context.oldState.owner !== context.record.owner) {
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: context.oldState.owner,
513
- newOwner: context.record.owner
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.params.id}:pets`);
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
- beforeHook('delete', 'animal', (context) => {
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.get('animal', context.params.id);
861
+ const animal = await store.find('animal', context.request.recordId);
565
862
 
566
- if (animal.owner !== user.id && !user.isAdmin) {
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
- const owner = store.get('owner', context.record.owner);
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 [project-structure.md](project-structure.md).
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
+ }