@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.
Files changed (51) hide show
  1. package/README.md +341 -29
  2. package/dist/column-conversion.d.ts +34 -0
  3. package/dist/column-conversion.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +7 -4
  5. package/dist/dynamodb/connection.js +4 -4
  6. package/dist/dynamodb/dynamodb-db.d.ts +11 -0
  7. package/dist/dynamodb/dynamodb-db.js +54 -14
  8. package/dist/dynamodb/operation-builder.js +20 -13
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +1 -0
  11. package/dist/main.js +19 -1
  12. package/dist/manage-record.js +34 -3
  13. package/dist/mysql/connection.d.ts +1 -0
  14. package/dist/mysql/mysql-db.d.ts +8 -0
  15. package/dist/mysql/mysql-db.js +44 -10
  16. package/dist/normalize-record-id.d.ts +67 -0
  17. package/dist/normalize-record-id.js +88 -0
  18. package/dist/orm-request.d.ts +17 -1
  19. package/dist/orm-request.js +77 -19
  20. package/dist/postgres/column-rules.d.ts +33 -0
  21. package/dist/postgres/column-rules.js +34 -0
  22. package/dist/postgres/connection.d.ts +1 -0
  23. package/dist/postgres/connection.js +8 -6
  24. package/dist/postgres/postgres-db.d.ts +12 -0
  25. package/dist/postgres/postgres-db.js +60 -19
  26. package/dist/record.js +7 -5
  27. package/dist/relationships.js +1 -1
  28. package/dist/serializer.js +38 -2
  29. package/dist/store.d.ts +13 -1
  30. package/dist/store.js +65 -6
  31. package/dist/types/orm-types.d.ts +3 -0
  32. package/package.json +11 -7
  33. package/src/column-conversion.ts +53 -0
  34. package/src/dynamodb/connection.ts +9 -8
  35. package/src/dynamodb/dynamodb-db.ts +56 -13
  36. package/src/dynamodb/operation-builder.ts +29 -15
  37. package/src/index.ts +2 -0
  38. package/src/main.ts +19 -1
  39. package/src/manage-record.ts +41 -9
  40. package/src/mysql/connection.ts +1 -0
  41. package/src/mysql/mysql-db.ts +44 -12
  42. package/src/normalize-record-id.ts +88 -0
  43. package/src/orm-request.ts +109 -18
  44. package/src/postgres/column-rules.ts +37 -0
  45. package/src/postgres/connection.ts +10 -6
  46. package/src/postgres/postgres-db.ts +64 -21
  47. package/src/record.ts +8 -5
  48. package/src/relationships.ts +1 -1
  49. package/src/serializer.ts +39 -2
  50. package/src/store.ts +68 -6
  51. 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
- 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',
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. 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.
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 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`:
304
374
 
305
375
  ```js
306
- import setupRestServer from '@stonyx/orm/setup-rest-server';
307
-
308
- 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
+ };
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 GlobalAccess {
315
- models = ['owner', 'animal'];
392
+ export default class OwnerAccess {
393
+ models = ['owner'];
316
394
 
317
395
  access(request) {
318
- 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
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 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`.
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
- beforeHook('delete', 'animal', (context) => {
510
- 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);
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 (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
+
539
788
  afterHook('update', 'animal', async (context) => {
540
- // Use oldState to compare before/after values
541
- 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) {
542
793
  await sendNotification({
543
794
  type: 'adoption',
544
795
  animalId: context.record.id,
545
- previousOwner: context.oldState.owner,
546
- newOwner: context.record.owner
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.params.id}:pets`);
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
- 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) => {
596
860
  const user = context.state.currentUser;
597
- const animal = store.get('animal', context.params.id);
861
+ const animal = await store.find('animal', context.request.recordId);
598
862
 
599
- 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)) {
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
- 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;
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 [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.
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 DocumentClientConstructor = new (options: {
16
- client: unknown;
17
- }) => DocumentClient;
18
- export type DynamoDBClientConstructor = new (options: unknown) => unknown;
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 { DynamoDB } = await import('@aws-sdk/client-dynamodb');
13
- const { DynamoDBDocument } = await import('@aws-sdk/lib-dynamodb');
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 DynamoDB(clientOptions);
20
- return new DynamoDBDocument({ client: rawClient });
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[]>;