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 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 says:
95
- "I need Project p1: id, name, owner.name"
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
- That separation is why reading stays pure. Rendering a Surface never starts a
118
- request.
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
- Finally, provide the client implementation to the runtime:
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 flight.
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
- Persist only the disposable entity cache, not session machinery such as live
886
- cursors, optimistic layers, mutation bookkeeping, gaps, or retention roots.
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 { emptyStore, RemotePersistence } from 'foldkit-remote'
1006
+ import { RemotePersistence } from 'foldkit-remote'
890
1007
 
891
1008
  // SSR: prefetch on the server, embed, then hydrate on the client.
892
- const snapshot = RemotePersistence.dehydrate(
893
- Data.storeOf(loaded),
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
- RemotePersistence.hydrate(snapshot, { scope: userId }) ?? emptyStore,
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(store, {
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