@jsenv/navi 0.30.24 → 0.30.26

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
@@ -229,6 +229,10 @@ delay to hide it:
229
229
 
230
230
  A "check now" wired to `run()` therefore checks nothing, silently.
231
231
 
232
+ `run()`, `rerun()` and `prerun()` answer the way the callback does: a promise when it is asynchronous, even
233
+ when `run()` joins an answer the instance already holds, so `ACTION.run().catch(…)`
234
+ holds in every state; the data itself, synchronously, when it is synchronous.
235
+
232
236
  ### Aborting saves resources, it does not undo
233
237
 
234
238
  `abort()` cancels what can still be cancelled — a `fetch` wired to the
@@ -112,6 +112,24 @@ render.**
112
112
  };
113
113
  ```
114
114
 
115
+ The binding `actionRunEffect` returns holds the state's question: while the
116
+ state says no, it has nothing to ask, and a reader on it waits forever. That
117
+ is right when the state is the only thing that asks. When something else asks
118
+ too (a reader with `run: true` that does not follow that state, the code of a
119
+ section every profile with cups draws, asked ahead because mine has some),
120
+ the effect runs the action and the readers read the question that always
121
+ stands, `true` for a param-less action. Both are the same instance, so the
122
+ code is fetched once:
123
+
124
+ ```jsx
125
+ const PALMARES_CODE_ACTION = createAction(() =>
126
+ import("./palmares.jsx").then((m) => m.Palmares),
127
+ );
128
+ actionRunEffect(PALMARES_CODE_ACTION, () => myCupsSignal.value.length > 0);
129
+ const PALMARES_CODE = PALMARES_CODE_ACTION.bindParams(true);
130
+ // useAsyncData(PALMARES_CODE, { run: hasPalmares, loading: true })
131
+ ```
132
+
115
133
  3. **Nothing above the component asks** → `useAsyncData` with a function, the
116
134
  fallback: the component's own existence is the only thing that asks, so the
117
135
  request is its own, for the life of the instance.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.30.24",
3
+ "version": "0.30.26",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {