@jsenv/navi 0.29.417 → 0.29.419

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +347 -83
  3. package/dist/dev/jsenv_navi.js.map +12 -12
  4. package/dist/jsenv_navi.js +345 -81
  5. package/dist/jsenv_navi.js.map +12 -12
  6. package/docs/AI_INSTRUCTIONS.md +177 -258
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +291 -454
  16. package/docs/drag_to_travel.md +152 -748
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +170 -326
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -473
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -317
  37. package/docs/state_binding.md +40 -84
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
package/docs/resource.md CHANGED
@@ -29,9 +29,8 @@ Each callback returns the data to upsert into the store:
29
29
 
30
30
  Actions are read in components through the action system (`useAsyncData`,
31
31
  `<Button action>`, …) — see [actions.md](./actions.md). An action's `data` is
32
- `undefined` until its callback has answered once, plural verbs included: a
33
- `GET_MANY` that has not run has no array, and `[]` is what an empty answer
34
- looks like ([data_states.md](./data_states.md#data-and-loading-are-independent)).
32
+ `undefined` until its callback has answered once, plural verbs included
33
+ ([data_states.md](./data_states.md#data-and-loading-are-independent)).
35
34
 
36
35
  - [`store.upsert()` is not how data enters the store](#storeupsert-is-not-how-data-enters-the-store)
37
36
  - [`persist`: the last answer, drawn again after a reload](#persist-the-last-answer-drawn-again-after-a-reload)
@@ -39,11 +38,6 @@ looks like ([data_states.md](./data_states.md#data-and-loading-are-independent))
39
38
  - [Searching the same collection](#searching-the-same-collection)
40
39
  - [Relations: pick one of the four methods](#relations-pick-one-of-the-four-methods)
41
40
  - [Callback return contracts](#callback-return-contracts)
42
- - [`.one(propertyName, childResource, { GET, PUT, DELETE })`](#onepropertyname-childresource--get-put-delete-)
43
- - [`.many(propertyName, childResource, restCallbacks)`](#manypropertyname-childresource-restcallbacks)
44
- - [`.scopedOne(propertyName, { idKey, GET, POST, PUT, PATCH, DELETE })`](#scopedonepropertyname--idkey-get-post-put-patch-delete-)
45
- - [`.scopedMany(propertyName, { idKey, GET, GET_MANY, POST, PUT, PATCH, DELETE, … })`](#scopedmanypropertyname--idkey-get-get_many-post-put-patch-delete--)
46
- - [A relation declared on a scoped child](#a-relation-declared-on-a-scoped-child)
47
41
  - [When the backend answers a sub-route with the whole parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)
48
42
  - [Relations and autorerun](#relations-and-autorerun)
49
43
  - [`withParams()`: a scope with reruns of its own](#withparams-a-scope-with-reruns-of-its-own)
@@ -54,10 +48,10 @@ looks like ([data_states.md](./data_states.md#data-and-loading-are-independent))
54
48
  ## `store.upsert()` is not how data enters the store
55
49
 
56
50
  navi writes the store. Declare the resource and its relations, return the shape
57
- each callback owes (tables above and below), and the write happens. A hand
58
- written `store.upsert()` in the path of a normal request of the resource is the
59
- sign that something is missing: a relation that is not declared, or a callback
60
- that does not return what it should.
51
+ each callback owes, and the write happens. A hand-written `store.upsert()` in
52
+ the path of a normal request of the resource is the sign that something is
53
+ missing: a relation that is not declared, or a callback that does not return
54
+ what it should.
61
55
 
62
56
  `store` is exposed for what is **not** a request of the resource, where writing
63
57
  it yourself is the point:
@@ -65,28 +59,23 @@ it yourself is the point:
65
59
  - seeding it with data that came from elsewhere — state rendered by the server,
66
60
  a websocket message (the resource's own last answer is not "elsewhere": that
67
61
  is [`persist`](#persist-the-last-answer-drawn-again-after-a-reload), below);
68
- - absorbing the parent object a relation route answered with: the relation
69
- callback only writes the relation, so the parent's own fields have nowhere
70
- else to land (see [When the backend answers a sub-route with the whole
62
+ - absorbing the parent fields a **scoped** relation's route answered with: a
63
+ `.scopedOne()`/`.scopedMany()` callback writes the relation and nothing else
64
+ (see [When the backend answers a sub-route with the whole
71
65
  parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)).
72
66
 
73
- A list that loads its rows a slice at a time is **not** one of those cases — that is
74
- `GET_RANGE`, right below.
67
+ A list that loads its rows a slice at a time is **not** one of those cases —
68
+ that is `GET_RANGE`, below.
75
69
 
76
70
  ## `persist`: the last answer, drawn again after a reload
77
71
 
78
- Some `GET`s are the app. `GET /me` answers who is signed in, their things, what
79
- the header and the tab bar draw; until it lands, a page that is rendered has
80
- nothing to draw. On a reload, or when the app is reopened from the home screen,
81
- that wait is pure loss: the previous answer was on screen a moment ago and is
82
- almost always still true, and a server waking up makes it last seconds.
83
-
84
- What is wanted is the second line of the table in
85
- [data_states.md](./data_states.md#data-and-loading-are-independent) reached on
86
- a reload: the last known row drawn, the request out behind it, the fresh answer
87
- replacing it. `persist` names the resource whose answer is worth keeping, and
88
- the signal that keeps it — a `stateSignal` that persists, the primitive navi
89
- has for a state that survives a reload:
72
+ Some `GET`s are the app — `GET /me` answers who is signed in, what the header
73
+ and the tab bar draw — and on a reload, or an app reopened from the home screen,
74
+ waiting for them is pure loss: the previous answer was on screen a moment ago
75
+ and is almost always still true. `persist` puts that reload on the second line
76
+ of the table in [data_states.md](./data_states.md#data-and-loading-are-independent)
77
+ — the last known row drawn, the request out behind it — and names the signal
78
+ that keeps the copy, a `stateSignal` that persists:
90
79
 
91
80
  ```js
92
81
  const mePersistedSignal = stateSignal(undefined, {
@@ -105,70 +94,50 @@ ME.one("user", USER);
105
94
  ME.many("my_games", GAME);
106
95
  ```
107
96
 
108
- From there navi does the rest:
109
-
110
- - **the rows `GET` lands with are written into the signal** — one per params
111
- the `GET` was asked with, so a `GET` without params keeps one row. Where the
112
- signal keeps its value is the signal's business: `persists: true` is
113
- localStorage, and a plain `signal()` keeps it for the page's lifetime, which
114
- is what a test wants;
115
- - **the first run of the `GET` draws the kept row.** It enters the store and
116
- becomes the action's provisional value: `data` is set while `loading` is
117
- `true`, the row is on screen and the request is out behind it. Nothing else
118
- moves — `runningState` is `running`, the answer replaces the row, an error
119
- lands in `errorSignal` beside it;
120
- - **the copy follows the store.** It is written with its relations inline — the
121
- shape the `GET` callback returns, so it re-enters through the same setters a
122
- real answer goes through — and rewritten whenever the row or a row it reaches
123
- through a relation changes: a `PUT` on the item, a list upserting one of its
124
- children, a game joining `my_games`. A rewrite that changes nothing is
125
- skipped, so the signal moves only when the copy does;
126
- - **under a network policy answering reads from the store, the kept row answers
127
- the `GET`** ([network_policy.md](./network_policy.md)): a reload with no
128
- network reopens on the last answer rather than on nothing.
97
+ The copy follows the store — a `PUT` on the item, a game joining `my_games`
98
+ rewrite it — and the first run of the `GET` draws it as its provisional value:
99
+ `data` set while `loading` is `true`, the answer replacing it. Under a network
100
+ policy answering reads from the store, the kept row answers the `GET`
101
+ ([network_policy.md](./network_policy.md)): a reload with no network reopens on
102
+ it. A plain `signal()` keeps the copy for the page's lifetime, which is what a
103
+ test wants.
129
104
 
130
105
  What stays the app's, because only the app knows:
131
106
 
132
- - **the key.** A stored copy is only as good as the code reading it: a deploy
133
- that changes the shape of the answer must not draw the old one. Put in the
134
- signal's `id` whatever makes a copy unusable — the deployed version — and a
135
- new id starts clean.
107
+ - **the key.** A deploy that changes the shape of the answer must not draw the
108
+ old one: put in the signal's `id` whatever makes a copy unusable — the
109
+ deployed version — and a new id starts clean.
136
110
  - **`when`: is the copy about this session?** A tab viewing as someone else, an
137
- account being switched — the row in the store is somebody's, and it must not
138
- be kept for the next person. `when` is read whenever the copy is about to be
139
- read or written: `false` reads nothing, writes nothing, and empties the
140
- signal. It is a function so it can read a signal, and it has to: a plain
141
- variable is only looked at again when the store changes.
142
- - **the moment, not the state.** Sign-out is not a condition that holds, it is
143
- something that happens: writing `undefined` into the signal
144
- (`mePersistedSignal.value = undefined`) forgets the copy at once, and nothing
145
- is written until a `GET` lands again. The store rows stay — the screen keeps
146
- what it shows until the app resets the action, as it does today.
111
+ account being switched — the row is somebody's, and must not be kept for the
112
+ next person. `when` is read whenever the copy is about to be read or written:
113
+ `false` reads nothing, writes nothing, and empties the signal. It is a
114
+ function so it can read a signal, and it has to: a plain variable is only
115
+ looked at again when the store changes.
116
+ - **the moment, not the state.** Sign-out is something that happens, not a
117
+ condition that holds: writing `undefined` into the signal forgets the copy at
118
+ once, and nothing is written until a `GET` lands again. The store rows stay —
119
+ the screen keeps what it shows until the app resets the action.
147
120
  - **what the `GET` feeds beside its return value.** Counts, badges, a feature
148
- flag set from the response are the app's signals, not the row; they are not
149
- kept, and the callback does not run on a kept row. Anything the app wants
150
- back before the answer, it stores itself.
151
- - **the veil.** A loading screen that waits on the `GET` being `running` stays
152
- over the drawn row for the whole request. It waits on `data` being set —
153
- the emptiness test of data_states.md — and the refresh is shown as usual.
121
+ flag set from the response are the app's signals, not the row: they are not
122
+ kept, and the callback does not run on a kept row.
123
+ - **the veil.** A loading screen waiting on the `GET` being `running` stays over
124
+ the drawn row for the whole request. It waits on `data` being set — the
125
+ emptiness test of data_states.md — and the refresh is shown as usual.
154
126
 
155
127
  What the copy is not:
156
128
 
157
- - **an answer.** The `GET` goes out every time; the copy is a drawing aid. A
158
- screen must not read `data` as "the server confirmed": that is `loading`,
159
- and the two are independent.
160
- - **a store on disk.** One row per params key, `GET` only — a resource whose
161
- `GET` is asked for every id would keep every id. It is meant for a singleton
162
- or a handful of rows.
129
+ - **an answer.** The `GET` goes out every time; a screen must not read `data` as
130
+ "the server confirmed" — that is `loading`, and the two are independent.
131
+ - **a store on disk.** One row per params key, `GET` only: meant for a singleton
132
+ or a handful of rows, not a resource whose `GET` is asked for every id.
163
133
  - **seeded at declaration.** The row enters the store at the first run of the
164
- `GET`, once the relations are declared, so that its relation values are
165
- normalized into their stores as a real answer's are. `ME.store` read before
166
- that first run is empty.
134
+ `GET`, once the relations are declared, so its relation values are normalized
135
+ as a real answer's are. `ME.store` read before that first run is empty.
167
136
 
168
137
  ## `GET_RANGE`: feeding a list that loads as it scrolls
169
138
 
170
139
  A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
171
- `GET_RANGE` is the resource's answer to that question — one slice at a time:
140
+ `GET_RANGE` is the resource's answer to that question, one slice at a time:
172
141
 
173
142
  ```js
174
143
  const GAME = resource("game", {
@@ -188,18 +157,19 @@ const GAME = resource("game", {
188
157
  ```
189
158
 
190
159
  The callback receives the bound params merged with the range the list asks for
191
- (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and `{ signal }`
192
- as second argument — a signal aborted when the list stops wanting those rows.
193
- It returns a range the way a `Content-Range` does: **`{ items, start, count }`**
194
- — these rows, at this place, out of that many. `start` may be omitted when the
195
- list asked for one at or after `0` (a negative `start` counts back from the end,
196
- and the source must then say where the slice landed); `count` defaults to
197
- `start + items.length` (a source that does not know its total). The items are
198
- upserted on their way in, so the list draws store items — never copies of the
199
- JSON — and a request sent from a row is read back on that row.
200
-
201
- A row that must follow its **own fields** through a write reads them from the
202
- store rather than from the object it was handed:
160
+ (`start`, `end`, `limit`, `before`, `after`, `around`), and `{ signal }`,
161
+ aborted when the list stops wanting those rows. It returns a range the way a
162
+ `Content-Range` does: **`{ items, start, count }`** — these rows, at this place,
163
+ out of that many. `start` may be omitted when the list asked for one at or after
164
+ `0` (a negative `start` counts back from the end, and the source must then say
165
+ where the slice landed); `count` defaults to `start + items.length`. The items
166
+ are upserted on their way in, so the list draws store items, never copies of the
167
+ JSON.
168
+
169
+ But the list keeps the object it was handed, and an update replaces the item
170
+ object (the store holds values, which is what makes a change detectable). So a
171
+ row that must follow its **own fields** through a write reads them from the
172
+ store:
203
173
 
204
174
  ```jsx
205
175
  const GameCard = ({ id }) => {
@@ -208,41 +178,27 @@ const GameCard = ({ id }) => {
208
178
  };
209
179
  ```
210
180
 
211
- An update replaces the item object (the store holds values, and that is what
212
- makes a change detectable), so the one the list is holding is the one it was
213
- given. Relations are not concerned: they are keyed by owner, and a row reading
181
+ Relations are not concerned: they are keyed by owner, and a row reading
214
182
  `game.candidates` reads the shared collection whatever object carries it.
215
183
 
216
- `GET_RANGE` is a **reader, not an action**. It keeps no response and takes no
217
- place in the rerun graph, which is what makes it usable per slice:
218
-
219
- - the list already holds the slices it received and glues them back together —
220
- a second memory holding one of them would fight it;
221
- - a `POST` invalidating "the collection" would otherwise send every slice ever
222
- loaded back to the network at once.
223
-
224
- What it keeps instead is the collection's composition — which rank holds which
225
- id, and how many ranks there are, per resolved bound params — so a list drawing
226
- this collection again after its screen went away finds it drawn and revalidates
227
- it rather than starting over
228
- ([list_refresh.md](./list_refresh.md#leaving-the-screen-and-coming-back)).
229
-
230
- Membership is what a re-read answers: a verb listed in `rerunOn.GET_RANGE`
231
- (`["POST", "DELETE"]` by default) tells the run the collection moved, and it
232
- asks again for the window it draws, rows staying on screen meanwhile. A write
233
- that moves an item in or out through another verb — a `PATCH` that archives —
234
- is added there, or `reader.invalidate()` is called; a `key` on the run is not the
235
- way ([list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)).
236
-
237
- It reads a collection, so it lives on the resource itself (or on a
238
- `withParams()` of it), not on a relation.
184
+ `GET_RANGE` is a **reader, not an action**: it keeps no response and takes no
185
+ place in the rerun graph — the list already holds the slices it received, and a
186
+ `POST` invalidating "the collection" would otherwise send every slice ever
187
+ loaded back to the network at once. What it keeps is the collection's
188
+ composition, so a list coming back finds it drawn
189
+ ([list_refresh.md](./list_refresh.md#leaving-the-screen-and-coming-back)), and a
190
+ verb listed in `rerunOn.GET_RANGE` makes it ask again for the window it draws,
191
+ rows staying on screen
192
+ ([list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)). It
193
+ reads a collection, so it lives on the resource (or on a `withParams()` of it),
194
+ not on a relation.
239
195
 
240
196
  ## Searching the same collection
241
197
 
242
- A screen showing a collection asks a second question about it soon enough: the
243
- list draws a page, a search box goes looking in the whole base for what the page
244
- does not hold. Same entity, same ids — so it is the same `GET_MANY`, with one
245
- more param:
198
+ A screen showing a collection soon asks a second question about it: the list
199
+ draws a page, a search box goes looking in the whole base for what the page does
200
+ not hold. Same entity, same ids — so it is the same `GET_MANY`, with one more
201
+ param:
246
202
 
247
203
  ```js
248
204
  const USER = resource("user", {
@@ -251,65 +207,53 @@ const USER = resource("user", {
251
207
  });
252
208
 
253
209
  const pageAction = USER.GET_MANY.bindParams({ scope: "seatable" });
254
- const searchAction = USER.GET_MANY.bindParams({
255
- scope: "seatable",
256
- search: searchSignal,
257
- });
210
+ const searchAction = USER.GET_MANY.bindParams(
211
+ computed(() => ({ scope: "seatable", search: searchSignal.value })),
212
+ { debounce: 300 },
213
+ );
258
214
  ```
259
215
 
260
- Not a resource of its own, and not a `createAction` sitting beside the resource.
261
- Those answer with **detached copies**: the user a search found is a different
262
- object from the same user everywhere else on screen — it does not follow an
216
+ Not a resource of its own, and not a `createAction` sitting beside the resource:
217
+ those answer with **detached copies** — the user a search found is a different
218
+ object from the same user everywhere else on screen, it does not follow an
263
219
  update, and a mutation writes to the one it is not. The resource's own
264
220
  `GET_MANY` upserts what comes back like any other read, so one object per user
265
221
  answers both questions.
266
222
 
267
223
  **Two `GET_MANY` on one resource do not compete.** Each bound instance keeps its
268
- own array of ids (none until it has answered) and resolves it against the
269
- shared store, so answering the search leaves the page's list as it was — clear
270
- the search box and every row it had is still there. ["A `*_MANY` callback
271
- replaces the collection
272
- wholesale"](#when-the-backend-answers-a-sub-route-with-the-whole-parent), below,
273
- is about the rows the store keeps for one owner — not about what some other
274
- action reading the same resource is showing.
275
-
276
- It is `bindParams`, not `withParams`: a `withParams()` carves an autorerun scope
277
- (a POST at the root does not reach into it), and a word someone typed is not a
278
- scope — the search must refresh after a write like the list does
224
+ own array of ids (none until it has answered) and resolves it against the shared
225
+ store, so answering the search leaves the page's list as it was — clear the
226
+ search box and every row it had is still there. It is `bindParams`, not
227
+ `withParams`: a word someone typed is not a scope
279
228
  ([`withParams()`](#withparams-a-scope-with-reruns-of-its-own)).
280
229
 
281
- The lifecycle a search wants comes with being an action: `loading` while the
282
- word is in flight, the run before it aborted through its `signal`, typing
283
- debounced by the effect driving it —
284
- `actionRunEffect(searchAction, () => …, { debounce: 300 })` where the app owns
285
- the request, a `stateSignal` when the word is in the address. A word already
286
- asked is answered by the instance that asked it, with no second request
287
- ([actions.md](./actions.md#reading-an-action)).
230
+ The lifecycle a search wants comes with being an action, read with
231
+ `useAsyncData(searchAction, { run: true, loading: true })`
232
+ ([actions.md](./actions.md#params-bindparams-and-calling-the-action)): the typing
233
+ is debounced by the binding, `loading` is up while a word is in flight, and a
234
+ word already asked is answered by the instance that asked it, with no second
235
+ request.
288
236
 
289
237
  What stays the app's is what to KEEP of a search once the word changes — the
290
238
  rows someone picked out of it and expects to still see under the next word.
291
239
  Keep their **ids**, not the objects: an object is a snapshot, and it stops
292
240
  following its row the moment anything writes to it. `RESOURCE.useAllByIds(ids)`
293
- reads those rows as they are now — a kept id turned back into something that
294
- stays fresh, and the ids the store no longer holds simply drop out.
295
- `RESOURCE.useById(id)` is the singular, `RESOURCE.useArray()` the whole store.
296
- None is a hook (a `.map()` over them is fine), and each subscribes the render
297
- that calls it to what it returns: `useById` and `useAllByIds` to the rows they
298
- hand back — a card reading its own row stays still while other rows of the
299
- store are written, and re-renders when its row is — `useArray` to the whole
300
- store. An entry of `useAllByIds` given by a unique key rather than by id can
301
- only be found by reading the whole store, and that read follows it too.
241
+ reads those rows as they are now — the ids the store no longer holds drop out —
242
+ `RESOURCE.useById(id)` one row, `RESOURCE.useArray()` the whole store. None is a
243
+ hook (a `.map()` over them is fine), and each subscribes the render that calls
244
+ it to what it returns: a card reading its own row stays still while other rows
245
+ of the store are written. An entry of `useAllByIds` given by a unique key rather
246
+ than by id is found by reading the whole store, and that read follows it too.
302
247
 
303
248
  A row counts as written only when something in it changed:
304
249
 
305
- - **An answer** — what a callback returns — is compared to the row held in
306
- depth. Parsed JSON makes every object nested in a row a new one; a
307
- revalidation returning the same rows keeps every one of them, and their
308
- readers stay still.
309
- - **A relation** changes its row when the row points at other children. What
310
- happens to the children themselves is followed through the relation:
311
- `game.place.label` read in a render re-renders it when the place is written,
312
- and the game row stays the one it was.
250
+ - **An answer** is compared in depth to the row held: a revalidation returning
251
+ the same rows keeps every one of them — though parsed JSON makes every nested
252
+ object a new one — and their readers stay still.
253
+ - **A relation** changes its row when the row points at other children; the
254
+ children themselves are followed through the relation — `game.place.label`
255
+ read in a render re-renders it when the place is written, and the game row
256
+ stays the one it was.
313
257
  - **A value written with `store.upsert()`** is compared by reference: a new
314
258
  object is a change, whatever it holds.
315
259
 
@@ -330,14 +274,11 @@ PATCH: ({ id, op, ...rest }) => {
330
274
 
331
275
  One verb dispatching on a string gives up everything the store does for you:
332
276
  per-operation action state (loading/error per button), per-relation autorerun,
333
- and a child collection that other components can read.
334
-
335
- The same smell wears a second coat: a verb on one relation carrying, in its
336
- params, an id from a **different** relation — a `group_id` sent to the game ×
337
- user shares, which the server expands into people. That is two relations
338
- spelled as one, and the one the gesture is actually about (game × group) is the
339
- one that ends up with no rows, no action state, and nothing for its row on
340
- screen to read.
277
+ and a child collection that other components can read. The same smell wears a
278
+ second coat: a verb on one relation carrying an id from a **different** relation
279
+ — a `group_id` sent to the game × user shares, which the server expands into
280
+ people — is two relations spelled as one, and the one the gesture is about
281
+ (game × group) ends up with no rows and nothing for its row on screen to read.
341
282
 
342
283
  | Situation | Use |
343
284
  | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
@@ -350,173 +291,53 @@ screen to read.
350
291
  Singular vs plural is about the relation, not the verb: `.one`/`.scopedOne` for a
351
292
  single sub-object, `.many`/`.scopedMany` for a collection.
352
293
 
353
- And the table has a negative worth naming, because the workaround looks fine in
354
- a demo: **a write whose effect the response does not carry is a modelling bug,
355
- not a case for the client to remember.** After the write, some row on screen
356
- will claim "this happened" — and it must read that from the store, which only
357
- holds what responses carried. When the relation actually written has no row
358
- anywhere (the response answered with a different entity than the one the
359
- gesture was about), whatever the screen memorizes instead lives on the client
360
- and **dies with the session**: reload, and the row confidently claims the write
361
- never happened. The fix is on the shape — give that relation its own
362
- relationship method and a callback returning what was written, even when the
363
- backend must grow a field for it — never a flag next to the button.
294
+ **A write whose effect the response does not carry is a modelling bug, not a
295
+ case for the client to remember.** The row on screen claiming "this happened"
296
+ reads the store, which holds only what responses carried; whatever the screen
297
+ memorizes instead lives on the client and dies with the session — reload, and
298
+ the row confidently claims the write never happened. The fix is on the shape —
299
+ give that relation its own relationship method and a callback returning what was
300
+ written, even when the backend must grow a field for it — never a flag next to
301
+ the button.
364
302
 
365
303
  ## Callback return contracts
366
304
 
367
- These are not guessable — each relationship method has its own shape.
368
-
369
- ### `.one(propertyName, childResource, { GET, PUT, DELETE })`
370
-
371
- `GET` and `PUT` return the **parent** object with the child nested inside;
372
- `DELETE` returns the parent id, or `{ id }`:
373
-
374
- ```js
375
- const USER_SESSION = USER.one("session", SESSION, {
376
- GET: async ({ id }) => {
377
- const session = await fetchJson(`/users/${id}/session`);
378
- return { id, session }; // { id, session: { id: 10, token: "abc" } }
379
- },
380
- DELETE: async ({ id }) => {
381
- await fetchJson(`/users/${id}/session`, { method: "DELETE" });
382
- return id; // property becomes null
383
- },
384
- });
385
- ```
386
-
387
- `session: null` in the returned parent means "no relation". A parent GET/POST
388
- that embeds the child inline works too — the property setter upserts it.
389
-
390
- ### `.many(propertyName, childResource, restCallbacks)`
391
-
392
- GET_MANY returns the **parent** object with the array nested inside; DELETE
393
- returns the pair of ids:
394
-
395
- ```js
396
- const USER_FRIENDS = USER.many("friends", USER, {
397
- GET_MANY: async ({ id }) => {
398
- const friends = await fetchJson(`/users/${id}/friends`);
399
- return { id, friends }; // { id, friends: [{ id: 2 }, { id: 3 }] }
400
- },
401
- POST: async ({ id, friendId }) =>
402
- fetchJson(`/users/${id}/friends`, { method: "POST", body: { friendId } }),
403
- DELETE: async ({ id, friendId }) => {
404
- await fetchJson(`/users/${id}/friends/${friendId}`, { method: "DELETE" });
405
- return [id, friendId]; // [parentId, childId]
406
- },
407
- DELETE_MANY: async ({ id, friendIds }) => {
408
- await fetchJson(`/users/${id}/friends`, {
409
- method: "DELETE",
410
- body: { friendIds },
411
- });
412
- return [id, friendIds]; // [parentId, [childId, …]]
413
- },
414
- });
415
- ```
416
-
417
- The singular POST/PUT/PATCH callbacks return the **child** object. It is upserted
418
- into the child store, but it does not join the parent's array on its own: the
419
- array is the backend's, and only a GET_MANY refresh rewrites it.
420
-
421
- ### `.scopedOne(propertyName, { idKey, GET, POST, PUT, PATCH, DELETE })`
422
-
423
- Every callback returns `[ownerId, props | null]`:
424
-
425
- ```js
426
- const USER_PROFILE = USER.scopedOne("profile", {
427
- GET: async ({ id }) => [id, await fetchJson(`/users/${id}/profile`)],
428
- PATCH: async ({ id, ...props }) => [
429
- id,
430
- await fetchJson(`/users/${id}/profile`, { method: "PATCH", body: props }),
431
- ],
432
- DELETE: async ({ id }) => {
433
- await fetchJson(`/users/${id}/profile`, { method: "DELETE" });
434
- return [id, null];
435
- },
436
- });
437
- ```
438
-
439
- The property is `null` until a callback provides data. `ownerId` may be
440
- `{ [uniqueKey]: value }` when the owner is known by an alternate key rather than
441
- its id.
442
-
443
- ### `.scopedMany(propertyName, { idKey, GET, GET_MANY, POST, PUT, PATCH, DELETE, … })`
444
-
445
- Every callback returns `[ownerId, ...rest]`:
446
-
447
- ```js
448
- const GAME_CANDIDATES = GAME.scopedMany("candidates", {
449
- idKey: "user_id",
450
- GET_MANY: async ({ id }) => [id, await fetchJson(`/games/${id}/candidates`)],
451
- POST: async ({ id, ...body }) => [
452
- id,
453
- await fetchJson(`/games/${id}/candidates`, { method: "POST", body }),
454
- ],
455
- PUT: async ({ id, oldUserId, ...props }) => [id, oldUserId, props], // id rename
456
- DELETE: async ({ id, user_id }) => {
457
- await fetchJson(`/games/${id}/candidates/${user_id}`, { method: "DELETE" });
458
- return [id, user_id];
459
- },
460
- });
461
- ```
462
-
463
- | Callback | Returns |
464
- | ------------------ | ------------------------------------------------------ |
465
- | GET / POST / PATCH | `[ownerId, props]` |
466
- | PUT (id rename) | `[ownerId, oldId, props]` |
467
- | DELETE | `[ownerId, childId]` |
468
- | any `*_MANY` | `[ownerId, itemArray]` — replaces the whole collection |
469
- | DELETE_MANY | `[ownerId, [childId, …]]` |
470
-
471
- `idKey` names the child's own key inside its owner (`user_id` above); it defaults
472
- to `"id"`.
473
-
474
- ### A relation declared on a scoped child
475
-
476
- A scoped child takes relations of its own — a column pointing at a shared data
477
- type:
478
-
479
- ```js
480
- const TABLE_COLUMNS = TABLE.scopedMany("columns", {
481
- idKey: "name",
482
- GET_MANY: async ({ id }) => [id, await fetchJson(`/tables/${id}/columns`)],
483
- // [id, [{ name: "email", dataType: { id: 2, name: "varchar" } }, …]]
484
- PATCH: async ({ id, name, ...props }) => [
485
- id,
486
- await fetchJson(`/tables/${id}/columns/${name}`, {
487
- method: "PATCH",
488
- body: props,
489
- }),
490
- ],
491
- });
492
- TABLE_COLUMNS.one("dataType", DATA_TYPE);
493
- ```
494
-
495
- Every column gets a reactive `dataType` read from the `DATA_TYPE` store, fed by
496
- what the scoped child's callbacks embed. The relation itself takes none of the
497
- callbacks whose result names the column — `.one()`'s `GET`/`PUT`/`DELETE`,
498
- `.many()`'s `GET_MANY`/`DELETE`/`DELETE_MANY`, and every callback of a
499
- `.scopedOne()`/`.scopedMany()`, whose `ownerId` would be the column: they name
500
- it by its `name` alone, and a column exists only inside its table — two tables
501
- can each have an `email`. Declaring one throws. A column's data type is read and
502
- changed through `TABLE_COLUMNS`, whose results say which table
503
- (`[id, { name, dataType }]`); the fields of a data type itself, through
504
- `DATA_TYPE`.
505
-
506
- A `.scopedOne()`/`.scopedMany()` chained on a scoped child works the same way,
507
- and keeps one value per column: `TABLE_COLUMNS.scopedMany("constraints")` gives
508
- `users.email` and `admins.email` a collection each, fed by the `constraints`
509
- embedded in each table's columns.
305
+ These are not guessable — each relationship method has its own shape, written
306
+ out with an example in its JSDoc:
307
+
308
+ - **`.one(propertyName, CHILD, { GET, PUT, DELETE })`** — `GET`/`PUT` return
309
+ the **parent** with the child nested inside (`{ id, session: { id: 10 } }`,
310
+ `session: null` for none); `DELETE` the parent id, or `{ id }`. A parent
311
+ `GET`/`POST` that embeds the child inline works too.
312
+ - **`.many(propertyName, CHILD, callbacks)`** — `GET_MANY` returns the
313
+ **parent** with the array nested inside; `GET`/`POST`/`PUT`/`PATCH` the
314
+ **child**, upserted into the child store but not joining the parent's array,
315
+ which only a `GET_MANY` rewrites; `DELETE` `[parentId, childId]`,
316
+ `DELETE_MANY` `[parentId, [childId, …]]`.
317
+ - **`.scopedOne(propertyName, callbacks)`** — every callback returns
318
+ `[ownerId, props | null]`; the property is `null` until one does.
319
+ - **`.scopedMany(propertyName, { idKey, … })`** — `[ownerId, props]`; `PUT` (an
320
+ id rename) `[ownerId, oldId, props]`; `DELETE` `[ownerId, childId]`; any
321
+ `*_MANY` `[ownerId, itemArray]`, which replaces the whole collection;
322
+ `DELETE_MANY` `[ownerId, [childId, …]]`. `idKey` names the child's own key
323
+ inside its owner (`"id"` by default).
324
+
325
+ `ownerId` may be `{ [uniqueKey]: value }` when the owner is known by an
326
+ alternate key. A scoped child takes relations of its own —
327
+ `TABLE_COLUMNS.one("dataType", DATA_TYPE)`, fed by what the columns' callbacks
328
+ embed — but none of the callbacks whose result names the child by itself
329
+ (`.one()`'s `GET`/`PUT`/`DELETE`, `.many()`'s `GET_MANY`/`DELETE`/`DELETE_MANY`,
330
+ every scoped callback): a column exists only inside its table, and two tables
331
+ can each have an `email`. Declaring one throws.
510
332
 
511
333
  ## When the backend answers a sub-route with the whole parent
512
334
 
513
335
  This is the common REST shape, and it is the reason `op` dispatch feels
514
336
  attractive: a full-parent response absorbs into a parent `PATCH` with no
515
337
  thinking. Model the relation anyway and absorb the response in the callback —
516
- `resource()` gives a plural callback for exactly this: **any `*_MANY` callback
517
- replaces the collection wholesale** — the rows the store keeps for that owner,
518
- not what some other action reading the same resource is showing (see
519
- [Searching the same collection](#searching-the-same-collection)).
338
+ **any `*_MANY` callback replaces the collection wholesale**: the rows the store
339
+ keeps for that owner, not what some other action reading the same resource is
340
+ showing (see [Searching the same collection](#searching-the-same-collection)).
520
341
 
521
342
  ```js
522
343
  const GAME_CANDIDATES = GAME.scopedMany("candidates", {
@@ -537,22 +358,22 @@ const GAME_CANDIDATES = GAME.scopedMany("candidates", {
537
358
  });
538
359
  ```
539
360
 
540
- For `.many()`, the equivalent is its GET_MANY callback, which already takes the
541
- parent object with the array nested inside — return the response untouched.
542
-
543
- Deletion is the exception: `DELETE`/`DELETE_MANY` drop by id rather than replace,
544
- so return `[ownerId, deletedId]` using the id you already have in the params,
545
- and ignore the full parent the backend sent back.
361
+ For `.many()`, the equivalent is its `GET_MANY` callback, which already takes
362
+ the parent object with the array nested inside — return the response untouched.
363
+ Deletion is the exception: `DELETE`/`DELETE_MANY` drop by id rather than
364
+ replace, so return `[ownerId, deletedId]` from the id you already have in the
365
+ params, and ignore the full parent the backend sent back.
546
366
 
547
- Fields of the parent that come back in such a response (a `status`, a `score`)
548
- are not applied by the relation callback — it only writes the relation. If the
549
- parent's own fields change too, either let the parent GET rerun (see below) or
550
- `store.upsert()` the parent explicitly.
367
+ The parent's own fields in such a response (a `status`, a `score`) are applied
368
+ only where the callback returns the parent: `.one()`'s `GET`/`PUT` and
369
+ `.many()`'s `GET_MANY` upsert it whole, fields included. A scoped relation's
370
+ callback writes the relation alone — if the parent's own fields change too,
371
+ either let the parent `GET` rerun (below) or `store.upsert()` the parent
372
+ explicitly.
551
373
 
552
374
  ## Relations and autorerun
553
375
 
554
- Relationship mutations do **not** invalidate their parent by default. The exact
555
- rules, verified by `src/state/rest/tests/resource_graph_parent_rerun.test.js`:
376
+ Relationship mutations do **not** invalidate their parent by default. The rules:
556
377
 
557
378
  - `.scopedMany` child **POST** reruns the owner's singular `GET`, whether it
558
379
  goes through the child or through a `withParams()` scope of it — but only when
@@ -563,21 +384,21 @@ rules, verified by `src/state/rest/tests/resource_graph_parent_rerun.test.js`:
563
384
  already carries the updated child.
564
385
  - `.scopedOne` mutations rerun nothing, ever — the result is the new value.
565
386
  - `.one` / `.many` children live in an independent store; mutating them never
566
- reruns the parent. Declare it explicitly with `dependencies` if you need it
567
- (see [`dependencies`](#dependencies-rerun-after-another-resource-writes)).
387
+ reruns the parent. Declare it with
388
+ [`dependencies`](#dependencies-rerun-after-another-resource-writes) if you
389
+ need it.
568
390
  - Within a relationship resource, the usual defaults still apply: its own
569
391
  `GET_MANY` reruns after its own `POST`. A `.one`/`.many` `GET` is reset (not
570
392
  rerun) by its own `DELETE`, as a root `GET` is; a scoped `GET` is not reset —
571
- its data simply resolves to nothing once the child is dropped. Override per
572
- relation with `rerunOn`/`dependencies`, which every relationship method
573
- accepts; what a relation leaves out is its parent resource's (`rerunOn` key
574
- by key).
393
+ its data resolves to nothing once the child is dropped. Every relationship
394
+ method accepts `rerunOn`/`dependencies`; what a relation leaves out is its
395
+ parent resource's (`rerunOn` key by key).
575
396
 
576
397
  Splitting a sub-resource out of a parent `PATCH` therefore changes the refresh
577
398
  graph: a parent field the parent's own response kept fresh is, once the relation
578
399
  is a resource of its own, refreshed only by these rules. When a parent field
579
- genuinely depends on a child mutation, say so with `dependencies` rather than
580
- relying on a rerun that will not happen.
400
+ depends on a child mutation, say so with `dependencies` rather than relying on a
401
+ rerun that will not happen.
581
402
 
582
403
  ## `withParams()`: a scope with reruns of its own
583
404
 
@@ -589,8 +410,8 @@ resource read under several fixed questions at once — the admins and the guest
589
410
  of one `USER`, each list on its own screen. A guest created should not send the
590
411
  admin list back to the network.
591
412
 
592
- `withParams()` carves that out. It binds params into every action of the
593
- resource and gives the result a rerun scope of its own:
413
+ `withParams()` binds params into every action of the resource and gives the
414
+ result a rerun scope of its own:
594
415
 
595
416
  ```js
596
417
  const ADMIN = USER.withParams({ role: "admin" });
@@ -603,29 +424,29 @@ await ADMIN.POST({ name: "Bob" }); // reruns ADMIN.GET_MANY, and nothing else
603
424
 
604
425
  The isolation is complete, in both directions: a write on `ADMIN` reaches no
605
426
  read of `GUEST` and no read of `USER` itself, and a write on `USER` reaches no
606
- read of either scope (held by cases 5 and 6 of
607
- `src/state/rest/tests/resource_graph_lifecycle.test.js`). What the scopes still
608
- share is the **store**: an item updated through one is the same object in the
609
- others, so the fields of a row change everywhere without a request — only the
610
- membership of each list is a question its own scope answers.
611
-
612
- It scopes a relation as well as a resource, and the scope's actions are the
613
- relation's: `TABLE_COLUMNS.withParams({ withTypes: true }).GET_MANY` keeps the
614
- `[ownerId, items]` contract of `.scopedMany()` and replaces that table's
615
- columns.
616
-
617
- `withParams()` chains, merging the params —
618
- `USER.withParams({ role: "admin" }).withParams({ gender: "male" })` is
619
- `USER.withParams({ role: "admin", gender: "male" })`. Its second argument takes
620
- `rerunOn` and `dependencies` for that scope; what it leaves out is the
621
- resource's — a `rerunOn` key by key, so `{ rerunOn: { GET: ["PATCH"] } }` keeps
622
- the resource's `GET_MANY` and `GET_RANGE`. Empty params throw: a scope has to be
623
- about something.
624
-
625
- It is not the tool for a param the user types. A search word bound with
427
+ read of either scope. What the scopes still share is the **store**: an item
428
+ updated through one is the same object in the others, so the fields of a row
429
+ change everywhere without a request — only the membership of each list is a
430
+ question its own scope answers.
431
+
432
+ **A scope is the object `withParams()` returned, not its params.** Each call
433
+ builds one of its own: two `USER.withParams({ role: "admin" })` written in two
434
+ files do not reach each other, and neither do
435
+ `USER.withParams({ role: "admin" }).withParams({ gender: "male" })` and
436
+ `USER.withParams({ role: "admin", gender: "male" })`, though they bind the same
437
+ params. Declare each scope once, and import it.
438
+
439
+ It scopes a relation as well, keeping the relation's return contract
440
+ (`TABLE_COLUMNS.withParams({ withTypes: true }).GET_MANY` replaces that table's
441
+ columns). Its second argument takes `rerunOn` and `dependencies` for that scope;
442
+ what it leaves out is the resource's — a `rerunOn` key by key, so
443
+ `{ rerunOn: { GET: ["PATCH"] } }` keeps the resource's `GET_MANY` and
444
+ `GET_RANGE`. Empty params throw: a scope has to be about something.
445
+
446
+ It is not the tool for a param the user types: a search word bound with
626
447
  `withParams()` would keep its results from refreshing after a write on the
627
- resource; a word is a `bindParams` on the resource's own `GET_MANY`, which stays
628
- in the rerun graph ([Searching the same collection](#searching-the-same-collection)).
448
+ resource, where a `bindParams` on the resource's own `GET_MANY` stays in the
449
+ rerun graph ([Searching the same collection](#searching-the-same-collection)).
629
450
  `withParams()` is for a scope the code fixes once — a role, a status, a tenant.
630
451
 
631
452
  ### `dependencies`: rerun after another resource writes
@@ -642,12 +463,13 @@ const ROLE_WITH_OWNERS = ROLE.withParams(
642
463
  ```
643
464
 
644
465
  Any write on a listed resource (`POST`, `PUT`, `PATCH`, `DELETE`) reruns the
645
- completed `GET` and `GET_MANY` of the resource that declared it. A dependency is
646
- a facade: `DATABASE` and `DATABASE.withParams(…)` are two of them, and only
647
- writes on the one listed count. Nothing goes the other way, and nothing is
648
- inferred — the relationship methods have rules of their own
649
- ([above](#relations-and-autorerun)), and a relation that really is a sub-route
650
- of the parent is modelled with them, never declared as a dependency.
466
+ completed `GET` and `GET_MANY` of the resource that declared it, and invalidates
467
+ its `GET_RANGE` readers when the verb is in its `rerunOn.GET_RANGE`. A
468
+ dependency is a facade: `DATABASE` and `DATABASE.withParams(…)` are two of them,
469
+ and only writes on the one listed count. Nothing goes the other way, and nothing
470
+ is inferred — a relation that really is a sub-route of the parent is modelled
471
+ with the relationship methods ([above](#relations-and-autorerun)), never
472
+ declared as a dependency.
651
473
 
652
474
  ## A function calling the verb, or the instance
653
475
 
@@ -665,30 +487,32 @@ Both forms are real, and what separates them is narrower than it looks:
665
487
  </Button>
666
488
  ```
667
489
 
668
- The function gives up almost nothing. Either way the button is busy while the
669
- write runs, shows the error if it fails and goes back to what it held; either way
670
- what runs inside is the same resource run, so the store is updated and the
671
- actions this mutation invalidates rerun the same. The failure travels too — the
672
- call fails, so the action around it fails
673
- ([error_handling.md](./error_handling.md#what-a-failing-action-does)).
674
-
675
- What `bindParams` adds, and it adds only this, is **identity**: everyone binding
676
- the same params holds one instance, so a run started in one place is visible from
677
- another. Reach for it when something else on screen has to see this very run — a
678
- user row in a side explorer going loading while the main page renames that user,
679
- two affordances for the same delete that must both go busy at once. Nothing else
680
- in the stack answers that question: the store holds the data, not who is
681
- currently writing it.
682
-
683
- Either way, what to READ afterwards is the store, or the instance that ran —
684
- never the verb: a run made with params lands on the child those params made,
685
- so `GAME_CANDIDATES.POST.dataSignal` is empty whichever form ran it. The verb's
686
- own signals hold only a run made on the verb itself, with no params (see
687
- [actions.md](./actions.md#the-answer-is-kept-on-the-instance-that-ran)).
688
-
689
- Where it does not pay, and most call sites are here: one affordance, in one
690
- place, that nobody else watches. A `bindParams({ id })` written per row builds an
691
- identity nothing reads, and a plain function says the gesture more directly.
490
+ Either way the button is busy while the write runs and shows the error if it
491
+ fails; either way what runs inside is the same resource run, so the store is
492
+ updated, the actions this mutation invalidates rerun the same, and a failure
493
+ fails the action around it
494
+ ([error_handling.md](./error_handling.md#what-a-failing-action-does)). But a
495
+ function is wrapped into an action of its own, one with no verb, and the
496
+ instance carries two things that wrapper does not:
497
+
498
+ - **identity.** Everyone binding the same params holds one instance, so a run
499
+ started in one place is visible from another. Reach for it when something else
500
+ on screen has to see this very run — a user row in a side explorer going
501
+ loading while the main page renames that user, two affordances for the same
502
+ delete that must both go busy at once. Nothing else in the stack answers that
503
+ question: the store holds the data, not who is currently writing it.
504
+ - **the verb** (`meta.verb`). Under a [network policy](./network_policy.md), a
505
+ control holding a write verb's instance — or sitting in a `<Form>` holding one
506
+ — is read-only before the press and says why. The function form is not seen:
507
+ the press is accepted, and the write fails after it with a
508
+ `NetworkPolicyError`.
509
+
510
+ Where neither pays — one affordance, in one place, that nobody else watches, and
511
+ no network policy to hold the write — a `bindParams({ id })` written per row
512
+ builds an identity nothing reads, and a plain function says the gesture more
513
+ directly. Either way, what to READ afterwards is the store, or the instance that
514
+ ran — never the verb, whose signals hold only a run made on the verb itself, with
515
+ no params ([actions.md](./actions.md#the-answer-is-kept-on-the-instance-that-ran)).
692
516
 
693
517
  What genuinely loses what a resource gives is neither of those two, but a
694
518
  callback that goes around the verb:
@@ -702,10 +526,6 @@ callback that goes around the verb:
702
526
  </Button>
703
527
  ```
704
528
 
705
- Away from a component, where the run is a gesture and not something to render,
706
- an action is callable: `GAME.DELETE({ id })` is
707
- `GAME.DELETE.bindParams({ id }).rerun()`.
708
-
709
529
  ## See also
710
530
 
711
531
  - [list_refresh.md](./list_refresh.md) — what re-runs after a write, and what