foldkit-remote 0.5.0 → 0.6.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 +255 -70
- package/dist/index.d.mts +358 -28
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +701 -132
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -55,67 +55,17 @@ ownership.
|
|
|
55
55
|
|
|
56
56
|
## The mental model
|
|
57
57
|
|
|
58
|
-
The client side is a Foldkit Submodel:
|
|
59
|
-
|
|
60
|
-
```text
|
|
61
|
-
pure
|
|
62
|
-
Projection read
|
|
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
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
The other half of the model is the **requirement planner**:
|
|
92
|
-
|
|
93
58
|
```text
|
|
94
|
-
Surface
|
|
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
|
|
59
|
+
Surface Projection → requirements → active Subscription → RemoteClient I/O
|
|
60
|
+
↑ |
|
|
61
|
+
└──────── read Model ← Data.reduce ← Remote Message ──┘
|
|
115
62
|
```
|
|
116
63
|
|
|
117
|
-
|
|
118
|
-
|
|
64
|
+
A Projection reads the current cache and carries requirements such as
|
|
65
|
+
`Project:p1.name` and `User:u7.name`. An active Subscription compares those
|
|
66
|
+
requirements with the cache, then requests missing or stale fields. The
|
|
67
|
+
result returns as a Message; only reducing it changes what the next read sees.
|
|
68
|
+
Rendering the same Projection twice starts no work by itself.
|
|
119
69
|
|
|
120
70
|
## Four pieces to remember
|
|
121
71
|
|
|
@@ -133,7 +83,7 @@ Everything else builds on those four pieces.
|
|
|
133
83
|
## Install
|
|
134
84
|
|
|
135
85
|
```bash
|
|
136
|
-
pnpm add foldkit-remote
|
|
86
|
+
pnpm add foldkit-remote foldkit-entity
|
|
137
87
|
```
|
|
138
88
|
|
|
139
89
|
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
|
|
@@ -291,7 +241,14 @@ Remote.Model
|
|
|
291
241
|
ProjectPage now reads Ready(...)
|
|
292
242
|
```
|
|
293
243
|
|
|
294
|
-
|
|
244
|
+
The first example starts on the `home` route, so no project read runs yet.
|
|
245
|
+
Your route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
|
|
246
|
+
runtime must install `subscriptions`. Expect `Initial` until work starts,
|
|
247
|
+
`Loading` during the first request, and `Ready` once its result is reduced.
|
|
248
|
+
|
|
249
|
+
Finally, provide the client implementation to the runtime. Here `rpcClient`
|
|
250
|
+
is your configured Effect RPC client, not a value created by the declarations
|
|
251
|
+
above; the [server guide](../remote-server/README.md) supplies the other side:
|
|
295
252
|
|
|
296
253
|
```ts
|
|
297
254
|
const clientLayer = Remote.clientLayer(rpcClient)
|
|
@@ -354,15 +311,19 @@ missing ------------------------------------------------> Loading
|
|
|
354
311
|
|
|
355
312
|
server says entity is absent ---------------------------> NotFound
|
|
356
313
|
stored value fails Selection decoding -----------------> Failed
|
|
314
|
+
a read or a list's query fails ------------------------> Failed (with the old value, if any)
|
|
357
315
|
```
|
|
358
316
|
|
|
359
317
|
The states are:
|
|
360
318
|
|
|
361
319
|
- **`Initial`** — required data is absent and **nothing is currently fetching it**.
|
|
362
|
-
- **`Loading`** — required data is absent and a read is in
|
|
320
|
+
- **`Loading`** — required data is absent and a read, or a list's query, is in
|
|
321
|
+
flight.
|
|
363
322
|
- **`Ready`** — every selected field is present and decodes.
|
|
364
323
|
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
365
|
-
- **`Failed`** — stored server data does not decode against the Selection
|
|
324
|
+
- **`Failed`** — stored server data does not decode against the Selection, or
|
|
325
|
+
the read or query behind it failed. A value that was already shown is kept as
|
|
326
|
+
`previous`.
|
|
366
327
|
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
367
328
|
deleted it, or the server was asked for it by id and answered without it. That
|
|
368
329
|
id is then known absent, whether it never existed, is gone, or is not this
|
|
@@ -706,6 +667,159 @@ their required fields. `Data.next` / `Data.previous` preserve the page size and
|
|
|
706
667
|
use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
|
|
707
668
|
not from guessing based on row counts.
|
|
708
669
|
|
|
670
|
+
### Filtering a loaded list without asking the server
|
|
671
|
+
|
|
672
|
+
A search box over a page you already have should not be a round trip.
|
|
673
|
+
`Data.filtered` narrows a loaded list by a query body:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const found = Data.filtered(model, projects, ActiveProjects, {})
|
|
677
|
+
|
|
678
|
+
found.items // decoded exactly as the list decodes them
|
|
679
|
+
found.complete // whether the answer was about the whole list
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
**It filters a list; it does not run a query**, and the distinction is the
|
|
683
|
+
design. "Which rows match" would need to know this list holds every row the body
|
|
684
|
+
could match — predicate containment, which Remote deliberately does not do.
|
|
685
|
+
"Which rows *of this list* match" is decidable from what is already here.
|
|
686
|
+
|
|
687
|
+
`complete` is false unless three things hold: every edge could be judged (no row
|
|
688
|
+
missing a field the *body* reads), every match could be shown (no row missing a
|
|
689
|
+
field the *Selection* reads), and the list is terminal at both ends. So
|
|
690
|
+
empty-and-complete and empty-and-partial stay different answers — one means
|
|
691
|
+
"none", the other means "none that I can see yet".
|
|
692
|
+
|
|
693
|
+
Nothing is created: no connection, nothing new to retain, nothing new to fetch.
|
|
694
|
+
The server stays authoritative for which rows exist.
|
|
695
|
+
|
|
696
|
+
### Judging the rows you already hold
|
|
697
|
+
|
|
698
|
+
A connection's rows are edges the server delivered, and a query body describes
|
|
699
|
+
*which rows* — so until something evaluates the body on the client, nothing here
|
|
700
|
+
can say whether a row belongs to a query. `Remote.matching` says:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
const judged = Remote.matching(Data.storeOf(model), ProjectsByOwner, { ownerId: 'u1' })
|
|
704
|
+
|
|
705
|
+
judged.matched // keys satisfying the body, in the order it asks for
|
|
706
|
+
judged.skipped // keys held, but missing a field the body reads
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
It is pure, and it runs the **same interpreter the server checks itself
|
|
710
|
+
against** — the reference implementation in
|
|
711
|
+
[`foldkit-entity`](../entity) — so a body means one thing in both places. The
|
|
712
|
+
conformance suite is run over a store as well as over rows and a table.
|
|
713
|
+
|
|
714
|
+
Three things about it are deliberate.
|
|
715
|
+
|
|
716
|
+
**It answers "which of the rows I hold match", never "which rows match."** Those
|
|
717
|
+
are different questions, and the difference is not recoverable from a list of
|
|
718
|
+
keys — so it is in the shape. A caller that knows it holds the whole population
|
|
719
|
+
(a connection terminal at both ends) is the one that can turn this into a
|
|
720
|
+
complete answer.
|
|
721
|
+
|
|
722
|
+
**`skipped` is the honest half.** A row missing a field the body reads cannot be
|
|
723
|
+
judged: it is neither a match nor a non-match. Dropping it silently would turn
|
|
724
|
+
"I could not tell" into "no", so it is named instead, and the caller decides
|
|
725
|
+
whether to fetch the field, ask the server, or say the answer is partial.
|
|
726
|
+
|
|
727
|
+
**It takes the input decoded and encodes it itself.** A store holds wire values,
|
|
728
|
+
so a comparison has to happen in that space — and handing an interpreter a
|
|
729
|
+
decoded value is a mistake *no runtime error catches*: the answer is simply
|
|
730
|
+
empty, which reads exactly like a correct one. Doing the encoding here means a
|
|
731
|
+
caller cannot get it wrong.
|
|
732
|
+
|
|
733
|
+
It is **not authorization**. On the server a compiled `where` is conjoined with
|
|
734
|
+
the binding's `visible` rule; there is no `visible` here. This is safe only
|
|
735
|
+
because the client holds only rows the server already released to it, and a
|
|
736
|
+
local filter is not an access decision.
|
|
737
|
+
|
|
738
|
+
### An input that changes as fast as someone types
|
|
739
|
+
|
|
740
|
+
A connection's identity is its query plus its **input**, which is exactly right
|
|
741
|
+
for caching and exactly wrong for a search box. Bound naively, every keystroke
|
|
742
|
+
is a different connection, a different request, and a different thing to retain:
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
// Don't: `postSearch` changes per keystroke, so the query's input does too.
|
|
746
|
+
h.OnInput(text => Message.Searched({ text }))
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Remote has no debounce, and should not: its job is to be a faithful function of
|
|
750
|
+
the Model, so if the Model says the search is `Engi` then `Engi` is what the
|
|
751
|
+
query asks. **The debounce belongs between the input Message and the Model field
|
|
752
|
+
the query reads** — which is ordinary application state, and
|
|
753
|
+
[`foldkit-primitives`](../primitives) already has the piece:
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
import { debounce } from 'foldkit-primitives/time'
|
|
757
|
+
|
|
758
|
+
const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
|
|
759
|
+
const SearchBox = Bundle.declare(SearchInput, 'search')
|
|
760
|
+
|
|
761
|
+
// `latest` is what the box shows, so typing stays immediate.
|
|
762
|
+
// The settled OutMessage is what moves the field the query reads.
|
|
763
|
+
Page.at(SearchBox, {
|
|
764
|
+
args: { delayMs: 250 },
|
|
765
|
+
onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
|
|
766
|
+
})
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Two fields, deliberately: the one the box draws changes on every keystroke, and
|
|
770
|
+
the one the query reads changes a quarter second after the last one.
|
|
771
|
+
[`examples/entity`](../../examples/entity) does exactly this.
|
|
772
|
+
|
|
773
|
+
The same applies to any high-frequency query input — a slider, a map viewport, a
|
|
774
|
+
date scrubber. What makes a search box the usual case is that the query is often
|
|
775
|
+
written *for* it: `Expr.contains` over an empty string matches everything, so an
|
|
776
|
+
empty box and a filled one are one query rather than two.
|
|
777
|
+
|
|
778
|
+
### A page that overruns its window is refused
|
|
779
|
+
|
|
780
|
+
The client asks for `first: 25`, and a page carrying more than that is a
|
|
781
|
+
protocol disagreement rather than a windfall: the client cannot tell which of
|
|
782
|
+
the edges the window meant, and the connection's boundaries stop describing what
|
|
783
|
+
it holds. Such a page becomes a `QueryFailed` with a named protocol error, and
|
|
784
|
+
none of its edges reach the store — a connection already loaded is left exactly
|
|
785
|
+
as it was, so a refresh that overruns cannot replace good rows with a rejected
|
|
786
|
+
page.
|
|
787
|
+
|
|
788
|
+
A window with neither `first` nor `last` bounds nothing: `after`/`before` says
|
|
789
|
+
where to start, not how much to take.
|
|
790
|
+
|
|
791
|
+
### When a read fails
|
|
792
|
+
|
|
793
|
+
A failure is kept on the Model until something settles it, and the read says
|
|
794
|
+
so. Queries fail per connection, and entity reads per field. A read with
|
|
795
|
+
nothing to show reads `Failed` with the error. One that had a value (a failed
|
|
796
|
+
refresh, or a failed "load more") reads `Failed` with that value as
|
|
797
|
+
`previous`, which `RemoteData.render` draws as data with a `Stale` freshness,
|
|
798
|
+
so it stays on screen. A failure reaches every read that needs it: a list
|
|
799
|
+
fails when one of its rows' fields failed, and a project read fails when its
|
|
800
|
+
owner's did.
|
|
801
|
+
|
|
802
|
+
**It is not retried on its own.** A persistent error would otherwise be asked
|
|
803
|
+
again every time some unrelated read restarted the entry. What failed is left
|
|
804
|
+
out of the plan, and everything else is still fetched: the rows a failed list
|
|
805
|
+
holds, the other fields of an entity. What asks again:
|
|
806
|
+
|
|
807
|
+
- `Data.refresh(model, projection)` — the retry button. It works on something
|
|
808
|
+
that never loaded, too.
|
|
809
|
+
- The value arriving anyway: a page for a list, or a read, live patch or
|
|
810
|
+
mutation result that writes the field.
|
|
811
|
+
- The server invalidating a list over a live stream, or deleting the entity.
|
|
812
|
+
- Retention dropping it: something nothing reads any more forgets its failure,
|
|
813
|
+
so coming back to it later asks the server again.
|
|
814
|
+
|
|
815
|
+
`Remote.inspect(model.remote).failures` lists what failed, by connection
|
|
816
|
+
identity and by `entity\0id\0field` mark.
|
|
817
|
+
|
|
818
|
+
A broken live stream is not a failed read. Its `ReadFailed` carries the
|
|
819
|
+
`stream`, and records a gap in `RemoteModel.gaps` rather than failing any
|
|
820
|
+
field: nothing was being read, and the values on screen are still what the
|
|
821
|
+
server last said.
|
|
822
|
+
|
|
709
823
|
## Mutations and optimistic state
|
|
710
824
|
|
|
711
825
|
Register mutations on the domain:
|
|
@@ -882,27 +996,34 @@ and refetch; Sync recovery must preserve unsent client intent.**
|
|
|
882
996
|
|
|
883
997
|
## Persistence and hydration
|
|
884
998
|
|
|
885
|
-
|
|
886
|
-
|
|
999
|
+
A snapshot is `{ entities, connections }`. The entity cache is disposable and
|
|
1000
|
+
always goes; a connection goes only if named, and keeps its edges alone — never
|
|
1001
|
+
a cursor, which may name server state that is gone. Session machinery (live
|
|
1002
|
+
cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
|
|
1003
|
+
never in one.
|
|
887
1004
|
|
|
888
1005
|
```ts
|
|
889
|
-
import {
|
|
1006
|
+
import { RemotePersistence } from 'foldkit-remote'
|
|
890
1007
|
|
|
891
1008
|
// SSR: prefetch on the server, embed, then hydrate on the client.
|
|
892
|
-
|
|
893
|
-
|
|
1009
|
+
// `snapshotOf` names the connections that survive; none, here.
|
|
1010
|
+
const text = RemotePersistence.dehydrate(
|
|
1011
|
+
RemotePersistence.snapshotOf(loaded.remote),
|
|
894
1012
|
{ scope: userId },
|
|
895
1013
|
)
|
|
896
1014
|
|
|
1015
|
+
const restored =
|
|
1016
|
+
RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
|
|
1017
|
+
|
|
897
1018
|
Data.reduce(model, {
|
|
898
1019
|
_tag: 'Hydrated',
|
|
899
|
-
entities:
|
|
900
|
-
|
|
1020
|
+
entities: restored.entities,
|
|
1021
|
+
connections: restored.connections,
|
|
901
1022
|
merge: 'preserve-existing',
|
|
902
1023
|
})
|
|
903
1024
|
|
|
904
1025
|
// Or persist through Effect's KeyValueStore.
|
|
905
|
-
RemotePersistence.save(
|
|
1026
|
+
RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
|
|
906
1027
|
key: 'remote-cache',
|
|
907
1028
|
scope: userId,
|
|
908
1029
|
maxBytes: 512_000,
|
|
@@ -966,6 +1087,22 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
|
966
1087
|
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
967
1088
|
server packages together.
|
|
968
1089
|
|
|
1090
|
+
## Finding the next API
|
|
1091
|
+
|
|
1092
|
+
| Task | Start here |
|
|
1093
|
+
| --- | --- |
|
|
1094
|
+
| Explain `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
|
|
1095
|
+
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
1096
|
+
| Load an ordered list | [Queries and pagination](#queries-and-pagination) |
|
|
1097
|
+
| Save and show an optimistic result | [Mutations](#mutations-and-optimistic-state) |
|
|
1098
|
+
| Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
|
|
1099
|
+
| Seed a cache after reload or SSR | [Hydration](#persistence-and-hydration) |
|
|
1100
|
+
|
|
1101
|
+
A successful mutation and an optimistic overlay have different guarantees:
|
|
1102
|
+
the overlay is only a temporary view of server-owned data. Use Sync when the
|
|
1103
|
+
operation itself must survive offline, rather than persisting an optimistic
|
|
1104
|
+
Remote cache and treating it as an outbox.
|
|
1105
|
+
|
|
969
1106
|
## Introspection
|
|
970
1107
|
|
|
971
1108
|
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
@@ -974,6 +1111,7 @@ into private state:
|
|
|
974
1111
|
```ts
|
|
975
1112
|
Data.inspect(model) // serializable cache/domain summary
|
|
976
1113
|
Data.plan(model, projection) // the entity plan a read would execute
|
|
1114
|
+
Data.explain(model, query) // what one query read is, and currently is
|
|
977
1115
|
Remote.planQueries(...) // missing/stale query work
|
|
978
1116
|
Remote.inspectEntity(...) // one normalized entity
|
|
979
1117
|
```
|
|
@@ -992,6 +1130,53 @@ asked. That is deliberate. Runtime activity is not a second source of truth
|
|
|
992
1130
|
here, and a view that rendered from it would stop being reproducible from the
|
|
993
1131
|
Model.
|
|
994
1132
|
|
|
1133
|
+
### Explaining one query read
|
|
1134
|
+
|
|
1135
|
+
A query read is assembled from more pieces than it looks like: a definition, an
|
|
1136
|
+
input, a connection identity that excludes the window, a window that does not,
|
|
1137
|
+
and a Selection that is a slice asked of the server. `Data.explain` puts them in
|
|
1138
|
+
one serializable value, beside what the read answers from this Model right now:
|
|
1139
|
+
|
|
1140
|
+
```ts
|
|
1141
|
+
Data.explain(model, projects)
|
|
1142
|
+
// {
|
|
1143
|
+
// domain: 'remote',
|
|
1144
|
+
// query: 'ProjectsByOwner',
|
|
1145
|
+
// input: { ownerId: 'u1' },
|
|
1146
|
+
// identity: 'ProjectsByOwner\u0000{"ownerId":"u1"}',
|
|
1147
|
+
// window: { first: 25 },
|
|
1148
|
+
// select: { entity: 'Project', fields: ['id', 'name'] },
|
|
1149
|
+
// body: 'FROM Project\nWHERE Project.ownerId = $ownerId\nORDER BY Project.name DESC',
|
|
1150
|
+
// dependencies: { fields: [...], inputs: ['ownerId'], operations: ['eq'] },
|
|
1151
|
+
// state: 'Ready',
|
|
1152
|
+
// }
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
`body` is the query's meaning as readable text, and it is neither SQL nor
|
|
1156
|
+
whatever the backend compiled — an input shows as `$ownerId` rather than a bound
|
|
1157
|
+
parameter, and `contains` is named rather than rendered as somebody's `like`. A
|
|
1158
|
+
definition made with `Query.make` has no `body`, because its meaning lives on
|
|
1159
|
+
the server that answers it.
|
|
1160
|
+
|
|
1161
|
+
`state` comes from the projection's own read, so an explanation and the view
|
|
1162
|
+
cannot disagree about whether the data is there.
|
|
1163
|
+
|
|
1164
|
+
A Projection cannot say which Surfaces read it — several may — so pass the
|
|
1165
|
+
active record `subscriptions` takes and `explain` reports all of them, plus why
|
|
1166
|
+
each is active where that is a readable fact (`Surface.when`) rather than a
|
|
1167
|
+
callback (`Surface.at`):
|
|
1168
|
+
|
|
1169
|
+
```ts
|
|
1170
|
+
Data.explain(model, projects, { surfaces: actives })
|
|
1171
|
+
// surfaces: ['ProjectPage']
|
|
1172
|
+
// activation: [{ surface: 'ProjectPage', path: ['route'], tag: 'Project' }]
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
There is still no **executor**, because what answers a query is a
|
|
1176
|
+
`RemoteClient` Layer in the runtime rather than a value in the Model — the same
|
|
1177
|
+
purity that lets this be replayed from a recorded Model is what keeps it out of
|
|
1178
|
+
reach.
|
|
1179
|
+
|
|
995
1180
|
## Advanced: the kernel
|
|
996
1181
|
|
|
997
1182
|
Everything above compiles to the lower-level exports below; there is not a
|