foldkit-remote 0.7.0 → 0.8.0

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
@@ -1,50 +1,109 @@
1
1
  # `foldkit-remote`
2
2
 
3
- Server-owned data, cached **inside the Foldkit Model**.
3
+ Server data, cached inside the Foldkit Model. A screen says what it needs;
4
+ the view gets what the cache knows.
4
5
 
5
- A feature declares the server facts it needs. `foldkit-remote` compares those
6
- requirements with what the Model already knows, fetches only what is missing or
7
- stale, normalizes the result so every consumer shares one copy, and returns new
8
- facts to the application as ordinary Messages.
6
+ ```ts
7
+ const ProjectPage = App.surface('ProjectPage', {
8
+ params: { projectId: Schema.String },
9
+ model: ({ params }) => ({ project: Data.get(ProjectSummary, params.projectId) }),
10
+ })
9
11
 
10
- The central rule is:
12
+ RemoteData.render(project, {
13
+ loading: () => ProjectSkeleton(),
14
+ notFound: () => NoSuchProject(),
15
+ failed: error => ErrorView(error),
16
+ data: project => ProjectView(project),
17
+ })
18
+ ```
11
19
 
12
- > **A Remote Projection does not fetch. It declares what server-owned facts a
13
- > consumer requires. I/O happens outside render, and its results reduce back into
14
- > the Model.**
20
+ No fetch call, no cache object, no loading flag. While the page is active,
21
+ Remote fetches only the fields the Model lacks, stores each entity once, and
22
+ hands the view one of six states rather than an `undefined`. Every result is a
23
+ Message through one reducer, so a screen replays from a recorded Model.
15
24
 
16
- There is no hidden mutable cache beside the application. A read result, query
17
- page, mutation result, live event, hydration, and retention change all become
18
- Remote Messages and move the same Model through one pure reducer.
25
+ Remote is for facts the **server owns**. An edit the client authors, which
26
+ must survive being offline, belongs to [`foldkit-sync`](../sync).
19
27
 
20
- Use Remote when the **server owns the truth** and the client needs a normalized,
21
- disposable view of it. If an edit is client-authored and must survive offline,
22
- restart, or network failure until it converges, that belongs to
23
- [`foldkit-sync`](../sync) and [`foldkit-durable`](../durable), not Remote.
28
+ ## If you know TanStack Query, TanStack DB or fate
29
+
30
+ The same job, done from the other side of the Model:
31
+
32
+ | TanStack Query | `foldkit-remote` |
33
+ | --- | --- |
34
+ | `useQuery({ queryKey, queryFn })` in a component | `Data.get(Selection, id)` in a Surface; the active Surface fetches |
35
+ | a `queryKey` you design | the entity, its id and the fields selected; there is no key to design |
36
+ | one cache entry per key | one entity per id, shared by every selection of it |
37
+ | `staleTime`, `refetchOnWindowFocus` | `RemotePolicy`, `Data.refresh` from `update` |
38
+ | `gcTime` | retention: what no active Surface reaches is collected |
39
+ | `onMutate` + `setQueryData` | `optimistic: [Remote.patch(...)]`, a layer the settlement removes |
40
+ | `invalidateQueries` | not needed for overlap: a result updates the one copy |
41
+ | `isPending`, `isError`, `data` | `RemoteData`: six states, one exhaustive fold |
42
+ | a `QueryClient` beside your state | `Remote.Model`, a field of your Model, changed only by Messages |
43
+
44
+ Two differences change how you build. **There are no keys.** A TanStack cache
45
+ is a map from keys you design to results you shape, so two components that
46
+ want overlapping data either share a key and over-fetch, or hold two copies
47
+ and invalidate by hand. Remote stores fields by entity and id, and a Surface
48
+ declares which fields it selects, so overlap is the normal case and a
49
+ mutation's result is seen everywhere at once. **The cache is Model.** A
50
+ `QueryClient` lives beside your state and is updated by effects; `Remote.Model`
51
+ is a field of your Model, changed by the same reducer as everything else, so a
52
+ screen is a function of the Model, replays from a recording, and is tested
53
+ without a network.
54
+
55
+ What Remote does not have: a component hook (it has Surfaces and Foldkit
56
+ Subscriptions), a devtools panel of its own (`Data.inspect` and `Data.why` are
57
+ the data one would show), and retries on failure (a failed read stays failed
58
+ until something asks again; see [When a read fails](#when-a-read-fails)).
59
+
60
+ ### And TanStack DB, and fate
61
+
62
+ [TanStack DB](https://tanstack.com/db) is a local database: collections
63
+ populated eagerly (or on demand) from a REST API or a sync engine, live queries
64
+ that update incrementally, and optimistic transactions rolled back on failure.
65
+ Its unit is the collection; a screen queries what is already local. Remote's
66
+ unit is the field: a screen declares which fields of which entities it reads,
67
+ and only those are fetched, so there is no collection to load first and a page
68
+ carries what it shows. Remote has no live query engine: `Data.filtered` and
69
+ `Remote.matching` judge the rows a list already holds, and a new list is a
70
+ server query. Where TanStack DB syncs client-authored writes through a sync
71
+ engine, that job here belongs to [`foldkit-sync`](../sync), and Remote stays
72
+ the disposable cache of what the server owns.
73
+
74
+ [fate](https://fate.technology) is the nearest cousin: components declare
75
+ views of exactly the data they need, composed into one request per screen,
76
+ with a normalized cache that masks what a component did not ask for, and
77
+ actions with optimistic rollback. Remote's Selections are that strictness, and
78
+ its store is that cache. The differences are where it lives and what runs it:
79
+ fate is a React data client, driven by Suspense and Actions; Remote is a
80
+ Submodel of a Foldkit Model, driven by which Surfaces are active, with no view
81
+ framework in it.
24
82
 
25
83
  ## Find what you need
26
84
 
27
- This README is long because it covers everything Remote does. Read the
28
- [sixty-second example](#sixty-seconds-one-entity-one-surface) first, then jump
29
- to what you are doing:
85
+ Read the [first run](#the-first-run-one-entity-one-surface) once, then jump to
86
+ what you are doing:
30
87
 
31
88
  | Task | Start here |
32
89
  | --- | --- |
33
- | See it work before there is a server | [A backend held in memory](../remote-server/README.md#a-backend-held-in-memory) |
34
- | Understand `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
35
- | Find out why a read stays `Initial` | [`Data.why`](#remotedata-what-does-the-model-know-right-now) |
90
+ | See it work before there is a server | [Step 5](#5-provide-a-client) |
91
+ | A read stays `Initial` | [Why is it still `Initial`?](#why-is-it-still-initial) |
92
+ | A list shows old rows | [Why is it stale?](#why-is-it-stale) |
93
+ | A saved change showed, then vanished | [Why did my change disappear?](#why-did-my-change-disappear) |
94
+ | Data was gone when I came back | [Why is it gone?](#why-is-it-gone-after-navigating-away) |
36
95
  | Show a failed read, and let the user retry | [When a read fails](#when-a-read-fails) |
37
96
  | Load an ordered, paged list | [Queries and pagination](#queries-and-pagination) |
38
97
  | Filter a list already on screen | [Filtering a loaded list](#filtering-a-loaded-list-without-asking-the-server) |
39
98
  | Save, and show the result before the server answers | [Mutations](#mutations-and-optimistic-state) |
40
99
  | Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
41
- | Keep data across a reload, or seed it from SSR | [Hydration](#persistence-and-hydration) |
42
- | Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
100
+ | Keep data across a reload | [Hydration](#persistence-and-hydration) |
101
+ | Hand a server render's data to the browser | [Server rendering](#server-rendering-remoteresume) |
43
102
  | Answer the requests on the server | [How the server packages fit](#how-the-server-packages-fit) |
44
103
 
45
104
  ## Which state belongs here?
46
105
 
47
- A useful rule across Foldkit Plus is **one owner per datum**:
106
+ One owner per datum:
48
107
 
49
108
  | State | Owner |
50
109
  | --- | --- |
@@ -53,23 +112,13 @@ A useful rule across Foldkit Plus is **one owner per datum**:
53
112
  | Client-authored state that must survive offline and converge | `foldkit-sync` |
54
113
  | Local state represented in the URL or another store | the Model, observed by `foldkit-mirror` |
55
114
 
56
- The distinction between Remote and Sync is especially important:
57
-
58
115
  ```text
59
- Remote
60
- server owns the fact
61
- cache is disposable
62
- refetch is recovery
63
-
64
- Sync
65
- client owns the edit
66
- intent must survive offline/restart
67
- replay + reconciliation is recovery
116
+ Remote Sync
117
+ server owns the fact client owns the edit
118
+ cache is disposable intent must survive offline/restart
119
+ refetch is recovery replay + reconciliation is recovery
68
120
  ```
69
121
 
70
- A Remote mutation is therefore an immediate request against server-owned data.
71
- Its result updates the cache. It is **not** a durable local intent queue.
72
-
73
122
  A Surface may project all of these owners at once. Observation does not transfer
74
123
  ownership.
75
124
 
@@ -87,9 +136,11 @@ requirements with the cache, then requests missing or stale fields. The
87
136
  result returns as a Message; only reducing it changes what the next read sees.
88
137
  Rendering the same Projection twice starts no work by itself.
89
138
 
90
- ## Four pieces to remember
139
+ > **A Remote Projection does not fetch. It declares what server-owned facts a
140
+ > consumer requires. I/O happens outside render, and its results reduce back into
141
+ > the Model.**
91
142
 
92
- Most application code only needs four roles:
143
+ Four roles carry the loop, and most application code needs no more:
93
144
 
94
145
  | Piece | Responsibility |
95
146
  | --- | --- |
@@ -98,7 +149,11 @@ Most application code only needs four roles:
98
149
  | `Data.reduce` | reduce returned Remote Messages into the application's `Remote.Model` |
99
150
  | `RemoteClient` | perform the actual read/query/mutate/live I/O |
100
151
 
101
- Everything else builds on those four pieces.
152
+ One rule keeps the cache coherent: **`Data.reduce` is the only writer.** Local
153
+ application state changes with `modifyFields` inside `update`, like anywhere
154
+ else; a server fact only enters the cache as a Remote Message. Never install
155
+ cache state with a ref `set`: requirements, staleness and tombstones stay
156
+ consistent only because every fact arrives through the reducer.
102
157
 
103
158
  ## Install
104
159
 
@@ -109,9 +164,12 @@ pnpm add foldkit-remote foldkit-entity
109
164
  `foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
110
165
  package. The server-side interpreter is [`foldkit-remote-server`](../remote-server).
111
166
 
112
- ## Sixty seconds: one entity, one Surface
167
+ ## The first run: one entity, one Surface
168
+
169
+ Six steps take a project's name from a server to a screen. Each says what it
170
+ does, and what it deliberately does not.
113
171
 
114
- Start with one server entity and one selection:
172
+ ### 1. Declare an entity and what this screen needs of it
115
173
 
116
174
  ```ts
117
175
  import { Schema } from 'effect'
@@ -137,9 +195,10 @@ The entity comes from [`foldkit-entity`](../entity), which declares a domain
137
195
  without Remote in it, so the same declaration can serve the server's database
138
196
  binding and your forms too. It is also what gives an entity
139
197
  [relations](#entities-declared-with-foldkit-entity), derived members, and a
140
- [query body](#queries-and-pagination) to point at.
198
+ [query body](#queries-and-pagination) to point at. Nothing here touches a
199
+ network.
141
200
 
142
- Embed Remote's Submodel in the application and bind the domain to that field:
201
+ ### 2. Give Remote a place in the Model, and route its Messages
143
202
 
144
203
  ```ts
145
204
  import { defineMessageUnion } from 'foldkit/message'
@@ -162,19 +221,10 @@ const Model = Schema.Struct({
162
221
  type Model = typeof Model.Type
163
222
 
164
223
  const Message = defineMessageUnion({
165
- ...Remote.messages,
224
+ GotRemoteMessage: { message: Remote.Message },
166
225
  })
167
226
  type Message = typeof Message.Type
168
227
 
169
- function update(
170
- model: Model,
171
- message: Message,
172
- ): Update.Return<Model, Message, RemoteClient> {
173
- // Remote owns the `remote` Submodel, so all Remote Messages go through its reducer.
174
- if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
175
- return { model }
176
- }
177
-
178
228
  const App = Surface.application({
179
229
  Model,
180
230
  Message,
@@ -189,13 +239,30 @@ const Data = Remote.make({
189
239
  model: App.model.remote,
190
240
  entities: [Project],
191
241
  })
242
+
243
+ // Remote under one variant of the union, the shape Foldkit gives a Submodel.
244
+ const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
245
+
246
+ function update(
247
+ model: Model,
248
+ message: Message,
249
+ ): Update.Return<Model, Message, RemoteClient> {
250
+ return Message.match(message, {
251
+ // Remote owns the `remote` Submodel: everything it says goes through its reducer.
252
+ GotRemoteMessage: ({ message }) => foldData(model, message),
253
+ })
254
+ }
192
255
  ```
193
256
 
194
- `Remote.Model` is not a second application store. It is a Submodel **inside** the
195
- application Model. `Data` is the bound API for this domain: it knows where that
196
- Submodel lives and which descriptors this application registered.
257
+ `Remote.Model` is not a second store. It is a Submodel **inside** the
258
+ application Model, and `Data` is the bound API for this domain: it knows where
259
+ that Submodel lives and which entities this application registered. `update`
260
+ matches the union exhaustively; Remote's own Messages arrive inside
261
+ `GotRemoteMessage` and go straight to Remote's reducer. (There is a shorter
262
+ form for an `update` that only reduces; see
263
+ [Routing Remote's Messages](#routing-remotes-messages).)
197
264
 
198
- Now declare what a feature needs:
265
+ ### 3. Declare what the screen reads
199
266
 
200
267
  ```ts
201
268
  const ProjectPage = App.surface('ProjectPage', {
@@ -210,15 +277,16 @@ const ProjectPage = App.surface('ProjectPage', {
210
277
  ```
211
278
 
212
279
  `ProjectPage` reads a `RemoteData<{ id: string; name: string }>` from the Model.
213
- If those fields are absent, the Projection still does not fetch them.
280
+ If those fields are absent, the Projection still does not fetch them: it says
281
+ what it needs, and something else decides whether to ask.
214
282
 
215
- The active Surface drives I/O through a Foldkit Subscription:
283
+ ### 4. Let the active screen drive the fetching
216
284
 
217
285
  ```ts
218
286
  import * as Subscription from 'foldkit/subscription'
219
287
 
220
288
  const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
221
- Data.subscriptions({
289
+ foldData.subscriptions({
222
290
  project: Surface.at(ProjectPage, model =>
223
291
  model.route._tag === 'project'
224
292
  ? { projectId: model.route.projectId }
@@ -230,9 +298,36 @@ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
230
298
 
231
299
  `Surface.at` makes activation a fact of the Model. When the route activates the
232
300
  page, Remote sees its requirements, diffs them against the cache, and fetches
233
- only what is missing. When the page is inactive, it creates no read work.
301
+ only what is missing. When the page is inactive, it creates no read work. This
302
+ is the one step that causes I/O, and it does so only while the screen is on.
303
+
304
+ ### 5. Provide a client
234
305
 
235
- The complete loop is:
306
+ The Subscription needs a `RemoteClient`. Before there is a server, a backend
307
+ held in memory answers through the same handlers a real one uses:
308
+
309
+ ```ts
310
+ import { RemoteServer } from 'foldkit-remote-server'
311
+
312
+ const clientLayer = RemoteServer.memory({
313
+ domain: Data,
314
+ rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
315
+ }).layer
316
+ ```
317
+
318
+ With a server, adapt your configured Effect RPC client instead; the
319
+ [server guide](../remote-server/README.md) supplies the other side:
320
+
321
+ ```ts
322
+ const clientLayer = Remote.clientLayer(rpcClient)
323
+ ```
324
+
325
+ Either is a Layer the runtime provides to `subscriptions` and Commands. The
326
+ transport itself is not owned by Remote. See
327
+ [`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory)
328
+ for what the in-memory one can and cannot do.
329
+
330
+ ### 6. Watch it arrive
236
331
 
237
332
  ```text
238
333
  ProjectPage Projection
@@ -259,43 +354,39 @@ Remote.Model
259
354
  ProjectPage now reads Ready(...)
260
355
  ```
261
356
 
262
- The first example starts on the `home` route, so no project read runs yet.
263
- Your route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
264
- runtime must install `subscriptions`. Expect `Initial` until work starts,
265
- `Loading` during the first request, and `Ready` once its result is reduced.
266
-
267
- Finally, provide the client implementation to the runtime. Here `rpcClient`
268
- is your configured Effect RPC client, not a value created by the declarations
269
- above; the [server guide](../remote-server/README.md) supplies the other side:
357
+ The first run starts on the `home` route, so no project read runs yet. Your
358
+ route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
359
+ runtime must install `subscriptions`. The page then reads `Initial` until work
360
+ starts, `Loading` during the first request, and `Ready` once its result is
361
+ reduced. If it stays `Initial`, see [Why is it still `Initial`?](#why-is-it-still-initial).
270
362
 
271
- ```ts
272
- const clientLayer = Remote.clientLayer(rpcClient)
273
- ```
363
+ ## Routing Remote's Messages
274
364
 
275
- `Remote.clientLayer` adapts an Effect RPC client for `RemoteRpc` into the
276
- `RemoteClient` service used by subscriptions and Commands. The transport itself
277
- is not owned by Remote.
365
+ The first run folds Remote's Messages under one variant of the application's
366
+ union, the shape Foldkit gives a Submodel. The fold's `fetch`, `mutate`, and
367
+ `subscriptions` yield that variant too, with the lift recorded so a Story
368
+ resolves a fetch or a mutation by Remote's own answer.
278
369
 
279
- To see the page work before you have a server, give it rows instead. A backend
280
- held in memory answers through the same handlers a real server uses:
370
+ The shorter path, for an `update` that only reduces, spreads Remote's cases
371
+ into the union and narrows by tag; the rest of `update` is then a partial
372
+ match over tags the application never handles:
281
373
 
282
374
  ```ts
283
- import { RemoteServer } from 'foldkit-remote-server'
375
+ const Message = defineMessageUnion({ ...Remote.messages })
284
376
 
285
- const clientLayer = RemoteServer.memory({
286
- domain: Data,
287
- rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
288
- }).layer
377
+ function update(model: Model, message: Message): Update.Return<Model, Message, RemoteClient> {
378
+ if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
379
+ return { model }
380
+ }
289
381
  ```
290
382
 
291
- It is for a first run, a test or a demo. See
292
- [`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory).
383
+ The rest of this guide uses the fold. With the spread, read `Data.subscriptions`
384
+ for `foldData.subscriptions` and `Data.fetch` for `foldData.fetch`.
293
385
 
294
- ## One list per application with `Data.wiring`
386
+ ### One value with `foldkit-bundle`: `Data.wiring`
295
387
 
296
- The integration steps above — spread `Remote.messages`, reduce by tag, derive
297
- `Data.subscriptions`, provide the client — are one value when the application
298
- uses `foldkit-bundle`:
388
+ The integration steps above — fold or reduce, derive the Subscriptions,
389
+ provide the client — are one value when the application uses `foldkit-bundle`:
299
390
 
300
391
  ```ts
301
392
  import { Bundle } from 'foldkit-bundle'
@@ -354,14 +445,16 @@ The states are:
354
445
  flight.
355
446
  - **`Ready`** — every selected field is present and decodes.
356
447
  - **`Refreshing`** — the current value remains visible while it is being refetched.
357
- - **`Failed`** — stored server data does not decode against the Selection, or
358
- the read or query behind it failed. A value that was already shown is kept as
359
- `previous`.
448
+ - **`Failed`** — stored server data does not decode against the Selection, the
449
+ read or query behind it failed, or the server settled a selected field
450
+ without a value (an `Unavailable` error; see
451
+ [When a field is withheld](#when-a-field-is-withheld)). A value that was
452
+ already shown is kept as `previous`.
360
453
  - **`NotFound`** — the entity is represented by a tombstone: a live event
361
- deleted it, or the server was asked for it by id and answered without it. That
362
- id is then known absent, whether it never existed, is gone, or is not this
363
- principal's to see. It is not planned again until `Data.refresh` forces it or a
364
- write brings it back. The target of a returned ref is not marked this way: a
454
+ deleted it, or the server was asked for it by id and answered with nothing
455
+ about it. That id is then known absent, whether it never existed, is gone, or
456
+ is hidden whole from this principal. It is not planned again until
457
+ `Data.refresh` forces it or a write brings it back. The target of a returned ref is not marked this way: a
365
458
  server need not expand a relation that rides on a request, and the planner asks
366
459
  for such a target by id next, which is when its absence is learned.
367
460
 
@@ -370,20 +463,8 @@ no active Surface may remain `Initial` forever. Rendering a spinner for
370
463
  `Initial` can therefore hide an activation/wiring mistake; `Loading` is the
371
464
  state that actually means "wait for this request."
372
465
 
373
- When a read sits at `Initial` and you expected data, ask the Model why:
374
-
375
- ```ts
376
- Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
377
- // { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
378
- // message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }
379
- ```
380
-
381
- `NotObserved` means no active Surface reads it: the Surface is not in the
382
- record, or its params are `undefined` for this Model. `NotFetching` means one
383
- does, and nothing started a request, which almost always means Remote's
384
- Subscriptions are not installed in the runtime. Without `surfaces` it can only
385
- say `Unknown`. For every other state it says what the state means in words,
386
- and a failed request's message says that nothing retries it on its own.
466
+ When a read sits at `Initial` and you expected data, ask the Model why with
467
+ `Data.why`; see [Why is it still `Initial`?](#why-is-it-still-initial).
387
468
 
388
469
  `RemoteData.match` is exhaustive, so adding or omitting a state is visible at
389
470
  compile time.
@@ -428,6 +509,102 @@ spinner that never ends or an error nobody can act on.
428
509
  Reach for `match` instead when the six states really do draw differently — it
429
510
  stays the exhaustive fold, and `render` does not replace it.
430
511
 
512
+ ## When it does not do what you expect
513
+
514
+ Each of these is a design of the package showing through, not a fault. The
515
+ symptom names the section; the cure is one call.
516
+
517
+ ### Why is it still `Initial`?
518
+
519
+ `Initial` means required data is absent and **nothing is fetching it**, so a
520
+ spinner here hides a wiring mistake. Ask the Model:
521
+
522
+ ```ts
523
+ Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
524
+ // { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
525
+ // message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }
526
+ ```
527
+
528
+ - `NotObserved`: no active Surface reads it. The Surface is not in the record
529
+ you gave `Data.subscriptions`, or its params are `undefined` for this Model
530
+ (the route is not there yet).
531
+ - `NotFetching`: an active Surface reads it and nothing started a request,
532
+ which almost always means Remote's Subscriptions are not installed in the
533
+ runtime.
534
+ - Without `surfaces` it can only say `Unknown`. For every other state it says
535
+ what the state means in words.
536
+
537
+ `Data.plan(model, projection)` shows whether Remote believes anything is
538
+ missing at all.
539
+
540
+ ### Why is it stale?
541
+
542
+ Under the default `RemotePolicy.cacheFirst`, a present field is never asked for
543
+ again. That is the point of a cache, and the reason a list shows what the
544
+ server said last time. To see change:
545
+
546
+ - **On demand**: `Data.refresh(model, projection)` from `update` marks what the
547
+ screen reads `Refreshing` and the active Subscription fetches it again, old
548
+ value still on screen. A refresh button, a window regaining focus.
549
+ - **By age**: `RemotePolicy.staleWhileRevalidate({ maxAge })` refetches a value
550
+ older than `maxAge` the next time it is planned.
551
+ - **Always**: `RemotePolicy.networkOnly` requests every selected field, cached
552
+ value visible meanwhile.
553
+ - **As it happens**: `Data.live` instead of `Data.get`, with a server that
554
+ publishes changes; see [Live data](#live-data).
555
+
556
+ Connections have no age: a list re-queries when invalidated, refreshed, or
557
+ under `networkOnly`. See [Reading, policies, and prefetch](#reading-policies-and-prefetch).
558
+
559
+ ### Why did my change disappear?
560
+
561
+ A mutation's `optimistic` patches are layers over the store while the request
562
+ is in flight. When the server answers, the layers come off and the cache shows
563
+ what the server said:
564
+
565
+ - **It failed.** `MutationFailed` removes the layers; `Data.mutation(model,
566
+ requestId)` reads `Failed` with the error. Nothing retries it: the mutation
567
+ is not a durable queue, and if losing it is data loss the edit belongs to
568
+ [`foldkit-sync`](../sync).
569
+ - **It succeeded, but the server's result did not include the field.** The
570
+ layer came off and the store still holds the old value, until a read or the
571
+ result writes the new one. A mutation `Output` that returns the changed
572
+ entity settles this at once.
573
+ - **A preview** shown with `Data.overlay` stays until `Data.lift`; a settled
574
+ mutation never takes it along.
575
+
576
+ See [Mutations and optimistic state](#mutations-and-optimistic-state).
577
+
578
+ ### Why is it gone after navigating away?
579
+
580
+ Remote is a cache, and it forgets facts no active feature needs. Retention
581
+ roots come from the active Surfaces; after the grace period, `RetentionChanged`
582
+ collects everything they do not reach. A screen you left, and nothing else
583
+ reads, loses its rows, which is safe because coming back refetches them. To
584
+ keep something a screen does not read, name it in a retain entry or a
585
+ `connections` list; see [Retention](#retention-and-garbage-collection).
586
+
587
+ ### Why does a value the server has read `Failed`?
588
+
589
+ `Failed` with no `previous` and no request error means the stored value did not
590
+ **decode** against the Selection: the server's shape and the entity's Schema
591
+ disagree; the error is a `DecodeError` with the Schema's message. An
592
+ `Unavailable` error means the server answered without a field the Selection
593
+ names and will not answer with it; see
594
+ [When a field is withheld](#when-a-field-is-withheld). A failed request reads
595
+ the same way, with the request's error; see
596
+ [When a read fails](#when-a-read-fails). A
597
+ `RemoteProtocolError` means the client and server disagree on
598
+ `REMOTE_PROTOCOL_VERSION`, and nothing is silently accepted.
599
+
600
+ ### Why did a live update not show?
601
+
602
+ Live events carry cursors. One behind the Model's is a duplicate and is
603
+ dropped; one ahead of the next is a **gap**, recorded in `RemoteModel.gaps`
604
+ and not applied, so the screen keeps what the server last said rather than
605
+ a history with a hole in it. The host resynchronizes and clears the gap; see
606
+ [Live data](#live-data).
607
+
431
608
  ## Normalized entities and selections
432
609
 
433
610
  Remote stores an entity once by identity, regardless of how many Surfaces read
@@ -555,6 +732,16 @@ A refreshing policy emits `RefreshStarted` before the read, so the Projection
555
732
  becomes `Refreshing` without discarding the old value. Planning accepts `now`
556
733
  (default `Date.now`) as an input, so tests can control time.
557
734
 
735
+ **Time reaches Remote only as a Message.** The plan is a function of the Model
736
+ and `now`, and it runs when the Model changes; a page that sits still past its
737
+ `maxAge` would otherwise never be looked at again. So a read entry under
738
+ `staleWhileRevalidate` knows when the earliest value it holds ages out, sleeps
739
+ until then under the Effect clock, and emits `RefreshStarted` for what is due.
740
+ That marks the fields stale in the Model, and the plan that follows fetches
741
+ them, the same path a refresh button takes. Nothing reads the clock while a
742
+ Projection is read, so a Model reads the same twice, and a recorded one
743
+ replays.
744
+
558
745
  For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
559
746
 
560
747
  ```ts
@@ -573,24 +760,6 @@ Projection itself.
573
760
 
574
761
  ### Refreshing from `update`
575
762
 
576
- ### Showing a change nobody has made
577
-
578
- A mutation's `optimistic` operations show over the store while it is in flight.
579
- `Data.overlay` shows operations the same way with no request behind them, until
580
- `Data.lift`: a preview, in every Selection and view, of something not yet sent.
581
-
582
- ```ts
583
- const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
584
- const back = Data.lift(previewed, 'post-preview')
585
- ```
586
-
587
- - Both are called from `update`, and neither performs I/O or touches what the
588
- server said: the store beneath is as it was.
589
- - Showing an id again replaces what it showed. Lifting what was never shown
590
- returns the same Model.
591
- - An overlay's id is apart from every request's, so a mutation that settles does
592
- not take a preview with it.
593
-
594
763
  To revalidate what a screen already declares — a refresh button, a focus
595
764
  regained — hand its Projection (or a Surface without params) to `Data.refresh`:
596
765
 
@@ -616,6 +785,31 @@ nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
616
785
  - A read already in flight restarts instead of landing after the refresh.
617
786
  - Refreshing what is already refreshing, or nothing, returns the same Model.
618
787
 
788
+ ### Forgetting everything: a change of principal
789
+
790
+ Everything Remote knows, it knows for a principal: a value the server gave
791
+ this session, a tombstone for an id it answered nothing about, a field it
792
+ settled as unavailable, the rows of a list. Log in, log out, switch
793
+ organization, and none of it is trustworthy. `Data.forget` is the one
794
+ boundary:
795
+
796
+ ```ts
797
+ case 'SignedOut': {
798
+ return { model: Data.forget({ ...model, session: Option.none() }) }
799
+ }
800
+ ```
801
+
802
+ It performs no I/O and returns the Model with every server-derived fact gone:
803
+ values, tombstones, unavailable fields, connections, live cursors, failures,
804
+ and the reads in flight. Every active Surface's read and live entries restart,
805
+ so the screen asks again as whoever the client now is; a read or stream begun
806
+ before is interrupted rather than landing after. A mutation in flight is
807
+ treated as applied, so its answer writes nothing into the new store, and the
808
+ mutation sequence is kept, so request ids stay unique.
809
+
810
+ Who the client is belongs to the `RemoteClient` layer or the server's session,
811
+ not to Remote. `forget` only says that what was known no longer is.
812
+
619
813
  ## Queries and pagination
620
814
 
621
815
  Entities answer "which fields of this known thing?" A Query answers "which
@@ -783,6 +977,15 @@ the binding's `visible` rule; there is no `visible` here. This is safe only
783
977
  because the client holds only rows the server already released to it, and a
784
978
  local filter is not an access decision.
785
979
 
980
+ **Local execution is not a second authority.** `matching` and `filtered` derive
981
+ conclusions from facts the server gave; they never promote those conclusions
982
+ into knowledge of the server's whole dataset. Knowing `p1.ownerId = u7` says
983
+ that `p1` matches `ProjectsByOwner(u7)`; it does not say that
984
+ `ProjectsByOwner(u7)` is `[p1]`, and nothing here will say so unless a
985
+ connection's boundaries, terminal at both ends, independently prove it. That
986
+ line is what keeps Remote a cache of what the server owns rather than a local
987
+ database with opinions of its own.
988
+
786
989
  ### An input that changes as fast as someone types
787
990
 
788
991
  A connection's identity is its query plus its **input**, which is exactly right
@@ -804,13 +1007,13 @@ the query reads** — which is ordinary application state, and
804
1007
  import { debounce } from 'foldkit-primitives/time'
805
1008
 
806
1009
  const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
807
- const SearchBox = Bundle.declare(SearchInput, 'search')
808
1010
 
809
1011
  // `latest` is what the box shows, so typing stays immediate.
810
1012
  // The settled OutMessage is what moves the field the query reads.
811
- Page.at(SearchBox, {
1013
+ // A step of the page's Bundle.compose(...).pipe(...):
1014
+ Bundle.withChild('search', SearchInput, {
812
1015
  args: { delayMs: 250 },
813
- onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
1016
+ onOut: out => model => ({ model: modifyFields(model, { postSearch: () => out.value }) }),
814
1017
  })
815
1018
  ```
816
1019
 
@@ -868,6 +1071,41 @@ A broken live stream is not a failed read. Its `ReadFailed` carries the
868
1071
  field: nothing was being read, and the values on screen are still what the
869
1072
  server last said.
870
1073
 
1074
+ ### When a field is withheld
1075
+
1076
+ A server may answer a read without a field it was asked for, and mean it: the
1077
+ Source's `authorize` withheld it from this principal, or the record it returned
1078
+ had no such field. The read result says so, in `settled`: the fields it asked
1079
+ for that this answer does not carry and a later one would not either. Why is
1080
+ not on the wire. The client records each as **unavailable**, one more kind of
1081
+ knowledge beside a value and a tombstone:
1082
+
1083
+ ```text
1084
+ missing not established; the planner asks
1085
+ present known
1086
+ stale known, and being asked again
1087
+ unavailable asked, and answered without: the planner does not ask again
1088
+ tombstone the whole entity is known absent
1089
+ ```
1090
+
1091
+ A Selection that names an unavailable field reads `Failed` with an
1092
+ `Unavailable` error naming the field, not `Loading` (nothing is fetching it)
1093
+ and not `NotFound` (the entity is there). What it is not is a seventh state:
1094
+ the remedy is the one a failed read has. Select without the field, in a
1095
+ Selection of its own if some principals may read it, or show the error. A
1096
+ Selection that does not name it is unaffected: `name` reads `Ready` while
1097
+ `privateNotes` is unavailable beside it.
1098
+
1099
+ The mark is forgotten by what forgets a failure: `Data.refresh` asks again,
1100
+ and a read, live patch or mutation result that writes the field clears it.
1101
+ It survives persistence and a server render's `Remote.resume`, so the browser
1102
+ does not ask once more for what the server already declined.
1103
+
1104
+ An id the server answers with nothing about, neither values nor settled fields,
1105
+ is a tombstone as before. A server settles a withheld field without reading
1106
+ its Source for an id whose every field is withheld, so an absent id and a
1107
+ hidden one answer alike, and existence is not told.
1108
+
871
1109
  ## Mutations and optimistic state
872
1110
 
873
1111
  Register mutations on the domain:
@@ -951,6 +1189,24 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
951
1189
  A successful server result can replace the request's temporary connection
952
1190
  overlays with confirmed ones in place.
953
1191
 
1192
+ ### Showing a change nobody has made
1193
+
1194
+ A mutation's `optimistic` operations show over the store while it is in flight.
1195
+ `Data.overlay` shows operations the same way with no request behind them, until
1196
+ `Data.lift`: a preview, in every Selection and view, of something not yet sent.
1197
+
1198
+ ```ts
1199
+ const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
1200
+ const back = Data.lift(previewed, 'post-preview')
1201
+ ```
1202
+
1203
+ - Both are called from `update`, and neither performs I/O or touches what the
1204
+ server said: the store beneath is as it was.
1205
+ - Showing an id again replaces what it showed. Lifting what was never shown
1206
+ returns the same Model.
1207
+ - An overlay's id is apart from every request's, so a mutation that settles does
1208
+ not take a preview with it.
1209
+
954
1210
  ### Reading past what is only pending
955
1211
 
956
1212
  Every projection reads the **visible** cache: the server-derived store under
@@ -988,25 +1244,12 @@ flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight
988
1244
 
989
1245
  ### Remote mutation vs Sync operation
990
1246
 
991
- Do not use Remote mutation as a durable offline-write mechanism:
992
-
993
- ```text
994
- Remote mutation
995
- request server now
996
- server owns truth
997
- result updates disposable cache
998
- no durable outbox
999
-
1000
- Sync operation
1001
- client authored the durable fact/intent
1002
- survives offline/restart
1003
- enters an ordered log
1004
- converges with other replicas
1005
- ```
1006
-
1007
- If losing an unsent edit would be data loss, it belongs to Sync rather than
1008
- Remote. For the same reason, an optimistic overlay is only a temporary view of
1009
- server-owned data: do not persist one and treat it as a queue of edits.
1247
+ A Remote mutation requests the server now; its result updates a disposable
1248
+ cache, and there is no durable outbox. If losing an unsent edit would be data
1249
+ loss, it belongs to [`foldkit-sync`](../sync), not here (see [which state
1250
+ belongs here](#which-state-belongs-here)). For the same reason, an optimistic
1251
+ overlay is only a temporary view of server-owned data: do not persist one and
1252
+ treat it as a queue of edits.
1010
1253
 
1011
1254
  ## Live data
1012
1255
 
@@ -1060,8 +1303,9 @@ never in one.
1060
1303
  ```ts
1061
1304
  import { RemotePersistence } from 'foldkit-remote'
1062
1305
 
1063
- // SSR: prefetch on the server, embed, then hydrate on the client.
1064
- // `snapshotOf` names the connections that survive; none, here.
1306
+ // A cache carried in the page: prefetch on the server, embed, then hydrate.
1307
+ // `snapshotOf` names the connections that survive; none, here. For a page
1308
+ // the browser resumes with foldkit-ssr, see Server rendering below.
1065
1309
  const text = RemotePersistence.dehydrate(
1066
1310
  RemotePersistence.snapshotOf(loaded.remote),
1067
1311
  { scope: userId },
@@ -1096,6 +1340,40 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
1096
1340
  discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
1097
1341
  hydration still changes the application through the reducer.
1098
1342
 
1343
+ ## Server rendering: `Remote.resume`
1344
+
1345
+ A snapshot is built for a cache that survived a reload: it keeps the whole
1346
+ entity store and deliberately no cursors. A page rendered a moment ago wants
1347
+ the opposite. `Remote.resume(Data)` is a resume part for
1348
+ [`foldkit-ssr`](../ssr/README.md) (in development), which sends exactly what
1349
+ the page's active Surfaces read, and resumes it in the browser:
1350
+
1351
+ ```ts
1352
+ const Page = SSR.plan(App, {
1353
+ id: 'project',
1354
+ state: Projection.pick(App.model.route),
1355
+ surfaces: [ProjectPageAt],
1356
+ parts: [Remote.resume(Data)],
1357
+ })
1358
+ ```
1359
+
1360
+ - **Only what is read crosses.** For each requirement, the fields it names,
1361
+ following its relations through the refs the store holds; for each
1362
+ connection, the fields its selection reads of each item. A field in the
1363
+ server's store that no Surface selects, such as a user's email, is not in the
1364
+ page.
1365
+ - **Connections cross whole**, with their boundaries, so "load more" knows
1366
+ where the server stopped.
1367
+ - **Live cursors cross with the entities they follow**, so a live
1368
+ subscription started in the browser asks from where the server's left off.
1369
+ - **Nothing else crosses.** Loading marks, failures, the mutation ledger,
1370
+ optimistic layers, gaps and retention start at their initial values.
1371
+
1372
+ The browser's read entry plans against the restored store, so it asks for
1373
+ none of it again. The capture is JSON through its own Schema, and a page whose
1374
+ capture does not decode is refused whole by `foldkit-ssr`. Give a second domain
1375
+ in one application its own `id`: `Remote.resume(Catalog, { id: 'catalog' })`.
1376
+
1099
1377
  ## How the server packages fit
1100
1378
 
1101
1379
  `foldkit-remote` owns **client cache semantics**, not your source database or
@@ -1155,10 +1433,9 @@ Remote.planQueries(...) // missing/stale query work
1155
1433
  Remote.inspectEntity(...) // one normalized entity
1156
1434
  ```
1157
1435
 
1158
- These are pure and useful in tests, tooling, and debugging. A particularly
1159
- useful question is: **"Why is this Projection still Initial?"** `Data.plan`
1160
- shows whether Remote believes anything is actually missing; active Surface
1161
- wiring determines whether that plan is being executed.
1436
+ These are pure and useful in tests, tooling, and debugging. For a read that
1437
+ stays `Initial`, `Data.why` and `Data.plan` are the tools; see
1438
+ [Why is it still `Initial`?](#why-is-it-still-initial).
1162
1439
 
1163
1440
  `Data.inspect(model).loading` answers the other one — **"what is Remote doing
1164
1441
  right now?"** — with the `entity\0id\0field` marks of the reads in flight,
@@ -1391,15 +1668,6 @@ Remote does **not** own:
1391
1668
  - authentication; server-side authorization belongs at the Source boundary;
1392
1669
  - a background scheduler or general-purpose database/query engine.
1393
1670
 
1394
- ## How state changes here
1395
-
1396
- Local application state uses `evo` inside `update`, like anywhere else.
1397
- Remote Messages go through `Data.reduce` into the embedded `Remote.Model`
1398
- submodel — Remote owns that reducer, and it is the only writer of the
1399
- cache. Never install cache state with a ref `set`; the requirements,
1400
- staleness, and tombstones only stay coherent when every fact arrives as a
1401
- Remote Message.
1402
-
1403
1671
  ## Limits
1404
1672
 
1405
1673
  - Effect RPC is the wire contract; the application supplies deployment layers.