@stonyx/orm 0.3.2-alpha.64 → 0.3.2-alpha.65

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.
package/README.md CHANGED
@@ -315,24 +315,15 @@ Access classes define models and provide custom filtering/authorization logic.
315
315
  > [Identifying the collection](#identifying-the-collection) before copying this.**
316
316
  > Every attempt to identify the collection by parsing the request target has
317
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 all five variants are **unconstructible** against it
322
- > rather than merely handled.
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.
323
321
  >
324
322
  > That is still a stopgap. **The real fix is
325
323
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
326
324
  > receive the model, the operation and the record, so there is nothing to
327
325
  > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
328
326
  > you can: the **function** shape is the one that requires any matching at all.
329
- >
330
- > The one read of argument **one** that survives is `request.path`, for the
331
- > `/archived` sub-path deny — and it has to. The context names which model and
332
- > which verb, not which route, so that deny **cannot be expressed from the
333
- > context alone** and a context-only rewrite would silently turn it into an
334
- > allow.
335
- >
336
327
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
337
328
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
338
329
  > README and that source header are the two copies a consumer sees.
@@ -346,65 +337,27 @@ Access classes define models and provide custom filtering/authorization logic.
346
337
  export default class GlobalAccess {
347
338
  models = ['owner', 'animal'];
348
339
 
349
- access(request, { model, operation }) {
350
- // `model` is the model this route was mounted for. It is assigned once, at
351
- // mount time, and no request can influence it — not a mount prefix, not a
352
- // query string, not a case-varied path, not an absolute-form request
353
- // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
354
- // constructible against this predicate any more — they are history, not
355
- // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
356
- // THAN IT WAS: a matcher stricter than the router can still be stepped
357
- // around, because the sub-path rule below is still a string comparison.
358
- // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
359
- //
360
- // `operation` is destructured to name the whole contract at the point of
361
- // use. This sample's rules are per-model and per-sub-path rather than
362
- // per-verb, so it does not branch on it; the permission array at the bottom
363
- // is where the verb is answered.
364
-
365
- // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
366
- // resolved this predicate without supplying the context, and a request this
367
- // function cannot identify DENIES rather than falling through to the CRUD
368
- // grant at the bottom. An unidentifiable input must never be the permissive
369
- // path. Argument ONE is guarded at its own read, below — this guard does
370
- // not cover it.
371
- if (typeof model !== 'string' || model === '') return false;
372
-
373
- if (model === 'owner') {
374
- // The context names WHICH MODEL and WHICH VERB — not which route. Six
375
- // distinct owner surfaces produce one identical context, so a rule that
376
- // depends on the SUB-PATH still needs argument one. `request.path` is
377
- // mount-relative and query-free, and it is the one read of the raw
378
- // request the README sanctions. false → 403 for the whole request.
379
- //
380
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
381
- // does not remove a rule, it turns a deny into an ALLOW, silently.
382
- //
383
- // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
384
- // this one covers the request, and since #202 they are two different
385
- // objects. A caller that resolves this predicate through the documented
386
- // `Orm.instance.getAccess()` path and hand-assembles a request can supply
387
- // a perfectly valid context with no usable `path` — and
388
- // `String(request.path ?? '')` is then `''`, which matches no sub-path
389
- // rule and falls straight through to the per-record filter below. That is
390
- // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
391
- // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
392
- // file's header condemns does not appear below.
393
- if (typeof request?.path !== 'string' || request.path === '') return false;
394
-
395
- // Lower-cased because the router matched case-insensitively, so a
396
- // case-sensitive rule here would be stricter than the router and could be
397
- // stepped around.
398
- //
399
- // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
400
- // not a recipe for one. Express sets `request.path` from the RAW pathname
401
- // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
402
- // comparison as `/%61rchived`, walks past the deny, and is dispatched as
403
- // the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
404
- // the way the router that dispatched the request does. Record ids are
405
- // case-sensitive and must be compared at their real case.
406
- const path = request.path.toLowerCase();
340
+ access(request) {
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();
407
359
 
360
+ if (collection.endsWith('/owners')) {
408
361
  if (path === '/archived' || path.startsWith('/archived/')) return false;
409
362
 
410
363
  // Returning a function plugs it in as a per-record filter, and it is
@@ -420,7 +373,7 @@ export default class GlobalAccess {
420
373
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
421
374
  // shape as well as the resolved one would absorb a resolution regression
422
375
  // silently, which is exactly what blinded this fixture before.
423
- if (model === 'animal') return record => record.owner?.id !== 'restricted';
376
+ if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
424
377
 
425
378
  // Allows full access to all calls that don't match any of the above conditions
426
379
  return ['read', 'create', 'update', 'delete'];
@@ -570,10 +523,9 @@ sample, `getAccess('owner') === getAccess('animal')`.
570
523
  #### Passing the context makes a model-correct answer *possible*
571
524
 
572
525
  It does not make the answer model-correct on its own. **The resolved predicate
573
- has to read the context.** Against a predicate that ignores it the failure is
574
- measurable. On a request Express dispatched to `GET /owners/angela`, asked about
575
- **animals**, the sample as it shipped before
576
- [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
526
+ has to read the context.** Measured against the access class shipped with this
527
+ repo, on a request Express dispatched to `GET /owners/angela`, asked about
528
+ **animals**:
577
529
 
578
530
  ```
579
531
  getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
@@ -581,26 +533,18 @@ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
581
533
  ```
582
534
 
583
535
  That is the **owners** filter, and it returns `true` for animal 21 — the record
584
- hidden on every animal surface. Under a mount such a predicate recognizes
585
- neither way it is worse still: it falls through to
586
- `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
587
- context was supplied and the answer is not the animal answer, and it is wrong in
588
- the direction that **grants** because that predicate was single-argument and
589
- identified its collection from the request, so it answered about the collection
590
- the request was *addressed to* while being asked about another one.
591
-
592
- The sample shipped with this repo has since been migrated to read the context,
593
- and the same call now answers with the **animal** filter:
594
-
595
- ```
596
- getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
597
- -> record => record.owner?.id !== 'restricted'
598
- ```
599
-
600
- A single-argument predicate remains the default in every consumer tree, and a
601
- caller has no supported way to tell which kind it resolved. The boot-time arity
602
- warning that surfaces one is
603
- [#221](https://github.com/abofs/stonyx-orm/issues/221).
536
+ hidden on every animal surface. Under a mount that predicate recognizes neither
537
+ way it is worse still: it falls through to
538
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant.
539
+
540
+ Either way the context was supplied and the answer is not the animal answer, and
541
+ it is wrong in the direction that **grants**. That predicate is single-argument
542
+ and identifies its collection from the request, so it answered about the
543
+ collection the request is *addressed to* while being asked about another one.
544
+ Every predicate in this repo, and in every consumer tree, is single-argument on
545
+ the day this ships, and a caller has no supported way to tell which kind it
546
+ resolved. The boot-time arity warning that would surface it is
547
+ [#213](https://github.com/abofs/stonyx-orm/issues/213).
604
548
 
605
549
  So: pass the context, and do not treat a resolved predicate's answer as
606
550
  model-specific until that predicate has been migrated to read it.
@@ -637,7 +581,7 @@ a write to a *different* collection can still re-parent one. See
637
581
  | `GET /:models/:id/relationships/{relationship}` | `404` — same |
638
582
  | `PATCH /:models/:id` | `404`, no attribute is applied |
639
583
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
640
- | `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 |
584
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
641
585
 
642
586
  **Denied record-level requests return 404, not 403.** This is deliberate and it
643
587
  is the property most easily "improved" away. 403 would confirm that the record
@@ -671,23 +615,70 @@ chooses the id and learns whether the create succeeded — so under a filter the
671
615
  caller does not choose the id. The refusal happens before any store lookup, so
672
616
  neither the status nor the response time depends on whether the id exists.
673
617
 
674
- Let the server assign the id and read it back from the response. Callers with no
675
- function-style filter are unaffected: `409` on a duplicate id and `200` on a free
676
- one both behave exactly as before.
618
+ Let the server assign the id and read it back from the response and read it
619
+ back rather than predicting it, because the value it returns is documented but
620
+ not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
621
+ numeric ceiling, the lowest free integer), and a string-id model gets
622
+ `<model>-<n>`. See breaking change 8.
623
+
624
+ **What a server-assigned id is not.** It is not a secret. On a string-id
625
+ collection it is dense and enumerable from `1`, where previously it inherited
626
+ whatever entropy the last-inserted id happened to carry — a UUID-seeded store
627
+ answered a UUID-derived key. If a collection has **no** `access` config its
628
+ record-level routes are ungated, so the id was the only thing standing between
629
+ an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
630
+ a control and must not become one; configure `access`.
631
+
632
+ **And the id itself is an occupancy signal — on both model kinds.**
633
+ `assignRecordId` reads the whole store, not the caller's filtered view — it
634
+ never sees `state.filter` — so the id it returns is a function of records the
635
+ caller may not be permitted to read. **This applies to numeric-id collections
636
+ as well as string-id ones**, and the conditions differ, so read both:
637
+
638
+ - **String-id collections, always.** The assigned `n` is the smallest positive
639
+ integer whose landing key is free, which tells the caller that every key
640
+ below it is taken, hidden or not.
641
+ - **Numeric-id collections, once one record sits at the numeric ceiling.** The
642
+ normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
643
+ is not representable at or above 2^53, so the walk restarts from `1` (see
644
+ breaking change 8) and the assigned id becomes the smallest free integer —
645
+ the same occupancy predicate, now over arbitrary low keys. Each subsequent
646
+ no-id `POST` names the next free one, so a caller can enumerate the holes in
647
+ a range it cannot read.
648
+
649
+ **A ceiling record reaches a filter-protected collection even though `POST`
650
+ refuses caller ids on one.** Breaking change 3 makes
651
+ `POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
652
+ through a *relationship write on another collection* —
653
+ `POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
654
+ creates the animal under that key
655
+ ([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
656
+ **Known limitations** re-parenting note describes). So the precondition is
657
+ reachable by an unauthenticated caller on exactly the collections `access`
658
+ exists to protect. Measured on the sample fixture, with every animal hidden by
659
+ the `/animals` predicate and keys 4 and 7 deleted:
677
660
 
678
- ### Identifying the collection
661
+ ```
662
+ GET /animals -> 200 [] (nothing visible)
663
+ GET /animals/4 -> 404 (free — indistinguishable from hidden)
664
+ POST /animals {"id":4} -> 403 (breaking change 3)
665
+ POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
666
+ POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
667
+ POST /animals (no id) -> 200 id=7 <- and the other one
668
+ POST /animals (no id) -> 200 id=13
669
+ POST /animals (no id) -> 200 id=14
670
+ ```
671
+
672
+ Closing this requires the assignment to be filter-aware, which is a change to
673
+ the `access` contract rather than a fix; it is stated here rather than left to
674
+ be discovered. Callers with no function-style filter are unaffected — there are
675
+ no hidden records to disclose.
679
676
 
680
- **Do not reconstruct the request path — and since
681
- [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
682
- identify the collection at all.** Read `model` from
683
- [the access context](#the-access-context-second-argument): it is fixed at mount
684
- time, no request can influence it, and there is nothing left to parse.
677
+ ### Identifying the collection
685
678
 
686
- **Everything below is the record of what happened when this sample did parse
687
- it.** It is kept as history, not as a recipe none of these matching strategies
688
- should be written into a new predicate. Every version of this sample that tried
689
- to identify the collection from the request target failed **open**, and each
690
- variant was found only after the previous one was fixed:
679
+ **Do not reconstruct the request path.** Every version of this sample that tried
680
+ to has failed **open**, and each variant was found only after the previous one
681
+ was fixed:
691
682
 
692
683
  | # | Variant | Why it fails open |
693
684
  |---|---|---|
@@ -697,24 +688,13 @@ variant was found only after the previous one was fixed:
697
688
  | 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. |
698
689
  | 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. |
699
690
 
700
- **The fix is not a sixth rule, and it is not a better string to match.** It is
701
- to stop identifying the collection at all. That is a statement about
702
- **identifying the collection**, and it is not a statement about the sample as a
703
- whole: the `/archived` sub-path rule *is* still a string match, and
704
- [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
705
- gets past it. Sub-path rules are the residue this fix does not cover, which is
706
- why they must normalise the way the router does.
707
-
708
- An intermediate revision read **`request.baseUrl`** — the mount Express
709
- *actually matched*. That closed all five variants: it carries no query string
710
- (variant 2), it is not mount-relative (variant 1), it already contains the
711
- configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
712
- so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
713
- target (variant 5). It was still a transport artifact standing in for a
714
- structural fact, and it is **no longer what the sample does**: the sample reads
715
- `model`, so all five variants are unconstructible against it rather than
716
- handled. The table below is retained as the measured evidence behind the five
717
- variants, not because any of these values should be matched on:
691
+ **The fix is not a sixth rule.** It is to stop parsing:
692
+
693
+ **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
694
+ dispatched the request. It carries no query string (variant 2), it is not
695
+ mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
696
+ prefix (variant 4 there is nothing left to derive, so `/apiowners` is
697
+ unconstructible), and it is unaffected by an absolute-form target (variant 5).
718
698
 
719
699
  | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
720
700
  |---|---|---|---|---|
@@ -725,35 +705,21 @@ variants, not because any of these values should be matched on:
725
705
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
726
706
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
727
707
 
728
- **One read of argument one survives, and it must: `request.path`.** It is
729
- mount-relative and query-free, and it is for rules that distinguish **sub-paths**
730
- beneath the mount as the `/archived` deny in the sample above does. The context
731
- names which model and which verb, **not which route**, so that deny *cannot be
732
- expressed from the context alone*, and a context-only rewrite would silently turn
733
- it into an allow.
734
-
735
- **Normalise the way the router that dispatched the request does — and
736
- case-folding alone does not.** A matcher stricter than the router can be stepped
737
- around, so the sample lower-cases before comparing (the router matched
738
- case-insensitively). That closes the case gap and **it is not the whole rule**:
739
- Express sets `request.path` from the **raw, undecoded** pathname while the router
740
- **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
741
- comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
742
- `archived`. That gap is live in the sample above and is tracked as
743
- [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
744
- `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
745
- case-sensitive and must be compared at their real case.
746
-
747
- **Fail closed on anything you cannot identify — on *either* argument.**
748
- `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
749
- and it traded fail-closed for fail-**open**: an empty string matched no
750
- collection, so `access()` fell through to the permission array and granted full
751
- CRUD. The same rule applies to the context — the sample returns `false` for an
752
- absent `model` rather than falling through. Since #202 the guard and the read can
753
- sit on **different objects**, and a guard on argument two does not protect a read
754
- of argument one: the sample therefore also returns `false` when `request.path` is
755
- absent or is not a string, rather than letting `?? ''` fall through to the
756
- per-record filter. An input you cannot identify must **deny**.
708
+ Two rules remain, and they are the whole list:
709
+
710
+ **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
711
+ registered mount `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
712
+ case-insensitively, so a case-sensitive comparison here is stricter than the
713
+ router and can be walked past. Lower-case the **mount and path only**; record ids
714
+ are case-sensitive and must be compared at their real case.
715
+
716
+ **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
717
+ was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
718
+ empty string matches no collection, so `access()` fell through to the permission
719
+ array and granted full CRUD. An input you cannot identify must **deny**.
720
+
721
+ Use `request.path` mount-relative and query-free if you need to distinguish
722
+ sub-paths beneath the mount, as the `/archived` deny above does.
757
723
 
758
724
  ### Known limitations
759
725
 
@@ -776,14 +742,11 @@ per-record filter. An input you cannot identify must **deny**.
776
742
  see [The access context](#the-access-context-second-argument):
777
743
  `Orm.instance.getAccess(modelName)` makes another model's predicate
778
744
  **reachable**, and `context.model` makes a **model-correct answer possible** —
779
- possible, not guaranteed: the resolved predicate has to read the context. The
780
- sample shipped with this repo now does
781
- ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
782
- `getAccess('animal')` answers with the animal filter; a predicate that ignores
783
- the second argument still answers about the collection the request is
784
- addressed to, and the boot-time warning that surfaces one is
785
- [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
786
- exists; the ORM does not yet use it on this path.** The re-parenting write above is still
745
+ possible, not guaranteed: the resolved predicate has to read the context, and
746
+ every predicate in tree is still single-argument
747
+ ([#213](https://github.com/abofs/stonyx-orm/issues/213)), so today it answers
748
+ about the collection the request is addressed to. **The mechanism exists; the
749
+ ORM does not yet use it on this path.** The re-parenting write above is still
787
750
  **not refused** — that enforcement is
788
751
  [#196](https://github.com/abofs/stonyx-orm/issues/196) and
789
752
  [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
@@ -835,9 +798,10 @@ per-record filter. An input you cannot identify must **deny**.
835
798
  transform output differs from its lookup key is the same defect. Filtered
836
799
  collections are unaffected — breaking change 3 refuses any client-supplied id
837
800
  — so this reaches consumers with **no** function-style filter. Tracked as
838
- [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
839
- [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
840
- a create can land on an id nobody named.
801
+ [#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
802
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) a **server-assigned**
803
+ id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
804
+ the client-supplied half and is still open.
841
805
  - **`context.record` is `undefined` for an after-`create` hook when a string-id
842
806
  model is given a numeric-looking id.** The post-create lookup uses the same id
843
807
  coercion as every other surface, which resolves `'9107'` to the number `9107`,
@@ -847,15 +811,18 @@ per-record filter. An input you cannot identify must **deny**.
847
811
  [#209](https://github.com/abofs/stonyx-orm/issues/209).
848
812
  - **A denied `POST` rolls back only a record it *inserted*.** The rollback
849
813
  requires the store to have grown, because removing by id alone is a write
850
- primitive keyed by a caller-supplied value. When `assignRecordId` lands a
851
- **server-assigned** id on an occupied slot
852
- ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
853
- last-*inserted* + 1, not max + 1, so a store whose insertion order is not
854
- ascending collides), `createRecord` updates that record **in place**: the map
855
- does not grow, the rollback correctly declines to remove a record this request
856
- did not create, and the `403` leaves the caller's attributes on someone else's
857
- record. Narrow it needs a non-ascending insertion order — but it is the
858
- reachability condition, so it is stated rather than implied.
814
+ primitive keyed by a caller-supplied value. **The reachability condition this
815
+ bullet used to state is gone**: it was
816
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
817
+ returned last-*inserted* + 1, so a server-assigned id could land on an
818
+ occupied slot and `createRecord` would update it in place and #203 is fixed
819
+ (breaking change 8). A server-assigned create can no longer overwrite, so on a
820
+ collection whose only id channel is `createHandler` this guard has no
821
+ observable effect today. It is kept because a caller-supplied id reaching
822
+ `createRecord` from another route a relationship write,
823
+ [#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
824
+ back, and without the guard a denied `403` would delete a record the request
825
+ did not create.
859
826
 
860
827
  ### Breaking changes
861
828
 
@@ -916,6 +883,64 @@ they are recorded here.
916
883
  population breaking changes 3 and 4 explicitly exempt. If you were relying on
917
884
  a hex-shaped or whitespace-padded id creating a second record, it never did.
918
885
 
886
+ 8. **Server-assigned ids change value on string-id models, numeric ids stop
887
+ being monotonic at the numeric ceiling, and the create route gains a
888
+ `409`.** Three consumer-visible changes from
889
+ [#203](https://github.com/abofs/stonyx-orm/issues/203).
890
+
891
+ **The value.** A `POST` with no `id` against a model declaring
892
+ `id = attr('string')` previously produced the *last-inserted* id with `1`
893
+ concatenated onto it — an owner store holding `['gina', 'bob']` answered
894
+ `'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
895
+ lowest positive integer whose landing key is free. **No test in this repo
896
+ pinned the old value**, so a consumer relying on it gets no failing test, no
897
+ deprecation and no other signal — which is why it is recorded here. Numeric
898
+ id models (`id = attr('number')`, the default) are unaffected in shape: they
899
+ still get an integer, but it is now the **maximum** existing id plus one
900
+ rather than the last-inserted id plus one, which is the defect #203 is about.
901
+ They are **not** unaffected in *sequence* — see the monotonicity half below.
902
+
903
+ The value is deliberately **not** numeric-looking, and that is not cosmetic.
904
+ Every id-bearing surface resolves a numeric-looking string id to a **number**
905
+ (`GET /owners/1` looks up `1`), while a string-id model files its records
906
+ under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
907
+ record that was created successfully and could not be fetched, updated or
908
+ deleted by id, and whose after-`create` hook received
909
+ `context.record === undefined`
910
+ ([#209](https://github.com/abofs/stonyx-orm/issues/209)).
911
+
912
+ **Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
913
+ The precondition is narrow but it is reachable, and there is no signal when
914
+ it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
915
+ + 1` is not representable there, so assignment restarts from `1` and walks
916
+ up to the lowest free key — which means the id of a *deleted* record is
917
+ handed to the next `POST`. Both `dev` and every prior release were strictly
918
+ monotonic and never re-issued a numeric id, so a consumer that relied on
919
+ that — audit rows, cursors, cached authorization decisions, external
920
+ references keyed on the id — now has a stale reference that silently points
921
+ at a **different record, created by a different caller**, rather than at a
922
+ deleted one. Nothing fails; the reference simply resolves to the wrong
923
+ record.
924
+
925
+ The restart is deliberate and is not itself optional: without it, one record
926
+ at the ceiling made every subsequent server-assigned create on that
927
+ collection fail permanently. Re-use is the cost of keeping the collection
928
+ writable. **If you need monotonic ids, assign them yourself** rather than
929
+ letting the server assign, and note that a ceiling record can be planted by
930
+ an unauthenticated caller — see *And the id itself is an occupancy signal*
931
+ under [Filter functions](#filter-functions) for the reachability path.
932
+ String-id models are unaffected by this half: their keys are
933
+ `<model>-<n>` and were never monotonic over an integer sequence.
934
+
935
+ **The status.** `POST /{collection}` can now answer `409` for a reason other
936
+ than a duplicate id: the server could not derive a free id. That requires a
937
+ **non-injective** id transform — one that maps distinct candidates onto the
938
+ same store key, such as `boolean`, or anything you registered on
939
+ `Orm.instance.transforms` and named as an id type. It is a configuration
940
+ fault rather than a request fault; the message is logged through
941
+ `stonyx/log`. Previously this case threw out of the handler and express
942
+ answered `500` with a stack trace.
943
+
919
944
  ### Include Parameter (Sideloading Relationships)
920
945
 
921
946
  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.