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 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
- : undefined,
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.fetch` for `foldData.fetch`.
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' ? { projectId: model.route.projectId } : undefined,
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
- (default `Date.now`) as an input, so tests can control time.
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 Model
736
- and `now`, and it runs when the Model changes; a page that sits still past its
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's first page replaces its loaded pages, so items the
783
- server removed or reordered follow it; later pages are fetched again with
784
- `Data.next`.
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.next(model, projects) // QueryRef | undefined
894
- Data.previous(model, projects) // QueryRef | undefined
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. Once a page lands, those items become ordinary
905
- entity requirements and can be fulfilled in the same planning loop.
906
-
907
- Two Projections of the same connection plan one query and select the union of
908
- their required fields. `Data.next` / `Data.previous` preserve the page size and
909
- use the loaded boundaries; `hasNext` / `hasPrevious` come from those boundaries,
910
- not from guessing based on row counts.
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. A root
1280
- keeps:
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 where applicable), so equal reads of
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 and the Model's refresh
1578
- generation (`{ requirements, queries, refresh }`), so `Data.refresh` restarts
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
- Every emitted Message changes the Model, so Foldkit recomputes Subscription
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