@stonyx/orm 0.3.2-beta.28 → 0.3.2-beta.280

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 (60) hide show
  1. package/README.md +308 -14
  2. package/config/environment.js +99 -12
  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
  60. package/config/environment.ts +0 -91
package/README.md CHANGED
@@ -13,7 +13,7 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
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,12 @@ 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
+
43
49
  ## Usage example
44
50
 
45
51
  This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
@@ -65,13 +71,16 @@ const {
65
71
  MYSQL_DATABASE,
66
72
  MYSQL_CONNECTION_LIMIT,
67
73
  MYSQL_MIGRATIONS_DIR,
74
+ DYNAMODB_REGION,
75
+ DYNAMODB_ENDPOINT,
76
+ DYNAMODB_TABLE_PREFIX,
68
77
  } = process.env;
69
78
 
70
79
  export default {
71
80
  orm: {
72
81
  logColor: 'white',
73
82
  logMethod: 'db',
74
-
83
+
75
84
  db: {
76
85
  autosave: DB_AUTO_SAVE ?? 'false',
77
86
  file: DB_FILE ?? 'db.json',
@@ -95,15 +104,29 @@ export default {
95
104
  connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
96
105
  migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
97
106
  migrationsTable: '__migrations',
107
+ autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
108
+ } : undefined,
109
+ dynamodb: DYNAMODB_REGION ? {
110
+ region: DYNAMODB_REGION,
111
+ endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
112
+ tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
98
113
  } : undefined,
99
114
  restServer: {
100
- enabled: ORM_USE_REST_SERVER ?? 'true',
115
+ // 'true' requires @stonyx/rest-server to be installed — see
116
+ // "Optional peer dependencies" below.
117
+ enabled: ORM_USE_REST_SERVER ?? 'false',
101
118
  route: ORM_REST_ROUTE ?? '/'
102
119
  }
103
120
  }
104
121
  };
105
122
  ```
106
123
 
124
+ > **`route` and JSON:API `links`.** Generated endpoints are mounted under `route`, and every
125
+ > `links.self` / `links.related` the ORM emits is an absolute URL that includes it. With
126
+ > `ORM_REST_ROUTE='/api'` the animal collection is served at `/api/animals` and advertises
127
+ > `http://host/api/animals`, so published links are followable as-is — do not prepend the
128
+ > mount yourself.
129
+
107
130
  Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
108
131
 
109
132
  ```bash
@@ -112,6 +135,46 @@ stonyx serve
112
135
 
113
136
  For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
114
137
 
138
+ ## Optional peer dependencies
139
+
140
+ `@stonyx/orm` boots with **none** of its optional peers installed. Each one is
141
+ imported lazily, and only when your configuration selects it:
142
+
143
+ | Configuration | Package you must install |
144
+ |---|---|
145
+ | `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
146
+ | `orm.postgres` or `orm.timescale` | `pg` |
147
+ | `orm.mysql` | `mysql2` |
148
+ | `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
149
+
150
+ If a configuration key selects one that is not on disk, the failure surfaces
151
+ from `Orm.init()` while the framework boots:
152
+
153
+ ```
154
+ Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
155
+ ```
156
+
157
+ **ORM-only, no REST server.** This is the shape the `environment.js` above is
158
+ written for: keep `orm.restServer.enabled` at `'false'` and install nothing
159
+ beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
160
+ work unchanged; only the generated REST routes are absent.
161
+
162
+ > **The module's own default is the other way round.** The `config/environment.js`
163
+ > that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
164
+ > an app that omits the `restServer` key *entirely* gets REST switched on and
165
+ > needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
166
+ > you want it.
167
+
168
+ **Turning REST on.**
169
+
170
+ ```bash
171
+ npm install @stonyx/rest-server
172
+ ORM_USE_REST_SERVER=true stonyx serve
173
+ ```
174
+
175
+ See [REST Server Integration](#rest-server-integration) for access classes and
176
+ route configuration.
177
+
115
178
  ## Models
116
179
 
117
180
  Define a model with attributes and relationships:
@@ -243,6 +306,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
243
306
  | `stonyx db:migrate` | Apply pending migrations |
244
307
  | `stonyx db:migrate:rollback` | Rollback the most recent migration |
245
308
  | `stonyx db:migrate:status` | Show migration status |
309
+ | `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
310
+
311
+ ### DynamoDB Mode
312
+
313
+ 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.
314
+
315
+ ```javascript
316
+ dynamodb: {
317
+ region: 'us-east-1',
318
+ endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
319
+ tablePrefix: 'myapp-', // optional table name prefix
320
+ }
321
+ ```
322
+
323
+ Environment variables:
324
+
325
+ * `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
326
+ * `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
327
+ * `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
328
+
329
+ **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.
330
+
331
+ ```bash
332
+ npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
333
+ ```
246
334
 
247
335
  ### Running MySQL Tests
248
336
 
@@ -278,16 +366,164 @@ await setupRestServer('/', './access');
278
366
  Access classes define models and provide custom filtering/authorization logic:
279
367
 
280
368
  ```js
281
- export default class GlobalAccess {
282
- models = ['owner', 'animal'];
369
+ export default class OwnerAccess {
370
+ models = ['owner'];
283
371
 
284
372
  access(request) {
285
- if (request.url.endsWith('/owner/angela')) return false;
373
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
374
+ // never attaches `recordId`, so the collection branch below would fire on
375
+ // the record route and authorize it outright. Exact no-op on a build that
376
+ // has the change — `recordId` is always assigned, `undefined` included.
377
+ if (!('recordId' in request)) return false;
378
+
379
+ // `request.recordId` is the id the ORM resolves the record by. The ORM
380
+ // computes it before `access()` runs, from `request.params.id`, using the
381
+ // same function the record lookup uses — so your predicate and the lookup
382
+ // cannot disagree. Authorize on it, never on a URL and never on raw
383
+ // `params.id`.
384
+ const { recordId } = request;
385
+
386
+ // Returning false explicitly denies access to this record
387
+ if (recordId === 'angela') return false;
388
+
389
+ // No `recordId` means the collection route. Returning a function plugs it in to
390
+ // the response object as a filter. NOTE: a function return authorizes the
391
+ // request outright — the operations list below is not consulted — so this
392
+ // branch permits POST /owners as well as reads.
393
+ if (recordId === undefined) return record => record.id !== 'angela';
394
+
395
+ // Returning a list of operations allows full access to everything else
286
396
  return ['read', 'create', 'update', 'delete'];
287
397
  }
288
398
  }
289
399
  ```
290
400
 
401
+ **Do not authorize on the request URL.** `request.url` is rewritten relative to
402
+ the mount point, so inside the REST server it is `/angela`, not `/owners/angela`
403
+ — a suffix comparison against it never matches and the request falls through to
404
+ whatever the method returns next. `request.originalUrl` keeps the full path but
405
+ is still the raw text the client sent, so it varies with query strings
406
+ (`/owners?x=1`), trailing slashes (`/owners/angela/`), casing (`/OwNeRs/angela`)
407
+ and percent-encoding (`/owners/%61ngela`). Each of those is a plain address-bar
408
+ request. Which of them reach your handler at all depends on how the REST server
409
+ configures Express route matching — that is a deployment detail you should not
410
+ be building an authorization decision on top of. `request.params.id` is
411
+ identical for every spelling that does reach you.
412
+
413
+ **One access class per model when the rules are model-specific.** `access()`
414
+ receives only the request, and the request does not carry the model name
415
+ directly — `request.baseUrl` is the *mount text as the client spelled it*
416
+ (`/OWNERS`), so it must be case-normalised before it is compared, and it is
417
+ still the mount rather than the model. Deriving a model from it means every
418
+ unrecognised spelling falls through to whatever your method returns next, so
419
+ prefer one class per model. A class may still list several models in `models`
420
+ when they share one rule.
421
+
422
+ **Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
423
+ `params.id` is raw text from the client. When it looks numeric the ORM coerces
424
+ it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
425
+ `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
426
+ all address record `7`, while a `===` against the raw text matches only the one
427
+ spelling you wrote down. Every other spelling would fall through to whatever
428
+ your method returns next — which, in the shape above, is a full CRUD grant. All
429
+ of them are plain address-bar requests.
430
+
431
+ **The ORM does that normalisation for you and hands you the result.** `access()`
432
+ is called with `request.recordId` already set to the value the record will be
433
+ resolved by, so there is no arithmetic to copy into your predicate and nothing
434
+ to keep in sync — abofs/stonyx-orm#270. It is `undefined` on collection routes,
435
+ which is how you tell a collection request from a record request. `params.id` is
436
+ left untouched, so anything that needs the raw client text still has it.
437
+
438
+ **The samples above fail *closed* on a build that predates
439
+ abofs/stonyx-orm#270.** That is what the first line of each `access()` is for. A
440
+ build without this change never attaches `recordId`, so `recordId === undefined`
441
+ — the collection branch — fires on the *record* route, and a function return
442
+ authorizes the request outright.
443
+
444
+ The same normalisation is exported for the places `access()` does not reach —
445
+ hooks, custom handlers, your own lookups:
446
+
447
+ ```javascript
448
+ import { normalizeRecordId } from '@stonyx/orm';
449
+
450
+ normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
451
+ ```
452
+
453
+ Do not re-implement it. A hand-written copy is correct only for as long as it
454
+ happens to match, and nothing holds the two together.
455
+
456
+ **`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
457
+ section documents side by side do not agree, and the difference is load-bearing:
458
+ `request.recordId` is `undefined` on a collection route, while
459
+ `normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
460
+ `store.get(key, undefined)` returns the whole model Map rather than a record
461
+ (abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
462
+ is **never** true and a collection branch written that way never runs. Branch on
463
+ `request.recordId === undefined`, or compare against `''`.
464
+
465
+ ```javascript
466
+ export default class AnimalAccess {
467
+ models = ['animal'];
468
+
469
+ access(request) {
470
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
471
+ // never attaches `recordId`, so the collection branch below would fire on
472
+ // the record route and authorize it outright. Exact no-op on a build that
473
+ // has the change — `recordId` is always assigned, `undefined` included.
474
+ if (!('recordId' in request)) return false;
475
+
476
+ // Already normalised by the ORM, and it agrees with the lookup for every
477
+ // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
478
+ // string, so compare it against a number.
479
+ const { recordId } = request;
480
+
481
+ if (recordId === 7) return false;
482
+
483
+ if (recordId === undefined) return record => record.id !== 7;
484
+
485
+ return ['read', 'create', 'update', 'delete'];
486
+ }
487
+ }
488
+ ```
489
+
490
+ Both samples above are extracted from this file and executed verbatim against a
491
+ live server on every CI run — see
492
+ [`test/integration/readme-access/`](https://github.com/abofs/stonyx-orm/tree/dev/test/integration/readme-access).
493
+ `DELETE /owners/angela` is asserted to be refused with the record intact, and
494
+ every spelling named above is measured individually, the numeric ones over a raw
495
+ socket. Nothing in this section is prose that was never run.
496
+
497
+ ### Upgrading: behaviour changes
498
+
499
+ **Advertised `links.self` / `links.related` now carry the REST mount route.**
500
+ Consumer-visible for any deployment where `orm.restServer.route` is not the
501
+ default `'/'`.
502
+
503
+ Measured on this repo's mounted-route harness at `ORM_REST_ROUTE='/api'`, resource
504
+ `links.self` in the response to `GET /api/animals/1`:
505
+
506
+ | | published `links.self` | fetching that URL |
507
+ |---|---|---|
508
+ | before | `http://host/animals/1` | **404** |
509
+ | after | `http://host/api/animals/1` | **200** |
510
+
511
+ The ORM previously built links from the request origin alone, so at any non-default
512
+ mount every URL it advertised pointed at a route that did not exist
513
+ (abofs/stonyx-orm#254). Links are now built from the path the routes are actually
514
+ mounted at, and are followable verbatim.
515
+
516
+ **⚠️ Breaking if you carry a prepending workaround.** The usual workaround for #254
517
+ was for the client to prepend the mount to every link the ORM published. That now
518
+ double-prefixes: prepending `/api` to the new `http://host/api/animals/1` yields
519
+ `http://host/api/api/animals/1`, measured **404**. Remove the prepending. There is no
520
+ configuration flag that restores the old link shape.
521
+
522
+ **Unaffected.** Deployments on the default `ORM_REST_ROUTE='/'` see byte-identical
523
+ output — the prefix is empty, and this is pinned by a byte-identity test against a
524
+ golden fixture captured *before* the fix. Response structure, field names and the
525
+ public API are unchanged; only the URL value inside `links` changes.
526
+
291
527
  ### Include Parameter (Sideloading Relationships)
292
528
 
293
529
  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.
@@ -417,7 +653,7 @@ Each hook receives a context object with comprehensive information:
417
653
  model: 'animal', // Model name
418
654
  operation: 'create', // Operation type
419
655
  request, // Express request object
420
- params, // URL params (e.g., { id: 5 })
656
+ params, // URL params, raw client text (e.g., { id: '5' })
421
657
  body, // Request body (POST/PATCH)
422
658
  query, // Query parameters
423
659
  state, // Request state object
@@ -435,6 +671,7 @@ Each hook receives a context object with comprehensive information:
435
671
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
436
672
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
437
673
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
674
+ - `params.id` is the raw client text as a STRING (`/animals/05` gives `{ id: '05' }`). The id the ORM resolved the record by is `request.recordId`, and for a numeric-id model it is a NUMBER (`5` for both `/animals/5` and `/animals/05`) — abofs/stonyx-orm#270. Key lookups on `request.recordId`, never on `params.id`.
438
675
 
439
676
  ### Usage Examples
440
677
 
@@ -472,9 +709,13 @@ beforeHook('create', 'animal', (context) => {
472
709
  }
473
710
  });
474
711
 
475
- // Return an object to send a custom response
712
+ // Return an object to send a custom response.
713
+ // Look the record up by `context.request.recordId` — the value the ORM
714
+ // resolved the record by. `context.params.id` is the raw client text and is a
715
+ // different value for every alias of the same id, so a lookup keyed on it
716
+ // finds nothing on a numeric-id model (abofs/stonyx-orm#270).
476
717
  beforeHook('delete', 'animal', (context) => {
477
- const animal = store.get('animal', context.params.id);
718
+ const animal = store.get('animal', context.request.recordId);
478
719
  if (animal.protected) {
479
720
  return { errors: [{ detail: 'Cannot delete protected animals' }] };
480
721
  }
@@ -515,9 +756,12 @@ afterHook('update', 'animal', async (context) => {
515
756
  }
516
757
  });
517
758
 
518
- // Cache invalidation
759
+ // Cache invalidation.
760
+ // Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
761
+ // one record but two strings, so a raw-text key invalidates two entries and
762
+ // misses the one the write used.
519
763
  afterHook('delete', 'animal', async (context) => {
520
- await cache.invalidate(`owner:${context.params.id}:pets`);
764
+ await cache.invalidate(`owner:${context.request.recordId}:pets`);
521
765
  });
522
766
  ```
523
767
 
@@ -558,10 +802,16 @@ afterHook('delete', 'animal', async (context) => {
558
802
  #### Authorization
559
803
 
560
804
  ```javascript
561
- // Additional access control - halt with 403 if unauthorized
805
+ // Additional access control - halt with 403 if unauthorized.
806
+ //
807
+ // `context.request.recordId` is the id the ORM resolved the record by — the
808
+ // same value `access()` is handed. Authorizing on `context.params.id` instead
809
+ // looks up the raw client text: on a numeric-id model that lookup returns
810
+ // `undefined` for EVERY spelling, `animal.owner` throws, and the check never
811
+ // runs. Measured; abofs/stonyx-orm#270.
562
812
  beforeHook('delete', 'animal', (context) => {
563
813
  const user = context.state.currentUser;
564
- const animal = store.get('animal', context.params.id);
814
+ const animal = store.get('animal', context.request.recordId);
565
815
 
566
816
  if (animal.owner !== user.id && !user.isAdmin) {
567
817
  return 403; // Forbidden
@@ -762,10 +1012,54 @@ test('validation hook rejects negative age', async () => {
762
1012
  | `afterHook` | Register an after hook for post-operation logic. |
763
1013
  | `clearHook` | Clear hooks for a specific operation:model. |
764
1014
  | `clearAllHooks` | Clear all registered hooks (useful for testing). |
1015
+ | `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
1016
+
1017
+ ### `normalizeRecordId(id)`
1018
+
1019
+ ```ts
1020
+ normalizeRecordId(id?: string | null): string | number
1021
+ ```
1022
+
1023
+ The **one** implementation of URL-id normalisation in the package
1024
+ (abofs/stonyx-orm#270). `access()` is already handed its result as
1025
+ `request.recordId`; import it for the places `access()` does not reach — hooks,
1026
+ custom handlers, your own lookups. Synchronous, and it must stay synchronous:
1027
+ `auth()` is invoked without `await`.
1028
+
1029
+ | Input | Returns | Note |
1030
+ | --- | --- | --- |
1031
+ | `'7'`, `'007'`, `'7.0'`, `'7.9'`, `'7e0'`, `'0x7'`, `'+7'`, `' 7'` | `7` (number) | `parseInt` with **no radix**; passing a radix of 10 would make this `0` |
1032
+ | `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
1033
+ | `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
1034
+ | `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
1035
+ | `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
1036
+
1037
+ **The trap.** `request.recordId` is `undefined` on a collection route;
1038
+ `normalizeRecordId(undefined)` is `''`. They are different values and a
1039
+ collection check written against the wrong one silently never fires:
1040
+
1041
+ ```javascript
1042
+ // WRONG — never true, so this branch never runs
1043
+ if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
1044
+
1045
+ // Right — the ORM attaches undefined for "this route carries no :id"
1046
+ if (context.request.recordId === undefined) { /* collection route */ }
1047
+ ```
1048
+
1049
+ The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
1050
+ rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
1051
+ the empty string this function returns today.
1052
+
1053
+ **A normalised id is not a promise that a record exists.** `0` and `NaN` are both
1054
+ possible returns and neither addresses a record you can rely on — `NaN` is not
1055
+ even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
1056
+ `recordId` as "the key the lookup will use", not as "a record is there".
765
1057
 
766
1058
  ## Project Structure
767
1059
 
768
- For a full architectural reference, see [project-structure.md](project-structure.md).
1060
+ For a full architectural reference, see
1061
+ [docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
1062
+ That file is repo-only — it is not in the published tarball, so the link is absolute.
769
1063
 
770
1064
  ## License
771
1065
 
@@ -1,12 +1,99 @@
1
- // project configuration, override-able by listed environment variables
2
- const {
3
- DEBUG,
4
- NODE_ENV,
5
- } = process.env;
6
-
7
- const environment = NODE_ENV ?? 'development';
8
-
9
- export default {
10
- environment,
11
- debug: DEBUG ?? environment === 'development',
12
- }
1
+ const {
2
+ ORM_ACCESS_PATH,
3
+ ORM_MODEL_PATH,
4
+ ORM_REST_ROUTE,
5
+ ORM_SERIALIZER_PATH,
6
+ ORM_TRANSFORM_PATH,
7
+ ORM_VIEW_PATH,
8
+ ORM_USE_REST_SERVER,
9
+ DB_AUTO_SAVE,
10
+ DB_FILE,
11
+ DB_MODE,
12
+ DB_DIRECTORY,
13
+ DB_SCHEMA_PATH,
14
+ DB_SAVE_INTERVAL,
15
+ MYSQL_HOST,
16
+ MYSQL_PORT,
17
+ MYSQL_USER,
18
+ MYSQL_PASSWORD,
19
+ MYSQL_DATABASE,
20
+ MYSQL_CONNECTION_LIMIT,
21
+ MYSQL_MIGRATIONS_DIR,
22
+ PG_HOST,
23
+ PG_PORT,
24
+ PG_USER,
25
+ PG_PASSWORD,
26
+ PG_DATABASE,
27
+ PG_CONNECTION_LIMIT,
28
+ PG_MIGRATIONS_DIR,
29
+ TIMESCALE_HOST,
30
+ TIMESCALE_PORT,
31
+ TIMESCALE_USER,
32
+ TIMESCALE_PASSWORD,
33
+ TIMESCALE_DATABASE,
34
+ TIMESCALE_CONNECTION_LIMIT,
35
+ TIMESCALE_MIGRATIONS_DIR,
36
+ DYNAMODB_REGION,
37
+ DYNAMODB_ENDPOINT,
38
+ DYNAMODB_TABLE_PREFIX,
39
+ } = process.env;
40
+
41
+ export default {
42
+ logColor: 'white',
43
+ logMethod: 'db',
44
+
45
+ db: {
46
+ autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
47
+ file: DB_FILE ?? 'db.json',
48
+ mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
49
+ directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
50
+ saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
51
+ schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
52
+ },
53
+ paths: {
54
+ access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
55
+ model: ORM_MODEL_PATH ?? './models',
56
+ serializer: ORM_SERIALIZER_PATH ?? './serializers',
57
+ transform: ORM_TRANSFORM_PATH ?? './transforms',
58
+ view: ORM_VIEW_PATH ?? './views'
59
+ },
60
+ mysql: MYSQL_HOST ? {
61
+ host: MYSQL_HOST ?? 'localhost',
62
+ port: parseInt(MYSQL_PORT ?? '3306'),
63
+ user: MYSQL_USER ?? 'root',
64
+ password: MYSQL_PASSWORD ?? '',
65
+ database: MYSQL_DATABASE ?? 'stonyx',
66
+ connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
67
+ migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
68
+ migrationsTable: '__migrations',
69
+ } : undefined,
70
+ postgres: PG_HOST ? {
71
+ host: PG_HOST ?? 'localhost',
72
+ port: parseInt(PG_PORT ?? '5432'),
73
+ user: PG_USER ?? 'postgres',
74
+ password: PG_PASSWORD ?? '',
75
+ database: PG_DATABASE ?? 'stonyx',
76
+ connectionLimit: parseInt(PG_CONNECTION_LIMIT ?? '10'),
77
+ migrationsDir: PG_MIGRATIONS_DIR ?? 'migrations',
78
+ migrationsTable: '__migrations',
79
+ } : undefined,
80
+ timescale: TIMESCALE_HOST ? {
81
+ host: TIMESCALE_HOST ?? 'localhost',
82
+ port: parseInt(TIMESCALE_PORT ?? '5432'),
83
+ user: TIMESCALE_USER ?? 'postgres',
84
+ password: TIMESCALE_PASSWORD ?? '',
85
+ database: TIMESCALE_DATABASE ?? 'stonyx',
86
+ connectionLimit: parseInt(TIMESCALE_CONNECTION_LIMIT ?? '10'),
87
+ migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
88
+ migrationsTable: '__migrations',
89
+ } : undefined,
90
+ dynamodb: DYNAMODB_REGION ? {
91
+ region: DYNAMODB_REGION,
92
+ endpoint: DYNAMODB_ENDPOINT || undefined,
93
+ tablePrefix: DYNAMODB_TABLE_PREFIX || '',
94
+ } : undefined,
95
+ restServer: {
96
+ enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
97
+ route: ORM_REST_ROUTE ?? '/',
98
+ }
99
+ }
@@ -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
+ }