foldkit-remote 0.12.0 → 0.14.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
@@ -361,7 +361,15 @@ const overHttp = Remote.clientLayer(
361
361
  ```
362
362
 
363
363
  `headers` is read for each request, so a token that changes reaches the next
364
- call. Neither carries live data.
364
+ call. A 403 whose body does not say `forbidden` itself, as Remote's server's
365
+ does, comes from middleware in front of it refusing a session (a bare
366
+ `{ error }` included): it fails a mutation as `Forbidden` and any other call
367
+ with the status. Neither carries live data. For live data over the same `POST`s,
368
+ `Remote.httpWithLive` keeps reads, queries, and mutations as `http` sends
369
+ them and streams live requirements as a `text/event-stream` of changes;
370
+ [`serveFetch`](../remote-server/README.md#over-fetch-workers) is the other
371
+ end. Abandoning the stream aborts the request, which interrupts the
372
+ subscription server-side.
365
373
 
366
374
  For a server in a worker or in the page (a sandbox), `foldkit-remote/port`
367
375
  speaks Effect's own RPC worker protocol over a `MessagePort`, so live data,
@@ -534,7 +542,8 @@ When a read sits at `Initial` and you expected data, ask the Model why with
534
542
  `Data.why`; see [Why is it still `Initial`?](#why-is-it-still-initial).
535
543
 
536
544
  `RemoteData.match` is exhaustive, so adding or omitting a state is visible at
537
- compile time.
545
+ compile time. `RemoteData.answered(data)` is whether a read has an answer yet:
546
+ `Ready`, `Refreshing`, `Failed` or `NotFound`, not `Initial` or `Loading`.
538
547
 
539
548
  ### Drawing one: `RemoteData.render`
540
549
 
@@ -669,8 +678,8 @@ the same way, with the request's error; see
669
678
  Live events carry cursors. One behind the Model's is a duplicate and is
670
679
  dropped; one ahead of the next is a **gap**, recorded in `RemoteModel.gaps`
671
680
  and not applied, so the screen keeps what the server last said rather than
672
- a history with a hole in it. The host resynchronizes and clears the gap; see
673
- [Live data](#live-data).
681
+ a history with a hole in it. The live entry then restarts the stream and
682
+ refetches what it covers; see [When a stream breaks](#when-a-stream-breaks).
674
683
 
675
684
  ## Normalized entities and selections
676
685
 
@@ -868,9 +877,12 @@ const ready = await Effect.runPromise(
868
877
  )
869
878
  ```
870
879
 
871
- `actives` is the record `Data.wiring` takes. The passes are bounded (8 by
880
+ `actives` is the record `Data.wiring` takes. A `Surface.each` family is one
881
+ entry of it: its parent's requirements join its instances', so the parent is
882
+ fetched before what it reveals. The passes are bounded (8 by
872
883
  default, `{ passes }` to change it); a Surface still reading after the last
873
- fails the Effect with `RemoteUnsatisfied`, which names it, rather than
884
+ fails the Effect with `RemoteUnsatisfied`, which names it — a still-reading
885
+ family instance as `Child[key]` — rather than
874
886
  rendering it loading. A read the server leaves unanswered settles as missing,
875
887
  so it ends the loop; what runs out the passes is a chain whose every read
876
888
  reveals one more.
@@ -1065,7 +1077,9 @@ could match — predicate containment, which Remote deliberately does not do.
1065
1077
  missing a field the *body* reads), every match could be shown (no row missing a
1066
1078
  field the *Selection* reads), and the list is terminal at both ends. So
1067
1079
  empty-and-complete and empty-and-partial stay different answers — one means
1068
- "none", the other means "none that I can see yet".
1080
+ "none", the other means "none that I can see yet". An input no interpreter may
1081
+ answer (a search holding NUL, which the server refuses too) gives no items and
1082
+ `complete: false`, rather than throwing in `update`.
1069
1083
 
1070
1084
  Nothing is created: no connection, nothing new to retain, nothing new to fetch.
1071
1085
  The server stays authoritative for which rows exist.
@@ -1204,7 +1218,8 @@ identity and by `entity\0id\0field` mark.
1204
1218
  A broken live stream is not a failed read. Its `ReadFailed` carries the
1205
1219
  `stream`, and records a gap in `RemoteModel.gaps` rather than failing any
1206
1220
  field: nothing was being read, and the values on screen are still what the
1207
- server last said.
1221
+ server last said. The stream restarts on its own; see
1222
+ [When a stream breaks](#when-a-stream-breaks).
1208
1223
 
1209
1224
  ### When a field is withheld
1210
1225
 
@@ -1295,11 +1310,126 @@ A mutation that deletes says so. The server's outcome carries
1295
1310
  server names no list. Patches apply first, so an entity named both ways is
1296
1311
  deleted; a retry of the same request deletes nothing again.
1297
1312
 
1313
+ **Which lists a mutation changed is worked out from its answer.** Each patch
1314
+ says which row it wrote; the fields whose value differs from what the client
1315
+ held are what changed (an answer returning a whole row changes only what is
1316
+ new). Every loaded list over that Entity is judged by its query's body against
1317
+ the store the answer left:
1318
+
1319
+ - a list whose body reads none of the changed fields stands;
1320
+ - a row it holds that still matches, or a row it does not hold that still does
1321
+ not, leaves it standing;
1322
+ - a held row that stops matching leaves it, with nothing fetched: whether a
1323
+ row belongs is the body's, judged exactly;
1324
+ - a row that starts matching, a row new to the client, a held row whose
1325
+ ordering field changed, or a row whose order moves it in from past what a
1326
+ list has loaded, is placed where the body's order puts it among the rows
1327
+ loaded (`placeIn`), when
1328
+ the order is placeable (`Query.placement`); a row that sorts past what is
1329
+ loaded is left out of it (and out of what showed it), for the next page. A
1330
+ row that was itself where a page met rows not loaded (the last before a
1331
+ cursor, the first after one) cannot be told to have moved past it, its old
1332
+ values being gone, so it invalidates the list instead;
1333
+ - anything else invalidates it, and its read entry fetches it again: a row the
1334
+ client cannot judge (it lacks a field the body reads), an order it cannot
1335
+ place (text with no declared collation, the server's own), or a row an
1336
+ optimistic overlay shows; a read of it already in flight is restarted, since
1337
+ it could answer with the rows before the write;
1338
+ - a list over a `Query.make` query, which declares no body, is invalidated by
1339
+ any change to its Entity;
1340
+ - a row a list does not hold that changes a member the server's own read rule
1341
+ filters on (an owner, a team: declared on the Entity as `ReadFilter`, which
1342
+ `foldkit-guard` attaches) invalidates it: whether the row now belongs is the
1343
+ server's to say, since the client runs no such rule. A row it holds is
1344
+ judged by the body as usual. A new row counts, so under such a rule a create
1345
+ refetches the lists over its Entity;
1346
+ - a list whose body declares no order leaves its order to the server, by
1347
+ fields the client cannot see, so any change to a row it holds invalidates
1348
+ it. Declare the order in the body (`Query.orderBy`) to keep a list that a
1349
+ change provably did not move.
1350
+
1351
+ **A placement can be checked against the server.** `Remote.make({ ...,
1352
+ verifyPlacement: true })` places as usual, and also fetches each list it placed
1353
+ what the server said in (an answer, as above, or a read or a live patch, as
1354
+ below); when the server's first page arrives it is compared with the order
1355
+ placed, and a disagreement is kept in `model.remote.placementMismatches` (and
1356
+ `Data.inspect`): an order the page does not share, or a row placing took out
1357
+ that the page still lists. A pending edit is not checked: the server does not have it
1358
+ yet. Turn it on in development and tests: a collation the client
1359
+ and the server disagree on then fails an assertion instead of misplacing a row
1360
+ for a reader.
1361
+
1362
+ So a rename updates every view of the row and fetches nothing, a status change
1363
+ moves a row out of the lists filtered by status and into the others in their
1364
+ order, and a price change moves the row within a list sorted by price, all
1365
+ without a request.
1366
+
1367
+ **A pending edit, and the server's word on a row, are placed the same way.**
1368
+ While a request's `optimistic` patch or a `Data.overlay` is pending, a list
1369
+ that holds the row shows it where the edit puts it, or leaves it out once it
1370
+ stops matching; the server's pages are untouched, and lifting the edit puts
1371
+ the row back. A read or a live `EntityPatched` that brings a new value for a
1372
+ row a list holds moves it in the list's pages, as an answer does. Neither
1373
+ brings a row into a list, since what the list selects of it may not be
1374
+ loaded, and neither fetches: a list it cannot place stands as it is.
1375
+
1376
+ **An inserted row is drawn in order.** A `ConnectionChange.prepend` or
1377
+ `append` (a pending create's, a confirmed one's) and a live
1378
+ `ConnectionInsert` say a row is in a list; they do not say where. In a list
1379
+ the client can place, a read draws the row where the body's order puts it,
1380
+ or leaves it out when it sorts past what is loaded; one it cannot place (a
1381
+ field the order reads is missing, the order is the server's) stays at the end
1382
+ its change names. `Data.visibleItems(remote, connection)` gives a list's edges
1383
+ as its reads draw them, for a persistence `snapshot` that stores a list as
1384
+ shown.
1385
+ `Data.refresh` stays for what an answer cannot say, such as a server-side
1386
+ effect on rows it did not return.
1387
+
1298
1388
  `Data.mutation(model, requestId)` reads what became of it from the Model:
1299
1389
  `Pending`, `Applied`, `Failed` with the error the server or transport gave, or
1300
1390
  `Unknown` for an id never started here (or settled so long ago it left the
1301
1391
  bounded ledger). A retry reuses its id, and the latest outcome wins.
1302
1392
 
1393
+ A mutation can say what the server may refuse it with, as data rather than a
1394
+ message to parse:
1395
+
1396
+ ```ts
1397
+ const SetPrice = Mutation.make('SetPrice', {
1398
+ Input: { id: Schema.String, price: Schema.Number },
1399
+ Output: {},
1400
+ Refusal: Schema.Union([Refusal.field('price', Schema.String), Refusal.conflict]),
1401
+ })
1402
+
1403
+ Data.refusal(model, requestId, SetPrice) // Option<{ _tag: 'Field', key: 'price', reason: string } | { _tag: 'Conflict' }>
1404
+ ```
1405
+
1406
+ A mutation can also be a declared write: `Mutation.write(name, write)` over a
1407
+ `foldkit-entity` `Write.update`, `Write.insert` or `Write.delete`. Its input is
1408
+ the write's, it answers nothing of its own, and `Data.mutate` shows an update's
1409
+ or an insert's row until the server answers, so it needs no `optimistic` list;
1410
+ a delete shows nothing until its answer removes the row everywhere. `keys` names the input keys
1411
+ an author changed: only those are shown, and the request carries them for the
1412
+ server to write; the input is still sent and validated whole.
1413
+
1414
+ ```ts
1415
+ const EditProject = Mutation.write(
1416
+ 'EditProject',
1417
+ Write.update(Entity.input(Project, EditProjectInput), { id: 'id' }),
1418
+ )
1419
+ Data.mutate(model, EditProject, { id: 'p1', name: 'Apollo II', status: 'active' }, { keys: ['name'] })
1420
+ ```
1421
+
1422
+ `Refusal.field(key, reason)` names an input key and why; `Refusal.conflict`
1423
+ says the row moved on. The server sends the refusal encoded by this schema and
1424
+ `Data.refusal` decodes it by the same one; it is none while the mutation is
1425
+ pending or applied, or when it failed for another reason.
1426
+
1427
+ A server may also refuse who is asking, whatever the mutation
1428
+ (`RemoteServer.forbid`). Then `Data.mutation(model, requestId)` is `Failed`
1429
+ with an error of `_tag: 'Forbidden'`, and `reason` when the server gave one,
1430
+ for the person who asked. Asking again as the same person is refused again,
1431
+ so show it rather than retry.
1432
+
1303
1433
  Optimistic entity patches are **layers over the base store**, not inverse
1304
1434
  patches. If multiple mutations overlap, the visible cache is recomputed as base
1305
1435
  plus the still-pending layers, so settling one does not require trying to undo
@@ -1346,6 +1476,10 @@ const back = Data.lift(previewed, 'post-preview')
1346
1476
  `Failed`, with an `Overlaid` error naming what is missing, since nothing will
1347
1477
  fetch it. Overlay every field the Selection reads, or preview through a
1348
1478
  smaller Selection.
1479
+ - `Data.overlays(model)` lists the ids shown now, in the order they are drawn.
1480
+ A caller that overlays from state of its own reconciles against it:
1481
+ `foldkit-sync/remote` shows a replica's pending cell edits this way, so the
1482
+ durable edit stays Sync's and Remote only draws it.
1349
1483
 
1350
1484
  ### Reading past what is only pending
1351
1485
 
@@ -1403,14 +1537,54 @@ ordered cursors:
1403
1537
  - the next cursor is applied;
1404
1538
  - a cursor ahead of the expected value is a **gap** and is not applied.
1405
1539
 
1406
- A gap is recorded in `RemoteModel.gaps` so the host can resynchronize rather than
1407
- silently accepting missing history. An in-order event or `GapCleared` clears it.
1408
-
1409
1540
  Entity patches update normalized fields, deletes create tombstones, and
1410
1541
  connection insert/remove/invalidate events reconcile the same connection model
1411
1542
  used by query pages and optimistic overlays. A stream failure becomes a Remote
1412
1543
  failure Message rather than mutating anything out of band.
1413
1544
 
1545
+ ### When a stream breaks
1546
+
1547
+ A stream breaks when its transport fails or when an event arrives ahead of
1548
+ its cursor (a gap). Either way, what the server said meanwhile is lost: no
1549
+ server replays it, since `after` only numbers the events that follow. So
1550
+ recovery is a refetch, and the live entry does it on its own:
1551
+
1552
+ ```text
1553
+ break -> gap marked, restarts + 1 -> backoff
1554
+ -> RefreshStarted (what the stream covers) + GapCleared -> resubscribe from the cursor
1555
+ \-> read entry refetches
1556
+ ```
1557
+
1558
+ - **The backoff is Sync's transport policy:** 50 ms after the first break,
1559
+ doubling for each break in a row, at most 5 s, with ±20% jitter.
1560
+ `retryBase` and `maxRetryDelay` in the `Data.subscriptions` options change
1561
+ it. An event applied after a resubscribe starts it over.
1562
+ - **A gap stays until the restart.** An in-order event that a broken stream
1563
+ still delivers is applied, but does not clear it. The event that arrived
1564
+ ahead was dropped, and only the refetch brings back what it said.
1565
+ - **What a stream covers is its requirements.** A list the server watches
1566
+ for it (a `ConnectionInvalidate` it sends) is the source's to re-send when
1567
+ the stream opens; the cloudflare example's poll invalidates its list on
1568
+ every open for this reason.
1569
+
1570
+ `Data.liveStatus(model, active)` says how what one active entry reads live is
1571
+ doing, for a view that shows it:
1572
+
1573
+ ```ts
1574
+ Match.value(Data.liveStatus(model, Watched)).pipe(
1575
+ Match.tagsExhaustive({
1576
+ Idle: () => 'not live',
1577
+ Live: () => 'live',
1578
+ Reconnecting: ({ attempt }) => `reconnecting (attempt ${attempt})`,
1579
+ }),
1580
+ )
1581
+ ```
1582
+
1583
+ `Reconnecting` lasts from the break until the stream has resubscribed, and
1584
+ carries the error, which is none for a gap. `RemoteModel.streams` holds each
1585
+ stream's restarts, its breaks in a row and its last error. `forget` clears it
1586
+ with every other server-derived fact.
1587
+
1414
1588
  ## Retention and garbage collection
1415
1589
 
1416
1590
  Remote is a cache, so it should be allowed to forget facts no active feature
@@ -1481,6 +1655,58 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
1481
1655
  discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
1482
1656
  hydration still changes the application through the reducer.
1483
1657
 
1658
+ ### A cache kept across reloads: `Data.persistence`
1659
+
1660
+ The calls above are the parts. `Data.persistence` puts them together as a
1661
+ wiring: it restores the snapshot stored under a key, and saves the cache under
1662
+ that key whenever it changes.
1663
+
1664
+ ```ts
1665
+ const persistence = Data.persistence({
1666
+ key: model => `remote-cache:${model.userId}`,
1667
+ scope: model => model.userId,
1668
+ maxBytes: 512_000,
1669
+ })
1670
+
1671
+ // Assembled beside `Data.wiring`; the runtime provides a `KeyValueStore`,
1672
+ // such as `BrowserKeyValueStore.layerLocalStorage`.
1673
+ const placements = Page.assemble(Data.wiring(actives), persistence)
1674
+ ```
1675
+
1676
+ The restore and the save each run as Subscriptions, when the key changes or the
1677
+ cache does:
1678
+
1679
+ ```text
1680
+ key -> restore -> Hydrated { from: key } -> restoredFrom = key -> save on each change
1681
+ ```
1682
+
1683
+ - **A restore keeps what the Model already holds.** It hydrates with
1684
+ `merge: 'preserve-existing'`, so a fact resumed from the server, or read since
1685
+ the page started, wins over the older snapshot.
1686
+ - **So a change of principal is `Data.forget`, in the same `update`.** What one
1687
+ person read, left in memory, would be kept over the next one's snapshot and
1688
+ then saved under their key; `forget` clears it as the key changes. The
1689
+ `scope` check refuses a snapshot written for another reader; it cannot see
1690
+ facts that are only in memory.
1691
+ - **Nothing is saved before its own key is restored.** `Hydrated` records the
1692
+ key it came from in `model.remote.restoredFrom`. Until that key is restored,
1693
+ nothing is saved, so the empty store at startup cannot overwrite the
1694
+ snapshot it has not read yet. When the key changes (a different principal),
1695
+ saving stops again until the new key's snapshot is in.
1696
+ - **A store that cannot be read restores nothing, and so nothing is saved over
1697
+ it.** A stored snapshot from another version or scope, or one that will not
1698
+ decode, is removed, and an empty snapshot is restored in its place.
1699
+ - **Saving is debounced** (`debounce`, 250 ms by default) and best-effort: a
1700
+ full store keeps the snapshot it had, and the next change tries again. The
1701
+ write waits; the snapshot does not: it is serialized on each change of the
1702
+ cache, so under a steady stream of live changes keep it small, with
1703
+ `maxBytes` or a `snapshot` that stores only what a page shows. A
1704
+ cache grown past `maxBytes` removes its key, so an older, smaller snapshot
1705
+ cannot outlive it.
1706
+
1707
+ `snapshot` replaces `snapshotOf` when an application keeps something else, such
1708
+ as only the edges a page shows.
1709
+
1484
1710
  ## Server rendering: `Remote.resume`
1485
1711
 
1486
1712
  A snapshot is built for a cache that survived a reload: it keeps the whole
@@ -1635,6 +1861,8 @@ Data.explain(model, projects, { surfaces: actives })
1635
1861
  // activation: [{ surface: 'ProjectPage', path: ['route'], tag: 'Project' }]
1636
1862
  ```
1637
1863
 
1864
+ A `Surface.each` family reports each reading instance as `Child[key]`.
1865
+
1638
1866
  There is still no **executor**, because what answers a query is a
1639
1867
  `RemoteClient` Layer in the runtime rather than a value in the Model — the same
1640
1868
  purity that lets this be replayed from a recorded Model is what keeps it out of
@@ -1773,7 +2001,7 @@ const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
1773
2001
  const page = yield* Remote.query(ref)
1774
2002
 
1775
2003
  updateRemote(remote, Remote.queryMessage(ref, page))
1776
- Remote.visibleItems(remote, ref)
2004
+ Data.visibleItems(remote, ref)
1777
2005
  ```
1778
2006
 
1779
2007
  `items`, `hasNext`, `hasPrevious`, and `isGapped` describe the known region.
@@ -1782,6 +2010,31 @@ a refresh/query result settles it. A `ConnectionMerged` page with
1782
2010
  `refreshes: true` replaces a stale connection's pages rather than merging into
1783
2011
  them.
1784
2012
 
2013
+ ### Where a row falls: `placeIn`
2014
+
2015
+ A query whose body declares a placeable order (`Query.placement` in
2016
+ `foldkit-entity`: numbers, booleans, and text under a portable collation) can
2017
+ have a changed row placed in its loaded pages by the client, by the same order
2018
+ the server compiles. Such a connection also reads every field its body filters
2019
+ and orders by, so the rows it holds can be compared with one that changed.
2020
+
2021
+ ```ts
2022
+ // Schematic: `ProjectsByRank` is a Query.define ordered by a number, and
2023
+ // `connection` one of `remote.connections`. test/placement.test.ts runs it.
2024
+ placeIn(remote.entities, connection, ProjectsByRank.body!, {}, 'Project:p7')
2025
+ // Before { segment, edge } | End { segment } | Outside | Unknown { reason }
2026
+ ```
2027
+
2028
+ - `Before` and `End` are positions among loaded edges: before an edge, or at
2029
+ the end of a segment that ends the list (`Terminal`).
2030
+ - `Outside` is a row that sorts past a `Cursor` boundary or into a gap between
2031
+ segments: it belongs to rows the client has not read, so it is not shown.
2032
+ - `Unknown` is anything not judged exactly: a body that is not placeable, a
2033
+ row or an edge lacking a field the order reads, keys that do not compare.
2034
+
2035
+ It is pure and changes nothing. A mutation's answer is placed through it, as
2036
+ above.
2037
+
1785
2038
  ### Mutations by hand
1786
2039
 
1787
2040
  `Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic, now })` is