foldkit-remote 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +471 -159
- package/dist/index.d.mts +378 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +644 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -1,30 +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
|
+
})
|
|
11
|
+
|
|
12
|
+
RemoteData.render(project, {
|
|
13
|
+
loading: () => ProjectSkeleton(),
|
|
14
|
+
notFound: () => NoSuchProject(),
|
|
15
|
+
failed: error => ErrorView(error),
|
|
16
|
+
data: project => ProjectView(project),
|
|
17
|
+
})
|
|
18
|
+
```
|
|
9
19
|
|
|
10
|
-
|
|
20
|
+
No fetch call, no cache object, no loading flag. While the page is active,
|
|
21
|
+
Remote fetches only the fields the Model lacks, stores each entity once, and
|
|
22
|
+
hands the view one of six states rather than an `undefined`. Every result is a
|
|
23
|
+
Message through one reducer, so a screen replays from a recorded Model.
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
> the Model.**
|
|
25
|
+
Remote is for facts the **server owns**. An edit the client authors, which
|
|
26
|
+
must survive being offline, belongs to [`foldkit-sync`](../sync).
|
|
15
27
|
|
|
16
|
-
|
|
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.
|
|
82
|
+
|
|
83
|
+
## Find what you need
|
|
84
|
+
|
|
85
|
+
Read the [first run](#the-first-run-one-entity-one-surface) once, then jump to
|
|
86
|
+
what you are doing:
|
|
87
|
+
|
|
88
|
+
| Task | Start here |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| See it work before there is a server | [Step 5](#5-provide-a-client) |
|
|
91
|
+
| A read stays `Initial` | [Why is it still `Initial`?](#why-is-it-still-initial) |
|
|
92
|
+
| A list shows old rows | [Why is it stale?](#why-is-it-stale) |
|
|
93
|
+
| A saved change showed, then vanished | [Why did my change disappear?](#why-did-my-change-disappear) |
|
|
94
|
+
| Data was gone when I came back | [Why is it gone?](#why-is-it-gone-after-navigating-away) |
|
|
95
|
+
| Show a failed read, and let the user retry | [When a read fails](#when-a-read-fails) |
|
|
96
|
+
| Load an ordered, paged list | [Queries and pagination](#queries-and-pagination) |
|
|
97
|
+
| Filter a list already on screen | [Filtering a loaded list](#filtering-a-loaded-list-without-asking-the-server) |
|
|
98
|
+
| Save, and show the result before the server answers | [Mutations](#mutations-and-optimistic-state) |
|
|
99
|
+
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
100
|
+
| Keep data across a reload | [Hydration](#persistence-and-hydration) |
|
|
101
|
+
| Hand a server render's data to the browser | [Server rendering](#server-rendering-remoteresume) |
|
|
102
|
+
| Answer the requests on the server | [How the server packages fit](#how-the-server-packages-fit) |
|
|
24
103
|
|
|
25
104
|
## Which state belongs here?
|
|
26
105
|
|
|
27
|
-
|
|
106
|
+
One owner per datum:
|
|
28
107
|
|
|
29
108
|
| State | Owner |
|
|
30
109
|
| --- | --- |
|
|
@@ -33,23 +112,13 @@ A useful rule across Foldkit Plus is **one owner per datum**:
|
|
|
33
112
|
| Client-authored state that must survive offline and converge | `foldkit-sync` |
|
|
34
113
|
| Local state represented in the URL or another store | the Model, observed by `foldkit-mirror` |
|
|
35
114
|
|
|
36
|
-
The distinction between Remote and Sync is especially important:
|
|
37
|
-
|
|
38
115
|
```text
|
|
39
|
-
Remote
|
|
40
|
-
server owns the fact
|
|
41
|
-
cache is disposable
|
|
42
|
-
refetch is recovery
|
|
43
|
-
|
|
44
|
-
Sync
|
|
45
|
-
client owns the edit
|
|
46
|
-
intent must survive offline/restart
|
|
47
|
-
replay + reconciliation is recovery
|
|
116
|
+
Remote Sync
|
|
117
|
+
server owns the fact client owns the edit
|
|
118
|
+
cache is disposable intent must survive offline/restart
|
|
119
|
+
refetch is recovery replay + reconciliation is recovery
|
|
48
120
|
```
|
|
49
121
|
|
|
50
|
-
A Remote mutation is therefore an immediate request against server-owned data.
|
|
51
|
-
Its result updates the cache. It is **not** a durable local intent queue.
|
|
52
|
-
|
|
53
122
|
A Surface may project all of these owners at once. Observation does not transfer
|
|
54
123
|
ownership.
|
|
55
124
|
|
|
@@ -67,9 +136,11 @@ requirements with the cache, then requests missing or stale fields. The
|
|
|
67
136
|
result returns as a Message; only reducing it changes what the next read sees.
|
|
68
137
|
Rendering the same Projection twice starts no work by itself.
|
|
69
138
|
|
|
70
|
-
|
|
139
|
+
> **A Remote Projection does not fetch. It declares what server-owned facts a
|
|
140
|
+
> consumer requires. I/O happens outside render, and its results reduce back into
|
|
141
|
+
> the Model.**
|
|
71
142
|
|
|
72
|
-
|
|
143
|
+
Four roles carry the loop, and most application code needs no more:
|
|
73
144
|
|
|
74
145
|
| Piece | Responsibility |
|
|
75
146
|
| --- | --- |
|
|
@@ -78,7 +149,11 @@ Most application code only needs four roles:
|
|
|
78
149
|
| `Data.reduce` | reduce returned Remote Messages into the application's `Remote.Model` |
|
|
79
150
|
| `RemoteClient` | perform the actual read/query/mutate/live I/O |
|
|
80
151
|
|
|
81
|
-
|
|
152
|
+
One rule keeps the cache coherent: **`Data.reduce` is the only writer.** Local
|
|
153
|
+
application state changes with `modifyFields` inside `update`, like anywhere
|
|
154
|
+
else; a server fact only enters the cache as a Remote Message. Never install
|
|
155
|
+
cache state with a ref `set`: requirements, staleness and tombstones stay
|
|
156
|
+
consistent only because every fact arrives through the reducer.
|
|
82
157
|
|
|
83
158
|
## Install
|
|
84
159
|
|
|
@@ -89,9 +164,12 @@ pnpm add foldkit-remote foldkit-entity
|
|
|
89
164
|
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
|
|
90
165
|
package. The server-side interpreter is [`foldkit-remote-server`](../remote-server).
|
|
91
166
|
|
|
92
|
-
##
|
|
167
|
+
## The first run: one entity, one Surface
|
|
93
168
|
|
|
94
|
-
|
|
169
|
+
Six steps take a project's name from a server to a screen. Each says what it
|
|
170
|
+
does, and what it deliberately does not.
|
|
171
|
+
|
|
172
|
+
### 1. Declare an entity and what this screen needs of it
|
|
95
173
|
|
|
96
174
|
```ts
|
|
97
175
|
import { Schema } from 'effect'
|
|
@@ -114,14 +192,13 @@ const ProjectSummary = Entity.select(Project, {
|
|
|
114
192
|
```
|
|
115
193
|
|
|
116
194
|
The entity comes from [`foldkit-entity`](../entity), which declares a domain
|
|
117
|
-
without Remote in it
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
need; reach for `Entity.define` otherwise, which is most of the time.
|
|
195
|
+
without Remote in it, so the same declaration can serve the server's database
|
|
196
|
+
binding and your forms too. It is also what gives an entity
|
|
197
|
+
[relations](#entities-declared-with-foldkit-entity), derived members, and a
|
|
198
|
+
[query body](#queries-and-pagination) to point at. Nothing here touches a
|
|
199
|
+
network.
|
|
123
200
|
|
|
124
|
-
|
|
201
|
+
### 2. Give Remote a place in the Model, and route its Messages
|
|
125
202
|
|
|
126
203
|
```ts
|
|
127
204
|
import { defineMessageUnion } from 'foldkit/message'
|
|
@@ -144,19 +221,10 @@ const Model = Schema.Struct({
|
|
|
144
221
|
type Model = typeof Model.Type
|
|
145
222
|
|
|
146
223
|
const Message = defineMessageUnion({
|
|
147
|
-
|
|
224
|
+
GotRemoteMessage: { message: Remote.Message },
|
|
148
225
|
})
|
|
149
226
|
type Message = typeof Message.Type
|
|
150
227
|
|
|
151
|
-
function update(
|
|
152
|
-
model: Model,
|
|
153
|
-
message: Message,
|
|
154
|
-
): Update.Return<Model, Message, RemoteClient> {
|
|
155
|
-
// Remote owns the `remote` Submodel, so all Remote Messages go through its reducer.
|
|
156
|
-
if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
|
|
157
|
-
return { model }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
228
|
const App = Surface.application({
|
|
161
229
|
Model,
|
|
162
230
|
Message,
|
|
@@ -168,16 +236,33 @@ const App = Surface.application({
|
|
|
168
236
|
})
|
|
169
237
|
|
|
170
238
|
const Data = Remote.make({
|
|
171
|
-
model: App.
|
|
239
|
+
model: App.model.remote,
|
|
172
240
|
entities: [Project],
|
|
173
241
|
})
|
|
242
|
+
|
|
243
|
+
// Remote under one variant of the union, the shape Foldkit gives a Submodel.
|
|
244
|
+
const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
|
|
245
|
+
|
|
246
|
+
function update(
|
|
247
|
+
model: Model,
|
|
248
|
+
message: Message,
|
|
249
|
+
): Update.Return<Model, Message, RemoteClient> {
|
|
250
|
+
return Message.match(message, {
|
|
251
|
+
// Remote owns the `remote` Submodel: everything it says goes through its reducer.
|
|
252
|
+
GotRemoteMessage: ({ message }) => foldData(model, message),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
174
255
|
```
|
|
175
256
|
|
|
176
|
-
`Remote.Model` is not a second
|
|
177
|
-
application Model
|
|
178
|
-
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).)
|
|
179
264
|
|
|
180
|
-
|
|
265
|
+
### 3. Declare what the screen reads
|
|
181
266
|
|
|
182
267
|
```ts
|
|
183
268
|
const ProjectPage = App.surface('ProjectPage', {
|
|
@@ -192,15 +277,16 @@ const ProjectPage = App.surface('ProjectPage', {
|
|
|
192
277
|
```
|
|
193
278
|
|
|
194
279
|
`ProjectPage` reads a `RemoteData<{ id: string; name: string }>` from the Model.
|
|
195
|
-
If those fields are absent, the Projection still does not fetch them
|
|
280
|
+
If those fields are absent, the Projection still does not fetch them: it says
|
|
281
|
+
what it needs, and something else decides whether to ask.
|
|
196
282
|
|
|
197
|
-
|
|
283
|
+
### 4. Let the active screen drive the fetching
|
|
198
284
|
|
|
199
285
|
```ts
|
|
200
286
|
import * as Subscription from 'foldkit/subscription'
|
|
201
287
|
|
|
202
288
|
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
203
|
-
|
|
289
|
+
foldData.subscriptions({
|
|
204
290
|
project: Surface.at(ProjectPage, model =>
|
|
205
291
|
model.route._tag === 'project'
|
|
206
292
|
? { projectId: model.route.projectId }
|
|
@@ -212,9 +298,36 @@ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
|
212
298
|
|
|
213
299
|
`Surface.at` makes activation a fact of the Model. When the route activates the
|
|
214
300
|
page, Remote sees its requirements, diffs them against the cache, and fetches
|
|
215
|
-
only what is missing. When the page is inactive, it creates no read work.
|
|
301
|
+
only what is missing. When the page is inactive, it creates no read work. This
|
|
302
|
+
is the one step that causes I/O, and it does so only while the screen is on.
|
|
303
|
+
|
|
304
|
+
### 5. Provide a client
|
|
305
|
+
|
|
306
|
+
The Subscription needs a `RemoteClient`. Before there is a server, a backend
|
|
307
|
+
held in memory answers through the same handlers a real one uses:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { RemoteServer } from 'foldkit-remote-server'
|
|
216
311
|
|
|
217
|
-
|
|
312
|
+
const clientLayer = RemoteServer.memory({
|
|
313
|
+
domain: Data,
|
|
314
|
+
rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
|
|
315
|
+
}).layer
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
With a server, adapt your configured Effect RPC client instead; the
|
|
319
|
+
[server guide](../remote-server/README.md) supplies the other side:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const clientLayer = Remote.clientLayer(rpcClient)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Either is a Layer the runtime provides to `subscriptions` and Commands. The
|
|
326
|
+
transport itself is not owned by Remote. See
|
|
327
|
+
[`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory)
|
|
328
|
+
for what the in-memory one can and cannot do.
|
|
329
|
+
|
|
330
|
+
### 6. Watch it arrive
|
|
218
331
|
|
|
219
332
|
```text
|
|
220
333
|
ProjectPage Projection
|
|
@@ -241,28 +354,39 @@ Remote.Model
|
|
|
241
354
|
ProjectPage now reads Ready(...)
|
|
242
355
|
```
|
|
243
356
|
|
|
244
|
-
The first
|
|
245
|
-
|
|
246
|
-
runtime must install `subscriptions`.
|
|
247
|
-
`Loading` during the first request, and `Ready` once its result is
|
|
357
|
+
The first run starts on the `home` route, so no project read runs yet. Your
|
|
358
|
+
route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
|
|
359
|
+
runtime must install `subscriptions`. The page then reads `Initial` until work
|
|
360
|
+
starts, `Loading` during the first request, and `Ready` once its result is
|
|
361
|
+
reduced. If it stays `Initial`, see [Why is it still `Initial`?](#why-is-it-still-initial).
|
|
362
|
+
|
|
363
|
+
## Routing Remote's Messages
|
|
248
364
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
365
|
+
The first run folds Remote's Messages under one variant of the application's
|
|
366
|
+
union, the shape Foldkit gives a Submodel. The fold's `fetch`, `mutate`, and
|
|
367
|
+
`subscriptions` yield that variant too, with the lift recorded so a Story
|
|
368
|
+
resolves a fetch or a mutation by Remote's own answer.
|
|
369
|
+
|
|
370
|
+
The shorter path, for an `update` that only reduces, spreads Remote's cases
|
|
371
|
+
into the union and narrows by tag; the rest of `update` is then a partial
|
|
372
|
+
match over tags the application never handles:
|
|
252
373
|
|
|
253
374
|
```ts
|
|
254
|
-
const
|
|
375
|
+
const Message = defineMessageUnion({ ...Remote.messages })
|
|
376
|
+
|
|
377
|
+
function update(model: Model, message: Message): Update.Return<Model, Message, RemoteClient> {
|
|
378
|
+
if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
|
|
379
|
+
return { model }
|
|
380
|
+
}
|
|
255
381
|
```
|
|
256
382
|
|
|
257
|
-
|
|
258
|
-
`
|
|
259
|
-
is not owned by Remote.
|
|
383
|
+
The rest of this guide uses the fold. With the spread, read `Data.subscriptions`
|
|
384
|
+
for `foldData.subscriptions` and `Data.fetch` for `foldData.fetch`.
|
|
260
385
|
|
|
261
|
-
|
|
386
|
+
### One value with `foldkit-bundle`: `Data.wiring`
|
|
262
387
|
|
|
263
|
-
The integration steps above —
|
|
264
|
-
|
|
265
|
-
uses `foldkit-bundle`:
|
|
388
|
+
The integration steps above — fold or reduce, derive the Subscriptions,
|
|
389
|
+
provide the client — are one value when the application uses `foldkit-bundle`:
|
|
266
390
|
|
|
267
391
|
```ts
|
|
268
392
|
import { Bundle } from 'foldkit-bundle'
|
|
@@ -321,14 +445,16 @@ The states are:
|
|
|
321
445
|
flight.
|
|
322
446
|
- **`Ready`** — every selected field is present and decodes.
|
|
323
447
|
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
324
|
-
- **`Failed`** — stored server data does not decode against the Selection,
|
|
325
|
-
|
|
326
|
-
`
|
|
448
|
+
- **`Failed`** — stored server data does not decode against the Selection, the
|
|
449
|
+
read or query behind it failed, or the server settled a selected field
|
|
450
|
+
without a value (an `Unavailable` error; see
|
|
451
|
+
[When a field is withheld](#when-a-field-is-withheld)). A value that was
|
|
452
|
+
already shown is kept as `previous`.
|
|
327
453
|
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
328
|
-
deleted it, or the server was asked for it by id and answered
|
|
329
|
-
id is then known absent, whether it never existed, is gone, or
|
|
330
|
-
|
|
331
|
-
write brings it back. The target of a returned ref is not marked this way: a
|
|
454
|
+
deleted it, or the server was asked for it by id and answered with nothing
|
|
455
|
+
about it. That id is then known absent, whether it never existed, is gone, or
|
|
456
|
+
is hidden whole from this principal. It is not planned again until
|
|
457
|
+
`Data.refresh` forces it or a write brings it back. The target of a returned ref is not marked this way: a
|
|
332
458
|
server need not expand a relation that rides on a request, and the planner asks
|
|
333
459
|
for such a target by id next, which is when its absence is learned.
|
|
334
460
|
|
|
@@ -337,6 +463,9 @@ no active Surface may remain `Initial` forever. Rendering a spinner for
|
|
|
337
463
|
`Initial` can therefore hide an activation/wiring mistake; `Loading` is the
|
|
338
464
|
state that actually means "wait for this request."
|
|
339
465
|
|
|
466
|
+
When a read sits at `Initial` and you expected data, ask the Model why with
|
|
467
|
+
`Data.why`; see [Why is it still `Initial`?](#why-is-it-still-initial).
|
|
468
|
+
|
|
340
469
|
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
341
470
|
compile time.
|
|
342
471
|
|
|
@@ -380,6 +509,102 @@ spinner that never ends or an error nobody can act on.
|
|
|
380
509
|
Reach for `match` instead when the six states really do draw differently — it
|
|
381
510
|
stays the exhaustive fold, and `render` does not replace it.
|
|
382
511
|
|
|
512
|
+
## When it does not do what you expect
|
|
513
|
+
|
|
514
|
+
Each of these is a design of the package showing through, not a fault. The
|
|
515
|
+
symptom names the section; the cure is one call.
|
|
516
|
+
|
|
517
|
+
### Why is it still `Initial`?
|
|
518
|
+
|
|
519
|
+
`Initial` means required data is absent and **nothing is fetching it**, so a
|
|
520
|
+
spinner here hides a wiring mistake. Ask the Model:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
|
|
524
|
+
// { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
|
|
525
|
+
// message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
- `NotObserved`: no active Surface reads it. The Surface is not in the record
|
|
529
|
+
you gave `Data.subscriptions`, or its params are `undefined` for this Model
|
|
530
|
+
(the route is not there yet).
|
|
531
|
+
- `NotFetching`: an active Surface reads it and nothing started a request,
|
|
532
|
+
which almost always means Remote's Subscriptions are not installed in the
|
|
533
|
+
runtime.
|
|
534
|
+
- Without `surfaces` it can only say `Unknown`. For every other state it says
|
|
535
|
+
what the state means in words.
|
|
536
|
+
|
|
537
|
+
`Data.plan(model, projection)` shows whether Remote believes anything is
|
|
538
|
+
missing at all.
|
|
539
|
+
|
|
540
|
+
### Why is it stale?
|
|
541
|
+
|
|
542
|
+
Under the default `RemotePolicy.cacheFirst`, a present field is never asked for
|
|
543
|
+
again. That is the point of a cache, and the reason a list shows what the
|
|
544
|
+
server said last time. To see change:
|
|
545
|
+
|
|
546
|
+
- **On demand**: `Data.refresh(model, projection)` from `update` marks what the
|
|
547
|
+
screen reads `Refreshing` and the active Subscription fetches it again, old
|
|
548
|
+
value still on screen. A refresh button, a window regaining focus.
|
|
549
|
+
- **By age**: `RemotePolicy.staleWhileRevalidate({ maxAge })` refetches a value
|
|
550
|
+
older than `maxAge` the next time it is planned.
|
|
551
|
+
- **Always**: `RemotePolicy.networkOnly` requests every selected field, cached
|
|
552
|
+
value visible meanwhile.
|
|
553
|
+
- **As it happens**: `Data.live` instead of `Data.get`, with a server that
|
|
554
|
+
publishes changes; see [Live data](#live-data).
|
|
555
|
+
|
|
556
|
+
Connections have no age: a list re-queries when invalidated, refreshed, or
|
|
557
|
+
under `networkOnly`. See [Reading, policies, and prefetch](#reading-policies-and-prefetch).
|
|
558
|
+
|
|
559
|
+
### Why did my change disappear?
|
|
560
|
+
|
|
561
|
+
A mutation's `optimistic` patches are layers over the store while the request
|
|
562
|
+
is in flight. When the server answers, the layers come off and the cache shows
|
|
563
|
+
what the server said:
|
|
564
|
+
|
|
565
|
+
- **It failed.** `MutationFailed` removes the layers; `Data.mutation(model,
|
|
566
|
+
requestId)` reads `Failed` with the error. Nothing retries it: the mutation
|
|
567
|
+
is not a durable queue, and if losing it is data loss the edit belongs to
|
|
568
|
+
[`foldkit-sync`](../sync).
|
|
569
|
+
- **It succeeded, but the server's result did not include the field.** The
|
|
570
|
+
layer came off and the store still holds the old value, until a read or the
|
|
571
|
+
result writes the new one. A mutation `Output` that returns the changed
|
|
572
|
+
entity settles this at once.
|
|
573
|
+
- **A preview** shown with `Data.overlay` stays until `Data.lift`; a settled
|
|
574
|
+
mutation never takes it along.
|
|
575
|
+
|
|
576
|
+
See [Mutations and optimistic state](#mutations-and-optimistic-state).
|
|
577
|
+
|
|
578
|
+
### Why is it gone after navigating away?
|
|
579
|
+
|
|
580
|
+
Remote is a cache, and it forgets facts no active feature needs. Retention
|
|
581
|
+
roots come from the active Surfaces; after the grace period, `RetentionChanged`
|
|
582
|
+
collects everything they do not reach. A screen you left, and nothing else
|
|
583
|
+
reads, loses its rows, which is safe because coming back refetches them. To
|
|
584
|
+
keep something a screen does not read, name it in a retain entry or a
|
|
585
|
+
`connections` list; see [Retention](#retention-and-garbage-collection).
|
|
586
|
+
|
|
587
|
+
### Why does a value the server has read `Failed`?
|
|
588
|
+
|
|
589
|
+
`Failed` with no `previous` and no request error means the stored value did not
|
|
590
|
+
**decode** against the Selection: the server's shape and the entity's Schema
|
|
591
|
+
disagree; the error is a `DecodeError` with the Schema's message. An
|
|
592
|
+
`Unavailable` error means the server answered without a field the Selection
|
|
593
|
+
names and will not answer with it; see
|
|
594
|
+
[When a field is withheld](#when-a-field-is-withheld). A failed request reads
|
|
595
|
+
the same way, with the request's error; see
|
|
596
|
+
[When a read fails](#when-a-read-fails). A
|
|
597
|
+
`RemoteProtocolError` means the client and server disagree on
|
|
598
|
+
`REMOTE_PROTOCOL_VERSION`, and nothing is silently accepted.
|
|
599
|
+
|
|
600
|
+
### Why did a live update not show?
|
|
601
|
+
|
|
602
|
+
Live events carry cursors. One behind the Model's is a duplicate and is
|
|
603
|
+
dropped; one ahead of the next is a **gap**, recorded in `RemoteModel.gaps`
|
|
604
|
+
and not applied, so the screen keeps what the server last said rather than
|
|
605
|
+
a history with a hole in it. The host resynchronizes and clears the gap; see
|
|
606
|
+
[Live data](#live-data).
|
|
607
|
+
|
|
383
608
|
## Normalized entities and selections
|
|
384
609
|
|
|
385
610
|
Remote stores an entity once by identity, regardless of how many Surfaces read
|
|
@@ -507,6 +732,16 @@ A refreshing policy emits `RefreshStarted` before the read, so the Projection
|
|
|
507
732
|
becomes `Refreshing` without discarding the old value. Planning accepts `now`
|
|
508
733
|
(default `Date.now`) as an input, so tests can control time.
|
|
509
734
|
|
|
735
|
+
**Time reaches Remote only as a Message.** The plan is a function of the Model
|
|
736
|
+
and `now`, and it runs when the Model changes; a page that sits still past its
|
|
737
|
+
`maxAge` would otherwise never be looked at again. So a read entry under
|
|
738
|
+
`staleWhileRevalidate` knows when the earliest value it holds ages out, sleeps
|
|
739
|
+
until then under the Effect clock, and emits `RefreshStarted` for what is due.
|
|
740
|
+
That marks the fields stale in the Model, and the plan that follows fetches
|
|
741
|
+
them, the same path a refresh button takes. Nothing reads the clock while a
|
|
742
|
+
Projection is read, so a Model reads the same twice, and a recorded one
|
|
743
|
+
replays.
|
|
744
|
+
|
|
510
745
|
For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
|
|
511
746
|
|
|
512
747
|
```ts
|
|
@@ -525,24 +760,6 @@ Projection itself.
|
|
|
525
760
|
|
|
526
761
|
### Refreshing from `update`
|
|
527
762
|
|
|
528
|
-
### Showing a change nobody has made
|
|
529
|
-
|
|
530
|
-
A mutation's `optimistic` operations show over the store while it is in flight.
|
|
531
|
-
`Data.overlay` shows operations the same way with no request behind them, until
|
|
532
|
-
`Data.lift`: a preview, in every Selection and view, of something not yet sent.
|
|
533
|
-
|
|
534
|
-
```ts
|
|
535
|
-
const previewed = Data.overlay(model, 'post-preview', [Project.patch(id, { name: draft })])
|
|
536
|
-
const back = Data.lift(previewed, 'post-preview')
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
- Both are called from `update`, and neither performs I/O or touches what the
|
|
540
|
-
server said: the store beneath is as it was.
|
|
541
|
-
- Showing an id again replaces what it showed. Lifting what was never shown
|
|
542
|
-
returns the same Model.
|
|
543
|
-
- An overlay's id is apart from every request's, so a mutation that settles does
|
|
544
|
-
not take a preview with it.
|
|
545
|
-
|
|
546
763
|
To revalidate what a screen already declares — a refresh button, a focus
|
|
547
764
|
regained — hand its Projection (or a Surface without params) to `Data.refresh`:
|
|
548
765
|
|
|
@@ -568,6 +785,31 @@ nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
|
568
785
|
- A read already in flight restarts instead of landing after the refresh.
|
|
569
786
|
- Refreshing what is already refreshing, or nothing, returns the same Model.
|
|
570
787
|
|
|
788
|
+
### Forgetting everything: a change of principal
|
|
789
|
+
|
|
790
|
+
Everything Remote knows, it knows for a principal: a value the server gave
|
|
791
|
+
this session, a tombstone for an id it answered nothing about, a field it
|
|
792
|
+
settled as unavailable, the rows of a list. Log in, log out, switch
|
|
793
|
+
organization, and none of it is trustworthy. `Data.forget` is the one
|
|
794
|
+
boundary:
|
|
795
|
+
|
|
796
|
+
```ts
|
|
797
|
+
case 'SignedOut': {
|
|
798
|
+
return { model: Data.forget({ ...model, session: Option.none() }) }
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
It performs no I/O and returns the Model with every server-derived fact gone:
|
|
803
|
+
values, tombstones, unavailable fields, connections, live cursors, failures,
|
|
804
|
+
and the reads in flight. Every active Surface's read and live entries restart,
|
|
805
|
+
so the screen asks again as whoever the client now is; a read or stream begun
|
|
806
|
+
before is interrupted rather than landing after. A mutation in flight is
|
|
807
|
+
treated as applied, so its answer writes nothing into the new store, and the
|
|
808
|
+
mutation sequence is kept, so request ids stay unique.
|
|
809
|
+
|
|
810
|
+
Who the client is belongs to the `RemoteClient` layer or the server's session,
|
|
811
|
+
not to Remote. `forget` only says that what was known no longer is.
|
|
812
|
+
|
|
571
813
|
## Queries and pagination
|
|
572
814
|
|
|
573
815
|
Entities answer "which fields of this known thing?" A Query answers "which
|
|
@@ -584,7 +826,7 @@ const ProjectsByOwner = Query.make('ProjectsByOwner', {
|
|
|
584
826
|
})
|
|
585
827
|
|
|
586
828
|
const Data = Remote.make({
|
|
587
|
-
model: App.
|
|
829
|
+
model: App.model.remote,
|
|
588
830
|
entities: [User, Project],
|
|
589
831
|
queries: [ProjectsByOwner],
|
|
590
832
|
})
|
|
@@ -735,6 +977,15 @@ the binding's `visible` rule; there is no `visible` here. This is safe only
|
|
|
735
977
|
because the client holds only rows the server already released to it, and a
|
|
736
978
|
local filter is not an access decision.
|
|
737
979
|
|
|
980
|
+
**Local execution is not a second authority.** `matching` and `filtered` derive
|
|
981
|
+
conclusions from facts the server gave; they never promote those conclusions
|
|
982
|
+
into knowledge of the server's whole dataset. Knowing `p1.ownerId = u7` says
|
|
983
|
+
that `p1` matches `ProjectsByOwner(u7)`; it does not say that
|
|
984
|
+
`ProjectsByOwner(u7)` is `[p1]`, and nothing here will say so unless a
|
|
985
|
+
connection's boundaries, terminal at both ends, independently prove it. That
|
|
986
|
+
line is what keeps Remote a cache of what the server owns rather than a local
|
|
987
|
+
database with opinions of its own.
|
|
988
|
+
|
|
738
989
|
### An input that changes as fast as someone types
|
|
739
990
|
|
|
740
991
|
A connection's identity is its query plus its **input**, which is exactly right
|
|
@@ -756,13 +1007,13 @@ the query reads** — which is ordinary application state, and
|
|
|
756
1007
|
import { debounce } from 'foldkit-primitives/time'
|
|
757
1008
|
|
|
758
1009
|
const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
|
|
759
|
-
const SearchBox = Bundle.declare(SearchInput, 'search')
|
|
760
1010
|
|
|
761
1011
|
// `latest` is what the box shows, so typing stays immediate.
|
|
762
1012
|
// The settled OutMessage is what moves the field the query reads.
|
|
763
|
-
|
|
1013
|
+
// A step of the page's Bundle.compose(...).pipe(...):
|
|
1014
|
+
Bundle.withChild('search', SearchInput, {
|
|
764
1015
|
args: { delayMs: 250 },
|
|
765
|
-
onOut: out => model => ({ model:
|
|
1016
|
+
onOut: out => model => ({ model: modifyFields(model, { postSearch: () => out.value }) }),
|
|
766
1017
|
})
|
|
767
1018
|
```
|
|
768
1019
|
|
|
@@ -820,6 +1071,41 @@ A broken live stream is not a failed read. Its `ReadFailed` carries the
|
|
|
820
1071
|
field: nothing was being read, and the values on screen are still what the
|
|
821
1072
|
server last said.
|
|
822
1073
|
|
|
1074
|
+
### When a field is withheld
|
|
1075
|
+
|
|
1076
|
+
A server may answer a read without a field it was asked for, and mean it: the
|
|
1077
|
+
Source's `authorize` withheld it from this principal, or the record it returned
|
|
1078
|
+
had no such field. The read result says so, in `settled`: the fields it asked
|
|
1079
|
+
for that this answer does not carry and a later one would not either. Why is
|
|
1080
|
+
not on the wire. The client records each as **unavailable**, one more kind of
|
|
1081
|
+
knowledge beside a value and a tombstone:
|
|
1082
|
+
|
|
1083
|
+
```text
|
|
1084
|
+
missing not established; the planner asks
|
|
1085
|
+
present known
|
|
1086
|
+
stale known, and being asked again
|
|
1087
|
+
unavailable asked, and answered without: the planner does not ask again
|
|
1088
|
+
tombstone the whole entity is known absent
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
A Selection that names an unavailable field reads `Failed` with an
|
|
1092
|
+
`Unavailable` error naming the field, not `Loading` (nothing is fetching it)
|
|
1093
|
+
and not `NotFound` (the entity is there). What it is not is a seventh state:
|
|
1094
|
+
the remedy is the one a failed read has. Select without the field, in a
|
|
1095
|
+
Selection of its own if some principals may read it, or show the error. A
|
|
1096
|
+
Selection that does not name it is unaffected: `name` reads `Ready` while
|
|
1097
|
+
`privateNotes` is unavailable beside it.
|
|
1098
|
+
|
|
1099
|
+
The mark is forgotten by what forgets a failure: `Data.refresh` asks again,
|
|
1100
|
+
and a read, live patch or mutation result that writes the field clears it.
|
|
1101
|
+
It survives persistence and a server render's `Remote.resume`, so the browser
|
|
1102
|
+
does not ask once more for what the server already declined.
|
|
1103
|
+
|
|
1104
|
+
An id the server answers with nothing about, neither values nor settled fields,
|
|
1105
|
+
is a tombstone as before. A server settles a withheld field without reading
|
|
1106
|
+
its Source for an id whose every field is withheld, so an absent id and a
|
|
1107
|
+
hidden one answer alike, and existence is not told.
|
|
1108
|
+
|
|
823
1109
|
## Mutations and optimistic state
|
|
824
1110
|
|
|
825
1111
|
Register mutations on the domain:
|
|
@@ -844,7 +1130,7 @@ case 'ClickedRename': {
|
|
|
844
1130
|
RenameProject,
|
|
845
1131
|
{ id, name },
|
|
846
1132
|
{
|
|
847
|
-
optimistic: [
|
|
1133
|
+
optimistic: [Remote.patch(Project, id, { name })],
|
|
848
1134
|
},
|
|
849
1135
|
)
|
|
850
1136
|
|
|
@@ -858,6 +1144,12 @@ case 'ClickedRename': {
|
|
|
858
1144
|
2. it returns the Command that calls `RemoteClient` and eventually emits
|
|
859
1145
|
`MutationSucceeded` or `MutationFailed`.
|
|
860
1146
|
|
|
1147
|
+
`Remote.patch(entity, id, values)` builds one entity's patch, and
|
|
1148
|
+
`Remote.ref(entity, id)` names one row. Both take the entity you declared,
|
|
1149
|
+
whether with `foldkit-entity` or Remote's `Entity.make`, and check `values`
|
|
1150
|
+
against its fields in their wire shape: a relation is written as its ref key,
|
|
1151
|
+
such as `'User:u1'`.
|
|
1152
|
+
|
|
861
1153
|
Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
862
1154
|
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
863
1155
|
Settling is idempotent per `requestId`.
|
|
@@ -885,10 +1177,10 @@ import { ConnectionChange } from 'foldkit-remote'
|
|
|
885
1177
|
|
|
886
1178
|
const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
887
1179
|
optimistic: ({ tempId }) => [
|
|
888
|
-
|
|
1180
|
+
Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
|
|
889
1181
|
ConnectionChange.prepend(
|
|
890
1182
|
CommentsForPost.ref({ postId }),
|
|
891
|
-
|
|
1183
|
+
Remote.ref(Comment, tempId),
|
|
892
1184
|
),
|
|
893
1185
|
],
|
|
894
1186
|
})
|
|
@@ -897,6 +1189,24 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
|
897
1189
|
A successful server result can replace the request's temporary connection
|
|
898
1190
|
overlays with confirmed ones in place.
|
|
899
1191
|
|
|
1192
|
+
### Showing a change nobody has made
|
|
1193
|
+
|
|
1194
|
+
A mutation's `optimistic` operations show over the store while it is in flight.
|
|
1195
|
+
`Data.overlay` shows operations the same way with no request behind them, until
|
|
1196
|
+
`Data.lift`: a preview, in every Selection and view, of something not yet sent.
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
|
|
1200
|
+
const back = Data.lift(previewed, 'post-preview')
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
- Both are called from `update`, and neither performs I/O or touches what the
|
|
1204
|
+
server said: the store beneath is as it was.
|
|
1205
|
+
- Showing an id again replaces what it showed. Lifting what was never shown
|
|
1206
|
+
returns the same Model.
|
|
1207
|
+
- An overlay's id is apart from every request's, so a mutation that settles does
|
|
1208
|
+
not take a preview with it.
|
|
1209
|
+
|
|
900
1210
|
### Reading past what is only pending
|
|
901
1211
|
|
|
902
1212
|
Every projection reads the **visible** cache: the server-derived store under
|
|
@@ -934,24 +1244,12 @@ flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight
|
|
|
934
1244
|
|
|
935
1245
|
### Remote mutation vs Sync operation
|
|
936
1246
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
result updates disposable cache
|
|
944
|
-
no durable outbox
|
|
945
|
-
|
|
946
|
-
Sync operation
|
|
947
|
-
client authored the durable fact/intent
|
|
948
|
-
survives offline/restart
|
|
949
|
-
enters an ordered log
|
|
950
|
-
converges with other replicas
|
|
951
|
-
```
|
|
952
|
-
|
|
953
|
-
If losing an unsent edit would be data loss, it belongs to Sync rather than
|
|
954
|
-
Remote.
|
|
1247
|
+
A Remote mutation requests the server now; its result updates a disposable
|
|
1248
|
+
cache, and there is no durable outbox. If losing an unsent edit would be data
|
|
1249
|
+
loss, it belongs to [`foldkit-sync`](../sync), not here (see [which state
|
|
1250
|
+
belongs here](#which-state-belongs-here)). For the same reason, an optimistic
|
|
1251
|
+
overlay is only a temporary view of server-owned data: do not persist one and
|
|
1252
|
+
treat it as a queue of edits.
|
|
955
1253
|
|
|
956
1254
|
## Live data
|
|
957
1255
|
|
|
@@ -1005,8 +1303,9 @@ never in one.
|
|
|
1005
1303
|
```ts
|
|
1006
1304
|
import { RemotePersistence } from 'foldkit-remote'
|
|
1007
1305
|
|
|
1008
|
-
//
|
|
1009
|
-
// `snapshotOf` names the connections that survive; none, here.
|
|
1306
|
+
// A cache carried in the page: prefetch on the server, embed, then hydrate.
|
|
1307
|
+
// `snapshotOf` names the connections that survive; none, here. For a page
|
|
1308
|
+
// the browser resumes with foldkit-ssr, see Server rendering below.
|
|
1010
1309
|
const text = RemotePersistence.dehydrate(
|
|
1011
1310
|
RemotePersistence.snapshotOf(loaded.remote),
|
|
1012
1311
|
{ scope: userId },
|
|
@@ -1041,6 +1340,40 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
|
|
|
1041
1340
|
discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
|
|
1042
1341
|
hydration still changes the application through the reducer.
|
|
1043
1342
|
|
|
1343
|
+
## Server rendering: `Remote.resume`
|
|
1344
|
+
|
|
1345
|
+
A snapshot is built for a cache that survived a reload: it keeps the whole
|
|
1346
|
+
entity store and deliberately no cursors. A page rendered a moment ago wants
|
|
1347
|
+
the opposite. `Remote.resume(Data)` is a resume part for
|
|
1348
|
+
[`foldkit-ssr`](../ssr/README.md) (in development), which sends exactly what
|
|
1349
|
+
the page's active Surfaces read, and resumes it in the browser:
|
|
1350
|
+
|
|
1351
|
+
```ts
|
|
1352
|
+
const Page = SSR.plan(App, {
|
|
1353
|
+
id: 'project',
|
|
1354
|
+
state: Projection.pick(App.model.route),
|
|
1355
|
+
surfaces: [ProjectPageAt],
|
|
1356
|
+
parts: [Remote.resume(Data)],
|
|
1357
|
+
})
|
|
1358
|
+
```
|
|
1359
|
+
|
|
1360
|
+
- **Only what is read crosses.** For each requirement, the fields it names,
|
|
1361
|
+
following its relations through the refs the store holds; for each
|
|
1362
|
+
connection, the fields its selection reads of each item. A field in the
|
|
1363
|
+
server's store that no Surface selects, such as a user's email, is not in the
|
|
1364
|
+
page.
|
|
1365
|
+
- **Connections cross whole**, with their boundaries, so "load more" knows
|
|
1366
|
+
where the server stopped.
|
|
1367
|
+
- **Live cursors cross with the entities they follow**, so a live
|
|
1368
|
+
subscription started in the browser asks from where the server's left off.
|
|
1369
|
+
- **Nothing else crosses.** Loading marks, failures, the mutation ledger,
|
|
1370
|
+
optimistic layers, gaps and retention start at their initial values.
|
|
1371
|
+
|
|
1372
|
+
The browser's read entry plans against the restored store, so it asks for
|
|
1373
|
+
none of it again. The capture is JSON through its own Schema, and a page whose
|
|
1374
|
+
capture does not decode is refused whole by `foldkit-ssr`. Give a second domain
|
|
1375
|
+
in one application its own `id`: `Remote.resume(Catalog, { id: 'catalog' })`.
|
|
1376
|
+
|
|
1044
1377
|
## How the server packages fit
|
|
1045
1378
|
|
|
1046
1379
|
`foldkit-remote` owns **client cache semantics**, not your source database or
|
|
@@ -1087,22 +1420,6 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
|
1087
1420
|
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
1088
1421
|
server packages together.
|
|
1089
1422
|
|
|
1090
|
-
## Finding the next API
|
|
1091
|
-
|
|
1092
|
-
| Task | Start here |
|
|
1093
|
-
| --- | --- |
|
|
1094
|
-
| Explain `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
|
|
1095
|
-
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
1096
|
-
| Load an ordered list | [Queries and pagination](#queries-and-pagination) |
|
|
1097
|
-
| Save and show an optimistic result | [Mutations](#mutations-and-optimistic-state) |
|
|
1098
|
-
| Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
|
|
1099
|
-
| Seed a cache after reload or SSR | [Hydration](#persistence-and-hydration) |
|
|
1100
|
-
|
|
1101
|
-
A successful mutation and an optimistic overlay have different guarantees:
|
|
1102
|
-
the overlay is only a temporary view of server-owned data. Use Sync when the
|
|
1103
|
-
operation itself must survive offline, rather than persisting an optimistic
|
|
1104
|
-
Remote cache and treating it as an outbox.
|
|
1105
|
-
|
|
1106
1423
|
## Introspection
|
|
1107
1424
|
|
|
1108
1425
|
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
@@ -1116,10 +1433,9 @@ Remote.planQueries(...) // missing/stale query work
|
|
|
1116
1433
|
Remote.inspectEntity(...) // one normalized entity
|
|
1117
1434
|
```
|
|
1118
1435
|
|
|
1119
|
-
These are pure and useful in tests, tooling, and debugging.
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
wiring determines whether that plan is being executed.
|
|
1436
|
+
These are pure and useful in tests, tooling, and debugging. For a read that
|
|
1437
|
+
stays `Initial`, `Data.why` and `Data.plan` are the tools; see
|
|
1438
|
+
[Why is it still `Initial`?](#why-is-it-still-initial).
|
|
1123
1439
|
|
|
1124
1440
|
`Data.inspect(model).loading` answers the other one — **"what is Remote doing
|
|
1125
1441
|
right now?"** — with the `entity\0id\0field` marks of the reads in flight,
|
|
@@ -1193,6 +1509,11 @@ place in an application Model. It exposes the Remote `Model`, `initial`,
|
|
|
1193
1509
|
`Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
|
|
1194
1510
|
`Remote.make` combines definition and binding in one step.
|
|
1195
1511
|
|
|
1512
|
+
`Entity.make(name, schema)` is Remote's own entity descriptor, and what a
|
|
1513
|
+
`foldkit-entity` Entity is compiled to (`Entity.from`). Remote accepts either
|
|
1514
|
+
wherever it takes an entity. Declaring one directly skips `foldkit-entity`,
|
|
1515
|
+
and with it relations, derived members and query bodies, which is only worth
|
|
1516
|
+
it for a domain that Remote is all it will ever need.
|
|
1196
1517
|
`Selection.make(Entity, { ... })`, `Selection.connection(...)`, and
|
|
1197
1518
|
`Entity.patch(ref, values)` are the kernel constructors behind the entity
|
|
1198
1519
|
helpers. `Remote.select(bound, selection)(id)` is the lower-level form of
|
|
@@ -1347,15 +1668,6 @@ Remote does **not** own:
|
|
|
1347
1668
|
- authentication; server-side authorization belongs at the Source boundary;
|
|
1348
1669
|
- a background scheduler or general-purpose database/query engine.
|
|
1349
1670
|
|
|
1350
|
-
## How state changes here
|
|
1351
|
-
|
|
1352
|
-
Local application state uses `evo` inside `update`, like anywhere else.
|
|
1353
|
-
Remote Messages go through `Data.reduce` into the embedded `Remote.Model`
|
|
1354
|
-
submodel — Remote owns that reducer, and it is the only writer of the
|
|
1355
|
-
cache. Never install cache state with a ref `set`; the requirements,
|
|
1356
|
-
staleness, and tombstones only stay coherent when every fact arrives as a
|
|
1357
|
-
Remote Message.
|
|
1358
|
-
|
|
1359
1671
|
## Limits
|
|
1360
1672
|
|
|
1361
1673
|
- Effect RPC is the wire contract; the application supplies deployment layers.
|