foldkit-remote 0.6.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,30 +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
+ })
11
+
12
+ RemoteData.render(project, {
13
+ loading: () => ProjectSkeleton(),
14
+ notFound: () => NoSuchProject(),
15
+ failed: error => ErrorView(error),
16
+ data: project => ProjectView(project),
17
+ })
18
+ ```
9
19
 
10
- The central rule is:
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.
11
24
 
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.**
25
+ Remote is for facts the **server owns**. An edit the client authors, which
26
+ must survive being offline, belongs to [`foldkit-sync`](../sync).
15
27
 
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.
28
+ ## If you know TanStack Query, TanStack DB or fate
19
29
 
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.
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.
82
+
83
+ ## Find what you need
84
+
85
+ Read the [first run](#the-first-run-one-entity-one-surface) once, then jump to
86
+ what you are doing:
87
+
88
+ | Task | Start here |
89
+ | --- | --- |
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) |
95
+ | Show a failed read, and let the user retry | [When a read fails](#when-a-read-fails) |
96
+ | Load an ordered, paged list | [Queries and pagination](#queries-and-pagination) |
97
+ | Filter a list already on screen | [Filtering a loaded list](#filtering-a-loaded-list-without-asking-the-server) |
98
+ | Save, and show the result before the server answers | [Mutations](#mutations-and-optimistic-state) |
99
+ | Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
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) |
102
+ | Answer the requests on the server | [How the server packages fit](#how-the-server-packages-fit) |
24
103
 
25
104
  ## Which state belongs here?
26
105
 
27
- A useful rule across Foldkit Plus is **one owner per datum**:
106
+ One owner per datum:
28
107
 
29
108
  | State | Owner |
30
109
  | --- | --- |
@@ -33,23 +112,13 @@ A useful rule across Foldkit Plus is **one owner per datum**:
33
112
  | Client-authored state that must survive offline and converge | `foldkit-sync` |
34
113
  | Local state represented in the URL or another store | the Model, observed by `foldkit-mirror` |
35
114
 
36
- The distinction between Remote and Sync is especially important:
37
-
38
115
  ```text
39
- Remote
40
- server owns the fact
41
- cache is disposable
42
- refetch is recovery
43
-
44
- Sync
45
- client owns the edit
46
- intent must survive offline/restart
47
- 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
48
120
  ```
49
121
 
50
- A Remote mutation is therefore an immediate request against server-owned data.
51
- Its result updates the cache. It is **not** a durable local intent queue.
52
-
53
122
  A Surface may project all of these owners at once. Observation does not transfer
54
123
  ownership.
55
124
 
@@ -67,9 +136,11 @@ requirements with the cache, then requests missing or stale fields. The
67
136
  result returns as a Message; only reducing it changes what the next read sees.
68
137
  Rendering the same Projection twice starts no work by itself.
69
138
 
70
- ## 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.**
71
142
 
72
- Most application code only needs four roles:
143
+ Four roles carry the loop, and most application code needs no more:
73
144
 
74
145
  | Piece | Responsibility |
75
146
  | --- | --- |
@@ -78,7 +149,11 @@ Most application code only needs four roles:
78
149
  | `Data.reduce` | reduce returned Remote Messages into the application's `Remote.Model` |
79
150
  | `RemoteClient` | perform the actual read/query/mutate/live I/O |
80
151
 
81
- 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.
82
157
 
83
158
  ## Install
84
159
 
@@ -89,9 +164,12 @@ pnpm add foldkit-remote foldkit-entity
89
164
  `foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
90
165
  package. The server-side interpreter is [`foldkit-remote-server`](../remote-server).
91
166
 
92
- ## Sixty seconds: one entity, one Surface
167
+ ## The first run: one entity, one Surface
93
168
 
94
- Start with one server entity and one selection:
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.
171
+
172
+ ### 1. Declare an entity and what this screen needs of it
95
173
 
96
174
  ```ts
97
175
  import { Schema } from 'effect'
@@ -114,14 +192,13 @@ const ProjectSummary = Entity.select(Project, {
114
192
  ```
115
193
 
116
194
  The entity comes from [`foldkit-entity`](../entity), which declares a domain
117
- without Remote in it. Remote also has an `Entity.make` of its own, and accepts
118
- either — but only a `foldkit-entity` entity has addressable `fields`, so only
119
- that one can be given [relations](#entities-declared-with-foldkit-entity),
120
- derived members, or a [query body](#queries-and-pagination). Reach for
121
- `Entity.make` when a domain is small enough that Remote is all it will ever
122
- need; reach for `Entity.define` otherwise, which is most of the time.
195
+ without Remote in it, so the same declaration can serve the server's database
196
+ binding and your forms too. It is also what gives an entity
197
+ [relations](#entities-declared-with-foldkit-entity), derived members, and a
198
+ [query body](#queries-and-pagination) to point at. Nothing here touches a
199
+ network.
123
200
 
124
- 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
125
202
 
126
203
  ```ts
127
204
  import { defineMessageUnion } from 'foldkit/message'
@@ -144,19 +221,10 @@ const Model = Schema.Struct({
144
221
  type Model = typeof Model.Type
145
222
 
146
223
  const Message = defineMessageUnion({
147
- ...Remote.messages,
224
+ GotRemoteMessage: { message: Remote.Message },
148
225
  })
149
226
  type Message = typeof Message.Type
150
227
 
151
- function update(
152
- model: Model,
153
- message: Message,
154
- ): Update.Return<Model, Message, RemoteClient> {
155
- // Remote owns the `remote` Submodel, so all Remote Messages go through its reducer.
156
- if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
157
- return { model }
158
- }
159
-
160
228
  const App = Surface.application({
161
229
  Model,
162
230
  Message,
@@ -168,16 +236,33 @@ const App = Surface.application({
168
236
  })
169
237
 
170
238
  const Data = Remote.make({
171
- model: App.fields.remote,
239
+ model: App.model.remote,
172
240
  entities: [Project],
173
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
+ }
174
255
  ```
175
256
 
176
- `Remote.Model` is not a second application store. It is a Submodel **inside** the
177
- application Model. `Data` is the bound API for this domain: it knows where that
178
- 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).)
179
264
 
180
- Now declare what a feature needs:
265
+ ### 3. Declare what the screen reads
181
266
 
182
267
  ```ts
183
268
  const ProjectPage = App.surface('ProjectPage', {
@@ -192,15 +277,16 @@ const ProjectPage = App.surface('ProjectPage', {
192
277
  ```
193
278
 
194
279
  `ProjectPage` reads a `RemoteData<{ id: string; name: string }>` from the Model.
195
- 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.
196
282
 
197
- The active Surface drives I/O through a Foldkit Subscription:
283
+ ### 4. Let the active screen drive the fetching
198
284
 
199
285
  ```ts
200
286
  import * as Subscription from 'foldkit/subscription'
201
287
 
202
288
  const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
203
- Data.subscriptions({
289
+ foldData.subscriptions({
204
290
  project: Surface.at(ProjectPage, model =>
205
291
  model.route._tag === 'project'
206
292
  ? { projectId: model.route.projectId }
@@ -212,9 +298,36 @@ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
212
298
 
213
299
  `Surface.at` makes activation a fact of the Model. When the route activates the
214
300
  page, Remote sees its requirements, diffs them against the cache, and fetches
215
- 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
305
+
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'
216
311
 
217
- The complete loop is:
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
218
331
 
219
332
  ```text
220
333
  ProjectPage Projection
@@ -241,28 +354,39 @@ Remote.Model
241
354
  ProjectPage now reads Ready(...)
242
355
  ```
243
356
 
244
- The first example starts on the `home` route, so no project read runs yet.
245
- Your route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
246
- runtime must install `subscriptions`. Expect `Initial` until work starts,
247
- `Loading` during the first request, and `Ready` once its result is reduced.
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).
362
+
363
+ ## Routing Remote's Messages
248
364
 
249
- Finally, provide the client implementation to the runtime. Here `rpcClient`
250
- is your configured Effect RPC client, not a value created by the declarations
251
- above; the [server guide](../remote-server/README.md) supplies the other side:
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.
369
+
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:
252
373
 
253
374
  ```ts
254
- const clientLayer = Remote.clientLayer(rpcClient)
375
+ const Message = defineMessageUnion({ ...Remote.messages })
376
+
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
+ }
255
381
  ```
256
382
 
257
- `Remote.clientLayer` adapts an Effect RPC client for `RemoteRpc` into the
258
- `RemoteClient` service used by subscriptions and Commands. The transport itself
259
- is not owned by Remote.
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`.
260
385
 
261
- ## One list per application with `Data.wiring`
386
+ ### One value with `foldkit-bundle`: `Data.wiring`
262
387
 
263
- The integration steps above — spread `Remote.messages`, reduce by tag, derive
264
- `Data.subscriptions`, provide the client — are one value when the application
265
- 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`:
266
390
 
267
391
  ```ts
268
392
  import { Bundle } from 'foldkit-bundle'
@@ -321,14 +445,16 @@ The states are:
321
445
  flight.
322
446
  - **`Ready`** — every selected field is present and decodes.
323
447
  - **`Refreshing`** — the current value remains visible while it is being refetched.
324
- - **`Failed`** — stored server data does not decode against the Selection, or
325
- the read or query behind it failed. A value that was already shown is kept as
326
- `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`.
327
453
  - **`NotFound`** — the entity is represented by a tombstone: a live event
328
- deleted it, or the server was asked for it by id and answered without it. That
329
- id is then known absent, whether it never existed, is gone, or is not this
330
- principal's to see. It is not planned again until `Data.refresh` forces it or a
331
- 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
332
458
  server need not expand a relation that rides on a request, and the planner asks
333
459
  for such a target by id next, which is when its absence is learned.
334
460
 
@@ -337,6 +463,9 @@ no active Surface may remain `Initial` forever. Rendering a spinner for
337
463
  `Initial` can therefore hide an activation/wiring mistake; `Loading` is the
338
464
  state that actually means "wait for this request."
339
465
 
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).
468
+
340
469
  `RemoteData.match` is exhaustive, so adding or omitting a state is visible at
341
470
  compile time.
342
471
 
@@ -380,6 +509,102 @@ spinner that never ends or an error nobody can act on.
380
509
  Reach for `match` instead when the six states really do draw differently — it
381
510
  stays the exhaustive fold, and `render` does not replace it.
382
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
+
383
608
  ## Normalized entities and selections
384
609
 
385
610
  Remote stores an entity once by identity, regardless of how many Surfaces read
@@ -507,6 +732,16 @@ A refreshing policy emits `RefreshStarted` before the read, so the Projection
507
732
  becomes `Refreshing` without discarding the old value. Planning accepts `now`
508
733
  (default `Date.now`) as an input, so tests can control time.
509
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
+
510
745
  For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
511
746
 
512
747
  ```ts
@@ -525,24 +760,6 @@ Projection itself.
525
760
 
526
761
  ### Refreshing from `update`
527
762
 
528
- ### Showing a change nobody has made
529
-
530
- A mutation's `optimistic` operations show over the store while it is in flight.
531
- `Data.overlay` shows operations the same way with no request behind them, until
532
- `Data.lift`: a preview, in every Selection and view, of something not yet sent.
533
-
534
- ```ts
535
- const previewed = Data.overlay(model, 'post-preview', [Project.patch(id, { name: draft })])
536
- const back = Data.lift(previewed, 'post-preview')
537
- ```
538
-
539
- - Both are called from `update`, and neither performs I/O or touches what the
540
- server said: the store beneath is as it was.
541
- - Showing an id again replaces what it showed. Lifting what was never shown
542
- returns the same Model.
543
- - An overlay's id is apart from every request's, so a mutation that settles does
544
- not take a preview with it.
545
-
546
763
  To revalidate what a screen already declares — a refresh button, a focus
547
764
  regained — hand its Projection (or a Surface without params) to `Data.refresh`:
548
765
 
@@ -568,6 +785,31 @@ nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
568
785
  - A read already in flight restarts instead of landing after the refresh.
569
786
  - Refreshing what is already refreshing, or nothing, returns the same Model.
570
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
+
571
813
  ## Queries and pagination
572
814
 
573
815
  Entities answer "which fields of this known thing?" A Query answers "which
@@ -584,7 +826,7 @@ const ProjectsByOwner = Query.make('ProjectsByOwner', {
584
826
  })
585
827
 
586
828
  const Data = Remote.make({
587
- model: App.fields.remote,
829
+ model: App.model.remote,
588
830
  entities: [User, Project],
589
831
  queries: [ProjectsByOwner],
590
832
  })
@@ -735,6 +977,15 @@ the binding's `visible` rule; there is no `visible` here. This is safe only
735
977
  because the client holds only rows the server already released to it, and a
736
978
  local filter is not an access decision.
737
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
+
738
989
  ### An input that changes as fast as someone types
739
990
 
740
991
  A connection's identity is its query plus its **input**, which is exactly right
@@ -756,13 +1007,13 @@ the query reads** — which is ordinary application state, and
756
1007
  import { debounce } from 'foldkit-primitives/time'
757
1008
 
758
1009
  const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
759
- const SearchBox = Bundle.declare(SearchInput, 'search')
760
1010
 
761
1011
  // `latest` is what the box shows, so typing stays immediate.
762
1012
  // The settled OutMessage is what moves the field the query reads.
763
- Page.at(SearchBox, {
1013
+ // A step of the page's Bundle.compose(...).pipe(...):
1014
+ Bundle.withChild('search', SearchInput, {
764
1015
  args: { delayMs: 250 },
765
- onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
1016
+ onOut: out => model => ({ model: modifyFields(model, { postSearch: () => out.value }) }),
766
1017
  })
767
1018
  ```
768
1019
 
@@ -820,6 +1071,41 @@ A broken live stream is not a failed read. Its `ReadFailed` carries the
820
1071
  field: nothing was being read, and the values on screen are still what the
821
1072
  server last said.
822
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
+
823
1109
  ## Mutations and optimistic state
824
1110
 
825
1111
  Register mutations on the domain:
@@ -844,7 +1130,7 @@ case 'ClickedRename': {
844
1130
  RenameProject,
845
1131
  { id, name },
846
1132
  {
847
- optimistic: [Project.patch(id, { name })],
1133
+ optimistic: [Remote.patch(Project, id, { name })],
848
1134
  },
849
1135
  )
850
1136
 
@@ -858,6 +1144,12 @@ case 'ClickedRename': {
858
1144
  2. it returns the Command that calls `RemoteClient` and eventually emits
859
1145
  `MutationSucceeded` or `MutationFailed`.
860
1146
 
1147
+ `Remote.patch(entity, id, values)` builds one entity's patch, and
1148
+ `Remote.ref(entity, id)` names one row. Both take the entity you declared,
1149
+ whether with `foldkit-entity` or Remote's `Entity.make`, and check `values`
1150
+ against its fields in their wire shape: a relation is written as its ref key,
1151
+ such as `'User:u1'`.
1152
+
861
1153
  Those settlement Messages go through `Data.reduce` like every other Remote fact.
862
1154
  The request id comes from the Remote Model's sequence, so `update` stays pure.
863
1155
  Settling is idempotent per `requestId`.
@@ -885,10 +1177,10 @@ import { ConnectionChange } from 'foldkit-remote'
885
1177
 
886
1178
  const { model: started, command } = Data.mutate(model, AddComment, input, {
887
1179
  optimistic: ({ tempId }) => [
888
- Comment.patch(tempId, { id: tempId, body: input.body }),
1180
+ Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
889
1181
  ConnectionChange.prepend(
890
1182
  CommentsForPost.ref({ postId }),
891
- Comment.ref(tempId),
1183
+ Remote.ref(Comment, tempId),
892
1184
  ),
893
1185
  ],
894
1186
  })
@@ -897,6 +1189,24 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
897
1189
  A successful server result can replace the request's temporary connection
898
1190
  overlays with confirmed ones in place.
899
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
+
900
1210
  ### Reading past what is only pending
901
1211
 
902
1212
  Every projection reads the **visible** cache: the server-derived store under
@@ -934,24 +1244,12 @@ flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight
934
1244
 
935
1245
  ### Remote mutation vs Sync operation
936
1246
 
937
- Do not use Remote mutation as a durable offline-write mechanism:
938
-
939
- ```text
940
- Remote mutation
941
- request server now
942
- server owns truth
943
- result updates disposable cache
944
- no durable outbox
945
-
946
- Sync operation
947
- client authored the durable fact/intent
948
- survives offline/restart
949
- enters an ordered log
950
- converges with other replicas
951
- ```
952
-
953
- If losing an unsent edit would be data loss, it belongs to Sync rather than
954
- Remote.
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.
955
1253
 
956
1254
  ## Live data
957
1255
 
@@ -1005,8 +1303,9 @@ never in one.
1005
1303
  ```ts
1006
1304
  import { RemotePersistence } from 'foldkit-remote'
1007
1305
 
1008
- // SSR: prefetch on the server, embed, then hydrate on the client.
1009
- // `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.
1010
1309
  const text = RemotePersistence.dehydrate(
1011
1310
  RemotePersistence.snapshotOf(loaded.remote),
1012
1311
  { scope: userId },
@@ -1041,6 +1340,40 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
1041
1340
  discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
1042
1341
  hydration still changes the application through the reducer.
1043
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
+
1044
1377
  ## How the server packages fit
1045
1378
 
1046
1379
  `foldkit-remote` owns **client cache semantics**, not your source database or
@@ -1087,22 +1420,6 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
1087
1420
  rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
1088
1421
  server packages together.
1089
1422
 
1090
- ## Finding the next API
1091
-
1092
- | Task | Start here |
1093
- | --- | --- |
1094
- | Explain `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
1095
- | Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
1096
- | Load an ordered list | [Queries and pagination](#queries-and-pagination) |
1097
- | Save and show an optimistic result | [Mutations](#mutations-and-optimistic-state) |
1098
- | Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
1099
- | Seed a cache after reload or SSR | [Hydration](#persistence-and-hydration) |
1100
-
1101
- A successful mutation and an optimistic overlay have different guarantees:
1102
- the overlay is only a temporary view of server-owned data. Use Sync when the
1103
- operation itself must survive offline, rather than persisting an optimistic
1104
- Remote cache and treating it as an outbox.
1105
-
1106
1423
  ## Introspection
1107
1424
 
1108
1425
  Remote exposes its decisions as data rather than requiring DevTools to reach
@@ -1116,10 +1433,9 @@ Remote.planQueries(...) // missing/stale query work
1116
1433
  Remote.inspectEntity(...) // one normalized entity
1117
1434
  ```
1118
1435
 
1119
- These are pure and useful in tests, tooling, and debugging. A particularly
1120
- useful question is: **"Why is this Projection still Initial?"** `Data.plan`
1121
- shows whether Remote believes anything is actually missing; active Surface
1122
- 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).
1123
1439
 
1124
1440
  `Data.inspect(model).loading` answers the other one — **"what is Remote doing
1125
1441
  right now?"** — with the `entity\0id\0field` marks of the reads in flight,
@@ -1193,6 +1509,11 @@ place in an application Model. It exposes the Remote `Model`, `initial`,
1193
1509
  `Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
1194
1510
  `Remote.make` combines definition and binding in one step.
1195
1511
 
1512
+ `Entity.make(name, schema)` is Remote's own entity descriptor, and what a
1513
+ `foldkit-entity` Entity is compiled to (`Entity.from`). Remote accepts either
1514
+ wherever it takes an entity. Declaring one directly skips `foldkit-entity`,
1515
+ and with it relations, derived members and query bodies, which is only worth
1516
+ it for a domain that Remote is all it will ever need.
1196
1517
  `Selection.make(Entity, { ... })`, `Selection.connection(...)`, and
1197
1518
  `Entity.patch(ref, values)` are the kernel constructors behind the entity
1198
1519
  helpers. `Remote.select(bound, selection)(id)` is the lower-level form of
@@ -1347,15 +1668,6 @@ Remote does **not** own:
1347
1668
  - authentication; server-side authorization belongs at the Source boundary;
1348
1669
  - a background scheduler or general-purpose database/query engine.
1349
1670
 
1350
- ## How state changes here
1351
-
1352
- Local application state uses `evo` inside `update`, like anywhere else.
1353
- Remote Messages go through `Data.reduce` into the embedded `Remote.Model`
1354
- submodel — Remote owns that reducer, and it is the only writer of the
1355
- cache. Never install cache state with a ref `set`; the requirements,
1356
- staleness, and tombstones only stay coherent when every fact arrives as a
1357
- Remote Message.
1358
-
1359
1671
  ## Limits
1360
1672
 
1361
1673
  - Effect RPC is the wire contract; the application supplies deployment layers.