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 +21 -0
- package/README.md +564 -0
- package/dist/index.d.mts +2000 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2919 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +46 -0
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.
|