foldkit-remote 0.2.0 → 0.3.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 +782 -447
- package/dist/index.d.mts +225 -114
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +253 -73
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,38 +1,134 @@
|
|
|
1
1
|
# `foldkit-remote`
|
|
2
2
|
|
|
3
|
-
Server data
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
[`foldkit-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
3
|
+
Server-owned data, cached **inside the Foldkit Model**.
|
|
4
|
+
|
|
5
|
+
A feature declares the server facts it needs. `foldkit-remote` compares those
|
|
6
|
+
requirements with what the Model already knows, fetches only what is missing or
|
|
7
|
+
stale, normalizes the result so every consumer shares one copy, and returns new
|
|
8
|
+
facts to the application as ordinary Messages.
|
|
9
|
+
|
|
10
|
+
The central rule is:
|
|
11
|
+
|
|
12
|
+
> **A Remote Projection does not fetch. It declares what server-owned facts a
|
|
13
|
+
> consumer requires. I/O happens outside render, and its results reduce back into
|
|
14
|
+
> the Model.**
|
|
15
|
+
|
|
16
|
+
There is no hidden mutable cache beside the application. A read result, query
|
|
17
|
+
page, mutation result, live event, hydration, and retention change all become
|
|
18
|
+
Remote Messages and move the same Model through one pure reducer.
|
|
19
|
+
|
|
20
|
+
Use Remote when the **server owns the truth** and the client needs a normalized,
|
|
21
|
+
disposable view of it. If an edit is client-authored and must survive offline,
|
|
22
|
+
restart, or network failure until it converges, that belongs to
|
|
23
|
+
[`foldkit-sync`](../sync) and [`foldkit-durable`](../durable), not Remote.
|
|
24
|
+
|
|
25
|
+
## Which state belongs here?
|
|
26
|
+
|
|
27
|
+
A useful rule across Foldkit Plus is **one owner per datum**:
|
|
28
|
+
|
|
29
|
+
| State | Owner |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Route, selected item, local form state, transient UI errors | the application Model and `update` |
|
|
32
|
+
| Server-derived facts that can be refetched | `foldkit-remote` |
|
|
33
|
+
| Client-authored state that must survive offline and converge | `foldkit-sync` |
|
|
34
|
+
| Local state represented in the URL or another store | the Model, observed by `foldkit-mirror` |
|
|
35
|
+
|
|
36
|
+
The distinction between Remote and Sync is especially important:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
Remote
|
|
40
|
+
server owns the fact
|
|
41
|
+
cache is disposable
|
|
42
|
+
refetch is recovery
|
|
43
|
+
|
|
44
|
+
Sync
|
|
45
|
+
client owns the edit
|
|
46
|
+
intent must survive offline/restart
|
|
47
|
+
replay + reconciliation is recovery
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A Remote mutation is therefore an immediate request against server-owned data.
|
|
51
|
+
Its result updates the cache. It is **not** a durable local intent queue.
|
|
52
|
+
|
|
53
|
+
A Surface may project all of these owners at once. Observation does not transfer
|
|
54
|
+
ownership.
|
|
55
|
+
|
|
56
|
+
## The mental model
|
|
57
|
+
|
|
58
|
+
The client side is a Foldkit Submodel:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
pure
|
|
62
|
+
Projection read
|
|
63
|
+
|
|
|
64
|
+
v
|
|
65
|
+
+-------------+ +---------+ +------+
|
|
66
|
+
| Foldkit |---->| Surface |----->| View |
|
|
67
|
+
| Model | +---------+ +------+
|
|
68
|
+
| |
|
|
69
|
+
| Remote.Model|
|
|
70
|
+
| normalized |
|
|
71
|
+
| cache |
|
|
72
|
+
+------+------+
|
|
73
|
+
^
|
|
74
|
+
| Remote Message
|
|
75
|
+
|
|
|
76
|
+
+------+-------+
|
|
77
|
+
| Data.reduce |
|
|
78
|
+
+------+-------+
|
|
79
|
+
^
|
|
80
|
+
|
|
|
81
|
+
| read/query/mutate/live
|
|
82
|
+
|
|
|
83
|
+
+------+--------+
|
|
84
|
+
| RemoteClient |
|
|
85
|
+
+------+--------+
|
|
86
|
+
|
|
|
87
|
+
v
|
|
88
|
+
server
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The other half of the model is the **requirement planner**:
|
|
92
|
+
|
|
93
|
+
```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.
|
|
119
|
+
|
|
120
|
+
## Four pieces to remember
|
|
121
|
+
|
|
122
|
+
Most application code only needs four roles:
|
|
123
|
+
|
|
124
|
+
| Piece | Responsibility |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `Data.get` / `Data.live` / `Data.query` | create pure Projections that declare what a consumer needs |
|
|
127
|
+
| `Data.subscriptions` | turn the requirements of **active** Surfaces into reads, live subscriptions, and retention roots |
|
|
128
|
+
| `Data.reduce` | reduce returned Remote Messages into the application's `Remote.Model` |
|
|
129
|
+
| `RemoteClient` | perform the actual read/query/mutate/live I/O |
|
|
130
|
+
|
|
131
|
+
Everything else builds on those four pieces.
|
|
36
132
|
|
|
37
133
|
## Install
|
|
38
134
|
|
|
@@ -40,225 +136,286 @@ application code needs only the first.
|
|
|
40
136
|
pnpm add foldkit-remote
|
|
41
137
|
```
|
|
42
138
|
|
|
43
|
-
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with
|
|
44
|
-
The server
|
|
45
|
-
|
|
46
|
-
## Quick start
|
|
139
|
+
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
|
|
140
|
+
package. The server-side interpreter is [`foldkit-remote-server`](../remote-server).
|
|
47
141
|
|
|
48
|
-
|
|
49
|
-
owner's projects, and renames a project.
|
|
142
|
+
## Sixty seconds: one entity, one Surface
|
|
50
143
|
|
|
51
|
-
|
|
144
|
+
Start with one server entity and one selection:
|
|
52
145
|
|
|
53
146
|
```ts
|
|
54
147
|
import { Schema } from 'effect'
|
|
55
|
-
import { Entity
|
|
148
|
+
import { Entity } from 'foldkit-remote'
|
|
56
149
|
|
|
57
|
-
const User = Entity.make('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
58
150
|
const Project = Entity.make(
|
|
59
151
|
'Project',
|
|
60
152
|
Schema.Struct({
|
|
61
153
|
id: Schema.String,
|
|
62
154
|
name: Schema.String,
|
|
63
155
|
status: Schema.String,
|
|
64
|
-
owner: Entity.ref(User),
|
|
65
156
|
}),
|
|
66
157
|
)
|
|
67
158
|
|
|
68
|
-
|
|
69
|
-
const ProjectSummary = Project.select({
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Input: { ownerId: Schema.String },
|
|
73
|
-
Result: Project,
|
|
74
|
-
})
|
|
75
|
-
const RenameProject = Mutation.make('RenameProject', {
|
|
76
|
-
Input: { id: Schema.String, name: Schema.String },
|
|
77
|
-
Output: { id: Schema.String },
|
|
159
|
+
// A Selection is the exact server-owned shape this consumer needs.
|
|
160
|
+
const ProjectSummary = Project.select({
|
|
161
|
+
id: true,
|
|
162
|
+
name: true,
|
|
78
163
|
})
|
|
79
164
|
```
|
|
80
165
|
|
|
81
|
-
|
|
82
|
-
never an inline target schema, so a recursive relation (`Node.parent:
|
|
83
|
-
Entity.refTo('Node')`) stays finite. `Project.select` picks fields and reads
|
|
84
|
-
**through** a relation into its target (`owner: UserSummary`); an unknown field
|
|
85
|
-
or a nested selection of the wrong entity is a compile error. `Input`,
|
|
86
|
-
`Output`, and `Result` take a codec or the fields of the `Schema.Struct` it
|
|
87
|
-
would be; `Result: Project` is a connection over `Project`.
|
|
88
|
-
|
|
89
|
-
### 2. Embed the submodel and bind the domain
|
|
166
|
+
Embed Remote's Submodel in the application and bind the domain to that field:
|
|
90
167
|
|
|
91
168
|
```ts
|
|
92
169
|
import { defineMessageUnion } from 'foldkit/message'
|
|
93
170
|
import type * as Update from 'foldkit/update'
|
|
94
|
-
import { Remote } from 'foldkit-remote'
|
|
171
|
+
import { Remote, type RemoteClient } from 'foldkit-remote'
|
|
95
172
|
import { Surface } from 'foldkit-surface'
|
|
96
173
|
|
|
97
|
-
// Your own routes; this one has a project route carrying the id the page reads.
|
|
98
174
|
const Route = Schema.Union([
|
|
99
175
|
Schema.Struct({ _tag: Schema.Literal('home') }),
|
|
100
|
-
Schema.Struct({
|
|
176
|
+
Schema.Struct({
|
|
177
|
+
_tag: Schema.Literal('project'),
|
|
178
|
+
projectId: Schema.String,
|
|
179
|
+
}),
|
|
101
180
|
])
|
|
102
181
|
|
|
103
|
-
const Model = Schema.Struct({
|
|
182
|
+
const Model = Schema.Struct({
|
|
183
|
+
route: Route,
|
|
184
|
+
remote: Remote.Model,
|
|
185
|
+
})
|
|
104
186
|
type Model = typeof Model.Type
|
|
187
|
+
|
|
105
188
|
const Message = defineMessageUnion({
|
|
106
189
|
...Remote.messages,
|
|
107
|
-
ClickedRename: { id: Schema.String, name: Schema.String },
|
|
108
|
-
ClickedMore: {},
|
|
109
190
|
})
|
|
110
191
|
type Message = typeof Message.Type
|
|
111
192
|
|
|
193
|
+
function update(
|
|
194
|
+
model: Model,
|
|
195
|
+
message: Message,
|
|
196
|
+
): Update.Return<Model, Message, RemoteClient> {
|
|
197
|
+
// Remote owns the `remote` Submodel, so all Remote Messages go through its reducer.
|
|
198
|
+
if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
|
|
199
|
+
return { model }
|
|
200
|
+
}
|
|
201
|
+
|
|
112
202
|
const App = Surface.application({
|
|
113
203
|
Model,
|
|
114
204
|
Message,
|
|
115
|
-
initial: {
|
|
116
|
-
|
|
205
|
+
initial: {
|
|
206
|
+
route: { _tag: 'home' },
|
|
207
|
+
remote: Remote.initial,
|
|
208
|
+
},
|
|
209
|
+
update,
|
|
117
210
|
})
|
|
118
211
|
|
|
119
212
|
const Data = Remote.make({
|
|
120
|
-
model: App.
|
|
121
|
-
entities: [
|
|
122
|
-
queries: [ProjectsByOwner],
|
|
123
|
-
mutations: [RenameProject],
|
|
213
|
+
model: App.fields.remote,
|
|
214
|
+
entities: [Project],
|
|
124
215
|
})
|
|
125
216
|
```
|
|
126
217
|
|
|
127
|
-
`Remote.Model` is
|
|
128
|
-
Model
|
|
129
|
-
|
|
130
|
-
knows the Model, the place of the store in it, and the entities, queries, and
|
|
131
|
-
mutations it may be asked about — anything else is a compile error naming the
|
|
132
|
-
descriptor (`Entity "Team" is not registered with this Remote domain`), and a
|
|
133
|
-
runtime error naming the domain.
|
|
218
|
+
`Remote.Model` is not a second application store. It is a Submodel **inside** the
|
|
219
|
+
application Model. `Data` is the bound API for this domain: it knows where that
|
|
220
|
+
Submodel lives and which descriptors this application registered.
|
|
134
221
|
|
|
135
|
-
|
|
222
|
+
Now declare what a feature needs:
|
|
136
223
|
|
|
137
224
|
```ts
|
|
138
|
-
const projects = Data.query(ProjectsByOwner, { ownerId: 'u1' }, { select: ProjectSummary, first: 25 })
|
|
139
|
-
|
|
140
225
|
const ProjectPage = App.surface('ProjectPage', {
|
|
141
226
|
params: { projectId: Schema.String },
|
|
142
|
-
|
|
143
|
-
|
|
227
|
+
|
|
228
|
+
model: ({ params }) => ({
|
|
229
|
+
// Important: this performs no I/O.
|
|
230
|
+
// It is a pure Projection over Model plus a requirement for these fields.
|
|
231
|
+
project: Data.get(ProjectSummary, params.projectId),
|
|
232
|
+
}),
|
|
144
233
|
})
|
|
145
234
|
```
|
|
146
235
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
remote value is a `RemoteData`:
|
|
150
|
-
|
|
151
|
-
- `Initial` — a selected field is not present, and **nothing is fetching it**,
|
|
152
|
-
- `Loading` — not present, and a read is in flight,
|
|
153
|
-
- `Ready` — present,
|
|
154
|
-
- `Refreshing` — present, and being refetched (the value stays visible),
|
|
155
|
-
- `Failed` — the stored value did not decode against the selection,
|
|
156
|
-
- `NotFound` — the entity is a tombstone.
|
|
236
|
+
`ProjectPage` reads a `RemoteData<{ id: string; name: string }>` from the Model.
|
|
237
|
+
If those fields are absent, the Projection still does not fetch them.
|
|
157
238
|
|
|
158
|
-
|
|
159
|
-
flight. The distinction from `Initial` is worth rendering differently, because
|
|
160
|
-
nothing fetches a projection no active Surface observes. Such a projection reads
|
|
161
|
-
`Initial` forever, and showing a spinner for it hides the wiring mistake, while
|
|
162
|
-
`Loading` is the state where waiting is the right thing to do.
|
|
163
|
-
|
|
164
|
-
`RemoteData.match` is exhaustive — omitting a case is a compile error. `Data.get` reads once and refreshes by policy; `Data.live` also
|
|
165
|
-
follows the entity's changes; `Data.query` reads a connection as a `Page` of
|
|
166
|
-
selected items, `Initial` until the page and every item's fields are present.
|
|
167
|
-
|
|
168
|
-
### 4. Fetch, subscribe, and retain from the active Surfaces
|
|
239
|
+
The active Surface drives I/O through a Foldkit Subscription:
|
|
169
240
|
|
|
170
241
|
```ts
|
|
171
242
|
import * as Subscription from 'foldkit/subscription'
|
|
172
|
-
import { RemoteClient, RemotePolicy } from 'foldkit-remote'
|
|
173
243
|
|
|
174
244
|
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
175
|
-
Data.subscriptions(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
),
|
|
245
|
+
Data.subscriptions({
|
|
246
|
+
project: Surface.at(ProjectPage, model =>
|
|
247
|
+
model.route._tag === 'project'
|
|
248
|
+
? { projectId: model.route.projectId }
|
|
249
|
+
: undefined,
|
|
250
|
+
),
|
|
251
|
+
}),
|
|
183
252
|
)
|
|
184
253
|
```
|
|
185
254
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
255
|
+
`Surface.at` makes activation a fact of the Model. When the route activates the
|
|
256
|
+
page, Remote sees its requirements, diffs them against the cache, and fetches
|
|
257
|
+
only what is missing. When the page is inactive, it creates no read work.
|
|
258
|
+
|
|
259
|
+
The complete loop is:
|
|
260
|
+
|
|
261
|
+
```text
|
|
262
|
+
ProjectPage Projection
|
|
263
|
+
|
|
|
264
|
+
| requirements
|
|
265
|
+
v
|
|
266
|
+
Data.subscriptions
|
|
267
|
+
|
|
|
268
|
+
| plan missing fields
|
|
269
|
+
v
|
|
270
|
+
RemoteClient
|
|
271
|
+
|
|
|
272
|
+
| server result
|
|
273
|
+
v
|
|
274
|
+
Remote Message
|
|
275
|
+
|
|
|
276
|
+
v
|
|
277
|
+
Data.reduce
|
|
278
|
+
|
|
|
279
|
+
v
|
|
280
|
+
Remote.Model
|
|
281
|
+
|
|
|
282
|
+
v
|
|
283
|
+
ProjectPage now reads Ready(...)
|
|
284
|
+
```
|
|
192
285
|
|
|
193
|
-
|
|
286
|
+
Finally, provide the client implementation to the runtime:
|
|
194
287
|
|
|
195
288
|
```ts
|
|
196
|
-
|
|
197
|
-
if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
|
|
198
|
-
switch (message._tag) {
|
|
199
|
-
case 'ClickedRename': {
|
|
200
|
-
const { id, name } = message
|
|
201
|
-
const { model: started, command } = Data.mutate(model, RenameProject, { id, name }, {
|
|
202
|
-
optimistic: [Project.patch(id, { name })],
|
|
203
|
-
})
|
|
204
|
-
return { model: started, commands: [command] }
|
|
205
|
-
}
|
|
206
|
-
case 'ClickedMore': {
|
|
207
|
-
const next = Data.next(model, projects)
|
|
208
|
-
return { model, commands: next === undefined ? [] : [Data.fetch(next)] }
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
289
|
+
const clientLayer = Remote.clientLayer(rpcClient)
|
|
212
290
|
```
|
|
213
291
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
resource its Commands need (`Update.Return<Model, Message, RemoteClient>`) and
|
|
218
|
-
the runtime is given the client layer (step 6). `Data.mutate` starts the request in the Model (its id comes from the
|
|
219
|
-
Model's own sequence, so `update` stays pure) and returns the Command whose
|
|
220
|
-
Message settles it; the optimistic patch shows until then. `Data.next` is the
|
|
221
|
-
`QueryRef` of the page after the loaded end, or `undefined`, and `Data.fetch`
|
|
222
|
-
the Command that merges it, after which the same `projects` projection reads
|
|
223
|
-
every loaded page.
|
|
292
|
+
`Remote.clientLayer` adapts an Effect RPC client for `RemoteRpc` into the
|
|
293
|
+
`RemoteClient` service used by subscriptions and Commands. The transport itself
|
|
294
|
+
is not owned by Remote.
|
|
224
295
|
|
|
225
|
-
|
|
296
|
+
## `RemoteData`: what does the Model know right now?
|
|
297
|
+
|
|
298
|
+
A Remote Projection never lies by pretending an absent value is present. It
|
|
299
|
+
returns a closed `RemoteData` union:
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
no active fetch
|
|
303
|
+
missing ------------------------------------------------> Initial
|
|
304
|
+
|
|
305
|
+
fetch starts
|
|
306
|
+
missing ------------------------------------------------> Loading
|
|
307
|
+
|
|
|
308
|
+
result
|
|
309
|
+
v
|
|
310
|
+
Ready
|
|
311
|
+
|
|
|
312
|
+
refetch starts
|
|
313
|
+
v
|
|
314
|
+
Refreshing
|
|
315
|
+
old value stays
|
|
316
|
+
|
|
|
317
|
+
result
|
|
318
|
+
v
|
|
319
|
+
Ready
|
|
320
|
+
|
|
321
|
+
server says entity is absent ---------------------------> NotFound
|
|
322
|
+
stored value fails Selection decoding -----------------> Failed
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The states are:
|
|
326
|
+
|
|
327
|
+
- **`Initial`** — required data is absent and **nothing is currently fetching it**.
|
|
328
|
+
- **`Loading`** — required data is absent and a read is in flight.
|
|
329
|
+
- **`Ready`** — every selected field is present and decodes.
|
|
330
|
+
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
331
|
+
- **`Failed`** — stored server data does not decode against the Selection.
|
|
332
|
+
- **`NotFound`** — the entity is represented by a tombstone.
|
|
333
|
+
|
|
334
|
+
`Initial` is intentionally different from `Loading`. A Projection belonging to
|
|
335
|
+
no active Surface may remain `Initial` forever. Rendering a spinner for
|
|
336
|
+
`Initial` can therefore hide an activation/wiring mistake; `Loading` is the
|
|
337
|
+
state that actually means "wait for this request."
|
|
338
|
+
|
|
339
|
+
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
340
|
+
compile time.
|
|
341
|
+
|
|
342
|
+
## Normalized entities and selections
|
|
343
|
+
|
|
344
|
+
Remote stores an entity once by identity, regardless of how many Surfaces read
|
|
345
|
+
it. Consumers select different views of the same normalized fact.
|
|
346
|
+
|
|
347
|
+
Relations are references, not nested copies:
|
|
226
348
|
|
|
227
349
|
```ts
|
|
228
|
-
const
|
|
229
|
-
|
|
350
|
+
const User = Entity.make(
|
|
351
|
+
'User',
|
|
352
|
+
Schema.Struct({
|
|
353
|
+
id: Schema.String,
|
|
354
|
+
name: Schema.String,
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const Project = Entity.make(
|
|
359
|
+
'Project',
|
|
360
|
+
Schema.Struct({
|
|
361
|
+
id: Schema.String,
|
|
362
|
+
name: Schema.String,
|
|
363
|
+
owner: Entity.ref(User),
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const UserSummary = User.select({ id: true, name: true })
|
|
368
|
+
const ProjectSummary = Project.select({
|
|
369
|
+
id: true,
|
|
370
|
+
name: true,
|
|
371
|
+
owner: UserSummary,
|
|
372
|
+
})
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The store remains normalized:
|
|
376
|
+
|
|
377
|
+
```text
|
|
378
|
+
Project:p1 name PRESENT owner PRESENT -> User:u7
|
|
379
|
+
User:u7 name PRESENT
|
|
230
380
|
```
|
|
231
381
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
`
|
|
259
|
-
`
|
|
260
|
-
|
|
261
|
-
|
|
382
|
+
The Projection assembles the nested consumer value by following the reference.
|
|
383
|
+
An unknown field or a nested Selection for the wrong entity is a type error.
|
|
384
|
+
Recursive relations remain finite because the entity schema stores references
|
|
385
|
+
(`Entity.ref(User)` / `Entity.refTo('Node')`), not recursively inlined schemas.
|
|
386
|
+
|
|
387
|
+
Presence is tracked separately from the JavaScript value. These are distinct:
|
|
388
|
+
|
|
389
|
+
```text
|
|
390
|
+
field missing
|
|
391
|
+
field present with undefined
|
|
392
|
+
field present with null
|
|
393
|
+
field stale
|
|
394
|
+
entity not found
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Remote never infers presence from `value === undefined`.
|
|
398
|
+
|
|
399
|
+
## Reading, policies, and prefetch
|
|
400
|
+
|
|
401
|
+
`Data.get(selection, id)` returns a pure Projection. `Data.live(selection, id)`
|
|
402
|
+
returns the same kind of Projection but marks its requirements as live so
|
|
403
|
+
`Data.subscriptions` also follows server changes.
|
|
404
|
+
|
|
405
|
+
What Remote should do when the Model already contains the selected fields is a
|
|
406
|
+
`RemotePolicy`:
|
|
407
|
+
|
|
408
|
+
- `RemotePolicy.cacheFirst` (default) — fetch missing, stale, or re-windowed data.
|
|
409
|
+
- `RemotePolicy.staleWhileRevalidate({ maxAge })` — keep a present value visible
|
|
410
|
+
and refetch when it is older than `maxAge`.
|
|
411
|
+
- `RemotePolicy.networkOnly` — request every selected field; cached values stay
|
|
412
|
+
visible while the request is in flight.
|
|
413
|
+
|
|
414
|
+
A refreshing policy emits `RefreshStarted` before the read, so the Projection
|
|
415
|
+
becomes `Refreshing` without discarding the old value. Planning accepts `now`
|
|
416
|
+
(default `Date.now`) as an input, so tests can control time.
|
|
417
|
+
|
|
418
|
+
For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
|
|
262
419
|
|
|
263
420
|
```ts
|
|
264
421
|
import { Effect } from 'effect'
|
|
@@ -270,344 +427,522 @@ const loaded = await Effect.runPromise(
|
|
|
270
427
|
)
|
|
271
428
|
```
|
|
272
429
|
|
|
273
|
-
|
|
430
|
+
`Data.prefetch` performs I/O explicitly and returns the Model with the resulting
|
|
431
|
+
Remote Messages reduced into it. It never changes the semantics of the
|
|
432
|
+
Projection itself.
|
|
433
|
+
|
|
434
|
+
### Refreshing from `update`
|
|
435
|
+
|
|
436
|
+
To revalidate what a screen already declares — a refresh button, a focus
|
|
437
|
+
regained — hand its Projection (or a Surface without params) to `Data.refresh`:
|
|
274
438
|
|
|
275
439
|
```ts
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
Data.
|
|
280
|
-
|
|
440
|
+
case 'ClickedRefresh': {
|
|
441
|
+
if (model.route._tag !== 'project') return { model }
|
|
442
|
+
const page = ProjectPage.projection({ projectId: model.route.projectId })
|
|
443
|
+
return { model: Data.refresh(model, page) }
|
|
444
|
+
}
|
|
281
445
|
```
|
|
282
446
|
|
|
283
|
-
`
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
447
|
+
`Data.refresh` performs no I/O and restates no request. It returns the Model
|
|
448
|
+
with every selected field the store holds reading `Refreshing` and every loaded
|
|
449
|
+
connection invalidated; the `Data.subscriptions` read entries then refetch it,
|
|
450
|
+
since stale data is planned again under every policy, so the page is requested
|
|
451
|
+
once. The Projection must be observed, as it is while it is on screen; for data
|
|
452
|
+
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
453
|
+
|
|
454
|
+
- A refreshed connection's first page replaces its loaded pages, so items the
|
|
455
|
+
server removed or reordered follow it; later pages are fetched again with
|
|
456
|
+
`Data.next`.
|
|
457
|
+
- A read already in flight restarts instead of landing after the refresh.
|
|
458
|
+
- Refreshing what is already refreshing, or nothing, returns the same Model.
|
|
459
|
+
|
|
460
|
+
## Queries and pagination
|
|
461
|
+
|
|
462
|
+
Entities answer "which fields of this known thing?" A Query answers "which
|
|
463
|
+
entities belong in this server-owned list?"
|
|
464
|
+
|
|
465
|
+
Declare the query and register it with the domain:
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { Query } from 'foldkit-remote'
|
|
469
|
+
|
|
470
|
+
const ProjectsByOwner = Query.make('ProjectsByOwner', {
|
|
471
|
+
Input: { ownerId: Schema.String },
|
|
472
|
+
Result: Project,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const Data = Remote.make({
|
|
476
|
+
model: App.fields.remote,
|
|
477
|
+
entities: [User, Project],
|
|
478
|
+
queries: [ProjectsByOwner],
|
|
479
|
+
})
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Then a query is still just a Projection:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
const projects = Data.query(
|
|
486
|
+
ProjectsByOwner,
|
|
487
|
+
{ ownerId },
|
|
488
|
+
{ select: ProjectSummary, first: 25 },
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
projects.read(model)
|
|
492
|
+
// RemoteData<Page<ProjectSummary>>
|
|
493
|
+
|
|
494
|
+
Data.next(model, projects) // QueryRef | undefined
|
|
495
|
+
Data.previous(model, projects) // QueryRef | undefined
|
|
496
|
+
Data.fetch(ref) // Command whose Message merges the page
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The connection stores entity references and explicit boundaries rather than one
|
|
500
|
+
flat array. That matters when page 1 and page 3 are loaded but page 2 is not:
|
|
501
|
+
Remote represents an honest gap instead of pretending the visible rows are
|
|
502
|
+
adjacent.
|
|
503
|
+
|
|
504
|
+
A query Projection is `Initial` until the page **and every selected field of its
|
|
505
|
+
visible items** are present. Once a page lands, those items become ordinary
|
|
506
|
+
entity requirements and can be fulfilled in the same planning loop.
|
|
507
|
+
|
|
508
|
+
Two Projections of the same connection plan one query and select the union of
|
|
509
|
+
their required fields. `Data.next` / `Data.previous` preserve the page size and
|
|
510
|
+
use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
|
|
511
|
+
not from guessing based on row counts.
|
|
512
|
+
|
|
513
|
+
## Mutations and optimistic state
|
|
514
|
+
|
|
515
|
+
Register mutations on the domain:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import { Mutation } from 'foldkit-remote'
|
|
519
|
+
|
|
520
|
+
const RenameProject = Mutation.make('RenameProject', {
|
|
521
|
+
Input: { id: Schema.String, name: Schema.String },
|
|
522
|
+
Output: { id: Schema.String },
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
A mutation starts from ordinary application `update`:
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
case 'ClickedRename': {
|
|
530
|
+
const { id, name } = message
|
|
531
|
+
|
|
532
|
+
const { model: started, command } = Data.mutate(
|
|
533
|
+
model,
|
|
534
|
+
RenameProject,
|
|
535
|
+
{ id, name },
|
|
536
|
+
{
|
|
537
|
+
optimistic: [Project.patch(id, { name })],
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
return { model: started, commands: [command] }
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
`Data.mutate` does two things:
|
|
546
|
+
|
|
547
|
+
1. it applies `MutationStarted` to the Model, including any optimistic overlays;
|
|
548
|
+
2. it returns the Command that calls `RemoteClient` and eventually emits
|
|
549
|
+
`MutationSucceeded` or `MutationFailed`.
|
|
550
|
+
|
|
551
|
+
Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
552
|
+
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
553
|
+
Settling is idempotent per `requestId`.
|
|
554
|
+
|
|
555
|
+
Optimistic entity patches are **layers over the base store**, not inverse
|
|
556
|
+
patches. If multiple mutations overlap, the visible cache is recomputed as base
|
|
557
|
+
plus the still-pending layers, so settling one does not require trying to undo
|
|
558
|
+
its old value manually.
|
|
559
|
+
|
|
560
|
+
Connections can be optimistic too:
|
|
305
561
|
|
|
306
562
|
```ts
|
|
307
563
|
import { ConnectionChange } from 'foldkit-remote'
|
|
308
564
|
|
|
309
|
-
const { model: started,
|
|
565
|
+
const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
310
566
|
optimistic: ({ tempId }) => [
|
|
311
567
|
Comment.patch(tempId, { id: tempId, body: input.body }),
|
|
312
|
-
ConnectionChange.prepend(
|
|
568
|
+
ConnectionChange.prepend(
|
|
569
|
+
CommentsForPost.ref({ postId }),
|
|
570
|
+
Comment.ref(tempId),
|
|
571
|
+
),
|
|
313
572
|
],
|
|
314
573
|
})
|
|
315
|
-
return { model: started, commands: [command] }
|
|
316
574
|
```
|
|
317
575
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
intent. An edit that must survive the process or the network belongs to
|
|
342
|
-
`foldkit-sync` and `foldkit-durable`, which own an ordered log and its
|
|
343
|
-
idempotency; Remote adds no second queue.
|
|
576
|
+
A successful server result can replace the request's temporary connection
|
|
577
|
+
overlays with confirmed ones in place.
|
|
578
|
+
|
|
579
|
+
### Remote mutation vs Sync operation
|
|
580
|
+
|
|
581
|
+
Do not use Remote mutation as a durable offline-write mechanism:
|
|
582
|
+
|
|
583
|
+
```text
|
|
584
|
+
Remote mutation
|
|
585
|
+
request server now
|
|
586
|
+
server owns truth
|
|
587
|
+
result updates disposable cache
|
|
588
|
+
no durable outbox
|
|
589
|
+
|
|
590
|
+
Sync operation
|
|
591
|
+
client authored the durable fact/intent
|
|
592
|
+
survives offline/restart
|
|
593
|
+
enters an ordered log
|
|
594
|
+
converges with other replicas
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
If losing an unsent edit would be data loss, it belongs to Sync rather than
|
|
598
|
+
Remote.
|
|
344
599
|
|
|
345
600
|
## Live data
|
|
346
601
|
|
|
347
|
-
`Data.live`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
602
|
+
`Data.live` marks a Projection so active `Data.subscriptions` follow server
|
|
603
|
+
changes for it.
|
|
604
|
+
|
|
605
|
+
The Remote Model owns each stream's cursor. Live events carry monotonically
|
|
606
|
+
ordered cursors:
|
|
607
|
+
|
|
608
|
+
- an old/repeated cursor is a duplicate and is ignored;
|
|
609
|
+
- the next cursor is applied;
|
|
610
|
+
- a cursor ahead of the expected value is a **gap** and is not applied.
|
|
611
|
+
|
|
612
|
+
A gap is recorded in `RemoteModel.gaps` so the host can resynchronize rather than
|
|
613
|
+
silently accepting missing history. An in-order event or `GapCleared` clears it.
|
|
614
|
+
|
|
615
|
+
Entity patches update normalized fields, deletes create tombstones, and
|
|
616
|
+
connection insert/remove/invalidate events reconcile the same connection model
|
|
617
|
+
used by query pages and optimistic overlays. A stream failure becomes a Remote
|
|
618
|
+
failure Message rather than mutating anything out of band.
|
|
619
|
+
|
|
620
|
+
## Retention and garbage collection
|
|
621
|
+
|
|
622
|
+
Remote is a cache, so it should be allowed to forget facts no active feature
|
|
623
|
+
needs.
|
|
624
|
+
|
|
625
|
+
`Data.subscriptions` derives retention roots from the active Surfaces. A root
|
|
626
|
+
keeps:
|
|
627
|
+
|
|
628
|
+
- the entities and fields its Projection requires;
|
|
629
|
+
- referenced targets reached by nested selections;
|
|
630
|
+
- retained connections and the selected fields of their visible items;
|
|
631
|
+
- data touched by pending optimistic work.
|
|
632
|
+
|
|
633
|
+
After the configured grace period, `RetentionChanged` enters the Model and the
|
|
634
|
+
pure Remote reducer collects unreachable cache data. An inactive Surface that is
|
|
635
|
+
not otherwise retained may therefore lose its cache, which is safe because the
|
|
636
|
+
server remains authoritative and the planner can refetch it.
|
|
637
|
+
|
|
638
|
+
This is another important difference from Sync: **Remote recovery may discard
|
|
639
|
+
and refetch; Sync recovery must preserve unsent client intent.**
|
|
357
640
|
|
|
358
641
|
## Persistence and hydration
|
|
359
642
|
|
|
360
|
-
|
|
361
|
-
optimistic layers,
|
|
362
|
-
session and never appear in one.
|
|
643
|
+
Persist only the disposable entity cache, not session machinery such as live
|
|
644
|
+
cursors, optimistic layers, mutation bookkeeping, gaps, or retention roots.
|
|
363
645
|
|
|
364
646
|
```ts
|
|
365
647
|
import { emptyStore, RemotePersistence } from 'foldkit-remote'
|
|
366
648
|
|
|
367
|
-
// SSR: the server
|
|
368
|
-
const snapshot = RemotePersistence.dehydrate(
|
|
649
|
+
// SSR: prefetch on the server, embed, then hydrate on the client.
|
|
650
|
+
const snapshot = RemotePersistence.dehydrate(
|
|
651
|
+
Data.storeOf(loaded),
|
|
652
|
+
{ scope: userId },
|
|
653
|
+
)
|
|
654
|
+
|
|
369
655
|
Data.reduce(model, {
|
|
370
656
|
_tag: 'Hydrated',
|
|
371
|
-
entities:
|
|
657
|
+
entities:
|
|
658
|
+
RemotePersistence.hydrate(snapshot, { scope: userId }) ?? emptyStore,
|
|
372
659
|
merge: 'preserve-existing',
|
|
373
660
|
})
|
|
374
661
|
|
|
375
|
-
//
|
|
376
|
-
RemotePersistence.save(store, {
|
|
377
|
-
|
|
662
|
+
// Or persist through Effect's KeyValueStore.
|
|
663
|
+
RemotePersistence.save(store, {
|
|
664
|
+
key: 'remote-cache',
|
|
665
|
+
scope: userId,
|
|
666
|
+
maxBytes: 512_000,
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
RemotePersistence.restore({
|
|
670
|
+
key: 'remote-cache',
|
|
671
|
+
scope: userId,
|
|
672
|
+
maxBytes: 512_000,
|
|
673
|
+
})
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Snapshots are deterministic, versioned, scoped, and optionally size-bounded. A
|
|
677
|
+
snapshot from another version/scope, an oversized snapshot, or malformed data is
|
|
678
|
+
discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
|
|
679
|
+
hydration still changes the application through the reducer.
|
|
680
|
+
|
|
681
|
+
## How the server packages fit
|
|
682
|
+
|
|
683
|
+
`foldkit-remote` owns **client cache semantics**, not your source database or
|
|
684
|
+
network transport.
|
|
685
|
+
|
|
686
|
+
```text
|
|
687
|
+
client
|
|
688
|
+
|
|
689
|
+
Surface Projection
|
|
690
|
+
|
|
|
691
|
+
v
|
|
692
|
+
foldkit-remote
|
|
693
|
+
requirements + normalized cache
|
|
694
|
+
|
|
|
695
|
+
v
|
|
696
|
+
RemoteClient / Effect RPC
|
|
697
|
+
|
|
698
|
+
---------------- server boundary ----------------
|
|
699
|
+
|
|
700
|
+
foldkit-remote-server
|
|
701
|
+
Sources + field authorization
|
|
702
|
+
|
|
|
703
|
+
+---------------------+
|
|
704
|
+
| |
|
|
705
|
+
v v
|
|
706
|
+
foldkit-remote-drizzle hand-written Source
|
|
707
|
+
(optional SQL compiler) API / service / database
|
|
378
708
|
```
|
|
379
709
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
server
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
710
|
+
The package roles are:
|
|
711
|
+
|
|
712
|
+
| Package | Responsibility |
|
|
713
|
+
| --- | --- |
|
|
714
|
+
| `foldkit-remote` | normalized cache, requirements, planning, queries, optimistic overlays, live cursors, retention |
|
|
715
|
+
| `foldkit-remote-server` | interpret client selections/queries against server Sources and enforce semantic-field authorization |
|
|
716
|
+
| `foldkit-remote-drizzle` | optionally compile those selections and queries into typed Drizzle access |
|
|
717
|
+
| Effect RPC / your layers | transport and deployment |
|
|
718
|
+
|
|
719
|
+
`Remote.clientLayer(rpcClient)` adapts `RemoteRpc` to `RemoteClient` and
|
|
720
|
+
coalesces compatible reads/queries. HTTP, WebSocket, worker, and in-process
|
|
721
|
+
execution are layer choices rather than Remote semantics.
|
|
722
|
+
|
|
723
|
+
See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
724
|
+
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
725
|
+
server packages together.
|
|
391
726
|
|
|
392
727
|
## Introspection
|
|
393
728
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
729
|
+
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
730
|
+
into private state:
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
Data.inspect(model) // serializable cache/domain summary
|
|
734
|
+
Data.plan(model, projection) // the entity plan a read would execute
|
|
735
|
+
Remote.planQueries(...) // missing/stale query work
|
|
736
|
+
Remote.inspectEntity(...) // one normalized entity
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
These are pure and useful in tests, tooling, and debugging. A particularly
|
|
740
|
+
useful question is: **"Why is this Projection still Initial?"** `Data.plan`
|
|
741
|
+
shows whether Remote believes anything is actually missing; active Surface
|
|
742
|
+
wiring determines whether that plan is being executed.
|
|
400
743
|
|
|
401
744
|
## Advanced: the kernel
|
|
402
745
|
|
|
403
|
-
Everything above compiles to the exports below;
|
|
404
|
-
implementation. Reach for
|
|
405
|
-
Remote, or
|
|
746
|
+
Everything above compiles to the lower-level exports below; there is not a
|
|
747
|
+
second implementation. Reach for the kernel in tooling, SSR, tests, packages
|
|
748
|
+
built on Remote, or applications that deliberately need one piece by hand.
|
|
406
749
|
|
|
407
750
|
### Descriptors and binding
|
|
408
751
|
|
|
409
|
-
`Remote.define({ entities, queries, mutations })`
|
|
410
|
-
place in
|
|
411
|
-
name-keyed
|
|
412
|
-
`RemoteServer.validate
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
`
|
|
416
|
-
|
|
752
|
+
`Remote.define({ entities, queries, mutations })` creates a domain without a
|
|
753
|
+
place in an application Model. It exposes the Remote `Model`, `initial`,
|
|
754
|
+
`Message`, reducer, RPC group, and a name-keyed registry consumed by
|
|
755
|
+
`RemoteServer.validate`.
|
|
756
|
+
|
|
757
|
+
`Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
|
|
758
|
+
`Remote.make` combines definition and binding in one step.
|
|
759
|
+
|
|
760
|
+
`Selection.make(Entity, { ... })`, `Selection.connection(...)`, and
|
|
761
|
+
`Entity.patch(ref, values)` are the kernel constructors behind the entity
|
|
762
|
+
helpers. `Remote.select(bound, selection)(id)` is the lower-level form of
|
|
417
763
|
`Data.get`.
|
|
418
764
|
|
|
419
765
|
### Requirements and the planner
|
|
420
766
|
|
|
421
|
-
A
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`Remote.
|
|
439
|
-
|
|
767
|
+
A Projection carries its requirements as Remote's own metadata, attached with
|
|
768
|
+
`RemoteRequirements.of` / `RemoteConnections.of` and read with
|
|
769
|
+
`requirementsOf(projection)` / `connectionsOf(projection)`. Requirements are
|
|
770
|
+
plain data: entity, id, fields, any relation pagination windows, and the nested
|
|
771
|
+
target selections reached through relations. Connections describe query
|
|
772
|
+
connections as `{ identity, window, select }`.
|
|
773
|
+
|
|
774
|
+
`Remote.plan(bound, model, projection, options?)` deterministically diffs those
|
|
775
|
+
requirements against the visible store and returns only missing/stale work. A
|
|
776
|
+
relation field being fetched rides on the same request so the server can resolve
|
|
777
|
+
the graph in one read; a relation already present in the store is followed into
|
|
778
|
+
requirements for the concrete target.
|
|
779
|
+
|
|
780
|
+
`Remote.planQueries` performs the same job for absent/stale connections.
|
|
781
|
+
`RemotePolicy.toPlan(policy, now)` compiles a high-level policy into planner
|
|
782
|
+
options.
|
|
783
|
+
|
|
784
|
+
`Remote.storeOf` is the visible store after pending optimistic layers. Reads are
|
|
785
|
+
memoized per visible store (and connection where applicable), so equal reads of
|
|
786
|
+
one Model state assemble/decode once and return one value.
|
|
787
|
+
|
|
788
|
+
`Remote.prefetch(bound, model, projection, options?)` runs the same plans
|
|
789
|
+
imperatively and reduces their results into a new Model.
|
|
440
790
|
|
|
441
791
|
### The reducer and its Messages
|
|
442
792
|
|
|
443
|
-
`updateRemote(remote, message)`
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
`defineMessageUnion`, `Remote.reduces(message)` narrows an application
|
|
450
|
-
to those cases
|
|
451
|
-
store write a `ReadReceived` performs, for a caller that manages its own store.
|
|
793
|
+
`updateRemote(remote, message)` is the pure reducer over Remote facts;
|
|
794
|
+
`Data.update` applies it to a bound Remote state and `Data.reduce` applies it to
|
|
795
|
+
the application Model.
|
|
796
|
+
|
|
797
|
+
Its Messages cover reads and refreshes, mutations, live events/gaps, query
|
|
798
|
+
connections, retention, and hydration. `Remote.messages` is the case record for
|
|
799
|
+
`defineMessageUnion`, and `Remote.reduces(message)` narrows an application union
|
|
800
|
+
to those cases.
|
|
452
801
|
|
|
453
|
-
|
|
802
|
+
`Remote.writeRead(store, requests, result, now)` exposes the store write behind a
|
|
803
|
+
read result for callers deliberately managing their own Remote store.
|
|
454
804
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
805
|
+
### Subscription entries
|
|
806
|
+
|
|
807
|
+
`Data.subscriptions` is built from three kernel entries:
|
|
458
808
|
|
|
459
809
|
```text
|
|
460
|
-
Remote.observe(bound, surface, params, toMessage?, options?)
|
|
461
|
-
|
|
462
|
-
|
|
810
|
+
Remote.observe(bound, surface, params, toMessage?, options?)
|
|
811
|
+
plan + entity reads + query reads
|
|
812
|
+
|
|
813
|
+
Remote.live(bound, surface, params, toMessage?, options?)
|
|
814
|
+
live streams from the Model-owned cursor
|
|
815
|
+
|
|
816
|
+
Remote.retain(projections, toMessage?, { connections?, grace? })
|
|
817
|
+
active roots -> RetentionChanged
|
|
463
818
|
```
|
|
464
819
|
|
|
465
|
-
The
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
pending request touches survive; everything else is dropped, settled overlays
|
|
480
|
-
on a dropped connection included.
|
|
820
|
+
The observe entry's dependencies are the current plan and the Model's refresh
|
|
821
|
+
generation (`{ requirements, queries, refresh }`), so `Data.refresh` restarts
|
|
822
|
+
it. If entity requirements and queries are both empty, it performs no I/O. A
|
|
823
|
+
refreshing read emits `RefreshStarted` first, then a result Message.
|
|
824
|
+
|
|
825
|
+
Every emitted Message changes the Model, so Foldkit recomputes Subscription
|
|
826
|
+
dependencies. For example, after a query page lands, the next computation sees
|
|
827
|
+
the page's entity ids and plans any selected fields those entities still lack.
|
|
828
|
+
An interrupted identical read is joined by the client coalescer instead of
|
|
829
|
+
needlessly restarted.
|
|
830
|
+
|
|
831
|
+
Retention uses the Projection requirements/connections as roots and eventually
|
|
832
|
+
emits `RetentionChanged`; the reducer's pure `gc` keeps everything reachable
|
|
833
|
+
from those roots plus pending work and collects the rest.
|
|
481
834
|
|
|
482
835
|
### Connections by hand
|
|
483
836
|
|
|
484
|
-
A connection stores entity references in segments with explicit boundaries,
|
|
485
|
-
|
|
486
|
-
live inserts, and optimistic
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
837
|
+
A connection stores entity references in segments with explicit boundaries, so
|
|
838
|
+
an unloaded middle page remains a gap rather than an implied adjacency. Query
|
|
839
|
+
pages, live inserts/removals, and optimistic overlays are reconciled as evidence
|
|
840
|
+
about that same structure.
|
|
841
|
+
|
|
842
|
+
A `QueryRef` has a canonical identity based on its descriptor and encoded input,
|
|
843
|
+
excluding the page window. Thus `first(25)` and `after(cursor).first(25)` merge
|
|
844
|
+
into the same logical connection.
|
|
491
845
|
|
|
492
846
|
```ts
|
|
493
|
-
// Inside
|
|
847
|
+
// Inside Effect.gen, with RemoteClient provided.
|
|
494
848
|
const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
|
|
495
|
-
const page = yield* Remote.query(ref)
|
|
496
|
-
|
|
497
|
-
|
|
849
|
+
const page = yield* Remote.query(ref)
|
|
850
|
+
|
|
851
|
+
updateRemote(remote, Remote.queryMessage(ref, page))
|
|
852
|
+
Remote.visibleItems(remote, ref)
|
|
498
853
|
```
|
|
499
854
|
|
|
500
|
-
`items`, `hasNext`, `hasPrevious`, and `isGapped`
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
keeps showing its items; a `ConnectionMerged` with `refreshes: true`,
|
|
506
|
-
`ConnectionRefreshed`, or `QueryFailed` clears it.
|
|
855
|
+
`items`, `hasNext`, `hasPrevious`, and `isGapped` describe the known region.
|
|
856
|
+
`ConnectionInvalidated` keeps visible rows but marks the connection stale until
|
|
857
|
+
a refresh/query result settles it. A `ConnectionMerged` page with
|
|
858
|
+
`refreshes: true` replaces a stale connection's pages rather than merging into
|
|
859
|
+
them.
|
|
507
860
|
|
|
508
861
|
### Mutations by hand
|
|
509
862
|
|
|
510
863
|
`Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic })` is
|
|
511
|
-
the one-step imperative
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
864
|
+
the one-step imperative start/run/settle form for SSR and tests.
|
|
865
|
+
|
|
866
|
+
`Remote.mutate(mutation, input, requestId)` is the transport call itself. It
|
|
867
|
+
decodes the typed `Output` and returns normalized entity patches and confirmed
|
|
868
|
+
connection changes for a caller to reduce through `updateRemote`.
|
|
869
|
+
|
|
515
870
|
`MutationSucceeded` reconciles at most once per `requestId`; an unknown or
|
|
516
|
-
already-applied
|
|
871
|
+
already-applied settlement is a no-op.
|
|
517
872
|
|
|
518
873
|
### The transport seam
|
|
519
874
|
|
|
520
|
-
`RemoteClient` is an Effect service with `read`, `query`, `mutate`, and
|
|
521
|
-
|
|
522
|
-
`LiveRequirement`,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
beyond
|
|
533
|
-
|
|
534
|
-
## What
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
-
|
|
546
|
-
|
|
547
|
-
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
-
|
|
556
|
-
|
|
557
|
-
-
|
|
558
|
-
placement.
|
|
559
|
-
- **Queries.** `Data.query` reads a connection as a page of selected items and
|
|
560
|
-
carries the connection for the planner; `Data.next`/`previous`/`fetch` page
|
|
561
|
-
it. `Remote.query(ref)` encodes and runs a `QueryRef` by hand and
|
|
562
|
-
`Remote.queryMessage(ref, page)` merges the result into the connection keyed
|
|
563
|
-
by `ref.identity`.
|
|
564
|
-
- **Live classification.** Per-stream cursor ordering, duplicate suppression, and
|
|
565
|
-
gap detection; a gap clears when an in-order event applies or a `GapCleared`
|
|
566
|
-
message arrives.
|
|
567
|
-
- **Introspection.** `Remote.inspect` and `Remote.inspectEntity` return a pure,
|
|
568
|
-
serializable cache view for DevTools.
|
|
569
|
-
- **The transport seam.** `RemoteClient`, an Effect service with `read`, `query`,
|
|
570
|
-
`mutate`, and `live`; the wire schemas and `RemoteRpc` group.
|
|
571
|
-
`Remote.clientLayer(rpcClient)` adapts an Effect RPC client for `RemoteRpc` to
|
|
572
|
-
`RemoteClient`, including the `LiveChange`-to-`LiveEvent` mapping; what the
|
|
573
|
-
client requires (a database under in-process `RemoteServer.handlers`) is
|
|
574
|
-
supplied to the layer with `Layer.provide`.
|
|
575
|
-
- **Disposable cache persistence.** Deterministic, scoped, size-bounded
|
|
576
|
-
snapshots of the entity store: `dehydrate`/`hydrate` as text, `save`/`restore`
|
|
577
|
-
over `KeyValueStore`, and `Hydrated` to merge one into the Model.
|
|
875
|
+
`RemoteClient` is an Effect service with `read`, `query`, `mutate`, and `live`.
|
|
876
|
+
The wire schemas (`ReadBatch`, `QueryRequest`, `MutationRequest`,
|
|
877
|
+
`LiveRequirement`, results/errors) and the `RemoteRpc` group are exported.
|
|
878
|
+
|
|
879
|
+
`Remote.clientLayer(rpcClient, { window? })` adapts an Effect RPC client,
|
|
880
|
+
including live-change mapping. Compatible reads and queries coalesce:
|
|
881
|
+
requirements issued together become one `ReadBatch`, an identical in-flight
|
|
882
|
+
requirement/query is joined, and a failed request releases the coalesced entry.
|
|
883
|
+
A relation request with its own pagination window stays separate because one
|
|
884
|
+
response answers one window exactly.
|
|
885
|
+
|
|
886
|
+
`Remote.coalesced(layer, options)` adds the same behavior to a hand-written
|
|
887
|
+
`RemoteClient` layer; `window` widens batching beyond work issued concurrently.
|
|
888
|
+
|
|
889
|
+
## What Remote owns
|
|
890
|
+
|
|
891
|
+
Remote owns:
|
|
892
|
+
|
|
893
|
+
- typed entity identity, references, selections, and patches;
|
|
894
|
+
- the normalized entity store, field presence/staleness, and tombstones;
|
|
895
|
+
- pure requirements and deterministic planning;
|
|
896
|
+
- the `Remote.Model` Submodel and its reducer;
|
|
897
|
+
- query connections and pagination boundaries;
|
|
898
|
+
- optimistic mutation overlays and idempotent settlement bookkeeping;
|
|
899
|
+
- live cursors, duplicate suppression, and gap detection;
|
|
900
|
+
- retention/garbage collection of disposable cache data;
|
|
901
|
+
- deterministic, scoped cache snapshots;
|
|
902
|
+
- the `RemoteClient` semantic transport seam and `RemoteRpc` schemas;
|
|
903
|
+
- pure introspection of the cache and plans.
|
|
904
|
+
|
|
905
|
+
Remote does **not** own:
|
|
906
|
+
|
|
907
|
+
- your source database or authoritative server records;
|
|
908
|
+
- local UI/application state outside `Remote.Model`;
|
|
909
|
+
- durable offline client intent or replica convergence;
|
|
910
|
+
- HTTP/WebSocket deployment details;
|
|
911
|
+
- authentication; server-side authorization belongs at the Source boundary;
|
|
912
|
+
- a background scheduler or general-purpose database/query engine.
|
|
578
913
|
|
|
579
914
|
## Limits
|
|
580
915
|
|
|
581
|
-
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
- The wire `LiveChange` union carries entity patches
|
|
589
|
-
insert/remove/invalidate changes;
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
- A refreshing policy's `maxAge` applies to entities, which the store stamps
|
|
599
|
-
with a clock; a connection has no age, so a page is re-queried only when the
|
|
600
|
-
connection is invalidated (`ConnectionInvalidated`, a live
|
|
601
|
-
`ConnectionInvalidate`, or `networkOnly`'s `force`).
|
|
916
|
+
- Effect RPC is the wire contract; the application supplies deployment layers.
|
|
917
|
+
`ReadBatch` and `LiveRequirement` carry `REMOTE_PROTOCOL_VERSION`, and a
|
|
918
|
+
mismatched version fails with `RemoteProtocolError` rather than silently
|
|
919
|
+
drifting.
|
|
920
|
+
- One request may name at most `MAX_FIELDS_PER_REQUEST` (256) fields of one
|
|
921
|
+
entity and nest relations at most `MAX_RELATION_DEPTH` (8) levels; the wire
|
|
922
|
+
rejects larger inputs.
|
|
923
|
+
- The wire `LiveChange` union carries entity patches/deletes and connection
|
|
924
|
+
insert/remove/invalidate changes; `RemoteServer.live` serves them as a stream.
|
|
925
|
+
- Coalescing is scoped to one `RemoteClient` layer. Separate layers do not share
|
|
926
|
+
batches.
|
|
927
|
+
- Retention keeps what active Surfaces (and explicit retain entries) reach. An
|
|
928
|
+
inactive/unretained Surface may lose its cache on `RetentionChanged`.
|
|
929
|
+
- Two applications with the same Model type are not distinguishable by TypeScript
|
|
930
|
+
alone; `Data.subscriptions` also checks the Surface owner token at runtime.
|
|
931
|
+
- `staleWhileRevalidate({ maxAge })` ages entity data. Connections have no age;
|
|
932
|
+
they re-query when invalidated or when `networkOnly` forces them.
|
|
602
933
|
- `RemoteData` is a closed union.
|
|
603
|
-
- A
|
|
604
|
-
|
|
934
|
+
- A Selection must choose at least one field; an empty Selection would require
|
|
935
|
+
nothing and misleadingly read `Ready` for every id, so it is rejected.
|
|
605
936
|
|
|
606
937
|
## See also
|
|
607
938
|
|
|
608
|
-
- [Server-derived state](
|
|
609
|
-
|
|
610
|
-
- [`foldkit-remote-server`](
|
|
611
|
-
|
|
612
|
-
- [`
|
|
613
|
-
|
|
939
|
+
- [Server-derived state](../../docs/remote.md) — the deeper architectural guide
|
|
940
|
+
and ownership model.
|
|
941
|
+
- [`foldkit-remote-server`](../remote-server) — server Sources, resolution, and
|
|
942
|
+
semantic-field authorization.
|
|
943
|
+
- [`foldkit-remote-drizzle`](../remote-drizzle) — optional compilation of Remote
|
|
944
|
+
selections/queries into Drizzle access.
|
|
945
|
+
- [`examples/remote`](../../examples/remote) — an asserted trace through plan,
|
|
946
|
+
prefetch, refresh, query, render, mutation, retention, and decode failure.
|
|
947
|
+
- [`examples/kitchen-sink`](../../examples/kitchen-sink) — the same architecture
|
|
948
|
+
using the real server packages.
|