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 +582 -193
- package/dist/index.d.mts +596 -42
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1101 -239
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -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.
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
290
|
+
foldData.subscriptions({
|
|
222
291
|
project: Surface.at(ProjectPage, model =>
|
|
223
292
|
model.route._tag === 'project'
|
|
224
|
-
? { projectId: model.route.projectId }
|
|
225
|
-
:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
272
|
-
const clientLayer = Remote.clientLayer(rpcClient)
|
|
273
|
-
```
|
|
379
|
+
## Routing Remote's Messages
|
|
274
380
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
391
|
+
const Message = defineMessageUnion({ ...Remote.messages })
|
|
284
392
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
402
|
+
### One value with `foldkit-bundle`: `Data.wiring`
|
|
295
403
|
|
|
296
|
-
The integration steps above —
|
|
297
|
-
|
|
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'
|
|
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,
|
|
358
|
-
|
|
359
|
-
`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
584
|
-
|
|
817
|
+
const ready = await Effect.runPromise(
|
|
818
|
+
Data.satisfy(model, actives).pipe(Effect.provide(clientLayer)),
|
|
819
|
+
)
|
|
585
820
|
```
|
|
586
821
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
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.
|
|
700
|
-
|
|
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.
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
1037
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
|
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
|
|
1301
|
-
generation
|
|
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
|
-
|
|
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.
|