foldkit-remote 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,564 @@
1
+ # `foldkit-remote`
2
+
3
+ Normalized application-facing server state for Foldkit. Entities, field
4
+ selections, queries, connections, mutations, and live patches reconcile into one
5
+ store that is part of the Model. There is no hidden mutable cache: the same
6
+ `update` that moves the application moves remote data.
7
+
8
+ `foldkit-remote` owns semantic data modeling; Effect owns the infrastructure
9
+ below it. The wire is Effect RPC (`RemoteRpc`), so the transport is whatever
10
+ Effect layer the application chooses — HTTP, WebSocket, worker, in-process.
11
+ The server half is
12
+ [`foldkit-remote-server`](https://github.com/doeixd/foldkit-plus/tree/main/packages/remote-server).
13
+ The worked end-to-end trace is
14
+ [`examples/remote`](https://github.com/doeixd/foldkit-plus/tree/main/examples/remote).
15
+ The full design rationale is in
16
+ [Revision Plan §8](https://github.com/doeixd/foldkit-plus/blob/main/docs/design/REVISION_PLAN.md#8-remote).
17
+
18
+ The package has two faces. The **application API** is the bound domain `Data`:
19
+ `Data.get`, `Data.live`, `Data.query`, `Data.mutate`, `Data.subscriptions`, and
20
+ a handful more, each inferred from the descriptors it is given. The **kernel**
21
+ underneath — requirements, the planner, the reducer, the Subscription entries,
22
+ the transport seam — stays exported for tooling, SSR, tests, and package
23
+ authors, and is documented under [Advanced](#advanced-the-kernel). Ordinary
24
+ application code needs only the first.
25
+
26
+ ## Quick start
27
+
28
+ One feature, end to end: a project page that reads a project live, lists the
29
+ owner's projects, and renames a project.
30
+
31
+ ### 1. Declare the domain
32
+
33
+ ```ts
34
+ import { Schema } from 'effect'
35
+ import { Entity, Mutation, Query } from 'foldkit-remote'
36
+
37
+ const User = Entity.make('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
38
+ const Project = Entity.make(
39
+ 'Project',
40
+ Schema.Struct({
41
+ id: Schema.String,
42
+ name: Schema.String,
43
+ status: Schema.String,
44
+ owner: Entity.ref(User),
45
+ }),
46
+ )
47
+
48
+ const UserSummary = User.select({ id: true, name: true })
49
+ const ProjectSummary = Project.select({ id: true, name: true, status: true, owner: UserSummary })
50
+
51
+ const ProjectsByOwner = Query.make('ProjectsByOwner', {
52
+ Input: { ownerId: Schema.String },
53
+ Result: Project,
54
+ })
55
+ const RenameProject = Mutation.make('RenameProject', {
56
+ Input: { id: Schema.String, name: Schema.String },
57
+ Output: { id: Schema.String },
58
+ })
59
+ ```
60
+
61
+ An entity is its fields; a relation is a reference codec (`Entity.ref(User)`),
62
+ never an inline target schema, so a recursive relation (`Node.parent:
63
+ Entity.refTo('Node')`) stays finite. `Project.select` picks fields and reads
64
+ **through** a relation into its target (`owner: UserSummary`); an unknown field
65
+ or a nested selection of the wrong entity is a compile error. `Input`,
66
+ `Output`, and `Result` take a codec or the fields of the `Schema.Struct` it
67
+ would be; `Result: Project` is a connection over `Project`.
68
+
69
+ ### 2. Embed the submodel and bind the domain
70
+
71
+ ```ts
72
+ import { defineMessageUnion } from 'foldkit/message'
73
+ import type * as Update from 'foldkit/update'
74
+ import { Remote } from 'foldkit-remote'
75
+ import { Surface } from 'foldkit-surface'
76
+
77
+ const Model = Schema.Struct({ route: Route, remote: Remote.Model })
78
+ type Model = typeof Model.Type
79
+ const Message = defineMessageUnion({
80
+ ...Remote.messages,
81
+ ClickedRename: { id: Schema.String, name: Schema.String },
82
+ ClickedMore: {},
83
+ })
84
+ type Message = typeof Message.Type
85
+
86
+ const App = Surface.application({
87
+ Model,
88
+ Message,
89
+ initial: { route: Route.home(), remote: Remote.initial },
90
+ update, // step 5: a function declaration, so it may follow Data
91
+ })
92
+
93
+ const Data = Remote.make({
94
+ model: App.model.remote,
95
+ entities: [User, Project],
96
+ queries: [ProjectsByOwner],
97
+ mutations: [RenameProject],
98
+ })
99
+ ```
100
+
101
+ `Remote.Model` is the submodel's schema, the same for every domain, so the
102
+ Model embeds it before the domain is bound. Remote's Messages are cases of the
103
+ application's own union (`Remote.messages`). `Data` is the bound domain: it
104
+ knows the Model, the place of the store in it, and the entities, queries, and
105
+ mutations it may be asked about — anything else is a compile error naming the
106
+ descriptor (`Entity "Team" is not registered with this Remote domain`), and a
107
+ runtime error naming the domain.
108
+
109
+ ### 3. Read in a Surface
110
+
111
+ ```ts
112
+ const projects = Data.query(ProjectsByOwner, { ownerId: 'u1' }, { select: ProjectSummary, first: 25 })
113
+
114
+ const ProjectPage = App.surface('ProjectPage', {
115
+ params: { projectId: Schema.String },
116
+ model: ({ params }) => ({ project: Data.live(ProjectSummary, params.projectId), projects }),
117
+ messages: [Message.ClickedRename, Message.ClickedMore],
118
+ })
119
+ ```
120
+
121
+ The Surface's Model is `{ project: RemoteData<ProjectSummary>; projects:
122
+ RemoteData<Page<ProjectSummary>> }`, read purely from the Model, no I/O. Every
123
+ remote value is a `RemoteData`:
124
+
125
+ - `Initial` — some selected field is not present yet,
126
+ - `Ready` — present,
127
+ - `Refreshing` — present, and being refetched (the value stays visible),
128
+ - `Failed` — the stored value did not decode against the selection,
129
+ - `NotFound` — the entity is a tombstone.
130
+
131
+ `RemoteData.match` is exhaustive. `Data.get` reads once and refreshes by policy;
132
+ `Data.live` also follows the entity's changes; `Data.query` reads a connection
133
+ as a `Page` of selected items, `Initial` until the page and every item's fields
134
+ are present.
135
+
136
+ ### 4. Fetch, subscribe, and retain from the active Surfaces
137
+
138
+ ```ts
139
+ import * as Subscription from 'foldkit/subscription'
140
+ import { RemoteClient, RemotePolicy } from 'foldkit-remote'
141
+
142
+ const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
143
+ Data.subscriptions(
144
+ {
145
+ page: Surface.at(ProjectPage, model =>
146
+ model.route._tag === 'project' ? { projectId: model.route.projectId } : undefined,
147
+ ),
148
+ },
149
+ { policy: RemotePolicy.staleWhileRevalidate({ maxAge: 30_000 }), grace: '5 seconds' },
150
+ ),
151
+ )
152
+ ```
153
+
154
+ Activation is a fact of the Model: `Surface.at` gives the Surface its params as
155
+ a function of the Model, `undefined` while it is inactive. From the active
156
+ Surfaces `Data.subscriptions` derives what to fetch (only the fields and pages
157
+ the Model lacks), what to subscribe to (what is read through `Data.live`), and
158
+ what to keep (everything the active Surfaces reach); a fully-known Surface
159
+ fetches nothing, and there is never network work during render.
160
+
161
+ ### 5. Mutate and page from `update`
162
+
163
+ ```ts
164
+ function update(model: Model, message: Message): Update.Return<Model, Message, RemoteClient> {
165
+ if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
166
+ switch (message._tag) {
167
+ case 'ClickedRename': {
168
+ const { id, name } = message
169
+ const { model: started, command } = Data.mutate(model, RenameProject, { id, name }, {
170
+ optimistic: [Project.patch(id, { name })],
171
+ })
172
+ return { model: started, commands: [command] }
173
+ }
174
+ case 'ClickedMore': {
175
+ const next = Data.next(model, projects)
176
+ return { model, commands: next === undefined ? [] : [Data.fetch(next)] }
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ Every new fact — a read batch, a page, a mutation result, a live event — arrives
183
+ as one of Remote's Messages, and `Data.reduce` is the one reducer for all of
184
+ them. The Commands run through `RemoteClient`, so `update` names it as the
185
+ resource its Commands need (`Update.Return<Model, Message, RemoteClient>`) and
186
+ the runtime is given the client layer (step 6). `Data.mutate` starts the request in the Model (its id comes from the
187
+ Model's own sequence, so `update` stays pure) and returns the Command whose
188
+ Message settles it; the optimistic patch shows until then. `Data.next` is the
189
+ `QueryRef` of the page after the loaded end, or `undefined`, and `Data.fetch`
190
+ the Command that merges it, after which the same `projects` projection reads
191
+ every loaded page.
192
+
193
+ ### 6. Provide the client
194
+
195
+ ```ts
196
+ const clientLayer = Remote.clientLayer(rpcClient) // an Effect RPC client for RemoteRpc
197
+ // … provide it to the Foldkit runtime; in tests or in-process, RemoteServer.handlers(…) is one.
198
+ ```
199
+
200
+ `Remote.clientLayer` adapts an Effect RPC client for `RemoteRpc` to the
201
+ `RemoteClient` service the subscriptions and Commands run through, and coalesces
202
+ its reads and queries. The server side is
203
+ [`foldkit-remote-server`](../remote-server) with sources compiled by
204
+ [`foldkit-remote-drizzle`](../remote-drizzle) or written by hand.
205
+
206
+ ## Reading
207
+
208
+ `Data.get(selection, id)` and `Data.live(selection, id)` return
209
+ `Projection<AppModel, RemoteData<Value>>`, the value typed from the selection.
210
+ `Data.live` marks the projection's requirements `live`; the mark survives
211
+ `Projection.struct` and never reaches the wire, and `Data.subscriptions`
212
+ subscribes what is marked. What a field the Model already holds means is a
213
+ `RemotePolicy`, an option of `Data.subscriptions` and `Data.prefetch`:
214
+
215
+ - `RemotePolicy.cacheFirst` (default) — fetch only missing, stale, or
216
+ re-windowed fields.
217
+ - `RemotePolicy.staleWhileRevalidate({ maxAge })` — keep present values
218
+ visible and refetch an entry older than `maxAge` milliseconds.
219
+ - `RemotePolicy.networkOnly` — fetch every selected field; cached values stay
220
+ visible meanwhile.
221
+
222
+ A refreshing policy marks the refetched fields stale first, so the projection
223
+ reads `Refreshing` with the old value until the read lands. The clock the policy
224
+ reads is the `now` option (default `Date.now`), so tests inject time.
225
+
226
+ `Data.prefetch(model, projection, options?)` runs the same plan through
227
+ `RemoteClient` (the projection's pending queries first, then one read) and
228
+ returns the Model with the results reduced in, for SSR, route or hover
229
+ prefetch, and tests. It never runs during render.
230
+
231
+ ```ts
232
+ const loaded = await Effect.runPromise(
233
+ Data.prefetch(model, ProjectPage.projection({ projectId }), {
234
+ policy: RemotePolicy.staleWhileRevalidate({ maxAge: 30_000 }),
235
+ }).pipe(Effect.provide(clientLayer)),
236
+ )
237
+ ```
238
+
239
+ ## Queries and pages
240
+
241
+ ```ts
242
+ const projects = Data.query(ProjectsByOwner, { ownerId }, { select: ProjectSummary, first: 25 })
243
+ projects.read(model) // RemoteData<Page<{ id; name; status; owner: { name } }>>
244
+ Data.next(model, projects) // QueryRef | undefined, from the loaded end
245
+ Data.previous(model, projects) // …from the loaded start
246
+ Data.fetch(ref) // Command yielding the ConnectionMerged (or QueryFailed) that reduces it
247
+ ```
248
+
249
+ `select` is a selection of the query's entity (another entity is an error
250
+ naming both); the window is `first`/`after` or `last`/`before`, never a mix,
251
+ a page size is a non-negative integer (anything else is an error at the call,
252
+ and refused by the wire), and no window asks for the server's default page. The projection is `Initial`
253
+ until the page and every item's selected fields are present — never a partial
254
+ page — `Ready` once they are, `Refreshing` while the connection or any item is
255
+ being refetched, and `Failed` if an item does not decode. An optimistic insert
256
+ into a page should therefore carry every field the page selects, or the page
257
+ reads `Initial` until the read entry fetches the rest.
258
+
259
+ The projection carries its connection, so a Surface that reads a page needs
260
+ nothing more: the read entry runs the query when the Model does not hold the
261
+ connection (or holds it stale), and once the page is in the Model its items
262
+ are planned and read like any other field. A failed query yields
263
+ `QueryFailed`, which ends the refresh and keeps the pages.
264
+ `Data.next` and `Data.previous` keep the page size and are `undefined` at a
265
+ terminal or unknown boundary; the merged pages read through the same
266
+ projection, with `hasNext`/`hasPrevious` derived from the boundaries, never
267
+ from row counts. Two projections of one connection plan one query, selecting
268
+ the union of their fields.
269
+
270
+ ## Mutations
271
+
272
+ ```ts
273
+ const { model: started, requestId, tempId, command } = Data.mutate(model, AddComment, input, {
274
+ optimistic: ({ tempId }) => [
275
+ Comment.patch(tempId, { id: tempId, body: input.body }),
276
+ ConnectionChange.prepend(CommentsForPost.ref({ postId }), Comment.ref(tempId)),
277
+ ],
278
+ })
279
+ return { model: started, commands: [command] }
280
+ ```
281
+
282
+ `Data.mutate` accepts only the domain's mutations and the mutation's own
283
+ `Input`. It applies `MutationStarted` (with the optimistic operations) to the
284
+ Model and returns the Command whose Message (`MutationSucceeded` or
285
+ `MutationFailed`) settles it through `Data.reduce`; the Command never fails.
286
+ The request id is `<domain>-<n>` from the Model's mutation sequence; `{
287
+ requestId }` overrides it for a retry or a durable bridge, and `optimistic` may
288
+ be a function of `{ requestId, tempId }` so a created entity carries `tempId`
289
+ until the result names the real one.
290
+
291
+ A mutation owns its optimistic operations: entity patches (`Project.patch(id,
292
+ values)`) and connection changes (`ConnectionChange.prepend`/`append`/`remove`,
293
+ by `QueryRef` or identity), applied together when it starts and released
294
+ together when it settles. Patches are ordered layers over the base store, not
295
+ inverse patches, so overlapping layers rebase for free; connection changes are
296
+ overlays outside the server-known region. `MutationSucceeded` writes the
297
+ server's patches and puts the result's confirmed `connections` where the
298
+ request's overlays were, so a temporary edge becomes the real one without a
299
+ flicker; `MutationFailed` releases both, revealing the base. Settling is
300
+ idempotent per `requestId`, so a transport retry cannot apply the same change
301
+ twice.
302
+
303
+ A Remote mutation is an immediate, server-derived command: it runs now, against
304
+ the server that owns the data, and its result is cache. It is not durable
305
+ intent. An edit that must survive the process or the network belongs to
306
+ `foldkit-sync` and `foldkit-durable`, which own an ordered log and its
307
+ idempotency; Remote adds no second queue.
308
+
309
+ ## Live data
310
+
311
+ `Data.live` subscribes the Surfaces that read it to the entity's changes, from
312
+ the cursor the Model keeps (`RemoteModel.live`), so the application tracks no
313
+ cursor of its own. Events carry a monotonic cursor per stream: duplicates are
314
+ ignored, and an event ahead of the cursor is a gap — it is not applied, and the
315
+ stream is recorded in `RemoteModel.gaps` so the host can resync rather than
316
+ silently miss facts. The gap clears when an in-order event applies, or on a
317
+ `GapCleared` message. `EntityPatched` updates the store, `EntityDeleted` writes
318
+ a tombstone, and `ConnectionInsert`/`ConnectionRemove`/`ConnectionInvalidate`
319
+ change connection membership and ordering. A stream break arrives as a
320
+ `ReadFailed` Message like any other failure.
321
+
322
+ ## Persistence and hydration
323
+
324
+ A snapshot is the entity store and nothing else: connections' live cursors,
325
+ optimistic layers, the mutation ledger, gaps, and retention roots belong to the
326
+ session and never appear in one.
327
+
328
+ ```ts
329
+ // SSR: the server prefetches, dehydrates for the page, the client hydrates.
330
+ const html = RemotePersistence.dehydrate(Data.storeOf(loaded), { scope: userId })
331
+ Data.reduce(model, {
332
+ _tag: 'Hydrated',
333
+ entities: RemotePersistence.hydrate(html, { scope: userId }) ?? emptyStore,
334
+ merge: 'preserve-existing',
335
+ })
336
+
337
+ // A browser cache, through Effect's KeyValueStore:
338
+ RemotePersistence.save(store, { key: 'remote-cache', scope: userId, maxBytes: 512_000 })
339
+ RemotePersistence.restore({ key: 'remote-cache', scope: userId, maxBytes: 512_000 })
340
+ ```
341
+
342
+ `dehydrate` and `hydrate` are the string forms; `save` and `restore` put them
343
+ behind `KeyValueStore`, so the backend (memory, filesystem, Web Storage, SQL) is
344
+ the application's choice. The text is deterministic (equal stores give
345
+ byte-equal snapshots), a `scope` (a user, a tenant, a build) keeps one reader's
346
+ cache from another, and `maxBytes` bounds what is written and read. The cache is
347
+ server-derived and disposable: a snapshot that is oversized, another version,
348
+ another scope, or malformed is discarded (and its key removed) and the planner
349
+ refetches. `Hydrated` is a Message like every other cache change; `replace`
350
+ takes the snapshot's entries and `preserve-existing` keeps entries the store
351
+ already holds, which are at least as fresh. Hydrating the same snapshot twice is
352
+ the same as once.
353
+
354
+ ## Introspection
355
+
356
+ `Data.inspect(model)` returns a serializable summary of the cache — entities
357
+ with their present/stale fields, connection keys, live streams, gaps, and the
358
+ mutation ledger — and `Remote.inspectEntity(model, key)` returns one entity. Both
359
+ are pure, so DevTools never reach into the private layout. `Data.plan(model,
360
+ projection)` is the plan the read entry would run, and `Remote.planQueries`
361
+ the queries; both are plain data.
362
+
363
+ ## Advanced: the kernel
364
+
365
+ Everything above compiles to the exports below; nothing is a parallel
366
+ implementation. Reach for them in tooling, SSR, tests, a package that builds on
367
+ Remote, or an application that needs one piece by hand.
368
+
369
+ ### Descriptors and binding
370
+
371
+ `Remote.define({ entities, queries, mutations })` is the domain without a
372
+ place in a Model: `Model`, `initial`, `Message`, `update`, `rpc`, and a
373
+ name-keyed `registry` of the declared descriptors (consumed by
374
+ `RemoteServer.validate`). `Remote.at(definition, modelRef)` binds it to a
375
+ `ModelRef`; `Remote.make` is the two in one step. `Selection.make(Entity,
376
+ {…})`, `Selection.connection(Entity, window, nested?)`, and
377
+ `Entity.patch(ref, values)` are the kernel constructors behind the entity's
378
+ `select` and `patch` methods. `Remote.select(bound, selection)(id)` is
379
+ `Data.get`.
380
+
381
+ ### Requirements and the planner
382
+
383
+ A projection's `requirements` are plain data — entity, id, fields, a pagination
384
+ window per relation, and through `relations`, the slice required of each
385
+ relation's target — and its `connections` the query connections it reads
386
+ (`{ identity, window, select }`). `Remote.plan(bound, model, projection,
387
+ options?)` diffs them against the visible store and returns only the missing
388
+ or stale fields, deterministically: a relation whose field is being fetched
389
+ rides on the request so the server resolves the graph in one read, a relation
390
+ the store already holds is followed into concrete requirements for its
391
+ targets, and a known connection contributes its visible items' fields under
392
+ `select`. `Remote.planQueries` is the connections it does not hold, or holds
393
+ stale. `options` is a `PlanOptions`: `freshness` (`{ now, freshness }`)
394
+ refreshes an entry older than the window, `force` plans every field;
395
+ `RemotePolicy.toPlan(policy, now)` compiles a policy to it. `Remote.storeOf` is
396
+ the visible store — the base under the pending optimistic layers, computed once
397
+ per Model state so every read and plan of one render shares it; a read's
398
+ result is memoized per that store (and, for a page, per connection), so equal
399
+ reads of one Model state assemble and decode once and return one value — and
400
+ `Remote.prefetch(bound, model, projection, options?)` runs the entity plan and
401
+ returns the new store.
402
+
403
+ ### The reducer and its Messages
404
+
405
+ `Remote.update(remote, message)` (`Data.reduce` on the bound slice) is the one
406
+ pure reducer over `ReadReceived`/`ReadFailed`/`RefreshStarted`,
407
+ `MutationStarted`/`Succeeded`/`Failed`, `LiveReceived`/`GapCleared`,
408
+ `ConnectionMerged`/`Invalidated`/`Refreshed`/`QueryFailed`,
409
+ `RetentionChanged`, and `Hydrated`. `Remote.messages` is the case record for
410
+ `defineMessageUnion`, `Remote.reduces(message)` narrows an application's union
411
+ to those cases, and `Remote.writeRead(store, requests, result, now)` is the
412
+ store write a `ReadReceived` performs, for a caller that manages its own store.
413
+
414
+ ### The Subscription entries
415
+
416
+ `Data.subscriptions` is built from three kernel entries, each for one Surface
417
+ with fixed params and a `toMessage` that wraps the `RemoteMessage` in an
418
+ application union that does not spread `Remote.messages`:
419
+
420
+ ```ts
421
+ Remote.observe(bound, surface, params, toMessage?, options?) // plan + read + queries
422
+ Remote.live(bound, surface, params, toMessage?, options?) // the live stream, from the Model's cursor
423
+ Remote.retain(projections, toMessage?, { connections?, grace? }) // roots → RetentionChanged
424
+ ```
425
+
426
+ The read entry's dependencies are the plan (`{ requirements, queries }`); it
427
+ emits nothing when both are empty, `RefreshStarted` before a refreshing read,
428
+ and runs the entity read and the queries concurrently. Every Message it emits
429
+ changes the Model, so Foldkit recomputes the dependencies and restarts the
430
+ stream: a merged page's items are planned by that next computation, and a
431
+ read the restart interrupts is joined by the coalescer rather than repeated.
432
+ A page and its refresh are one `ConnectionMerged` (`refreshes: true`), since a
433
+ second Message could be lost to the restart. The retain entry's dependencies
434
+ are the roots (the projections' requirements, their connections with what
435
+ each page selects of its items, plus any `connections` listed by identity); it
436
+ emits `RetentionChanged` once the roots have been stable for `grace`, and
437
+ `Remote.update` applies the pure `gc(state, roots)`: a root entity, the targets
438
+ its retained fields refer to, the targets a nested relation selects, a retained
439
+ connection's edges and what its `select` reaches through them, and anything a
440
+ pending request touches survive; everything else is dropped, settled overlays
441
+ on a dropped connection included.
442
+
443
+ ### Connections by hand
444
+
445
+ A connection stores entity references in segments with explicit boundaries,
446
+ so an unloaded middle page is a gap rather than an implied adjacency; pages,
447
+ live inserts, and optimistic inserts are all evidence about the same structure
448
+ and merge through the reducer. A `QueryRef` is a server list operation with a
449
+ canonical identity (the descriptor plus the encoded input, excluding the
450
+ window), so `first(25)` and `after(cursor).first(25)` merge into one
451
+ connection.
452
+
453
+ ```ts
454
+ const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
455
+ const page = yield* Remote.query(ref) // Effect<QueryResult, RemoteQueryError, RemoteClient>
456
+ Remote.update(remote, Remote.queryMessage(ref, page)) // ConnectionMerged
457
+ Remote.visibleItems(remote, ref) // edges a view shows: overlays placed, removals and tombstones hidden
458
+ ```
459
+
460
+ `items`, `hasNext`, `hasPrevious`, and `isGapped` read the server-known region.
461
+ A merged page is newer than the settled overlays it covers: it drops a live
462
+ insert it carries and a live removal it contradicts, and leaves a pending
463
+ request's overlays alone; a replayed live event is a duplicate by cursor and
464
+ changes nothing. `ConnectionInvalidated` marks a connection stale while it
465
+ keeps showing its items; a `ConnectionMerged` with `refreshes: true`,
466
+ `ConnectionRefreshed`, or `QueryFailed` clears it.
467
+
468
+ ### Mutations by hand
469
+
470
+ `Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic })` is
471
+ the one-step imperative form (start, run, settle) for SSR and tests.
472
+ `Remote.mutate(mutation, input, requestId)` is the call itself: it decodes the
473
+ typed `Output` and returns the result's normalized `entities` and confirmed
474
+ `connections`, for a caller that reduces them through `Remote.update`. A
475
+ `MutationSucceeded` reconciles at most once per `requestId`; an unknown or
476
+ already-applied result is a no-op.
477
+
478
+ ### The transport seam
479
+
480
+ `RemoteClient` is an Effect service with `read`, `query`, `mutate`, and
481
+ `live`; the wire schemas (`ReadBatch`, `QueryRequest`, `MutationRequest`,
482
+ `LiveRequirement`, their results and errors) and the `RemoteRpc` group are
483
+ exported. `Remote.clientLayer(rpcClient, { window? })` adapts an Effect RPC
484
+ client, including the `LiveChange`-to-`LiveEvent` mapping; what the client
485
+ requires (a database under in-process `RemoteServer.handlers`) is supplied to
486
+ the layer with `Layer.provide`. Reads and queries through it coalesce:
487
+ requirements issued together become one `ReadBatch` (ids batched, fields
488
+ unioned), a requirement or an identical query already in flight is joined, a
489
+ failed read releases it, and a requirement that pages a relation reads alone
490
+ because a page answers exactly one window. `Remote.coalesced(layer, options)`
491
+ wraps a hand-written client the same way; `window` widens the batching delay
492
+ beyond "issued concurrently".
493
+
494
+ ## What it owns
495
+
496
+ - **Entity identity and references.** `Entity.make`, typed `EntityRef`s, and
497
+ reference codecs; an entity's `select` and `patch` methods (and `Entity.patch`)
498
+ type a selection or a patch against its fields.
499
+ - **Field selections.** `Selection.make` derives a Struct from the picked fields
500
+ and rejects unknown ones; a nested selection reads through a relation, and
501
+ the requirement carries the graph so one read resolves it.
502
+ - **The normalized store.** Values, per-field presence, staleness, and tombstones
503
+ are tracked separately, so `undefined`, `null`, absent, stale, and not-found
504
+ are distinct. Presence is never inferred from `value === undefined`.
505
+ - **The requirement planner.** `Remote.plan` diffs requirements against the
506
+ visible store and returns only missing or stale fields, deterministically.
507
+ - **The Remote submodel and the bound domain.** `Remote.Model`/`initial` are
508
+ the submodel; `Remote.define` returns `Message`, `update`, `rpc`, and a
509
+ name-keyed `registry`; `Remote.make` binds it and adds `get`, `live`,
510
+ `query`, `next`, `previous`, `fetch`, `subscriptions`, `plan`, `storeOf`,
511
+ `prefetch`, `mutate`, `reduce`, and `inspect`, each rejecting a descriptor
512
+ the domain never declared at compile time (a branded error naming it) and at
513
+ runtime. `Remote.update` is the single reducer over reads, mutation results,
514
+ live events, connections, and optimistic layers.
515
+ - **Mutation reconciliation.** Idempotent per `requestId`, with a bounded
516
+ settled-request ledger.
517
+ - **Connections.** Segmented ordered data with explicit boundaries and overlay
518
+ placement.
519
+ - **Queries.** `Data.query` reads a connection as a page of selected items and
520
+ carries the connection for the planner; `Data.next`/`previous`/`fetch` page
521
+ it. `Remote.query(ref)` encodes and runs a `QueryRef` by hand and
522
+ `Remote.queryMessage(ref, page)` merges the result into the connection keyed
523
+ by `ref.identity`.
524
+ - **Live classification.** Per-stream cursor ordering, duplicate suppression, and
525
+ gap detection; a gap clears when an in-order event applies or a `GapCleared`
526
+ message arrives.
527
+ - **Introspection.** `Remote.inspect` and `Remote.inspectEntity` return a pure,
528
+ serializable cache view for DevTools.
529
+ - **The transport seam.** `RemoteClient`, an Effect service with `read`, `query`,
530
+ `mutate`, and `live`; the wire schemas and `RemoteRpc` group.
531
+ `Remote.clientLayer(rpcClient)` adapts an Effect RPC client for `RemoteRpc` to
532
+ `RemoteClient`, including the `LiveChange`-to-`LiveEvent` mapping; what the
533
+ client requires (a database under in-process `RemoteServer.handlers`) is
534
+ supplied to the layer with `Layer.provide`.
535
+ - **Disposable cache persistence.** Deterministic, scoped, size-bounded
536
+ snapshots of the entity store: `dehydrate`/`hydrate` as text, `save`/`restore`
537
+ over `KeyValueStore`, and `Hydrated` to merge one into the Model.
538
+
539
+ ## Limits
540
+
541
+ - The transport is not part of the package. Effect RPC is the wire; the
542
+ application supplies the client and server protocol layers. `ReadBatch` and
543
+ `LiveRequirement` name `REMOTE_PROTOCOL_VERSION`; a server refuses another
544
+ version with `RemoteProtocolError`, so a wire change is a version bump, never
545
+ silent drift. A request names at most `MAX_FIELDS_PER_REQUEST` (256) fields
546
+ of one entity and nests relations at most `MAX_RELATION_DEPTH` (8) deep; the
547
+ wire refuses more at decode rather than truncating.
548
+ - The wire `LiveChange` union carries entity patches, deletes, and connection
549
+ insert/remove/invalidate changes; the client adapter reconstructs a `LiveEvent`
550
+ from it. `RemoteServer.live` serves them as a stream.
551
+ - Coalescing is per `RemoteClient` layer: two Remote domains with separate
552
+ layers do not share a batch. Retention keeps what the active Surfaces reach
553
+ (and what `Remote.retain` lists); a Surface that is neither active nor listed
554
+ loses its data on the next `RetentionChanged`.
555
+ - Two applications with the same Model type cannot be told apart by the types;
556
+ `Data.subscriptions` rejects a Surface of another application at runtime by
557
+ its owner token.
558
+ - A refreshing policy's `maxAge` applies to entities, which the store stamps
559
+ with a clock; a connection has no age, so a page is re-queried only when the
560
+ connection is invalidated (`ConnectionInvalidated`, a live
561
+ `ConnectionInvalidate`, or `networkOnly`'s `force`).
562
+ - `RemoteData` is a closed union.
563
+ - A selection picks at least one field: `Selection.make(User, {})` throws,
564
+ since it would require nothing and read `Ready` for any id.