foldkit-remote 0.5.0 → 0.7.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 +312 -83
- package/dist/index.d.mts +433 -31
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +846 -138
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -22,6 +22,26 @@ disposable view of it. If an edit is client-authored and must survive offline,
|
|
|
22
22
|
restart, or network failure until it converges, that belongs to
|
|
23
23
|
[`foldkit-sync`](../sync) and [`foldkit-durable`](../durable), not Remote.
|
|
24
24
|
|
|
25
|
+
## Find what you need
|
|
26
|
+
|
|
27
|
+
This README is long because it covers everything Remote does. Read the
|
|
28
|
+
[sixty-second example](#sixty-seconds-one-entity-one-surface) first, then jump
|
|
29
|
+
to what you are doing:
|
|
30
|
+
|
|
31
|
+
| Task | Start here |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| See it work before there is a server | [A backend held in memory](../remote-server/README.md#a-backend-held-in-memory) |
|
|
34
|
+
| Understand `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
|
|
35
|
+
| Find out why a read stays `Initial` | [`Data.why`](#remotedata-what-does-the-model-know-right-now) |
|
|
36
|
+
| Show a failed read, and let the user retry | [When a read fails](#when-a-read-fails) |
|
|
37
|
+
| Load an ordered, paged list | [Queries and pagination](#queries-and-pagination) |
|
|
38
|
+
| Filter a list already on screen | [Filtering a loaded list](#filtering-a-loaded-list-without-asking-the-server) |
|
|
39
|
+
| Save, and show the result before the server answers | [Mutations](#mutations-and-optimistic-state) |
|
|
40
|
+
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
41
|
+
| Keep data across a reload, or seed it from SSR | [Hydration](#persistence-and-hydration) |
|
|
42
|
+
| Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
|
|
43
|
+
| Answer the requests on the server | [How the server packages fit](#how-the-server-packages-fit) |
|
|
44
|
+
|
|
25
45
|
## Which state belongs here?
|
|
26
46
|
|
|
27
47
|
A useful rule across Foldkit Plus is **one owner per datum**:
|
|
@@ -55,67 +75,17 @@ ownership.
|
|
|
55
75
|
|
|
56
76
|
## The mental model
|
|
57
77
|
|
|
58
|
-
The client side is a Foldkit Submodel:
|
|
59
|
-
|
|
60
78
|
```text
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
v
|
|
65
|
-
+-------------+ +---------+ +------+
|
|
66
|
-
| Foldkit |---->| Surface |----->| View |
|
|
67
|
-
| Model | +---------+ +------+
|
|
68
|
-
| |
|
|
69
|
-
| Remote.Model|
|
|
70
|
-
| normalized |
|
|
71
|
-
| cache |
|
|
72
|
-
+------+------+
|
|
73
|
-
^
|
|
74
|
-
| Remote Message
|
|
75
|
-
|
|
|
76
|
-
+------+-------+
|
|
77
|
-
| Data.reduce |
|
|
78
|
-
+------+-------+
|
|
79
|
-
^
|
|
80
|
-
|
|
|
81
|
-
| read/query/mutate/live
|
|
82
|
-
|
|
|
83
|
-
+------+--------+
|
|
84
|
-
| RemoteClient |
|
|
85
|
-
+------+--------+
|
|
86
|
-
|
|
|
87
|
-
v
|
|
88
|
-
server
|
|
79
|
+
Surface Projection → requirements → active Subscription → RemoteClient I/O
|
|
80
|
+
↑ |
|
|
81
|
+
└──────── read Model ← Data.reduce ← Remote Message ──┘
|
|
89
82
|
```
|
|
90
83
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
|
98
|
-
v
|
|
99
|
-
Remote compares that requirement
|
|
100
|
-
with the normalized cache in Model
|
|
101
|
-
|
|
102
|
-
|
|
|
103
|
-
v
|
|
104
|
-
missing:
|
|
105
|
-
Project:p1.name
|
|
106
|
-
User:u7.name
|
|
107
|
-
|
|
108
|
-
|
|
|
109
|
-
v
|
|
110
|
-
active subscription performs one read
|
|
111
|
-
|
|
112
|
-
|
|
|
113
|
-
v
|
|
114
|
-
result -> Remote Message -> Data.reduce -> Model
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
That separation is why reading stays pure. Rendering a Surface never starts a
|
|
118
|
-
request.
|
|
84
|
+
A Projection reads the current cache and carries requirements such as
|
|
85
|
+
`Project:p1.name` and `User:u7.name`. An active Subscription compares those
|
|
86
|
+
requirements with the cache, then requests missing or stale fields. The
|
|
87
|
+
result returns as a Message; only reducing it changes what the next read sees.
|
|
88
|
+
Rendering the same Projection twice starts no work by itself.
|
|
119
89
|
|
|
120
90
|
## Four pieces to remember
|
|
121
91
|
|
|
@@ -133,7 +103,7 @@ Everything else builds on those four pieces.
|
|
|
133
103
|
## Install
|
|
134
104
|
|
|
135
105
|
```bash
|
|
136
|
-
pnpm add foldkit-remote
|
|
106
|
+
pnpm add foldkit-remote foldkit-entity
|
|
137
107
|
```
|
|
138
108
|
|
|
139
109
|
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
|
|
@@ -164,12 +134,10 @@ const ProjectSummary = Entity.select(Project, {
|
|
|
164
134
|
```
|
|
165
135
|
|
|
166
136
|
The entity comes from [`foldkit-entity`](../entity), which declares a domain
|
|
167
|
-
without Remote in it
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`Entity.make` when a domain is small enough that Remote is all it will ever
|
|
172
|
-
need; reach for `Entity.define` otherwise, which is most of the time.
|
|
137
|
+
without Remote in it, so the same declaration can serve the server's database
|
|
138
|
+
binding and your forms too. It is also what gives an entity
|
|
139
|
+
[relations](#entities-declared-with-foldkit-entity), derived members, and a
|
|
140
|
+
[query body](#queries-and-pagination) to point at.
|
|
173
141
|
|
|
174
142
|
Embed Remote's Submodel in the application and bind the domain to that field:
|
|
175
143
|
|
|
@@ -218,7 +186,7 @@ const App = Surface.application({
|
|
|
218
186
|
})
|
|
219
187
|
|
|
220
188
|
const Data = Remote.make({
|
|
221
|
-
model: App.
|
|
189
|
+
model: App.model.remote,
|
|
222
190
|
entities: [Project],
|
|
223
191
|
})
|
|
224
192
|
```
|
|
@@ -291,7 +259,14 @@ Remote.Model
|
|
|
291
259
|
ProjectPage now reads Ready(...)
|
|
292
260
|
```
|
|
293
261
|
|
|
294
|
-
|
|
262
|
+
The first example starts on the `home` route, so no project read runs yet.
|
|
263
|
+
Your route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
|
|
264
|
+
runtime must install `subscriptions`. Expect `Initial` until work starts,
|
|
265
|
+
`Loading` during the first request, and `Ready` once its result is reduced.
|
|
266
|
+
|
|
267
|
+
Finally, provide the client implementation to the runtime. Here `rpcClient`
|
|
268
|
+
is your configured Effect RPC client, not a value created by the declarations
|
|
269
|
+
above; the [server guide](../remote-server/README.md) supplies the other side:
|
|
295
270
|
|
|
296
271
|
```ts
|
|
297
272
|
const clientLayer = Remote.clientLayer(rpcClient)
|
|
@@ -301,6 +276,21 @@ const clientLayer = Remote.clientLayer(rpcClient)
|
|
|
301
276
|
`RemoteClient` service used by subscriptions and Commands. The transport itself
|
|
302
277
|
is not owned by Remote.
|
|
303
278
|
|
|
279
|
+
To see the page work before you have a server, give it rows instead. A backend
|
|
280
|
+
held in memory answers through the same handlers a real server uses:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import { RemoteServer } from 'foldkit-remote-server'
|
|
284
|
+
|
|
285
|
+
const clientLayer = RemoteServer.memory({
|
|
286
|
+
domain: Data,
|
|
287
|
+
rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
|
|
288
|
+
}).layer
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It is for a first run, a test or a demo. See
|
|
292
|
+
[`RemoteServer.memory`](../remote-server/README.md#a-backend-held-in-memory).
|
|
293
|
+
|
|
304
294
|
## One list per application with `Data.wiring`
|
|
305
295
|
|
|
306
296
|
The integration steps above — spread `Remote.messages`, reduce by tag, derive
|
|
@@ -354,15 +344,19 @@ missing ------------------------------------------------> Loading
|
|
|
354
344
|
|
|
355
345
|
server says entity is absent ---------------------------> NotFound
|
|
356
346
|
stored value fails Selection decoding -----------------> Failed
|
|
347
|
+
a read or a list's query fails ------------------------> Failed (with the old value, if any)
|
|
357
348
|
```
|
|
358
349
|
|
|
359
350
|
The states are:
|
|
360
351
|
|
|
361
352
|
- **`Initial`** — required data is absent and **nothing is currently fetching it**.
|
|
362
|
-
- **`Loading`** — required data is absent and a read is in
|
|
353
|
+
- **`Loading`** — required data is absent and a read, or a list's query, is in
|
|
354
|
+
flight.
|
|
363
355
|
- **`Ready`** — every selected field is present and decodes.
|
|
364
356
|
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
365
|
-
- **`Failed`** — stored server data does not decode against the Selection
|
|
357
|
+
- **`Failed`** — stored server data does not decode against the Selection, or
|
|
358
|
+
the read or query behind it failed. A value that was already shown is kept as
|
|
359
|
+
`previous`.
|
|
366
360
|
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
367
361
|
deleted it, or the server was asked for it by id and answered without it. That
|
|
368
362
|
id is then known absent, whether it never existed, is gone, or is not this
|
|
@@ -376,6 +370,21 @@ no active Surface may remain `Initial` forever. Rendering a spinner for
|
|
|
376
370
|
`Initial` can therefore hide an activation/wiring mistake; `Loading` is the
|
|
377
371
|
state that actually means "wait for this request."
|
|
378
372
|
|
|
373
|
+
When a read sits at `Initial` and you expected data, ask the Model why:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
|
|
377
|
+
// { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
|
|
378
|
+
// message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`NotObserved` means no active Surface reads it: the Surface is not in the
|
|
382
|
+
record, or its params are `undefined` for this Model. `NotFetching` means one
|
|
383
|
+
does, and nothing started a request, which almost always means Remote's
|
|
384
|
+
Subscriptions are not installed in the runtime. Without `surfaces` it can only
|
|
385
|
+
say `Unknown`. For every other state it says what the state means in words,
|
|
386
|
+
and a failed request's message says that nothing retries it on its own.
|
|
387
|
+
|
|
379
388
|
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
380
389
|
compile time.
|
|
381
390
|
|
|
@@ -571,7 +580,7 @@ A mutation's `optimistic` operations show over the store while it is in flight.
|
|
|
571
580
|
`Data.lift`: a preview, in every Selection and view, of something not yet sent.
|
|
572
581
|
|
|
573
582
|
```ts
|
|
574
|
-
const previewed = Data.overlay(model, 'post-preview', [
|
|
583
|
+
const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
|
|
575
584
|
const back = Data.lift(previewed, 'post-preview')
|
|
576
585
|
```
|
|
577
586
|
|
|
@@ -623,7 +632,7 @@ const ProjectsByOwner = Query.make('ProjectsByOwner', {
|
|
|
623
632
|
})
|
|
624
633
|
|
|
625
634
|
const Data = Remote.make({
|
|
626
|
-
model: App.
|
|
635
|
+
model: App.model.remote,
|
|
627
636
|
entities: [User, Project],
|
|
628
637
|
queries: [ProjectsByOwner],
|
|
629
638
|
})
|
|
@@ -706,6 +715,159 @@ their required fields. `Data.next` / `Data.previous` preserve the page size and
|
|
|
706
715
|
use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
|
|
707
716
|
not from guessing based on row counts.
|
|
708
717
|
|
|
718
|
+
### Filtering a loaded list without asking the server
|
|
719
|
+
|
|
720
|
+
A search box over a page you already have should not be a round trip.
|
|
721
|
+
`Data.filtered` narrows a loaded list by a query body:
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
const found = Data.filtered(model, projects, ActiveProjects, {})
|
|
725
|
+
|
|
726
|
+
found.items // decoded exactly as the list decodes them
|
|
727
|
+
found.complete // whether the answer was about the whole list
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
**It filters a list; it does not run a query**, and the distinction is the
|
|
731
|
+
design. "Which rows match" would need to know this list holds every row the body
|
|
732
|
+
could match — predicate containment, which Remote deliberately does not do.
|
|
733
|
+
"Which rows *of this list* match" is decidable from what is already here.
|
|
734
|
+
|
|
735
|
+
`complete` is false unless three things hold: every edge could be judged (no row
|
|
736
|
+
missing a field the *body* reads), every match could be shown (no row missing a
|
|
737
|
+
field the *Selection* reads), and the list is terminal at both ends. So
|
|
738
|
+
empty-and-complete and empty-and-partial stay different answers — one means
|
|
739
|
+
"none", the other means "none that I can see yet".
|
|
740
|
+
|
|
741
|
+
Nothing is created: no connection, nothing new to retain, nothing new to fetch.
|
|
742
|
+
The server stays authoritative for which rows exist.
|
|
743
|
+
|
|
744
|
+
### Judging the rows you already hold
|
|
745
|
+
|
|
746
|
+
A connection's rows are edges the server delivered, and a query body describes
|
|
747
|
+
*which rows* — so until something evaluates the body on the client, nothing here
|
|
748
|
+
can say whether a row belongs to a query. `Remote.matching` says:
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
const judged = Remote.matching(Data.storeOf(model), ProjectsByOwner, { ownerId: 'u1' })
|
|
752
|
+
|
|
753
|
+
judged.matched // keys satisfying the body, in the order it asks for
|
|
754
|
+
judged.skipped // keys held, but missing a field the body reads
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
It is pure, and it runs the **same interpreter the server checks itself
|
|
758
|
+
against** — the reference implementation in
|
|
759
|
+
[`foldkit-entity`](../entity) — so a body means one thing in both places. The
|
|
760
|
+
conformance suite is run over a store as well as over rows and a table.
|
|
761
|
+
|
|
762
|
+
Three things about it are deliberate.
|
|
763
|
+
|
|
764
|
+
**It answers "which of the rows I hold match", never "which rows match."** Those
|
|
765
|
+
are different questions, and the difference is not recoverable from a list of
|
|
766
|
+
keys — so it is in the shape. A caller that knows it holds the whole population
|
|
767
|
+
(a connection terminal at both ends) is the one that can turn this into a
|
|
768
|
+
complete answer.
|
|
769
|
+
|
|
770
|
+
**`skipped` is the honest half.** A row missing a field the body reads cannot be
|
|
771
|
+
judged: it is neither a match nor a non-match. Dropping it silently would turn
|
|
772
|
+
"I could not tell" into "no", so it is named instead, and the caller decides
|
|
773
|
+
whether to fetch the field, ask the server, or say the answer is partial.
|
|
774
|
+
|
|
775
|
+
**It takes the input decoded and encodes it itself.** A store holds wire values,
|
|
776
|
+
so a comparison has to happen in that space — and handing an interpreter a
|
|
777
|
+
decoded value is a mistake *no runtime error catches*: the answer is simply
|
|
778
|
+
empty, which reads exactly like a correct one. Doing the encoding here means a
|
|
779
|
+
caller cannot get it wrong.
|
|
780
|
+
|
|
781
|
+
It is **not authorization**. On the server a compiled `where` is conjoined with
|
|
782
|
+
the binding's `visible` rule; there is no `visible` here. This is safe only
|
|
783
|
+
because the client holds only rows the server already released to it, and a
|
|
784
|
+
local filter is not an access decision.
|
|
785
|
+
|
|
786
|
+
### An input that changes as fast as someone types
|
|
787
|
+
|
|
788
|
+
A connection's identity is its query plus its **input**, which is exactly right
|
|
789
|
+
for caching and exactly wrong for a search box. Bound naively, every keystroke
|
|
790
|
+
is a different connection, a different request, and a different thing to retain:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
// Don't: `postSearch` changes per keystroke, so the query's input does too.
|
|
794
|
+
h.OnInput(text => Message.Searched({ text }))
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Remote has no debounce, and should not: its job is to be a faithful function of
|
|
798
|
+
the Model, so if the Model says the search is `Engi` then `Engi` is what the
|
|
799
|
+
query asks. **The debounce belongs between the input Message and the Model field
|
|
800
|
+
the query reads** — which is ordinary application state, and
|
|
801
|
+
[`foldkit-primitives`](../primitives) already has the piece:
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
import { debounce } from 'foldkit-primitives/time'
|
|
805
|
+
|
|
806
|
+
const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
|
|
807
|
+
const SearchBox = Bundle.declare(SearchInput, 'search')
|
|
808
|
+
|
|
809
|
+
// `latest` is what the box shows, so typing stays immediate.
|
|
810
|
+
// The settled OutMessage is what moves the field the query reads.
|
|
811
|
+
Page.at(SearchBox, {
|
|
812
|
+
args: { delayMs: 250 },
|
|
813
|
+
onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
|
|
814
|
+
})
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Two fields, deliberately: the one the box draws changes on every keystroke, and
|
|
818
|
+
the one the query reads changes a quarter second after the last one.
|
|
819
|
+
[`examples/entity`](../../examples/entity) does exactly this.
|
|
820
|
+
|
|
821
|
+
The same applies to any high-frequency query input — a slider, a map viewport, a
|
|
822
|
+
date scrubber. What makes a search box the usual case is that the query is often
|
|
823
|
+
written *for* it: `Expr.contains` over an empty string matches everything, so an
|
|
824
|
+
empty box and a filled one are one query rather than two.
|
|
825
|
+
|
|
826
|
+
### A page that overruns its window is refused
|
|
827
|
+
|
|
828
|
+
The client asks for `first: 25`, and a page carrying more than that is a
|
|
829
|
+
protocol disagreement rather than a windfall: the client cannot tell which of
|
|
830
|
+
the edges the window meant, and the connection's boundaries stop describing what
|
|
831
|
+
it holds. Such a page becomes a `QueryFailed` with a named protocol error, and
|
|
832
|
+
none of its edges reach the store — a connection already loaded is left exactly
|
|
833
|
+
as it was, so a refresh that overruns cannot replace good rows with a rejected
|
|
834
|
+
page.
|
|
835
|
+
|
|
836
|
+
A window with neither `first` nor `last` bounds nothing: `after`/`before` says
|
|
837
|
+
where to start, not how much to take.
|
|
838
|
+
|
|
839
|
+
### When a read fails
|
|
840
|
+
|
|
841
|
+
A failure is kept on the Model until something settles it, and the read says
|
|
842
|
+
so. Queries fail per connection, and entity reads per field. A read with
|
|
843
|
+
nothing to show reads `Failed` with the error. One that had a value (a failed
|
|
844
|
+
refresh, or a failed "load more") reads `Failed` with that value as
|
|
845
|
+
`previous`, which `RemoteData.render` draws as data with a `Stale` freshness,
|
|
846
|
+
so it stays on screen. A failure reaches every read that needs it: a list
|
|
847
|
+
fails when one of its rows' fields failed, and a project read fails when its
|
|
848
|
+
owner's did.
|
|
849
|
+
|
|
850
|
+
**It is not retried on its own.** A persistent error would otherwise be asked
|
|
851
|
+
again every time some unrelated read restarted the entry. What failed is left
|
|
852
|
+
out of the plan, and everything else is still fetched: the rows a failed list
|
|
853
|
+
holds, the other fields of an entity. What asks again:
|
|
854
|
+
|
|
855
|
+
- `Data.refresh(model, projection)` — the retry button. It works on something
|
|
856
|
+
that never loaded, too.
|
|
857
|
+
- The value arriving anyway: a page for a list, or a read, live patch or
|
|
858
|
+
mutation result that writes the field.
|
|
859
|
+
- The server invalidating a list over a live stream, or deleting the entity.
|
|
860
|
+
- Retention dropping it: something nothing reads any more forgets its failure,
|
|
861
|
+
so coming back to it later asks the server again.
|
|
862
|
+
|
|
863
|
+
`Remote.inspect(model.remote).failures` lists what failed, by connection
|
|
864
|
+
identity and by `entity\0id\0field` mark.
|
|
865
|
+
|
|
866
|
+
A broken live stream is not a failed read. Its `ReadFailed` carries the
|
|
867
|
+
`stream`, and records a gap in `RemoteModel.gaps` rather than failing any
|
|
868
|
+
field: nothing was being read, and the values on screen are still what the
|
|
869
|
+
server last said.
|
|
870
|
+
|
|
709
871
|
## Mutations and optimistic state
|
|
710
872
|
|
|
711
873
|
Register mutations on the domain:
|
|
@@ -730,7 +892,7 @@ case 'ClickedRename': {
|
|
|
730
892
|
RenameProject,
|
|
731
893
|
{ id, name },
|
|
732
894
|
{
|
|
733
|
-
optimistic: [
|
|
895
|
+
optimistic: [Remote.patch(Project, id, { name })],
|
|
734
896
|
},
|
|
735
897
|
)
|
|
736
898
|
|
|
@@ -744,6 +906,12 @@ case 'ClickedRename': {
|
|
|
744
906
|
2. it returns the Command that calls `RemoteClient` and eventually emits
|
|
745
907
|
`MutationSucceeded` or `MutationFailed`.
|
|
746
908
|
|
|
909
|
+
`Remote.patch(entity, id, values)` builds one entity's patch, and
|
|
910
|
+
`Remote.ref(entity, id)` names one row. Both take the entity you declared,
|
|
911
|
+
whether with `foldkit-entity` or Remote's `Entity.make`, and check `values`
|
|
912
|
+
against its fields in their wire shape: a relation is written as its ref key,
|
|
913
|
+
such as `'User:u1'`.
|
|
914
|
+
|
|
747
915
|
Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
748
916
|
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
749
917
|
Settling is idempotent per `requestId`.
|
|
@@ -771,10 +939,10 @@ import { ConnectionChange } from 'foldkit-remote'
|
|
|
771
939
|
|
|
772
940
|
const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
773
941
|
optimistic: ({ tempId }) => [
|
|
774
|
-
|
|
942
|
+
Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
|
|
775
943
|
ConnectionChange.prepend(
|
|
776
944
|
CommentsForPost.ref({ postId }),
|
|
777
|
-
|
|
945
|
+
Remote.ref(Comment, tempId),
|
|
778
946
|
),
|
|
779
947
|
],
|
|
780
948
|
})
|
|
@@ -837,7 +1005,8 @@ Sync operation
|
|
|
837
1005
|
```
|
|
838
1006
|
|
|
839
1007
|
If losing an unsent edit would be data loss, it belongs to Sync rather than
|
|
840
|
-
Remote.
|
|
1008
|
+
Remote. For the same reason, an optimistic overlay is only a temporary view of
|
|
1009
|
+
server-owned data: do not persist one and treat it as a queue of edits.
|
|
841
1010
|
|
|
842
1011
|
## Live data
|
|
843
1012
|
|
|
@@ -882,27 +1051,34 @@ and refetch; Sync recovery must preserve unsent client intent.**
|
|
|
882
1051
|
|
|
883
1052
|
## Persistence and hydration
|
|
884
1053
|
|
|
885
|
-
|
|
886
|
-
|
|
1054
|
+
A snapshot is `{ entities, connections }`. The entity cache is disposable and
|
|
1055
|
+
always goes; a connection goes only if named, and keeps its edges alone — never
|
|
1056
|
+
a cursor, which may name server state that is gone. Session machinery (live
|
|
1057
|
+
cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
|
|
1058
|
+
never in one.
|
|
887
1059
|
|
|
888
1060
|
```ts
|
|
889
|
-
import {
|
|
1061
|
+
import { RemotePersistence } from 'foldkit-remote'
|
|
890
1062
|
|
|
891
1063
|
// SSR: prefetch on the server, embed, then hydrate on the client.
|
|
892
|
-
|
|
893
|
-
|
|
1064
|
+
// `snapshotOf` names the connections that survive; none, here.
|
|
1065
|
+
const text = RemotePersistence.dehydrate(
|
|
1066
|
+
RemotePersistence.snapshotOf(loaded.remote),
|
|
894
1067
|
{ scope: userId },
|
|
895
1068
|
)
|
|
896
1069
|
|
|
1070
|
+
const restored =
|
|
1071
|
+
RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
|
|
1072
|
+
|
|
897
1073
|
Data.reduce(model, {
|
|
898
1074
|
_tag: 'Hydrated',
|
|
899
|
-
entities:
|
|
900
|
-
|
|
1075
|
+
entities: restored.entities,
|
|
1076
|
+
connections: restored.connections,
|
|
901
1077
|
merge: 'preserve-existing',
|
|
902
1078
|
})
|
|
903
1079
|
|
|
904
1080
|
// Or persist through Effect's KeyValueStore.
|
|
905
|
-
RemotePersistence.save(
|
|
1081
|
+
RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
|
|
906
1082
|
key: 'remote-cache',
|
|
907
1083
|
scope: userId,
|
|
908
1084
|
maxBytes: 512_000,
|
|
@@ -974,6 +1150,7 @@ into private state:
|
|
|
974
1150
|
```ts
|
|
975
1151
|
Data.inspect(model) // serializable cache/domain summary
|
|
976
1152
|
Data.plan(model, projection) // the entity plan a read would execute
|
|
1153
|
+
Data.explain(model, query) // what one query read is, and currently is
|
|
977
1154
|
Remote.planQueries(...) // missing/stale query work
|
|
978
1155
|
Remote.inspectEntity(...) // one normalized entity
|
|
979
1156
|
```
|
|
@@ -992,6 +1169,53 @@ asked. That is deliberate. Runtime activity is not a second source of truth
|
|
|
992
1169
|
here, and a view that rendered from it would stop being reproducible from the
|
|
993
1170
|
Model.
|
|
994
1171
|
|
|
1172
|
+
### Explaining one query read
|
|
1173
|
+
|
|
1174
|
+
A query read is assembled from more pieces than it looks like: a definition, an
|
|
1175
|
+
input, a connection identity that excludes the window, a window that does not,
|
|
1176
|
+
and a Selection that is a slice asked of the server. `Data.explain` puts them in
|
|
1177
|
+
one serializable value, beside what the read answers from this Model right now:
|
|
1178
|
+
|
|
1179
|
+
```ts
|
|
1180
|
+
Data.explain(model, projects)
|
|
1181
|
+
// {
|
|
1182
|
+
// domain: 'remote',
|
|
1183
|
+
// query: 'ProjectsByOwner',
|
|
1184
|
+
// input: { ownerId: 'u1' },
|
|
1185
|
+
// identity: 'ProjectsByOwner\u0000{"ownerId":"u1"}',
|
|
1186
|
+
// window: { first: 25 },
|
|
1187
|
+
// select: { entity: 'Project', fields: ['id', 'name'] },
|
|
1188
|
+
// body: 'FROM Project\nWHERE Project.ownerId = $ownerId\nORDER BY Project.name DESC',
|
|
1189
|
+
// dependencies: { fields: [...], inputs: ['ownerId'], operations: ['eq'] },
|
|
1190
|
+
// state: 'Ready',
|
|
1191
|
+
// }
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
`body` is the query's meaning as readable text, and it is neither SQL nor
|
|
1195
|
+
whatever the backend compiled — an input shows as `$ownerId` rather than a bound
|
|
1196
|
+
parameter, and `contains` is named rather than rendered as somebody's `like`. A
|
|
1197
|
+
definition made with `Query.make` has no `body`, because its meaning lives on
|
|
1198
|
+
the server that answers it.
|
|
1199
|
+
|
|
1200
|
+
`state` comes from the projection's own read, so an explanation and the view
|
|
1201
|
+
cannot disagree about whether the data is there.
|
|
1202
|
+
|
|
1203
|
+
A Projection cannot say which Surfaces read it — several may — so pass the
|
|
1204
|
+
active record `subscriptions` takes and `explain` reports all of them, plus why
|
|
1205
|
+
each is active where that is a readable fact (`Surface.when`) rather than a
|
|
1206
|
+
callback (`Surface.at`):
|
|
1207
|
+
|
|
1208
|
+
```ts
|
|
1209
|
+
Data.explain(model, projects, { surfaces: actives })
|
|
1210
|
+
// surfaces: ['ProjectPage']
|
|
1211
|
+
// activation: [{ surface: 'ProjectPage', path: ['route'], tag: 'Project' }]
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
There is still no **executor**, because what answers a query is a
|
|
1215
|
+
`RemoteClient` Layer in the runtime rather than a value in the Model — the same
|
|
1216
|
+
purity that lets this be replayed from a recorded Model is what keeps it out of
|
|
1217
|
+
reach.
|
|
1218
|
+
|
|
995
1219
|
## Advanced: the kernel
|
|
996
1220
|
|
|
997
1221
|
Everything above compiles to the lower-level exports below; there is not a
|
|
@@ -1008,6 +1232,11 @@ place in an application Model. It exposes the Remote `Model`, `initial`,
|
|
|
1008
1232
|
`Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
|
|
1009
1233
|
`Remote.make` combines definition and binding in one step.
|
|
1010
1234
|
|
|
1235
|
+
`Entity.make(name, schema)` is Remote's own entity descriptor, and what a
|
|
1236
|
+
`foldkit-entity` Entity is compiled to (`Entity.from`). Remote accepts either
|
|
1237
|
+
wherever it takes an entity. Declaring one directly skips `foldkit-entity`,
|
|
1238
|
+
and with it relations, derived members and query bodies, which is only worth
|
|
1239
|
+
it for a domain that Remote is all it will ever need.
|
|
1011
1240
|
`Selection.make(Entity, { ... })`, `Selection.connection(...)`, and
|
|
1012
1241
|
`Entity.patch(ref, values)` are the kernel constructors behind the entity
|
|
1013
1242
|
helpers. `Remote.select(bound, selection)(id)` is the lower-level form of
|