foldkit-remote 0.8.0 → 0.9.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 +152 -31
- package/dist/index.d.mts +301 -44
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +624 -218
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -283,14 +283,15 @@ what it needs, and something else decides whether to ask.
|
|
|
283
283
|
### 4. Let the active screen drive the fetching
|
|
284
284
|
|
|
285
285
|
```ts
|
|
286
|
+
import { Option } from 'effect'
|
|
286
287
|
import * as Subscription from 'foldkit/subscription'
|
|
287
288
|
|
|
288
289
|
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
289
290
|
foldData.subscriptions({
|
|
290
291
|
project: Surface.at(ProjectPage, model =>
|
|
291
292
|
model.route._tag === 'project'
|
|
292
|
-
? { projectId: model.route.projectId }
|
|
293
|
-
:
|
|
293
|
+
? Option.some({ projectId: model.route.projectId })
|
|
294
|
+
: Option.none(),
|
|
294
295
|
),
|
|
295
296
|
}),
|
|
296
297
|
)
|
|
@@ -301,6 +302,21 @@ page, Remote sees its requirements, diffs them against the cache, and fetches
|
|
|
301
302
|
only what is missing. When the page is inactive, it creates no read work. This
|
|
302
303
|
is the one step that causes I/O, and it does so only while the screen is on.
|
|
303
304
|
|
|
305
|
+
A read that is not a Surface's, such as the record a detail pane shows, is made
|
|
306
|
+
active with `Data.active`: its function of the Model returns the read, or none
|
|
307
|
+
while there is nothing to read.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
const detail = Data.active('ProjectDetail', model =>
|
|
311
|
+
Option.map(model.openProjectId, id => Data.get(Project.select({ name: true }), id)),
|
|
312
|
+
)
|
|
313
|
+
foldData.subscriptions({ detail })
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
It belongs to the domain's application and sends no Messages. A domain bound
|
|
317
|
+
to a raw optic (`ModelRef.fromOptic`) names no application, so `Data.active`
|
|
318
|
+
throws for one rather than guess.
|
|
319
|
+
|
|
304
320
|
### 5. Provide a client
|
|
305
321
|
|
|
306
322
|
The Subscription needs a `RemoteClient`. Before there is a server, a backend
|
|
@@ -381,7 +397,7 @@ function update(model: Model, message: Message): Update.Return<Model, Message, R
|
|
|
381
397
|
```
|
|
382
398
|
|
|
383
399
|
The rest of this guide uses the fold. With the spread, read `Data.subscriptions`
|
|
384
|
-
for `foldData.subscriptions` and `Data.
|
|
400
|
+
for `foldData.subscriptions` and `Data.mutate` for `foldData.mutate`.
|
|
385
401
|
|
|
386
402
|
### One value with `foldkit-bundle`: `Data.wiring`
|
|
387
403
|
|
|
@@ -395,7 +411,9 @@ const Page = Bundle.parent({ Model, Message })
|
|
|
395
411
|
const wiring = Page.assemble(
|
|
396
412
|
Data.wiring({
|
|
397
413
|
project: Surface.at(ProjectPage, model =>
|
|
398
|
-
model.route._tag === 'project'
|
|
414
|
+
model.route._tag === 'project'
|
|
415
|
+
? Option.some({ projectId: model.route.projectId })
|
|
416
|
+
: Option.none(),
|
|
399
417
|
),
|
|
400
418
|
}),
|
|
401
419
|
)
|
|
@@ -730,10 +748,42 @@ What Remote should do when the Model already contains the selected fields is a
|
|
|
730
748
|
|
|
731
749
|
A refreshing policy emits `RefreshStarted` before the read, so the Projection
|
|
732
750
|
becomes `Refreshing` without discarding the old value. Planning accepts `now`
|
|
733
|
-
|
|
751
|
+
as an input, so tests can control time. It defaults to `Date.now`, read at each
|
|
752
|
+
use, so fake timers (`vi.useFakeTimers`) move it; Effect's `TestClock` does not,
|
|
753
|
+
because a plan runs outside Effect, so a test that drives `TestClock` passes
|
|
754
|
+
`now`. A mutation's answer is dated the same way, by `Data.mutate`'s `now`
|
|
755
|
+
option.
|
|
756
|
+
|
|
757
|
+
A policy belongs to one `subscriptions` call, and covers every read in it. Reads
|
|
758
|
+
under different policies take a call each:
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
const projectPage = Surface.at(ProjectPage, model =>
|
|
762
|
+
model.route._tag === 'project' ? Option.some({ projectId: model.route.projectId }) : Option.none(),
|
|
763
|
+
)
|
|
764
|
+
const owner = Data.active('Owner', () => Option.some(Data.get(UserSummary, 'u1')))
|
|
765
|
+
|
|
766
|
+
const pageEntries = Data.subscriptions({ page: projectPage })
|
|
767
|
+
// Retention is the domain's: one `retain` roots every read, so the second
|
|
768
|
+
// call's is left out rather than spread over the first's.
|
|
769
|
+
const { retain: _domainRetain, ...ownerEntries } = Data.subscriptions(
|
|
770
|
+
{ owner },
|
|
771
|
+
{ policy: RemotePolicy.staleWhileRevalidate({ maxAge: 5_000 }) },
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() => ({
|
|
775
|
+
...pageEntries,
|
|
776
|
+
...ownerEntries,
|
|
777
|
+
}))
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
Retention stays the domain's: a call's `retain` entry roots every read any call
|
|
781
|
+
of the domain names, so neither call collects what the other reads. Keeping one
|
|
782
|
+
`retain` entry is all it takes; it waits for its own call's `grace`.
|
|
734
783
|
|
|
735
|
-
**Time reaches Remote only as a Message.** The plan is a function of the
|
|
736
|
-
and `now`, and it runs
|
|
784
|
+
**Time reaches Remote only as a Message.** The plan is a function of the Remote
|
|
785
|
+
model, what is asked, and `now`, and it runs again only when one of those
|
|
786
|
+
changes; a page that sits still past its
|
|
737
787
|
`maxAge` would otherwise never be looked at again. So a read entry under
|
|
738
788
|
`staleWhileRevalidate` knows when the earliest value it holds ages out, sleeps
|
|
739
789
|
until then under the Effect clock, and emits `RefreshStarted` for what is due.
|
|
@@ -758,6 +808,24 @@ const loaded = await Effect.runPromise(
|
|
|
758
808
|
Remote Messages reduced into it. It never changes the semantics of the
|
|
759
809
|
Projection itself.
|
|
760
810
|
|
|
811
|
+
A render that fetches nothing (SSR, a prerender) needs everything a page's
|
|
812
|
+
active Surfaces read, and one read can decide what another asks for: a page's
|
|
813
|
+
Blocks are known once its document is. `Data.satisfy` prefetches every active
|
|
814
|
+
Surface, cache-first, pass after pass until a pass plans nothing:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
const ready = await Effect.runPromise(
|
|
818
|
+
Data.satisfy(model, actives).pipe(Effect.provide(clientLayer)),
|
|
819
|
+
)
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
`actives` is the record `Data.wiring` takes. The passes are bounded (8 by
|
|
823
|
+
default, `{ passes }` to change it); a Surface still reading after the last
|
|
824
|
+
fails the Effect with `RemoteUnsatisfied`, which names it, rather than
|
|
825
|
+
rendering it loading. A read the server leaves unanswered settles as missing,
|
|
826
|
+
so it ends the loop; what runs out the passes is a chain whose every read
|
|
827
|
+
reveals one more.
|
|
828
|
+
|
|
761
829
|
### Refreshing from `update`
|
|
762
830
|
|
|
763
831
|
To revalidate what a screen already declares — a refresh button, a focus
|
|
@@ -779,9 +847,9 @@ since stale data is planned again under every policy, so the page is requested
|
|
|
779
847
|
once. The Projection must be observed, as it is while it is on screen; for data
|
|
780
848
|
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
781
849
|
|
|
782
|
-
- A refreshed connection
|
|
783
|
-
|
|
784
|
-
|
|
850
|
+
- A refreshed connection is asked for again as one page the size of the widest
|
|
851
|
+
window reading it (including what "load more" grew), which replaces what it
|
|
852
|
+
held, so items the server removed or reordered follow it.
|
|
785
853
|
- A read already in flight restarts instead of landing after the refresh.
|
|
786
854
|
- Refreshing what is already refreshing, or nothing, returns the same Model.
|
|
787
855
|
|
|
@@ -888,11 +956,10 @@ const projects = Data.query(
|
|
|
888
956
|
)
|
|
889
957
|
|
|
890
958
|
projects.read(model)
|
|
891
|
-
// RemoteData<Page<ProjectSummary
|
|
959
|
+
// RemoteData<Page<ProjectSummary>>: at most 25 items
|
|
892
960
|
|
|
893
|
-
Data.
|
|
894
|
-
|
|
895
|
-
Data.fetch(ref) // Command whose Message merges the page
|
|
961
|
+
Data.more(model, projects)
|
|
962
|
+
// Option<Model>: "load more", the window grown to 50; none when all are shown
|
|
896
963
|
```
|
|
897
964
|
|
|
898
965
|
The connection stores entity references and explicit boundaries rather than one
|
|
@@ -901,13 +968,32 @@ Remote represents an honest gap instead of pretending the visible rows are
|
|
|
901
968
|
adjacent.
|
|
902
969
|
|
|
903
970
|
A query Projection is `Initial` until the page **and every selected field of its
|
|
904
|
-
visible items** are present.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
971
|
+
visible items** are present. The `select` travels with the query: the server
|
|
972
|
+
returns the selected fields of the page's items with the edges, and one
|
|
973
|
+
`ConnectionMerged` merges the page and writes them, so a fresh list usually
|
|
974
|
+
lands `Ready` in one response. What the server leaves out (withheld fields,
|
|
975
|
+
nested targets it does not expand) becomes ordinary entity requirements and
|
|
976
|
+
is fetched next, as before; a server that predates payloads sends edges only
|
|
977
|
+
and the list reads the old two-round-trip way.
|
|
978
|
+
|
|
979
|
+
**A read shows at most its window.** Remote keeps one connection per query and
|
|
980
|
+
input, whatever window asks for it, so a picker's `first: 50` and a card's
|
|
981
|
+
`first: 3` of the same query share the rows, and each read is cut to its own
|
|
982
|
+
window: `first` from the start, `last` from the end. `hasNext` / `hasPrevious`
|
|
983
|
+
are true when the read cut rows or the connection's boundary says there are
|
|
984
|
+
more; they are never guessed from a row count.
|
|
985
|
+
|
|
986
|
+
Two Projections of the same connection plan one query, for the wider window,
|
|
987
|
+
and select the union of their fields. When a connection holds fewer rows than a
|
|
988
|
+
window asks for, the read entry asks only for the rows it lacks, after the
|
|
989
|
+
loaded end (or before the loaded start).
|
|
990
|
+
|
|
991
|
+
**"Load more" grows the window.** `Data.more(model, projection)` is the Model
|
|
992
|
+
with that read's window one page larger, called from `update`; the read entry
|
|
993
|
+
then fetches what the connection lacks. It is kept in Remote's store, under the
|
|
994
|
+
query, input and window first asked for, so a list holds no state of its own,
|
|
995
|
+
and it is forgotten when retention drops the connection. Another window of the
|
|
996
|
+
same query does not grow with it.
|
|
911
997
|
|
|
912
998
|
### Filtering a loaded list without asking the server
|
|
913
999
|
|
|
@@ -1206,6 +1292,11 @@ const back = Data.lift(previewed, 'post-preview')
|
|
|
1206
1292
|
returns the same Model.
|
|
1207
1293
|
- An overlay's id is apart from every request's, so a mutation that settles does
|
|
1208
1294
|
not take a preview with it.
|
|
1295
|
+
- A preview of an entity the server has not seen, such as an unsaved post, has
|
|
1296
|
+
only the fields the overlay holds. A Selection that reads more reads
|
|
1297
|
+
`Failed`, with an `Overlaid` error naming what is missing, since nothing will
|
|
1298
|
+
fetch it. Overlay every field the Selection reads, or preview through a
|
|
1299
|
+
smaller Selection.
|
|
1209
1300
|
|
|
1210
1301
|
### Reading past what is only pending
|
|
1211
1302
|
|
|
@@ -1276,8 +1367,9 @@ failure Message rather than mutating anything out of band.
|
|
|
1276
1367
|
Remote is a cache, so it should be allowed to forget facts no active feature
|
|
1277
1368
|
needs.
|
|
1278
1369
|
|
|
1279
|
-
`Data.subscriptions` derives retention roots from the active Surfaces
|
|
1280
|
-
|
|
1370
|
+
`Data.subscriptions` derives retention roots from the active Surfaces of every
|
|
1371
|
+
call on the domain, so a call's `retain` entry never collects what another call
|
|
1372
|
+
reads; `connections` given to any call are kept the same way. A root keeps:
|
|
1281
1373
|
|
|
1282
1374
|
- the entities and fields its Projection requires;
|
|
1283
1375
|
- referenced targets reached by nested selections;
|
|
@@ -1428,11 +1520,17 @@ into private state:
|
|
|
1428
1520
|
```ts
|
|
1429
1521
|
Data.inspect(model) // serializable cache/domain summary
|
|
1430
1522
|
Data.plan(model, projection) // the entity plan a read would execute
|
|
1523
|
+
Data.meta(model, projection) // when what is shown was received, stale/loading
|
|
1431
1524
|
Data.explain(model, query) // what one query read is, and currently is
|
|
1432
1525
|
Remote.planQueries(...) // missing/stale query work
|
|
1433
1526
|
Remote.inspectEntity(...) // one normalized entity
|
|
1434
1527
|
```
|
|
1435
1528
|
|
|
1529
|
+
`Data.meta` is for views, not debugging: `updatedAt` is the newest server
|
|
1530
|
+
write among what the projection shows (`undefined` when nothing shown was
|
|
1531
|
+
received), with `stale` and `loading` beside it, so "updated 5s ago" reads
|
|
1532
|
+
the Model like any other render.
|
|
1533
|
+
|
|
1436
1534
|
These are pure and useful in tests, tooling, and debugging. For a read that
|
|
1437
1535
|
stays `Initial`, `Data.why` and `Data.plan` are the tools; see
|
|
1438
1536
|
[Why is it still `Initial`?](#why-is-it-still-initial).
|
|
@@ -1539,8 +1637,16 @@ requirements for the concrete target.
|
|
|
1539
1637
|
options.
|
|
1540
1638
|
|
|
1541
1639
|
`Remote.storeOf` is the visible store after pending optimistic layers. Reads are
|
|
1542
|
-
memoized per visible store (and connection
|
|
1543
|
-
one Model state assemble/decode once and return one value.
|
|
1640
|
+
memoized per visible store (and, for a list, its connection and overlays), so
|
|
1641
|
+
equal reads of one Model state assemble/decode once and return one value.
|
|
1642
|
+
|
|
1643
|
+
Across Model states, an entity's or row's decoded value stays the same object
|
|
1644
|
+
while its entity's fields are equal: a refetch or live patch that brought equal
|
|
1645
|
+
data hands the view the value it already rendered, so a keyed row's lazy view
|
|
1646
|
+
does not re-run. A write to any field of the entity, even one the row does not
|
|
1647
|
+
select, or a pending optimistic patch on it while other writes land, gives the
|
|
1648
|
+
row a new value. When the store changed, the `RemoteData` around a value and a
|
|
1649
|
+
page's `items` array are new.
|
|
1544
1650
|
|
|
1545
1651
|
`Remote.prefetch(bound, model, projection, options?)` runs the same plans
|
|
1546
1652
|
imperatively and reduces their results into a new Model.
|
|
@@ -1549,7 +1655,10 @@ imperatively and reduces their results into a new Model.
|
|
|
1549
1655
|
|
|
1550
1656
|
`updateRemote(remote, message)` is the pure reducer over Remote facts;
|
|
1551
1657
|
`Data.update` applies it to a bound Remote state and `Data.reduce` applies it to
|
|
1552
|
-
the application Model.
|
|
1658
|
+
the application Model. The common Messages that change nothing (a duplicate live
|
|
1659
|
+
event, a repeated `ReadStarted`, an empty `Hydrated`, a retention pass that
|
|
1660
|
+
collects nothing) return the Model they were given, so Foldkit neither renders
|
|
1661
|
+
nor recomputes Subscription dependencies.
|
|
1553
1662
|
|
|
1554
1663
|
Its Messages cover reads and refreshes, mutations, live events/gaps, query
|
|
1555
1664
|
connections, retention, and hydration. `Remote.messages` is the case record for
|
|
@@ -1574,21 +1683,30 @@ Remote.retain(projections, toMessage?, { connections?, grace? })
|
|
|
1574
1683
|
active roots -> RetentionChanged
|
|
1575
1684
|
```
|
|
1576
1685
|
|
|
1577
|
-
The observe entry's dependencies are the current plan
|
|
1578
|
-
generation
|
|
1686
|
+
The observe entry's dependencies are the current plan, the Model's refresh
|
|
1687
|
+
generation, and when a held value next ages out
|
|
1688
|
+
(`{ requirements, queries, refresh, expires }`), so `Data.refresh` restarts
|
|
1579
1689
|
it. If entity requirements and queries are both empty, it performs no I/O. A
|
|
1580
1690
|
refreshing read emits `RefreshStarted` first, then a result Message.
|
|
1581
1691
|
|
|
1582
|
-
|
|
1692
|
+
A Message that changes the Model makes Foldkit recompute Subscription
|
|
1583
1693
|
dependencies. For example, after a query page lands, the next computation sees
|
|
1584
1694
|
the page's entity ids and plans any selected fields those entities still lack.
|
|
1585
1695
|
An interrupted identical read is joined by the client coalescer instead of
|
|
1586
1696
|
needlessly restarted.
|
|
1587
1697
|
|
|
1698
|
+
The live entry's dependencies are what the Surface reads live, the refresh
|
|
1699
|
+
floor, and the stream's cursor. The stream stays open as events advance the
|
|
1700
|
+
cursor; the cursor is only where a stream restarted for another reason resumes.
|
|
1701
|
+
|
|
1588
1702
|
Retention uses the Projection requirements/connections as roots and eventually
|
|
1589
1703
|
emits `RetentionChanged`; the reducer's pure `gc` keeps everything reachable
|
|
1590
1704
|
from those roots plus pending work and collects the rest.
|
|
1591
1705
|
|
|
1706
|
+
The entries `Data.subscriptions` returns carry these dependencies in their
|
|
1707
|
+
types: `ReadDependencies` for `<key>.read`, `LiveDependencies` for
|
|
1708
|
+
`<key>.live`, and `RetentionRoots` for `retain`.
|
|
1709
|
+
|
|
1592
1710
|
### Connections by hand
|
|
1593
1711
|
|
|
1594
1712
|
A connection stores entity references in segments with explicit boundaries, so
|
|
@@ -1617,7 +1735,7 @@ them.
|
|
|
1617
1735
|
|
|
1618
1736
|
### Mutations by hand
|
|
1619
1737
|
|
|
1620
|
-
`Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic })` is
|
|
1738
|
+
`Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic, now })` is
|
|
1621
1739
|
the one-step imperative start/run/settle form for SSR and tests.
|
|
1622
1740
|
|
|
1623
1741
|
`Remote.mutate(mutation, input, requestId)` is the transport call itself. It
|
|
@@ -1625,7 +1743,10 @@ decodes the typed `Output` and returns normalized entity patches and confirmed
|
|
|
1625
1743
|
connection changes for a caller to reduce through `updateRemote`.
|
|
1626
1744
|
|
|
1627
1745
|
`MutationSucceeded` reconciles at most once per `requestId`; an unknown or
|
|
1628
|
-
already-applied settlement is a no-op.
|
|
1746
|
+
already-applied settlement is a no-op. It carries `now`, the clock reading of
|
|
1747
|
+
the answer, which dates the entities it writes as a read's `now` does, so a
|
|
1748
|
+
freshness policy ages them from then. `Data.mutate` and `Remote.mutateInto`
|
|
1749
|
+
stamp it from their `now` option, `Date.now` by default.
|
|
1629
1750
|
|
|
1630
1751
|
### The transport seam
|
|
1631
1752
|
|