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