foldkit-remote 0.4.0 → 0.6.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 +428 -79
- package/dist/index.d.mts +588 -38
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1074 -242
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -55,67 +55,17 @@ ownership.
|
|
|
55
55
|
|
|
56
56
|
## The mental model
|
|
57
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
58
|
```text
|
|
94
|
-
Surface
|
|
95
|
-
|
|
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
|
|
59
|
+
Surface Projection → requirements → active Subscription → RemoteClient I/O
|
|
60
|
+
↑ |
|
|
61
|
+
└──────── read Model ← Data.reduce ← Remote Message ──┘
|
|
115
62
|
```
|
|
116
63
|
|
|
117
|
-
|
|
118
|
-
|
|
64
|
+
A Projection reads the current cache and carries requirements such as
|
|
65
|
+
`Project:p1.name` and `User:u7.name`. An active Subscription compares those
|
|
66
|
+
requirements with the cache, then requests missing or stale fields. The
|
|
67
|
+
result returns as a Message; only reducing it changes what the next read sees.
|
|
68
|
+
Rendering the same Projection twice starts no work by itself.
|
|
119
69
|
|
|
120
70
|
## Four pieces to remember
|
|
121
71
|
|
|
@@ -133,7 +83,7 @@ Everything else builds on those four pieces.
|
|
|
133
83
|
## Install
|
|
134
84
|
|
|
135
85
|
```bash
|
|
136
|
-
pnpm add foldkit-remote
|
|
86
|
+
pnpm add foldkit-remote foldkit-entity
|
|
137
87
|
```
|
|
138
88
|
|
|
139
89
|
`foldkit` and `effect` are peer dependencies; `foldkit-surface` comes with the
|
|
@@ -145,9 +95,9 @@ Start with one server entity and one selection:
|
|
|
145
95
|
|
|
146
96
|
```ts
|
|
147
97
|
import { Schema } from 'effect'
|
|
148
|
-
import { Entity } from 'foldkit-
|
|
98
|
+
import { Entity } from 'foldkit-entity'
|
|
149
99
|
|
|
150
|
-
const Project = Entity.
|
|
100
|
+
const Project = Entity.define(
|
|
151
101
|
'Project',
|
|
152
102
|
Schema.Struct({
|
|
153
103
|
id: Schema.String,
|
|
@@ -157,12 +107,20 @@ const Project = Entity.make(
|
|
|
157
107
|
)
|
|
158
108
|
|
|
159
109
|
// A Selection is the exact server-owned shape this consumer needs.
|
|
160
|
-
const ProjectSummary =
|
|
110
|
+
const ProjectSummary = Entity.select(Project, {
|
|
161
111
|
id: true,
|
|
162
112
|
name: true,
|
|
163
113
|
})
|
|
164
114
|
```
|
|
165
115
|
|
|
116
|
+
The entity comes from [`foldkit-entity`](../entity), which declares a domain
|
|
117
|
+
without Remote in it. Remote also has an `Entity.make` of its own, and accepts
|
|
118
|
+
either — but only a `foldkit-entity` entity has addressable `fields`, so only
|
|
119
|
+
that one can be given [relations](#entities-declared-with-foldkit-entity),
|
|
120
|
+
derived members, or a [query body](#queries-and-pagination). Reach for
|
|
121
|
+
`Entity.make` when a domain is small enough that Remote is all it will ever
|
|
122
|
+
need; reach for `Entity.define` otherwise, which is most of the time.
|
|
123
|
+
|
|
166
124
|
Embed Remote's Submodel in the application and bind the domain to that field:
|
|
167
125
|
|
|
168
126
|
```ts
|
|
@@ -283,7 +241,14 @@ Remote.Model
|
|
|
283
241
|
ProjectPage now reads Ready(...)
|
|
284
242
|
```
|
|
285
243
|
|
|
286
|
-
|
|
244
|
+
The first example starts on the `home` route, so no project read runs yet.
|
|
245
|
+
Your route update must activate `{ _tag: 'project', projectId: 'p1' }`, and the
|
|
246
|
+
runtime must install `subscriptions`. Expect `Initial` until work starts,
|
|
247
|
+
`Loading` during the first request, and `Ready` once its result is reduced.
|
|
248
|
+
|
|
249
|
+
Finally, provide the client implementation to the runtime. Here `rpcClient`
|
|
250
|
+
is your configured Effect RPC client, not a value created by the declarations
|
|
251
|
+
above; the [server guide](../remote-server/README.md) supplies the other side:
|
|
287
252
|
|
|
288
253
|
```ts
|
|
289
254
|
const clientLayer = Remote.clientLayer(rpcClient)
|
|
@@ -346,15 +311,19 @@ missing ------------------------------------------------> Loading
|
|
|
346
311
|
|
|
347
312
|
server says entity is absent ---------------------------> NotFound
|
|
348
313
|
stored value fails Selection decoding -----------------> Failed
|
|
314
|
+
a read or a list's query fails ------------------------> Failed (with the old value, if any)
|
|
349
315
|
```
|
|
350
316
|
|
|
351
317
|
The states are:
|
|
352
318
|
|
|
353
319
|
- **`Initial`** — required data is absent and **nothing is currently fetching it**.
|
|
354
|
-
- **`Loading`** — required data is absent and a read is in
|
|
320
|
+
- **`Loading`** — required data is absent and a read, or a list's query, is in
|
|
321
|
+
flight.
|
|
355
322
|
- **`Ready`** — every selected field is present and decodes.
|
|
356
323
|
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
357
|
-
- **`Failed`** — stored server data does not decode against the Selection
|
|
324
|
+
- **`Failed`** — stored server data does not decode against the Selection, or
|
|
325
|
+
the read or query behind it failed. A value that was already shown is kept as
|
|
326
|
+
`previous`.
|
|
358
327
|
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
359
328
|
deleted it, or the server was asked for it by id and answered without it. That
|
|
360
329
|
id is then known absent, whether it never existed, is gone, or is not this
|
|
@@ -371,6 +340,46 @@ state that actually means "wait for this request."
|
|
|
371
340
|
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
372
341
|
compile time.
|
|
373
342
|
|
|
343
|
+
### Drawing one: `RemoteData.render`
|
|
344
|
+
|
|
345
|
+
Most views draw these six states three ways, under one policy: useful data
|
|
346
|
+
stays on screen. `RemoteData.render` is that fold.
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
RemoteData.render(project, {
|
|
350
|
+
loading: () => ProjectSkeleton(),
|
|
351
|
+
notFound: () => NoSuchProject(),
|
|
352
|
+
failed: error => ErrorView(error),
|
|
353
|
+
data: (value, freshness) =>
|
|
354
|
+
ProjectView({ project: value, dimmed: freshness._tag !== 'Fresh' }),
|
|
355
|
+
})
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`Initial` and `Loading` reach `loading`; `Ready` and `Refreshing` reach `data`;
|
|
359
|
+
a `Failed` that still carries the value it had reaches `data` too, so a read
|
|
360
|
+
that failed does not throw away what the reader was already looking at. Only a
|
|
361
|
+
`Failed` with nothing to show reaches `failed`.
|
|
362
|
+
|
|
363
|
+
The `data` branch is told which it got, as a `Freshness` — exported, so a view
|
|
364
|
+
helper that takes one can name it:
|
|
365
|
+
|
|
366
|
+
| `Freshness` | What it means |
|
|
367
|
+
| --- | --- |
|
|
368
|
+
| `Fresh` | This is the current answer. |
|
|
369
|
+
| `Refreshing` | A newer answer is on its way; this one is still good. |
|
|
370
|
+
| `Stale` | A read failed; this is what was last known good, with its `error`. |
|
|
371
|
+
|
|
372
|
+
It is one tag rather than a pair of booleans because a value cannot be both
|
|
373
|
+
refreshing and stale, and a type that can say so invites a view to handle a
|
|
374
|
+
state that never arrives.
|
|
375
|
+
|
|
376
|
+
`notFound` is its own branch and not optional. A row the server answered for
|
|
377
|
+
and does not have is neither loading nor a failure; drawing it as either is a
|
|
378
|
+
spinner that never ends or an error nobody can act on.
|
|
379
|
+
|
|
380
|
+
Reach for `match` instead when the six states really do draw differently — it
|
|
381
|
+
stays the exhaustive fold, and `render` does not replace it.
|
|
382
|
+
|
|
374
383
|
## Normalized entities and selections
|
|
375
384
|
|
|
376
385
|
Remote stores an entity once by identity, regardless of how many Surfaces read
|
|
@@ -444,7 +453,9 @@ const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name;
|
|
|
444
453
|
|
|
445
454
|
- A relation becomes a ref field: `one` a ref, an optional `one` a nullable ref,
|
|
446
455
|
`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.
|
|
456
|
+
- The Entity needs an `id` field; Remote keys the store by it. When that field
|
|
457
|
+
is branded, `Data.get` and `Data.live` take that type for an Entity Selection:
|
|
458
|
+
another Entity's id, or plain text, does not compile.
|
|
448
459
|
- Register the Entities of one `Entity.relate` result and select from that same
|
|
449
460
|
result; `Object.values(Work)` registers them all.
|
|
450
461
|
- `Entity.from(entity)` and `Selection.from(selection)` are the compile steps,
|
|
@@ -452,9 +463,14 @@ const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name;
|
|
|
452
463
|
handler.
|
|
453
464
|
- `Entity.page(selection, window)` in an Entity Selection is Remote's
|
|
454
465
|
`Selection.connection`: the window travels with the read, and the server
|
|
455
|
-
answers with a page of refs.
|
|
456
|
-
|
|
457
|
-
same
|
|
466
|
+
answers with a page of refs. A page of a whole list is read under a name of
|
|
467
|
+
its own, `comments@first=10`, so one view can show every comment while another
|
|
468
|
+
shows the first ten of the same post: they are two fields to the store, fetched
|
|
469
|
+
in one batch, merged and refreshed each on its own. A write to the list (a
|
|
470
|
+
mutation's patch, a live change) marks its pages stale, so they are read again.
|
|
471
|
+
A cursor is not part of the name: a page read from a cursor continues its page.
|
|
472
|
+
The server refuses a request that pages one relation more than four ways, and
|
|
473
|
+
a field's own name may not contain `@`.
|
|
458
474
|
|
|
459
475
|
`Entity.make` with `Entity.ref` keeps working, and both kinds can share one
|
|
460
476
|
domain. Both packages export `Entity`; a module that needs `Entity.from` beside
|
|
@@ -509,6 +525,24 @@ Projection itself.
|
|
|
509
525
|
|
|
510
526
|
### Refreshing from `update`
|
|
511
527
|
|
|
528
|
+
### Showing a change nobody has made
|
|
529
|
+
|
|
530
|
+
A mutation's `optimistic` operations show over the store while it is in flight.
|
|
531
|
+
`Data.overlay` shows operations the same way with no request behind them, until
|
|
532
|
+
`Data.lift`: a preview, in every Selection and view, of something not yet sent.
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
const previewed = Data.overlay(model, 'post-preview', [Project.patch(id, { name: draft })])
|
|
536
|
+
const back = Data.lift(previewed, 'post-preview')
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
- Both are called from `update`, and neither performs I/O or touches what the
|
|
540
|
+
server said: the store beneath is as it was.
|
|
541
|
+
- Showing an id again replaces what it showed. Lifting what was never shown
|
|
542
|
+
returns the same Model.
|
|
543
|
+
- An overlay's id is apart from every request's, so a mutation that settles does
|
|
544
|
+
not take a preview with it.
|
|
545
|
+
|
|
512
546
|
To revalidate what a screen already declares — a refresh button, a focus
|
|
513
547
|
regained — hand its Projection (or a Surface without params) to `Data.refresh`:
|
|
514
548
|
|
|
@@ -521,8 +555,9 @@ case 'ClickedRefresh': {
|
|
|
521
555
|
```
|
|
522
556
|
|
|
523
557
|
`Data.refresh` performs no I/O and restates no request. It returns the Model
|
|
524
|
-
with every selected field the store holds reading `Refreshing
|
|
525
|
-
|
|
558
|
+
with every selected field the store holds reading `Refreshing`, every entity it
|
|
559
|
+
knew to be absent forgotten, so `NotFound` reads `Loading` and is asked for
|
|
560
|
+
again, and every loaded connection invalidated; the `Data.subscriptions` read entries then refetch it,
|
|
526
561
|
since stale data is planned again under every policy, so the page is requested
|
|
527
562
|
once. The Projection must be observed, as it is while it is on screen; for data
|
|
528
563
|
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
@@ -555,6 +590,52 @@ const Data = Remote.make({
|
|
|
555
590
|
})
|
|
556
591
|
```
|
|
557
592
|
|
|
593
|
+
`Query.make` names a query and says what it returns. Where it *means*
|
|
594
|
+
something the client should be able to state, declare it with a body instead:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { Entity, Expr, Order, Query } from 'foldkit-entity'
|
|
598
|
+
|
|
599
|
+
const Task = Entity.define(
|
|
600
|
+
'Task',
|
|
601
|
+
Schema.Struct({ id: Schema.String, ownerId: Schema.String, updatedAt: Schema.Number }),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
const TasksByOwner = Query.define(
|
|
605
|
+
'TasksByOwner',
|
|
606
|
+
{ ownerId: Schema.String },
|
|
607
|
+
({ input }) =>
|
|
608
|
+
Query.from(Task).pipe(
|
|
609
|
+
Query.where(Expr.eq(Task.fields.ownerId, input.ownerId)),
|
|
610
|
+
Query.orderBy(Order.desc(Task.fields.updatedAt), Order.asc(Task.fields.id)),
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
A body is written over a [`foldkit-entity`](../entity) Entity, because that is
|
|
616
|
+
what has addressable fields: `Task.fields.ownerId` is a reference an `Expr` can
|
|
617
|
+
be built from, where this package's own `Entity.make` describes a field as the
|
|
618
|
+
schema of its value and has nothing to point at. An entity declared either way
|
|
619
|
+
still reads, selects and normalizes the same; only a query body needs the
|
|
620
|
+
richer declaration.
|
|
621
|
+
|
|
622
|
+
What comes back is an ordinary descriptor — the same name, `Input`, `ref` and
|
|
623
|
+
connection identity — carrying its `body` besides, and the result is a
|
|
624
|
+
connection over the Entity the body reads, so it is not named twice. A server
|
|
625
|
+
can compile that body rather than be told the same thing again in its own
|
|
626
|
+
dialect; one that would rather answer the query its own way still can, and a
|
|
627
|
+
descriptor from `Query.make` has no body at all.
|
|
628
|
+
|
|
629
|
+
The body is built **once**, when the query is declared. Inside it `input.ownerId`
|
|
630
|
+
is a placeholder for the value the query will be given, not the value — there is
|
|
631
|
+
nothing there yet to branch on. A condition that depends on what was passed is a
|
|
632
|
+
comparison over the placeholder, never a `?:` around it.
|
|
633
|
+
|
|
634
|
+
`Input` must be fields or a plain `Schema.Struct`, because that is what the
|
|
635
|
+
placeholders are made from. A codec that exposes no keys is refused where the
|
|
636
|
+
query is declared rather than handing the body an empty object, which would
|
|
637
|
+
compare a column to nothing while every type agreed.
|
|
638
|
+
|
|
558
639
|
Then a query is still just a Projection:
|
|
559
640
|
|
|
560
641
|
```ts
|
|
@@ -586,6 +667,159 @@ their required fields. `Data.next` / `Data.previous` preserve the page size and
|
|
|
586
667
|
use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
|
|
587
668
|
not from guessing based on row counts.
|
|
588
669
|
|
|
670
|
+
### Filtering a loaded list without asking the server
|
|
671
|
+
|
|
672
|
+
A search box over a page you already have should not be a round trip.
|
|
673
|
+
`Data.filtered` narrows a loaded list by a query body:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const found = Data.filtered(model, projects, ActiveProjects, {})
|
|
677
|
+
|
|
678
|
+
found.items // decoded exactly as the list decodes them
|
|
679
|
+
found.complete // whether the answer was about the whole list
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
**It filters a list; it does not run a query**, and the distinction is the
|
|
683
|
+
design. "Which rows match" would need to know this list holds every row the body
|
|
684
|
+
could match — predicate containment, which Remote deliberately does not do.
|
|
685
|
+
"Which rows *of this list* match" is decidable from what is already here.
|
|
686
|
+
|
|
687
|
+
`complete` is false unless three things hold: every edge could be judged (no row
|
|
688
|
+
missing a field the *body* reads), every match could be shown (no row missing a
|
|
689
|
+
field the *Selection* reads), and the list is terminal at both ends. So
|
|
690
|
+
empty-and-complete and empty-and-partial stay different answers — one means
|
|
691
|
+
"none", the other means "none that I can see yet".
|
|
692
|
+
|
|
693
|
+
Nothing is created: no connection, nothing new to retain, nothing new to fetch.
|
|
694
|
+
The server stays authoritative for which rows exist.
|
|
695
|
+
|
|
696
|
+
### Judging the rows you already hold
|
|
697
|
+
|
|
698
|
+
A connection's rows are edges the server delivered, and a query body describes
|
|
699
|
+
*which rows* — so until something evaluates the body on the client, nothing here
|
|
700
|
+
can say whether a row belongs to a query. `Remote.matching` says:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
const judged = Remote.matching(Data.storeOf(model), ProjectsByOwner, { ownerId: 'u1' })
|
|
704
|
+
|
|
705
|
+
judged.matched // keys satisfying the body, in the order it asks for
|
|
706
|
+
judged.skipped // keys held, but missing a field the body reads
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
It is pure, and it runs the **same interpreter the server checks itself
|
|
710
|
+
against** — the reference implementation in
|
|
711
|
+
[`foldkit-entity`](../entity) — so a body means one thing in both places. The
|
|
712
|
+
conformance suite is run over a store as well as over rows and a table.
|
|
713
|
+
|
|
714
|
+
Three things about it are deliberate.
|
|
715
|
+
|
|
716
|
+
**It answers "which of the rows I hold match", never "which rows match."** Those
|
|
717
|
+
are different questions, and the difference is not recoverable from a list of
|
|
718
|
+
keys — so it is in the shape. A caller that knows it holds the whole population
|
|
719
|
+
(a connection terminal at both ends) is the one that can turn this into a
|
|
720
|
+
complete answer.
|
|
721
|
+
|
|
722
|
+
**`skipped` is the honest half.** A row missing a field the body reads cannot be
|
|
723
|
+
judged: it is neither a match nor a non-match. Dropping it silently would turn
|
|
724
|
+
"I could not tell" into "no", so it is named instead, and the caller decides
|
|
725
|
+
whether to fetch the field, ask the server, or say the answer is partial.
|
|
726
|
+
|
|
727
|
+
**It takes the input decoded and encodes it itself.** A store holds wire values,
|
|
728
|
+
so a comparison has to happen in that space — and handing an interpreter a
|
|
729
|
+
decoded value is a mistake *no runtime error catches*: the answer is simply
|
|
730
|
+
empty, which reads exactly like a correct one. Doing the encoding here means a
|
|
731
|
+
caller cannot get it wrong.
|
|
732
|
+
|
|
733
|
+
It is **not authorization**. On the server a compiled `where` is conjoined with
|
|
734
|
+
the binding's `visible` rule; there is no `visible` here. This is safe only
|
|
735
|
+
because the client holds only rows the server already released to it, and a
|
|
736
|
+
local filter is not an access decision.
|
|
737
|
+
|
|
738
|
+
### An input that changes as fast as someone types
|
|
739
|
+
|
|
740
|
+
A connection's identity is its query plus its **input**, which is exactly right
|
|
741
|
+
for caching and exactly wrong for a search box. Bound naively, every keystroke
|
|
742
|
+
is a different connection, a different request, and a different thing to retain:
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
// Don't: `postSearch` changes per keystroke, so the query's input does too.
|
|
746
|
+
h.OnInput(text => Message.Searched({ text }))
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Remote has no debounce, and should not: its job is to be a faithful function of
|
|
750
|
+
the Model, so if the Model says the search is `Engi` then `Engi` is what the
|
|
751
|
+
query asks. **The debounce belongs between the input Message and the Model field
|
|
752
|
+
the query reads** — which is ordinary application state, and
|
|
753
|
+
[`foldkit-primitives`](../primitives) already has the piece:
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
import { debounce } from 'foldkit-primitives/time'
|
|
757
|
+
|
|
758
|
+
const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
|
|
759
|
+
const SearchBox = Bundle.declare(SearchInput, 'search')
|
|
760
|
+
|
|
761
|
+
// `latest` is what the box shows, so typing stays immediate.
|
|
762
|
+
// The settled OutMessage is what moves the field the query reads.
|
|
763
|
+
Page.at(SearchBox, {
|
|
764
|
+
args: { delayMs: 250 },
|
|
765
|
+
onOut: out => model => ({ model: evo(model, { postSearch: () => out.value }) }),
|
|
766
|
+
})
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Two fields, deliberately: the one the box draws changes on every keystroke, and
|
|
770
|
+
the one the query reads changes a quarter second after the last one.
|
|
771
|
+
[`examples/entity`](../../examples/entity) does exactly this.
|
|
772
|
+
|
|
773
|
+
The same applies to any high-frequency query input — a slider, a map viewport, a
|
|
774
|
+
date scrubber. What makes a search box the usual case is that the query is often
|
|
775
|
+
written *for* it: `Expr.contains` over an empty string matches everything, so an
|
|
776
|
+
empty box and a filled one are one query rather than two.
|
|
777
|
+
|
|
778
|
+
### A page that overruns its window is refused
|
|
779
|
+
|
|
780
|
+
The client asks for `first: 25`, and a page carrying more than that is a
|
|
781
|
+
protocol disagreement rather than a windfall: the client cannot tell which of
|
|
782
|
+
the edges the window meant, and the connection's boundaries stop describing what
|
|
783
|
+
it holds. Such a page becomes a `QueryFailed` with a named protocol error, and
|
|
784
|
+
none of its edges reach the store — a connection already loaded is left exactly
|
|
785
|
+
as it was, so a refresh that overruns cannot replace good rows with a rejected
|
|
786
|
+
page.
|
|
787
|
+
|
|
788
|
+
A window with neither `first` nor `last` bounds nothing: `after`/`before` says
|
|
789
|
+
where to start, not how much to take.
|
|
790
|
+
|
|
791
|
+
### When a read fails
|
|
792
|
+
|
|
793
|
+
A failure is kept on the Model until something settles it, and the read says
|
|
794
|
+
so. Queries fail per connection, and entity reads per field. A read with
|
|
795
|
+
nothing to show reads `Failed` with the error. One that had a value (a failed
|
|
796
|
+
refresh, or a failed "load more") reads `Failed` with that value as
|
|
797
|
+
`previous`, which `RemoteData.render` draws as data with a `Stale` freshness,
|
|
798
|
+
so it stays on screen. A failure reaches every read that needs it: a list
|
|
799
|
+
fails when one of its rows' fields failed, and a project read fails when its
|
|
800
|
+
owner's did.
|
|
801
|
+
|
|
802
|
+
**It is not retried on its own.** A persistent error would otherwise be asked
|
|
803
|
+
again every time some unrelated read restarted the entry. What failed is left
|
|
804
|
+
out of the plan, and everything else is still fetched: the rows a failed list
|
|
805
|
+
holds, the other fields of an entity. What asks again:
|
|
806
|
+
|
|
807
|
+
- `Data.refresh(model, projection)` — the retry button. It works on something
|
|
808
|
+
that never loaded, too.
|
|
809
|
+
- The value arriving anyway: a page for a list, or a read, live patch or
|
|
810
|
+
mutation result that writes the field.
|
|
811
|
+
- The server invalidating a list over a live stream, or deleting the entity.
|
|
812
|
+
- Retention dropping it: something nothing reads any more forgets its failure,
|
|
813
|
+
so coming back to it later asks the server again.
|
|
814
|
+
|
|
815
|
+
`Remote.inspect(model.remote).failures` lists what failed, by connection
|
|
816
|
+
identity and by `entity\0id\0field` mark.
|
|
817
|
+
|
|
818
|
+
A broken live stream is not a failed read. Its `ReadFailed` carries the
|
|
819
|
+
`stream`, and records a gap in `RemoteModel.gaps` rather than failing any
|
|
820
|
+
field: nothing was being read, and the values on screen are still what the
|
|
821
|
+
server last said.
|
|
822
|
+
|
|
589
823
|
## Mutations and optimistic state
|
|
590
824
|
|
|
591
825
|
Register mutations on the domain:
|
|
@@ -663,6 +897,41 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
|
663
897
|
A successful server result can replace the request's temporary connection
|
|
664
898
|
overlays with confirmed ones in place.
|
|
665
899
|
|
|
900
|
+
### Reading past what is only pending
|
|
901
|
+
|
|
902
|
+
Every projection reads the **visible** cache: the server-derived store under
|
|
903
|
+
the layers still pending, which is why an optimistic change shows at once. A
|
|
904
|
+
reader that must not believe a change until the server has agreed to it asks
|
|
905
|
+
for the confirmed read instead:
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
Data.confirmed(Data.get(ProjectSummary, projectId))
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
It is the same projection — the same requirements, planned the same way, so
|
|
912
|
+
observing it fetches exactly what observing the original fetches — reading the
|
|
913
|
+
server-derived store alone, with the pending layers and connection overlays
|
|
914
|
+
left off. An optimistically inserted edge is not in its page; an
|
|
915
|
+
optimistically patched field reads as the server last said.
|
|
916
|
+
|
|
917
|
+
A view almost always wants the projection itself. This is for the reader that
|
|
918
|
+
reports on the world rather than drawing it, which in practice is an Agent
|
|
919
|
+
capability that must not claim success before the server reflects it:
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
Agent.when({
|
|
923
|
+
projection: Data.confirmed(Data.get(ProjectSummary, projectId)),
|
|
924
|
+
predicate: (project, request) => project.name === request.name,
|
|
925
|
+
})
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
There is deliberately no `Data.visible`: a projection already is the visible
|
|
929
|
+
read, and a second name for it would be a wrapper that only forwards.
|
|
930
|
+
|
|
931
|
+
Sync draws the same line over its replica, with the same words and a different
|
|
932
|
+
mechanism: see [what a reader sees while a change is in
|
|
933
|
+
flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight).
|
|
934
|
+
|
|
666
935
|
### Remote mutation vs Sync operation
|
|
667
936
|
|
|
668
937
|
Do not use Remote mutation as a durable offline-write mechanism:
|
|
@@ -727,27 +996,34 @@ and refetch; Sync recovery must preserve unsent client intent.**
|
|
|
727
996
|
|
|
728
997
|
## Persistence and hydration
|
|
729
998
|
|
|
730
|
-
|
|
731
|
-
|
|
999
|
+
A snapshot is `{ entities, connections }`. The entity cache is disposable and
|
|
1000
|
+
always goes; a connection goes only if named, and keeps its edges alone — never
|
|
1001
|
+
a cursor, which may name server state that is gone. Session machinery (live
|
|
1002
|
+
cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
|
|
1003
|
+
never in one.
|
|
732
1004
|
|
|
733
1005
|
```ts
|
|
734
|
-
import {
|
|
1006
|
+
import { RemotePersistence } from 'foldkit-remote'
|
|
735
1007
|
|
|
736
1008
|
// SSR: prefetch on the server, embed, then hydrate on the client.
|
|
737
|
-
|
|
738
|
-
|
|
1009
|
+
// `snapshotOf` names the connections that survive; none, here.
|
|
1010
|
+
const text = RemotePersistence.dehydrate(
|
|
1011
|
+
RemotePersistence.snapshotOf(loaded.remote),
|
|
739
1012
|
{ scope: userId },
|
|
740
1013
|
)
|
|
741
1014
|
|
|
1015
|
+
const restored =
|
|
1016
|
+
RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
|
|
1017
|
+
|
|
742
1018
|
Data.reduce(model, {
|
|
743
1019
|
_tag: 'Hydrated',
|
|
744
|
-
entities:
|
|
745
|
-
|
|
1020
|
+
entities: restored.entities,
|
|
1021
|
+
connections: restored.connections,
|
|
746
1022
|
merge: 'preserve-existing',
|
|
747
1023
|
})
|
|
748
1024
|
|
|
749
1025
|
// Or persist through Effect's KeyValueStore.
|
|
750
|
-
RemotePersistence.save(
|
|
1026
|
+
RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
|
|
751
1027
|
key: 'remote-cache',
|
|
752
1028
|
scope: userId,
|
|
753
1029
|
maxBytes: 512_000,
|
|
@@ -811,6 +1087,22 @@ See [`foldkit-remote-server`](../remote-server) for Source and authorization
|
|
|
811
1087
|
rules, and [`examples/kitchen-sink`](../../examples/kitchen-sink) for the real
|
|
812
1088
|
server packages together.
|
|
813
1089
|
|
|
1090
|
+
## Finding the next API
|
|
1091
|
+
|
|
1092
|
+
| Task | Start here |
|
|
1093
|
+
| --- | --- |
|
|
1094
|
+
| Explain `Initial`, `Loading`, or a stale value | [`RemoteData`](#remotedata-what-does-the-model-know-right-now) |
|
|
1095
|
+
| Fetch outside an active screen | [Policies and prefetch](#reading-policies-and-prefetch) |
|
|
1096
|
+
| Load an ordered list | [Queries and pagination](#queries-and-pagination) |
|
|
1097
|
+
| Save and show an optimistic result | [Mutations](#mutations-and-optimistic-state) |
|
|
1098
|
+
| Release data no active feature needs | [Retention](#retention-and-garbage-collection) |
|
|
1099
|
+
| Seed a cache after reload or SSR | [Hydration](#persistence-and-hydration) |
|
|
1100
|
+
|
|
1101
|
+
A successful mutation and an optimistic overlay have different guarantees:
|
|
1102
|
+
the overlay is only a temporary view of server-owned data. Use Sync when the
|
|
1103
|
+
operation itself must survive offline, rather than persisting an optimistic
|
|
1104
|
+
Remote cache and treating it as an outbox.
|
|
1105
|
+
|
|
814
1106
|
## Introspection
|
|
815
1107
|
|
|
816
1108
|
Remote exposes its decisions as data rather than requiring DevTools to reach
|
|
@@ -819,6 +1111,7 @@ into private state:
|
|
|
819
1111
|
```ts
|
|
820
1112
|
Data.inspect(model) // serializable cache/domain summary
|
|
821
1113
|
Data.plan(model, projection) // the entity plan a read would execute
|
|
1114
|
+
Data.explain(model, query) // what one query read is, and currently is
|
|
822
1115
|
Remote.planQueries(...) // missing/stale query work
|
|
823
1116
|
Remote.inspectEntity(...) // one normalized entity
|
|
824
1117
|
```
|
|
@@ -828,6 +1121,62 @@ useful question is: **"Why is this Projection still Initial?"** `Data.plan`
|
|
|
828
1121
|
shows whether Remote believes anything is actually missing; active Surface
|
|
829
1122
|
wiring determines whether that plan is being executed.
|
|
830
1123
|
|
|
1124
|
+
`Data.inspect(model).loading` answers the other one — **"what is Remote doing
|
|
1125
|
+
right now?"** — with the `entity\0id\0field` marks of the reads in flight,
|
|
1126
|
+
beside `mutations.pending` for the writes. Both are read from the Model, not
|
|
1127
|
+
from the fibers doing the work: a tool that shows them shows something a
|
|
1128
|
+
recorded Model can be replayed to, and nothing that needs the runtime to be
|
|
1129
|
+
asked. That is deliberate. Runtime activity is not a second source of truth
|
|
1130
|
+
here, and a view that rendered from it would stop being reproducible from the
|
|
1131
|
+
Model.
|
|
1132
|
+
|
|
1133
|
+
### Explaining one query read
|
|
1134
|
+
|
|
1135
|
+
A query read is assembled from more pieces than it looks like: a definition, an
|
|
1136
|
+
input, a connection identity that excludes the window, a window that does not,
|
|
1137
|
+
and a Selection that is a slice asked of the server. `Data.explain` puts them in
|
|
1138
|
+
one serializable value, beside what the read answers from this Model right now:
|
|
1139
|
+
|
|
1140
|
+
```ts
|
|
1141
|
+
Data.explain(model, projects)
|
|
1142
|
+
// {
|
|
1143
|
+
// domain: 'remote',
|
|
1144
|
+
// query: 'ProjectsByOwner',
|
|
1145
|
+
// input: { ownerId: 'u1' },
|
|
1146
|
+
// identity: 'ProjectsByOwner\u0000{"ownerId":"u1"}',
|
|
1147
|
+
// window: { first: 25 },
|
|
1148
|
+
// select: { entity: 'Project', fields: ['id', 'name'] },
|
|
1149
|
+
// body: 'FROM Project\nWHERE Project.ownerId = $ownerId\nORDER BY Project.name DESC',
|
|
1150
|
+
// dependencies: { fields: [...], inputs: ['ownerId'], operations: ['eq'] },
|
|
1151
|
+
// state: 'Ready',
|
|
1152
|
+
// }
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
`body` is the query's meaning as readable text, and it is neither SQL nor
|
|
1156
|
+
whatever the backend compiled — an input shows as `$ownerId` rather than a bound
|
|
1157
|
+
parameter, and `contains` is named rather than rendered as somebody's `like`. A
|
|
1158
|
+
definition made with `Query.make` has no `body`, because its meaning lives on
|
|
1159
|
+
the server that answers it.
|
|
1160
|
+
|
|
1161
|
+
`state` comes from the projection's own read, so an explanation and the view
|
|
1162
|
+
cannot disagree about whether the data is there.
|
|
1163
|
+
|
|
1164
|
+
A Projection cannot say which Surfaces read it — several may — so pass the
|
|
1165
|
+
active record `subscriptions` takes and `explain` reports all of them, plus why
|
|
1166
|
+
each is active where that is a readable fact (`Surface.when`) rather than a
|
|
1167
|
+
callback (`Surface.at`):
|
|
1168
|
+
|
|
1169
|
+
```ts
|
|
1170
|
+
Data.explain(model, projects, { surfaces: actives })
|
|
1171
|
+
// surfaces: ['ProjectPage']
|
|
1172
|
+
// activation: [{ surface: 'ProjectPage', path: ['route'], tag: 'Project' }]
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
There is still no **executor**, because what answers a query is a
|
|
1176
|
+
`RemoteClient` Layer in the runtime rather than a value in the Model — the same
|
|
1177
|
+
purity that lets this be replayed from a recorded Model is what keeps it out of
|
|
1178
|
+
reach.
|
|
1179
|
+
|
|
831
1180
|
## Advanced: the kernel
|
|
832
1181
|
|
|
833
1182
|
Everything above compiles to the lower-level exports below; there is not a
|