foldkit-remote 0.20.0 → 0.22.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 +987 -198
- package/dist/answerGaps.d.ts +16 -0
- package/dist/answerGaps.d.ts.map +1 -0
- package/dist/answerGaps.js +60 -0
- package/dist/answerGaps.js.map +1 -0
- package/dist/client-B055kQuP.d.ts +110 -0
- package/dist/client-B055kQuP.d.ts.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +142 -0
- package/dist/client.js.map +1 -0
- package/dist/coalesce-CLAr92Nk.d.ts +34 -0
- package/dist/coalesce-CLAr92Nk.d.ts.map +1 -0
- package/dist/coalesce.d.ts +2 -0
- package/dist/coalesce.js +131 -0
- package/dist/coalesce.js.map +1 -0
- package/dist/connection-BRyaJy89.d.ts +45 -0
- package/dist/connection-BRyaJy89.d.ts.map +1 -0
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +170 -0
- package/dist/connection.js.map +1 -0
- package/dist/data.d.ts +7 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +21 -0
- package/dist/data.js.map +1 -0
- package/dist/diagnostics-DHhVYflV.d.ts +125 -0
- package/dist/diagnostics-DHhVYflV.d.ts.map +1 -0
- package/dist/diagnostics.d.ts +2 -0
- package/dist/diagnostics.js +60 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/entity-CgsG5Uel.d.ts +196 -0
- package/dist/entity-CgsG5Uel.d.ts.map +1 -0
- package/dist/entity-DuDxaa3n.js +279 -0
- package/dist/entity-DuDxaa3n.js.map +1 -0
- package/dist/entity.d.ts +2 -0
- package/dist/entity.js +1 -0
- package/dist/held.d.ts +19 -0
- package/dist/held.d.ts.map +1 -0
- package/dist/held.js +70 -0
- package/dist/held.js.map +1 -0
- package/dist/impact.d.ts +79 -0
- package/dist/impact.d.ts.map +1 -0
- package/dist/impact.js +166 -0
- package/dist/impact.js.map +1 -0
- package/dist/index.d.ts +1542 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2791 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect-DtRQGCRP.d.ts +134 -0
- package/dist/inspect-DtRQGCRP.d.ts.map +1 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +33 -0
- package/dist/inspect.js.map +1 -0
- package/dist/json-DowLl1eB.d.ts +52 -0
- package/dist/json-DowLl1eB.d.ts.map +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +123 -0
- package/dist/json.js.map +1 -0
- package/dist/law.d.ts +56 -0
- package/dist/law.d.ts.map +1 -0
- package/dist/law.js +201 -0
- package/dist/law.js.map +1 -0
- package/dist/live-BpTzlCnY.d.ts +79 -0
- package/dist/live-BpTzlCnY.d.ts.map +1 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +126 -0
- package/dist/live.js.map +1 -0
- package/dist/matching-DF5F9Us8.d.ts +100 -0
- package/dist/matching-DF5F9Us8.d.ts.map +1 -0
- package/dist/matching.d.ts +2 -0
- package/dist/matching.js +172 -0
- package/dist/matching.js.map +1 -0
- package/dist/model-B6zk1NMB.d.ts +710 -0
- package/dist/model-B6zk1NMB.d.ts.map +1 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +1308 -0
- package/dist/model.js.map +1 -0
- package/dist/mutation.d.ts +2 -0
- package/dist/mutation.js +612 -0
- package/dist/mutation.js.map +1 -0
- package/dist/optimistic-CECsfNGY.d.ts +523 -0
- package/dist/optimistic-CECsfNGY.d.ts.map +1 -0
- package/dist/optimistic.d.ts +2 -0
- package/dist/optimistic.js +336 -0
- package/dist/optimistic.js.map +1 -0
- package/dist/outbox-DNuc4kZp.d.ts +259 -0
- package/dist/outbox-DNuc4kZp.d.ts.map +1 -0
- package/dist/outbox.d.ts +2 -0
- package/dist/outbox.js +326 -0
- package/dist/outbox.js.map +1 -0
- package/dist/patchValues.d.ts +10 -0
- package/dist/patchValues.d.ts.map +1 -0
- package/dist/patchValues.js +57 -0
- package/dist/patchValues.js.map +1 -0
- package/dist/persistence-CCMnvg7-.d.ts +114 -0
- package/dist/persistence-CCMnvg7-.d.ts.map +1 -0
- package/dist/persistence.d.ts +2 -0
- package/dist/persistence.js +205 -0
- package/dist/persistence.js.map +1 -0
- package/dist/placement-jZdsIpmj.d.ts +50 -0
- package/dist/placement-jZdsIpmj.d.ts.map +1 -0
- package/dist/placement.d.ts +2 -0
- package/dist/placement.js +116 -0
- package/dist/placement.js.map +1 -0
- package/dist/plan-D9cftxC_.d.ts +35 -0
- package/dist/plan-D9cftxC_.d.ts.map +1 -0
- package/dist/plan.d.ts +2 -0
- package/dist/plan.js +100 -0
- package/dist/plan.js.map +1 -0
- package/dist/policy-DsWHmIAq.d.ts +42 -0
- package/dist/policy-DsWHmIAq.d.ts.map +1 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +32 -0
- package/dist/policy.js.map +1 -0
- package/dist/{port.d.mts → port.d.ts} +3 -2
- package/dist/port.d.ts.map +1 -0
- package/dist/{port.mjs → port.js} +3 -2
- package/dist/port.js.map +1 -0
- package/dist/query-BuIU93xA.d.ts +141 -0
- package/dist/query-BuIU93xA.d.ts.map +1 -0
- package/dist/query.d.ts +2 -0
- package/dist/query.js +0 -0
- package/dist/query.js.map +1 -0
- package/dist/record.d.ts +11 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +21 -0
- package/dist/record.js.map +1 -0
- package/dist/relation-BLBGOZom.d.ts +59 -0
- package/dist/relation-BLBGOZom.d.ts.map +1 -0
- package/dist/relation.d.ts +2 -0
- package/dist/relation.js +93 -0
- package/dist/relation.js.map +1 -0
- package/dist/remoteData-mK1suYi1.d.ts +148 -0
- package/dist/remoteData-mK1suYi1.d.ts.map +1 -0
- package/dist/remoteData.d.ts +2 -0
- package/dist/remoteData.js +179 -0
- package/dist/remoteData.js.map +1 -0
- package/dist/reporting.d.ts +12 -0
- package/dist/reporting.d.ts.map +1 -0
- package/dist/reporting.js +45 -0
- package/dist/reporting.js.map +1 -0
- package/dist/requirement-D57S2LpH.d.ts +64 -0
- package/dist/requirement-D57S2LpH.d.ts.map +1 -0
- package/dist/requirement.d.ts +2 -0
- package/dist/requirement.js +114 -0
- package/dist/requirement.js.map +1 -0
- package/dist/resume-Ct5R6yli.d.ts +92 -0
- package/dist/resume-Ct5R6yli.d.ts.map +1 -0
- package/dist/resume.d.ts +2 -0
- package/dist/resume.js +222 -0
- package/dist/resume.js.map +1 -0
- package/dist/retain-BfNTvBRb.d.ts +48 -0
- package/dist/retain-BfNTvBRb.d.ts.map +1 -0
- package/dist/retain.d.ts +2 -0
- package/dist/retain.js +107 -0
- package/dist/retain.js.map +1 -0
- package/dist/selection.d.ts +2 -0
- package/dist/selection.js +1 -0
- package/dist/shown.d.ts +20 -0
- package/dist/shown.d.ts.map +1 -0
- package/dist/shown.js +40 -0
- package/dist/shown.js.map +1 -0
- package/dist/sse-C7LhdbiQ.d.ts +17 -0
- package/dist/sse-C7LhdbiQ.d.ts.map +1 -0
- package/dist/sse.d.ts +2 -0
- package/dist/sse.js +127 -0
- package/dist/sse.js.map +1 -0
- package/dist/store-ah2qjcJW.d.ts +100 -0
- package/dist/store-ah2qjcJW.d.ts.map +1 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +218 -0
- package/dist/store.js.map +1 -0
- package/dist/testing.d.ts +133 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +0 -0
- package/dist/testing.js.map +1 -0
- package/dist/why-kgp_Wr3z.d.ts +483 -0
- package/dist/why-kgp_Wr3z.d.ts.map +1 -0
- package/dist/why.d.ts +2 -0
- package/dist/why.js +293 -0
- package/dist/why.js.map +1 -0
- package/dist/whyReasons.d.ts +42 -0
- package/dist/whyReasons.d.ts.map +1 -0
- package/dist/whyReasons.js +211 -0
- package/dist/whyReasons.js.map +1 -0
- package/dist/wire-DthPcTM6.d.ts +787 -0
- package/dist/wire-DthPcTM6.d.ts.map +1 -0
- package/dist/wire.d.ts +2 -0
- package/dist/wire.js +329 -0
- package/dist/wire.js.map +1 -0
- package/package.json +19 -14
- package/dist/index-BEvVAL76.d.mts +0 -4097
- package/dist/index-BEvVAL76.d.mts.map +0 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/port.d.mts.map +0 -1
- package/dist/port.mjs.map +0 -1
- package/dist/src-BDqn7Ocr.mjs +0 -6344
- package/dist/src-BDqn7Ocr.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -22,8 +22,10 @@ Remote fetches only the fields the Model lacks, stores each entity once, and
|
|
|
22
22
|
hands the view one of six states rather than an `undefined`. Every result is a
|
|
23
23
|
Message through one reducer, so a screen replays from a recorded Model.
|
|
24
24
|
|
|
25
|
-
Remote is for facts the **server owns**.
|
|
26
|
-
|
|
25
|
+
Remote is for facts the **server owns**. A write that must reach the server
|
|
26
|
+
even if the page goes first is a durable write, which Remote's outbox sends
|
|
27
|
+
later. An edit the client authors, which must converge with other devices'
|
|
28
|
+
edits while offline, belongs to [`foldkit-sync`](../sync).
|
|
27
29
|
|
|
28
30
|
## If you know TanStack Query, TanStack DB or fate
|
|
29
31
|
|
|
@@ -53,9 +55,11 @@ screen is a function of the Model, replays from a recording, and is tested
|
|
|
53
55
|
without a network.
|
|
54
56
|
|
|
55
57
|
What Remote does not have: a component hook (it has Surfaces and Foldkit
|
|
56
|
-
Subscriptions), a devtools panel of its own (`Data.inspect
|
|
57
|
-
the data one would show), and retries
|
|
58
|
-
until something asks again; see
|
|
58
|
+
Subscriptions), a devtools panel of its own (`Data.inspect`, `Data.why` and
|
|
59
|
+
`Data.timeline` are the data one would show), and retries of a failed read (it
|
|
60
|
+
stays failed until something asks again; see
|
|
61
|
+
[When a read fails](#when-a-read-fails)). Only a durable write is sent again
|
|
62
|
+
on its own, and only when nothing answered it.
|
|
59
63
|
|
|
60
64
|
### And TanStack DB, and fate
|
|
61
65
|
|
|
@@ -66,8 +70,9 @@ Its unit is the collection; a screen queries what is already local. Remote's
|
|
|
66
70
|
unit is the field: a screen declares which fields of which entities it reads,
|
|
67
71
|
and only those are fetched, so there is no collection to load first and a page
|
|
68
72
|
carries what it shows. Remote has no live query engine: `Data.filtered` and
|
|
69
|
-
`
|
|
70
|
-
|
|
73
|
+
`matching` judge the rows a list already holds, a new list is a server query,
|
|
74
|
+
and a list read `live` is kept current by the server's live source, not by
|
|
75
|
+
the client. Where TanStack DB syncs client-authored writes through a sync
|
|
71
76
|
engine, that job here belongs to [`foldkit-sync`](../sync), and Remote stays
|
|
72
77
|
the disposable cache of what the server owns.
|
|
73
78
|
|
|
@@ -89,13 +94,19 @@ what you are doing:
|
|
|
89
94
|
| --- | --- |
|
|
90
95
|
| See it work before there is a server | [Step 5](#5-provide-a-client) |
|
|
91
96
|
| A read stays `Initial` | [Why is it still `Initial`?](#why-is-it-still-initial) |
|
|
97
|
+
| A read is `Loading` or `Refreshing` and you did not expect it | [Why is it `Loading`?](#why-is-it-loading) |
|
|
92
98
|
| A list shows old rows | [Why is it stale?](#why-is-it-stale) |
|
|
93
99
|
| A saved change showed, then vanished | [Why did my change disappear?](#why-did-my-change-disappear) |
|
|
94
|
-
|
|
|
100
|
+
| A page I came back to reads `Refreshing` or `Loading` | [Why `Refreshing`?](#why-does-a-page-i-went-back-to-read-refreshing) |
|
|
101
|
+
| Ask the Model why a read shows what it shows | [`Data.why`](#why-is-it-loading) |
|
|
102
|
+
| Test that a list never blinks to `Loading`, without a browser | [`Trace`](#testing-what-a-reader-sees-trace) |
|
|
103
|
+
| Test that what a write shows is what the server stores | [`Law`](#testing-that-a-prediction-is-the-truth-law) |
|
|
95
104
|
| Show a failed read, and let the user retry | [When a read fails](#when-a-read-fails) |
|
|
96
105
|
| Load an ordered, paged list | [Queries and pagination](#queries-and-pagination) |
|
|
97
106
|
| Filter a list already on screen | [Filtering a loaded list](#filtering-a-loaded-list-without-asking-the-server) |
|
|
98
107
|
| Save, and show the result before the server answers | [Mutations](#mutations-and-optimistic-state) |
|
|
108
|
+
| Save a write that must reach the server across a reload or offline | [`Data.outbox`](#a-write-that-outlives-the-page-dataoutbox) |
|
|
109
|
+
| Keep a list current as the server changes it | [Live data](#live-data) |
|
|
99
110
|
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
100
111
|
| Keep data across a reload | [Hydration](#persistence-and-hydration) |
|
|
101
112
|
| Hand a server render's data to the browser | [Server rendering](#server-rendering-remoteresume) |
|
|
@@ -109,6 +120,7 @@ One owner per datum:
|
|
|
109
120
|
| --- | --- |
|
|
110
121
|
| Route, selected item, local form state, transient UI errors | the application Model and `update` |
|
|
111
122
|
| Server-derived facts that can be refetched | `foldkit-remote` |
|
|
123
|
+
| A write the server decides, which must be sent even after a reload | `foldkit-remote`, `durable` (its outbox) |
|
|
112
124
|
| Client-authored state that must survive offline and converge | `foldkit-sync` |
|
|
113
125
|
| Local state represented in the URL or another store | the Model, observed by `foldkit-mirror` |
|
|
114
126
|
|
|
@@ -117,6 +129,7 @@ Remote Sync
|
|
|
117
129
|
server owns the fact client owns the edit
|
|
118
130
|
cache is disposable intent must survive offline/restart
|
|
119
131
|
refetch is recovery replay + reconciliation is recovery
|
|
132
|
+
outbox: send it later converge
|
|
120
133
|
```
|
|
121
134
|
|
|
122
135
|
A Surface may project all of these owners at once. Observation does not transfer
|
|
@@ -308,12 +321,13 @@ while there is nothing to read.
|
|
|
308
321
|
|
|
309
322
|
```ts
|
|
310
323
|
const detail = Data.active('ProjectDetail', model =>
|
|
311
|
-
Option.map(model.openProjectId, id => Data.get(
|
|
324
|
+
Option.map(model.openProjectId, id => Data.get(Entity.select(Project, { name: true }), id)),
|
|
312
325
|
)
|
|
313
326
|
foldData.subscriptions({ detail })
|
|
314
327
|
```
|
|
315
328
|
|
|
316
|
-
|
|
329
|
+
Its `projectionOf(model)` gives back that read, with its value's type. It
|
|
330
|
+
belongs to the domain's application and sends no Messages. A domain bound
|
|
317
331
|
to a raw optic (`ModelRef.fromOptic`) names no application, so `Data.active`
|
|
318
332
|
throws for one rather than guess.
|
|
319
333
|
|
|
@@ -351,8 +365,7 @@ const clientLayer = Layer.unwrap(
|
|
|
351
365
|
Or, with no Effect RPC on the server, plain JSON over HTTP: each call a `POST`
|
|
352
366
|
of `{ operation, payload }`, answered `{ result }` or `{ error }`.
|
|
353
367
|
[`RemoteServer.answer`](../remote-server/README.md#over-plain-json) is the
|
|
354
|
-
other end
|
|
355
|
-
sending the request, a worker or a server running in the page:
|
|
368
|
+
other end:
|
|
356
369
|
|
|
357
370
|
```ts
|
|
358
371
|
const overHttp = Remote.clientLayer(
|
|
@@ -360,53 +373,13 @@ const overHttp = Remote.clientLayer(
|
|
|
360
373
|
)
|
|
361
374
|
```
|
|
362
375
|
|
|
363
|
-
|
|
364
|
-
call. A 401 fails every call kind, a read, a query, a mutation or a live
|
|
365
|
-
stream, with `_tag: 'Unauthenticated'` (and `reason` when the server gave
|
|
366
|
-
one): the server could not tell who is asking. `RemoteData.denied(error)`
|
|
367
|
-
reads a failure as that or a 403 (`Forbidden`), typed, and as none for any
|
|
368
|
-
other, so a view tells "sign in again" from "could not load" without
|
|
369
|
-
comparing tags. Remote treats it as any failure: a read or query is `Failed`, and a live stream restarts with its
|
|
370
|
-
backoff, refused again each time, until the application signs in again and
|
|
371
|
-
calls `Remote.forget`. A 403 whose body does not
|
|
372
|
-
say `forbidden` itself, as Remote's server's does, comes from middleware in
|
|
373
|
-
front of it refusing a session (a bare `{ error }` included): it fails a
|
|
374
|
-
mutation as `Forbidden` and any other call with the status. A 401 is read the
|
|
375
|
-
same whatever its body says. Neither carries live data. For live data over the same `POST`s,
|
|
376
|
-
`Remote.httpWithLive` keeps reads, queries, and mutations as `http` sends
|
|
377
|
-
them and streams live requirements as a `text/event-stream` of changes;
|
|
378
|
-
[`serveFetch`](../remote-server/README.md#over-fetch-workers) is the other
|
|
379
|
-
end. Abandoning the stream aborts the request, which interrupts the
|
|
380
|
-
subscription server-side.
|
|
381
|
-
|
|
382
|
-
For a server in a worker or in the page (a sandbox), `foldkit-remote/port`
|
|
383
|
-
speaks Effect's own RPC worker protocol over a `MessagePort`, so live data,
|
|
384
|
-
typed errors and interruption cross it too.
|
|
385
|
-
[`servePort`](../remote-server/README.md#over-a-messageport) is the other end:
|
|
386
|
-
|
|
387
|
-
```ts
|
|
388
|
-
import { port } from 'foldkit-remote/port'
|
|
389
|
-
|
|
390
|
-
// A new conversation each time the protocol connects: one port here, the other to the server.
|
|
391
|
-
const overPort = port(() => {
|
|
392
|
-
const channel = new MessageChannel()
|
|
393
|
-
worker.port.postMessage({ open: 'remote' }, [channel.port2])
|
|
394
|
-
return channel.port1
|
|
395
|
-
})
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
It is a `Layer<RemoteClient>` like the others; a port that cannot be set up is
|
|
399
|
-
a defect, not a failed read.
|
|
400
|
-
|
|
401
|
-
The stock client is accepted as it is. When its transport fails, the call
|
|
402
|
-
fails with its own Remote error (`RemoteReadError`, `RemoteQueryError`,
|
|
403
|
-
`RemoteMutationError`, `RemoteLiveError`), which the UI shows and retries like
|
|
404
|
-
any other failure.
|
|
405
|
-
|
|
406
|
-
Either is a Layer the runtime provides to `subscriptions` and Commands. The
|
|
376
|
+
Each is a Layer the runtime provides to `subscriptions` and Commands; the
|
|
407
377
|
transport itself is not owned by Remote. See
|
|
408
378
|
[`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory)
|
|
409
|
-
for what the in-memory one can and cannot do
|
|
379
|
+
for what the in-memory one can and cannot do, and
|
|
380
|
+
[Clients and transports](#clients-and-transports) for the others (a worker or
|
|
381
|
+
a `MessagePort`, live data over HTTP), what a refused or unreachable call
|
|
382
|
+
does, and testing a server over HTTP.
|
|
410
383
|
|
|
411
384
|
### 6. Watch it arrive
|
|
412
385
|
|
|
@@ -461,8 +434,11 @@ function update(model: Model, message: Message): Update.Return<Model, Message, R
|
|
|
461
434
|
}
|
|
462
435
|
```
|
|
463
436
|
|
|
464
|
-
The
|
|
465
|
-
|
|
437
|
+
The snippets below call the domain itself (`Data.subscriptions`,
|
|
438
|
+
`Data.mutate`, `Data.retry`, `Data.preload`), as the spread form does. Under
|
|
439
|
+
the fold, call the fold's members of the same names (`foldData.mutate`, …),
|
|
440
|
+
which yield the wrapper Message; the members that only read or write the Model
|
|
441
|
+
(`Data.get`, `Data.refresh`, `Data.overlay`, …) stay on the domain either way.
|
|
466
442
|
|
|
467
443
|
### One value with `foldkit-bundle`: `Data.wiring`
|
|
468
444
|
|
|
@@ -527,7 +503,8 @@ The states are:
|
|
|
527
503
|
- **`Loading`** — required data is absent and a read, or a list's query, is in
|
|
528
504
|
flight.
|
|
529
505
|
- **`Ready`** — every selected field is present and decodes.
|
|
530
|
-
- **`Refreshing`** — the current value remains visible while it is being
|
|
506
|
+
- **`Refreshing`** — the current value remains visible while it is being
|
|
507
|
+
refetched, or, for a list, while some of its rows are still being read.
|
|
531
508
|
- **`Failed`** — stored server data does not decode against the Selection, the
|
|
532
509
|
read or query behind it failed, or the server settled a selected field
|
|
533
510
|
without a value (an `Unavailable` error; see
|
|
@@ -574,7 +551,8 @@ that failed does not throw away what the reader was already looking at. Only a
|
|
|
574
551
|
`Failed` with nothing to show reaches `failed`.
|
|
575
552
|
|
|
576
553
|
The `data` branch is told which it got, as a `Freshness` — exported, so a view
|
|
577
|
-
helper that takes one can name it
|
|
554
|
+
helper that takes one can name it, and folds it with its exhaustive
|
|
555
|
+
`Freshness.match`:
|
|
578
556
|
|
|
579
557
|
| `Freshness` | What it means |
|
|
580
558
|
| --- | --- |
|
|
@@ -605,22 +583,75 @@ spinner here hides a wiring mistake. Ask the Model:
|
|
|
605
583
|
|
|
606
584
|
```ts
|
|
607
585
|
Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
|
|
608
|
-
// { state: 'Initial',
|
|
609
|
-
//
|
|
586
|
+
// { state: 'Initial',
|
|
587
|
+
// because: [Why.NotFetching({ surfaces: ['ProjectPage'] }), Why.Fields({ … })],
|
|
588
|
+
// message: 'ProjectPage reads it and is active, yet nothing is fetching it. …',
|
|
589
|
+
// surfaces: Option.some(['ProjectPage']) }
|
|
610
590
|
```
|
|
611
591
|
|
|
592
|
+
The first reason is the one that decides `Initial`:
|
|
593
|
+
|
|
612
594
|
- `NotObserved`: no active Surface reads it. The Surface is not in the record
|
|
613
|
-
you gave `Data.subscriptions`, or its
|
|
614
|
-
(the route is not there yet).
|
|
615
|
-
- `NotFetching`: an active Surface reads it and nothing started a request
|
|
616
|
-
|
|
617
|
-
runtime.
|
|
618
|
-
-
|
|
619
|
-
what the state means in words.
|
|
595
|
+
you gave `Data.subscriptions`, or its `Surface.at` function returns none for
|
|
596
|
+
this Model (the route is not there yet).
|
|
597
|
+
- `NotFetching`: an active Surface reads it and nothing started a request.
|
|
598
|
+
Right after a Message that is the moment before the read entry asks; if it
|
|
599
|
+
stays so, Remote's Subscriptions are not installed in the runtime.
|
|
600
|
+
- `NoActiveRecord`: no `surfaces` were given, so it cannot tell which.
|
|
620
601
|
|
|
621
602
|
`Data.plan(model, projection)` shows whether Remote believes anything is
|
|
622
603
|
missing at all.
|
|
623
604
|
|
|
605
|
+
### Why is it `Loading`?
|
|
606
|
+
|
|
607
|
+
A list reads `Loading` when its page is missing and its query is out, and
|
|
608
|
+
also when it holds its page and one row's field is being fetched. It reads
|
|
609
|
+
`Refreshing` when the page is stale, a row is stale, or a row is left out
|
|
610
|
+
until a field arrives. `Data.why` tells them apart. `because` lists every
|
|
611
|
+
fact the Model holds about what the read reads, by typed reference, never by
|
|
612
|
+
the store's internal marks. The reason that decides the state comes first.
|
|
613
|
+
Match them with `Why.match`:
|
|
614
|
+
|
|
615
|
+
| `Why` | What it says | What restarts it |
|
|
616
|
+
| --- | --- | --- |
|
|
617
|
+
| `List { list, held, inFlight }` | the list's own page is `Missing` or `Stale` | the read entry, while a Surface reads it |
|
|
618
|
+
| `Fields { row, via, fields, held, inFlight }` | fields of one row are `Missing` or `Stale`; `via` is the relation path (`['owner']`) | the read entry |
|
|
619
|
+
| `ListFailed`, `FieldsFailed` | a request failed | **nothing on its own**: `Data.refresh`, a write of the field, a new page |
|
|
620
|
+
| `Unavailable`, `Undecodable`, `OverlayOnly` | the store holds what cannot be shown | a change of Selection or overlay |
|
|
621
|
+
| `Deleted { row, by }` | the server deleted it (`by` none), or a pending write does | — |
|
|
622
|
+
| `LiveReconnecting`, `LiveRefused` | a live stream of a Surface reading it broke, or was refused | the live entry, with backoff; nothing, for a refusal |
|
|
623
|
+
| `Pending { row, fields, by }` | a pending write shows these fields | its answer |
|
|
624
|
+
| `Joined`, `Left { list, row, by, terms }` | the list shows a row its pages do not hold, or hides one they do | its answer |
|
|
625
|
+
|
|
626
|
+
`by` is a `ShownBy`: `Write { requestId, mutation }`, `Overlay { id }` or
|
|
627
|
+
`Live { cursor }`. `terms` are the list body's `where` predicates judged over
|
|
628
|
+
the row as the client placed it, so a row that joined says which term put it
|
|
629
|
+
there, and one the client could not judge says what it lacked:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
// a project archived optimistically, in a list of active ones
|
|
633
|
+
Why.Left({
|
|
634
|
+
list: { query: 'ByStatus', identity },
|
|
635
|
+
row: { entity: 'Project', id: 'p1' },
|
|
636
|
+
by: Option.some(ShownBy.Write({ requestId: 'r7', mutation: Option.some('Archive') })),
|
|
637
|
+
terms: [{ term: 'Project.status = $status', truth: 'False', reason: Option.none() }],
|
|
638
|
+
})
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
`truth` is `True`, `False`, `Null` (SQL's unknown, which keeps a row out) or
|
|
642
|
+
`Unknown` (the store lacks what it rests on). `ReadWhy` is a Schema:
|
|
643
|
+
`Schema.encodeSync(ReadWhy)(why)` is JSON for a log or a devtools client.
|
|
644
|
+
`Data.why` reads the Model and nothing else: it plans nothing and fetches
|
|
645
|
+
nothing.
|
|
646
|
+
|
|
647
|
+
**Across a history.** `Data.timeline(start, history, { feed, post },
|
|
648
|
+
{ surfaces })` takes the Messages `update` handled, each with the Model it
|
|
649
|
+
left (what Foldkit's devtools records), and gives one step per change of a
|
|
650
|
+
read's tag: the Message that made it, `why` at the Model it changed to, and
|
|
651
|
+
`regression` when a read that showed a value stopped showing it with no
|
|
652
|
+
reason a reader can see. Foldkit's devtools has no place for a panel of
|
|
653
|
+
another package's, so the timeline is the data, not a panel.
|
|
654
|
+
|
|
624
655
|
### Why is it stale?
|
|
625
656
|
|
|
626
657
|
Under the default `RemotePolicy.cacheFirst`, a present field is never asked for
|
|
@@ -634,8 +665,8 @@ server said last time. To see change:
|
|
|
634
665
|
older than `maxAge` the next time it is planned.
|
|
635
666
|
- **Always**: `RemotePolicy.networkOnly` requests every selected field, cached
|
|
636
667
|
value visible meanwhile.
|
|
637
|
-
- **As it happens**: `Data.live` instead of `Data.get`,
|
|
638
|
-
publishes changes; see [Live data](#live-data).
|
|
668
|
+
- **As it happens**: `Data.live` instead of `Data.get`, or `live: true` on a
|
|
669
|
+
`Data.query`, with a server that publishes changes; see [Live data](#live-data).
|
|
639
670
|
|
|
640
671
|
Connections have no age: a list re-queries when invalidated, refreshed, or
|
|
641
672
|
under `networkOnly`. See [Reading, policies, and prefetch](#reading-policies-and-prefetch).
|
|
@@ -647,25 +678,42 @@ is in flight. When the server answers, the layers come off and the cache shows
|
|
|
647
678
|
what the server said:
|
|
648
679
|
|
|
649
680
|
- **It failed.** `MutationFailed` removes the layers; `Data.mutation(model,
|
|
650
|
-
requestId)` reads `Failed` with the error. Nothing retries it
|
|
651
|
-
|
|
681
|
+
requestId)` reads `Failed` with the error. Nothing retries it on its own
|
|
682
|
+
(`Data.retry` sends it again; see [When a write fails](#when-a-write-fails)).
|
|
683
|
+
A write survives a reload only when it is sent `durable`
|
|
684
|
+
([`Data.outbox`](#a-write-that-outlives-the-page-dataoutbox)); an edit that
|
|
685
|
+
must converge with other devices' offline edits belongs to
|
|
652
686
|
[`foldkit-sync`](../sync).
|
|
653
|
-
- **It succeeded, but the
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
687
|
+
- **It succeeded, but the answer did not name the row.** The answer carries
|
|
688
|
+
what the active Surfaces and the request's layer show of each row it names
|
|
689
|
+
([What an answer carries](#what-an-answer-carries)); a row it changed
|
|
690
|
+
without naming (a post's count, after a like) keeps the old value until a
|
|
691
|
+
read writes the new one. Name the row in the answer.
|
|
657
692
|
- **A preview** shown with `Data.overlay` stays until `Data.lift`; a settled
|
|
658
693
|
mutation never takes it along.
|
|
659
694
|
|
|
660
695
|
See [Mutations and optimistic state](#mutations-and-optimistic-state).
|
|
661
696
|
|
|
662
|
-
### Why
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
697
|
+
### Why does a row leave its list for a frame after a mutation?
|
|
698
|
+
|
|
699
|
+
The answer left out fields the row showed while the request was pending. Once
|
|
700
|
+
the layer comes off, the store lacks them, so the read entry fetches them:
|
|
701
|
+
until they arrive, a list leaves the row out (and reads `Refreshing`), and a
|
|
702
|
+
read of that entity alone reads `Loading`. An answer carries what the active
|
|
703
|
+
Surfaces read and the layer showed of every row it names, so this is a row
|
|
704
|
+
the answer did not name, a field the server's read left out, or a server
|
|
705
|
+
that answers without the read (one older than the client, or whose read
|
|
706
|
+
failed). `Remote.make({ verifyPredictions: true })` names each one: see
|
|
707
|
+
[Checking an answer against what was shown](#checking-an-answer-against-what-was-shown).
|
|
708
|
+
|
|
709
|
+
### Why does a page I went back to read `Refreshing`?
|
|
710
|
+
|
|
711
|
+
Remote keeps what the screens you most recently left reached: the last 16
|
|
712
|
+
lists and 256 reads (`departedLimit`). A list left is held **stale**, so going
|
|
713
|
+
back shows its rows at once and reads `Refreshing` while the planner refetches
|
|
714
|
+
it; the page that answers replaces what was held. Only past those limits is a
|
|
715
|
+
screen's data collected, and then coming back reads `Loading` and refetches.
|
|
716
|
+
To keep something no screen reads, name it in a retain entry or a
|
|
669
717
|
`connections` list; see [Retention](#retention-and-garbage-collection).
|
|
670
718
|
|
|
671
719
|
### Why does a value the server has read `Failed`?
|
|
@@ -780,6 +828,12 @@ const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name;
|
|
|
780
828
|
A cursor is not part of the name: a page read from a cursor continues its page.
|
|
781
829
|
The server refuses a request that pages one relation more than four ways, and
|
|
782
830
|
a field's own name may not contain `@`.
|
|
831
|
+
- A `many` relation read whole, with no window, is bounded on the server:
|
|
832
|
+
`foldkit-remote-drizzle`'s `source` reads at most `maxCollection` children of
|
|
833
|
+
one row (100 unless its Source says), and fails the read, naming the
|
|
834
|
+
relation, when a row has more, since a whole list cannot say it was cut
|
|
835
|
+
short. The read then reads `Failed`. Read a collection that can grow without
|
|
836
|
+
limit through `Entity.page`.
|
|
783
837
|
|
|
784
838
|
`Entity.make` with `Entity.ref` keeps working, and both kinds can share one
|
|
785
839
|
domain. Both packages export `Entity`; a module that needs `Entity.from` beside
|
|
@@ -817,8 +871,10 @@ becomes `Refreshing` without discarding the old value. Planning accepts `now`
|
|
|
817
871
|
as an input, so tests can control time. It defaults to `Date.now`, read at each
|
|
818
872
|
use, so fake timers (`vi.useFakeTimers`) move it; Effect's `TestClock` does not,
|
|
819
873
|
because a plan runs outside Effect, so a test that drives `TestClock` passes
|
|
820
|
-
`now`. A mutation's answer is dated
|
|
821
|
-
|
|
874
|
+
`now`. A mutation's answer is dated by another clock: Effect's `Clock`, read
|
|
875
|
+
when the answer lands, so a test of how written rows age drives `TestClock`.
|
|
876
|
+
`Data.mutate`'s `now` option is the write's time, sent to the server; it does
|
|
877
|
+
not date the answer.
|
|
822
878
|
|
|
823
879
|
A policy belongs to one `subscriptions` call, and covers every read in it. Reads
|
|
824
880
|
under different policies take a call each:
|
|
@@ -924,20 +980,23 @@ of it.
|
|
|
924
980
|
### Refreshing from `update`
|
|
925
981
|
|
|
926
982
|
To revalidate what a screen already declares — a refresh button, a focus
|
|
927
|
-
regained — hand
|
|
983
|
+
regained — hand `Data.refresh` the active record the wiring takes:
|
|
928
984
|
|
|
929
|
-
```ts
|
|
930
|
-
case 'ClickedRefresh':
|
|
931
|
-
|
|
932
|
-
const page = ProjectPage.projection({ projectId: model.route.projectId })
|
|
933
|
-
return { model: Data.refresh(model, page) }
|
|
934
|
-
}
|
|
985
|
+
```ts sketch
|
|
986
|
+
case 'ClickedRefresh':
|
|
987
|
+
return { model: Data.refresh(model, actives) }
|
|
935
988
|
```
|
|
936
989
|
|
|
990
|
+
Each entry is resolved for this Model as the read entries resolve it, so a
|
|
991
|
+
`Surface.at` page reads with the params the route gives it, and an inactive
|
|
992
|
+
entry asks for nothing. Add a Surface to `actives` and its refresh comes with
|
|
993
|
+
it. One entry (`Data.refresh(model, actives.project)`), a Projection, or a
|
|
994
|
+
Surface without params narrows it.
|
|
995
|
+
|
|
937
996
|
`Data.refresh` performs no I/O and restates no request. It returns the Model
|
|
938
997
|
with every selected field the store holds reading `Refreshing`, every entity it
|
|
939
|
-
knew to be absent
|
|
940
|
-
|
|
998
|
+
knew to be absent asked for again (it reads `NotFound`, and stays out of every
|
|
999
|
+
list, until the answer says it exists), and every loaded connection invalidated; the `Data.subscriptions` read entries then refetch it,
|
|
941
1000
|
since stale data is planned again under every policy, so the page is requested
|
|
942
1001
|
once. The Projection must be observed, as it is while it is on screen; for data
|
|
943
1002
|
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
@@ -948,6 +1007,25 @@ nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
|
948
1007
|
- A read already in flight restarts instead of landing after the refresh.
|
|
949
1008
|
- Refreshing what is already refreshing, or nothing, returns the same Model.
|
|
950
1009
|
|
|
1010
|
+
`{ failed: true }` asks again only what failed: each entity read whose fields
|
|
1011
|
+
(or a related row's) failed, and each list whose query did. That is a "Try
|
|
1012
|
+
again": what loaded keeps its value, and a list keeps its later pages, which a
|
|
1013
|
+
whole refresh would replace with one page.
|
|
1014
|
+
|
|
1015
|
+
```ts sketch
|
|
1016
|
+
case 'ClickedTryAgain':
|
|
1017
|
+
return { model: Data.refresh(model, actives, { failed: true }) }
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
One list can also be named by its query and input, with no select or window
|
|
1021
|
+
to restate: `Data.refresh(model, Everyone.ref({}))` invalidates that list for
|
|
1022
|
+
every window reading it. It marks no row's fields, since a ref names no
|
|
1023
|
+
selection; the refetched page carries what each read selects. That suits a
|
|
1024
|
+
list another page reads, after a write whose answer cannot say it changed: a
|
|
1025
|
+
list the Model never loaded is left alone, and one nothing observes now is
|
|
1026
|
+
fetched when it is next read. With `{ failed: true }` it is asked again only
|
|
1027
|
+
if its query failed. A query the domain does not register is refused.
|
|
1028
|
+
|
|
951
1029
|
### Forgetting everything: a change of principal
|
|
952
1030
|
|
|
953
1031
|
Everything Remote knows, it knows for a principal: a value the server gave
|
|
@@ -956,7 +1034,7 @@ settled as unavailable, the rows of a list. Log in, log out, switch
|
|
|
956
1034
|
organization, and none of it is trustworthy. `Data.forget` is the one
|
|
957
1035
|
boundary:
|
|
958
1036
|
|
|
959
|
-
```ts
|
|
1037
|
+
```ts sketch
|
|
960
1038
|
case 'SignedOut': {
|
|
961
1039
|
return { model: Data.forget({ ...model, session: Option.none() }) }
|
|
962
1040
|
}
|
|
@@ -999,7 +1077,8 @@ const Data = Remote.make({
|
|
|
999
1077
|
something the client should be able to state, declare it with a body instead:
|
|
1000
1078
|
|
|
1001
1079
|
```ts
|
|
1002
|
-
import { Entity, Expr, Order
|
|
1080
|
+
import { Entity, Expr, Order } from 'foldkit-entity'
|
|
1081
|
+
import { Query } from 'foldkit-remote'
|
|
1003
1082
|
|
|
1004
1083
|
const Task = Entity.define(
|
|
1005
1084
|
'Task',
|
|
@@ -1031,6 +1110,23 @@ can compile that body rather than be told the same thing again in its own
|
|
|
1031
1110
|
dialect; one that would rather answer the query its own way still can, and a
|
|
1032
1111
|
descriptor from `Query.make` has no body at all.
|
|
1033
1112
|
|
|
1113
|
+
**Which to declare.** A list the client should keep right after a write needs
|
|
1114
|
+
a body: the body is what lets the client place a new or changed row in the
|
|
1115
|
+
pages it holds instead of fetching the list again. A `Query.make` list is
|
|
1116
|
+
invalidated by any change to its Entity, so it is fetched again after every
|
|
1117
|
+
write that touches one. A body can follow relations
|
|
1118
|
+
(`Expr.path`, `Expr.exists`, a path from `Expr.viewer`), so a feed of the
|
|
1119
|
+
people you follow is a body too. Keep `Query.make` for a list whose meaning
|
|
1120
|
+
only the server can state, such as a search ranking.
|
|
1121
|
+
|
|
1122
|
+
When part of a filter is something a body cannot say, put what it can say in
|
|
1123
|
+
the body and the rest in the server's own `where`
|
|
1124
|
+
(`foldkit-remote-drizzle`'s `query(descriptor, { entity, where })`), which is
|
|
1125
|
+
conjoined with the body. The client judges rows by the body alone, so write
|
|
1126
|
+
the body to admit no row the client makes or moves that the server's `where`
|
|
1127
|
+
refuses: such a row is placed in the list and stays there until the list is
|
|
1128
|
+
read again.
|
|
1129
|
+
|
|
1034
1130
|
The body is built **once**, when the query is declared. Inside it `input.ownerId`
|
|
1035
1131
|
is a placeholder for the value the query will be given, not the value — there is
|
|
1036
1132
|
nothing there yet to branch on. A condition that depends on what was passed is a
|
|
@@ -1062,8 +1158,14 @@ flat array. That matters when page 1 and page 3 are loaded but page 2 is not:
|
|
|
1062
1158
|
Remote represents an honest gap instead of pretending the visible rows are
|
|
1063
1159
|
adjacent.
|
|
1064
1160
|
|
|
1065
|
-
A query Projection is `Initial` until the page
|
|
1066
|
-
visible
|
|
1161
|
+
A query Projection is `Initial` until the page and the selected fields of at
|
|
1162
|
+
least one visible item are present. Then it shows the rows it can assemble: one
|
|
1163
|
+
whose fields have not arrived yet (a live insert, a row paged in without its
|
|
1164
|
+
fields) is left out, and the list reads `Refreshing` until it is, so the rows
|
|
1165
|
+
already shown stay on screen. A deleted row is left out too, so a list whose
|
|
1166
|
+
shown rows were all deleted shows none (`Refreshing` while others are read),
|
|
1167
|
+
never `Loading`. It reads
|
|
1168
|
+
`Ready` once every visible item is assembled. The `select` travels with the query: the server
|
|
1067
1169
|
returns the selected fields of the page's items with the edges, and one
|
|
1068
1170
|
`ConnectionMerged` merges the page and writes them, so a fresh list usually
|
|
1069
1171
|
lands `Ready` in one response. What the server leaves out (withheld fields,
|
|
@@ -1115,6 +1217,12 @@ empty-and-complete and empty-and-partial stay different answers — one means
|
|
|
1115
1217
|
answer (a search holding NUL, which the server refuses too) gives no items and
|
|
1116
1218
|
`complete: false`, rather than throwing in `update`.
|
|
1117
1219
|
|
|
1220
|
+
A body that reads other rows (`Expr.path(Member.relations.team,
|
|
1221
|
+
Team.fields.name)`, a path from the viewer) is judged over the whole store: a
|
|
1222
|
+
row whose path the store can follow is judged, and one whose path reaches a row
|
|
1223
|
+
or field the store lacks is not judged either way, so the answer is
|
|
1224
|
+
`complete: false`.
|
|
1225
|
+
|
|
1118
1226
|
Nothing is created: no connection, nothing new to retain, nothing new to fetch.
|
|
1119
1227
|
The server stays authoritative for which rows exist.
|
|
1120
1228
|
|
|
@@ -1122,13 +1230,14 @@ The server stays authoritative for which rows exist.
|
|
|
1122
1230
|
|
|
1123
1231
|
A connection's rows are edges the server delivered, and a query body describes
|
|
1124
1232
|
*which rows* — so until something evaluates the body on the client, nothing here
|
|
1125
|
-
can say whether a row belongs to a query. `
|
|
1233
|
+
can say whether a row belongs to a query. `matching`, exported by
|
|
1234
|
+
`foldkit-remote`, says:
|
|
1126
1235
|
|
|
1127
1236
|
```ts
|
|
1128
|
-
const judged =
|
|
1237
|
+
const judged = matching(Data.storeOf(model), ProjectsByOwner, { ownerId: 'u1' })
|
|
1129
1238
|
|
|
1130
1239
|
judged.matched // keys satisfying the body, in the order it asks for
|
|
1131
|
-
judged.skipped // keys held, but
|
|
1240
|
+
judged.skipped // keys held, but not judged either way
|
|
1132
1241
|
```
|
|
1133
1242
|
|
|
1134
1243
|
It is pure, and it runs the **same interpreter the server checks itself
|
|
@@ -1145,7 +1254,9 @@ keys — so it is in the shape. A caller that knows it holds the whole populatio
|
|
|
1145
1254
|
complete answer.
|
|
1146
1255
|
|
|
1147
1256
|
**`skipped` is the honest half.** A row missing a field the body reads cannot be
|
|
1148
|
-
judged: it is neither a match nor a non-match.
|
|
1257
|
+
judged: it is neither a match nor a non-match. Nor can a row holding a value no
|
|
1258
|
+
interpreter answers for (text with a NUL); each row is judged alone, so one such
|
|
1259
|
+
row is skipped and the others are still answered. Dropping it silently would turn
|
|
1149
1260
|
"I could not tell" into "no", so it is named instead, and the caller decides
|
|
1150
1261
|
whether to fetch the field, ask the server, or say the answer is partial.
|
|
1151
1262
|
|
|
@@ -1238,8 +1349,10 @@ again every time some unrelated read restarted the entry. What failed is left
|
|
|
1238
1349
|
out of the plan, and everything else is still fetched: the rows a failed list
|
|
1239
1350
|
holds, the other fields of an entity. What asks again:
|
|
1240
1351
|
|
|
1241
|
-
- `Data.refresh(model,
|
|
1242
|
-
that never loaded, too.
|
|
1352
|
+
- `Data.refresh(model, actives, { failed: true })` — the retry button. It
|
|
1353
|
+
works on something that never loaded, too. A read with nothing to show reads
|
|
1354
|
+
`Loading` while it is asked again, and `Failed` again if it fails again, so
|
|
1355
|
+
the button does not blink away.
|
|
1243
1356
|
- The value arriving anyway: a page for a list, or a read, live patch or
|
|
1244
1357
|
mutation result that writes the field.
|
|
1245
1358
|
- The server invalidating a list over a live stream, or deleting the entity.
|
|
@@ -1305,7 +1418,7 @@ const RenameProject = Mutation.make('RenameProject', {
|
|
|
1305
1418
|
|
|
1306
1419
|
A mutation starts from ordinary application `update`:
|
|
1307
1420
|
|
|
1308
|
-
```ts
|
|
1421
|
+
```ts sketch
|
|
1309
1422
|
case 'ClickedRename': {
|
|
1310
1423
|
const { id, name } = message
|
|
1311
1424
|
|
|
@@ -1331,20 +1444,80 @@ case 'ClickedRename': {
|
|
|
1331
1444
|
`Remote.patch(entity, id, values)` builds one entity's patch, and
|
|
1332
1445
|
`Remote.ref(entity, id)` names one row. Both take the entity you declared,
|
|
1333
1446
|
whether with `foldkit-entity` or Remote's `Entity.make`, and check `values`
|
|
1334
|
-
against its fields in their wire shape
|
|
1335
|
-
|
|
1447
|
+
against its fields in their wire shape, except a relation, which is a ref:
|
|
1448
|
+
`owner: Remote.ref(User, 'u1')`, an array of refs for a `many`, or `null`
|
|
1449
|
+
where it is optional. The patch holds the ref key the store keeps. A ref key
|
|
1450
|
+
typed by hand (`'User:u1'`) or a ref to another entity is refused, by the type
|
|
1451
|
+
and when the patch is made.
|
|
1336
1452
|
|
|
1337
1453
|
Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
1338
1454
|
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
1339
1455
|
Settling is idempotent per `requestId`.
|
|
1340
1456
|
|
|
1457
|
+
**Answers settle in the order their requests started, not the order they
|
|
1458
|
+
arrive.** The server applied the requests in that order, so an answer does not
|
|
1459
|
+
overwrite a field, or clear a row's deletion, that a request started after it
|
|
1460
|
+
has already settled; and an answer's `deleted` does not delete a row a later
|
|
1461
|
+
request has written. Two renames answered out of order keep the second name,
|
|
1462
|
+
and an edit answered after the row's delete leaves it deleted. Reads and live
|
|
1463
|
+
patches are the server's word at the time they arrive and land as they come.
|
|
1464
|
+
The ledger keeps each pending request's place in `mutations.order`, and the
|
|
1465
|
+
place of the last answer to write each field in `mutations.settled`, only
|
|
1466
|
+
while an earlier request is still pending.
|
|
1467
|
+
|
|
1341
1468
|
A mutation that deletes says so. The server's outcome carries
|
|
1342
1469
|
`deleted: [{ entity, id }]`, and settling it tombstones those entities: they read
|
|
1343
1470
|
`NotFound`, and they leave every connection and relation they were in, so the
|
|
1344
1471
|
server names no list. Patches apply first, so an entity named both ways is
|
|
1345
1472
|
deleted; a retry of the same request deletes nothing again.
|
|
1346
1473
|
|
|
1347
|
-
|
|
1474
|
+
A delete can show before the answer, with no list named either.
|
|
1475
|
+
`Remote.tombstone(entity, id)` in `optimistic` is a pending tombstone: while
|
|
1476
|
+
the request is in flight the row reads `NotFound` and leaves every list and
|
|
1477
|
+
every relation that lists it, as a settled delete does. It is a layer like a
|
|
1478
|
+
patch, so the server's store is untouched (`Data.confirmed` still reads the
|
|
1479
|
+
row), a failure brings the row back everywhere, and the answer's `deleted`
|
|
1480
|
+
keeps it gone. A pending delete wins over every pending patch of its row.
|
|
1481
|
+
|
|
1482
|
+
```ts
|
|
1483
|
+
Data.mutate(model, DeletePost, { id }, { optimistic: [Remote.tombstone(Post, id)] })
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
A pending patch shows a field before the server has sent it, but does not stand
|
|
1487
|
+
in for it: a read still fetches a field of a row the server holds that only a
|
|
1488
|
+
layer shows, so a failure that takes the layer away leaves the server's value,
|
|
1489
|
+
not `Initial`. A row only a request holds (one the store has never read, or
|
|
1490
|
+
read as absent: a pending create) is not fetched while the request is pending,
|
|
1491
|
+
since the server has nothing of it yet; once the answer lands, the fields it
|
|
1492
|
+
did not carry (a guard's `may`) are.
|
|
1493
|
+
|
|
1494
|
+
Optimistic entity patches are **layers over the base store**, not inverse
|
|
1495
|
+
patches. If multiple mutations overlap, the visible cache is recomputed as base
|
|
1496
|
+
plus the still-pending layers, so settling one does not require trying to undo
|
|
1497
|
+
its old value manually.
|
|
1498
|
+
|
|
1499
|
+
Connections can be optimistic too:
|
|
1500
|
+
|
|
1501
|
+
```ts
|
|
1502
|
+
import { ConnectionChange } from 'foldkit-remote'
|
|
1503
|
+
|
|
1504
|
+
const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
1505
|
+
optimistic: ({ tempId }) => [
|
|
1506
|
+
Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
|
|
1507
|
+
ConnectionChange.prepend(
|
|
1508
|
+
CommentsForPost.ref({ postId }),
|
|
1509
|
+
Remote.ref(Comment, tempId),
|
|
1510
|
+
),
|
|
1511
|
+
],
|
|
1512
|
+
})
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
A successful server result can replace the request's temporary connection
|
|
1516
|
+
overlays with confirmed ones in place.
|
|
1517
|
+
|
|
1518
|
+
### Which lists a write moves
|
|
1519
|
+
|
|
1520
|
+
Which lists a mutation changed is worked out from its answer. Each patch
|
|
1348
1521
|
says which row it wrote; the fields whose value differs from what the client
|
|
1349
1522
|
held are what changed (an answer returning a whole row changes only what is
|
|
1350
1523
|
new). Every loaded list over that Entity is judged by its query's body against
|
|
@@ -1371,6 +1544,10 @@ the store the answer left:
|
|
|
1371
1544
|
it could answer with the rows before the write;
|
|
1372
1545
|
- a list over a `Query.make` query, which declares no body, is invalidated by
|
|
1373
1546
|
any change to its Entity;
|
|
1547
|
+
- a list whose body reads other rows (a path, an exists, a path from the
|
|
1548
|
+
viewer) is invalidated by a change to any member those read, or a deletion
|
|
1549
|
+
of such a row: a team renamed moves every member listed by its name. A
|
|
1550
|
+
change to the link a path starts from re-judges that row alone;
|
|
1374
1551
|
- a row a list does not hold that changes a member the server's own read rule
|
|
1375
1552
|
filters on (an owner, a team: declared on the Entity as `ReadFilter`, which
|
|
1376
1553
|
`foldkit-guard` attaches) invalidates it: whether the row now belongs is the
|
|
@@ -1389,12 +1566,13 @@ does not run is judged by the server, as any row the client cannot judge is:
|
|
|
1389
1566
|
`Data.filtered` answers it incomplete and a change to it refetches.
|
|
1390
1567
|
|
|
1391
1568
|
**A placement can be checked against the server.** `Remote.make({ ...,
|
|
1392
|
-
|
|
1569
|
+
verifyPredictions: true })` places as usual, and also fetches each list it placed
|
|
1393
1570
|
what the server said in (an answer, as above, or a read or a live patch, as
|
|
1394
1571
|
below); when the server's first page arrives it is compared with the order
|
|
1395
|
-
placed, and a disagreement is
|
|
1396
|
-
`
|
|
1397
|
-
|
|
1572
|
+
placed, and a disagreement is a `PredictionMismatch` [diagnostic](#diagnostics)
|
|
1573
|
+
whose `part` is a `Placement`:
|
|
1574
|
+
an order the page does not share, or a row placing took out that the page
|
|
1575
|
+
still lists. A pending edit is not checked: the server does not have it
|
|
1398
1576
|
yet. Turn it on in development and tests: a collation the client
|
|
1399
1577
|
and the server disagree on then fails an assertion instead of misplacing a row
|
|
1400
1578
|
for a reader.
|
|
@@ -1425,6 +1603,8 @@ shown.
|
|
|
1425
1603
|
`Data.refresh` stays for what an answer cannot say, such as a server-side
|
|
1426
1604
|
effect on rows it did not return.
|
|
1427
1605
|
|
|
1606
|
+
### What became of a write
|
|
1607
|
+
|
|
1428
1608
|
`Data.mutation(model, requestId)` reads what became of it from the Model:
|
|
1429
1609
|
`Pending`, `Applied`, `Failed` with the error the server or transport gave, or
|
|
1430
1610
|
`Unknown` for an id never started here (or settled so long ago it left the
|
|
@@ -1439,8 +1619,65 @@ operation, and a retry with the same `requestId` (`Data.mutate(model, M, input,
|
|
|
1439
1619
|
can recognise the retry. After a reload the record of it is gone, and the same
|
|
1440
1620
|
request id is a new operation.
|
|
1441
1621
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1622
|
+
### When a write fails
|
|
1623
|
+
|
|
1624
|
+
A write that fails releases its optimistic layer, so its change disappears.
|
|
1625
|
+
Remote keeps the failure in the ledger, with the mutation and the input it was
|
|
1626
|
+
started with, until the application answers it. The application reads it from
|
|
1627
|
+
the Model; it keeps no list of its own:
|
|
1628
|
+
|
|
1629
|
+
```ts sketch
|
|
1630
|
+
// The view: a notice for each post that did not send, with its text to give back.
|
|
1631
|
+
Data.failedWrites(model, CreatePost) // ReadonlyArray<{ requestId, mutation, input, error, refusal }>
|
|
1632
|
+
|
|
1633
|
+
// update
|
|
1634
|
+
case 'ClickedRetry':
|
|
1635
|
+
return Option.match(Data.retry(model, message.requestId), {
|
|
1636
|
+
onNone: () => ({ model }),
|
|
1637
|
+
onSome: started => ({ model: started.model, commands: [started.command] }),
|
|
1638
|
+
})
|
|
1639
|
+
case 'ClickedDiscard':
|
|
1640
|
+
return { model: Data.dismiss(model, message.requestId) }
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
- `Data.failedWrites(model)` lists every failed write, oldest first, and
|
|
1644
|
+
`Data.failedWrites(model, CreatePost)` only that mutation's, with its `input`
|
|
1645
|
+
and `refusal` typed by it. A write in flight or applied is not listed.
|
|
1646
|
+
- `Data.retry(model, requestId)` sends a failed write again as itself: the same
|
|
1647
|
+
input, request id and operation, with its optimistic change shown again. It
|
|
1648
|
+
is `None` for a write that is not failed, so a second press does nothing.
|
|
1649
|
+
- `Data.dismiss(model, requestId)` forgets it.
|
|
1650
|
+
|
|
1651
|
+
Only a write in flight can fail: a `MutationFailed` repeated, or arriving after
|
|
1652
|
+
the write applied, was dismissed or was settled by `forget`, returns the Model
|
|
1653
|
+
it was given, as a repeated `MutationSucceeded` does.
|
|
1654
|
+
|
|
1655
|
+
A failed write stays listed until one of those, or `Data.forget` at a change of
|
|
1656
|
+
principal. Nothing drops one on a timer: that would hide a failure the reader
|
|
1657
|
+
has not seen. The ledger is bounded (the last 1024 failures), and is never
|
|
1658
|
+
persisted, so a reload forgets it.
|
|
1659
|
+
|
|
1660
|
+
To act on a failure in `update` (log it, move focus, put text back), have the
|
|
1661
|
+
wiring share it. Remote reduces it first, then the application's own update
|
|
1662
|
+
receives it with a Model that already lists it:
|
|
1663
|
+
|
|
1664
|
+
```ts
|
|
1665
|
+
Page.assemble(Data.wiring(actives, { shared: ['MutationFailed'] }))
|
|
1666
|
+
// own update: MutationFailed is now one of its Messages, so its match must answer it
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
A wiring that does not share it records each failed write as a `WriteUnheard`
|
|
1670
|
+
[diagnostic](#diagnostics), which by default logs it: the change went, and
|
|
1671
|
+
nothing in the application's update was told.
|
|
1672
|
+
|
|
1673
|
+
**Refused for who asks.** A server may also refuse who is asking, whatever the mutation
|
|
1674
|
+
(`RemoteServer.forbid`). Then `Data.mutation(model, requestId)` is `Failed`
|
|
1675
|
+
with an error of `_tag: 'Forbidden'`, and `reason` when the server gave one,
|
|
1676
|
+
for the person who asked. Asking again as the same person is refused again,
|
|
1677
|
+
so show it rather than retry.
|
|
1678
|
+
|
|
1679
|
+
**Refusals as data.** A mutation can say what the server may refuse it with,
|
|
1680
|
+
as data rather than a message to parse:
|
|
1444
1681
|
|
|
1445
1682
|
```ts
|
|
1446
1683
|
const SetPrice = Mutation.make('SetPrice', {
|
|
@@ -1452,17 +1689,115 @@ const SetPrice = Mutation.make('SetPrice', {
|
|
|
1452
1689
|
Data.refusal(model, requestId, SetPrice) // Option<{ _tag: 'Field', key: 'price', reason: string } | { _tag: 'Conflict' }>
|
|
1453
1690
|
```
|
|
1454
1691
|
|
|
1455
|
-
|
|
1692
|
+
`Refusal.field(key, reason)` names an input key and the Schema of why;
|
|
1693
|
+
`Refusal.conflict` says the row moved on, and `Refusal.unmet` that it does not
|
|
1694
|
+
hold what a [declared write](#declared-writes-what-the-client-predicts)'s
|
|
1695
|
+
`when` asks of it. A `Mutation.write` is refused as unmet only when its write
|
|
1696
|
+
has a `when`, and its `Refusal` type says so: without one it stays `Conflict`,
|
|
1697
|
+
so a match over it needs no `Unmet` arm. `Refusal.taken` says an insert's id
|
|
1698
|
+
is already a row's: its `entity` and `id`, the caller's own, and nothing of the
|
|
1699
|
+
row, which may be hidden from them. A `Mutation.write` over `Write.insert` is
|
|
1700
|
+
refused as taken and never as a conflict, since an insert expects no revision.
|
|
1701
|
+
`Refusal.invalid` says the row it
|
|
1702
|
+
would leave breaks an invariant of its Entity (`Entity.invariant`): its
|
|
1703
|
+
`entity` and `rule` (an id is unique only within its Entity, so the two name
|
|
1704
|
+
it) and its `path`, which a form shows the `message` beside, or none and empty
|
|
1705
|
+
when the invariant reads something the caller may not see. Every `Mutation.write` may be
|
|
1706
|
+
refused so, since the server's Entity may hold invariants the client's does not
|
|
1707
|
+
show. `Refusal.isInvalidBy(refusal, invariant)` asks whether it names one
|
|
1708
|
+
invariant, by its Entity and its id, given as `Entity.invariant` returned it
|
|
1709
|
+
(export it beside the Entity), so its id is never spelled again; an unnamed
|
|
1710
|
+
refusal matches none, and so does another Entity's invariant of the same id. The
|
|
1711
|
+
server sends the refusal encoded by this schema and `Data.refusal` decodes it
|
|
1712
|
+
by the same one; it is none while the mutation is pending or applied, or when
|
|
1713
|
+
it failed for another reason.
|
|
1714
|
+
|
|
1715
|
+
### Declared writes: what the client predicts
|
|
1716
|
+
|
|
1717
|
+
A mutation can be a declared write: `Mutation.write(name, write)` over a
|
|
1456
1718
|
`foldkit-entity` `Write.update`, `Write.insert` or `Write.delete`. Its input is
|
|
1457
1719
|
the write's, it answers nothing of its own, and `Data.mutate` shows an update's
|
|
1458
1720
|
or an insert's row until the server answers, so it needs no `optimistic` list;
|
|
1459
|
-
a delete shows
|
|
1721
|
+
a delete shows its row deleted, as `Remote.tombstone` does. An update's
|
|
1460
1722
|
`increment` is shown as a prediction: its amount is added to the value the store
|
|
1461
1723
|
holds whenever the row is read, so a change that reaches the store meanwhile is
|
|
1462
|
-
added to rather than hidden, and two pending increments add up. A
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1724
|
+
added to rather than hidden, and two pending increments add up. A computed
|
|
1725
|
+
field is predicted the same way, by `Remote.make`'s `operators` over the row as
|
|
1726
|
+
it is read (`Write.apply`), so a live change to what it reads changes the
|
|
1727
|
+
prediction. So is a count another registered Entity derives over the written
|
|
1728
|
+
row's relation (`likeCount: Derived.count(Like.relations.post)`): inserting a
|
|
1729
|
+
Like adds one to the post it points at, deleting a Like the client holds takes
|
|
1730
|
+
one away from the post it pointed at, and pointing it elsewhere does both, each
|
|
1731
|
+
added as an increment is. Where the client does not hold where the row pointed,
|
|
1732
|
+
or holds no count on that post, nothing is predicted.
|
|
1733
|
+
|
|
1734
|
+
A write shows what its Entity sets from the context (`Entity.sets`) as well,
|
|
1735
|
+
which no input sets: `Expr.viewer` as the viewer `Remote.make` is told of, and
|
|
1736
|
+
`Expr.now` as `Data.mutate`'s `now`, the time the reader acted. An insert sets
|
|
1737
|
+
every such member, an update those set `on: 'write'` (an `editedAt`). A new
|
|
1738
|
+
row's counts read 0 and its exists none.
|
|
1739
|
+
|
|
1740
|
+
`update` reads no clock, so the time is read where the action is, by a
|
|
1741
|
+
Command, and carried in the Message that starts the write. `Remote.now(toMessage)`
|
|
1742
|
+
is that Command; it reads Effect's `Clock`, so a test pins it:
|
|
1743
|
+
|
|
1744
|
+
```ts sketch
|
|
1745
|
+
// the action asks for the time…
|
|
1746
|
+
Posted: ({ post }) => ({ model, commands: [Remote.now(now => Message.Clocked({ post, now }))] }),
|
|
1747
|
+
// …and the Message it yields starts the write with it
|
|
1748
|
+
Clocked: ({ post, now }) => {
|
|
1749
|
+
const sent = Data.mutate(model, PostChirp, post, { now })
|
|
1750
|
+
return { model: sent.model, commands: [sent.command] }
|
|
1751
|
+
},
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
The request sends the same number, and the server takes it as the row's time
|
|
1755
|
+
when it lies within its `clockSkew` of the server's own clock (60 seconds
|
|
1756
|
+
unless `RemoteServer.make` says), so the answer agrees with what was shown.
|
|
1757
|
+
A retry (`Data.retry`) sends it again. Without `now`, a time the write sets is
|
|
1758
|
+
not predicted: the row reads as missing it until the server's answer brings the
|
|
1759
|
+
server's time. A supplied member declared with a `predict` (`Derived.make(schema,
|
|
1760
|
+
{ predict })`, as Guard's `may` is) is foreseen over the row the write leaves,
|
|
1761
|
+
the store's row with the write's values over it and a field the write moves
|
|
1762
|
+
unknown; where it foresees none, the row reads as missing it until the answer.
|
|
1763
|
+
An application that already mints the row's id in a Command can read the
|
|
1764
|
+
clock in the same one (`examples/reference`'s `NameNote`). The row then joins
|
|
1765
|
+
every loaded list whose body admits it, where the list's order puts it, while pending and once answered: a post
|
|
1766
|
+
set at its time goes to the top of a newest-first list. A list without a
|
|
1767
|
+
body, or one filtered by a read rule the client does not run, is the server's
|
|
1768
|
+
to place.
|
|
1769
|
+
|
|
1770
|
+
A viewer-relative member is predicted for the viewer `Remote.make` is told of.
|
|
1771
|
+
With a `Like` pointing at a `Post` and a `User` (`examples/chirp` declares the
|
|
1772
|
+
whole domain, its writes and its server):
|
|
1773
|
+
|
|
1774
|
+
```ts sketch
|
|
1775
|
+
const Post = Social.Post.pipe(
|
|
1776
|
+
Entity.derived({
|
|
1777
|
+
liked: Derived.exists(Social.Like.relations.post, { viewer: Social.Like.relations.user }),
|
|
1778
|
+
}),
|
|
1779
|
+
)
|
|
1780
|
+
const Data = Remote.make({
|
|
1781
|
+
model: App.model.remote,
|
|
1782
|
+
entities: [User, Post, Like],
|
|
1783
|
+
mutations: [LikePost, Unlike],
|
|
1784
|
+
viewer: model => Option.some(Remote.ref(User, model.me)),
|
|
1785
|
+
})
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
`liked` is the id of the viewer's Like of the post, or none. Inserting a Like
|
|
1789
|
+
pointing at a post and at the viewer shows it on that post (where the post is
|
|
1790
|
+
held with `liked` read, and none yet); deleting a Like, or pointing it at
|
|
1791
|
+
another post or person, takes it from the post it is shown on. That post also
|
|
1792
|
+
says where the deleted Like pointed, so its count goes down with no Like row
|
|
1793
|
+
held: unliking is `Data.mutate(model, Unlike, { id })` with the id `liked`
|
|
1794
|
+
holds. The store holds one viewer's values, as it holds one principal's, so a
|
|
1795
|
+
change of viewer is a change of principal: `Data.forget`.
|
|
1796
|
+
|
|
1797
|
+
A field the client has not read, or a computation by an operator its table
|
|
1798
|
+
lacks, is not guessed; it shows the server's value when the answer comes. The
|
|
1799
|
+
prediction is never authority: the server decides, and its answer replaces
|
|
1800
|
+
it. The [declared writes guide](../../docs/declared-writes.md) follows
|
|
1466
1801
|
one write from here to the statement that lands it. `keys` names the input keys
|
|
1467
1802
|
an author changed: only those are shown, and the request carries them for the
|
|
1468
1803
|
server to write; the input is still sent and validated whole.
|
|
@@ -1475,51 +1810,106 @@ const EditProject = Mutation.write(
|
|
|
1475
1810
|
Data.mutate(model, EditProject, { id: 'p1', name: 'Apollo II', status: 'active' }, { keys: ['name'] })
|
|
1476
1811
|
```
|
|
1477
1812
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
`
|
|
1487
|
-
|
|
1488
|
-
asks
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1813
|
+
### Refused where it is made
|
|
1814
|
+
|
|
1815
|
+
Before it shows or sends a declared write, `Data.mutate` refuses the ones the
|
|
1816
|
+
client can already tell the server would refuse, in the order the server
|
|
1817
|
+
checks: who asks, then the row.
|
|
1818
|
+
|
|
1819
|
+
**A write the held `may` says no to.** A supplied member may say which write
|
|
1820
|
+
its value forbids (`Derived.make(schema, { forbids })`); Guard's `may` does:
|
|
1821
|
+
the row's `Update` or `Delete`, a written member's own rule, or a relation's
|
|
1822
|
+
`Link` or `Unlink`, where it reads `false` for the row as the store holds it.
|
|
1823
|
+
`Data.mutate` asks each such member it holds before it judges the invariants,
|
|
1824
|
+
as the server refuses who asks first, and answers `MutationFailed` with `_tag:
|
|
1825
|
+
'Forbidden'` and the `explain`'s words as `reason`, showing and sending
|
|
1826
|
+
nothing. A `may` not held, or one that does not name the part, refuses nothing;
|
|
1827
|
+
a held `may` is the server's answer as of its read, so `Data.refresh` asks
|
|
1828
|
+
again.
|
|
1829
|
+
|
|
1830
|
+
`may` is a UI hint, and the server enforces every write. A `may` that says
|
|
1831
|
+
yes, stale after a change of role, foreseen for a row the client wrote, or
|
|
1832
|
+
edited in the page's Model, decides nothing: the request carries no `may`,
|
|
1833
|
+
the server checks the write by its rules as the request's principal, and a
|
|
1834
|
+
refusal comes back as `Forbidden`, which takes the shown change back. The
|
|
1835
|
+
client's refusal only saves the round trip for a no it already holds. See
|
|
1836
|
+
[`foldkit-guard`](../guard/README.md#what-may-tells-a-reader-and-what-it-does-not)
|
|
1837
|
+
for what `may` tells a reader.
|
|
1838
|
+
|
|
1839
|
+
**A write the client can tell is invalid.** `Data.mutate` judges a declared
|
|
1840
|
+
write's Entity invariants over the row it would leave, as the client holds it:
|
|
1841
|
+
the store's row with the write's values over it, a field it moves (an
|
|
1842
|
+
increment, a computed value) not held. Where one is definitely broken, it shows
|
|
1843
|
+
nothing and sends nothing; its Command answers `MutationFailed` at once with
|
|
1844
|
+
the very `Refusal.invalid` the server would send, so `Data.refusal`,
|
|
1845
|
+
`Data.failedWrites` and your `update` handle it as a server's. An invariant
|
|
1846
|
+
over a field the client lacks is undecided and refuses nothing: the server
|
|
1847
|
+
judges it. Passing `optimistic` yourself skips the check.
|
|
1848
|
+
|
|
1849
|
+
### What an answer carries
|
|
1850
|
+
|
|
1851
|
+
When the answer comes, the request's layer comes off, so the answer must hold
|
|
1852
|
+
everything the layer showed. The client says what that is: `Data.mutate` sends,
|
|
1853
|
+
with the request, what every active Surface reads of each entity (each
|
|
1854
|
+
`Data.get`'s fields and each `Data.query`'s `select`, relation targets
|
|
1855
|
+
included), and every field the layer itself shows. The second matters when
|
|
1856
|
+
a write moves a count no Surface reads now: a note deleted on its own page
|
|
1857
|
+
lowers its author's `noteCount`, which only the notebook page reads, and
|
|
1858
|
+
without it the answer would leave the count as it was before the write. The
|
|
1859
|
+
server reads, of each row its answer names, the fields it
|
|
1860
|
+
shows that the answer left out, after the write and as any read is, and sends
|
|
1861
|
+
that read with the answer (`foldkit-remote-server`'s
|
|
1862
|
+
[What an answer carries](../remote-server/README.md#what-an-answer-carries)).
|
|
1863
|
+
The client writes it in the reduction that releases the layer:
|
|
1493
1864
|
|
|
1494
|
-
|
|
1495
|
-
(
|
|
1496
|
-
|
|
1497
|
-
for the person who asked. Asking again as the same person is refused again,
|
|
1498
|
-
so show it rather than retry.
|
|
1499
|
-
|
|
1500
|
-
Optimistic entity patches are **layers over the base store**, not inverse
|
|
1501
|
-
patches. If multiple mutations overlap, the visible cache is recomputed as base
|
|
1502
|
-
plus the still-pending layers, so settling one does not require trying to undo
|
|
1503
|
-
its old value manually.
|
|
1504
|
-
|
|
1505
|
-
Connections can be optimistic too:
|
|
1506
|
-
|
|
1507
|
-
```ts
|
|
1508
|
-
import { ConnectionChange } from 'foldkit-remote'
|
|
1865
|
+
```text
|
|
1866
|
+
answer (rows the write names) + read (what the Surfaces and the layer show of them) -> store, layer released
|
|
1867
|
+
```
|
|
1509
1868
|
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1869
|
+
So a created post lands with the count, the `may` and the author's name the
|
|
1870
|
+
feed reads, though the write stored none of them, and the feed never reads
|
|
1871
|
+
`Loading` for it. There is nothing to configure: the Surfaces already say what
|
|
1872
|
+
is shown. A row the answer does not name is not read; the server names the
|
|
1873
|
+
rows a write changed. Each `Data.subscriptions` call registers what it reads, so
|
|
1874
|
+
a `Data.mutate` with none registered sends nothing to show.
|
|
1875
|
+
|
|
1876
|
+
### Checking an answer against what was shown
|
|
1877
|
+
|
|
1878
|
+
When the answer comes, the request's layer comes off, and what the row shows
|
|
1879
|
+
is what the store holds. Two things can then differ from what was shown.
|
|
1880
|
+
`Remote.make({ ..., verifyPredictions: true })` settles every mutation as usual
|
|
1881
|
+
and records each as a `PredictionMismatch` [diagnostic](#diagnostics), its
|
|
1882
|
+
`part` naming which:
|
|
1883
|
+
|
|
1884
|
+
- **`Value`:** a field the layer showed that the answer carries another value
|
|
1885
|
+
of. The client's prediction and the server's write disagree: a count moved
|
|
1886
|
+
by two, a time from another clock, a hand-written `optimistic` patch that
|
|
1887
|
+
guessed. A field another pending request also shows is not judged, since
|
|
1888
|
+
that request's value shows before and after.
|
|
1889
|
+
- **`Gap`:** a field the layer showed that the answer left out, and the store
|
|
1890
|
+
does not hold. It is then missing: an active Surface that reads it fetches it
|
|
1891
|
+
again, and the row reads `Loading` meanwhile.
|
|
1892
|
+
|
|
1893
|
+
```ts sketch
|
|
1894
|
+
// requestId: the one Data.mutate returned; mutation: its name
|
|
1895
|
+
{ _tag: 'PredictionMismatch', part: { _tag: 'Value', requestId, mutation, entity: 'Post', id: 'p9', field: 'likeCount', shown: 3, answered: 4 }, … }
|
|
1896
|
+
{ _tag: 'PredictionMismatch', part: { _tag: 'Gap', requestId, mutation, entity: 'Post', id: 'p9', fields: ['editedAt', 'author'] }, … }
|
|
1519
1897
|
```
|
|
1520
1898
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1899
|
+
Match on the part with `PredictionPart.$match`. `Law.check`
|
|
1900
|
+
([below](#testing-that-a-prediction-is-the-truth-law)) is the same law, in a
|
|
1901
|
+
test, over every part rather than the ones an answer carries.
|
|
1902
|
+
|
|
1903
|
+
A row's shown fields are those the request's optimistic patch wrote, added
|
|
1904
|
+
to or computed (a declared write's own included), plus those the active
|
|
1905
|
+
Surfaces (every `Data.subscriptions` call) read of a row the request patched
|
|
1906
|
+
or put in a list. A field the store still holds (an older value, or another
|
|
1907
|
+
pending request's) is not reported, nor is a row the answer deleted, or a
|
|
1908
|
+
temporary id it replaced. A repeated answer is not checked again. It changes
|
|
1909
|
+
nothing but `diagnostics`, so turn it on in development and tests, and assert
|
|
1910
|
+
in a test that `Data.inspect(model).diagnostics` is empty. With [the answer's read](#what-an-answer-carries),
|
|
1911
|
+
a gap is a row the answer did not name: name it in the answer's `entities`
|
|
1912
|
+
(`Remote.patch(Post, id, {})` asks the server for all it shows of it).
|
|
1523
1913
|
|
|
1524
1914
|
### Showing a change nobody has made
|
|
1525
1915
|
|
|
@@ -1572,7 +1962,14 @@ capability that must not claim success before the server reflects it:
|
|
|
1572
1962
|
```ts
|
|
1573
1963
|
Agent.when({
|
|
1574
1964
|
projection: Data.confirmed(Data.get(ProjectSummary, projectId)),
|
|
1575
|
-
|
|
1965
|
+
// The projection reads a RemoteData: only a value the server holds can match.
|
|
1966
|
+
predicate: (project, request: { readonly name: string }) =>
|
|
1967
|
+
RemoteData.render(project, {
|
|
1968
|
+
loading: () => false,
|
|
1969
|
+
notFound: () => false,
|
|
1970
|
+
failed: () => false,
|
|
1971
|
+
data: value => value.name === request.name,
|
|
1972
|
+
}),
|
|
1576
1973
|
})
|
|
1577
1974
|
```
|
|
1578
1975
|
|
|
@@ -1583,22 +1980,113 @@ Sync draws the same line over its replica, with the same words and a different
|
|
|
1583
1980
|
mechanism: see [what a reader sees while a change is in
|
|
1584
1981
|
flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight).
|
|
1585
1982
|
|
|
1983
|
+
### A write that outlives the page: `Data.outbox`
|
|
1984
|
+
|
|
1985
|
+
A like pressed on a train, a post written in a tunnel: the write must reach the
|
|
1986
|
+
server, but nothing needs to converge, since the server decides it once. Make
|
|
1987
|
+
it durable, and assemble the outbox beside `Data.wiring`:
|
|
1988
|
+
|
|
1989
|
+
```ts
|
|
1990
|
+
const outbox = Data.outbox({
|
|
1991
|
+
key: model => `outbox:${model.userId}`,
|
|
1992
|
+
scope: model => model.userId,
|
|
1993
|
+
})
|
|
1994
|
+
const placements = Page.assemble(Data.wiring(actives), outbox)
|
|
1995
|
+
|
|
1996
|
+
// update: queued, not sent; its layer shows at once
|
|
1997
|
+
const sent = Data.mutate(model, PostChirp, post, { now, durable: true })
|
|
1998
|
+
return { model: sent.model, commands: [sent.command] }
|
|
1999
|
+
|
|
2000
|
+
// the view
|
|
2001
|
+
const { waiting } = Data.outboxStatus(model) // "3 changes waiting"
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
`outbox.restore(model)` is `persistence.restore`'s twin: resolve it as the
|
|
2005
|
+
runtime's `flags` and start `init` from it. It reads back what a reload left and
|
|
2006
|
+
mints the page's session, which names its writes; `Data.mutate` with `durable`
|
|
2007
|
+
before it has run throws.
|
|
2008
|
+
|
|
2009
|
+
```text
|
|
2010
|
+
Data.mutate(…, { durable }) → queued in the Model, layer shown (its Command answers MutationQueued)
|
|
2011
|
+
outbox.save → the queue written to the store, merged with other tabs' → OutboxRead (stored)
|
|
2012
|
+
outbox.send → the oldest stored write sent, one at a time, by one tab → MutationSucceeded | MutationFailed
|
|
2013
|
+
reload → outbox.restore: the queue back, layers shown → sent again, same operation id, same now
|
|
2014
|
+
```
|
|
2015
|
+
|
|
2016
|
+
- **Stored before sent.** A write is sent only once the store holds it, so a
|
|
2017
|
+
reload while it is on its way finds it and sends it again. Its request id is
|
|
2018
|
+
its operation id, so the writer's ledger answers the second send from its
|
|
2019
|
+
record: a durable Mutation needs a ledgered writer (`RemoteServer.write`, or
|
|
2020
|
+
remote-drizzle's `writer`), or a handler that answers by operation id.
|
|
2021
|
+
- **In order, one at a time.** A post and the like of it, made offline, reach
|
|
2022
|
+
the server in that order. A refused write, or one this version cannot read,
|
|
2023
|
+
does not hold back the rest.
|
|
2024
|
+
- **Sent again only when nothing answered.** A send that fails with
|
|
2025
|
+
`Unreachable` (the request did not go, or what came back was no Remote answer) keeps the
|
|
2026
|
+
write waiting and sends it again after 1 second doubling to 30 (`retry: {
|
|
2027
|
+
base, max }`), and as soon as the browser is `online`; each attempt is
|
|
2028
|
+
`OutboxDelayed`. A refusal, `Forbidden` or a server error settles it as
|
|
2029
|
+
`Failed`, as for any write: `Data.failedWrites`, `Data.retry` (queued again)
|
|
2030
|
+
and `Data.dismiss` (gone from the store too) work on it unchanged. A write
|
|
2031
|
+
still waiting cannot be dismissed: it may be on its way.
|
|
2032
|
+
- **One tab sends; every tab shows.** The store is shared by every tab on the
|
|
2033
|
+
key. A Web Lock picks the one that sends, which reads the store again first
|
|
2034
|
+
so a write another tab settled is not sent twice; a `BroadcastChannel` tells
|
|
2035
|
+
the others, which show each other's waiting writes and settle them with the
|
|
2036
|
+
sender's answer. Without Web Locks each tab sends what it holds.
|
|
2037
|
+
- **`now` is the reader's, still.** A stored write is sent with the time it was
|
|
2038
|
+
made at. Past the server's `clockSkew`, the server writes its own time and
|
|
2039
|
+
reports `ClockSkewed`, and the answer moves the row there: the bound that
|
|
2040
|
+
stops a client backdating rows cannot tell an honest wait from a forged one.
|
|
2041
|
+
- **After a deploy.** A stored write naming a Mutation this version lacks, or
|
|
2042
|
+
whose input no longer decodes, is kept as `Unreadable` in `outboxStatus`,
|
|
2043
|
+
never sent, reported once as `OutboxUnreadable`, and removed by
|
|
2044
|
+
`Data.dismiss`. A store that cannot be written reports `OutboxUnsaved`, and
|
|
2045
|
+
the writes are sent anyway.
|
|
2046
|
+
- **A change of principal** is `Data.forget` and another `key`, as for
|
|
2047
|
+
persistence: one person's waiting writes are never sent as another's. The
|
|
2048
|
+
page holds the queue it read for one `key` and `scope`; when either moves,
|
|
2049
|
+
it sends none of it and reads the new one's. A store holding another
|
|
2050
|
+
scope's waiting writes is not written over, so a `key` shared by
|
|
2051
|
+
principals reports `OutboxUnsaved` rather than losing them.
|
|
2052
|
+
|
|
1586
2053
|
### Remote mutation vs Sync operation
|
|
1587
2054
|
|
|
1588
|
-
A Remote
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2055
|
+
A durable Remote write is still Remote's: the server decides it once, a
|
|
2056
|
+
refusal is final, and the client never reads its own effects before the server
|
|
2057
|
+
has answered. If edits must merge with other devices' edits made offline, or
|
|
2058
|
+
the application must build on its own unsent edits as if they were committed,
|
|
2059
|
+
that is [`foldkit-sync`](../sync)'s journal (see [which state belongs
|
|
2060
|
+
here](#which-state-belongs-here)). An optimistic overlay stays a temporary view
|
|
2061
|
+
of server-owned data: the outbox stores the operation, never the overlay as a
|
|
2062
|
+
queue of edits.
|
|
1594
2063
|
|
|
1595
2064
|
## Live data
|
|
1596
2065
|
|
|
1597
2066
|
`Data.live` marks a Projection so active `Data.subscriptions` follow server
|
|
1598
|
-
changes for it.
|
|
2067
|
+
changes for it. A list is marked by its read: `live: true` on `Data.query`
|
|
2068
|
+
follows the rows that join, change or leave it, with no row of its own to
|
|
2069
|
+
subscribe to:
|
|
2070
|
+
|
|
2071
|
+
```ts
|
|
2072
|
+
Data.query(Notes, {}, { select: NoteRow, first: 50, live: true })
|
|
2073
|
+
```
|
|
2074
|
+
|
|
2075
|
+
A Surface's live entry (`<key>.live`) subscribes the rows and the lists its
|
|
2076
|
+
reads mark live in one stream, sending each list as its read sends it (query,
|
|
2077
|
+
input, window, `select`). A list read live needs `first`, since a server reads
|
|
2078
|
+
it again for every change it checks; `Data.query` throws without one. The
|
|
2079
|
+
server admits a stream before it asks any live source: its lists' `first`
|
|
2080
|
+
count toward `maxIdsPerEntity` with the rows it follows, and it may carry at
|
|
2081
|
+
most `MAX_LIVE_QUERIES` (32) lists; past either it is refused
|
|
2082
|
+
(`RemoteLiveRefused`, below). A live
|
|
2083
|
+
source that watches lists (`foldkit-remote-drizzle`'s `pollLive`) diffs it;
|
|
2084
|
+
`RemoteServer.liveHub` watches none, so on a server with only a hub a live
|
|
2085
|
+
list changes only when read again.
|
|
1599
2086
|
|
|
1600
2087
|
The Remote Model owns each stream's cursor. Live events carry monotonically
|
|
1601
|
-
ordered cursors
|
|
2088
|
+
ordered cursors, counting on from the cursor the stream was opened after (`0`
|
|
2089
|
+
for a new stream, so its first event is `1`):
|
|
1602
2090
|
|
|
1603
2091
|
- an old/repeated cursor is a duplicate and is ignored;
|
|
1604
2092
|
- the next cursor is applied;
|
|
@@ -1629,10 +2117,9 @@ break -> gap marked, restarts + 1 -> backoff
|
|
|
1629
2117
|
- **A gap stays until the restart.** An in-order event that a broken stream
|
|
1630
2118
|
still delivers is applied, but does not clear it. The event that arrived
|
|
1631
2119
|
ahead was dropped, and only the refetch brings back what it said.
|
|
1632
|
-
- **What a stream covers is its requirements.** A list
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
every open for this reason.
|
|
2120
|
+
- **What a stream covers is its requirements.** A list read live is the
|
|
2121
|
+
live source's to re-send when the stream opens: `pollLive` sends a
|
|
2122
|
+
`ConnectionInvalidate` for each list on every open for this reason.
|
|
1636
2123
|
|
|
1637
2124
|
`Data.liveStatus(model, active)` says how what one active entry reads live is
|
|
1638
2125
|
doing, for a view that shows it:
|
|
@@ -1643,15 +2130,26 @@ Match.value(Data.liveStatus(model, Watched)).pipe(
|
|
|
1643
2130
|
Idle: () => 'not live',
|
|
1644
2131
|
Live: () => 'live',
|
|
1645
2132
|
Reconnecting: ({ attempt }) => `reconnecting (attempt ${attempt})`,
|
|
2133
|
+
Refused: ({ error }) => `not live: ${error.message}`,
|
|
1646
2134
|
}),
|
|
1647
2135
|
)
|
|
1648
2136
|
```
|
|
1649
2137
|
|
|
1650
2138
|
`Reconnecting` lasts from the break until the stream has resubscribed, and
|
|
1651
|
-
carries the error, which is none for a gap. `
|
|
2139
|
+
carries the error, which is none for a gap. `Refused` is a stream the server
|
|
2140
|
+
would not open for what it asks (`RemoteLiveRefused`: more rows of one Entity
|
|
2141
|
+
than its `maxIdsPerEntity`, or more than 32 lists). Asking again would be refused again, so it is
|
|
2142
|
+
not restarted until the entry's live requirements change, which opens a new
|
|
2143
|
+
stream; a `LiveRefused` diagnostic records it once. `RemoteModel.streams` holds each
|
|
1652
2144
|
stream's restarts, its breaks in a row and its last error. `forget` clears it
|
|
1653
2145
|
with every other server-derived fact.
|
|
1654
2146
|
|
|
2147
|
+
The live ledger (`live`, `streams`, `gaps`) keeps the 256 streams most
|
|
2148
|
+
recently heard from. Retention cannot prune it by what is shown: its roots
|
|
2149
|
+
merge every Surface's requirements, and a stream is one Surface's. A stream
|
|
2150
|
+
dropped while still open has no cursor, so its next event, past `1`, is a
|
|
2151
|
+
gap: it restarts from the start and refetches what it covers.
|
|
2152
|
+
|
|
1655
2153
|
## Retention and garbage collection
|
|
1656
2154
|
|
|
1657
2155
|
Remote is a cache, so it should be allowed to forget facts no active feature
|
|
@@ -1667,9 +2165,21 @@ reads; `connections` given to any call are kept the same way. A root keeps:
|
|
|
1667
2165
|
- data touched by pending optimistic work.
|
|
1668
2166
|
|
|
1669
2167
|
After the configured grace period, `RetentionChanged` enters the Model and the
|
|
1670
|
-
pure Remote reducer collects
|
|
1671
|
-
|
|
1672
|
-
|
|
2168
|
+
pure Remote reducer collects what no root reaches. The roots it keeps are the
|
|
2169
|
+
active ones and those of the Surfaces most recently left, up to
|
|
2170
|
+
`departedLimit` (16 lists and 256 entity reads, oldest dropped first), so going
|
|
2171
|
+
back to a page shows what it held rather than reading `Loading`:
|
|
2172
|
+
|
|
2173
|
+
```text
|
|
2174
|
+
Surface left -> its lists held stale, its reads held
|
|
2175
|
+
Surface again -> rows read Refreshing; the planner refetches the stale list
|
|
2176
|
+
page arrives -> it replaces the held rows; Ready
|
|
2177
|
+
past the limit -> collected; going back reads Loading and refetches
|
|
2178
|
+
```
|
|
2179
|
+
|
|
2180
|
+
A value nothing reached when retention ran, such as a prefetch no Surface
|
|
2181
|
+
showed, is still collected at once. Collecting is safe because the server
|
|
2182
|
+
remains authoritative and the planner can refetch.
|
|
1673
2183
|
|
|
1674
2184
|
This is another important difference from Sync: **Remote recovery may discard
|
|
1675
2185
|
and refetch; Sync recovery must preserve unsent client intent.**
|
|
@@ -1683,6 +2193,7 @@ cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
|
|
|
1683
2193
|
never in one.
|
|
1684
2194
|
|
|
1685
2195
|
```ts
|
|
2196
|
+
import { Option } from 'effect'
|
|
1686
2197
|
import { RemotePersistence } from 'foldkit-remote'
|
|
1687
2198
|
|
|
1688
2199
|
// A cache carried in the page: prefetch on the server, embed, then hydrate.
|
|
@@ -1693,8 +2204,11 @@ const text = RemotePersistence.dehydrate(
|
|
|
1693
2204
|
{ scope: userId },
|
|
1694
2205
|
)
|
|
1695
2206
|
|
|
1696
|
-
|
|
1697
|
-
|
|
2207
|
+
// None when the text is refused (another scope, version, or a corrupt text).
|
|
2208
|
+
const restored = Option.getOrElse(
|
|
2209
|
+
RemotePersistence.hydrate(text, { scope: userId }),
|
|
2210
|
+
() => RemotePersistence.emptySnapshot,
|
|
2211
|
+
)
|
|
1698
2212
|
|
|
1699
2213
|
Data.reduce(model, {
|
|
1700
2214
|
_tag: 'Hydrated',
|
|
@@ -1740,13 +2254,33 @@ const persistence = Data.persistence({
|
|
|
1740
2254
|
const placements = Page.assemble(Data.wiring(actives), persistence)
|
|
1741
2255
|
```
|
|
1742
2256
|
|
|
1743
|
-
The restore
|
|
1744
|
-
|
|
2257
|
+
The first restore belongs before the first frame, or a reload draws `Loading`
|
|
2258
|
+
until a Subscription brings the stored rows. `persistence.restore(model)` is
|
|
2259
|
+
that restore as an Effect: resolve it as the runtime's `flags`, which the
|
|
2260
|
+
runtime runs with its `resources` before `init`, and start `init` from the
|
|
2261
|
+
Model it gives (`examples/cloudflare/src/browser.ts` does):
|
|
2262
|
+
|
|
2263
|
+
```ts
|
|
2264
|
+
Runtime.makeElement(
|
|
2265
|
+
placements.runtime({
|
|
2266
|
+
Model,
|
|
2267
|
+
Flags: Model,
|
|
2268
|
+
flags: persistence.restore(initial(actor)),
|
|
2269
|
+
initial: (model: Model) => ({ model }),
|
|
2270
|
+
// …
|
|
2271
|
+
}),
|
|
2272
|
+
)
|
|
2273
|
+
```
|
|
2274
|
+
|
|
2275
|
+
After that the wiring restores only a key the Model is not restored from (a
|
|
2276
|
+
change of principal), and saves as the cache changes:
|
|
1745
2277
|
|
|
1746
2278
|
```text
|
|
1747
2279
|
key -> restore -> Hydrated { from: key } -> restoredFrom = key -> save on each change
|
|
1748
2280
|
```
|
|
1749
2281
|
|
|
2282
|
+
Without `restore` in `init`, the wiring restores at start too, a frame late.
|
|
2283
|
+
|
|
1750
2284
|
- **A restore keeps what the Model already holds.** It hydrates with
|
|
1751
2285
|
`merge: 'preserve-existing'`, so a fact resumed from the server, or read since
|
|
1752
2286
|
the page started, wins over the older snapshot.
|
|
@@ -1755,6 +2289,11 @@ key -> restore -> Hydrated { from: key } -> restoredFrom = key -> save on each c
|
|
|
1755
2289
|
then saved under their key; `forget` clears it as the key changes. The
|
|
1756
2290
|
`scope` check refuses a snapshot written for another reader; it cannot see
|
|
1757
2291
|
facts that are only in memory.
|
|
2292
|
+
- **`forget` never brings back what it forgot.** It keeps `restoredFrom`, so
|
|
2293
|
+
a key that does not change (a sign-out that keeps the key) is not restored
|
|
2294
|
+
again; the emptied cache is saved over its snapshot after the debounce
|
|
2295
|
+
instead. A key that changes is restored as any new key is, so both
|
|
2296
|
+
`forget` alone and `forget` with a new key are safe at a change of principal.
|
|
1758
2297
|
- **Nothing is saved before its own key is restored.** `Hydrated` records the
|
|
1759
2298
|
key it came from in `model.remote.restoredFrom`. Until that key is restored,
|
|
1760
2299
|
nothing is saved, so the empty store at startup cannot overwrite the
|
|
@@ -1854,12 +2393,92 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
|
1854
2393
|
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
1855
2394
|
server packages together.
|
|
1856
2395
|
|
|
2396
|
+
## Clients and transports
|
|
2397
|
+
|
|
2398
|
+
Every client is a `Layer<RemoteClient>`, and Remote's semantics do not change
|
|
2399
|
+
with it: a read, a query, a write and a live stream mean the same over Effect
|
|
2400
|
+
RPC, JSON or a port. What differs is how each reports a request that did not
|
|
2401
|
+
get an answer.
|
|
2402
|
+
|
|
2403
|
+
`Remote.json(send)` is the JSON client over any way of sending the request
|
|
2404
|
+
text; `Remote.http(url, { headers, fetch })` is it over `fetch`. `headers` is
|
|
2405
|
+
read for each request, so a token that changes reaches the next call.
|
|
2406
|
+
|
|
2407
|
+
**Who is asking.** A 401 fails every call kind, a read, a query, a mutation or
|
|
2408
|
+
a live stream, with `_tag: 'Unauthenticated'` (and `reason` when the server
|
|
2409
|
+
gave one): the server could not tell who is asking. A 401 is read so whatever
|
|
2410
|
+
its body says. A 403 whose body does not say `forbidden` itself, as Remote's
|
|
2411
|
+
server's does, comes from middleware in front of it refusing a session (a
|
|
2412
|
+
bare `{ error }` included): it fails a mutation as `Forbidden` and any other
|
|
2413
|
+
call with the status. `RemoteData.denied(error)` reads a failure as
|
|
2414
|
+
`Unauthenticated` or `Forbidden`, typed, and as none for any other, so a view
|
|
2415
|
+
tells "sign in again" from "could not load" without comparing tags. Remote
|
|
2416
|
+
treats either as any failure: a read or query is `Failed`, and a live stream
|
|
2417
|
+
restarts with its backoff, refused again each time, until the application
|
|
2418
|
+
signs in again and calls `Data.forget`.
|
|
2419
|
+
|
|
2420
|
+
The JSON clients carry no live data. For live data over the same `POST`s,
|
|
2421
|
+
`Remote.httpWithLive` keeps reads, queries, and mutations as `http` sends
|
|
2422
|
+
them and streams live requirements as a `text/event-stream` of changes;
|
|
2423
|
+
[`serveFetch`](../remote-server/README.md#over-fetch-workers) is the other
|
|
2424
|
+
end. Abandoning the stream aborts the request, which interrupts the
|
|
2425
|
+
subscription server-side.
|
|
2426
|
+
|
|
2427
|
+
For a server in a worker or in the page (a sandbox), `foldkit-remote/port`
|
|
2428
|
+
speaks Effect's own RPC worker protocol over a `MessagePort`, so live data,
|
|
2429
|
+
typed errors and interruption cross it too.
|
|
2430
|
+
[`servePort`](../remote-server/README.md#over-a-messageport) is the other end:
|
|
2431
|
+
|
|
2432
|
+
```ts
|
|
2433
|
+
import { port } from 'foldkit-remote/port'
|
|
2434
|
+
|
|
2435
|
+
// A new conversation each time the protocol connects: one port here, the other to the server.
|
|
2436
|
+
const overPort = port(() => {
|
|
2437
|
+
const channel = new MessageChannel()
|
|
2438
|
+
worker.port.postMessage({ open: 'remote' }, [channel.port2])
|
|
2439
|
+
return channel.port1
|
|
2440
|
+
})
|
|
2441
|
+
```
|
|
2442
|
+
|
|
2443
|
+
It is a `Layer<RemoteClient>` like the others; a port that cannot be set up is
|
|
2444
|
+
a defect, not a failed read.
|
|
2445
|
+
|
|
2446
|
+
**Nothing answered.** The client `RpcClient.make(RemoteRpc)` builds is
|
|
2447
|
+
accepted as it is. When its transport fails, a read, a
|
|
2448
|
+
query or a live stream fails with its own Remote error (`RemoteReadError`,
|
|
2449
|
+
`RemoteQueryError`, `RemoteLiveError`), which the UI shows and retries like any
|
|
2450
|
+
other failure. A mutation fails with `RemoteUnreachable` (`_tag:
|
|
2451
|
+
'Unreachable'`): nothing answered, so the server may or may not have run it.
|
|
2452
|
+
`RemoteData.isUnreachable(error)` asks that of a failure without comparing tags.
|
|
2453
|
+
`Remote.json`, `Remote.http` and `Remote.httpWithLive` say the same of a
|
|
2454
|
+
request they could not send, and of a response that is no Remote answer: a
|
|
2455
|
+
body that is not JSON, JSON that is not `{ result }` or `{ error }` (a
|
|
2456
|
+
gateway's `{}`), or a response with status 408, 429, 502, 503 or 504, which
|
|
2457
|
+
Remote's server never answers with. Each is a `RemoteJsonClient`, a
|
|
2458
|
+
`RemoteRpcClient` whose transport error is `RemoteUnreachable`. A mutation the
|
|
2459
|
+
server failed is a `RemoteMutationError`. The difference matters for a [durable write](#a-write-that-outlives-the-page-dataoutbox):
|
|
2460
|
+
an `Unreachable` one is sent again, a refused one is settled `Failed`.
|
|
2461
|
+
|
|
2462
|
+
**Testing a server over HTTP.** Call it as the application does: give
|
|
2463
|
+
`` Remote.http(`${origin}/remote`, { headers, fetch }) `` the server's address
|
|
2464
|
+
(miniflare's `(await mf.ready).origin` for a Worker) and call
|
|
2465
|
+
`FoldkitRemoteRead`, `FoldkitRemoteQuery` and `FoldkitRemoteMutate` on it, as
|
|
2466
|
+
`examples/cloudflare/src/client.ts` does over its local stack. To speak the
|
|
2467
|
+
wire yourself, each `POST` body is one of these, `version` being
|
|
2468
|
+
`REMOTE_PROTOCOL_VERSION`:
|
|
2469
|
+
|
|
2470
|
+
```json
|
|
2471
|
+
{ "operation": "read", "payload": { "version": 5, "requests": [{ "entity": "Post", "id": "p1", "fields": ["title"] }] } }
|
|
2472
|
+
{ "operation": "query", "payload": { "query": "PostsByAuthor", "input": { "authorId": "a1" }, "window": { "first": 20 } } }
|
|
2473
|
+
{ "operation": "mutate", "payload": { "requestId": "r1", "mutation": "RenamePost", "input": { "id": "p1", "title": "Hello" } } }
|
|
2474
|
+
```
|
|
2475
|
+
|
|
1857
2476
|
## Introspection
|
|
1858
2477
|
|
|
1859
2478
|
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
1860
2479
|
into private state:
|
|
1861
2480
|
|
|
1862
|
-
```ts
|
|
2481
|
+
```ts sketch
|
|
1863
2482
|
Data.inspect(model) // serializable cache/domain summary
|
|
1864
2483
|
Data.plan(model, projection) // the entity plan a read would execute
|
|
1865
2484
|
Data.meta(model, projection) // when what is shown was received, stale/loading
|
|
@@ -1886,6 +2505,52 @@ asked. That is deliberate. Runtime activity is not a second source of truth
|
|
|
1886
2505
|
here, and a view that rendered from it would stop being reproducible from the
|
|
1887
2506
|
Model.
|
|
1888
2507
|
|
|
2508
|
+
### Diagnostics
|
|
2509
|
+
|
|
2510
|
+
What Remote meets and keeps going past is recorded in the Model, typed, as
|
|
2511
|
+
`model.remote.diagnostics` (and `Data.inspect(model).diagnostics`), oldest
|
|
2512
|
+
first, the newest 256 kept:
|
|
2513
|
+
|
|
2514
|
+
| `_tag` | When |
|
|
2515
|
+
| --- | --- |
|
|
2516
|
+
| `PredictionMismatch` | Under `verifyPredictions`, an answer disagreed with what the client predicted: a field's value (`Value`), a list's order (`Placement`), or a field left out (`Gap`). |
|
|
2517
|
+
| `WriteUnheard` | A write failed, and the wiring does not share `MutationFailed`. |
|
|
2518
|
+
|
|
2519
|
+
Each carries its facts, `source: 'foldkit-remote'`, a `level` and a `message`
|
|
2520
|
+
to log. The reducer only records them, since it is pure. The wiring's route and
|
|
2521
|
+
`Remote.fold` report what each update recorded, with a Command that hands them
|
|
2522
|
+
to the `Diagnostics.Reporter` in the runtime's Effect context and answers
|
|
2523
|
+
`CompletedReportDiagnostics`, which changes nothing. With no reporter given,
|
|
2524
|
+
that is Effect's log in the browser's console: `console.error` for an `error`
|
|
2525
|
+
diagnostic, `console.log` for a `warning`, as all of Remote's are. An
|
|
2526
|
+
application that reduces Remote's Messages by hand (`Data.reduce`) has them in the Model, and
|
|
2527
|
+
nothing reports them.
|
|
2528
|
+
|
|
2529
|
+
`Diagnostics` is `foldkit-surface`'s, re-exported here; server packages report
|
|
2530
|
+
into the same channel. Another reporter goes in the runtime's resources:
|
|
2531
|
+
|
|
2532
|
+
```ts
|
|
2533
|
+
const resources = Layer.mergeAll(
|
|
2534
|
+
Remote.clientLayer(Remote.http('/remote')),
|
|
2535
|
+
// Logged as by default, and sent on to the server.
|
|
2536
|
+
Layer.succeed(Diagnostics.Reporter, diagnostic =>
|
|
2537
|
+
Effect.andThen(
|
|
2538
|
+
Diagnostics.log(diagnostic),
|
|
2539
|
+
Effect.sync(() => navigator.sendBeacon('/diagnostics', diagnostic.message)),
|
|
2540
|
+
),
|
|
2541
|
+
),
|
|
2542
|
+
)
|
|
2543
|
+
```
|
|
2544
|
+
|
|
2545
|
+
In a test, assert on the Model, `[]` included:
|
|
2546
|
+
|
|
2547
|
+
```ts
|
|
2548
|
+
expect(Data.inspect(model).diagnostics).toEqual([])
|
|
2549
|
+
```
|
|
2550
|
+
|
|
2551
|
+
Nothing a diagnostic records changes what Remote does: a read, a write and a
|
|
2552
|
+
placement behave the same whether one is recorded or not.
|
|
2553
|
+
|
|
1889
2554
|
### Explaining one query read
|
|
1890
2555
|
|
|
1891
2556
|
A query read is assembled from more pieces than it looks like: a definition, an
|
|
@@ -1935,6 +2600,115 @@ There is still no **executor**, because what answers a query is a
|
|
|
1935
2600
|
purity that lets this be replayed from a recorded Model is what keeps it out of
|
|
1936
2601
|
reach.
|
|
1937
2602
|
|
|
2603
|
+
## Testing what a reader sees: `Trace`
|
|
2604
|
+
|
|
2605
|
+
"A list on screen never blinks to `Loading`" depends on the reducer, the read
|
|
2606
|
+
entries and the order answers land in. `foldkit-remote/testing` checks it in
|
|
2607
|
+
Vitest, with no browser. `Trace.run` runs your `update` and the domain's own
|
|
2608
|
+
Subscriptions from a Model. It **holds** every request they make. Each step is
|
|
2609
|
+
one of your Messages, or a `Respond` that lets one held request go on, answered
|
|
2610
|
+
by the `RemoteClient` you pass. Every Model in between is recorded, so a
|
|
2611
|
+
regression that lasts one Message cannot fall between samples.
|
|
2612
|
+
|
|
2613
|
+
```ts
|
|
2614
|
+
import { Respond, Trace } from 'foldkit-remote/testing'
|
|
2615
|
+
|
|
2616
|
+
const trace = await Trace.run(Data, {
|
|
2617
|
+
start: loaded, // a Model showing the list
|
|
2618
|
+
update,
|
|
2619
|
+
active,
|
|
2620
|
+
client: server, // a fake, or your server's in-process client
|
|
2621
|
+
steps: [
|
|
2622
|
+
Message.Created({ id: 'p2', name: 'Two' }),
|
|
2623
|
+
Respond.mutation(Create), // the answer lands first
|
|
2624
|
+
Respond.read(Project, 'p2'), // then the new row's fields
|
|
2625
|
+
],
|
|
2626
|
+
})
|
|
2627
|
+
|
|
2628
|
+
expect(Trace.regressions(trace, { list })).toEqual([])
|
|
2629
|
+
expect(Trace.tags(trace, list)).toEqual(['Ready', 'Refreshing', 'Ready'])
|
|
2630
|
+
```
|
|
2631
|
+
|
|
2632
|
+
- **The trace decides when; the client decides what.** `Respond.query(ref)`,
|
|
2633
|
+
`Respond.read(Entity, id)` and `Respond.mutation(M)` each answer the oldest
|
|
2634
|
+
held request of their kind, and each takes `{ fail }` to answer with an
|
|
2635
|
+
error instead. `Respond.all` answers everything held, and what that leads
|
|
2636
|
+
to, until nothing is. Reorder the steps to try another interleaving.
|
|
2637
|
+
- **`Trace.regressions`** names each time a read that showed a value read
|
|
2638
|
+
`Loading`, `Initial`, or `Failed` without it. It gives the step, the Message
|
|
2639
|
+
and `Data.why`; it is `Data.timeline` over the trace, kept to its
|
|
2640
|
+
regressions. `NotFound` is not a regression, since the server said the row
|
|
2641
|
+
is gone. **`Trace.tags`** is each change of a read's tag.
|
|
2642
|
+
- **It fails loudly.** A `Respond` that matches nothing held throws and lists
|
|
2643
|
+
what is held. A request abandoned by a restart is no longer held. A
|
|
2644
|
+
Subscription or Command that dies throws. An `update` that does not take
|
|
2645
|
+
Remote's Messages is a type error.
|
|
2646
|
+
- **What it leaves out.** Its clock stands still, at `now` (0 unless given):
|
|
2647
|
+
every Command it runs and every answer it asks for reads that time
|
|
2648
|
+
(`Remote.now`, an answer's date), and a retention `grace`, an age-out and a
|
|
2649
|
+
live restart never fire. A live stream sends nothing, so
|
|
2650
|
+
script its events as `LiveReceived` Messages. Only Remote's Commands run,
|
|
2651
|
+
and `entry.skipped` names the others. It drives a domain whose Messages are
|
|
2652
|
+
in your union, not a `Remote.fold`.
|
|
2653
|
+
- **Durable writes need the outbox.** Pass `outbox` (your `Data.outbox`
|
|
2654
|
+
wiring) and the trace starts from its `restore`, over an empty store of its
|
|
2655
|
+
own, and runs its Subscriptions as one tab: a durable write is stored and
|
|
2656
|
+
sent by it, and `Respond.mutation` answers that send.
|
|
2657
|
+
|
|
2658
|
+
## Testing that a prediction is the truth: `Law`
|
|
2659
|
+
|
|
2660
|
+
A declared write is interpreted twice: by `Data.mutate`, in memory, over what
|
|
2661
|
+
the client holds (what it shows while the write is pending), and by the
|
|
2662
|
+
server, in SQL (what it stores). The law between them: **where the client
|
|
2663
|
+
predicts a part, the server's answer equals it**, while the write is pending
|
|
2664
|
+
and again once its answer has landed and the prediction is released.
|
|
2665
|
+
`Law.check` runs one operation through both, as an application does, and
|
|
2666
|
+
compares:
|
|
2667
|
+
|
|
2668
|
+
```ts
|
|
2669
|
+
import { Law } from 'foldkit-remote/testing'
|
|
2670
|
+
|
|
2671
|
+
const report = await Effect.runPromise(
|
|
2672
|
+
Law.check(Data, {
|
|
2673
|
+
model: { remote: Remote.initial, me: 'u1' }, // whom Remote.make's viewer reads
|
|
2674
|
+
holds: [{ entity: Card, ids: ['c1', 'c2'] }], // read whole, as the principal
|
|
2675
|
+
lists: [ByRank.ref({})], // loaded whole; their placements compared
|
|
2676
|
+
mutation: AddCard,
|
|
2677
|
+
input: { id: 'c9', title: 'delta', rank: 3, votes: 0 },
|
|
2678
|
+
now: 1_000_000,
|
|
2679
|
+
}).pipe(
|
|
2680
|
+
Effect.provide(Remote.clientLayer(RemoteServer.handlers(server, 'u1')).pipe(Layer.provide(db))),
|
|
2681
|
+
),
|
|
2682
|
+
)
|
|
2683
|
+
expect(report.disagreed).toEqual([])
|
|
2684
|
+
expect(report.agreed).toEqual(expect.arrayContaining(['Card:c9.createdAt', ByRank.ref({}).identity]))
|
|
2685
|
+
```
|
|
2686
|
+
|
|
2687
|
+
- **Nothing is stubbed.** The rows in `holds` are read through the server as
|
|
2688
|
+
its principal sees them and reduced into the Model, so the client holds what
|
|
2689
|
+
the server would send it; the lists are queried and merged; `Data.mutate`
|
|
2690
|
+
predicts; its own Command sends the write. Each row the request's layer
|
|
2691
|
+
changed is read back, every list queried again, and compared twice: with
|
|
2692
|
+
the Model as it showed the write (`when: 'pending'`) and with the Model
|
|
2693
|
+
once the answer is reduced into it (`when: 'answered'`). A part is agreed
|
|
2694
|
+
only when both agree. A field the answer left out, which the client would
|
|
2695
|
+
read again, is not compared once answered; a list the answer left stale is
|
|
2696
|
+
fetched again, and is not either.
|
|
2697
|
+
- **A part is `Entity:id.field`, `Entity:id`** (whether the row is there) **or
|
|
2698
|
+
a list's identity.** What the client left to the server (a field it could not
|
|
2699
|
+
work out, a list with a row it could not judge) is in `unknown`, and is not
|
|
2700
|
+
compared.
|
|
2701
|
+
- **Pin the server's clock to `now`** (a `Clock` service, or `TestClock`), or a
|
|
2702
|
+
`now` outside the server's `clockSkew` is replaced by its own, and every time
|
|
2703
|
+
the write sets disagrees.
|
|
2704
|
+
- **A refusal is a part too.** A write the client refused itself (a broken
|
|
2705
|
+
invariant) is still sent to the server, which must refuse it alike; one the
|
|
2706
|
+
server refuses after the client showed it is a disagreement.
|
|
2707
|
+
- **It fails when it compared nothing**, as for a mutation the client foresees
|
|
2708
|
+
nothing of: two sides that both said nothing agree, and that is no evidence.
|
|
2709
|
+
Name the parts you expect compared, as above, so a prediction that silently
|
|
2710
|
+
stops is caught too.
|
|
2711
|
+
|
|
1938
2712
|
## Advanced: the kernel
|
|
1939
2713
|
|
|
1940
2714
|
Everything above compiles to the lower-level exports below; there is not a
|
|
@@ -1949,7 +2723,9 @@ place in an application Model. It exposes the Remote `Model`, `initial`,
|
|
|
1949
2723
|
`RemoteServer.validate`.
|
|
1950
2724
|
|
|
1951
2725
|
`Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
|
|
1952
|
-
`Remote.make` combines definition and binding in one step.
|
|
2726
|
+
`Remote.make` combines definition and binding in one step. Either one's
|
|
2727
|
+
`store` is the ref's `Focus` (`ModelRef.focus(modelRef)`): its `get`, `set`
|
|
2728
|
+
and `dependency`, which `foldkit-sync` and the law checks read.
|
|
1953
2729
|
|
|
1954
2730
|
`Entity.make(name, schema)` is Remote's own entity descriptor, and what a
|
|
1955
2731
|
`foldkit-entity` Entity is compiled to (`Entity.from`). Remote accepts either
|
|
@@ -2087,11 +2863,15 @@ and orders by, so the rows it holds can be compared with one that changed.
|
|
|
2087
2863
|
|
|
2088
2864
|
```ts
|
|
2089
2865
|
// Schematic: `ProjectsByRank` is a Query.define ordered by a number, and
|
|
2090
|
-
// `connection` one of `remote.connections
|
|
2091
|
-
|
|
2866
|
+
// `connection` one of `remote.connections`; `Operators` is foldkit-entity's.
|
|
2867
|
+
// test/placement.test.ts runs it.
|
|
2868
|
+
placeIn(remote.entities, connection, ProjectsByRank.body!, {}, 'Project:p7', Operators.kernel)
|
|
2092
2869
|
// Before { segment, edge } | End { segment } | Outside | Unknown { reason }
|
|
2093
2870
|
```
|
|
2094
2871
|
|
|
2872
|
+
The last argument is the operator table the body's calls run by, the one
|
|
2873
|
+
`Remote.make`'s `operators` names (`Operators.kernel` by default).
|
|
2874
|
+
|
|
2095
2875
|
- `Before` and `End` are positions among loaded edges: before an edge, or at
|
|
2096
2876
|
the end of a segment that ends the list (`Terminal`).
|
|
2097
2877
|
- `Outside` is a row that sorts past a `Cursor` boundary or into a gap between
|
|
@@ -2116,8 +2896,9 @@ a request passes the one it first sent.
|
|
|
2116
2896
|
`MutationSucceeded` reconciles at most once per `requestId`; an unknown or
|
|
2117
2897
|
already-applied settlement is a no-op. It carries `now`, the clock reading of
|
|
2118
2898
|
the answer, which dates the entities it writes as a read's `now` does, so a
|
|
2119
|
-
freshness policy ages them from then. `Data.mutate
|
|
2120
|
-
stamp it from
|
|
2899
|
+
freshness policy ages them from then. `Data.mutate`, `Remote.mutateInto` and
|
|
2900
|
+
the outbox stamp it from Effect's `Clock` when the answer lands, never from the
|
|
2901
|
+
`now` option, which is the write's time sent to the server.
|
|
2121
2902
|
|
|
2122
2903
|
### The transport seam
|
|
2123
2904
|
|
|
@@ -2147,6 +2928,7 @@ Remote owns:
|
|
|
2147
2928
|
- the `Remote.Model` Submodel and its reducer;
|
|
2148
2929
|
- query connections and pagination boundaries;
|
|
2149
2930
|
- optimistic mutation overlays and idempotent settlement bookkeeping;
|
|
2931
|
+
- the outbox of durable writes: stored, sent in order by one tab, sent again;
|
|
2150
2932
|
- live cursors, duplicate suppression, and gap detection;
|
|
2151
2933
|
- retention/garbage collection of disposable cache data;
|
|
2152
2934
|
- deterministic, scoped cache snapshots;
|
|
@@ -2157,26 +2939,30 @@ Remote does **not** own:
|
|
|
2157
2939
|
|
|
2158
2940
|
- your source database or authoritative server records;
|
|
2159
2941
|
- local UI/application state outside `Remote.Model`;
|
|
2160
|
-
-
|
|
2942
|
+
- client-authored state that converges across devices, or replica convergence;
|
|
2161
2943
|
- HTTP/WebSocket deployment details;
|
|
2162
2944
|
- authentication; server-side authorization belongs at the Source boundary;
|
|
2163
2945
|
- a background scheduler or general-purpose database/query engine.
|
|
2164
2946
|
|
|
2165
2947
|
## Limits
|
|
2166
2948
|
|
|
2167
|
-
-
|
|
2168
|
-
`
|
|
2169
|
-
|
|
2170
|
-
|
|
2949
|
+
- `RemoteRpc`'s schemas are the wire contract, carried by Effect RPC, by JSON
|
|
2950
|
+
over `POST` (`Remote.http`), or over a `MessagePort`; the application
|
|
2951
|
+
supplies deployment layers. `ReadBatch` and `LiveRequirement` carry
|
|
2952
|
+
`REMOTE_PROTOCOL_VERSION`, and a mismatched version fails with
|
|
2953
|
+
`RemoteProtocolError` rather than silently drifting.
|
|
2171
2954
|
- One request may name at most `MAX_FIELDS_PER_REQUEST` (256) fields of one
|
|
2172
2955
|
entity and nest relations at most `MAX_RELATION_DEPTH` (8) levels; the wire
|
|
2173
|
-
rejects larger inputs.
|
|
2956
|
+
rejects larger inputs. A live stream carries at most `MAX_LIVE_QUERIES` (32)
|
|
2957
|
+
lists, and a `many` relation read whole is bounded by the server's Source
|
|
2958
|
+
(`maxCollection` in `foldkit-remote-drizzle`).
|
|
2174
2959
|
- The wire `LiveChange` union carries entity patches/deletes and connection
|
|
2175
2960
|
insert/remove/invalidate changes; `RemoteServer.live` serves them as a stream.
|
|
2176
2961
|
- Coalescing is scoped to one `RemoteClient` layer. Separate layers do not share
|
|
2177
2962
|
batches.
|
|
2178
|
-
- Retention keeps what active Surfaces (and explicit retain entries) reach
|
|
2179
|
-
|
|
2963
|
+
- Retention keeps what active Surfaces (and explicit retain entries) reach, and
|
|
2964
|
+
what the most recently left ones reached, up to `departedLimit`. Past it, an
|
|
2965
|
+
inactive Surface loses its cache on `RetentionChanged`.
|
|
2180
2966
|
- Two applications with the same Model type are not distinguishable by TypeScript
|
|
2181
2967
|
alone; `Data.subscriptions` also checks the Surface owner token at runtime.
|
|
2182
2968
|
- `staleWhileRevalidate({ maxAge })` ages entity data. Connections have no age;
|
|
@@ -2189,6 +2975,9 @@ Remote does **not** own:
|
|
|
2189
2975
|
|
|
2190
2976
|
- [Server-derived state](../../docs/remote.md) — the deeper architectural guide
|
|
2191
2977
|
and ownership model.
|
|
2978
|
+
- [Errors and failures](../../docs/errors.md) — how a read's, a write's and
|
|
2979
|
+
every other package's failures fit together: who owns each, what the reader
|
|
2980
|
+
sees, what the application does.
|
|
2192
2981
|
- [`foldkit-remote-server`](../remote-server) — server Sources, resolution, and
|
|
2193
2982
|
semantic-field authorization.
|
|
2194
2983
|
- [`foldkit-remote-drizzle`](../remote-drizzle) — optional compilation of Remote
|