foldkit-remote 0.7.0 → 0.9.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.
24
+
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.
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,19 +277,21 @@ 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
286
+ import { Option } from 'effect'
218
287
  import * as Subscription from 'foldkit/subscription'
219
288
 
220
289
  const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
221
- Data.subscriptions({
290
+ foldData.subscriptions({
222
291
  project: Surface.at(ProjectPage, model =>
223
292
  model.route._tag === 'project'
224
- ? { projectId: model.route.projectId }
225
- : undefined,
293
+ ? Option.some({ projectId: model.route.projectId })
294
+ : Option.none(),
226
295
  ),
227
296
  }),
228
297
  )
@@ -230,9 +299,51 @@ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
230
299
 
231
300
  `Surface.at` makes activation a fact of the Model. When the route activates the
232
301
  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.
302
+ only what is missing. When the page is inactive, it creates no read work. This
303
+ is the one step that causes I/O, and it does so only while the screen is on.
304
+
305
+ A read that is not a Surface's, such as the record a detail pane shows, is made
306
+ active with `Data.active`: its function of the Model returns the read, or none
307
+ while there is nothing to read.
308
+
309
+ ```ts
310
+ const detail = Data.active('ProjectDetail', model =>
311
+ Option.map(model.openProjectId, id => Data.get(Project.select({ name: true }), id)),
312
+ )
313
+ foldData.subscriptions({ detail })
314
+ ```
315
+
316
+ It belongs to the domain's application and sends no Messages. A domain bound
317
+ to a raw optic (`ModelRef.fromOptic`) names no application, so `Data.active`
318
+ throws for one rather than guess.
234
319
 
235
- The complete loop is:
320
+ ### 5. Provide a client
321
+
322
+ The Subscription needs a `RemoteClient`. Before there is a server, a backend
323
+ held in memory answers through the same handlers a real one uses:
324
+
325
+ ```ts
326
+ import { RemoteServer } from 'foldkit-remote-server'
327
+
328
+ const clientLayer = RemoteServer.memory({
329
+ domain: Data,
330
+ rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
331
+ }).layer
332
+ ```
333
+
334
+ With a server, adapt your configured Effect RPC client instead; the
335
+ [server guide](../remote-server/README.md) supplies the other side:
336
+
337
+ ```ts
338
+ const clientLayer = Remote.clientLayer(rpcClient)
339
+ ```
340
+
341
+ Either is a Layer the runtime provides to `subscriptions` and Commands. The
342
+ transport itself is not owned by Remote. See
343
+ [`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory)
344
+ for what the in-memory one can and cannot do.
345
+
346
+ ### 6. Watch it arrive
236
347
 
237
348
  ```text
238
349
  ProjectPage Projection
@@ -259,43 +370,39 @@ Remote.Model
259
370
  ProjectPage now reads Ready(...)
260
371
  ```
261
372
 
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:
373
+ The first run starts on the `home` route, so no project read runs yet. Your
374
+ route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
375
+ runtime must install `subscriptions`. The page then reads `Initial` until work
376
+ starts, `Loading` during the first request, and `Ready` once its result is
377
+ reduced. If it stays `Initial`, see [Why is it still `Initial`?](#why-is-it-still-initial).
270
378
 
271
- ```ts
272
- const clientLayer = Remote.clientLayer(rpcClient)
273
- ```
379
+ ## Routing Remote's Messages
274
380
 
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.
381
+ The first run folds Remote's Messages under one variant of the application's
382
+ union, the shape Foldkit gives a Submodel. The fold's `fetch`, `mutate`, and
383
+ `subscriptions` yield that variant too, with the lift recorded so a Story
384
+ resolves a fetch or a mutation by Remote's own answer.
278
385
 
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:
386
+ The shorter path, for an `update` that only reduces, spreads Remote's cases
387
+ into the union and narrows by tag; the rest of `update` is then a partial
388
+ match over tags the application never handles:
281
389
 
282
390
  ```ts
283
- import { RemoteServer } from 'foldkit-remote-server'
391
+ const Message = defineMessageUnion({ ...Remote.messages })
284
392
 
285
- const clientLayer = RemoteServer.memory({
286
- domain: Data,
287
- rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
288
- }).layer
393
+ function update(model: Model, message: Message): Update.Return<Model, Message, RemoteClient> {
394
+ if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
395
+ return { model }
396
+ }
289
397
  ```
290
398
 
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).
399
+ The rest of this guide uses the fold. With the spread, read `Data.subscriptions`
400
+ for `foldData.subscriptions` and `Data.mutate` for `foldData.mutate`.
293
401
 
294
- ## One list per application with `Data.wiring`
402
+ ### One value with `foldkit-bundle`: `Data.wiring`
295
403
 
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`:
404
+ The integration steps above — fold or reduce, derive the Subscriptions,
405
+ provide the client — are one value when the application uses `foldkit-bundle`:
299
406
 
300
407
  ```ts
301
408
  import { Bundle } from 'foldkit-bundle'
@@ -304,7 +411,9 @@ const Page = Bundle.parent({ Model, Message })
304
411
  const wiring = Page.assemble(
305
412
  Data.wiring({
306
413
  project: Surface.at(ProjectPage, model =>
307
- model.route._tag === 'project' ? { projectId: model.route.projectId } : undefined,
414
+ model.route._tag === 'project'
415
+ ? Option.some({ projectId: model.route.projectId })
416
+ : Option.none(),
308
417
  ),
309
418
  }),
310
419
  )
@@ -354,14 +463,16 @@ The states are:
354
463
  flight.
355
464
  - **`Ready`** — every selected field is present and decodes.
356
465
  - **`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`.
466
+ - **`Failed`** — stored server data does not decode against the Selection, the
467
+ read or query behind it failed, or the server settled a selected field
468
+ without a value (an `Unavailable` error; see
469
+ [When a field is withheld](#when-a-field-is-withheld)). A value that was
470
+ already shown is kept as `previous`.
360
471
  - **`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
472
+ deleted it, or the server was asked for it by id and answered with nothing
473
+ about it. That id is then known absent, whether it never existed, is gone, or
474
+ is hidden whole from this principal. It is not planned again until
475
+ `Data.refresh` forces it or a write brings it back. The target of a returned ref is not marked this way: a
365
476
  server need not expand a relation that rides on a request, and the planner asks
366
477
  for such a target by id next, which is when its absence is learned.
367
478
 
@@ -370,20 +481,8 @@ no active Surface may remain `Initial` forever. Rendering a spinner for
370
481
  `Initial` can therefore hide an activation/wiring mistake; `Loading` is the
371
482
  state that actually means "wait for this request."
372
483
 
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.
484
+ When a read sits at `Initial` and you expected data, ask the Model why with
485
+ `Data.why`; see [Why is it still `Initial`?](#why-is-it-still-initial).
387
486
 
388
487
  `RemoteData.match` is exhaustive, so adding or omitting a state is visible at
389
488
  compile time.
@@ -428,6 +527,102 @@ spinner that never ends or an error nobody can act on.
428
527
  Reach for `match` instead when the six states really do draw differently — it
429
528
  stays the exhaustive fold, and `render` does not replace it.
430
529
 
530
+ ## When it does not do what you expect
531
+
532
+ Each of these is a design of the package showing through, not a fault. The
533
+ symptom names the section; the cure is one call.
534
+
535
+ ### Why is it still `Initial`?
536
+
537
+ `Initial` means required data is absent and **nothing is fetching it**, so a
538
+ spinner here hides a wiring mistake. Ask the Model:
539
+
540
+ ```ts
541
+ Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
542
+ // { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
543
+ // message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }
544
+ ```
545
+
546
+ - `NotObserved`: no active Surface reads it. The Surface is not in the record
547
+ you gave `Data.subscriptions`, or its params are `undefined` for this Model
548
+ (the route is not there yet).
549
+ - `NotFetching`: an active Surface reads it and nothing started a request,
550
+ which almost always means Remote's Subscriptions are not installed in the
551
+ runtime.
552
+ - Without `surfaces` it can only say `Unknown`. For every other state it says
553
+ what the state means in words.
554
+
555
+ `Data.plan(model, projection)` shows whether Remote believes anything is
556
+ missing at all.
557
+
558
+ ### Why is it stale?
559
+
560
+ Under the default `RemotePolicy.cacheFirst`, a present field is never asked for
561
+ again. That is the point of a cache, and the reason a list shows what the
562
+ server said last time. To see change:
563
+
564
+ - **On demand**: `Data.refresh(model, projection)` from `update` marks what the
565
+ screen reads `Refreshing` and the active Subscription fetches it again, old
566
+ value still on screen. A refresh button, a window regaining focus.
567
+ - **By age**: `RemotePolicy.staleWhileRevalidate({ maxAge })` refetches a value
568
+ older than `maxAge` the next time it is planned.
569
+ - **Always**: `RemotePolicy.networkOnly` requests every selected field, cached
570
+ value visible meanwhile.
571
+ - **As it happens**: `Data.live` instead of `Data.get`, with a server that
572
+ publishes changes; see [Live data](#live-data).
573
+
574
+ Connections have no age: a list re-queries when invalidated, refreshed, or
575
+ under `networkOnly`. See [Reading, policies, and prefetch](#reading-policies-and-prefetch).
576
+
577
+ ### Why did my change disappear?
578
+
579
+ A mutation's `optimistic` patches are layers over the store while the request
580
+ is in flight. When the server answers, the layers come off and the cache shows
581
+ what the server said:
582
+
583
+ - **It failed.** `MutationFailed` removes the layers; `Data.mutation(model,
584
+ requestId)` reads `Failed` with the error. Nothing retries it: the mutation
585
+ is not a durable queue, and if losing it is data loss the edit belongs to
586
+ [`foldkit-sync`](../sync).
587
+ - **It succeeded, but the server's result did not include the field.** The
588
+ layer came off and the store still holds the old value, until a read or the
589
+ result writes the new one. A mutation `Output` that returns the changed
590
+ entity settles this at once.
591
+ - **A preview** shown with `Data.overlay` stays until `Data.lift`; a settled
592
+ mutation never takes it along.
593
+
594
+ See [Mutations and optimistic state](#mutations-and-optimistic-state).
595
+
596
+ ### Why is it gone after navigating away?
597
+
598
+ Remote is a cache, and it forgets facts no active feature needs. Retention
599
+ roots come from the active Surfaces; after the grace period, `RetentionChanged`
600
+ collects everything they do not reach. A screen you left, and nothing else
601
+ reads, loses its rows, which is safe because coming back refetches them. To
602
+ keep something a screen does not read, name it in a retain entry or a
603
+ `connections` list; see [Retention](#retention-and-garbage-collection).
604
+
605
+ ### Why does a value the server has read `Failed`?
606
+
607
+ `Failed` with no `previous` and no request error means the stored value did not
608
+ **decode** against the Selection: the server's shape and the entity's Schema
609
+ disagree; the error is a `DecodeError` with the Schema's message. An
610
+ `Unavailable` error means the server answered without a field the Selection
611
+ names and will not answer with it; see
612
+ [When a field is withheld](#when-a-field-is-withheld). A failed request reads
613
+ the same way, with the request's error; see
614
+ [When a read fails](#when-a-read-fails). A
615
+ `RemoteProtocolError` means the client and server disagree on
616
+ `REMOTE_PROTOCOL_VERSION`, and nothing is silently accepted.
617
+
618
+ ### Why did a live update not show?
619
+
620
+ Live events carry cursors. One behind the Model's is a duplicate and is
621
+ dropped; one ahead of the next is a **gap**, recorded in `RemoteModel.gaps`
622
+ and not applied, so the screen keeps what the server last said rather than
623
+ a history with a hole in it. The host resynchronizes and clears the gap; see
624
+ [Live data](#live-data).
625
+
431
626
  ## Normalized entities and selections
432
627
 
433
628
  Remote stores an entity once by identity, regardless of how many Surfaces read
@@ -553,7 +748,49 @@ What Remote should do when the Model already contains the selected fields is a
553
748
 
554
749
  A refreshing policy emits `RefreshStarted` before the read, so the Projection
555
750
  becomes `Refreshing` without discarding the old value. Planning accepts `now`
556
- (default `Date.now`) as an input, so tests can control time.
751
+ as an input, so tests can control time. It defaults to `Date.now`, read at each
752
+ use, so fake timers (`vi.useFakeTimers`) move it; Effect's `TestClock` does not,
753
+ because a plan runs outside Effect, so a test that drives `TestClock` passes
754
+ `now`. A mutation's answer is dated the same way, by `Data.mutate`'s `now`
755
+ option.
756
+
757
+ A policy belongs to one `subscriptions` call, and covers every read in it. Reads
758
+ under different policies take a call each:
759
+
760
+ ```ts
761
+ const projectPage = Surface.at(ProjectPage, model =>
762
+ model.route._tag === 'project' ? Option.some({ projectId: model.route.projectId }) : Option.none(),
763
+ )
764
+ const owner = Data.active('Owner', () => Option.some(Data.get(UserSummary, 'u1')))
765
+
766
+ const pageEntries = Data.subscriptions({ page: projectPage })
767
+ // Retention is the domain's: one `retain` roots every read, so the second
768
+ // call's is left out rather than spread over the first's.
769
+ const { retain: _domainRetain, ...ownerEntries } = Data.subscriptions(
770
+ { owner },
771
+ { policy: RemotePolicy.staleWhileRevalidate({ maxAge: 5_000 }) },
772
+ )
773
+
774
+ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() => ({
775
+ ...pageEntries,
776
+ ...ownerEntries,
777
+ }))
778
+ ```
779
+
780
+ Retention stays the domain's: a call's `retain` entry roots every read any call
781
+ of the domain names, so neither call collects what the other reads. Keeping one
782
+ `retain` entry is all it takes; it waits for its own call's `grace`.
783
+
784
+ **Time reaches Remote only as a Message.** The plan is a function of the Remote
785
+ model, what is asked, and `now`, and it runs again only when one of those
786
+ changes; a page that sits still past its
787
+ `maxAge` would otherwise never be looked at again. So a read entry under
788
+ `staleWhileRevalidate` knows when the earliest value it holds ages out, sleeps
789
+ until then under the Effect clock, and emits `RefreshStarted` for what is due.
790
+ That marks the fields stale in the Model, and the plan that follows fetches
791
+ them, the same path a refresh button takes. Nothing reads the clock while a
792
+ Projection is read, so a Model reads the same twice, and a recorded one
793
+ replays.
557
794
 
558
795
  For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
559
796
 
@@ -571,25 +808,25 @@ const loaded = await Effect.runPromise(
571
808
  Remote Messages reduced into it. It never changes the semantics of the
572
809
  Projection itself.
573
810
 
574
- ### Refreshing from `update`
575
-
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.
811
+ A render that fetches nothing (SSR, a prerender) needs everything a page's
812
+ active Surfaces read, and one read can decide what another asks for: a page's
813
+ Blocks are known once its document is. `Data.satisfy` prefetches every active
814
+ Surface, cache-first, pass after pass until a pass plans nothing:
581
815
 
582
816
  ```ts
583
- const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
584
- const back = Data.lift(previewed, 'post-preview')
817
+ const ready = await Effect.runPromise(
818
+ Data.satisfy(model, actives).pipe(Effect.provide(clientLayer)),
819
+ )
585
820
  ```
586
821
 
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.
822
+ `actives` is the record `Data.wiring` takes. The passes are bounded (8 by
823
+ default, `{ passes }` to change it); a Surface still reading after the last
824
+ fails the Effect with `RemoteUnsatisfied`, which names it, rather than
825
+ rendering it loading. A read the server leaves unanswered settles as missing,
826
+ so it ends the loop; what runs out the passes is a chain whose every read
827
+ reveals one more.
828
+
829
+ ### Refreshing from `update`
593
830
 
594
831
  To revalidate what a screen already declares — a refresh button, a focus
595
832
  regained — hand its Projection (or a Surface without params) to `Data.refresh`:
@@ -610,12 +847,37 @@ since stale data is planned again under every policy, so the page is requested
610
847
  once. The Projection must be observed, as it is while it is on screen; for data
611
848
  nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
612
849
 
613
- - A refreshed connection's first page replaces its loaded pages, so items the
614
- server removed or reordered follow it; later pages are fetched again with
615
- `Data.next`.
850
+ - A refreshed connection is asked for again as one page the size of the widest
851
+ window reading it (including what "load more" grew), which replaces what it
852
+ held, so items the server removed or reordered follow it.
616
853
  - A read already in flight restarts instead of landing after the refresh.
617
854
  - Refreshing what is already refreshing, or nothing, returns the same Model.
618
855
 
856
+ ### Forgetting everything: a change of principal
857
+
858
+ Everything Remote knows, it knows for a principal: a value the server gave
859
+ this session, a tombstone for an id it answered nothing about, a field it
860
+ settled as unavailable, the rows of a list. Log in, log out, switch
861
+ organization, and none of it is trustworthy. `Data.forget` is the one
862
+ boundary:
863
+
864
+ ```ts
865
+ case 'SignedOut': {
866
+ return { model: Data.forget({ ...model, session: Option.none() }) }
867
+ }
868
+ ```
869
+
870
+ It performs no I/O and returns the Model with every server-derived fact gone:
871
+ values, tombstones, unavailable fields, connections, live cursors, failures,
872
+ and the reads in flight. Every active Surface's read and live entries restart,
873
+ so the screen asks again as whoever the client now is; a read or stream begun
874
+ before is interrupted rather than landing after. A mutation in flight is
875
+ treated as applied, so its answer writes nothing into the new store, and the
876
+ mutation sequence is kept, so request ids stay unique.
877
+
878
+ Who the client is belongs to the `RemoteClient` layer or the server's session,
879
+ not to Remote. `forget` only says that what was known no longer is.
880
+
619
881
  ## Queries and pagination
620
882
 
621
883
  Entities answer "which fields of this known thing?" A Query answers "which
@@ -694,11 +956,10 @@ const projects = Data.query(
694
956
  )
695
957
 
696
958
  projects.read(model)
697
- // RemoteData<Page<ProjectSummary>>
959
+ // RemoteData<Page<ProjectSummary>>: at most 25 items
698
960
 
699
- Data.next(model, projects) // QueryRef | undefined
700
- Data.previous(model, projects) // QueryRef | undefined
701
- Data.fetch(ref) // Command whose Message merges the page
961
+ Data.more(model, projects)
962
+ // Option<Model>: "load more", the window grown to 50; none when all are shown
702
963
  ```
703
964
 
704
965
  The connection stores entity references and explicit boundaries rather than one
@@ -707,13 +968,32 @@ Remote represents an honest gap instead of pretending the visible rows are
707
968
  adjacent.
708
969
 
709
970
  A query Projection is `Initial` until the page **and every selected field of its
710
- visible items** are present. Once a page lands, those items become ordinary
711
- entity requirements and can be fulfilled in the same planning loop.
712
-
713
- Two Projections of the same connection plan one query and select the union of
714
- their required fields. `Data.next` / `Data.previous` preserve the page size and
715
- use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
716
- not from guessing based on row counts.
971
+ visible items** are present. The `select` travels with the query: the server
972
+ returns the selected fields of the page's items with the edges, and one
973
+ `ConnectionMerged` merges the page and writes them, so a fresh list usually
974
+ lands `Ready` in one response. What the server leaves out (withheld fields,
975
+ nested targets it does not expand) becomes ordinary entity requirements and
976
+ is fetched next, as before; a server that predates payloads sends edges only
977
+ and the list reads the old two-round-trip way.
978
+
979
+ **A read shows at most its window.** Remote keeps one connection per query and
980
+ input, whatever window asks for it, so a picker's `first: 50` and a card's
981
+ `first: 3` of the same query share the rows, and each read is cut to its own
982
+ window: `first` from the start, `last` from the end. `hasNext` / `hasPrevious`
983
+ are true when the read cut rows or the connection's boundary says there are
984
+ more; they are never guessed from a row count.
985
+
986
+ Two Projections of the same connection plan one query, for the wider window,
987
+ and select the union of their fields. When a connection holds fewer rows than a
988
+ window asks for, the read entry asks only for the rows it lacks, after the
989
+ loaded end (or before the loaded start).
990
+
991
+ **"Load more" grows the window.** `Data.more(model, projection)` is the Model
992
+ with that read's window one page larger, called from `update`; the read entry
993
+ then fetches what the connection lacks. It is kept in Remote's store, under the
994
+ query, input and window first asked for, so a list holds no state of its own,
995
+ and it is forgotten when retention drops the connection. Another window of the
996
+ same query does not grow with it.
717
997
 
718
998
  ### Filtering a loaded list without asking the server
719
999
 
@@ -783,6 +1063,15 @@ the binding's `visible` rule; there is no `visible` here. This is safe only
783
1063
  because the client holds only rows the server already released to it, and a
784
1064
  local filter is not an access decision.
785
1065
 
1066
+ **Local execution is not a second authority.** `matching` and `filtered` derive
1067
+ conclusions from facts the server gave; they never promote those conclusions
1068
+ into knowledge of the server's whole dataset. Knowing `p1.ownerId = u7` says
1069
+ that `p1` matches `ProjectsByOwner(u7)`; it does not say that
1070
+ `ProjectsByOwner(u7)` is `[p1]`, and nothing here will say so unless a
1071
+ connection's boundaries, terminal at both ends, independently prove it. That
1072
+ line is what keeps Remote a cache of what the server owns rather than a local
1073
+ database with opinions of its own.
1074
+
786
1075
  ### An input that changes as fast as someone types
787
1076
 
788
1077
  A connection's identity is its query plus its **input**, which is exactly right
@@ -804,13 +1093,13 @@ the query reads** — which is ordinary application state, and
804
1093
  import { debounce } from 'foldkit-primitives/time'
805
1094
 
806
1095
  const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
807
- const SearchBox = Bundle.declare(SearchInput, 'search')
808
1096
 
809
1097
  // `latest` is what the box shows, so typing stays immediate.
810
1098
  // The settled OutMessage is what moves the field the query reads.
811
- Page.at(SearchBox, {
1099
+ // A step of the page's Bundle.compose(...).pipe(...):
1100
+ Bundle.withChild('search', SearchInput, {
812
1101
  args: { delayMs: 250 },
813
- onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
1102
+ onOut: out => model => ({ model: modifyFields(model, { postSearch: () => out.value }) }),
814
1103
  })
815
1104
  ```
816
1105
 
@@ -868,6 +1157,41 @@ A broken live stream is not a failed read. Its `ReadFailed` carries the
868
1157
  field: nothing was being read, and the values on screen are still what the
869
1158
  server last said.
870
1159
 
1160
+ ### When a field is withheld
1161
+
1162
+ A server may answer a read without a field it was asked for, and mean it: the
1163
+ Source's `authorize` withheld it from this principal, or the record it returned
1164
+ had no such field. The read result says so, in `settled`: the fields it asked
1165
+ for that this answer does not carry and a later one would not either. Why is
1166
+ not on the wire. The client records each as **unavailable**, one more kind of
1167
+ knowledge beside a value and a tombstone:
1168
+
1169
+ ```text
1170
+ missing not established; the planner asks
1171
+ present known
1172
+ stale known, and being asked again
1173
+ unavailable asked, and answered without: the planner does not ask again
1174
+ tombstone the whole entity is known absent
1175
+ ```
1176
+
1177
+ A Selection that names an unavailable field reads `Failed` with an
1178
+ `Unavailable` error naming the field, not `Loading` (nothing is fetching it)
1179
+ and not `NotFound` (the entity is there). What it is not is a seventh state:
1180
+ the remedy is the one a failed read has. Select without the field, in a
1181
+ Selection of its own if some principals may read it, or show the error. A
1182
+ Selection that does not name it is unaffected: `name` reads `Ready` while
1183
+ `privateNotes` is unavailable beside it.
1184
+
1185
+ The mark is forgotten by what forgets a failure: `Data.refresh` asks again,
1186
+ and a read, live patch or mutation result that writes the field clears it.
1187
+ It survives persistence and a server render's `Remote.resume`, so the browser
1188
+ does not ask once more for what the server already declined.
1189
+
1190
+ An id the server answers with nothing about, neither values nor settled fields,
1191
+ is a tombstone as before. A server settles a withheld field without reading
1192
+ its Source for an id whose every field is withheld, so an absent id and a
1193
+ hidden one answer alike, and existence is not told.
1194
+
871
1195
  ## Mutations and optimistic state
872
1196
 
873
1197
  Register mutations on the domain:
@@ -951,6 +1275,29 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
951
1275
  A successful server result can replace the request's temporary connection
952
1276
  overlays with confirmed ones in place.
953
1277
 
1278
+ ### Showing a change nobody has made
1279
+
1280
+ A mutation's `optimistic` operations show over the store while it is in flight.
1281
+ `Data.overlay` shows operations the same way with no request behind them, until
1282
+ `Data.lift`: a preview, in every Selection and view, of something not yet sent.
1283
+
1284
+ ```ts
1285
+ const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
1286
+ const back = Data.lift(previewed, 'post-preview')
1287
+ ```
1288
+
1289
+ - Both are called from `update`, and neither performs I/O or touches what the
1290
+ server said: the store beneath is as it was.
1291
+ - Showing an id again replaces what it showed. Lifting what was never shown
1292
+ returns the same Model.
1293
+ - An overlay's id is apart from every request's, so a mutation that settles does
1294
+ not take a preview with it.
1295
+ - A preview of an entity the server has not seen, such as an unsaved post, has
1296
+ only the fields the overlay holds. A Selection that reads more reads
1297
+ `Failed`, with an `Overlaid` error naming what is missing, since nothing will
1298
+ fetch it. Overlay every field the Selection reads, or preview through a
1299
+ smaller Selection.
1300
+
954
1301
  ### Reading past what is only pending
955
1302
 
956
1303
  Every projection reads the **visible** cache: the server-derived store under
@@ -988,25 +1335,12 @@ flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight
988
1335
 
989
1336
  ### Remote mutation vs Sync operation
990
1337
 
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.
1338
+ A Remote mutation requests the server now; its result updates a disposable
1339
+ cache, and there is no durable outbox. If losing an unsent edit would be data
1340
+ loss, it belongs to [`foldkit-sync`](../sync), not here (see [which state
1341
+ belongs here](#which-state-belongs-here)). For the same reason, an optimistic
1342
+ overlay is only a temporary view of server-owned data: do not persist one and
1343
+ treat it as a queue of edits.
1010
1344
 
1011
1345
  ## Live data
1012
1346
 
@@ -1033,8 +1367,9 @@ failure Message rather than mutating anything out of band.
1033
1367
  Remote is a cache, so it should be allowed to forget facts no active feature
1034
1368
  needs.
1035
1369
 
1036
- `Data.subscriptions` derives retention roots from the active Surfaces. A root
1037
- keeps:
1370
+ `Data.subscriptions` derives retention roots from the active Surfaces of every
1371
+ call on the domain, so a call's `retain` entry never collects what another call
1372
+ reads; `connections` given to any call are kept the same way. A root keeps:
1038
1373
 
1039
1374
  - the entities and fields its Projection requires;
1040
1375
  - referenced targets reached by nested selections;
@@ -1060,8 +1395,9 @@ never in one.
1060
1395
  ```ts
1061
1396
  import { RemotePersistence } from 'foldkit-remote'
1062
1397
 
1063
- // SSR: prefetch on the server, embed, then hydrate on the client.
1064
- // `snapshotOf` names the connections that survive; none, here.
1398
+ // A cache carried in the page: prefetch on the server, embed, then hydrate.
1399
+ // `snapshotOf` names the connections that survive; none, here. For a page
1400
+ // the browser resumes with foldkit-ssr, see Server rendering below.
1065
1401
  const text = RemotePersistence.dehydrate(
1066
1402
  RemotePersistence.snapshotOf(loaded.remote),
1067
1403
  { scope: userId },
@@ -1096,6 +1432,40 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
1096
1432
  discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
1097
1433
  hydration still changes the application through the reducer.
1098
1434
 
1435
+ ## Server rendering: `Remote.resume`
1436
+
1437
+ A snapshot is built for a cache that survived a reload: it keeps the whole
1438
+ entity store and deliberately no cursors. A page rendered a moment ago wants
1439
+ the opposite. `Remote.resume(Data)` is a resume part for
1440
+ [`foldkit-ssr`](../ssr/README.md) (in development), which sends exactly what
1441
+ the page's active Surfaces read, and resumes it in the browser:
1442
+
1443
+ ```ts
1444
+ const Page = SSR.plan(App, {
1445
+ id: 'project',
1446
+ state: Projection.pick(App.model.route),
1447
+ surfaces: [ProjectPageAt],
1448
+ parts: [Remote.resume(Data)],
1449
+ })
1450
+ ```
1451
+
1452
+ - **Only what is read crosses.** For each requirement, the fields it names,
1453
+ following its relations through the refs the store holds; for each
1454
+ connection, the fields its selection reads of each item. A field in the
1455
+ server's store that no Surface selects, such as a user's email, is not in the
1456
+ page.
1457
+ - **Connections cross whole**, with their boundaries, so "load more" knows
1458
+ where the server stopped.
1459
+ - **Live cursors cross with the entities they follow**, so a live
1460
+ subscription started in the browser asks from where the server's left off.
1461
+ - **Nothing else crosses.** Loading marks, failures, the mutation ledger,
1462
+ optimistic layers, gaps and retention start at their initial values.
1463
+
1464
+ The browser's read entry plans against the restored store, so it asks for
1465
+ none of it again. The capture is JSON through its own Schema, and a page whose
1466
+ capture does not decode is refused whole by `foldkit-ssr`. Give a second domain
1467
+ in one application its own `id`: `Remote.resume(Catalog, { id: 'catalog' })`.
1468
+
1099
1469
  ## How the server packages fit
1100
1470
 
1101
1471
  `foldkit-remote` owns **client cache semantics**, not your source database or
@@ -1150,15 +1520,20 @@ into private state:
1150
1520
  ```ts
1151
1521
  Data.inspect(model) // serializable cache/domain summary
1152
1522
  Data.plan(model, projection) // the entity plan a read would execute
1523
+ Data.meta(model, projection) // when what is shown was received, stale/loading
1153
1524
  Data.explain(model, query) // what one query read is, and currently is
1154
1525
  Remote.planQueries(...) // missing/stale query work
1155
1526
  Remote.inspectEntity(...) // one normalized entity
1156
1527
  ```
1157
1528
 
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.
1529
+ `Data.meta` is for views, not debugging: `updatedAt` is the newest server
1530
+ write among what the projection shows (`undefined` when nothing shown was
1531
+ received), with `stale` and `loading` beside it, so "updated 5s ago" reads
1532
+ the Model like any other render.
1533
+
1534
+ These are pure and useful in tests, tooling, and debugging. For a read that
1535
+ stays `Initial`, `Data.why` and `Data.plan` are the tools; see
1536
+ [Why is it still `Initial`?](#why-is-it-still-initial).
1162
1537
 
1163
1538
  `Data.inspect(model).loading` answers the other one — **"what is Remote doing
1164
1539
  right now?"** — with the `entity\0id\0field` marks of the reads in flight,
@@ -1262,8 +1637,16 @@ requirements for the concrete target.
1262
1637
  options.
1263
1638
 
1264
1639
  `Remote.storeOf` is the visible store after pending optimistic layers. Reads are
1265
- memoized per visible store (and connection where applicable), so equal reads of
1266
- one Model state assemble/decode once and return one value.
1640
+ memoized per visible store (and, for a list, its connection and overlays), so
1641
+ equal reads of one Model state assemble/decode once and return one value.
1642
+
1643
+ Across Model states, an entity's or row's decoded value stays the same object
1644
+ while its entity's fields are equal: a refetch or live patch that brought equal
1645
+ data hands the view the value it already rendered, so a keyed row's lazy view
1646
+ does not re-run. A write to any field of the entity, even one the row does not
1647
+ select, or a pending optimistic patch on it while other writes land, gives the
1648
+ row a new value. When the store changed, the `RemoteData` around a value and a
1649
+ page's `items` array are new.
1267
1650
 
1268
1651
  `Remote.prefetch(bound, model, projection, options?)` runs the same plans
1269
1652
  imperatively and reduces their results into a new Model.
@@ -1272,7 +1655,10 @@ imperatively and reduces their results into a new Model.
1272
1655
 
1273
1656
  `updateRemote(remote, message)` is the pure reducer over Remote facts;
1274
1657
  `Data.update` applies it to a bound Remote state and `Data.reduce` applies it to
1275
- the application Model.
1658
+ the application Model. The common Messages that change nothing (a duplicate live
1659
+ event, a repeated `ReadStarted`, an empty `Hydrated`, a retention pass that
1660
+ collects nothing) return the Model they were given, so Foldkit neither renders
1661
+ nor recomputes Subscription dependencies.
1276
1662
 
1277
1663
  Its Messages cover reads and refreshes, mutations, live events/gaps, query
1278
1664
  connections, retention, and hydration. `Remote.messages` is the case record for
@@ -1297,21 +1683,30 @@ Remote.retain(projections, toMessage?, { connections?, grace? })
1297
1683
  active roots -> RetentionChanged
1298
1684
  ```
1299
1685
 
1300
- The observe entry's dependencies are the current plan and the Model's refresh
1301
- generation (`{ requirements, queries, refresh }`), so `Data.refresh` restarts
1686
+ The observe entry's dependencies are the current plan, the Model's refresh
1687
+ generation, and when a held value next ages out
1688
+ (`{ requirements, queries, refresh, expires }`), so `Data.refresh` restarts
1302
1689
  it. If entity requirements and queries are both empty, it performs no I/O. A
1303
1690
  refreshing read emits `RefreshStarted` first, then a result Message.
1304
1691
 
1305
- Every emitted Message changes the Model, so Foldkit recomputes Subscription
1692
+ A Message that changes the Model makes Foldkit recompute Subscription
1306
1693
  dependencies. For example, after a query page lands, the next computation sees
1307
1694
  the page's entity ids and plans any selected fields those entities still lack.
1308
1695
  An interrupted identical read is joined by the client coalescer instead of
1309
1696
  needlessly restarted.
1310
1697
 
1698
+ The live entry's dependencies are what the Surface reads live, the refresh
1699
+ floor, and the stream's cursor. The stream stays open as events advance the
1700
+ cursor; the cursor is only where a stream restarted for another reason resumes.
1701
+
1311
1702
  Retention uses the Projection requirements/connections as roots and eventually
1312
1703
  emits `RetentionChanged`; the reducer's pure `gc` keeps everything reachable
1313
1704
  from those roots plus pending work and collects the rest.
1314
1705
 
1706
+ The entries `Data.subscriptions` returns carry these dependencies in their
1707
+ types: `ReadDependencies` for `<key>.read`, `LiveDependencies` for
1708
+ `<key>.live`, and `RetentionRoots` for `retain`.
1709
+
1315
1710
  ### Connections by hand
1316
1711
 
1317
1712
  A connection stores entity references in segments with explicit boundaries, so
@@ -1340,7 +1735,7 @@ them.
1340
1735
 
1341
1736
  ### Mutations by hand
1342
1737
 
1343
- `Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic })` is
1738
+ `Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic, now })` is
1344
1739
  the one-step imperative start/run/settle form for SSR and tests.
1345
1740
 
1346
1741
  `Remote.mutate(mutation, input, requestId)` is the transport call itself. It
@@ -1348,7 +1743,10 @@ decodes the typed `Output` and returns normalized entity patches and confirmed
1348
1743
  connection changes for a caller to reduce through `updateRemote`.
1349
1744
 
1350
1745
  `MutationSucceeded` reconciles at most once per `requestId`; an unknown or
1351
- already-applied settlement is a no-op.
1746
+ already-applied settlement is a no-op. It carries `now`, the clock reading of
1747
+ the answer, which dates the entities it writes as a read's `now` does, so a
1748
+ freshness policy ages them from then. `Data.mutate` and `Remote.mutateInto`
1749
+ stamp it from their `now` option, `Date.now` by default.
1352
1750
 
1353
1751
  ### The transport seam
1354
1752
 
@@ -1391,15 +1789,6 @@ Remote does **not** own:
1391
1789
  - authentication; server-side authorization belongs at the Source boundary;
1392
1790
  - a background scheduler or general-purpose database/query engine.
1393
1791
 
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
1792
  ## Limits
1404
1793
 
1405
1794
  - Effect RPC is the wire contract; the application supplies deployment layers.