foldkit-remote 0.2.0 → 0.4.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 +877 -446
- package/dist/index.d.mts +445 -172
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1537 -1276
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
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,362 @@ 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
|
-
|
|
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.
|
|
238
|
+
|
|
239
|
+
The active Surface drives I/O through a Foldkit Subscription:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import * as Subscription from 'foldkit/subscription'
|
|
243
|
+
|
|
244
|
+
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
245
|
+
Data.subscriptions({
|
|
246
|
+
project: Surface.at(ProjectPage, model =>
|
|
247
|
+
model.route._tag === 'project'
|
|
248
|
+
? { projectId: model.route.projectId }
|
|
249
|
+
: undefined,
|
|
250
|
+
),
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
```
|
|
150
254
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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.
|
|
157
258
|
|
|
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.
|
|
259
|
+
The complete loop is:
|
|
163
260
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
```
|
|
167
285
|
|
|
168
|
-
|
|
286
|
+
Finally, provide the client implementation to the runtime:
|
|
169
287
|
|
|
170
288
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
289
|
+
const clientLayer = Remote.clientLayer(rpcClient)
|
|
290
|
+
```
|
|
173
291
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
295
|
+
|
|
296
|
+
## One list per application with `Data.wiring`
|
|
297
|
+
|
|
298
|
+
The integration steps above — spread `Remote.messages`, reduce by tag, derive
|
|
299
|
+
`Data.subscriptions`, provide the client — are one value when the application
|
|
300
|
+
uses `foldkit-bundle`:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { Bundle } from 'foldkit-bundle'
|
|
304
|
+
|
|
305
|
+
const Page = Bundle.parent({ Model, Message })
|
|
306
|
+
const wiring = Page.assemble(
|
|
307
|
+
Data.wiring({
|
|
308
|
+
project: Surface.at(ProjectPage, model =>
|
|
309
|
+
model.route._tag === 'project' ? { projectId: model.route.projectId } : undefined,
|
|
310
|
+
),
|
|
311
|
+
}),
|
|
183
312
|
)
|
|
313
|
+
const update = wiring.update(model => ({ model }))
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`Data.wiring` routes Remote's Messages into `Data.reduce`, brings the active
|
|
317
|
+
Surfaces' Subscriptions and the domain's contract, and requires `RemoteClient`
|
|
318
|
+
from the runtime's resources — the same client layer from above. One assembly
|
|
319
|
+
holds at most one Remote domain: every domain claims the same Message tags,
|
|
320
|
+
and the assembly refuses a second claimant at startup, naming both.
|
|
321
|
+
|
|
322
|
+
## `RemoteData`: what does the Model know right now?
|
|
323
|
+
|
|
324
|
+
A Remote Projection never lies by pretending an absent value is present. It
|
|
325
|
+
returns a closed `RemoteData` union:
|
|
326
|
+
|
|
327
|
+
```text
|
|
328
|
+
no active fetch
|
|
329
|
+
missing ------------------------------------------------> Initial
|
|
330
|
+
|
|
331
|
+
fetch starts
|
|
332
|
+
missing ------------------------------------------------> Loading
|
|
333
|
+
|
|
|
334
|
+
result
|
|
335
|
+
v
|
|
336
|
+
Ready
|
|
337
|
+
|
|
|
338
|
+
refetch starts
|
|
339
|
+
v
|
|
340
|
+
Refreshing
|
|
341
|
+
old value stays
|
|
342
|
+
|
|
|
343
|
+
result
|
|
344
|
+
v
|
|
345
|
+
Ready
|
|
346
|
+
|
|
347
|
+
server says entity is absent ---------------------------> NotFound
|
|
348
|
+
stored value fails Selection decoding -----------------> Failed
|
|
184
349
|
```
|
|
185
350
|
|
|
186
|
-
|
|
187
|
-
a function of the Model, `undefined` while it is inactive. From the active
|
|
188
|
-
Surfaces `Data.subscriptions` derives what to fetch (only the fields and pages
|
|
189
|
-
the Model lacks), what to subscribe to (what is read through `Data.live`), and
|
|
190
|
-
what to keep (everything the active Surfaces reach); a fully-known Surface
|
|
191
|
-
fetches nothing, and there is never network work during render.
|
|
351
|
+
The states are:
|
|
192
352
|
|
|
193
|
-
|
|
353
|
+
- **`Initial`** — required data is absent and **nothing is currently fetching it**.
|
|
354
|
+
- **`Loading`** — required data is absent and a read is in flight.
|
|
355
|
+
- **`Ready`** — every selected field is present and decodes.
|
|
356
|
+
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
357
|
+
- **`Failed`** — stored server data does not decode against the Selection.
|
|
358
|
+
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
359
|
+
deleted it, or the server was asked for it by id and answered without it. That
|
|
360
|
+
id is then known absent, whether it never existed, is gone, or is not this
|
|
361
|
+
principal's to see. It is not planned again until `Data.refresh` forces it or a
|
|
362
|
+
write brings it back. The target of a returned ref is not marked this way: a
|
|
363
|
+
server need not expand a relation that rides on a request, and the planner asks
|
|
364
|
+
for such a target by id next, which is when its absence is learned.
|
|
365
|
+
|
|
366
|
+
`Initial` is intentionally different from `Loading`. A Projection belonging to
|
|
367
|
+
no active Surface may remain `Initial` forever. Rendering a spinner for
|
|
368
|
+
`Initial` can therefore hide an activation/wiring mistake; `Loading` is the
|
|
369
|
+
state that actually means "wait for this request."
|
|
370
|
+
|
|
371
|
+
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
372
|
+
compile time.
|
|
373
|
+
|
|
374
|
+
## Normalized entities and selections
|
|
375
|
+
|
|
376
|
+
Remote stores an entity once by identity, regardless of how many Surfaces read
|
|
377
|
+
it. Consumers select different views of the same normalized fact.
|
|
378
|
+
|
|
379
|
+
Relations are references, not nested copies:
|
|
194
380
|
|
|
195
381
|
```ts
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
382
|
+
const User = Entity.make(
|
|
383
|
+
'User',
|
|
384
|
+
Schema.Struct({
|
|
385
|
+
id: Schema.String,
|
|
386
|
+
name: Schema.String,
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
const Project = Entity.make(
|
|
391
|
+
'Project',
|
|
392
|
+
Schema.Struct({
|
|
393
|
+
id: Schema.String,
|
|
394
|
+
name: Schema.String,
|
|
395
|
+
owner: Entity.ref(User),
|
|
396
|
+
}),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
const UserSummary = User.select({ id: true, name: true })
|
|
400
|
+
const ProjectSummary = Project.select({
|
|
401
|
+
id: true,
|
|
402
|
+
name: true,
|
|
403
|
+
owner: UserSummary,
|
|
404
|
+
})
|
|
212
405
|
```
|
|
213
406
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
407
|
+
The store remains normalized:
|
|
408
|
+
|
|
409
|
+
```text
|
|
410
|
+
Project:p1 name PRESENT owner PRESENT -> User:u7
|
|
411
|
+
User:u7 name PRESENT
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The Projection assembles the nested consumer value by following the reference.
|
|
415
|
+
An unknown field or a nested Selection for the wrong entity is a type error.
|
|
416
|
+
Recursive relations remain finite because the entity schema stores references
|
|
417
|
+
(`Entity.ref(User)` / `Entity.refTo('Node')`), not recursively inlined schemas.
|
|
418
|
+
|
|
419
|
+
### Entities declared with `foldkit-entity`
|
|
224
420
|
|
|
225
|
-
|
|
421
|
+
[`foldkit-entity`](../entity/README.md) declares a domain without Remote in it:
|
|
422
|
+
fields, relations as navigation edges, derived members. `Remote.make` registers
|
|
423
|
+
those Entities, and `Data.get`, `Data.live`, and a query's `select` take their
|
|
424
|
+
Selections. Remote compiles both into the descriptor and Selection above, so
|
|
425
|
+
the store, the planner, and the server see nothing new.
|
|
226
426
|
|
|
227
427
|
```ts
|
|
228
|
-
|
|
229
|
-
|
|
428
|
+
import { Schema } from 'effect'
|
|
429
|
+
import { Entity, Relation } from 'foldkit-entity'
|
|
430
|
+
import { Remote } from 'foldkit-remote'
|
|
431
|
+
|
|
432
|
+
const User = Entity.define('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
433
|
+
const Project = Entity.define('Project', Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
434
|
+
const Work = Entity.relate({ User, Project }, { Project: { owner: Relation.one(User) } })
|
|
435
|
+
|
|
436
|
+
const ProjectCard = Entity.select(Work.Project, {
|
|
437
|
+
name: true,
|
|
438
|
+
owner: Entity.select(Work.User, { name: true }),
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const Data = Remote.make({ model: App.model.remote, entities: [Work.User, Work.Project] })
|
|
442
|
+
const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name; owner: { name } }>>
|
|
230
443
|
```
|
|
231
444
|
|
|
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
|
-
|
|
445
|
+
- A relation becomes a ref field: `one` a ref, an optional `one` a nullable ref,
|
|
446
|
+
`many` an array of refs. A derived member becomes a field the server supplies.
|
|
447
|
+
- The Entity needs an `id` field; Remote keys the store by it.
|
|
448
|
+
- Register the Entities of one `Entity.relate` result and select from that same
|
|
449
|
+
result; `Object.values(Work)` registers them all.
|
|
450
|
+
- `Entity.from(entity)` and `Selection.from(selection)` are the compile steps,
|
|
451
|
+
for when you need the descriptor itself: `patch` and `ref` in a mutation
|
|
452
|
+
handler.
|
|
453
|
+
- `Entity.page(selection, window)` in an Entity Selection is Remote's
|
|
454
|
+
`Selection.connection`: the window travels with the read, and the server
|
|
455
|
+
answers with a page of refs. One relation of one entity is held whole or as
|
|
456
|
+
one window at a time, so two views of the same entity should not read the
|
|
457
|
+
same relation both ways at once.
|
|
458
|
+
|
|
459
|
+
`Entity.make` with `Entity.ref` keeps working, and both kinds can share one
|
|
460
|
+
domain. Both packages export `Entity`; a module that needs `Entity.from` beside
|
|
461
|
+
`Entity.define` aliases one of them.
|
|
462
|
+
|
|
463
|
+
Presence is tracked separately from the JavaScript value. These are distinct:
|
|
464
|
+
|
|
465
|
+
```text
|
|
466
|
+
field missing
|
|
467
|
+
field present with undefined
|
|
468
|
+
field present with null
|
|
469
|
+
field stale
|
|
470
|
+
entity not found
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Remote never infers presence from `value === undefined`.
|
|
474
|
+
|
|
475
|
+
## Reading, policies, and prefetch
|
|
476
|
+
|
|
477
|
+
`Data.get(selection, id)` returns a pure Projection. `Data.live(selection, id)`
|
|
478
|
+
returns the same kind of Projection but marks its requirements as live so
|
|
479
|
+
`Data.subscriptions` also follows server changes.
|
|
480
|
+
|
|
481
|
+
What Remote should do when the Model already contains the selected fields is a
|
|
482
|
+
`RemotePolicy`:
|
|
483
|
+
|
|
484
|
+
- `RemotePolicy.cacheFirst` (default) — fetch missing, stale, or re-windowed data.
|
|
485
|
+
- `RemotePolicy.staleWhileRevalidate({ maxAge })` — keep a present value visible
|
|
486
|
+
and refetch when it is older than `maxAge`.
|
|
487
|
+
- `RemotePolicy.networkOnly` — request every selected field; cached values stay
|
|
488
|
+
visible while the request is in flight.
|
|
489
|
+
|
|
490
|
+
A refreshing policy emits `RefreshStarted` before the read, so the Projection
|
|
491
|
+
becomes `Refreshing` without discarding the old value. Planning accepts `now`
|
|
492
|
+
(default `Date.now`) as an input, so tests can control time.
|
|
493
|
+
|
|
494
|
+
For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
|
|
262
495
|
|
|
263
496
|
```ts
|
|
264
497
|
import { Effect } from 'effect'
|
|
@@ -270,344 +503,542 @@ const loaded = await Effect.runPromise(
|
|
|
270
503
|
)
|
|
271
504
|
```
|
|
272
505
|
|
|
273
|
-
|
|
506
|
+
`Data.prefetch` performs I/O explicitly and returns the Model with the resulting
|
|
507
|
+
Remote Messages reduced into it. It never changes the semantics of the
|
|
508
|
+
Projection itself.
|
|
509
|
+
|
|
510
|
+
### Refreshing from `update`
|
|
511
|
+
|
|
512
|
+
To revalidate what a screen already declares — a refresh button, a focus
|
|
513
|
+
regained — hand its Projection (or a Surface without params) to `Data.refresh`:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
case 'ClickedRefresh': {
|
|
517
|
+
if (model.route._tag !== 'project') return { model }
|
|
518
|
+
const page = ProjectPage.projection({ projectId: model.route.projectId })
|
|
519
|
+
return { model: Data.refresh(model, page) }
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`Data.refresh` performs no I/O and restates no request. It returns the Model
|
|
524
|
+
with every selected field the store holds reading `Refreshing` and every loaded
|
|
525
|
+
connection invalidated; the `Data.subscriptions` read entries then refetch it,
|
|
526
|
+
since stale data is planned again under every policy, so the page is requested
|
|
527
|
+
once. The Projection must be observed, as it is while it is on screen; for data
|
|
528
|
+
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
529
|
+
|
|
530
|
+
- A refreshed connection's first page replaces its loaded pages, so items the
|
|
531
|
+
server removed or reordered follow it; later pages are fetched again with
|
|
532
|
+
`Data.next`.
|
|
533
|
+
- A read already in flight restarts instead of landing after the refresh.
|
|
534
|
+
- Refreshing what is already refreshing, or nothing, returns the same Model.
|
|
535
|
+
|
|
536
|
+
## Queries and pagination
|
|
537
|
+
|
|
538
|
+
Entities answer "which fields of this known thing?" A Query answers "which
|
|
539
|
+
entities belong in this server-owned list?"
|
|
540
|
+
|
|
541
|
+
Declare the query and register it with the domain:
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { Query } from 'foldkit-remote'
|
|
545
|
+
|
|
546
|
+
const ProjectsByOwner = Query.make('ProjectsByOwner', {
|
|
547
|
+
Input: { ownerId: Schema.String },
|
|
548
|
+
Result: Project,
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
const Data = Remote.make({
|
|
552
|
+
model: App.fields.remote,
|
|
553
|
+
entities: [User, Project],
|
|
554
|
+
queries: [ProjectsByOwner],
|
|
555
|
+
})
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Then a query is still just a Projection:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
const projects = Data.query(
|
|
562
|
+
ProjectsByOwner,
|
|
563
|
+
{ ownerId },
|
|
564
|
+
{ select: ProjectSummary, first: 25 },
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
projects.read(model)
|
|
568
|
+
// RemoteData<Page<ProjectSummary>>
|
|
569
|
+
|
|
570
|
+
Data.next(model, projects) // QueryRef | undefined
|
|
571
|
+
Data.previous(model, projects) // QueryRef | undefined
|
|
572
|
+
Data.fetch(ref) // Command whose Message merges the page
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
The connection stores entity references and explicit boundaries rather than one
|
|
576
|
+
flat array. That matters when page 1 and page 3 are loaded but page 2 is not:
|
|
577
|
+
Remote represents an honest gap instead of pretending the visible rows are
|
|
578
|
+
adjacent.
|
|
579
|
+
|
|
580
|
+
A query Projection is `Initial` until the page **and every selected field of its
|
|
581
|
+
visible items** are present. Once a page lands, those items become ordinary
|
|
582
|
+
entity requirements and can be fulfilled in the same planning loop.
|
|
583
|
+
|
|
584
|
+
Two Projections of the same connection plan one query and select the union of
|
|
585
|
+
their required fields. `Data.next` / `Data.previous` preserve the page size and
|
|
586
|
+
use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
|
|
587
|
+
not from guessing based on row counts.
|
|
588
|
+
|
|
589
|
+
## Mutations and optimistic state
|
|
590
|
+
|
|
591
|
+
Register mutations on the domain:
|
|
274
592
|
|
|
275
593
|
```ts
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
594
|
+
import { Mutation } from 'foldkit-remote'
|
|
595
|
+
|
|
596
|
+
const RenameProject = Mutation.make('RenameProject', {
|
|
597
|
+
Input: { id: Schema.String, name: Schema.String },
|
|
598
|
+
Output: { id: Schema.String },
|
|
599
|
+
})
|
|
281
600
|
```
|
|
282
601
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
602
|
+
A mutation starts from ordinary application `update`:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
case 'ClickedRename': {
|
|
606
|
+
const { id, name } = message
|
|
607
|
+
|
|
608
|
+
const { model: started, command } = Data.mutate(
|
|
609
|
+
model,
|
|
610
|
+
RenameProject,
|
|
611
|
+
{ id, name },
|
|
612
|
+
{
|
|
613
|
+
optimistic: [Project.patch(id, { name })],
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return { model: started, commands: [command] }
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
`Data.mutate` does two things:
|
|
622
|
+
|
|
623
|
+
1. it applies `MutationStarted` to the Model, including any optimistic overlays;
|
|
624
|
+
2. it returns the Command that calls `RemoteClient` and eventually emits
|
|
625
|
+
`MutationSucceeded` or `MutationFailed`.
|
|
626
|
+
|
|
627
|
+
Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
628
|
+
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
629
|
+
Settling is idempotent per `requestId`.
|
|
630
|
+
|
|
631
|
+
A mutation that deletes says so. The server's outcome carries
|
|
632
|
+
`deleted: [{ entity, id }]`, and settling it tombstones those entities: they read
|
|
633
|
+
`NotFound`, and they leave every connection and relation they were in, so the
|
|
634
|
+
server names no list. Patches apply first, so an entity named both ways is
|
|
635
|
+
deleted; a retry of the same request deletes nothing again.
|
|
636
|
+
|
|
637
|
+
`Data.mutation(model, requestId)` reads what became of it from the Model:
|
|
638
|
+
`Pending`, `Applied`, `Failed` with the error the server or transport gave, or
|
|
639
|
+
`Unknown` for an id never started here (or settled so long ago it left the
|
|
640
|
+
bounded ledger). A retry reuses its id, and the latest outcome wins.
|
|
641
|
+
|
|
642
|
+
Optimistic entity patches are **layers over the base store**, not inverse
|
|
643
|
+
patches. If multiple mutations overlap, the visible cache is recomputed as base
|
|
644
|
+
plus the still-pending layers, so settling one does not require trying to undo
|
|
645
|
+
its old value manually.
|
|
646
|
+
|
|
647
|
+
Connections can be optimistic too:
|
|
305
648
|
|
|
306
649
|
```ts
|
|
307
650
|
import { ConnectionChange } from 'foldkit-remote'
|
|
308
651
|
|
|
309
|
-
const { model: started,
|
|
652
|
+
const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
310
653
|
optimistic: ({ tempId }) => [
|
|
311
654
|
Comment.patch(tempId, { id: tempId, body: input.body }),
|
|
312
|
-
ConnectionChange.prepend(
|
|
655
|
+
ConnectionChange.prepend(
|
|
656
|
+
CommentsForPost.ref({ postId }),
|
|
657
|
+
Comment.ref(tempId),
|
|
658
|
+
),
|
|
313
659
|
],
|
|
314
660
|
})
|
|
315
|
-
return { model: started, commands: [command] }
|
|
316
661
|
```
|
|
317
662
|
|
|
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.
|
|
663
|
+
A successful server result can replace the request's temporary connection
|
|
664
|
+
overlays with confirmed ones in place.
|
|
665
|
+
|
|
666
|
+
### Remote mutation vs Sync operation
|
|
667
|
+
|
|
668
|
+
Do not use Remote mutation as a durable offline-write mechanism:
|
|
669
|
+
|
|
670
|
+
```text
|
|
671
|
+
Remote mutation
|
|
672
|
+
request server now
|
|
673
|
+
server owns truth
|
|
674
|
+
result updates disposable cache
|
|
675
|
+
no durable outbox
|
|
676
|
+
|
|
677
|
+
Sync operation
|
|
678
|
+
client authored the durable fact/intent
|
|
679
|
+
survives offline/restart
|
|
680
|
+
enters an ordered log
|
|
681
|
+
converges with other replicas
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
If losing an unsent edit would be data loss, it belongs to Sync rather than
|
|
685
|
+
Remote.
|
|
344
686
|
|
|
345
687
|
## Live data
|
|
346
688
|
|
|
347
|
-
`Data.live`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
689
|
+
`Data.live` marks a Projection so active `Data.subscriptions` follow server
|
|
690
|
+
changes for it.
|
|
691
|
+
|
|
692
|
+
The Remote Model owns each stream's cursor. Live events carry monotonically
|
|
693
|
+
ordered cursors:
|
|
694
|
+
|
|
695
|
+
- an old/repeated cursor is a duplicate and is ignored;
|
|
696
|
+
- the next cursor is applied;
|
|
697
|
+
- a cursor ahead of the expected value is a **gap** and is not applied.
|
|
698
|
+
|
|
699
|
+
A gap is recorded in `RemoteModel.gaps` so the host can resynchronize rather than
|
|
700
|
+
silently accepting missing history. An in-order event or `GapCleared` clears it.
|
|
701
|
+
|
|
702
|
+
Entity patches update normalized fields, deletes create tombstones, and
|
|
703
|
+
connection insert/remove/invalidate events reconcile the same connection model
|
|
704
|
+
used by query pages and optimistic overlays. A stream failure becomes a Remote
|
|
705
|
+
failure Message rather than mutating anything out of band.
|
|
706
|
+
|
|
707
|
+
## Retention and garbage collection
|
|
708
|
+
|
|
709
|
+
Remote is a cache, so it should be allowed to forget facts no active feature
|
|
710
|
+
needs.
|
|
711
|
+
|
|
712
|
+
`Data.subscriptions` derives retention roots from the active Surfaces. A root
|
|
713
|
+
keeps:
|
|
714
|
+
|
|
715
|
+
- the entities and fields its Projection requires;
|
|
716
|
+
- referenced targets reached by nested selections;
|
|
717
|
+
- retained connections and the selected fields of their visible items;
|
|
718
|
+
- data touched by pending optimistic work.
|
|
719
|
+
|
|
720
|
+
After the configured grace period, `RetentionChanged` enters the Model and the
|
|
721
|
+
pure Remote reducer collects unreachable cache data. An inactive Surface that is
|
|
722
|
+
not otherwise retained may therefore lose its cache, which is safe because the
|
|
723
|
+
server remains authoritative and the planner can refetch it.
|
|
724
|
+
|
|
725
|
+
This is another important difference from Sync: **Remote recovery may discard
|
|
726
|
+
and refetch; Sync recovery must preserve unsent client intent.**
|
|
357
727
|
|
|
358
728
|
## Persistence and hydration
|
|
359
729
|
|
|
360
|
-
|
|
361
|
-
optimistic layers,
|
|
362
|
-
session and never appear in one.
|
|
730
|
+
Persist only the disposable entity cache, not session machinery such as live
|
|
731
|
+
cursors, optimistic layers, mutation bookkeeping, gaps, or retention roots.
|
|
363
732
|
|
|
364
733
|
```ts
|
|
365
734
|
import { emptyStore, RemotePersistence } from 'foldkit-remote'
|
|
366
735
|
|
|
367
|
-
// SSR: the server
|
|
368
|
-
const snapshot = RemotePersistence.dehydrate(
|
|
736
|
+
// SSR: prefetch on the server, embed, then hydrate on the client.
|
|
737
|
+
const snapshot = RemotePersistence.dehydrate(
|
|
738
|
+
Data.storeOf(loaded),
|
|
739
|
+
{ scope: userId },
|
|
740
|
+
)
|
|
741
|
+
|
|
369
742
|
Data.reduce(model, {
|
|
370
743
|
_tag: 'Hydrated',
|
|
371
|
-
entities:
|
|
744
|
+
entities:
|
|
745
|
+
RemotePersistence.hydrate(snapshot, { scope: userId }) ?? emptyStore,
|
|
372
746
|
merge: 'preserve-existing',
|
|
373
747
|
})
|
|
374
748
|
|
|
375
|
-
//
|
|
376
|
-
RemotePersistence.save(store, {
|
|
377
|
-
|
|
749
|
+
// Or persist through Effect's KeyValueStore.
|
|
750
|
+
RemotePersistence.save(store, {
|
|
751
|
+
key: 'remote-cache',
|
|
752
|
+
scope: userId,
|
|
753
|
+
maxBytes: 512_000,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
RemotePersistence.restore({
|
|
757
|
+
key: 'remote-cache',
|
|
758
|
+
scope: userId,
|
|
759
|
+
maxBytes: 512_000,
|
|
760
|
+
})
|
|
378
761
|
```
|
|
379
762
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
the
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
763
|
+
Snapshots are deterministic, versioned, scoped, and optionally size-bounded. A
|
|
764
|
+
snapshot from another version/scope, an oversized snapshot, or malformed data is
|
|
765
|
+
discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
|
|
766
|
+
hydration still changes the application through the reducer.
|
|
767
|
+
|
|
768
|
+
## How the server packages fit
|
|
769
|
+
|
|
770
|
+
`foldkit-remote` owns **client cache semantics**, not your source database or
|
|
771
|
+
network transport.
|
|
772
|
+
|
|
773
|
+
```text
|
|
774
|
+
client
|
|
775
|
+
|
|
776
|
+
Surface Projection
|
|
777
|
+
|
|
|
778
|
+
v
|
|
779
|
+
foldkit-remote
|
|
780
|
+
requirements + normalized cache
|
|
781
|
+
|
|
|
782
|
+
v
|
|
783
|
+
RemoteClient / Effect RPC
|
|
784
|
+
|
|
785
|
+
---------------- server boundary ----------------
|
|
786
|
+
|
|
787
|
+
foldkit-remote-server
|
|
788
|
+
Sources + field authorization
|
|
789
|
+
|
|
|
790
|
+
+---------------------+
|
|
791
|
+
| |
|
|
792
|
+
v v
|
|
793
|
+
foldkit-remote-drizzle hand-written Source
|
|
794
|
+
(optional SQL compiler) API / service / database
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
The package roles are:
|
|
798
|
+
|
|
799
|
+
| Package | Responsibility |
|
|
800
|
+
| --- | --- |
|
|
801
|
+
| `foldkit-remote` | normalized cache, requirements, planning, queries, optimistic overlays, live cursors, retention |
|
|
802
|
+
| `foldkit-remote-server` | interpret client selections/queries against server Sources and enforce semantic-field authorization |
|
|
803
|
+
| `foldkit-remote-drizzle` | optionally compile those selections and queries into typed Drizzle access |
|
|
804
|
+
| Effect RPC / your layers | transport and deployment |
|
|
805
|
+
|
|
806
|
+
`Remote.clientLayer(rpcClient)` adapts `RemoteRpc` to `RemoteClient` and
|
|
807
|
+
coalesces compatible reads/queries. HTTP, WebSocket, worker, and in-process
|
|
808
|
+
execution are layer choices rather than Remote semantics.
|
|
809
|
+
|
|
810
|
+
See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
811
|
+
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
812
|
+
server packages together.
|
|
391
813
|
|
|
392
814
|
## Introspection
|
|
393
815
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
816
|
+
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
817
|
+
into private state:
|
|
818
|
+
|
|
819
|
+
```ts
|
|
820
|
+
Data.inspect(model) // serializable cache/domain summary
|
|
821
|
+
Data.plan(model, projection) // the entity plan a read would execute
|
|
822
|
+
Remote.planQueries(...) // missing/stale query work
|
|
823
|
+
Remote.inspectEntity(...) // one normalized entity
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
These are pure and useful in tests, tooling, and debugging. A particularly
|
|
827
|
+
useful question is: **"Why is this Projection still Initial?"** `Data.plan`
|
|
828
|
+
shows whether Remote believes anything is actually missing; active Surface
|
|
829
|
+
wiring determines whether that plan is being executed.
|
|
400
830
|
|
|
401
831
|
## Advanced: the kernel
|
|
402
832
|
|
|
403
|
-
Everything above compiles to the exports below;
|
|
404
|
-
implementation. Reach for
|
|
405
|
-
Remote, or
|
|
833
|
+
Everything above compiles to the lower-level exports below; there is not a
|
|
834
|
+
second implementation. Reach for the kernel in tooling, SSR, tests, packages
|
|
835
|
+
built on Remote, or applications that deliberately need one piece by hand.
|
|
406
836
|
|
|
407
837
|
### Descriptors and binding
|
|
408
838
|
|
|
409
|
-
`Remote.define({ entities, queries, mutations })`
|
|
410
|
-
place in
|
|
411
|
-
name-keyed
|
|
412
|
-
`RemoteServer.validate
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
`
|
|
416
|
-
|
|
839
|
+
`Remote.define({ entities, queries, mutations })` creates a domain without a
|
|
840
|
+
place in an application Model. It exposes the Remote `Model`, `initial`,
|
|
841
|
+
`Message`, reducer, RPC group, and a name-keyed registry consumed by
|
|
842
|
+
`RemoteServer.validate`.
|
|
843
|
+
|
|
844
|
+
`Remote.at(definition, modelRef)` binds that domain to a `ModelRef`;
|
|
845
|
+
`Remote.make` combines definition and binding in one step.
|
|
846
|
+
|
|
847
|
+
`Selection.make(Entity, { ... })`, `Selection.connection(...)`, and
|
|
848
|
+
`Entity.patch(ref, values)` are the kernel constructors behind the entity
|
|
849
|
+
helpers. `Remote.select(bound, selection)(id)` is the lower-level form of
|
|
417
850
|
`Data.get`.
|
|
418
851
|
|
|
419
852
|
### Requirements and the planner
|
|
420
853
|
|
|
421
|
-
A
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`Remote.
|
|
439
|
-
|
|
854
|
+
A Projection carries its requirements as Remote's own metadata, attached with
|
|
855
|
+
`RemoteRequirements.of` / `RemoteConnections.of` and read with
|
|
856
|
+
`requirementsOf(projection)` / `connectionsOf(projection)`. Requirements are
|
|
857
|
+
plain data: entity, id, fields, any relation pagination windows, and the nested
|
|
858
|
+
target selections reached through relations. Connections describe query
|
|
859
|
+
connections as `{ identity, window, select }`.
|
|
860
|
+
|
|
861
|
+
`Remote.plan(bound, model, projection, options?)` deterministically diffs those
|
|
862
|
+
requirements against the visible store and returns only missing/stale work. A
|
|
863
|
+
relation field being fetched rides on the same request so the server can resolve
|
|
864
|
+
the graph in one read; a relation already present in the store is followed into
|
|
865
|
+
requirements for the concrete target.
|
|
866
|
+
|
|
867
|
+
`Remote.planQueries` performs the same job for absent/stale connections.
|
|
868
|
+
`RemotePolicy.toPlan(policy, now)` compiles a high-level policy into planner
|
|
869
|
+
options.
|
|
870
|
+
|
|
871
|
+
`Remote.storeOf` is the visible store after pending optimistic layers. Reads are
|
|
872
|
+
memoized per visible store (and connection where applicable), so equal reads of
|
|
873
|
+
one Model state assemble/decode once and return one value.
|
|
874
|
+
|
|
875
|
+
`Remote.prefetch(bound, model, projection, options?)` runs the same plans
|
|
876
|
+
imperatively and reduces their results into a new Model.
|
|
440
877
|
|
|
441
878
|
### The reducer and its Messages
|
|
442
879
|
|
|
443
|
-
`updateRemote(remote, message)`
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
`MutationStarted`/`Succeeded`/`Failed`, `LiveReceived`/`GapCleared`,
|
|
447
|
-
`ConnectionMerged`/`Invalidated`/`Refreshed`/`QueryFailed`,
|
|
448
|
-
`RetentionChanged`, and `Hydrated`. `Remote.messages` is the case record for
|
|
449
|
-
`defineMessageUnion`, `Remote.reduces(message)` narrows an application's union
|
|
450
|
-
to those cases, and `Remote.writeRead(store, requests, result, now)` is the
|
|
451
|
-
store write a `ReadReceived` performs, for a caller that manages its own store.
|
|
880
|
+
`updateRemote(remote, message)` is the pure reducer over Remote facts;
|
|
881
|
+
`Data.update` applies it to a bound Remote state and `Data.reduce` applies it to
|
|
882
|
+
the application Model.
|
|
452
883
|
|
|
453
|
-
|
|
884
|
+
Its Messages cover reads and refreshes, mutations, live events/gaps, query
|
|
885
|
+
connections, retention, and hydration. `Remote.messages` is the case record for
|
|
886
|
+
`defineMessageUnion`, and `Remote.reduces(message)` narrows an application union
|
|
887
|
+
to those cases.
|
|
454
888
|
|
|
455
|
-
`
|
|
456
|
-
|
|
457
|
-
|
|
889
|
+
`Remote.writeRead(store, requests, result, now)` exposes the store write behind a
|
|
890
|
+
read result for callers deliberately managing their own Remote store.
|
|
891
|
+
|
|
892
|
+
### Subscription entries
|
|
893
|
+
|
|
894
|
+
`Data.subscriptions` is built from three kernel entries:
|
|
458
895
|
|
|
459
896
|
```text
|
|
460
|
-
Remote.observe(bound, surface, params, toMessage?, options?)
|
|
461
|
-
|
|
462
|
-
|
|
897
|
+
Remote.observe(bound, surface, params, toMessage?, options?)
|
|
898
|
+
plan + entity reads + query reads
|
|
899
|
+
|
|
900
|
+
Remote.live(bound, surface, params, toMessage?, options?)
|
|
901
|
+
live streams from the Model-owned cursor
|
|
902
|
+
|
|
903
|
+
Remote.retain(projections, toMessage?, { connections?, grace? })
|
|
904
|
+
active roots -> RetentionChanged
|
|
463
905
|
```
|
|
464
906
|
|
|
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.
|
|
907
|
+
The observe entry's dependencies are the current plan and the Model's refresh
|
|
908
|
+
generation (`{ requirements, queries, refresh }`), so `Data.refresh` restarts
|
|
909
|
+
it. If entity requirements and queries are both empty, it performs no I/O. A
|
|
910
|
+
refreshing read emits `RefreshStarted` first, then a result Message.
|
|
911
|
+
|
|
912
|
+
Every emitted Message changes the Model, so Foldkit recomputes Subscription
|
|
913
|
+
dependencies. For example, after a query page lands, the next computation sees
|
|
914
|
+
the page's entity ids and plans any selected fields those entities still lack.
|
|
915
|
+
An interrupted identical read is joined by the client coalescer instead of
|
|
916
|
+
needlessly restarted.
|
|
917
|
+
|
|
918
|
+
Retention uses the Projection requirements/connections as roots and eventually
|
|
919
|
+
emits `RetentionChanged`; the reducer's pure `gc` keeps everything reachable
|
|
920
|
+
from those roots plus pending work and collects the rest.
|
|
481
921
|
|
|
482
922
|
### Connections by hand
|
|
483
923
|
|
|
484
|
-
A connection stores entity references in segments with explicit boundaries,
|
|
485
|
-
|
|
486
|
-
live inserts, and optimistic
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
924
|
+
A connection stores entity references in segments with explicit boundaries, so
|
|
925
|
+
an unloaded middle page remains a gap rather than an implied adjacency. Query
|
|
926
|
+
pages, live inserts/removals, and optimistic overlays are reconciled as evidence
|
|
927
|
+
about that same structure.
|
|
928
|
+
|
|
929
|
+
A `QueryRef` has a canonical identity based on its descriptor and encoded input,
|
|
930
|
+
excluding the page window. Thus `first(25)` and `after(cursor).first(25)` merge
|
|
931
|
+
into the same logical connection.
|
|
491
932
|
|
|
492
933
|
```ts
|
|
493
|
-
// Inside
|
|
934
|
+
// Inside Effect.gen, with RemoteClient provided.
|
|
494
935
|
const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
|
|
495
|
-
const page = yield* Remote.query(ref)
|
|
496
|
-
|
|
497
|
-
|
|
936
|
+
const page = yield* Remote.query(ref)
|
|
937
|
+
|
|
938
|
+
updateRemote(remote, Remote.queryMessage(ref, page))
|
|
939
|
+
Remote.visibleItems(remote, ref)
|
|
498
940
|
```
|
|
499
941
|
|
|
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.
|
|
942
|
+
`items`, `hasNext`, `hasPrevious`, and `isGapped` describe the known region.
|
|
943
|
+
`ConnectionInvalidated` keeps visible rows but marks the connection stale until
|
|
944
|
+
a refresh/query result settles it. A `ConnectionMerged` page with
|
|
945
|
+
`refreshes: true` replaces a stale connection's pages rather than merging into
|
|
946
|
+
them.
|
|
507
947
|
|
|
508
948
|
### Mutations by hand
|
|
509
949
|
|
|
510
950
|
`Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic })` is
|
|
511
|
-
the one-step imperative
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
951
|
+
the one-step imperative start/run/settle form for SSR and tests.
|
|
952
|
+
|
|
953
|
+
`Remote.mutate(mutation, input, requestId)` is the transport call itself. It
|
|
954
|
+
decodes the typed `Output` and returns normalized entity patches and confirmed
|
|
955
|
+
connection changes for a caller to reduce through `updateRemote`.
|
|
956
|
+
|
|
515
957
|
`MutationSucceeded` reconciles at most once per `requestId`; an unknown or
|
|
516
|
-
already-applied
|
|
958
|
+
already-applied settlement is a no-op.
|
|
517
959
|
|
|
518
960
|
### The transport seam
|
|
519
961
|
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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.
|
|
962
|
+
`RemoteClient` is an Effect service with `read`, `query`, `mutate`, and `live`.
|
|
963
|
+
The wire schemas (`ReadBatch`, `QueryRequest`, `MutationRequest`,
|
|
964
|
+
`LiveRequirement`, results/errors) and the `RemoteRpc` group are exported.
|
|
965
|
+
|
|
966
|
+
`Remote.clientLayer(rpcClient, { window? })` adapts an Effect RPC client,
|
|
967
|
+
including live-change mapping. Compatible reads and queries coalesce:
|
|
968
|
+
requirements issued together become one `ReadBatch`, an identical in-flight
|
|
969
|
+
requirement/query is joined, and a failed request releases the coalesced entry.
|
|
970
|
+
A relation request with its own pagination window stays separate because one
|
|
971
|
+
response answers one window exactly.
|
|
972
|
+
|
|
973
|
+
`Remote.coalesced(layer, options)` adds the same behavior to a hand-written
|
|
974
|
+
`RemoteClient` layer; `window` widens batching beyond work issued concurrently.
|
|
975
|
+
|
|
976
|
+
## What Remote owns
|
|
977
|
+
|
|
978
|
+
Remote owns:
|
|
979
|
+
|
|
980
|
+
- typed entity identity, references, selections, and patches;
|
|
981
|
+
- the normalized entity store, field presence/staleness, and tombstones;
|
|
982
|
+
- pure requirements and deterministic planning;
|
|
983
|
+
- the `Remote.Model` Submodel and its reducer;
|
|
984
|
+
- query connections and pagination boundaries;
|
|
985
|
+
- optimistic mutation overlays and idempotent settlement bookkeeping;
|
|
986
|
+
- live cursors, duplicate suppression, and gap detection;
|
|
987
|
+
- retention/garbage collection of disposable cache data;
|
|
988
|
+
- deterministic, scoped cache snapshots;
|
|
989
|
+
- the `RemoteClient` semantic transport seam and `RemoteRpc` schemas;
|
|
990
|
+
- pure introspection of the cache and plans.
|
|
991
|
+
|
|
992
|
+
Remote does **not** own:
|
|
993
|
+
|
|
994
|
+
- your source database or authoritative server records;
|
|
995
|
+
- local UI/application state outside `Remote.Model`;
|
|
996
|
+
- durable offline client intent or replica convergence;
|
|
997
|
+
- HTTP/WebSocket deployment details;
|
|
998
|
+
- authentication; server-side authorization belongs at the Source boundary;
|
|
999
|
+
- a background scheduler or general-purpose database/query engine.
|
|
1000
|
+
|
|
1001
|
+
## How state changes here
|
|
1002
|
+
|
|
1003
|
+
Local application state uses `evo` inside `update`, like anywhere else.
|
|
1004
|
+
Remote Messages go through `Data.reduce` into the embedded `Remote.Model`
|
|
1005
|
+
submodel — Remote owns that reducer, and it is the only writer of the
|
|
1006
|
+
cache. Never install cache state with a ref `set`; the requirements,
|
|
1007
|
+
staleness, and tombstones only stay coherent when every fact arrives as a
|
|
1008
|
+
Remote Message.
|
|
578
1009
|
|
|
579
1010
|
## Limits
|
|
580
1011
|
|
|
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`).
|
|
1012
|
+
- Effect RPC is the wire contract; the application supplies deployment layers.
|
|
1013
|
+
`ReadBatch` and `LiveRequirement` carry `REMOTE_PROTOCOL_VERSION`, and a
|
|
1014
|
+
mismatched version fails with `RemoteProtocolError` rather than silently
|
|
1015
|
+
drifting.
|
|
1016
|
+
- One request may name at most `MAX_FIELDS_PER_REQUEST` (256) fields of one
|
|
1017
|
+
entity and nest relations at most `MAX_RELATION_DEPTH` (8) levels; the wire
|
|
1018
|
+
rejects larger inputs.
|
|
1019
|
+
- The wire `LiveChange` union carries entity patches/deletes and connection
|
|
1020
|
+
insert/remove/invalidate changes; `RemoteServer.live` serves them as a stream.
|
|
1021
|
+
- Coalescing is scoped to one `RemoteClient` layer. Separate layers do not share
|
|
1022
|
+
batches.
|
|
1023
|
+
- Retention keeps what active Surfaces (and explicit retain entries) reach. An
|
|
1024
|
+
inactive/unretained Surface may lose its cache on `RetentionChanged`.
|
|
1025
|
+
- Two applications with the same Model type are not distinguishable by TypeScript
|
|
1026
|
+
alone; `Data.subscriptions` also checks the Surface owner token at runtime.
|
|
1027
|
+
- `staleWhileRevalidate({ maxAge })` ages entity data. Connections have no age;
|
|
1028
|
+
they re-query when invalidated or when `networkOnly` forces them.
|
|
602
1029
|
- `RemoteData` is a closed union.
|
|
603
|
-
- A
|
|
604
|
-
|
|
1030
|
+
- A Selection must choose at least one field; an empty Selection would require
|
|
1031
|
+
nothing and misleadingly read `Ready` for every id, so it is rejected.
|
|
605
1032
|
|
|
606
1033
|
## See also
|
|
607
1034
|
|
|
608
|
-
- [Server-derived state](
|
|
609
|
-
|
|
610
|
-
- [`foldkit-remote-server`](
|
|
611
|
-
|
|
612
|
-
- [`
|
|
613
|
-
|
|
1035
|
+
- [Server-derived state](../../docs/remote.md) — the deeper architectural guide
|
|
1036
|
+
and ownership model.
|
|
1037
|
+
- [`foldkit-remote-server`](../remote-server) — server Sources, resolution, and
|
|
1038
|
+
semantic-field authorization.
|
|
1039
|
+
- [`foldkit-remote-drizzle`](../remote-drizzle) — optional compilation of Remote
|
|
1040
|
+
selections/queries into Drizzle access.
|
|
1041
|
+
- [`examples/remote`](../../examples/remote) — an asserted trace through plan,
|
|
1042
|
+
prefetch, refresh, query, render, mutation, retention, and decode failure.
|
|
1043
|
+
- [`examples/kitchen-sink`](../../examples/kitchen-sink) — the same architecture
|
|
1044
|
+
using the real server packages.
|