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 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
- 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
79
+ Surface Projection → requirements → active Subscription → RemoteClient I/O
80
+ ↑ |
81
+ └──────── read Model ← Data.reduce ← Remote Message ──┘
89
82
  ```
90
83
 
91
- The other half of the model is the **requirement planner**:
92
-
93
- ```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
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. Remote also has an `Entity.make` of its own, and accepts
168
- either — but only a `foldkit-entity` entity has addressable `fields`, so only
169
- that one can be given [relations](#entities-declared-with-foldkit-entity),
170
- derived members, or a [query body](#queries-and-pagination). Reach for
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.fields.remote,
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
- Finally, provide the client implementation to the runtime:
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 flight.
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', [Project.patch(id, { name: draft })])
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.fields.remote,
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: [Project.patch(id, { name })],
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
- Comment.patch(tempId, { id: tempId, body: input.body }),
942
+ Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
775
943
  ConnectionChange.prepend(
776
944
  CommentsForPost.ref({ postId }),
777
- Comment.ref(tempId),
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
- Persist only the disposable entity cache, not session machinery such as live
886
- cursors, optimistic layers, mutation bookkeeping, gaps, or retention roots.
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 { emptyStore, RemotePersistence } from 'foldkit-remote'
1061
+ import { RemotePersistence } from 'foldkit-remote'
890
1062
 
891
1063
  // SSR: prefetch on the server, embed, then hydrate on the client.
892
- const snapshot = RemotePersistence.dehydrate(
893
- Data.storeOf(loaded),
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
- RemotePersistence.hydrate(snapshot, { scope: userId }) ?? emptyStore,
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(store, {
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