foldkit-remote 0.13.0 → 0.14.1

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,
@@ -670,8 +678,8 @@ the same way, with the request's error; see
670
678
  Live events carry cursors. One behind the Model's is a duplicate and is
671
679
  dropped; one ahead of the next is a **gap**, recorded in `RemoteModel.gaps`
672
680
  and not applied, so the screen keeps what the server last said rather than
673
- a history with a hole in it. The host resynchronizes and clears the gap; see
674
- [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).
675
683
 
676
684
  ## Normalized entities and selections
677
685
 
@@ -869,9 +877,12 @@ const ready = await Effect.runPromise(
869
877
  )
870
878
  ```
871
879
 
872
- `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
873
883
  default, `{ passes }` to change it); a Surface still reading after the last
874
- 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
875
886
  rendering it loading. A read the server leaves unanswered settles as missing,
876
887
  so it ends the loop; what runs out the passes is a chain whose every read
877
888
  reveals one more.
@@ -1066,7 +1077,9 @@ could match — predicate containment, which Remote deliberately does not do.
1066
1077
  missing a field the *body* reads), every match could be shown (no row missing a
1067
1078
  field the *Selection* reads), and the list is terminal at both ends. So
1068
1079
  empty-and-complete and empty-and-partial stay different answers — one means
1069
- "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`.
1070
1083
 
1071
1084
  Nothing is created: no connection, nothing new to retain, nothing new to fetch.
1072
1085
  The server stays authoritative for which rows exist.
@@ -1205,7 +1218,8 @@ identity and by `entity\0id\0field` mark.
1205
1218
  A broken live stream is not a failed read. Its `ReadFailed` carries the
1206
1219
  `stream`, and records a gap in `RemoteModel.gaps` rather than failing any
1207
1220
  field: nothing was being read, and the values on screen are still what the
1208
- server last said.
1221
+ server last said. The stream restarts on its own; see
1222
+ [When a stream breaks](#when-a-stream-breaks).
1209
1223
 
1210
1224
  ### When a field is withheld
1211
1225
 
@@ -1296,11 +1310,126 @@ A mutation that deletes says so. The server's outcome carries
1296
1310
  server names no list. Patches apply first, so an entity named both ways is
1297
1311
  deleted; a retry of the same request deletes nothing again.
1298
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
+
1299
1388
  `Data.mutation(model, requestId)` reads what became of it from the Model:
1300
1389
  `Pending`, `Applied`, `Failed` with the error the server or transport gave, or
1301
1390
  `Unknown` for an id never started here (or settled so long ago it left the
1302
1391
  bounded ledger). A retry reuses its id, and the latest outcome wins.
1303
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
+
1304
1433
  Optimistic entity patches are **layers over the base store**, not inverse
1305
1434
  patches. If multiple mutations overlap, the visible cache is recomputed as base
1306
1435
  plus the still-pending layers, so settling one does not require trying to undo
@@ -1347,6 +1476,10 @@ const back = Data.lift(previewed, 'post-preview')
1347
1476
  `Failed`, with an `Overlaid` error naming what is missing, since nothing will
1348
1477
  fetch it. Overlay every field the Selection reads, or preview through a
1349
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.
1350
1483
 
1351
1484
  ### Reading past what is only pending
1352
1485
 
@@ -1404,14 +1537,54 @@ ordered cursors:
1404
1537
  - the next cursor is applied;
1405
1538
  - a cursor ahead of the expected value is a **gap** and is not applied.
1406
1539
 
1407
- A gap is recorded in `RemoteModel.gaps` so the host can resynchronize rather than
1408
- silently accepting missing history. An in-order event or `GapCleared` clears it.
1409
-
1410
1540
  Entity patches update normalized fields, deletes create tombstones, and
1411
1541
  connection insert/remove/invalidate events reconcile the same connection model
1412
1542
  used by query pages and optimistic overlays. A stream failure becomes a Remote
1413
1543
  failure Message rather than mutating anything out of band.
1414
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
+
1415
1588
  ## Retention and garbage collection
1416
1589
 
1417
1590
  Remote is a cache, so it should be allowed to forget facts no active feature
@@ -1482,6 +1655,58 @@ snapshot from another version/scope, an oversized snapshot, or malformed data is
1482
1655
  discarded and the planner refetches. `Hydrated` is itself a Remote Message, so
1483
1656
  hydration still changes the application through the reducer.
1484
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
+
1485
1710
  ## Server rendering: `Remote.resume`
1486
1711
 
1487
1712
  A snapshot is built for a cache that survived a reload: it keeps the whole
@@ -1636,6 +1861,8 @@ Data.explain(model, projects, { surfaces: actives })
1636
1861
  // activation: [{ surface: 'ProjectPage', path: ['route'], tag: 'Project' }]
1637
1862
  ```
1638
1863
 
1864
+ A `Surface.each` family reports each reading instance as `Child[key]`.
1865
+
1639
1866
  There is still no **executor**, because what answers a query is a
1640
1867
  `RemoteClient` Layer in the runtime rather than a value in the Model — the same
1641
1868
  purity that lets this be replayed from a recorded Model is what keeps it out of
@@ -1774,7 +2001,7 @@ const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
1774
2001
  const page = yield* Remote.query(ref)
1775
2002
 
1776
2003
  updateRemote(remote, Remote.queryMessage(ref, page))
1777
- Remote.visibleItems(remote, ref)
2004
+ Data.visibleItems(remote, ref)
1778
2005
  ```
1779
2006
 
1780
2007
  `items`, `hasNext`, `hasPrevious`, and `isGapped` describe the known region.
@@ -1783,6 +2010,31 @@ a refresh/query result settles it. A `ConnectionMerged` page with
1783
2010
  `refreshes: true` replaces a stale connection's pages rather than merging into
1784
2011
  them.
1785
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
+
1786
2038
  ### Mutations by hand
1787
2039
 
1788
2040
  `Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic, now })` is