@stonyx/orm 0.3.2-beta.15 → 0.3.2-beta.151

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 (48) hide show
  1. package/README.md +402 -10
  2. package/config/environment.js +99 -12
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/main.js +10 -0
  13. package/dist/manage-record.js +34 -3
  14. package/dist/mysql/connection.d.ts +1 -0
  15. package/dist/mysql/mysql-db.d.ts +8 -0
  16. package/dist/mysql/mysql-db.js +44 -10
  17. package/dist/orm-request.d.ts +60 -0
  18. package/dist/orm-request.js +644 -47
  19. package/dist/postgres/connection.d.ts +1 -0
  20. package/dist/postgres/connection.js +8 -6
  21. package/dist/postgres/postgres-db.d.ts +8 -0
  22. package/dist/postgres/postgres-db.js +44 -10
  23. package/dist/record.js +7 -5
  24. package/dist/relationships.js +1 -1
  25. package/dist/serializer.js +38 -2
  26. package/dist/store.d.ts +13 -1
  27. package/dist/store.js +65 -6
  28. package/dist/types/orm-types.d.ts +11 -0
  29. package/package.json +16 -7
  30. package/src/commands.ts +43 -0
  31. package/src/dynamodb/connection.ts +50 -0
  32. package/src/dynamodb/dynamodb-db.ts +811 -0
  33. package/src/dynamodb/operation-builder.ts +202 -0
  34. package/src/dynamodb/type-map.ts +54 -0
  35. package/src/main.ts +10 -0
  36. package/src/manage-record.ts +41 -9
  37. package/src/mysql/connection.ts +1 -0
  38. package/src/mysql/mysql-db.ts +44 -12
  39. package/src/orm-request.ts +654 -45
  40. package/src/postgres/connection.ts +10 -6
  41. package/src/postgres/postgres-db.ts +44 -12
  42. package/src/record.ts +8 -5
  43. package/src/relationships.ts +1 -1
  44. package/src/serializer.ts +39 -2
  45. package/src/store.ts +68 -6
  46. package/src/types/orm-types.ts +12 -0
  47. package/src/types/stonyx.d.ts +7 -1
  48. 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
 
@@ -65,13 +65,16 @@ const {
65
65
  MYSQL_DATABASE,
66
66
  MYSQL_CONNECTION_LIMIT,
67
67
  MYSQL_MIGRATIONS_DIR,
68
+ DYNAMODB_REGION,
69
+ DYNAMODB_ENDPOINT,
70
+ DYNAMODB_TABLE_PREFIX,
68
71
  } = process.env;
69
72
 
70
73
  export default {
71
74
  orm: {
72
75
  logColor: 'white',
73
76
  logMethod: 'db',
74
-
77
+
75
78
  db: {
76
79
  autosave: DB_AUTO_SAVE ?? 'false',
77
80
  file: DB_FILE ?? 'db.json',
@@ -95,6 +98,12 @@ export default {
95
98
  connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
96
99
  migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
97
100
  migrationsTable: '__migrations',
101
+ autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
102
+ } : undefined,
103
+ dynamodb: DYNAMODB_REGION ? {
104
+ region: DYNAMODB_REGION,
105
+ endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
106
+ tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
98
107
  } : undefined,
99
108
  restServer: {
100
109
  enabled: ORM_USE_REST_SERVER ?? 'true',
@@ -243,6 +252,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
243
252
  | `stonyx db:migrate` | Apply pending migrations |
244
253
  | `stonyx db:migrate:rollback` | Rollback the most recent migration |
245
254
  | `stonyx db:migrate:status` | Show migration status |
255
+ | `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
256
+
257
+ ### DynamoDB Mode
258
+
259
+ 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.
260
+
261
+ ```javascript
262
+ dynamodb: {
263
+ region: 'us-east-1',
264
+ endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
265
+ tablePrefix: 'myapp-', // optional table name prefix
266
+ }
267
+ ```
268
+
269
+ Environment variables:
270
+
271
+ * `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
272
+ * `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
273
+ * `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
274
+
275
+ **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.
276
+
277
+ ```bash
278
+ npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
279
+ ```
246
280
 
247
281
  ### Running MySQL Tests
248
282
 
@@ -275,19 +309,343 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
275
309
  await setupRestServer('/', './access');
276
310
  ```
277
311
 
278
- Access classes define models and provide custom filtering/authorization logic:
312
+ Access classes define models and provide custom filtering/authorization logic.
313
+
314
+ > **Do not reconstruct the request path inside `access()`. Read
315
+ > [Identifying the collection](#identifying-the-collection) before copying this.**
316
+ > Every attempt to identify the collection by parsing the request target has
317
+ > failed **open** — five distinct variants of this same example, each found only
318
+ > after the previous was fixed, by five different people. The sample below does
319
+ > not parse anything: it reads `request.baseUrl`, the mount Express actually
320
+ > matched.
321
+ >
322
+ > That is still a stopgap. **The real fix is
323
+ > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
324
+ > receive the model, the operation and the record, so there is nothing to
325
+ > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
326
+ > you can: the **function** shape is the one that requires any matching at all.
327
+ > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
328
+ > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
329
+ > README and that source header are the two copies a consumer sees.
330
+ >
331
+ > This sample is the same code as the shipped test fixture, and a test asserts
332
+ > the two `access()` bodies are identical line for line. For four rounds they
333
+ > were two independently written copies — and the fifth fail-open variant was
334
+ > found in the one nothing was mutating.
279
335
 
280
336
  ```js
281
337
  export default class GlobalAccess {
282
338
  models = ['owner', 'animal'];
283
339
 
284
340
  access(request) {
285
- if (request.url.endsWith('/owner/angela')) return false;
341
+ // `request.baseUrl` is the mount Express matched — `/owners`, or
342
+ // `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
343
+ // the raw request target and can be absolute-form.
344
+ const mount = request.baseUrl;
345
+
346
+ // FAIL CLOSED. If Express did not tell us what it matched we are not behind
347
+ // the mount we think we are, and an unidentifiable request denies rather
348
+ // than falling through to the CRUD grant at the bottom.
349
+ if (typeof mount !== 'string' || mount === '') return false;
350
+
351
+ // Lower-cased because the router matched case-INSENSITIVELY, and a matcher
352
+ // stricter than the router that dispatched the request can be stepped
353
+ // around. The PATH only — record ids stay at their real case below.
354
+ const collection = mount.toLowerCase();
355
+
356
+ // `request.path` is mount-relative and query-free, so sub-path rules need no
357
+ // prefix arithmetic either. false → 403 for the whole request.
358
+ const path = String(request.path ?? '').toLowerCase();
359
+
360
+ if (collection.endsWith('/owners')) {
361
+ if (path === '/archived' || path.startsWith('/archived/')) return false;
362
+
363
+ // Returning a function plugs it in as a per-record filter, and it is
364
+ // enforced on every surface addressed to one of these records:
365
+ // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
366
+ // A rejected record is 404 on record routes — the same status as a record
367
+ // that does not exist — so the filter is not an existence oracle.
368
+ return record => record.id !== 'angela' && record.id !== 'restricted';
369
+ }
370
+
371
+ // `record.owner` resolves to an OrmRecord, not to the owner's id string —
372
+ // comparing it directly against a string is the bug that made this predicate
373
+ // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
374
+ // shape as well as the resolved one would absorb a resolution regression
375
+ // silently, which is exactly what blinded this fixture before.
376
+ if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
377
+
378
+ // Allows full access to all calls that don't match any of the above conditions
286
379
  return ['read', 'create', 'update', 'delete'];
287
380
  }
288
381
  }
289
382
  ```
290
383
 
384
+ ### Return values
385
+
386
+ | `access()` returns | Effect |
387
+ |---|---|
388
+ | `false` (or any falsy value) | `403` for the whole request |
389
+ | `true` | full access, no filter |
390
+ | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
391
+ | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
392
+ | a function | a per-record filter — see below |
393
+ | anything else | `403` — unknown shapes fail **closed** |
394
+
395
+ A `throw` inside `access()` is a **denial**, not a 500.
396
+
397
+ ### Filter functions
398
+
399
+ A function return value is a **per-record predicate**, and it is enforced on
400
+ every endpoint that is addressed to a record — not only on the collection.
401
+
402
+ It is evaluated against the record the route is *addressed to*, **on that model
403
+ only**. It is not a guarantee that a hidden record cannot be reached or modified:
404
+ a write to a *different* collection can still re-parent one. See
405
+ [Known limitations](#known-limitations) and
406
+ [#207](https://github.com/abofs/stonyx-orm/issues/207).
407
+
408
+ | Endpoint | A record the predicate rejects |
409
+ |---|---|
410
+ | `GET /:models` | omitted from the collection |
411
+ | `GET /:models/:id` | `404` |
412
+ | `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
413
+ | `GET /:models/:id/relationships/{relationship}` | `404` — same |
414
+ | `PATCH /:models/:id` | `404`, no attribute is applied |
415
+ | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
416
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
417
+
418
+ **Denied record-level requests return 404, not 403.** This is deliberate and it
419
+ is the property most easily "improved" away. 403 would confirm that the record
420
+ exists to a caller who is not allowed to know that, which turns the filter into
421
+ an existence oracle: `404` means "no such record", `403` means "there is one and
422
+ it is not yours". Every status on a record route must therefore be identical for
423
+ "filtered out" and "does not exist" — including `DELETE`, which is why deleting
424
+ a record that never existed also returns 404 rather than 204.
425
+
426
+ `POST` is the one exception and returns **403**, because 404 on a mounted
427
+ collection route is indistinguishable from "model not mounted" — a genuinely
428
+ different failure a developer needs to diagnose.
429
+
430
+ **A client-supplied `id` on `POST` is refused with `403` whenever a function
431
+ filter is in force.** This is the part that keeps `POST` from being an
432
+ enumeration oracle, and it is worth understanding rather than working around.
433
+ The duplicate-id check has to run before the filter, and it sees records the
434
+ filter hides, so the *status* of a `POST` otherwise leaks whether an id is
435
+ taken:
436
+
437
+ | `POST /animals` with a payload the caller may create | before | now |
438
+ |---|---|---|
439
+ | an id held by a record the filter **hides** | `403` | `403` |
440
+ | an id that is **free** | `200` | `403` |
441
+ | an id held by a record the caller **can see** | `409` | `403` |
442
+
443
+ Three outcomes, one request per id, the whole id space. Filtering only the
444
+ *collision* status narrows that to callers who cannot create a record they are
445
+ allowed to see; it does not close it. It cannot be closed while a caller both
446
+ chooses the id and learns whether the create succeeded — so under a filter the
447
+ caller does not choose the id. The refusal happens before any store lookup, so
448
+ neither the status nor the response time depends on whether the id exists.
449
+
450
+ Let the server assign the id and read it back from the response. Callers with no
451
+ function-style filter are unaffected: `409` on a duplicate id and `200` on a free
452
+ one both behave exactly as before.
453
+
454
+ ### Identifying the collection
455
+
456
+ **Do not reconstruct the request path.** Every version of this sample that tried
457
+ to has failed **open**, and each variant was found only after the previous one
458
+ was fixed:
459
+
460
+ | # | Variant | Why it fails open |
461
+ |---|---|---|
462
+ | 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
463
+ | 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
464
+ | 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` → 404 but `GET /OwNeRs/angela` → 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
465
+ | 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
466
+ | 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
467
+
468
+ **The fix is not a sixth rule.** It is to stop parsing:
469
+
470
+ **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
471
+ dispatched the request. It carries no query string (variant 2), it is not
472
+ mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
473
+ prefix (variant 4 — there is nothing left to derive, so `/apiowners` is
474
+ unconstructible), and it is unaffected by an absolute-form target (variant 5).
475
+
476
+ | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
477
+ |---|---|---|---|---|
478
+ | `GET /owners` | `/` | `/owners` | `/owners` | `/` |
479
+ | `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
480
+ | `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
481
+ | `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
482
+ | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
483
+ | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
484
+
485
+ Two rules remain, and they are the whole list:
486
+
487
+ **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
488
+ registered mount — `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
489
+ case-insensitively, so a case-sensitive comparison here is stricter than the
490
+ router and can be walked past. Lower-case the **mount and path only**; record ids
491
+ are case-sensitive and must be compared at their real case.
492
+
493
+ **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
494
+ was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
495
+ empty string matches no collection, so `access()` fell through to the permission
496
+ array and granted full CRUD. An input you cannot identify must **deny**.
497
+
498
+ Use `request.path` — mount-relative and query-free — if you need to distinguish
499
+ sub-paths beneath the mount, as the `/archived` deny above does.
500
+
501
+ ### Known limitations
502
+
503
+ - **A function-style filter is not a guarantee that a hidden record cannot be
504
+ modified.** A write to a *different* collection can re-parent one and de-hide
505
+ it: `POST /owners` (or `PATCH /owners/{id}`) carrying
506
+ `relationships: { pets: { data: { id: 21 } } }` — or
507
+ `attributes: { pets: [21, 22] } `, which never enters the relationships loop at
508
+ all — re-parents animal 21 onto an owner the caller is permitted to write. The
509
+ animal's `owner` is the field the `/animals` predicate reads, so the record
510
+ stops being rejected: it becomes readable through `GET /animals/21` and
511
+ deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
512
+ one collection is writable and another is filtered on a field the first can
513
+ set. Blocking it requires checking animal 21 against the **animal** model's
514
+ predicate while servicing an **owners** route — cross-model access resolution,
515
+ which the current contract cannot express: `access()` never receives the model
516
+ structurally ([#202](https://github.com/abofs/stonyx-orm/issues/202)) and
517
+ `setup-rest-server.ts` discards the model→predicate map at boot
518
+ ([#196](https://github.com/abofs/stonyx-orm/issues/196)). Tracked as
519
+ [#207](https://github.com/abofs/stonyx-orm/issues/207), blocked on that chain
520
+ (#202 → #196 → #207). Until it lands, do not rely on a filter to keep a record
521
+ unmodifiable; keep the *writable* collections' predicates as tight as the
522
+ hidden ones.
523
+ - **Authorization by identifying the collection is a consumer-side
524
+ reconstruction of information the framework already holds.** `access()`
525
+ receives a transport artifact and is asked to work out which model, which
526
+ operation and which record the request addresses. The five variants above are
527
+ the five ways that has been observed to fail open so far. Tracked as
528
+ [#202](https://github.com/abofs/stonyx-orm/issues/202).
529
+ - **Related and included records are not filtered.** The predicate is evaluated
530
+ against the record the route is *addressed to*. `GET /animals/1/owner`,
531
+ `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
532
+ related record without resolving that model's own access class, so a filter on
533
+ `/owners` does not hide an owner reached through `/animals`. Tracked as
534
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
535
+ `include=`, related-resource routes and relationship-linkage routes.
536
+ - **A before-hook that returns a value short-circuits the request.** On write
537
+ operations addressed to a record the filter is consulted first, so a hook
538
+ cannot answer for a record the caller may not see. On reads it is not, so a
539
+ `beforeHook('get', ...)` read-through cache can answer past the filter.
540
+ - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
541
+ `create` there is no record to test until the handler has built one, so the
542
+ denial is not knowable in time. Every *after*-hook is gated, and every
543
+ before-hook on `update` and `delete` is gated; before-`create` is the one
544
+ exception. A before-`create` hook must not assume the create will succeed.
545
+ - **A caller can still learn that a collection *has* a per-record filter**, by
546
+ observing `403` rather than `409`/`200` for an id-bearing `POST`. That
547
+ discloses a configuration fact, not the existence of any record.
548
+ - **Enforcement is post-fetch.** The record is loaded and then tested, which
549
+ leaves a small timing difference between a hidden record and one that never
550
+ existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
551
+ - **A `relationships` key that is not a declared relationship is still applied
552
+ to the record.** The key comes verbatim from the request body and is checked
553
+ against nothing except `id`, which is stripped. On a `POST` that makes an
554
+ undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
555
+ `updateRecord`. `id` is stripped in both handlers because it defeats breaking
556
+ change 3 above; the general form is tracked as
557
+ [#204](https://github.com/abofs/stonyx-orm/issues/204).
558
+ - **A `POST` body `id` that the duplicate check cannot resolve can still
559
+ overwrite a different record on an unfiltered collection.** The lookup is
560
+ correct and deliberately does not coerce: `"9105h"` is rejected as a string
561
+ rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
562
+ themselves. The model's id transform then coerces anyway — a bare `parseInt`
563
+ with no such guard — so the create lands on `9105` (or on `NaN`) and
564
+ overwrites whatever is there. **This is not string-only**: any body id whose
565
+ transform output differs from its lookup key is the same defect. Filtered
566
+ collections are unaffected — breaking change 3 refuses any client-supplied id
567
+ — so this reaches consumers with **no** function-style filter. Tracked as
568
+ [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
569
+ [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
570
+ a create can land on an id nobody named.
571
+ - **`context.record` is `undefined` for an after-`create` hook when a string-id
572
+ model is given a numeric-looking id.** The post-create lookup uses the same id
573
+ coercion as every other surface, which resolves `'9107'` to the number `9107`,
574
+ while a model declaring `id = attr('string')` files the record under the string
575
+ key. The create itself succeeds and `context.response.data` is correct; only
576
+ the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
577
+ [#209](https://github.com/abofs/stonyx-orm/issues/209).
578
+ - **A denied `POST` rolls back only a record it *inserted*.** The rollback
579
+ requires the store to have grown, because removing by id alone is a write
580
+ primitive keyed by a caller-supplied value. When `assignRecordId` lands a
581
+ **server-assigned** id on an occupied slot
582
+ ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
583
+ last-*inserted* + 1, not max + 1, so a store whose insertion order is not
584
+ ascending collides), `createRecord` updates that record **in place**: the map
585
+ does not grow, the rollback correctly declines to remove a record this request
586
+ did not create, and the `403` leaves the caller's attributes on someone else's
587
+ record. Narrow — it needs a non-ascending insertion order — but it is the
588
+ reachability condition, so it is stated rather than implied.
589
+
590
+ ### Breaking changes
591
+
592
+ These land in the next published build. There is no changelog or release-notes channel yet
593
+ ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
594
+ they are recorded here.
595
+
596
+ 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
597
+ instead of `204`.** This affects **every** consumer issuing a DELETE against
598
+ any mounted collection, whether or not an access filter is configured, and
599
+ `models: '*'` mounts every model by default. It is not optional: if a denied
600
+ delete returned 404 while a missing one returned 204, the pair would be a
601
+ perfect existence oracle and the filter would be worthless.
602
+ 2. **After-hooks no longer fire for a request that failed** — denied, missing,
603
+ `400` or `409`. The gate is on the handler's status, not on the operation, so
604
+ it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
605
+ hook either. Previously `afterHook('delete', ...)` ran with a populated
606
+ `context.recordId` on a request that deleted nothing, so a consumer cascade
607
+ destroyed children behind a 404.
608
+ 3. **`POST` with a client-supplied `id` returns `403` when a function-style
609
+ `access` filter is in force**, whatever the payload and whether or not the id
610
+ exists, and *before* any store lookup — so neither the status nor the lookup
611
+ cost can depend on whether that id exists. Only affects function-style
612
+ `access` users. See [Filter functions](#filter-functions) for why, and let the
613
+ server assign the id instead. `409`-on-duplicate is unchanged for everyone
614
+ else.
615
+
616
+ "Whatever the payload" is a statement about the **`id` member of the resource
617
+ object**, and it holds only because that is the sole channel a caller id can
618
+ arrive on. It was not always: a caller id moved into
619
+ `relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
620
+ refusal and overwrote a hidden record in place. Both strips — `attributes.id`
621
+ and `relationships.id` — are part of this behaviour, not tidiness. A future
622
+ change that adds a third channel without stripping it re-opens the oracle;
623
+ see [#204](https://github.com/abofs/stonyx-orm/issues/204).
624
+ 4. **Function-style `access` is now enforced on all seven surfaces.** Records
625
+ previously reachable by id despite being filtered from the collection now
626
+ return 404. Only affects function-style `access` users, for whom the old
627
+ behaviour was the bypass.
628
+
629
+ "Seven surfaces" means the seven endpoints of **the filtered model**. A write
630
+ to another collection can still reach one of its records through a
631
+ relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
632
+ is **not** closed here.
633
+ 5. **A predicate that throws is treated as a denial** rather than propagating to
634
+ Express's default 500 handler. So is an `access()` that throws.
635
+ 6. **`access()` returning a bare string is one permission, not full access.**
636
+ `AccessMethod` declares `string` legal, and it previously fell through every
637
+ branch and granted all four operations — `return 'read'` allowed `DELETE`.
638
+ It is now equivalent to `['read']`. Any other unrecognised shape (an object,
639
+ a number) now returns `403` rather than granting full access.
640
+ 7. **`POST` with a client-supplied `id` normalises the body id before the
641
+ duplicate check**, so an id shape that previously *missed* the store's key
642
+ now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
643
+ where they answered `200`, and the `200` was not a success: the lookup missed,
644
+ the duplicate check was skipped, and `createRecord` overwrote the colliding
645
+ record in place. **This one reaches consumers with no filter at all** — the
646
+ population breaking changes 3 and 4 explicitly exempt. If you were relying on
647
+ a hex-shaped or whitespace-padded id creating a second record, it never did.
648
+
291
649
  ### Include Parameter (Sideloading Relationships)
292
650
 
293
651
  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.
@@ -561,7 +919,19 @@ afterHook('delete', 'animal', async (context) => {
561
919
  // Additional access control - halt with 403 if unauthorized
562
920
  beforeHook('delete', 'animal', (context) => {
563
921
  const user = context.state.currentUser;
564
- const animal = store.get('animal', context.params.id);
922
+
923
+ // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
924
+ // has already fetched the record (and already applied the access filter to
925
+ // it) before this hook runs, so re-fetching it here is a fourth id coercion
926
+ // that has to agree with three others.
927
+ //
928
+ // And it would not agree. `context.params.id` is the raw url segment, always
929
+ // a string, while the store keys numeric-id models by NUMBER — so
930
+ // `store.get('animal', '21')` misses the record held under `21`, and so does
931
+ // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
932
+ // record", which in an authorization hook fails whichever way your code
933
+ // happens to handle a null.
934
+ const animal = context.oldState;
565
935
 
566
936
  if (animal.owner !== user.id && !user.isAdmin) {
567
937
  return 403; // Forbidden
@@ -569,6 +939,10 @@ beforeHook('delete', 'animal', (context) => {
569
939
  });
570
940
  ```
571
941
 
942
+ > If you do need a lookup for some *other* model inside a hook, coerce the id
943
+ > yourself to the type that model's `id` attribute declares — the store is a
944
+ > `Map` and `'21'` and `21` are different keys.
945
+
572
946
  #### Auditing
573
947
 
574
948
  ```javascript
@@ -702,11 +1076,29 @@ beforeHook('create', 'post', (context) => {
702
1076
 
703
1077
  ### Hook Execution Order
704
1078
 
705
- 1. **Before hooks** fire first (sequentially, in registration order)
706
- 2. **Main operation** executes (if no before hook halted)
707
- 3. **After hooks** fire last (sequentially, in registration order)
708
-
709
- Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
1079
+ 1. **Authorization is evaluated first for `update` and `delete`.** A record the
1080
+ access filter rejects returns `404` **before any before-hook runs**, so a
1081
+ hook never sees a record or a `context.oldState` — that the caller is not
1082
+ allowed to read. `create` is the exception: there is no record to test until
1083
+ the handler has built one, so `beforeHook('create', ...)` **does** fire for a
1084
+ `POST` that goes on to answer `403`.
1085
+ 2. **Before hooks** fire next (sequentially, in registration order).
1086
+ 3. **Main operation** executes (if no before hook halted).
1087
+ 4. **After hooks** fire last (sequentially, in registration order) — **only if
1088
+ the request succeeded.**
1089
+
1090
+ Before hooks can halt the operation by returning a value, and that value becomes
1091
+ the response.
1092
+
1093
+ **After hooks do not run for a failed request.** Any status `>= 400` — denied,
1094
+ missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
1095
+ persistence and `onUpdate` autosave. This is a behaviour change; see
1096
+ [Breaking changes](#breaking-changes). It applies to the samples above: the
1097
+ `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
1098
+ that answered `404`, and the `afterHook('update', ...)` change-tracking hook
1099
+ writes none for a `PATCH` that answered `404` or `400`. If you need a record of
1100
+ refused requests, log them from a before-hook or from your own middleware —
1101
+ `after<operation>` fires only for an operation that actually happened.
710
1102
 
711
1103
  ### Best Practices
712
1104
 
@@ -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
+ }
package/dist/commands.js CHANGED
@@ -20,6 +20,11 @@ const commands = {
20
20
  description: 'Generate a MySQL migration from current model schemas',
21
21
  bootstrap: true,
22
22
  run: async (args) => {
23
+ const config = (await import('stonyx/config')).default;
24
+ if (config.orm.dynamodb) {
25
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
26
+ return;
27
+ }
23
28
  const description = args?.join(' ') || 'migration';
24
29
  const { generateMigration } = await import('./mysql/migration-generator.js');
25
30
  const result = await generateMigration(description);
@@ -31,12 +36,33 @@ const commands = {
31
36
  }
32
37
  }
33
38
  },
39
+ 'db:sync': {
40
+ description: 'Provision DynamoDB tables and GSIs from current model schemas',
41
+ bootstrap: true,
42
+ run: async () => {
43
+ const config = (await import('stonyx/config')).default;
44
+ if (!config.orm.dynamodb) {
45
+ console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
46
+ process.exit(1);
47
+ }
48
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
49
+ const db = new DynamoDBDB();
50
+ await db.init();
51
+ await db.startup();
52
+ await db.shutdown();
53
+ console.log('DynamoDB tables synced successfully.');
54
+ }
55
+ },
34
56
  'db:migrate': {
35
57
  description: 'Apply pending MySQL migrations',
36
58
  bootstrap: true,
37
59
  run: async () => {
38
60
  const config = (await import('stonyx/config')).default;
39
61
  const mysqlConfig = config.orm.mysql;
62
+ if (config.orm.dynamodb) {
63
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
64
+ return;
65
+ }
40
66
  if (!mysqlConfig) {
41
67
  console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
42
68
  process.exit(1);
@@ -75,6 +101,10 @@ const commands = {
75
101
  bootstrap: true,
76
102
  run: async () => {
77
103
  const config = (await import('stonyx/config')).default;
104
+ if (config.orm.dynamodb) {
105
+ console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
106
+ return;
107
+ }
78
108
  const mysqlConfig = config.orm.mysql;
79
109
  if (!mysqlConfig) {
80
110
  console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
@@ -113,6 +143,10 @@ const commands = {
113
143
  bootstrap: true,
114
144
  run: async () => {
115
145
  const config = (await import('stonyx/config')).default;
146
+ if (config.orm.dynamodb) {
147
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
148
+ return;
149
+ }
116
150
  const mysqlConfig = config.orm.mysql;
117
151
  if (!mysqlConfig) {
118
152
  console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');