@stonyx/orm 0.3.2-alpha.7 → 0.3.2-alpha.70

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 +870 -11
  2. package/config/environment.js +8 -0
  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/hooks.d.ts +15 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/main.d.ts +116 -0
  15. package/dist/main.js +129 -0
  16. package/dist/manage-record.js +268 -12
  17. package/dist/mysql/connection.d.ts +1 -0
  18. package/dist/mysql/mysql-db.d.ts +8 -0
  19. package/dist/mysql/mysql-db.js +44 -10
  20. package/dist/orm-request.d.ts +264 -3
  21. package/dist/orm-request.js +975 -49
  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 +8 -0
  25. package/dist/postgres/postgres-db.js +44 -10
  26. package/dist/record.js +7 -5
  27. package/dist/relationships.js +1 -1
  28. package/dist/serializer.js +38 -2
  29. package/dist/setup-rest-server.js +51 -5
  30. package/dist/standalone-db.js +17 -5
  31. package/dist/store.d.ts +13 -1
  32. package/dist/store.js +65 -6
  33. package/dist/types/orm-types.d.ts +207 -0
  34. package/dist/utils.d.ts +44 -0
  35. package/dist/utils.js +47 -0
  36. package/package.json +16 -7
  37. package/src/commands.ts +43 -0
  38. package/src/dynamodb/connection.ts +50 -0
  39. package/src/dynamodb/dynamodb-db.ts +811 -0
  40. package/src/dynamodb/operation-builder.ts +202 -0
  41. package/src/dynamodb/type-map.ts +54 -0
  42. package/src/hooks.ts +15 -1
  43. package/src/index.ts +1 -0
  44. package/src/main.ts +133 -0
  45. package/src/manage-record.ts +294 -18
  46. package/src/mysql/connection.ts +1 -0
  47. package/src/mysql/mysql-db.ts +44 -12
  48. package/src/orm-request.ts +992 -52
  49. package/src/postgres/connection.ts +10 -6
  50. package/src/postgres/postgres-db.ts +44 -12
  51. package/src/record.ts +8 -5
  52. package/src/relationships.ts +1 -1
  53. package/src/serializer.ts +39 -2
  54. package/src/setup-rest-server.ts +59 -6
  55. package/src/standalone-db.ts +17 -6
  56. package/src/store.ts +68 -6
  57. package/src/types/orm-types.ts +214 -0
  58. package/src/types/stonyx-rest-server.d.ts +14 -1
  59. package/src/types/stonyx.d.ts +7 -1
  60. package/src/utils.ts +50 -0
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,800 @@ 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. That section is now a
319
+ > record of what not to do, not a matching recipe: the sample below reads `model`
320
+ > from [the access context](#the-access-context-second-argument) and never looks
321
+ > at the mount at all, so variants 1, 2, 4 and 5 are **unconstructible** against
322
+ > it rather than merely handled. **Variant 3 survives.** It is the general shape
323
+ > "a hand-written matcher normalises differently from the router", and the
324
+ > migrated sample still runs one string comparison — the `/archived` sub-path
325
+ > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
+ > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
+ >
328
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
329
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
330
+ > than rewritten.** Both claims above are now false: `#228` is **closed**, and the
331
+ > one string comparison variant 3 lived in is gone — the sample below compares the
332
+ > **decoded `recordId`** the access context supplies, so there is no comparison
333
+ > left to step around. The paragraph about `request.path` further down is
334
+ > superseded the same way. Nothing here is deleted because the same "variant 3
335
+ > survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
336
+ > the test fixture) and retiring one of four leaves the shipped copies
337
+ > contradicting each other; retiring all four **with the measurement that retires
338
+ > them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
339
+ > this blockquote and the reference section below.
340
+ >
341
+ > That is still a stopgap. **The real fix is
342
+ > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
343
+ > receive the model, the operation and the record, so there is nothing to
344
+ > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
345
+ > you can: the **function** shape is the one that requires any matching at all.
346
+ >
347
+ > The one read of argument **one** that survives is `request.path`, for the
348
+ > `/archived` sub-path deny — and it has to. The context names which model and
349
+ > which verb, not which route, so that deny **cannot be expressed from the
350
+ > context alone** and a context-only rewrite would silently turn it into an
351
+ > allow.
352
+ >
353
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
354
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
355
+ > above. No read of argument **one** survives in the sample below: the context
356
+ > carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
357
+ > expressible from the context alone. It still must not be dropped — expressible is
358
+ > not optional. Retirement of this wording:
359
+ > [#238](https://github.com/abofs/stonyx-orm/issues/238).
360
+ >
361
+ > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
362
+ > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
363
+ > README and that source header are the two copies a consumer sees.
364
+ >
365
+ > This sample is the same code as the shipped test fixture, and a test asserts
366
+ > the two `access()` bodies are identical line for line. For four rounds they
367
+ > were two independently written copies — and the fifth fail-open variant was
368
+ > found in the one nothing was mutating.
279
369
 
280
370
  ```js
281
371
  export default class GlobalAccess {
282
372
  models = ['owner', 'animal'];
283
373
 
284
- access(request) {
285
- if (request.url.endsWith('/owner/angela')) return false;
374
+ access(request, { model, operation, recordId }) {
375
+ // `model` is the model this route was mounted for. It is assigned once, at
376
+ // mount time, and no request can influence it — not a mount prefix, not a
377
+ // query string, not a case-varied path, not an absolute-form request
378
+ // target. `recordId` is the record this route was ADDRESSED TO, decoded by
379
+ // the router and coerced to the key the store lookup uses. Nothing below
380
+ // parses anything, and since abofs/stonyx-orm#236 nothing below reads
381
+ // argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
382
+ // the sub-path STRING COMPARISON that variant 3 lived in is gone too,
383
+ // replaced by a comparison against the decoded id. Retiring the "variant 3
384
+ // survives" wording at the four sites that still carry it — with the
385
+ // measurement that retires it, rather than by deletion — is
386
+ // abofs/stonyx-orm#238.
387
+ //
388
+ // `operation` is destructured to name the whole contract at the point of
389
+ // use. This sample's rules are per-model and per-record rather than
390
+ // per-verb, so it does not branch on it; the permission array at the bottom
391
+ // is where the verb is answered.
392
+
393
+ // FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
394
+ // that resolved this predicate without supplying the context, and a request
395
+ // this function cannot identify DENIES rather than falling through to the
396
+ // CRUD grant at the bottom. An unidentifiable input must never be the
397
+ // permissive path.
398
+ if (typeof model !== 'string' || model === '') return false;
399
+
400
+ if (model === 'owner') {
401
+ // FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
402
+ // SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
403
+ // collection route, which is addressed to no record — so `undefined`
404
+ // means the context did not come from `auth()`: it was hand-assembled by
405
+ // a caller resolving this predicate through the documented
406
+ // `Orm.instance.getAccess()` path. Letting that through would fall
407
+ // straight to the per-record filter below, which is a DENY becoming an
408
+ // ALLOW. This is the same rule the old guard on `request.path` enforced,
409
+ // moved to the argument this predicate now actually reads.
410
+ if (recordId === undefined) return false;
411
+
412
+ // THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
413
+ // `request.path.toLowerCase()` compared against `'/archived'`, and that
414
+ // was wrong in both directions at once.
415
+ //
416
+ // TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
417
+ // the router DECODES `:id`, so `GET /owners/%61rchived` reached the
418
+ // comparison as `/%61rchived`, walked past the deny and was dispatched as
419
+ // the record `archived` — 200 with the record in full, and DELETE
420
+ // answered 204 with the record DESTROYED, unauthenticated. 255
421
+ // non-canonical spellings of that 8-character id decode to the same key,
422
+ // so no deny-list of spellings was ever going to close it.
423
+ //
424
+ // TOO STRICT: a record id is a VALUE, not a literal route segment, and
425
+ // express's `case sensitive routing` governs literal segments only. With
426
+ // a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
427
+ // `GET /owners/ARCHIVED` — the wrong record — while still admitting
428
+ // `GET /owners/%41RCHIVED`, the same record encoded.
429
+ //
430
+ // SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
431
+ // which is what a route parameter means: `/owners/%2561rchived` is the
432
+ // legitimate id `%61rchived`, and decoding until stable would deny it. Do
433
+ // not case-fold it. Do not rebuild it from `request.path` — decoding the
434
+ // whole path decodes THEN splits while the router splits THEN decodes,
435
+ // which over-denies the distinct record at `/owners/archived%2fx`.
436
+ //
437
+ // THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
438
+ // what `recordId` bought — and it still must not be dropped. Deleting it
439
+ // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
+ if (recordId === 'archived') return false;
441
+
442
+ // Returning a function plugs it in as a per-record filter, and it is
443
+ // enforced on every surface addressed to one of these records:
444
+ // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
445
+ // A rejected record is 404 on record routes — the same status as a record
446
+ // that does not exist — so the filter is not an existence oracle.
447
+ return record => record.id !== 'angela' && record.id !== 'restricted';
448
+ }
449
+
450
+ // `record.owner` resolves to an OrmRecord, not to the owner's id string —
451
+ // comparing it directly against a string is the bug that made this predicate
452
+ // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
453
+ // shape as well as the resolved one would absorb a resolution regression
454
+ // silently, which is exactly what blinded this fixture before.
455
+ if (model === 'animal') return record => record.owner?.id !== 'restricted';
456
+
457
+ // Allows full access to all calls that don't match any of the above conditions
286
458
  return ['read', 'create', 'update', 'delete'];
287
459
  }
288
460
  }
289
461
  ```
290
462
 
463
+
464
+ ### The access context (second argument)
465
+
466
+ `access()` is called with **two** arguments:
467
+
468
+ ```js
469
+ access(request, { model, operation })
470
+ ```
471
+
472
+ The second is the **access context** — the structural facts about the request,
473
+ which the framework already holds at authorization time. Read these instead of
474
+ parsing anything.
475
+
476
+ | Key | Value |
477
+ |---|---|
478
+ | `model` | The model this route was mounted for, as a **model name**: kebab-case, exactly as declared under `config.orm.paths.model` and keyed in the store — `'owner'`, `'animal'`, `'phone-number'`. **Not** the pluralized, dasherized, mount-prefixed *route* name. |
479
+ | `operation` | One of **`'read'`, `'create'`, `'update'`, `'delete'`** — and no second vocabulary *on this path*. Never an HTTP method name like `'GET'`, and **not** the hook vocabulary either (see [below](#operation-is-not-the-hook-operation)). `undefined` when the dispatched method has no entry in the framework's method map. |
480
+
481
+ So a predicate can be written without reference to any URL:
482
+
483
+ ```js
484
+ export default class OwnerAccess {
485
+ models = ['owner'];
486
+
487
+ access(request, { model, operation }) {
488
+ if (model === 'owner' && operation === 'read') {
489
+ return record => record.id !== 'angela';
490
+ }
491
+
492
+ return ['read'];
493
+ }
494
+ }
495
+ ```
496
+
497
+ There is no string to parse, no variant to miss, and no way to fail open through
498
+ a URL shape nobody anticipated. `model` is fixed at mount time and no request
499
+ can influence it — not a mount prefix, not a query string, not a case-varied
500
+ path, not an absolute-form request target.
501
+
502
+ #### What the context does not tell you: which surface
503
+
504
+ It names **which model and which verb**, not **which route**. Measured over the
505
+ live router, six surfaces produce one identical context:
506
+
507
+ ```
508
+ GET /owners { model: 'owner', operation: 'read' }
509
+ GET /owners/gina { model: 'owner', operation: 'read' }
510
+ GET /owners/gina/pets { model: 'owner', operation: 'read' }
511
+ GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
512
+ GET /owners/archived { model: 'owner', operation: 'read' }
513
+ GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
514
+ ```
515
+
516
+ So a rule that depends on the **sub-path** still needs `request.path` —
517
+ mount-relative and query-free, and the one read of argument one that
518
+ [Identifying the collection](#identifying-the-collection) sanctions. The sample
519
+ access class shipped with this repo has such a rule: its `/archived` deny
520
+ **cannot be expressed from the context alone**, and a predicate migrated to
521
+ context-only would silently drop it — a deny becoming an allow.
522
+
523
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
+ `recordId` — the record this route was addressed to, already decoded — so the
526
+ `/archived` deny **is** expressible from the context alone, and the shipped sample
527
+ no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
+ `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
+ and this paragraph — is corrected by
530
+ [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
+ what it currently says is an instruction, and the instruction is wrong.
532
+
533
+ Note also that the related-resource and `?include=` surfaces serve *another
534
+ model's* records under `model: 'owner'`, and the context gives a predicate no
535
+ signal that it is authorizing a related-resource route. That is
536
+ [#196](https://github.com/abofs/stonyx-orm/issues/196).
537
+
538
+ #### `operation` is not the hook `operation`
539
+
540
+ This module exposes a **second** `operation` vocabulary, on an identically-named
541
+ key of an identically-shaped context object:
542
+ [hook contexts](#hook-context-object) carry `list` / `get` / `create` /
543
+ `update` / `delete`. The access vocabulary collapses `list` and `get` into
544
+ `'read'`, so for one `GET /animals/1` a hook sees `'get'` while `access()` sees
545
+ `'read'` — and a predicate cannot distinguish a collection read from a
546
+ record read.
547
+
548
+ "No second vocabulary" above is a statement about the **access path**, where
549
+ both the context and the permission array come from one method map. It is not a
550
+ statement about the module. Writing `operation === 'get'` in a predicate never
551
+ matches, and a predicate that stops matching falls through to the permission
552
+ array — so the misreading is fail-open shaped. In TypeScript the exported
553
+ `AccessOperation` union makes it a compile error.
554
+
555
+ The four `operation` values are the same four strings the permission-array
556
+ return shape is written in (`['read', 'create', 'update', 'delete']`), because
557
+ both come from one method map inside the framework. The two forms cannot
558
+ disagree about the same request.
559
+
560
+ **`operation` is `undefined`, never defaulted, for an unmapped method.** Express
561
+ delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
562
+ not defaulted to `'read'`: a fabricated operation would turn an unclassified
563
+ request into an authorized one. Treat `undefined` as *not classified* and deny.
564
+
565
+ **The second argument is additive.** JavaScript ignores extra arguments, so an
566
+ existing `access(request)` predicate keeps working exactly as it did. Nothing
567
+ needs to be migrated to keep running — but note that argument **one** is still
568
+ the raw request, so the warning in
569
+ [Identifying the collection](#identifying-the-collection) still applies to any
570
+ predicate that reads it.
571
+
572
+ #### `record` is not in the context
573
+
574
+ Deliberately, and it is not an oversight. `auth()` runs after route matching but
575
+ **before any handler executes**, so nothing has been fetched yet. Supplying a
576
+ record would force a pre-fetch on every request — a second store hit, a new
577
+ failure mode, and an ordering change in the middle of an authorization path.
578
+
579
+ It is also unnecessary: the **function** return shape already *is* the
580
+ per-record hook. Return `(record) => boolean` and the handlers apply it to every
581
+ record the request touches. Auth-time and record-time are separate decision
582
+ points, and the contract keeps them separate.
583
+
584
+ #### Reaching another model's predicate
585
+
586
+ The model → predicate map is published on the ORM instance at boot, before any
587
+ route is mounted, so a predicate can be resolved by model name and asked about a
588
+ request routed to a *different* model:
589
+
590
+ ```js
591
+ import Orm from '@stonyx/orm';
592
+
593
+ const predicate = Orm.instance.getAccess('animal');
594
+ if (!predicate) return deny;
595
+
596
+ const verdict = predicate(request, { model: 'animal', operation: 'read' });
597
+ ```
598
+
599
+ **`undefined` means no predicate could be resolved — not that the model is
600
+ unrestricted. Treat it as deny.** It covers a model with no access class *and* a
601
+ model whose access class failed to **load**: a load failure is caught and warned
602
+ about, and the partial map is published anyway, so a missing key is not evidence
603
+ of an unrestricted model. This is the same rule as `operation === undefined`
604
+ above, and for the same reason.
605
+
606
+ The raw map is `Orm.instance.accessFunctions`, keyed by model name; prefer
607
+ `getAccess()` — it is guarded against inherited `Object.prototype` members and a
608
+ direct index is not. Note that it maps a model name to the predicate of the
609
+ access *class* that claims it, which may claim many models: against this repo's
610
+ sample, `getAccess('owner') === getAccess('animal')`.
611
+
612
+ #### Passing the context makes a model-correct answer *possible*
613
+
614
+ It does not make the answer model-correct on its own. **The resolved predicate
615
+ has to read the context.** Against a predicate that ignores it the failure is
616
+ measurable. On a request Express dispatched to `GET /owners/angela`, asked about
617
+ **animals**, the sample as it shipped before
618
+ [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
619
+
620
+ ```
621
+ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
622
+ -> record => record.id !== 'angela' && record.id !== 'restricted'
623
+ ```
624
+
625
+ That is the **owners** filter, and it returns `true` for animal 21 — the record
626
+ hidden on every animal surface. Under a mount such a predicate recognizes
627
+ neither way it is worse still: it falls through to
628
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
629
+ context was supplied and the answer is not the animal answer, and it is wrong in
630
+ the direction that **grants** — because that predicate was single-argument and
631
+ identified its collection from the request, so it answered about the collection
632
+ the request was *addressed to* while being asked about another one.
633
+
634
+ The sample shipped with this repo has since been migrated to read the context,
635
+ and the same call now answers with the **animal** filter:
636
+
637
+ ```
638
+ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
639
+ -> record => record.owner?.id !== 'restricted'
640
+ ```
641
+
642
+ A single-argument predicate remains the default in every consumer tree, and a
643
+ caller has no supported way to tell which kind it resolved. The boot-time arity
644
+ warning that surfaces one is
645
+ [#221](https://github.com/abofs/stonyx-orm/issues/221).
646
+
647
+ So: pass the context, and do not treat a resolved predicate's answer as
648
+ model-specific until that predicate has been migrated to read it.
649
+
650
+ ### Return values
651
+
652
+ | `access()` returns | Effect |
653
+ |---|---|
654
+ | `false` (or any falsy value) | `403` for the whole request |
655
+ | `true` | full access, no filter |
656
+ | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
657
+ | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
658
+ | a function | a per-record filter — see below |
659
+ | anything else | `403` — unknown shapes fail **closed** |
660
+
661
+ A `throw` inside `access()` is a **denial**, not a 500.
662
+
663
+ ### Filter functions
664
+
665
+ A function return value is a **per-record predicate**, and it is enforced on
666
+ every endpoint that is addressed to a record — not only on the collection.
667
+
668
+ It is evaluated against the record the route is *addressed to*, **on that model
669
+ only**. It is not a guarantee that a hidden record cannot be reached or modified:
670
+ a write to a *different* collection can still re-parent one. See
671
+ [Known limitations](#known-limitations) and
672
+ [#207](https://github.com/abofs/stonyx-orm/issues/207).
673
+
674
+ | Endpoint | A record the predicate rejects |
675
+ |---|---|
676
+ | `GET /:models` | omitted from the collection |
677
+ | `GET /:models/:id` | `404` |
678
+ | `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
679
+ | `GET /:models/:id/relationships/{relationship}` | `404` — same |
680
+ | `PATCH /:models/:id` | `404`, no attribute is applied |
681
+ | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
682
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
683
+
684
+ **Denied record-level requests return 404, not 403.** This is deliberate and it
685
+ is the property most easily "improved" away. 403 would confirm that the record
686
+ exists to a caller who is not allowed to know that, which turns the filter into
687
+ an existence oracle: `404` means "no such record", `403` means "there is one and
688
+ it is not yours". Every status on a record route must therefore be identical for
689
+ "filtered out" and "does not exist" — including `DELETE`, which is why deleting
690
+ a record that never existed also returns 404 rather than 204.
691
+
692
+ `POST` is the one exception and returns **403**, because 404 on a mounted
693
+ collection route is indistinguishable from "model not mounted" — a genuinely
694
+ different failure a developer needs to diagnose.
695
+
696
+ **A client-supplied `id` on `POST` is refused with `403` whenever a function
697
+ filter is in force.** This is the part that keeps `POST` from being an
698
+ enumeration oracle, and it is worth understanding rather than working around.
699
+ The duplicate-id check has to run before the filter, and it sees records the
700
+ filter hides, so the *status* of a `POST` otherwise leaks whether an id is
701
+ taken:
702
+
703
+ | `POST /animals` with a payload the caller may create | before | now |
704
+ |---|---|---|
705
+ | an id held by a record the filter **hides** | `403` | `403` |
706
+ | an id that is **free** | `200` | `403` |
707
+ | an id held by a record the caller **can see** | `409` | `403` |
708
+
709
+ Three outcomes, one request per id, the whole id space. Filtering only the
710
+ *collision* status narrows that to callers who cannot create a record they are
711
+ allowed to see; it does not close it. It cannot be closed while a caller both
712
+ chooses the id and learns whether the create succeeded — so under a filter the
713
+ caller does not choose the id. The refusal happens before any store lookup, so
714
+ neither the status nor the response time depends on whether the id exists.
715
+
716
+ Let the server assign the id and read it back from the response — and read it
717
+ back rather than predicting it, because the value it returns is documented but
718
+ not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
719
+ numeric ceiling, the lowest free integer), and a string-id model gets
720
+ `<model>-<n>`. See breaking change 8.
721
+
722
+ **What a server-assigned id is not.** It is not a secret. On a string-id
723
+ collection it is dense and enumerable from `1`, where previously it inherited
724
+ whatever entropy the last-inserted id happened to carry — a UUID-seeded store
725
+ answered a UUID-derived key. If a collection has **no** `access` config its
726
+ record-level routes are ungated, so the id was the only thing standing between
727
+ an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
728
+ a control and must not become one; configure `access`.
729
+
730
+ **And the id itself is an occupancy signal — on both model kinds.**
731
+ `assignRecordId` reads the whole store, not the caller's filtered view — it
732
+ never sees `state.filter` — so the id it returns is a function of records the
733
+ caller may not be permitted to read. **This applies to numeric-id collections
734
+ as well as string-id ones**, and the conditions differ, so read both:
735
+
736
+ - **String-id collections, always.** The assigned `n` is the smallest positive
737
+ integer whose landing key is free, which tells the caller that every key
738
+ below it is taken, hidden or not.
739
+ - **Numeric-id collections, once one record sits at the numeric ceiling.** The
740
+ normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
741
+ is not representable at or above 2^53, so the walk restarts from `1` (see
742
+ breaking change 8) and the assigned id becomes the smallest free integer —
743
+ the same occupancy predicate, now over arbitrary low keys. Each subsequent
744
+ no-id `POST` names the next free one, so a caller can enumerate the holes in
745
+ a range it cannot read.
746
+
747
+ **A ceiling record reaches a filter-protected collection even though `POST`
748
+ refuses caller ids on one.** Breaking change 3 makes
749
+ `POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
750
+ through a *relationship write on another collection* —
751
+ `POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
752
+ creates the animal under that key
753
+ ([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
754
+ **Known limitations** re-parenting note describes). So the precondition is
755
+ reachable by an unauthenticated caller on exactly the collections `access`
756
+ exists to protect. Measured on the sample fixture, with every animal hidden by
757
+ the `/animals` predicate and keys 4 and 7 deleted:
758
+
759
+ ```
760
+ GET /animals -> 200 [] (nothing visible)
761
+ GET /animals/4 -> 404 (free — indistinguishable from hidden)
762
+ POST /animals {"id":4} -> 403 (breaking change 3)
763
+ POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
764
+ POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
765
+ POST /animals (no id) -> 200 id=7 <- and the other one
766
+ POST /animals (no id) -> 200 id=13
767
+ POST /animals (no id) -> 200 id=14
768
+ ```
769
+
770
+ Closing this requires the assignment to be filter-aware, which is a change to
771
+ the `access` contract rather than a fix; it is stated here rather than left to
772
+ be discovered. Callers with no function-style filter are unaffected — there are
773
+ no hidden records to disclose.
774
+
775
+ ### Identifying the collection
776
+
777
+ **Do not reconstruct the request path — and since
778
+ [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
779
+ identify the collection at all.** Read `model` from
780
+ [the access context](#the-access-context-second-argument): it is fixed at mount
781
+ time, no request can influence it, and there is nothing left to parse.
782
+
783
+ **Everything below is the record of what happened when this sample did parse
784
+ it.** It is kept as history, not as a recipe — none of these matching strategies
785
+ should be written into a new predicate. Every version of this sample that tried
786
+ to identify the collection from the request target failed **open**, and each
787
+ variant was found only after the previous one was fixed:
788
+
789
+ | # | Variant | Why it fails open |
790
+ |---|---|---|
791
+ | 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. |
792
+ | 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. |
793
+ | 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). |
794
+ | 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. |
795
+ | 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. |
796
+
797
+ **The fix is not a sixth rule, and it is not a better string to match.** It is
798
+ to stop identifying the collection at all. That is a statement about
799
+ **identifying the collection**, and it is not a statement about the sample as a
800
+ whole: the `/archived` sub-path rule *is* still a string match, and
801
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
802
+ gets past it. Sub-path rules are the residue this fix does not cover, which is
803
+ why they must normalise the way the router does.
804
+
805
+ An intermediate revision read **`request.baseUrl`** — the mount Express
806
+ *actually matched*. That closed all five variants: it carries no query string
807
+ (variant 2), it is not mount-relative (variant 1), it already contains the
808
+ configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
809
+ so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
810
+ target (variant 5). It was still a transport artifact standing in for a
811
+ structural fact, and it is **no longer what the sample does**: the sample reads
812
+ `model`, so variants 1, 2, 4 and 5 are unconstructible against it rather than
813
+ handled. **Variant 3 survives**, in the one string comparison the migration
814
+ leaves behind: the `/archived` sub-path deny folds case but does not decode
815
+ ([#228](https://github.com/abofs/stonyx-orm/issues/228)). The table below is
816
+ retained as the measured evidence behind the five variants, not because any of
817
+ these values should be matched on:
818
+
819
+ | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
820
+ |---|---|---|---|---|
821
+ | `GET /owners` | `/` | `/owners` | `/owners` | `/` |
822
+ | `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
823
+ | `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
824
+ | `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
825
+ | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
826
+ | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
827
+
828
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
+ [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
+ and the two paragraphs below, are no longer true.** The access context now carries
831
+ `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
+ the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
+ read of argument one survives in the sample. The wording is left standing rather
834
+ than deleted because it appears at four sites (this file twice,
835
+ `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
+ shipped copies contradicting each other; retiring all four **with the measurement
837
+ that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
+
839
+ **One read of argument one survives, and it must: `request.path`.** It is
840
+ mount-relative and query-free, and it is for rules that distinguish **sub-paths**
841
+ beneath the mount — as the `/archived` deny in the sample above does. The context
842
+ names which model and which verb, **not which route**, so that deny *cannot be
843
+ expressed from the context alone*, and a context-only rewrite would silently turn
844
+ it into an allow.
845
+
846
+ **Normalise the way the router that dispatched the request does — and
847
+ case-folding alone does not.** A matcher stricter than the router can be stepped
848
+ around, so the sample lower-cases before comparing (the router matched
849
+ case-insensitively). That closes the case gap and **it is not the whole rule**:
850
+ Express sets `request.path` from the **raw, undecoded** pathname while the router
851
+ **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
852
+ comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
853
+ `archived`. That gap is live in the sample above and is tracked as
854
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
855
+ `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
856
+ case-sensitive and must be compared at their real case.
857
+
858
+ **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
+ [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
+ not merely stale observations, which is why this note is louder than a date. The
862
+ sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
+ anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
+ once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
+ false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
+ on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
+ not decode it, do not derive it from `request.path`. The contract is
868
+ `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
+ case-fold it". Retirement of this wording, with its measurement:
870
+ [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
+
872
+ **Fail closed on anything you cannot identify — on *either* argument.**
873
+ `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
874
+ and it traded fail-closed for fail-**open**: an empty string matched no
875
+ collection, so `access()` fell through to the permission array and granted full
876
+ CRUD. The same rule applies to the context — the sample returns `false` for an
877
+ absent `model` rather than falling through. Since #202 the guard and the read can
878
+ sit on **different objects**, and a guard on argument two does not protect a read
879
+ of argument one: the sample therefore also returns `false` when `request.path` is
880
+ absent or is not a string, rather than letting `?? ''` fall through to the
881
+ per-record filter. An input you cannot identify must **deny**.
882
+
883
+ ### Known limitations
884
+
885
+ - **A function-style filter is not a guarantee that a hidden record cannot be
886
+ modified.** A write to a *different* collection can re-parent one and de-hide
887
+ it: `POST /owners` (or `PATCH /owners/{id}`) carrying
888
+ `relationships: { pets: { data: { id: 21 } } }` — or
889
+ `attributes: { pets: [21, 22] } `, which never enters the relationships loop at
890
+ all — re-parents animal 21 onto an owner the caller is permitted to write. The
891
+ animal's `owner` is the field the `/animals` predicate reads, so the record
892
+ stops being rejected: it becomes readable through `GET /animals/21` and
893
+ deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
894
+ one collection is writable and another is filtered on a field the first can
895
+ set. Blocking it requires checking animal 21 against the **animal** model's
896
+ predicate while servicing an **owners** route — cross-model access resolution,
897
+ which the contract could not express before
898
+ [#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
899
+ received the model structurally and `setup-rest-server.ts` discarded the
900
+ model→predicate map at boot. **#202 has landed and both halves now exist** —
901
+ see [The access context](#the-access-context-second-argument):
902
+ `Orm.instance.getAccess(modelName)` makes another model's predicate
903
+ **reachable**, and `context.model` makes a **model-correct answer possible** —
904
+ possible, not guaranteed: the resolved predicate has to read the context. The
905
+ sample shipped with this repo now does
906
+ ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
907
+ `getAccess('animal')` answers with the animal filter; a predicate that ignores
908
+ the second argument still answers about the collection the request is
909
+ addressed to, and the boot-time warning that surfaces one is
910
+ [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
911
+ exists; the ORM does not yet use it on this path.** The re-parenting write above is still
912
+ **not refused** — that enforcement is
913
+ [#196](https://github.com/abofs/stonyx-orm/issues/196) and
914
+ [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
915
+ #202 and are now free to proceed. Until they land, do not rely on a filter to
916
+ keep a record unmodifiable; keep the *writable* collections' predicates as tight as
917
+ the hidden ones.
918
+ - **Authorization by identifying the collection is a consumer-side
919
+ reconstruction of information the framework already holds.** `access()`
920
+ receives a transport artifact and is asked to work out which model, which
921
+ operation and which record the request addresses. The five variants above are
922
+ the five ways that has been observed to fail open so far. Tracked as
923
+ [#202](https://github.com/abofs/stonyx-orm/issues/202).
924
+ - **Related and included records are not filtered.** The predicate is evaluated
925
+ against the record the route is *addressed to*. `GET /animals/1/owner`,
926
+ `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
927
+ related record without resolving that model's own access class, so a filter on
928
+ `/owners` does not hide an owner reached through `/animals`. Tracked as
929
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
930
+ `include=`, related-resource routes and relationship-linkage routes.
931
+ - **A before-hook that returns a value short-circuits the request.** On write
932
+ operations addressed to a record the filter is consulted first, so a hook
933
+ cannot answer for a record the caller may not see. On reads it is not, so a
934
+ `beforeHook('get', ...)` read-through cache can answer past the filter.
935
+ - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
936
+ `create` there is no record to test until the handler has built one, so the
937
+ denial is not knowable in time. Every *after*-hook is gated, and every
938
+ before-hook on `update` and `delete` is gated; before-`create` is the one
939
+ exception. A before-`create` hook must not assume the create will succeed.
940
+ - **A caller can still learn that a collection *has* a per-record filter**, by
941
+ observing `403` rather than `409`/`200` for an id-bearing `POST`. That
942
+ discloses a configuration fact, not the existence of any record.
943
+ - **Enforcement is post-fetch.** The record is loaded and then tested, which
944
+ leaves a small timing difference between a hidden record and one that never
945
+ existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
946
+ - **A `relationships` key that is not a declared relationship is still applied
947
+ to the record.** The key comes verbatim from the request body and is checked
948
+ against nothing except `id`, which is stripped. On a `POST` that makes an
949
+ undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
950
+ `updateRecord`. `id` is stripped in both handlers because it defeats breaking
951
+ change 3 above; the general form is tracked as
952
+ [#204](https://github.com/abofs/stonyx-orm/issues/204).
953
+ - **A `POST` body `id` that the duplicate check cannot resolve can still
954
+ overwrite a different record on an unfiltered collection.** The lookup is
955
+ correct and deliberately does not coerce: `"9105h"` is rejected as a string
956
+ rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
957
+ themselves. The model's id transform then coerces anyway — a bare `parseInt`
958
+ with no such guard — so the create lands on `9105` (or on `NaN`) and
959
+ overwrites whatever is there. **This is not string-only**: any body id whose
960
+ transform output differs from its lookup key is the same defect. Filtered
961
+ collections are unaffected — breaking change 3 refuses any client-supplied id
962
+ — so this reaches consumers with **no** function-style filter. Tracked as
963
+ [#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
964
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) — a **server-assigned**
965
+ id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
966
+ the client-supplied half and is still open.
967
+ - **`context.record` is `undefined` for an after-`create` hook when a string-id
968
+ model is given a numeric-looking id.** The post-create lookup uses the same id
969
+ coercion as every other surface, which resolves `'9107'` to the number `9107`,
970
+ while a model declaring `id = attr('string')` files the record under the string
971
+ key. The create itself succeeds and `context.response.data` is correct; only
972
+ the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
973
+ [#209](https://github.com/abofs/stonyx-orm/issues/209).
974
+ - **A denied `POST` rolls back only a record it *inserted*.** The rollback
975
+ requires the store to have grown, because removing by id alone is a write
976
+ primitive keyed by a caller-supplied value. **The reachability condition this
977
+ bullet used to state is gone**: it was
978
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
979
+ returned last-*inserted* + 1, so a server-assigned id could land on an
980
+ occupied slot and `createRecord` would update it in place — and #203 is fixed
981
+ (breaking change 8). A server-assigned create can no longer overwrite, so on a
982
+ collection whose only id channel is `createHandler` this guard has no
983
+ observable effect today. It is kept because a caller-supplied id reaching
984
+ `createRecord` from another route — a relationship write,
985
+ [#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
986
+ back, and without the guard a denied `403` would delete a record the request
987
+ did not create.
988
+
989
+ ### Breaking changes
990
+
991
+ These land in the next published build. There is no changelog or release-notes channel yet
992
+ ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
993
+ they are recorded here.
994
+
995
+ 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
996
+ instead of `204`.** This affects **every** consumer issuing a DELETE against
997
+ any mounted collection, whether or not an access filter is configured, and
998
+ `models: '*'` mounts every model by default. It is not optional: if a denied
999
+ delete returned 404 while a missing one returned 204, the pair would be a
1000
+ perfect existence oracle and the filter would be worthless.
1001
+ 2. **After-hooks no longer fire for a request that failed** — denied, missing,
1002
+ `400` or `409`. The gate is on the handler's status, not on the operation, so
1003
+ it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
1004
+ hook either. Previously `afterHook('delete', ...)` ran with a populated
1005
+ `context.recordId` on a request that deleted nothing, so a consumer cascade
1006
+ destroyed children behind a 404.
1007
+ 3. **`POST` with a client-supplied `id` returns `403` when a function-style
1008
+ `access` filter is in force**, whatever the payload and whether or not the id
1009
+ exists, and *before* any store lookup — so neither the status nor the lookup
1010
+ cost can depend on whether that id exists. Only affects function-style
1011
+ `access` users. See [Filter functions](#filter-functions) for why, and let the
1012
+ server assign the id instead. `409`-on-duplicate is unchanged for everyone
1013
+ else.
1014
+
1015
+ "Whatever the payload" is a statement about the **`id` member of the resource
1016
+ object**, and it holds only because that is the sole channel a caller id can
1017
+ arrive on. It was not always: a caller id moved into
1018
+ `relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
1019
+ refusal and overwrote a hidden record in place. Both strips — `attributes.id`
1020
+ and `relationships.id` — are part of this behaviour, not tidiness. A future
1021
+ change that adds a third channel without stripping it re-opens the oracle;
1022
+ see [#204](https://github.com/abofs/stonyx-orm/issues/204).
1023
+ 4. **Function-style `access` is now enforced on all seven surfaces.** Records
1024
+ previously reachable by id despite being filtered from the collection now
1025
+ return 404. Only affects function-style `access` users, for whom the old
1026
+ behaviour was the bypass.
1027
+
1028
+ "Seven surfaces" means the seven endpoints of **the filtered model**. A write
1029
+ to another collection can still reach one of its records through a
1030
+ relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
1031
+ is **not** closed here.
1032
+ 5. **A predicate that throws is treated as a denial** rather than propagating to
1033
+ Express's default 500 handler. So is an `access()` that throws.
1034
+ 6. **`access()` returning a bare string is one permission, not full access.**
1035
+ `AccessMethod` declares `string` legal, and it previously fell through every
1036
+ branch and granted all four operations — `return 'read'` allowed `DELETE`.
1037
+ It is now equivalent to `['read']`. Any other unrecognised shape (an object,
1038
+ a number) now returns `403` rather than granting full access.
1039
+ 7. **`POST` with a client-supplied `id` normalises the body id before the
1040
+ duplicate check**, so an id shape that previously *missed* the store's key
1041
+ now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
1042
+ where they answered `200`, and the `200` was not a success: the lookup missed,
1043
+ the duplicate check was skipped, and `createRecord` overwrote the colliding
1044
+ record in place. **This one reaches consumers with no filter at all** — the
1045
+ population breaking changes 3 and 4 explicitly exempt. If you were relying on
1046
+ a hex-shaped or whitespace-padded id creating a second record, it never did.
1047
+
1048
+ 8. **Server-assigned ids change value on string-id models, numeric ids stop
1049
+ being monotonic at the numeric ceiling, and the create route gains a
1050
+ `409`.** Three consumer-visible changes from
1051
+ [#203](https://github.com/abofs/stonyx-orm/issues/203).
1052
+
1053
+ **The value.** A `POST` with no `id` against a model declaring
1054
+ `id = attr('string')` previously produced the *last-inserted* id with `1`
1055
+ concatenated onto it — an owner store holding `['gina', 'bob']` answered
1056
+ `'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
1057
+ lowest positive integer whose landing key is free. **No test in this repo
1058
+ pinned the old value**, so a consumer relying on it gets no failing test, no
1059
+ deprecation and no other signal — which is why it is recorded here. Numeric
1060
+ id models (`id = attr('number')`, the default) are unaffected in shape: they
1061
+ still get an integer, but it is now the **maximum** existing id plus one
1062
+ rather than the last-inserted id plus one, which is the defect #203 is about.
1063
+ They are **not** unaffected in *sequence* — see the monotonicity half below.
1064
+
1065
+ The value is deliberately **not** numeric-looking, and that is not cosmetic.
1066
+ Every id-bearing surface resolves a numeric-looking string id to a **number**
1067
+ (`GET /owners/1` looks up `1`), while a string-id model files its records
1068
+ under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
1069
+ record that was created successfully and could not be fetched, updated or
1070
+ deleted by id, and whose after-`create` hook received
1071
+ `context.record === undefined`
1072
+ ([#209](https://github.com/abofs/stonyx-orm/issues/209)).
1073
+
1074
+ **Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
1075
+ The precondition is narrow but it is reachable, and there is no signal when
1076
+ it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
1077
+ + 1` is not representable there, so assignment restarts from `1` and walks
1078
+ up to the lowest free key — which means the id of a *deleted* record is
1079
+ handed to the next `POST`. Both `dev` and every prior release were strictly
1080
+ monotonic and never re-issued a numeric id, so a consumer that relied on
1081
+ that — audit rows, cursors, cached authorization decisions, external
1082
+ references keyed on the id — now has a stale reference that silently points
1083
+ at a **different record, created by a different caller**, rather than at a
1084
+ deleted one. Nothing fails; the reference simply resolves to the wrong
1085
+ record.
1086
+
1087
+ The restart is deliberate and is not itself optional: without it, one record
1088
+ at the ceiling made every subsequent server-assigned create on that
1089
+ collection fail permanently. Re-use is the cost of keeping the collection
1090
+ writable. **If you need monotonic ids, assign them yourself** rather than
1091
+ letting the server assign, and note that a ceiling record can be planted by
1092
+ an unauthenticated caller — see *And the id itself is an occupancy signal*
1093
+ under [Filter functions](#filter-functions) for the reachability path.
1094
+ String-id models are unaffected by this half: their keys are
1095
+ `<model>-<n>` and were never monotonic over an integer sequence.
1096
+
1097
+ **The status.** `POST /{collection}` can now answer `409` for a reason other
1098
+ than a duplicate id: the server could not derive a free id. That requires a
1099
+ **non-injective** id transform — one that maps distinct candidates onto the
1100
+ same store key, such as `boolean`, or anything you registered on
1101
+ `Orm.instance.transforms` and named as an id type. It is a configuration
1102
+ fault rather than a request fault; the message is logged through
1103
+ `stonyx/log`. Previously this case threw out of the handler and express
1104
+ answered `500` with a stack trace.
1105
+
291
1106
  ### Include Parameter (Sideloading Relationships)
292
1107
 
293
1108
  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.
@@ -434,6 +1249,16 @@ Each hook receives a context object with comprehensive information:
434
1249
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
435
1250
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
436
1251
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1252
+ - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1253
+ object, different coverage: `_withHooks` sets this key **only** under
1254
+ `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1255
+ **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1256
+ never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1257
+ and the id sitting in `ctx.params`). The access context, by contrast, carries
1258
+ `recordId` on every route it classifies and spells absence as `null`, never
1259
+ `undefined`. Tracked as
1260
+ [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1261
+ `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
437
1262
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
438
1263
 
439
1264
  ### Usage Examples
@@ -561,7 +1386,19 @@ afterHook('delete', 'animal', async (context) => {
561
1386
  // Additional access control - halt with 403 if unauthorized
562
1387
  beforeHook('delete', 'animal', (context) => {
563
1388
  const user = context.state.currentUser;
564
- const animal = store.get('animal', context.params.id);
1389
+
1390
+ // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
1391
+ // has already fetched the record (and already applied the access filter to
1392
+ // it) before this hook runs, so re-fetching it here is a fourth id coercion
1393
+ // that has to agree with three others.
1394
+ //
1395
+ // And it would not agree. `context.params.id` is the raw url segment, always
1396
+ // a string, while the store keys numeric-id models by NUMBER — so
1397
+ // `store.get('animal', '21')` misses the record held under `21`, and so does
1398
+ // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
1399
+ // record", which in an authorization hook fails whichever way your code
1400
+ // happens to handle a null.
1401
+ const animal = context.oldState;
565
1402
 
566
1403
  if (animal.owner !== user.id && !user.isAdmin) {
567
1404
  return 403; // Forbidden
@@ -569,6 +1406,10 @@ beforeHook('delete', 'animal', (context) => {
569
1406
  });
570
1407
  ```
571
1408
 
1409
+ > If you do need a lookup for some *other* model inside a hook, coerce the id
1410
+ > yourself to the type that model's `id` attribute declares — the store is a
1411
+ > `Map` and `'21'` and `21` are different keys.
1412
+
572
1413
  #### Auditing
573
1414
 
574
1415
  ```javascript
@@ -702,11 +1543,29 @@ beforeHook('create', 'post', (context) => {
702
1543
 
703
1544
  ### Hook Execution Order
704
1545
 
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.
1546
+ 1. **Authorization is evaluated first for `update` and `delete`.** A record the
1547
+ access filter rejects returns `404` **before any before-hook runs**, so a
1548
+ hook never sees a record or a `context.oldState` — that the caller is not
1549
+ allowed to read. `create` is the exception: there is no record to test until
1550
+ the handler has built one, so `beforeHook('create', ...)` **does** fire for a
1551
+ `POST` that goes on to answer `403`.
1552
+ 2. **Before hooks** fire next (sequentially, in registration order).
1553
+ 3. **Main operation** executes (if no before hook halted).
1554
+ 4. **After hooks** fire last (sequentially, in registration order) — **only if
1555
+ the request succeeded.**
1556
+
1557
+ Before hooks can halt the operation by returning a value, and that value becomes
1558
+ the response.
1559
+
1560
+ **After hooks do not run for a failed request.** Any status `>= 400` — denied,
1561
+ missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
1562
+ persistence and `onUpdate` autosave. This is a behaviour change; see
1563
+ [Breaking changes](#breaking-changes). It applies to the samples above: the
1564
+ `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
1565
+ that answered `404`, and the `afterHook('update', ...)` change-tracking hook
1566
+ writes none for a `PATCH` that answered `404` or `400`. If you need a record of
1567
+ refused requests, log them from a before-hook or from your own middleware —
1568
+ `after<operation>` fires only for an operation that actually happened.
710
1569
 
711
1570
  ### Best Practices
712
1571