foldkit-remote 0.4.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
@@ -145,9 +95,9 @@ Start with one server entity and one selection:
145
95
 
146
96
  ```ts
147
97
  import { Schema } from 'effect'
148
- import { Entity } from 'foldkit-remote'
98
+ import { Entity } from 'foldkit-entity'
149
99
 
150
- const Project = Entity.make(
100
+ const Project = Entity.define(
151
101
  'Project',
152
102
  Schema.Struct({
153
103
  id: Schema.String,
@@ -157,12 +107,20 @@ const Project = Entity.make(
157
107
  )
158
108
 
159
109
  // A Selection is the exact server-owned shape this consumer needs.
160
- const ProjectSummary = Project.select({
110
+ const ProjectSummary = Entity.select(Project, {
161
111
  id: true,
162
112
  name: true,
163
113
  })
164
114
  ```
165
115
 
116
+ The entity comes from [`foldkit-entity`](../entity), which declares a domain
117
+ without Remote in it. Remote also has an `Entity.make` of its own, and accepts
118
+ either — but only a `foldkit-entity` entity has addressable `fields`, so only
119
+ that one can be given [relations](#entities-declared-with-foldkit-entity),
120
+ derived members, or a [query body](#queries-and-pagination). Reach for
121
+ `Entity.make` when a domain is small enough that Remote is all it will ever
122
+ need; reach for `Entity.define` otherwise, which is most of the time.
123
+
166
124
  Embed Remote's Submodel in the application and bind the domain to that field:
167
125
 
168
126
  ```ts
@@ -283,7 +241,14 @@ Remote.Model
283
241
  ProjectPage now reads Ready(...)
284
242
  ```
285
243
 
286
- 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:
287
252
 
288
253
  ```ts
289
254
  const clientLayer = Remote.clientLayer(rpcClient)
@@ -346,15 +311,19 @@ missing ------------------------------------------------> Loading
346
311
 
347
312
  server says entity is absent ---------------------------> NotFound
348
313
  stored value fails Selection decoding -----------------> Failed
314
+ a read or a list's query fails ------------------------> Failed (with the old value, if any)
349
315
  ```
350
316
 
351
317
  The states are:
352
318
 
353
319
  - **`Initial`** — required data is absent and **nothing is currently fetching it**.
354
- - **`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.
355
322
  - **`Ready`** — every selected field is present and decodes.
356
323
  - **`Refreshing`** — the current value remains visible while it is being refetched.
357
- - **`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`.
358
327
  - **`NotFound`** — the entity is represented by a tombstone: a live event
359
328
  deleted it, or the server was asked for it by id and answered without it. That
360
329
  id is then known absent, whether it never existed, is gone, or is not this
@@ -371,6 +340,46 @@ state that actually means "wait for this request."
371
340
  `RemoteData.match` is exhaustive, so adding or omitting a state is visible at
372
341
  compile time.
373
342
 
343
+ ### Drawing one: `RemoteData.render`
344
+
345
+ Most views draw these six states three ways, under one policy: useful data
346
+ stays on screen. `RemoteData.render` is that fold.
347
+
348
+ ```ts
349
+ RemoteData.render(project, {
350
+ loading: () => ProjectSkeleton(),
351
+ notFound: () => NoSuchProject(),
352
+ failed: error => ErrorView(error),
353
+ data: (value, freshness) =>
354
+ ProjectView({ project: value, dimmed: freshness._tag !== 'Fresh' }),
355
+ })
356
+ ```
357
+
358
+ `Initial` and `Loading` reach `loading`; `Ready` and `Refreshing` reach `data`;
359
+ a `Failed` that still carries the value it had reaches `data` too, so a read
360
+ that failed does not throw away what the reader was already looking at. Only a
361
+ `Failed` with nothing to show reaches `failed`.
362
+
363
+ The `data` branch is told which it got, as a `Freshness` — exported, so a view
364
+ helper that takes one can name it:
365
+
366
+ | `Freshness` | What it means |
367
+ | --- | --- |
368
+ | `Fresh` | This is the current answer. |
369
+ | `Refreshing` | A newer answer is on its way; this one is still good. |
370
+ | `Stale` | A read failed; this is what was last known good, with its `error`. |
371
+
372
+ It is one tag rather than a pair of booleans because a value cannot be both
373
+ refreshing and stale, and a type that can say so invites a view to handle a
374
+ state that never arrives.
375
+
376
+ `notFound` is its own branch and not optional. A row the server answered for
377
+ and does not have is neither loading nor a failure; drawing it as either is a
378
+ spinner that never ends or an error nobody can act on.
379
+
380
+ Reach for `match` instead when the six states really do draw differently — it
381
+ stays the exhaustive fold, and `render` does not replace it.
382
+
374
383
  ## Normalized entities and selections
375
384
 
376
385
  Remote stores an entity once by identity, regardless of how many Surfaces read
@@ -444,7 +453,9 @@ const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name;
444
453
 
445
454
  - A relation becomes a ref field: `one` a ref, an optional `one` a nullable ref,
446
455
  `many` an array of refs. A derived member becomes a field the server supplies.
447
- - The Entity needs an `id` field; Remote keys the store by it.
456
+ - The Entity needs an `id` field; Remote keys the store by it. When that field
457
+ is branded, `Data.get` and `Data.live` take that type for an Entity Selection:
458
+ another Entity's id, or plain text, does not compile.
448
459
  - Register the Entities of one `Entity.relate` result and select from that same
449
460
  result; `Object.values(Work)` registers them all.
450
461
  - `Entity.from(entity)` and `Selection.from(selection)` are the compile steps,
@@ -452,9 +463,14 @@ const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name;
452
463
  handler.
453
464
  - `Entity.page(selection, window)` in an Entity Selection is Remote's
454
465
  `Selection.connection`: the window travels with the read, and the server
455
- answers with a page of refs. One relation of one entity is held whole or as
456
- one window at a time, so two views of the same entity should not read the
457
- same relation both ways at once.
466
+ answers with a page of refs. A page of a whole list is read under a name of
467
+ its own, `comments@first=10`, so one view can show every comment while another
468
+ shows the first ten of the same post: they are two fields to the store, fetched
469
+ in one batch, merged and refreshed each on its own. A write to the list (a
470
+ mutation's patch, a live change) marks its pages stale, so they are read again.
471
+ A cursor is not part of the name: a page read from a cursor continues its page.
472
+ The server refuses a request that pages one relation more than four ways, and
473
+ a field's own name may not contain `@`.
458
474
 
459
475
  `Entity.make` with `Entity.ref` keeps working, and both kinds can share one
460
476
  domain. Both packages export `Entity`; a module that needs `Entity.from` beside
@@ -509,6 +525,24 @@ Projection itself.
509
525
 
510
526
  ### Refreshing from `update`
511
527
 
528
+ ### Showing a change nobody has made
529
+
530
+ A mutation's `optimistic` operations show over the store while it is in flight.
531
+ `Data.overlay` shows operations the same way with no request behind them, until
532
+ `Data.lift`: a preview, in every Selection and view, of something not yet sent.
533
+
534
+ ```ts
535
+ const previewed = Data.overlay(model, 'post-preview', [Project.patch(id, { name: draft })])
536
+ const back = Data.lift(previewed, 'post-preview')
537
+ ```
538
+
539
+ - Both are called from `update`, and neither performs I/O or touches what the
540
+ server said: the store beneath is as it was.
541
+ - Showing an id again replaces what it showed. Lifting what was never shown
542
+ returns the same Model.
543
+ - An overlay's id is apart from every request's, so a mutation that settles does
544
+ not take a preview with it.
545
+
512
546
  To revalidate what a screen already declares — a refresh button, a focus
513
547
  regained — hand its Projection (or a Surface without params) to `Data.refresh`:
514
548
 
@@ -521,8 +555,9 @@ case 'ClickedRefresh': {
521
555
  ```
522
556
 
523
557
  `Data.refresh` performs no I/O and restates no request. It returns the Model
524
- with every selected field the store holds reading `Refreshing` and every loaded
525
- connection invalidated; the `Data.subscriptions` read entries then refetch it,
558
+ with every selected field the store holds reading `Refreshing`, every entity it
559
+ knew to be absent forgotten, so `NotFound` reads `Loading` and is asked for
560
+ again, and every loaded connection invalidated; the `Data.subscriptions` read entries then refetch it,
526
561
  since stale data is planned again under every policy, so the page is requested
527
562
  once. The Projection must be observed, as it is while it is on screen; for data
528
563
  nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
@@ -555,6 +590,52 @@ const Data = Remote.make({
555
590
  })
556
591
  ```
557
592
 
593
+ `Query.make` names a query and says what it returns. Where it *means*
594
+ something the client should be able to state, declare it with a body instead:
595
+
596
+ ```ts
597
+ import { Entity, Expr, Order, Query } from 'foldkit-entity'
598
+
599
+ const Task = Entity.define(
600
+ 'Task',
601
+ Schema.Struct({ id: Schema.String, ownerId: Schema.String, updatedAt: Schema.Number }),
602
+ )
603
+
604
+ const TasksByOwner = Query.define(
605
+ 'TasksByOwner',
606
+ { ownerId: Schema.String },
607
+ ({ input }) =>
608
+ Query.from(Task).pipe(
609
+ Query.where(Expr.eq(Task.fields.ownerId, input.ownerId)),
610
+ Query.orderBy(Order.desc(Task.fields.updatedAt), Order.asc(Task.fields.id)),
611
+ ),
612
+ )
613
+ ```
614
+
615
+ A body is written over a [`foldkit-entity`](../entity) Entity, because that is
616
+ what has addressable fields: `Task.fields.ownerId` is a reference an `Expr` can
617
+ be built from, where this package's own `Entity.make` describes a field as the
618
+ schema of its value and has nothing to point at. An entity declared either way
619
+ still reads, selects and normalizes the same; only a query body needs the
620
+ richer declaration.
621
+
622
+ What comes back is an ordinary descriptor — the same name, `Input`, `ref` and
623
+ connection identity — carrying its `body` besides, and the result is a
624
+ connection over the Entity the body reads, so it is not named twice. A server
625
+ can compile that body rather than be told the same thing again in its own
626
+ dialect; one that would rather answer the query its own way still can, and a
627
+ descriptor from `Query.make` has no body at all.
628
+
629
+ The body is built **once**, when the query is declared. Inside it `input.ownerId`
630
+ is a placeholder for the value the query will be given, not the value — there is
631
+ nothing there yet to branch on. A condition that depends on what was passed is a
632
+ comparison over the placeholder, never a `?:` around it.
633
+
634
+ `Input` must be fields or a plain `Schema.Struct`, because that is what the
635
+ placeholders are made from. A codec that exposes no keys is refused where the
636
+ query is declared rather than handing the body an empty object, which would
637
+ compare a column to nothing while every type agreed.
638
+
558
639
  Then a query is still just a Projection:
559
640
 
560
641
  ```ts
@@ -586,6 +667,159 @@ their required fields. `Data.next` / `Data.previous` preserve the page size and
586
667
  use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
587
668
  not from guessing based on row counts.
588
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
+
589
823
  ## Mutations and optimistic state
590
824
 
591
825
  Register mutations on the domain:
@@ -663,6 +897,41 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
663
897
  A successful server result can replace the request's temporary connection
664
898
  overlays with confirmed ones in place.
665
899
 
900
+ ### Reading past what is only pending
901
+
902
+ Every projection reads the **visible** cache: the server-derived store under
903
+ the layers still pending, which is why an optimistic change shows at once. A
904
+ reader that must not believe a change until the server has agreed to it asks
905
+ for the confirmed read instead:
906
+
907
+ ```ts
908
+ Data.confirmed(Data.get(ProjectSummary, projectId))
909
+ ```
910
+
911
+ It is the same projection — the same requirements, planned the same way, so
912
+ observing it fetches exactly what observing the original fetches — reading the
913
+ server-derived store alone, with the pending layers and connection overlays
914
+ left off. An optimistically inserted edge is not in its page; an
915
+ optimistically patched field reads as the server last said.
916
+
917
+ A view almost always wants the projection itself. This is for the reader that
918
+ reports on the world rather than drawing it, which in practice is an Agent
919
+ capability that must not claim success before the server reflects it:
920
+
921
+ ```ts
922
+ Agent.when({
923
+ projection: Data.confirmed(Data.get(ProjectSummary, projectId)),
924
+ predicate: (project, request) => project.name === request.name,
925
+ })
926
+ ```
927
+
928
+ There is deliberately no `Data.visible`: a projection already is the visible
929
+ read, and a second name for it would be a wrapper that only forwards.
930
+
931
+ Sync draws the same line over its replica, with the same words and a different
932
+ mechanism: see [what a reader sees while a change is in
933
+ flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight).
934
+
666
935
  ### Remote mutation vs Sync operation
667
936
 
668
937
  Do not use Remote mutation as a durable offline-write mechanism:
@@ -727,27 +996,34 @@ and refetch; Sync recovery must preserve unsent client intent.**
727
996
 
728
997
  ## Persistence and hydration
729
998
 
730
- Persist only the disposable entity cache, not session machinery such as live
731
- 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.
732
1004
 
733
1005
  ```ts
734
- import { emptyStore, RemotePersistence } from 'foldkit-remote'
1006
+ import { RemotePersistence } from 'foldkit-remote'
735
1007
 
736
1008
  // SSR: prefetch on the server, embed, then hydrate on the client.
737
- const snapshot = RemotePersistence.dehydrate(
738
- Data.storeOf(loaded),
1009
+ // `snapshotOf` names the connections that survive; none, here.
1010
+ const text = RemotePersistence.dehydrate(
1011
+ RemotePersistence.snapshotOf(loaded.remote),
739
1012
  { scope: userId },
740
1013
  )
741
1014
 
1015
+ const restored =
1016
+ RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
1017
+
742
1018
  Data.reduce(model, {
743
1019
  _tag: 'Hydrated',
744
- entities:
745
- RemotePersistence.hydrate(snapshot, { scope: userId }) ?? emptyStore,
1020
+ entities: restored.entities,
1021
+ connections: restored.connections,
746
1022
  merge: 'preserve-existing',
747
1023
  })
748
1024
 
749
1025
  // Or persist through Effect's KeyValueStore.
750
- RemotePersistence.save(store, {
1026
+ RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
751
1027
  key: 'remote-cache',
752
1028
  scope: userId,
753
1029
  maxBytes: 512_000,
@@ -811,6 +1087,22 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
811
1087
  rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
812
1088
  server packages together.
813
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
+
814
1106
  ## Introspection
815
1107
 
816
1108
  Remote exposes its decisions as data rather than requiring DevTools to reach
@@ -819,6 +1111,7 @@ into private state:
819
1111
  ```ts
820
1112
  Data.inspect(model) // serializable cache/domain summary
821
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
822
1115
  Remote.planQueries(...) // missing/stale query work
823
1116
  Remote.inspectEntity(...) // one normalized entity
824
1117
  ```
@@ -828,6 +1121,62 @@ useful question is: **"Why is this Projection still Initial?"** `Data.plan`
828
1121
  shows whether Remote believes anything is actually missing; active Surface
829
1122
  wiring determines whether that plan is being executed.
830
1123
 
1124
+ `Data.inspect(model).loading` answers the other one — **"what is Remote doing
1125
+ right now?"** — with the `entity\0id\0field` marks of the reads in flight,
1126
+ beside `mutations.pending` for the writes. Both are read from the Model, not
1127
+ from the fibers doing the work: a tool that shows them shows something a
1128
+ recorded Model can be replayed to, and nothing that needs the runtime to be
1129
+ asked. That is deliberate. Runtime activity is not a second source of truth
1130
+ here, and a view that rendered from it would stop being reproducible from the
1131
+ Model.
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
+
831
1180
  ## Advanced: the kernel
832
1181
 
833
1182
  Everything above compiles to the lower-level exports below; there is not a