@jsenv/navi 0.30.23 → 0.30.25

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.
@@ -89,7 +89,11 @@ start when unsure which export solves a problem.
89
89
  (`{ run: true }` is the fallback), `action` versus `uiAction`, an action and a
90
90
  command on one press. Before running an action from a component.
91
91
  - `resource.md` — `resource()` and its relations (never an `op` discriminator),
92
- what each read shows while its params move, `GET_RANGE` (its inclusive `end`,
92
+ a row pairing an entity with fields of its own (the entity under `.one()`,
93
+ never spread in), what a copy of a relation keeps, what a parent shows once
94
+ its `.one()` child is deleted (`onDelete: "cascade"` for a parent that is
95
+ nothing without it), what each read shows while
96
+ its params move, `GET_RANGE` (its inclusive `end`,
93
97
  the count for the screen around the list, a question following a search
94
98
  field), a search as the same `GET_MANY`, `withParams()` scopes and
95
99
  `dependencies`, `persist` (and `keepPageOnScreen` for the page on screen), a
package/docs/actions.md CHANGED
@@ -554,8 +554,8 @@ const SUMMARY = createAction(fetchSummary, {
554
554
  });
555
555
  ```
556
556
 
557
- After a listed write, its instances that answered, or are still asking, ask
558
- again; `on` is read as a resource's is
557
+ After a listed write, its instances that answered, are still asking, or failed
558
+ ask again; `on` is read as a resource's is
559
559
  ([resource.md](./resource.md#dependencies-rerun-after-another-resource-writes)).
560
560
 
561
561
  What the server moves on its own — an email that may bounce, an account being
@@ -236,6 +236,14 @@ changes what re-reads the slices and nothing else — `GET_MANY` still reruns af
236
236
  `POST`. `false` turns a key off. A `withParams()` scope or a relation reads its
237
237
  own `rerunOn` the same way, against the resource it is declared on.
238
238
 
239
+ A write asks again every read that answered, is still out, or failed. A failed
240
+ read has nothing else to bring it back: left out, one bad answer during a deploy
241
+ would keep a count on its old value until the page reloads. The status does not
242
+ matter, since a write can change what a `403` or a `404` answers, so a read that
243
+ keeps failing costs one request and one error per write, and never a retry loop.
244
+ Two reads stay out: an aborted or never-run one, which nobody is waiting for,
245
+ and a head start that failed, which the screen arriving asks for itself.
246
+
239
247
  Adding `PUT`/`PATCH` to `GET_MANY` is the usual over-correction: it costs a
240
248
  request and a `loading` pass to obtain something the response already contained.
241
249
  When a `PATCH` "doesn't show", the fix is almost always server-side — return the
package/docs/resource.md CHANGED
@@ -42,6 +42,8 @@ Actions are read in components through the action system (`useAsyncData`,
42
42
  - [Callback return contracts](#callback-return-contracts)
43
43
  - [When the backend answers a sub-route with the whole parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)
44
44
  - [Relations and autorerun](#relations-and-autorerun)
45
+ - [Reading a relation](#reading-a-relation)
46
+ - [When the child is deleted](#when-the-child-is-deleted)
45
47
  - [What a read shows while its params move](#what-a-read-shows-while-its-params-move)
46
48
  - [`withParams()`: a scope with reruns of its own](#withparams-a-scope-with-reruns-of-its-own)
47
49
  - [`dependencies`: rerun after another resource writes](#dependencies-rerun-after-another-resource-writes)
@@ -434,7 +436,7 @@ A row counts as written only when something in it changed:
434
436
  - **A relation** changes its row when the row points at other children; the
435
437
  children themselves are followed through the relation — `game.place.label`
436
438
  read in a render re-renders it when the place is written, and the game row
437
- stays the one it was.
439
+ stays the one it was ([what a copy of it keeps](#reading-a-relation)).
438
440
  - **A value written with `store.upsert()`** is compared by reference: a new
439
441
  object is a change, whatever it holds.
440
442
 
@@ -461,18 +463,45 @@ second coat: a verb on one relation carrying an id from a **different** relation
461
463
  people — is two relations spelled as one, and the one the gesture is about
462
464
  (game × group) ends up with no rows and nothing for its row on screen to read.
463
465
 
464
- | Situation | Use |
465
- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
466
- | The child is a first-class entity with its own store, shared across parents (a user referenced by many games) | `.one()` / `.many()` |
467
- | The child only exists inside its owner, with no identity outside it (a game's candidates, a table's columns) | `.scopedOne()` / `.scopedMany()` |
468
- | The relation itself carries fields (`candidate_since`, `seen_at`, `slot`) | `.scopedMany()` — those fields belong to the pair; putting them in a shared child store corrupts that entity for every other reader |
469
- | The backend answers every sub-route with the whole refreshed parent | still model the relation; absorb the response with a plural callback (see below) |
470
- | A genuine partial update of the parent itself (cancel a game) | plain `PATCH` on the parent |
471
- | Several writes on the item itself, none of them a sub-object (cancel, archive, mark as seen) | one named in [`writes`](#several-writes-on-one-item-writes) each |
466
+ | Situation | Use |
467
+ | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
468
+ | The child is a first-class entity with its own store, shared across parents (a user referenced by many games) | `.one()` / `.many()` |
469
+ | The child only exists inside its owner, with no identity outside it (a game's candidates, a table's columns) | `.scopedOne()` / `.scopedMany()` |
470
+ | The relation itself carries fields (`candidate_since`, `seen_at`, `slot`) | `.scopedMany()` — those fields belong to the pair; putting them in a shared child store corrupts that entity for every other reader |
471
+ | A list whose items pair another resource's entity with fields of their own (my contacts, a club's members, a tournament's registrations) | the pair is the row, under a key of its own, and the entity goes under `.one()` on it; never spread into the row (see below) |
472
+ | The backend answers every sub-route with the whole refreshed parent | still model the relation; absorb the response with a plural callback (see below) |
473
+ | A genuine partial update of the parent itself (cancel a game) | plain `PATCH` on the parent |
474
+ | Several writes on the item itself, none of them a sub-object (cancel, archive, mark as seen) | one named in [`writes`](#several-writes-on-one-item-writes) each |
472
475
 
473
476
  Singular vs plural is about the relation, not the verb: `.one`/`.scopedOne` for a
474
477
  single sub-object, `.many`/`.scopedMany` for a collection.
475
478
 
479
+ **A row pairing an entity with fields of its own holds the entity as a
480
+ relation.** One of my contacts is what links me to someone (games together,
481
+ favourite) and that someone, the same user their profile shows. The row is the
482
+ pair, keyed by it: a resource of its own, or a `.scopedMany()` of an owner the
483
+ store holds (a club's members). The person goes under `.one()`:
484
+
485
+ ```js
486
+ // GET /me/contacts → [{ key: "user:12", contact: { … }, user: { id: 12, … } }, …]
487
+ const CONTACT = resource("contact", { idKey: "key", GET_MANY });
488
+ CONTACT.one("user", USER, { onDelete: "cascade" });
489
+ ```
490
+
491
+ The row is nothing without the person, so it leaves when they are deleted
492
+ (`onDelete: "cascade"`, see [When the child is deleted](#when-the-child-is-deleted)).
493
+
494
+ Spread into the row (`{ ...user, key, contact }`), the person is a second copy
495
+ in another store. Their profile read again, a write on them, a search that finds
496
+ them: none of these reach the row, and the list shows the old person until it is
497
+ read again. A [`dependency`](#dependencies-rerun-after-another-resource-writes)
498
+ rereading the list after every write that might change the person is the same
499
+ copy, refreshed by hand, and it misses the writes nobody listed. A backend
500
+ answering the row flat is reshaped in the callback
501
+ (`({ key, contact, ...user }) => ({ key, contact, user })`). It is the mirror of
502
+ the row above it: the pair's fields written into the entity's store corrupt it
503
+ for every reader, the entity's fields written into the pair's row go stale.
504
+
476
505
  **A write whose effect the response does not carry is a modelling bug, not a
477
506
  case for the client to remember.** The row on screen claiming "this happened"
478
507
  reads the store, which holds only what responses carried; whatever the screen
@@ -649,7 +678,9 @@ Relationship mutations do **not** invalidate their parent by default. The rules:
649
678
  - `.scopedOne` mutations rerun nothing, ever — the result is the new value,
650
679
  and whoever reads the property is drawn again with it.
651
680
  - `.one` / `.many` children live in an independent store; mutating them never
652
- reruns the parent. A `.many()` write changes the parent's array only through
681
+ reruns the parent. Deleting a `.one()` child removes its parents only when
682
+ the relation says so ([When the child is deleted](#when-the-child-is-deleted)).
683
+ A `.many()` write changes the parent's array only through
653
684
  the answers naming it (`[parentId, …]`, see
654
685
  [Callback return contracts](#callback-return-contracts)); otherwise declare
655
686
  it with [`dependencies`](#dependencies-rerun-after-another-resource-writes)
@@ -667,6 +698,84 @@ is a resource of its own, refreshed only by these rules. When a parent field
667
698
  depends on a child mutation, say so with `dependencies` rather than relying on a
668
699
  rerun that will not happen.
669
700
 
701
+ ## Reading a relation
702
+
703
+ `contact.user` is read through the relation each time: in a render, it is the
704
+ child as the store holds it, and the render that read it is drawn again when the
705
+ child is written. The row, and the list holding it, stay the objects they were
706
+ when only a child changes
707
+ ([a row counts as written only when something in it changed](#searching-the-same-collection)).
708
+ So anything taken out of a row is a snapshot of that moment, and stays as it
709
+ was:
710
+
711
+ - `{ ...contact }`, which holds the child the spread found;
712
+ - a child kept in a variable;
713
+ - a value derived in `useMemo(…, [contacts])`: the list does not move when a
714
+ child does, so the derivation never runs again.
715
+
716
+ Read the relation where it is drawn. A derivation that has to flatten the rows
717
+ (`{ ...contact.user, contact: contact.contact }`) reads the children at render
718
+ and is keyed on them: a `.one()` child is the same object until it is written,
719
+ and a new one after.
720
+
721
+ ## When the child is deleted
722
+
723
+ A `.one()` child leaves its store when it is deleted: its `DELETE`, its
724
+ `DELETE_MANY`, or a write named in `writes` with verb `DELETE` (a profile merged
725
+ into another account). Whether its parents leave with it is declared on the
726
+ relation. Declared wrong, the screen draws dead rows: a parent with nothing in
727
+ it, until the parent is read again. A `.many()` needs nothing: a deleted child
728
+ leaves every array holding it. Decide it for every `.one()` by asking what the
729
+ parent is without its child:
730
+
731
+ | The parent without its child | Declare | Once the child is deleted |
732
+ | -------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------ |
733
+ | Still something: a game whose organizer deleted their account, a message whose author left | nothing | the parent stays, and its property reads an empty object until the parent is read again |
734
+ | Nothing: a row of my contacts (the person), a favourite (the place), a notification about one game | `onDelete: "cascade"` | the parent leaves its store in the same write, and with it every list and range holding it |
735
+
736
+ The server answers the same question with its foreign key: `ON DELETE CASCADE`
737
+ on the parent's table is `onDelete: "cascade"` here, and `SET NULL` (or a row
738
+ kept pointing at nothing) is the default.
739
+
740
+ **`onDelete: "cascade"`.** The parent leaves as on a `DELETE` of its own,
741
+ without the action:
742
+
743
+ - a `GET_MANY` or a `GET_RANGE` holding it stops drawing it, and a `GET` of that
744
+ row has nothing left to show;
745
+ - nothing listening to the parent's own writes hears it (its `rerunOn`, a
746
+ `dependencies` naming it): the parent wrote nothing;
747
+ - the parent leaving is a deletion too: a resource pointing at it through a
748
+ cascading `.one()` of its own loses its rows in turn;
749
+ - a read of the parent that answers the row again brings it back: the server
750
+ has the last word.
751
+
752
+ Keep a [`dependencies`](#dependencies-rerun-after-another-resource-writes)
753
+ entry on the child's delete when that delete changes other parents too (a
754
+ merge moves my links onto the account it merged into): the cascade takes away
755
+ at once what is gone, the reread brings what moved. The relation's own `DELETE`
756
+ is not a deletion of the child. It unlinks: the property becomes `null` and the
757
+ parent stays. On a `.one()` declared on a scoped child, `onDelete` throws: a
758
+ scoped item leaves through its owner's callbacks, so the owner is read again
759
+ after the child's delete (`dependencies`), and the item reads an empty object
760
+ until that read answers.
761
+
762
+ **Left out: the empty object.** A parent outliving its child reads its property
763
+ as an object with no fields, whose `valueOf()` is `null`. A relation that is
764
+ `null` reads the same. It is an object on purpose: `game.organizer.name` reads
765
+ `undefined` rather than throwing, and a binding handed `game.organizer` follows
766
+ the relation, a child arriving later included. But it is **truthy**, and that is
767
+ where screens draw dead things:
768
+
769
+ - `game.organizer ? <Avatar user={game.organizer} /> : null` draws an avatar of
770
+ nobody;
771
+ - `{ ...contact.user, key }` spreads into a row holding a key and nothing else,
772
+ which a name component fills with its fallback ("Anonymous").
773
+
774
+ Test the child by a field it always has, its id:
775
+ `game.organizer.id ? … : …`. A list that must never draw a parent without its
776
+ child needs the cascade, not a filter in the screen: the filter guesses what the
777
+ store knows, and every other list reading the relation needs the same filter.
778
+
670
779
  ## What a read shows while its params move
671
780
 
672
781
  A binding following a signal — a route action, `bindParams(signal)` — moves to
@@ -708,7 +817,7 @@ another collection, drawn from its first item
708
817
 
709
818
  A resource reruns its own reads after its own writes (`rerunOn`, see
710
819
  [list_refresh.md](./list_refresh.md#rerunon-verb-by-verb)), and it does so for
711
- every read of the resource: a `POST` reruns every `GET_MANY` that completed,
820
+ every read of the resource: a `POST` reruns every `GET_MANY` already asked,
712
821
  whatever params each one was bound to. Right for one collection, wrong for a
713
822
  resource read under several fixed questions at once — the admins and the guests
714
823
  of one `USER`, each list on its own screen. A guest created should not send the
@@ -767,8 +876,9 @@ const ROLE_WITH_OWNERS = ROLE.withParams(
767
876
  ```
768
877
 
769
878
  Any write on a listed resource (`POST`, `PUT`, `PATCH`, `DELETE`) reruns the
770
- completed `GET` and `GET_MANY` of the resource that declared it, and invalidates
771
- its `GET_RANGE` readers: its own `rerunOn` is about its own writes. A
879
+ `GET` and `GET_MANY` of the resource that declared it, those that failed included
880
+ ([which ones](./list_refresh.md#rerunon-verb-by-verb)), and invalidates its
881
+ `GET_RANGE` readers: its own `rerunOn` is about its own writes. A
772
882
  dependency is a facade: `DATABASE` and `DATABASE.withParams(…)` are two of them,
773
883
  and only writes on the one listed count. Nothing goes the other way, and nothing
774
884
  is inferred — a relation that really is a sub-route of the parent is modelled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.30.23",
3
+ "version": "0.30.25",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {