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.
@@ -1,12 +1,13 @@
1
- import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaAST, SchemaGetter, Stream } from "effect";
1
+ import { Context, Data, Deferred, Duration, Effect, Layer, Match, Option, Predicate, Record, Request, RequestResolver, Result, Schema, SchemaAST, SchemaGetter, Stream } from "effect";
2
+ import { KeyValueStore } from "effect/persistence";
2
3
  import * as Domain from "foldkit-entity";
3
- import { Entity, Expr, Query, SchemaShape, SelectionTypeId, evaluate } from "foldkit-entity";
4
+ import { Entity, Expr, Query, QueryEvaluateError, ReadFilter, SchemaShape, SelectionTypeId, Write, compareRows, dependenciesOf, evaluate } from "foldkit-entity";
4
5
  import { mapMessage } from "foldkit/command";
5
6
  import { defineMessageUnion } from "foldkit/message";
6
7
  import * as Subscription from "foldkit/subscription";
7
8
  import { Metadata } from "foldkit-surface";
8
9
  import { Rpc, RpcGroup } from "effect/rpc";
9
- import { KeyValueStore } from "effect/persistence";
10
+ import { defineTaggedUnion } from "foldkit/schema";
10
11
  //#region src/requirement.ts
11
12
  /**
12
13
  * What a Remote Projection needs from the server: entity slices and query
@@ -485,18 +486,89 @@ const reconcileMutation = (store, state, requestId, answer) => {
485
486
  }
486
487
  };
487
488
  };
489
+ const conflict = Schema.TaggedStruct("Conflict", {});
490
+ const anyField = Schema.TaggedStruct("Field", {
491
+ key: Schema.String,
492
+ reason: Schema.Unknown
493
+ });
494
+ /** Refusals a form or an editor knows how to show. */
495
+ const Refusal = {
496
+ /** One input key was refused, for `reason`, which a form shows beside that key. */
497
+ field: (key, reason) => Schema.TaggedStruct("Field", {
498
+ key: Schema.Literal(key),
499
+ reason
500
+ }),
501
+ /** The row moved on since the client read it; the author decides what wins. */
502
+ conflict,
503
+ /** Whether a refusal, of whatever mutation, is a `Refusal.field`. */
504
+ isField: Schema.is(anyField),
505
+ /** Whether a refusal, of whatever mutation, is `Refusal.conflict`. */
506
+ isConflict: Schema.is(conflict)
507
+ };
488
508
  /** The codec itself, or a `Schema.Struct` over the fields. */
489
509
  const schemaOf = (shape) => Schema.isSchema(shape) ? shape : Schema.Struct(shape);
490
- const Mutation = {
491
510
  /**
492
- * Declares a mutation. `Input` and `Output` are codecs, or the fields of the
493
- * `Schema.Struct` they would be (`{ id: ProjectId, name: Schema.String }`).
511
+ * A bound write as the store patches it: its fields as they are, and each
512
+ * relation it points as the ref key the store holds (`'User:u1'`), or null.
513
+ */
514
+ const patchOfWrite = (bound) => ({
515
+ entity: bound.entity,
516
+ id: bound.id,
517
+ values: {
518
+ ...bound.values,
519
+ ...Record.map(bound.links, (link) => Option.match(link, {
520
+ onNone: () => null,
521
+ onSome: (ref) => entityKey(ref.entity, ref.id)
522
+ }))
523
+ }
524
+ });
525
+ /**
526
+ * What a declared write shows before the server answers: an update's or an
527
+ * insert's row as a patch (an update's only for the keys named, when some
528
+ * are). A delete shows nothing, since no operation removes a row from every
529
+ * list it is in; its answer does.
494
530
  */
495
- make: (name, config) => ({
496
- name,
497
- Input: schemaOf(config.Input),
498
- Output: schemaOf(config.Output)
499
- }) };
531
+ const optimisticOfWrite = (write, input, keys) => Match.value(write).pipe(Match.tagsExhaustive({
532
+ Update: () => [patchOfWrite(Write.bind(write, input, keys))],
533
+ Insert: () => {
534
+ const patch = patchOfWrite(Write.bind(write, input));
535
+ return [{
536
+ ...patch,
537
+ values: {
538
+ id: patch.id,
539
+ ...patch.values
540
+ }
541
+ }];
542
+ },
543
+ Delete: () => []
544
+ }));
545
+ const Mutation = {
546
+ /**
547
+ * Declares a mutation. `Input` and `Output` are codecs, or the fields of the
548
+ * `Schema.Struct` they would be (`{ id: ProjectId, name: Schema.String }`).
549
+ * `Refusal`, when given, is what the server may refuse with: a codec, usually
550
+ * a union of `Refusal.field(...)` and `Refusal.conflict`.
551
+ */
552
+ /**
553
+ * A mutation that is a declared `Write` (`update`, `insert` or `delete`): its
554
+ * input is the write's, and it answers nothing but the row it changed, as a
555
+ * patch, or its deletion. It may be refused as a conflict, for a write that
556
+ * `expect`s a revision the row has moved past.
557
+ */
558
+ write: (name, write) => ({
559
+ name,
560
+ Input: write.input.schema,
561
+ Output: Schema.Struct({}),
562
+ Refusal: Refusal.conflict,
563
+ write
564
+ }),
565
+ make: (name, config) => ({
566
+ name,
567
+ Input: schemaOf(config.Input),
568
+ Output: schemaOf(config.Output),
569
+ Refusal: config.Refusal ?? Schema.Never
570
+ })
571
+ };
500
572
  //#endregion
501
573
  //#region src/query.ts
502
574
  /**
@@ -654,7 +726,20 @@ const Query$1 = {
654
726
  */
655
727
  const REMOTE_PROTOCOL_VERSION = 4;
656
728
  var RemoteReadError = class extends Schema.TaggedError()("RemoteReadError", { message: Schema.String }) {};
657
- var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", { message: Schema.String }) {};
729
+ var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", {
730
+ message: Schema.String,
731
+ /** The mutation's own `Refusal`, encoded, when the server refused it as one. */
732
+ refusal: Schema.optional(Schema.Unknown)
733
+ }) {};
734
+ /**
735
+ * The server refused who is asking: they may not do this, and asking again
736
+ * as the same principal is refused again. `reason` is the application's own
737
+ * words for the person who asked; without it the refusal says nothing of why.
738
+ */
739
+ var RemoteForbidden = class extends Schema.TaggedError()("Forbidden", {
740
+ message: Schema.String,
741
+ reason: Schema.optional(Schema.String)
742
+ }) {};
658
743
  var RemoteLiveError = class extends Schema.TaggedError()("RemoteLiveError", { message: Schema.String }) {};
659
744
  /** The peer speaks another protocol version; nothing was read. */
660
745
  var RemoteProtocolError = class extends Schema.TaggedError()("RemoteProtocolError", {
@@ -731,7 +816,9 @@ const MutationRequest = Schema.Struct({
731
816
  /** Stable across transport retries so a mutation is not applied twice. */
732
817
  requestId: Schema.String,
733
818
  mutation: Schema.String,
734
- input: Schema.Unknown
819
+ input: Schema.Unknown,
820
+ /** For a mutation that is a `Write`: the input keys to write, when not all of them. */
821
+ keys: Schema.optional(Schema.Array(Schema.String))
735
822
  });
736
823
  const LiveEdge = Schema.Struct({
737
824
  entity: Schema.String,
@@ -812,7 +899,7 @@ const Read = Rpc.make("FoldkitRemoteRead", {
812
899
  const Mutate = Rpc.make("FoldkitRemoteMutate", {
813
900
  payload: MutationRequest,
814
901
  success: MutationResult,
815
- error: RemoteMutationError
902
+ error: Schema.Union([RemoteMutationError, RemoteForbidden])
816
903
  });
817
904
  const Live = Rpc.make("FoldkitRemoteLive", {
818
905
  payload: LiveRequirement,
@@ -1074,7 +1161,9 @@ const liveEventOf = (change) => {
1074
1161
  };
1075
1162
  const remoteError = (error) => ({
1076
1163
  _tag: error._tag,
1077
- message: error.message
1164
+ message: error.message,
1165
+ ...error.refusal === void 0 ? {} : { refusal: error.refusal },
1166
+ ...error.reason === void 0 ? {} : { reason: error.reason }
1078
1167
  });
1079
1168
  const coalescedLayer = (layer, options = {}) => Layer.effect(RemoteClient, Effect.gen(function* () {
1080
1169
  const client = yield* RemoteClient;
@@ -1090,7 +1179,7 @@ const coalescedLayer = (layer, options = {}) => Layer.effect(RemoteClient, Effec
1090
1179
  * `Remote.mutate` as a standalone effect, so `Remote.mutateInto` can reuse it
1091
1180
  * without the object literal referencing itself.
1092
1181
  */
1093
- const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requestId) {
1182
+ const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requestId, keys) {
1094
1183
  yield* Effect.annotateCurrentSpan({
1095
1184
  mutation: mutation.name,
1096
1185
  requestId
@@ -1100,7 +1189,8 @@ const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requ
1100
1189
  const result = yield* client.mutate({
1101
1190
  requestId,
1102
1191
  mutation: mutation.name,
1103
- input: encoded
1192
+ input: encoded,
1193
+ ...keys === void 0 ? {} : { keys }
1104
1194
  });
1105
1195
  return {
1106
1196
  output: yield* Schema.decodeUnknownEffect(mutation.Output)(result.output).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message })))),
@@ -1546,6 +1636,116 @@ const Entity$1 = {
1546
1636
  })
1547
1637
  };
1548
1638
  //#endregion
1639
+ //#region src/placement.ts
1640
+ /**
1641
+ * Where one row falls in a loaded list, by the list's own declared order:
1642
+ * the decidable half of placing a row instead of fetching the list again.
1643
+ *
1644
+ * A list is segments of edges between boundaries, and only a `Terminal`
1645
+ * boundary says nothing lies beyond it. So a row is placed between two loaded
1646
+ * edges, or before a segment's first edge when that segment starts the list,
1647
+ * or at its end when it ends it; a row that sorts past a `Cursor` or into a
1648
+ * gap belongs to rows the client has not read, and is `Outside`. Anything the
1649
+ * client cannot judge exactly (a body it cannot place, a field a row lacks,
1650
+ * keys it cannot compare) is `Unknown`, with why, never a guess.
1651
+ *
1652
+ * Pure: it reads the store and the list it is given.
1653
+ */
1654
+ const RowPlace = Data.taggedEnum();
1655
+ /**
1656
+ * The values a row holds of the fields an order reads, or none when it lacks
1657
+ * one. An `id` no Selection stored is its key's, when the id is stored as
1658
+ * text as a key holds it, so the id tie-break every order ends on does not
1659
+ * make such a row unplaceable; a number id is never compared as text.
1660
+ */
1661
+ const keysOf = (store, key, id, idIsText, terms) => {
1662
+ const values = Object.create(null);
1663
+ for (const term of terms) {
1664
+ if (term.expr._tag !== "Field") return Option.none();
1665
+ const field = term.expr.key;
1666
+ const stored = readField(store, key, field);
1667
+ const value = field === "id" && Option.isNone(stored) && idIsText ? id : stored;
1668
+ if (Option.isNone(value)) return Option.none();
1669
+ values[field] = value.value;
1670
+ }
1671
+ return Option.some(values);
1672
+ };
1673
+ /**
1674
+ * Where the row stored under `key` falls in `connection`, by `body`'s order
1675
+ * for `input` (encoded, as a connection's identity carries it). The row's own
1676
+ * edge, if the list holds it, is left out: placing a row that moved asks
1677
+ * where it goes now.
1678
+ */
1679
+ const placeIn = (store, connection, body, input, key) => {
1680
+ const placement = Query.placement(body, input);
1681
+ if (placement._tag === "NotPlaceable") return RowPlace.Unknown({ reason: placement.reason });
1682
+ const terms = Query.orderFor(body, input);
1683
+ const prefix = `${body.entity.name}:`;
1684
+ const id = key.startsWith(prefix) ? Option.some(key.slice(prefix.length)) : Option.none();
1685
+ const idField = body.entity.fields.id;
1686
+ const idIsText = idField !== void 0 && SchemaAST.isString(Schema.toEncoded(idField.schema).ast);
1687
+ const row = keysOf(store, key, id, idIsText, terms);
1688
+ if (Option.isNone(row)) return RowPlace.Unknown({ reason: `${key} lacks a field the order reads` });
1689
+ /** Negative when the row sorts before the edge; none when the edge cannot be compared. */
1690
+ const against = (edge) => Option.flatMap(keysOf(store, entityKey(edge.ref.entity, edge.ref.id), Option.some(edge.ref.id), idIsText, terms), (values) => {
1691
+ try {
1692
+ return Option.some(compareRows(terms, row.value, values, body.entity.name));
1693
+ } catch {
1694
+ return Option.none();
1695
+ }
1696
+ });
1697
+ const boundary = RowPlace.Unknown({ reason: `${key} was where its page met rows not loaded` });
1698
+ const { segments } = connection;
1699
+ for (let index = 0; index < segments.length; index++) {
1700
+ const segment = segments[index];
1701
+ const held = segment.edges.findIndex((edge) => edge.key === key);
1702
+ const edges = segment.edges.filter((edge) => edge.key !== key);
1703
+ for (let position = 0; position < edges.length; position++) {
1704
+ const edge = edges[position];
1705
+ const sign = against(edge);
1706
+ if (Option.isNone(sign)) return RowPlace.Unknown({ reason: `${edge.key} lacks a field the order reads` });
1707
+ if (sign.value < 0) {
1708
+ const before = RowPlace.Before({
1709
+ segment: index,
1710
+ edge: edge.key
1711
+ });
1712
+ if (position > 0) return before;
1713
+ return Match.value(segment.start).pipe(Match.tag("Terminal", () => before), Match.orElse(() => held === 0 ? boundary : RowPlace.Outside()));
1714
+ }
1715
+ }
1716
+ if (Match.value(segment.end).pipe(Match.tag("Terminal", () => true), Match.orElse(() => false))) return RowPlace.End({ segment: index });
1717
+ if (held >= 0 && held === segment.edges.length - 1) return boundary;
1718
+ }
1719
+ return RowPlace.Outside();
1720
+ };
1721
+ /** The connection without the edge of `key`, or the connection itself when it holds none. */
1722
+ const withoutEdge = (connection, key) => connection.segments.some((segment) => segment.edges.some((edge) => edge.key === key)) ? {
1723
+ ...connection,
1724
+ segments: connection.segments.map((segment) => ({
1725
+ ...segment,
1726
+ edges: segment.edges.filter((edge) => edge.key !== key)
1727
+ }))
1728
+ } : connection;
1729
+ /** The connection with `edge` where `place` says: before an edge, or at a segment's end. */
1730
+ const withEdge = (connection, added, place) => ({
1731
+ ...connection,
1732
+ segments: connection.segments.map((segment, index) => {
1733
+ if (index !== place.segment) return segment;
1734
+ const at = Match.value(place).pipe(Match.tagsExhaustive({
1735
+ Before: ({ edge }) => segment.edges.findIndex((held) => held.key === edge),
1736
+ End: () => segment.edges.length
1737
+ }));
1738
+ return {
1739
+ ...segment,
1740
+ edges: [
1741
+ ...segment.edges.slice(0, at),
1742
+ added,
1743
+ ...segment.edges.slice(at)
1744
+ ]
1745
+ };
1746
+ })
1747
+ });
1748
+ //#endregion
1549
1749
  //#region src/matching.ts
1550
1750
  /**
1551
1751
  * Running a query body against the rows the client already holds.
@@ -1652,12 +1852,132 @@ const belongsEncoded = (store, descriptor, encoded, key) => {
1652
1852
  if (entry.tombstone) return "no";
1653
1853
  const body = descriptor.body;
1654
1854
  if (body !== void 0 && decidableOn(body).some((field) => entry.stale.has(field))) return "unknown";
1655
- const judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
1855
+ let judged;
1856
+ try {
1857
+ judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
1858
+ } catch (error) {
1859
+ if (error instanceof QueryEvaluateError) return "unknown";
1860
+ throw error;
1861
+ }
1656
1862
  if (judged.matched.includes(key)) return "yes";
1657
1863
  if (judged.skipped.includes(key)) return "unknown";
1658
1864
  return "no";
1659
1865
  };
1660
1866
  //#endregion
1867
+ //#region src/impact.ts
1868
+ /**
1869
+ * What a write did to the lists the client holds: given one change and one
1870
+ * loaded connection, whether the connection is still right as it stands.
1871
+ *
1872
+ * Pure, and asked once per connection a write could touch. The answer is never
1873
+ * a guess: a connection kept must be provably unchanged in which rows it holds
1874
+ * and in what order, a connection placed is the client's exact deduction of
1875
+ * what the server now lists, and everything else is invalidated, with the
1876
+ * reason, for the read entry to fetch again.
1877
+ *
1878
+ * Membership is judged by the query's body, as a live insert is
1879
+ * (`belongsEncoded`), and position by its declared order (`placeIn`), when
1880
+ * the order is placeable. A body that names no order leaves it to the server,
1881
+ * by fields the client cannot see, so any change to a row it holds
1882
+ * invalidates it.
1883
+ */
1884
+ /**
1885
+ * One row a write changed: the fields it wrote, and whether the client held
1886
+ * no values of the row before (`created`), or its deletion.
1887
+ */
1888
+ const EntityChange = defineTaggedUnion({
1889
+ Changed: {
1890
+ entity: Schema.String,
1891
+ id: Schema.String,
1892
+ fields: Schema.Array(Schema.String),
1893
+ created: Schema.Boolean
1894
+ },
1895
+ Deleted: {
1896
+ entity: Schema.String,
1897
+ id: Schema.String
1898
+ }
1899
+ });
1900
+ /**
1901
+ * What a mutation's answer changed, against `before`, the store as the server
1902
+ * last said it: each patch's fields whose value is new, and each deletion. An
1903
+ * answer returns more than it changed (a whole row, say), and counting an
1904
+ * unchanged ordering field would refetch every list on every save.
1905
+ */
1906
+ const changesOf = (answer, before) => [...answer.entities.flatMap((patch) => {
1907
+ const held = before[entityKey(patch.entity, patch.id)]?.values;
1908
+ const fields = Object.keys(patch.values).filter((field) => held === void 0 || !Object.hasOwn(held, field) || !sameData(held[field], patch.values[field]));
1909
+ return fields.length === 0 ? [] : [EntityChange.Changed({
1910
+ entity: patch.entity,
1911
+ id: patch.id,
1912
+ fields,
1913
+ created: held === void 0
1914
+ })];
1915
+ }), ...(answer.deleted ?? []).map((gone) => EntityChange.Deleted({
1916
+ entity: gone.entity,
1917
+ id: gone.id
1918
+ }))];
1919
+ const Impact = Data.taggedEnum();
1920
+ const kept = Impact.Kept();
1921
+ /** Whether two versions of a list hold the same rows in the same order, segment by segment. */
1922
+ const sameOrder = (left, right) => left.segments.length === right.segments.length && left.segments.every((segment, index) => {
1923
+ const other = right.segments[index].edges;
1924
+ return segment.edges.length === other.length && segment.edges.every((edge, position) => edge.key === other[position].key);
1925
+ });
1926
+ /**
1927
+ * What `change` does to `connection`, judged against `store`, the store after
1928
+ * the write.
1929
+ */
1930
+ const impactOn = (connection, store, change) => {
1931
+ const { descriptor } = connection;
1932
+ const body = descriptor.body;
1933
+ if ((body?.entity.name ?? descriptor.Result.entity) !== change.entity) return kept;
1934
+ return EntityChange.match(change, {
1935
+ Deleted: () => kept,
1936
+ Changed: ({ fields, created }) => {
1937
+ if (body === void 0) return Impact.Invalidated({ reason: `query "${descriptor.name}" declares no body, so what it reads is unknown` });
1938
+ const key = entityKey(change.entity, change.id);
1939
+ const held = connection.holds.has(key);
1940
+ const ruled = fields.find((field) => connection.filtered.includes(field));
1941
+ if (!held && ruled !== void 0) return Impact.Invalidated({ reason: `${key} changed "${ruled}", which the server's read rule for ${change.entity} filters on` });
1942
+ const roles = Query.dependencies(body);
1943
+ const touches = (keys) => keys.some((field) => fields.includes(field.key));
1944
+ let order;
1945
+ try {
1946
+ order = Query.orderFor(body, connection.encoded);
1947
+ } catch {
1948
+ return Impact.Invalidated({ reason: `"${descriptor.name}" was read in an order its body does not offer` });
1949
+ }
1950
+ if (held && body.orderBy.length === 0) return Impact.Invalidated({ reason: `${key} changed, and "${descriptor.name}" is ordered by the server, by fields its body does not name` });
1951
+ const pages = connection.connection;
1952
+ const reorders = touches(dependenciesOf(...order).fields);
1953
+ const refilters = touches(roles.predicate);
1954
+ const unloaded = hasPrevious(pages) || hasNext(pages) || isGapped(pages);
1955
+ if (!(held && reorders) && !refilters && !(!held && (created || reorders && unloaded))) return kept;
1956
+ const belongs = held && !refilters ? "yes" : belongsEncoded(store, descriptor, connection.encoded, key);
1957
+ if (belongs === "unknown") return Impact.Invalidated({ reason: `${key} changed a field "${descriptor.name}" filters on, and the client cannot judge it` });
1958
+ if (!held && belongs === "no") return kept;
1959
+ if (held && belongs === "yes" && !reorders) return kept;
1960
+ const inPages = pages.segments.some((segment) => segment.edges.some((edge) => edge.key === key));
1961
+ if (held && !inPages) return Impact.Invalidated({ reason: `${key} is shown in "${descriptor.name}" by an overlay, which a placement does not move` });
1962
+ if (belongs === "no") return Impact.Placed({ connection: withoutEdge(pages, key) });
1963
+ /** The row where `place` says; the list as it was when that is where it already stood. */
1964
+ const placedAt = (place) => {
1965
+ const next = withEdge(withoutEdge(pages, key), edge({
1966
+ entity: change.entity,
1967
+ id: change.id
1968
+ }), place);
1969
+ return sameOrder(pages, next) ? kept : Impact.Placed({ connection: next });
1970
+ };
1971
+ return RowPlace.$match(placeIn(store, pages, body, connection.encoded, key), {
1972
+ Before: placedAt,
1973
+ End: placedAt,
1974
+ Outside: () => held ? Impact.Placed({ connection: withoutEdge(pages, key) }) : kept,
1975
+ Unknown: ({ reason }) => Impact.Invalidated({ reason: `${key} ${held ? "moved in" : "joined"} "${descriptor.name}", and cannot be placed: ${reason}` })
1976
+ });
1977
+ }
1978
+ });
1979
+ };
1980
+ //#endregion
1661
1981
  //#region src/inspect.ts
1662
1982
  const inspectEntry = (key, entry) => ({
1663
1983
  key,
@@ -1674,6 +1994,7 @@ const inspectRemote = (model) => ({
1674
1994
  connections: Object.keys(model.connections),
1675
1995
  live: Object.keys(model.live),
1676
1996
  gaps: [...model.gaps],
1997
+ placementMismatches: model.placementMismatches,
1677
1998
  loading: [...model.loading],
1678
1999
  failures: model.failures,
1679
2000
  mutations: {
@@ -2369,7 +2690,9 @@ const fresh = { _tag: "Fresh" };
2369
2690
  const refreshing = { _tag: "Refreshing" };
2370
2691
  const remoteErrorSchema = Schema.Struct({
2371
2692
  _tag: Schema.String,
2372
- message: Schema.String
2693
+ message: Schema.String,
2694
+ refusal: Schema.optional(Schema.Unknown),
2695
+ reason: Schema.optional(Schema.String)
2373
2696
  });
2374
2697
  /** A `RemoteData` schema, so a projection that reads remote state is typed. */
2375
2698
  const remoteDataSchemas = /* @__PURE__ */ new WeakMap();
@@ -2403,6 +2726,18 @@ const RemoteData = {
2403
2726
  * be embedded in a hand-written Model schema, not only through `Remote.select`.
2404
2727
  */
2405
2728
  schema: (value) => remoteDataSchema(value),
2729
+ /**
2730
+ * Whether the read has an answer: `Ready`, `Refreshing`, `Failed` or
2731
+ * `NotFound`. `Initial` and `Loading` are still waiting for one.
2732
+ */
2733
+ answered: (data) => RemoteData.match(data, {
2734
+ Initial: () => false,
2735
+ Loading: () => false,
2736
+ Ready: () => true,
2737
+ Refreshing: () => true,
2738
+ Failed: () => true,
2739
+ NotFound: () => true
2740
+ }),
2406
2741
  /** Exhaustive: omitting a state is a compile error. */
2407
2742
  match: (data, cases) => {
2408
2743
  switch (data._tag) {
@@ -2524,9 +2859,13 @@ const initialRemoteModel = {
2524
2859
  live: {},
2525
2860
  mutations: emptyMutationState,
2526
2861
  gaps: /* @__PURE__ */ new Set(),
2862
+ streams: {},
2527
2863
  loading: /* @__PURE__ */ new Set(),
2528
2864
  refresh: emptyRefresh,
2529
- failures: noFailures
2865
+ failures: noFailures,
2866
+ restoredFrom: Option.none(),
2867
+ placed: {},
2868
+ placementMismatches: []
2530
2869
  };
2531
2870
  /** The entity store and the mutation ledger are runtime values, not wire shapes. */
2532
2871
  const runtimeSchema = Schema.Unknown;
@@ -2538,12 +2877,16 @@ const remoteModelSchema = () => Schema.Struct({
2538
2877
  live: Schema.Record(Schema.String, runtimeSchema),
2539
2878
  mutations: runtimeSchema,
2540
2879
  gaps: runtimeSchema,
2880
+ streams: Schema.Record(Schema.String, runtimeSchema),
2541
2881
  loading: runtimeSchema,
2542
2882
  refresh: runtimeSchema,
2543
2883
  failures: Schema.Struct({
2544
2884
  connections: Schema.Record(Schema.String, remoteErrorSchema),
2545
2885
  fields: Schema.Record(Schema.String, remoteErrorSchema)
2546
- })
2886
+ }),
2887
+ restoredFrom: runtimeSchema,
2888
+ placed: runtimeSchema,
2889
+ placementMismatches: runtimeSchema
2547
2890
  });
2548
2891
  const retentionRootsSchema = Schema.Struct({
2549
2892
  requirements: Schema.Array(ReadRequest),
@@ -2576,6 +2919,7 @@ const remoteMessageCases = {
2576
2919
  RetentionChanged: { roots: retentionRootsSchema },
2577
2920
  Hydrated: {
2578
2921
  connections: Schema.optional(Schema.Unknown),
2922
+ from: Schema.optional(Schema.String),
2579
2923
  entities: runtimeSchema,
2580
2924
  merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
2581
2925
  },
@@ -2639,7 +2983,10 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
2639
2983
  ...fields
2640
2984
  })));
2641
2985
  /** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
2642
- const overlayLayer = (id) => `overlay:${id}`;
2986
+ const OVERLAY = "overlay:";
2987
+ const overlayLayer = (id) => `${OVERLAY}${id}`;
2988
+ /** The ids `OverlayShown` shows now, each once, in the order they are drawn. */
2989
+ const overlaysOf = (model) => [...new Set([...model.optimistic.layers, ...model.optimistic.overlays].flatMap(({ id }) => id.startsWith(OVERLAY) ? [id.slice(8)] : []))];
2643
2990
  /** The key a grown window is kept under: its connection and the window first asked for. */
2644
2991
  const windowGrowthKey = (identity, window) => `${identity}\u0000${window}`;
2645
2992
  /**
@@ -2760,6 +3107,22 @@ const withRefreshRequested = (refresh, requests, connections = []) => {
2760
3107
  };
2761
3108
  };
2762
3109
  /**
3110
+ * Marks connections as needing to be asked again, and restarts a read of them
3111
+ * already in flight: a page requested before what invalidated it could land
3112
+ * after it, saying what is no longer true. `Remote.refresh` marks the same way.
3113
+ */
3114
+ const invalidateConnections = (model, connections) => {
3115
+ if (connections.length === 0) return model;
3116
+ const marked = connections.reduce((current, connection) => updateRemote(current, {
3117
+ _tag: "ConnectionInvalidated",
3118
+ connection
3119
+ }), model);
3120
+ return {
3121
+ ...marked,
3122
+ refresh: withRefreshRequested(marked.refresh, [], connections)
3123
+ };
3124
+ };
3125
+ /**
2763
3126
  * Records that a read of `requests` began under `generation`. A read that
2764
3127
  * carries no generation began under none, and leaves the marks as they are.
2765
3128
  */
@@ -2810,9 +3173,49 @@ const withLiveState = (live, stream, state) => live[stream] === state ? live : {
2810
3173
  ...live,
2811
3174
  [stream]: state
2812
3175
  };
2813
- const markGap = (model, stream) => model.gaps.has(stream) ? model : {
2814
- ...model,
2815
- gaps: /* @__PURE__ */ new Set([...model.gaps, stream])
3176
+ const noHealth = {
3177
+ restarts: 0,
3178
+ failures: 0,
3179
+ error: Option.none()
3180
+ };
3181
+ /**
3182
+ * A stream broke: it is marked a gap and its restart count moves, which
3183
+ * restarts its live entry. A stream already broken and not yet restarted
3184
+ * changes nothing, so the events a dying stream still delivers restart it once.
3185
+ */
3186
+ const broke = (model, stream, error) => {
3187
+ if (model.gaps.has(stream)) return model;
3188
+ const health = model.streams[stream] ?? noHealth;
3189
+ return {
3190
+ ...model,
3191
+ gaps: /* @__PURE__ */ new Set([...model.gaps, stream]),
3192
+ streams: {
3193
+ ...model.streams,
3194
+ [stream]: {
3195
+ restarts: health.restarts + 1,
3196
+ failures: health.failures + 1,
3197
+ error
3198
+ }
3199
+ }
3200
+ };
3201
+ };
3202
+ /**
3203
+ * An event applied on a stream that resubscribed: it works, so its next break
3204
+ * backs off from the start. A broken stream's last events prove nothing.
3205
+ */
3206
+ const healthy = (model, stream) => {
3207
+ const health = model.streams[stream];
3208
+ return health === void 0 || health.failures === 0 || model.gaps.has(stream) ? model : {
3209
+ ...model,
3210
+ streams: {
3211
+ ...model.streams,
3212
+ [stream]: {
3213
+ ...health,
3214
+ failures: 0,
3215
+ error: Option.none()
3216
+ }
3217
+ }
3218
+ };
2816
3219
  };
2817
3220
  const clearGap = (model, stream) => model.gaps.has(stream) ? {
2818
3221
  ...model,
@@ -2820,8 +3223,8 @@ const clearGap = (model, stream) => model.gaps.has(stream) ? {
2820
3223
  } : model;
2821
3224
  /**
2822
3225
  * The pure reducer all four producers share. A live event that arrives ahead of
2823
- * its cursor is a gap: it is not applied, and the stream is recorded so the host
2824
- * can resubscribe rather than silently miss facts. When every field a Message
3226
+ * its cursor is a gap: it is not applied, and the stream is recorded so its
3227
+ * live entry restarts it rather than silently missing facts. When every field a Message
2825
3228
  * touches keeps its identity, `model` itself is returned, so the application's
2826
3229
  * root keeps its identity and Foldkit does not render. The common no-ops do (a
2827
3230
  * duplicate live event, a repeated `ReadStarted`); a repeated failure, or a
@@ -2837,6 +3240,32 @@ const sameFields = (model, next) => {
2837
3240
  const keys = Object.keys(next);
2838
3241
  return keys.length === Object.keys(model).length && keys.every((key) => Object.is(model[key], next[key]));
2839
3242
  };
3243
+ /**
3244
+ * A placement compared with the server's first page of the list it placed
3245
+ * in, under `verifyPlacement`: the keys as far as both reach must agree, and
3246
+ * no row placing took out may be one the page still lists. The record goes
3247
+ * once compared; a disagreement is kept.
3248
+ */
3249
+ const checkedPlacement = (model, connection, page, refreshes) => {
3250
+ const placed = model.placed[connection];
3251
+ if (placed === void 0 || !refreshes || page.start._tag !== "Terminal") return {
3252
+ placed: model.placed,
3253
+ placementMismatches: model.placementMismatches
3254
+ };
3255
+ const { [connection]: _, ...rest } = model.placed;
3256
+ const reach = Math.min(placed.order.length, page.edges.length);
3257
+ const served = page.edges.slice(0, reach).map((edge) => edge.key);
3258
+ const asPlaced = placed.order.slice(0, reach);
3259
+ const listed = new Set(page.edges.map((edge) => edge.key));
3260
+ return {
3261
+ placed: rest,
3262
+ placementMismatches: served.every((key, index) => key === asPlaced[index]) && !placed.dropped.some((key) => listed.has(key)) ? model.placementMismatches : [...model.placementMismatches, {
3263
+ connection,
3264
+ placed: asPlaced,
3265
+ served
3266
+ }]
3267
+ };
3268
+ };
2840
3269
  const reduceRemote = (model, message) => {
2841
3270
  switch (message._tag) {
2842
3271
  case "ReadReceived": return {
@@ -2846,7 +3275,7 @@ const reduceRemote = (model, message) => {
2846
3275
  failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
2847
3276
  };
2848
3277
  case "ReadFailed":
2849
- if (message.stream !== void 0) return markGap(model, message.stream);
3278
+ if (message.stream !== void 0) return broke(model, message.stream, Option.some(message.error));
2850
3279
  return {
2851
3280
  ...model,
2852
3281
  entities: setStale(model.entities, marksOf(message.requests), false),
@@ -2886,7 +3315,8 @@ const reduceRemote = (model, message) => {
2886
3315
  } : {
2887
3316
  ...Object.fromEntries(restored),
2888
3317
  ...model.connections
2889
- }
3318
+ },
3319
+ restoredFrom: message.from === void 0 ? model.restoredFrom : Option.some(message.from)
2890
3320
  };
2891
3321
  }
2892
3322
  case "RefreshStarted": {
@@ -2956,29 +3386,28 @@ const reduceRemote = (model, message) => {
2956
3386
  if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
2957
3387
  const event = message.event;
2958
3388
  const applied = applyEntityEvent(state, model.entities, event, message.now);
2959
- if (applied.outcome === "gap") return markGap(model, message.stream);
3389
+ if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
2960
3390
  const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
2961
3391
  ...event.ref,
2962
3392
  values: event.values
2963
3393
  }])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
2964
- return clearGap({
3394
+ const next = {
2965
3395
  ...model,
2966
3396
  entities: applied.store,
2967
3397
  live: withLiveState(model.live, message.stream, applied.state),
2968
3398
  failures: withoutFieldFailures(model.failures, settled)
2969
- }, message.stream);
3399
+ };
3400
+ return applied.outcome === "applied" ? healthy(next, message.stream) : next;
2970
3401
  }
2971
3402
  const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
2972
- if (applied.outcome === "gap") return markGap(model, message.stream);
2973
- const next = clearGap({
3403
+ if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
3404
+ const written = {
2974
3405
  ...model,
2975
3406
  optimistic: applied.optimistic,
2976
3407
  live: withLiveState(model.live, message.stream, applied.state)
2977
- }, message.stream);
2978
- return applied.invalidated === void 0 ? next : updateRemote(next, {
2979
- _tag: "ConnectionInvalidated",
2980
- connection: applied.invalidated
2981
- });
3408
+ };
3409
+ const next = applied.outcome === "applied" ? healthy(written, message.stream) : written;
3410
+ return applied.invalidated === void 0 ? next : invalidateConnections(next, [applied.invalidated]);
2982
3411
  }
2983
3412
  case "GapCleared": return clearGap(model, message.stream);
2984
3413
  case "ConnectionMerged": {
@@ -2999,6 +3428,7 @@ const reduceRemote = (model, message) => {
2999
3428
  const failures = withoutFieldFailures(withoutConnectionFailure(model.failures, message.connection), [...fieldMarks(requests), ...patchedMarks(payload)]);
3000
3429
  return {
3001
3430
  ...model,
3431
+ ...checkedPlacement(model, message.connection, message.page, message.refreshes === true),
3002
3432
  entities,
3003
3433
  connections: {
3004
3434
  ...model.connections,
@@ -3529,29 +3959,46 @@ const RemoteJsonRequest = Schema.Struct({
3529
3959
  payload: Schema.Unknown
3530
3960
  });
3531
3961
  /** An answer: the call's result, or why there is none. */
3532
- const RemoteJsonAnswer = Schema.Union([Schema.Struct({ result: Schema.Unknown }), Schema.Struct({ error: Schema.String })]);
3962
+ const RemoteJsonAnswer = Schema.Union([Schema.Struct({ result: Schema.Unknown }), Schema.Struct({
3963
+ error: Schema.String,
3964
+ refusal: Schema.optional(Schema.Unknown),
3965
+ /** Who is asking was refused, with the application's reason when it gave one. */
3966
+ forbidden: Schema.optional(Schema.Struct({ reason: Schema.optional(Schema.String) }))
3967
+ })]);
3533
3968
  const decodeAnswer = Schema.decodeUnknownEffect(RemoteJsonAnswer);
3534
3969
  const call = (send, operation, payload, fail) => Effect.tryPromise({
3535
3970
  try: () => send(JSON.stringify({
3536
3971
  operation,
3537
3972
  payload
3538
3973
  })),
3539
- catch: (error) => fail(error instanceof Error ? error.message : String(error))
3540
- }).pipe(Effect.flatMap((answer) => decodeAnswer(answer).pipe(Effect.mapError(() => fail("The server answered with something that is not an answer")))), Effect.flatMap((answer) => "error" in answer ? Effect.fail(fail(answer.error)) : Effect.succeed(answer.result)));
3974
+ catch: (error) => fail({ error: error instanceof Error ? error.message : String(error) })
3975
+ }).pipe(Effect.flatMap((answer) => decodeAnswer(answer).pipe(Effect.mapError(() => fail({ error: "The server answered with something that is not an answer" })))), Effect.flatMap((answer) => "error" in answer ? Effect.fail(fail(answer)) : Effect.succeed(answer.result)));
3541
3976
  /** Remote's client over `send`, for `Remote.clientLayer`. */
3542
3977
  const json = (send) => ({
3543
- FoldkitRemoteRead: (payload) => call(send, "read", payload, (message) => new RemoteReadError({ message })),
3544
- FoldkitRemoteQuery: (payload) => call(send, "query", payload, (message) => new RemoteQueryError({ message })),
3545
- FoldkitRemoteMutate: (payload) => call(send, "mutate", payload, (message) => new RemoteMutationError({ message })),
3978
+ FoldkitRemoteRead: (payload) => call(send, "read", payload, ({ error }) => new RemoteReadError({ message: error })),
3979
+ FoldkitRemoteQuery: (payload) => call(send, "query", payload, ({ error }) => new RemoteQueryError({ message: error })),
3980
+ FoldkitRemoteMutate: (payload) => call(send, "mutate", payload, ({ error, refusal, forbidden }) => forbidden === void 0 ? new RemoteMutationError({
3981
+ message: error,
3982
+ refusal
3983
+ }) : new RemoteForbidden({
3984
+ message: error,
3985
+ ...forbidden.reason === void 0 ? {} : { reason: forbidden.reason }
3986
+ })),
3546
3987
  FoldkitRemoteLive: () => Stream.empty
3547
3988
  });
3989
+ /** A body that did not parse, told apart from every value one can parse to, `null` included. */
3990
+ const notJson = Symbol("not JSON");
3548
3991
  /**
3549
3992
  * Remote's client over HTTP: each call a `POST` of the request to `url`, the
3550
- * answer its JSON body whatever its status. `headers` is read per request, so
3551
- * a token that changes reaches the next call.
3993
+ * answer its JSON body whatever its status. A 403 is answered as forbidden
3994
+ * unless its body says so itself, as the server's own does: one from
3995
+ * middleware in front of Remote (a refused session) may say only `{ error }`.
3996
+ * `headers` is read per request, so a token that changes reaches the next
3997
+ * call. `fetch` defaults to the global one; pass it to route calls through
3998
+ * another implementation in tests.
3552
3999
  */
3553
4000
  const http = (url, options = {}) => json(async (request) => {
3554
- const response = await fetch(url, {
4001
+ const response = await (options.fetch ?? fetch)(url, {
3555
4002
  method: "POST",
3556
4003
  headers: {
3557
4004
  "content-type": "application/json",
@@ -3559,9 +4006,120 @@ const http = (url, options = {}) => json(async (request) => {
3559
4006
  },
3560
4007
  body: request
3561
4008
  });
3562
- return response.json().catch(() => {
3563
- throw new Error(`${response.status} ${response.statusText}`);
4009
+ const status = `${response.status} ${response.statusText}`;
4010
+ const body = await response.json().catch(() => notJson);
4011
+ const saysForbidden = typeof body === "object" && body !== null && Object.hasOwn(body, "forbidden");
4012
+ if (response.status === 403 && !saysForbidden) return {
4013
+ error: status,
4014
+ forbidden: {}
4015
+ };
4016
+ if (body === notJson) throw new Error(status);
4017
+ return body;
4018
+ });
4019
+ //#endregion
4020
+ //#region src/sse.ts
4021
+ /**
4022
+ * Remote live over fetch with SSE framing: the other end of
4023
+ * `foldkit-remote-server/fetch`'s live open. One `POST` carries the live
4024
+ * requirement; the answer is a `text/event-stream` of `LiveChange` frames,
4025
+ * one `data:` frame per change, closing when the server has nothing more or
4026
+ * failing on a terminal `event: error` frame. Abandoning the stream aborts
4027
+ * the request, which interrupts the subscription server-side.
4028
+ *
4029
+ * `httpWithLive` is this live stream beside `http`'s reads, queries, and
4030
+ * mutations, for `Remote.clientLayer`.
4031
+ */
4032
+ const decodeChange = Schema.decodeUnknownSync(LiveChange);
4033
+ const parseFrame = (text) => {
4034
+ let event = "message";
4035
+ const data = [];
4036
+ for (const line of text.split(/\r\n|\n/)) {
4037
+ if (line.startsWith(":") || line === "") continue;
4038
+ const colon = line.indexOf(":");
4039
+ if (colon < 0) continue;
4040
+ const field = line.slice(0, colon);
4041
+ const value = line.slice(colon + 1).startsWith(" ") ? line.slice(colon + 2) : line.slice(colon + 1);
4042
+ if (field === "event") event = value;
4043
+ else if (field === "data") data.push(value);
4044
+ }
4045
+ return {
4046
+ event,
4047
+ data
4048
+ };
4049
+ };
4050
+ /** The frames of a body, split liberally: a blank line ends a frame. */
4051
+ async function* frames(body) {
4052
+ const reader = body.getReader();
4053
+ const decoder = new TextDecoder();
4054
+ let buffer = "";
4055
+ try {
4056
+ for (;;) {
4057
+ const next = await reader.read();
4058
+ if (next.done) break;
4059
+ buffer += decoder.decode(next.value, { stream: true });
4060
+ for (;;) {
4061
+ const found = /(?:\r\n|\n){2}/.exec(buffer);
4062
+ if (found === null) break;
4063
+ yield parseFrame(buffer.slice(0, found.index));
4064
+ buffer = buffer.slice(found.index + found[0].length);
4065
+ }
4066
+ }
4067
+ buffer += decoder.decode();
4068
+ if (buffer.trim() !== "") yield parseFrame(buffer);
4069
+ } finally {
4070
+ await reader.cancel().catch(() => {});
4071
+ reader.releaseLock();
4072
+ }
4073
+ }
4074
+ /** The wire changes of a live body: data frames decoded, error frames failed. */
4075
+ async function* changes(body) {
4076
+ for await (const frame of frames(body)) {
4077
+ if (frame.data.length === 0) continue;
4078
+ const text = frame.data.join("\n");
4079
+ if (frame.event === "error") {
4080
+ const message = JSON.parse(text).message;
4081
+ throw new RemoteLiveError({ message: typeof message === "string" ? message : text });
4082
+ }
4083
+ yield decodeChange(JSON.parse(text));
4084
+ }
4085
+ }
4086
+ const liveError = (error) => error instanceof RemoteLiveError ? error : new RemoteLiveError({ message: error instanceof Error ? error.message : String(error) });
4087
+ /** Remote's live stream over fetch: `serveFetch`'s live open as a `Stream`. */
4088
+ const liveFetch = (url, options = {}) => (payload) => Stream.unwrap(Effect.gen(function* () {
4089
+ const via = options.fetch ?? fetch;
4090
+ const controller = yield* Effect.acquireRelease(Effect.sync(() => new AbortController()), (open) => Effect.sync(() => open.abort()));
4091
+ const response = yield* Effect.tryPromise({
4092
+ try: () => via(url, {
4093
+ method: "POST",
4094
+ headers: {
4095
+ "content-type": "application/json",
4096
+ ...options.headers?.()
4097
+ },
4098
+ body: JSON.stringify({
4099
+ operation: "live",
4100
+ payload
4101
+ }),
4102
+ signal: controller.signal
4103
+ }),
4104
+ catch: liveError
3564
4105
  });
4106
+ if (!response.ok) {
4107
+ const answered = yield* Effect.tryPromise({
4108
+ try: () => response.json().catch(() => null),
4109
+ catch: liveError
4110
+ });
4111
+ return yield* new RemoteLiveError({ message: Predicate.isObject(answered) && "error" in answered ? String(answered.error) : `HTTP ${response.status} ${response.statusText}` });
4112
+ }
4113
+ if (response.body === null) return yield* new RemoteLiveError({ message: "The server answered live with no body" });
4114
+ return Stream.fromAsyncIterable(changes(response.body), liveError);
4115
+ }));
4116
+ /**
4117
+ * Remote's client over HTTP with live: reads, queries, and mutations as
4118
+ * `http` sends them, live as `liveFetch` streams it, for `clientLayer`.
4119
+ */
4120
+ const httpWithLive = (url, options = {}) => ({
4121
+ ...http(url, options),
4122
+ FoldkitRemoteLive: liveFetch(url, options)
3565
4123
  });
3566
4124
  //#endregion
3567
4125
  //#region src/index.ts
@@ -3600,6 +4158,30 @@ const readContract = (source, shape, window) => {
3600
4158
  }
3601
4159
  };
3602
4160
  };
4161
+ /**
4162
+ * A read contract whose connection, when a client can place rows in it, also
4163
+ * reads every field its body filters and orders by: a changed row is then
4164
+ * compared with neighbours that hold those fields. What the view reads, and so
4165
+ * decodes, stays its Selection.
4166
+ */
4167
+ const placeableReadContract = (contract, body) => {
4168
+ if (body === void 0) return contract;
4169
+ const { requirement, ref } = contract;
4170
+ const encoded = Schema.encodeSync(ref.Input)(ref.input);
4171
+ if (Query.placement(body, encoded)._tag !== "Placeable") return contract;
4172
+ const missing = Query.dependencies(body).fields.filter((field) => !requirement.select.fields.includes(field.key));
4173
+ if (missing.length === 0) return contract;
4174
+ return {
4175
+ ...contract,
4176
+ requirement: {
4177
+ ...requirement,
4178
+ select: {
4179
+ ...requirement.select,
4180
+ fields: [...requirement.select.fields, ...missing.map((field) => field.key)]
4181
+ }
4182
+ }
4183
+ };
4184
+ };
3603
4185
  const metaOf = (remote, asked) => {
3604
4186
  const visible = visibleStoreOf(remote.entities, remote.optimistic);
3605
4187
  let updatedAt;
@@ -3781,7 +4363,8 @@ const defineRemote = (config) => {
3781
4363
  registry: {
3782
4364
  entities: new Map(entities.map((entity) => [entity.name, entity])),
3783
4365
  queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
3784
- mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
4366
+ mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation])),
4367
+ filtered: new Map(config.entities.flatMap((entity) => Entity.is(entity) ? [[entity.name, ReadFilter.members(entity.metadata)]] : []))
3785
4368
  }
3786
4369
  };
3787
4370
  };
@@ -3803,19 +4386,10 @@ const bindRemote = (definition, store) => ({
3803
4386
  const assertRegistered = (bound, kind, registered, name) => {
3804
4387
  if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
3805
4388
  };
3806
- const nothingAsked = {
3807
- requirements: [],
3808
- connections: []
3809
- };
3810
4389
  const askedOf = (projection) => ({
3811
4390
  requirements: requirementsOf(projection),
3812
4391
  connections: connectionsOf(projection)
3813
4392
  });
3814
- /** What an active Surface asks for: nothing while it is inactive. */
3815
- const askedWhile = (projection) => Option.match(projection, {
3816
- onNone: () => nothingAsked,
3817
- onSome: askedOf
3818
- });
3819
4393
  /**
3820
4394
  * Whether `outer` asks for everything `inner` does of one entity: each field,
3821
4395
  * each relation's own slice of its target, and each page window alike.
@@ -4162,58 +4736,121 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
4162
4736
  };
4163
4737
  };
4164
4738
  const resumeCursor = Schema.Number.pipe(Schema.overrideToEquivalence(() => () => true));
4739
+ /** The wait before a stream's restart after `failures` breaks in a row: Sync's transport backoff. */
4740
+ const restartDelay = (options, failures) => {
4741
+ const base = Duration.toMillis(Duration.fromInputUnsafe(options.retryBase ?? "50 millis"));
4742
+ const max = Duration.toMillis(Duration.fromInputUnsafe(options.maxRetryDelay ?? "5 seconds"));
4743
+ const backoff = Math.min(max, base * 2 ** Math.min(Math.max(failures, 1) - 1, 30));
4744
+ return Duration.millis(backoff * (.8 + Math.random() * .4));
4745
+ };
4165
4746
  /** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
4166
4747
  const liveEntry = (bound, requirementsOf, toMessage, options) => ({
4167
4748
  dependenciesSchema: Schema.Struct({
4168
4749
  requirements: Schema.Array(ReadRequest),
4169
4750
  cursor: resumeCursor,
4170
- floor: Schema.Number
4751
+ floor: Schema.Number,
4752
+ restarts: Schema.Number,
4753
+ failures: resumeCursor
4171
4754
  }),
4172
4755
  modelToDependencies: (model) => {
4173
4756
  const requirements = requirementsOf(model);
4174
4757
  const stream = liveStreamKey(requirements);
4175
4758
  const remote = bound.store.get(model);
4759
+ const health = remote.streams[stream];
4176
4760
  return {
4177
4761
  requirements,
4178
4762
  cursor: remote.live[stream]?.cursor ?? 0,
4179
- floor: remote.refresh.floor
4763
+ floor: remote.refresh.floor,
4764
+ restarts: health?.restarts ?? 0,
4765
+ failures: health?.failures ?? 0
4180
4766
  };
4181
4767
  },
4182
- dependenciesToStream: ({ requirements, cursor }) => requirements.length === 0 ? Stream.empty : Stream.unwrap(Effect.gen(function* () {
4183
- return (yield* RemoteClient).live({
4184
- requirements,
4185
- after: cursor
4186
- });
4187
- })).pipe(Stream.map((event) => toMessage({
4188
- _tag: "LiveReceived",
4189
- stream: liveStreamKey(requirements),
4190
- event,
4191
- now: (options.now ?? wallClock)()
4192
- })), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
4193
- _tag: "ReadFailed",
4194
- requests: requirements,
4195
- error: remoteError(error),
4196
- stream: liveStreamKey(requirements)
4197
- }))))
4768
+ dependenciesToStream: ({ requirements, cursor, restarts, failures }) => {
4769
+ if (requirements.length === 0) return Stream.empty;
4770
+ const stream = liveStreamKey(requirements);
4771
+ const live = Stream.unwrap(Effect.gen(function* () {
4772
+ return (yield* RemoteClient).live({
4773
+ requirements,
4774
+ after: cursor
4775
+ });
4776
+ })).pipe(Stream.map((event) => toMessage({
4777
+ _tag: "LiveReceived",
4778
+ stream: liveStreamKey(requirements),
4779
+ event,
4780
+ now: (options.now ?? wallClock)()
4781
+ })), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
4782
+ _tag: "ReadFailed",
4783
+ requests: requirements,
4784
+ error: remoteError(error),
4785
+ stream: liveStreamKey(requirements)
4786
+ }))));
4787
+ if (restarts === 0) return live;
4788
+ return Stream.fromEffect(Effect.sleep(restartDelay(options, failures))).pipe(Stream.drain, Stream.concat(Stream.make(toMessage({
4789
+ _tag: "RefreshStarted",
4790
+ requests: requirements
4791
+ }), toMessage({
4792
+ _tag: "GapCleared",
4793
+ stream
4794
+ }))), Stream.concat(live));
4795
+ }
4796
+ });
4797
+ /** The projection an active entry has for this Model; none while it is inactive. */
4798
+ const projectionOf = (entry, model) => {
4799
+ if ("projectionOf" in entry) return entry.projectionOf(model);
4800
+ if ("projection" in entry) return Option.some(entry.projection());
4801
+ return Option.none();
4802
+ };
4803
+ /**
4804
+ * Every projection an entry resolves to for a Model: the lone one of a
4805
+ * `Surface.at`/`Surface.when` value or a bare Surface, or a family's parent
4806
+ * first and then each instance. The parent joins so an unloaded parent is
4807
+ * fetched before what it reveals; `Data.satisfy` reaches the instances in a
4808
+ * later pass. A caller that also passes the parent separately double-covers
4809
+ * its requirements, as overlapping Surfaces already do.
4810
+ */
4811
+ const resolvedOf = (entry, model) => {
4812
+ const source = entry;
4813
+ if (typeof source.instancesOf === "function") {
4814
+ const instances = source.instancesOf(model);
4815
+ const from = entry.from;
4816
+ if (from === void 0) return instances.map((instance) => ({
4817
+ name: entry.name,
4818
+ projection: instance.projection
4819
+ }));
4820
+ return [...Option.toArray(from.projectionOf(model)).map((projection) => ({
4821
+ name: from.name,
4822
+ projection
4823
+ })), ...instances.map((instance) => ({
4824
+ name: `${entry.name}[${instance.key}]`,
4825
+ projection: instance.projection
4826
+ }))];
4827
+ }
4828
+ return Option.toArray(projectionOf(entry, model)).map((projection) => ({
4829
+ name: entry.name,
4830
+ projection
4831
+ }));
4832
+ };
4833
+ /** What some projections ask for together: their requirements and connections unioned. */
4834
+ const askedUnion = (projections) => ({
4835
+ requirements: projections.flatMap(requirementsOf),
4836
+ connections: projections.flatMap(connectionsOf)
4198
4837
  });
4199
- /** The projection an active Surface has for this Model; none while it is inactive. */
4200
- const projectionOf = (entry, model) => "projectionOf" in entry ? entry.projectionOf(model) : Option.some(entry.projection());
4201
4838
  /**
4202
- * `projectionOf` computed once per Model: the read, live, and retain entries
4203
- * all derive their dependencies from the same projection on the same Model
4839
+ * `resolvedOf` computed once per Model: the read, live, and retain entries
4840
+ * all derive their dependencies from the same projections on the same Model
4204
4841
  * change, and a Surface's `model` callback (which lifts and builds schemas)
4205
4842
  * need not run three times for it. Models are objects, so the memo is weak.
4206
4843
  */
4207
- const memoizedProjectionOf = (entry) => {
4844
+ const memoizedResolved = (entry) => {
4208
4845
  const cache = /* @__PURE__ */ new WeakMap();
4209
4846
  return (model) => {
4210
4847
  const key = model;
4211
- if (typeof key !== "object" || key === null) return projectionOf(entry, model);
4848
+ if (typeof key !== "object" || key === null) return resolvedOf(entry, model);
4212
4849
  const known = cache.get(key);
4213
4850
  if (known !== void 0) return known;
4214
- const projection = projectionOf(entry, model);
4215
- cache.set(key, projection);
4216
- return projection;
4851
+ const resolved = resolvedOf(entry, model);
4852
+ cache.set(key, resolved);
4853
+ return resolved;
4217
4854
  };
4218
4855
  };
4219
4856
  const Remote = {
@@ -4294,7 +4931,7 @@ const Remote = {
4294
4931
  * in one step; the result carries the application-facing operations. The
4295
4932
  * descriptor alone is `Remote.define`, the binding alone `Remote.at`.
4296
4933
  */
4297
- make: (config) => bindDomain(defineRemote(config), config.model),
4934
+ make: (config) => bindDomain(defineRemote(config), config.model, { verifyPlacement: config.verifyPlacement }),
4298
4935
  /**
4299
4936
  * Declares a Remote domain without binding it: its entities, queries, and
4300
4937
  * mutations, plus the submodel. For a domain reused across applications or
@@ -4516,6 +5153,13 @@ const Remote = {
4516
5153
  * request to `url`. `headers` is read per request (a session token, say).
4517
5154
  */
4518
5155
  http,
5156
+ /**
5157
+ * Remote's client over HTTP with live, for `clientLayer`: reads, queries,
5158
+ * and mutations as `http` sends them, live requirements as one `POST`
5159
+ * answered with a `text/event-stream` of changes.
5160
+ * `foldkit-remote-server/fetch` is the other end.
5161
+ */
5162
+ httpWithLive,
4519
5163
  clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
4520
5164
  const context = yield* Effect.context();
4521
5165
  return {
@@ -4571,15 +5215,6 @@ const Remote = {
4571
5215
  }),
4572
5216
  /** The `RemoteMessage` that merges a query page into its connection. */
4573
5217
  queryMessage: (ref, result, select, now) => pageMessage(ref.identity, result, false, ref.window, select, now),
4574
- /**
4575
- * The edges a connection shows: its server-known region with pending and
4576
- * confirmed overlays placed around it, minus removed edges and edges whose
4577
- * target is a tombstone.
4578
- */
4579
- visibleItems: (model, connection) => {
4580
- const identity = connectionIdentity(connection);
4581
- return visibleItems(model.connections[identity] ?? emptyConnection, identity, model.optimistic.overlays, model.entities);
4582
- },
4583
5218
  /** A pure, serializable view of the whole cache. */
4584
5219
  inspect: inspectRemote,
4585
5220
  /** A pure, serializable view of one entity, or `undefined` if unknown. */
@@ -4631,7 +5266,8 @@ const Remote = {
4631
5266
  * requirements, emitting a `LiveReceived` per event and a `ReadFailed`
4632
5267
  * carrying its `stream` when the stream breaks (including
4633
5268
  * `ResumeUnavailable`). That records a gap on the stream rather than a failed
4634
- * read, since nothing was being read. The resume cursor is read
5269
+ * read, since nothing was being read, and restarts the entry after a
5270
+ * backoff; the restart refetches what it covers. The resume cursor is read
4635
5271
  * from `RemoteModel.live`, so the application tracks no cursor of its own.
4636
5272
  */
4637
5273
  live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
@@ -4647,7 +5283,7 @@ const failedReadWords = (error) => {
4647
5283
  }
4648
5284
  };
4649
5285
  /** The bound domain: the descriptor, the binding, and the operations over them. */
4650
- const bindDomain = (definition, store) => {
5286
+ const bindDomain = (definition, store, options = {}) => {
4651
5287
  const bound = bindRemote(definition, store);
4652
5288
  const readers = /* @__PURE__ */ new Map();
4653
5289
  const keptConnections = /* @__PURE__ */ new Set();
@@ -4692,11 +5328,248 @@ const bindDomain = (definition, store) => {
4692
5328
  policy: livePolicyFor(model, live.event)
4693
5329
  };
4694
5330
  const remote = store.get(model);
4695
- const next = updateRemote(remote, resolved);
5331
+ const reduced = updateRemote(remote, resolved);
5332
+ const answer = message._tag === "MutationSucceeded" ? message : void 0;
5333
+ const next = answer === void 0 ? placedNews(reduced, newsOf(message), remote.entities) : impactOf(reduced, answer, remote.entities);
4696
5334
  return next === remote ? model : store.set(model, next);
4697
5335
  };
5336
+ /** The registered query a connection identity names, and its encoded input. */
5337
+ const queryOf = (identity) => {
5338
+ const separator = identity.indexOf("\0");
5339
+ const descriptor = definition.registry.queries.get(identity.slice(0, separator));
5340
+ if (descriptor === void 0) return Option.none();
5341
+ const encoded = JSON.parse(identity.slice(separator + 1));
5342
+ const over = descriptor.body?.entity.name;
5343
+ const filtered = (over === void 0 ? void 0 : definition.registry.filtered.get(over)) ?? [];
5344
+ return Option.some({
5345
+ descriptor,
5346
+ encoded,
5347
+ filtered
5348
+ });
5349
+ };
5350
+ /**
5351
+ * `pages` with each change to a row they hold placed, judged against
5352
+ * `store`: a row that moved goes where the body's order puts it, and one
5353
+ * that stopped matching leaves. A row a change brings in is not added,
5354
+ * since what the list selects of it may not be loaded, and a change the
5355
+ * client cannot place leaves the row where it is.
5356
+ */
5357
+ const placedHeld = (identity, pages, changes, store) => {
5358
+ const query = queryOf(identity);
5359
+ if (Option.isNone(query)) return pages;
5360
+ const holds = new Set(items(pages).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
5361
+ let placed = pages;
5362
+ for (const change of changes) {
5363
+ if (!holds.has(entityKey(change.entity, change.id))) continue;
5364
+ placed = Impact.$match(impactOn({
5365
+ ...query.value,
5366
+ connection: placed,
5367
+ holds
5368
+ }, store, change), {
5369
+ Kept: () => placed,
5370
+ Placed: ({ connection }) => connection,
5371
+ Invalidated: () => placed
5372
+ });
5373
+ }
5374
+ return placed;
5375
+ };
5376
+ /**
5377
+ * A loaded list with the pending layers placed in its pages, so a row a
5378
+ * pending edit moves, or takes out of the list, shows where its answer
5379
+ * will be placed until the edit settles.
5380
+ */
5381
+ const pendingPlaced = (remote, identity, pages) => {
5382
+ const { layers } = remote.optimistic;
5383
+ if (layers.length === 0) return pages;
5384
+ return memoRead([
5385
+ pages,
5386
+ layers,
5387
+ remote.entities
5388
+ ], `placed:${identity}`, () => placedHeld(identity, pages, changesOf({ entities: layers.flatMap((layer) => layer.patches) }, remote.entities), visibleStoreOf(remote.entities, remote.optimistic)));
5389
+ };
5390
+ /**
5391
+ * The edges a loaded list shows: its pages with pending edits placed, and
5392
+ * each row an insert overlay shows (a pending create, a confirmed or a live
5393
+ * insert) where the body's order puts it, or left out when it sorts past
5394
+ * what is loaded. An overlay says a row is in the list; in a list the
5395
+ * client can place, the order says where. A row it cannot place stays at
5396
+ * the end its overlay names.
5397
+ */
5398
+ const shownEdges = (remote, identity, connection) => {
5399
+ const pages = pendingPlaced(remote, identity, connection);
5400
+ const shown = remote.optimistic.overlays.some((overlay) => overlay.connection === identity && overlay.position !== "remove") ? insertsPlaced(remote, identity, pages) : {
5401
+ pages,
5402
+ overlays: remote.optimistic.overlays
5403
+ };
5404
+ return visibleItems(shown.pages, identity, shown.overlays, remote.entities);
5405
+ };
5406
+ const insertsPlaced = (remote, identity, pages) => {
5407
+ const { overlays } = remote.optimistic;
5408
+ const store = visibleStoreOf(remote.entities, remote.optimistic);
5409
+ return memoRead([
5410
+ pages,
5411
+ overlays,
5412
+ store
5413
+ ], `inserted:${identity}`, () => {
5414
+ const query = queryOf(identity);
5415
+ const body = Option.isSome(query) ? query.value.descriptor.body : void 0;
5416
+ if (Option.isNone(query) || body === void 0) return {
5417
+ pages,
5418
+ overlays
5419
+ };
5420
+ const inserted = /* @__PURE__ */ new Map();
5421
+ for (const overlay of overlays) {
5422
+ if (overlay.connection !== identity || overlay.position === "remove") continue;
5423
+ for (const edge of overlay.edges) inserted.set(edge.key, edge);
5424
+ }
5425
+ const held = new Set(items(pages).map((edge) => edge.key));
5426
+ let placed = pages;
5427
+ const outside = [];
5428
+ for (const edge of inserted.values()) {
5429
+ if (held.has(edge.key)) continue;
5430
+ const key = entityKey(edge.ref.entity, edge.ref.id);
5431
+ RowPlace.$match(placeIn(store, placed, body, query.value.encoded, key), {
5432
+ Before: (place) => void (placed = withEdge(placed, edge, place)),
5433
+ End: (place) => void (placed = withEdge(placed, edge, place)),
5434
+ Outside: () => void outside.push(edge),
5435
+ Unknown: () => void 0
5436
+ });
5437
+ }
5438
+ return {
5439
+ pages: placed,
5440
+ overlays: outside.length === 0 ? overlays : [...overlays, {
5441
+ id: "placed:outside",
5442
+ connection: identity,
5443
+ edges: outside,
5444
+ position: "remove"
5445
+ }]
5446
+ };
5447
+ });
5448
+ };
5449
+ /**
5450
+ * The loaded lists with what the server said of rows they hold placed: a
5451
+ * read or a live patch that changes a held row's ordering or filtered field
5452
+ * moves it, or takes it out, as a mutation's answer does. A list it cannot
5453
+ * place stands as it is, as a read is no write: its own refetch or live
5454
+ * stream says otherwise. Only a value the client held before counts, so a
5455
+ * row's first read judges nothing.
5456
+ */
5457
+ const placedNews = (remote, news, before) => {
5458
+ if (news.length === 0) return remote;
5459
+ const held = news.map((patch) => {
5460
+ const values = before[entityKey(patch.entity, patch.id)]?.values ?? {};
5461
+ return {
5462
+ ...patch,
5463
+ values: Object.fromEntries(Object.entries(patch.values).filter(([field]) => Object.hasOwn(values, field)))
5464
+ };
5465
+ });
5466
+ const changes = changesOf({ entities: held }, before);
5467
+ if (changes.length === 0) return remote;
5468
+ const placed = {};
5469
+ for (const [identity, pages] of Object.entries(remote.connections)) {
5470
+ const after = placedHeld(identity, pages, changes, remote.entities);
5471
+ if (after !== pages) placed[identity] = after;
5472
+ }
5473
+ return withPlacements(remote, placed, []);
5474
+ };
5475
+ /** The rows a read or a live patch says the server holds now. */
5476
+ const newsOf = (message) => {
5477
+ if (message._tag === "ReadReceived") return message.result.entities;
5478
+ if (message._tag !== "LiveReceived") return [];
5479
+ const event = message.event;
5480
+ if (event._tag !== "EntityPatched") return [];
5481
+ return [{
5482
+ entity: event.ref.entity,
5483
+ id: event.ref.id,
5484
+ values: event.values
5485
+ }];
5486
+ };
5487
+ /**
5488
+ * The loaded connections as a write left them: each judged by its body
5489
+ * against the store as the write left it, a change it can place placed in
5490
+ * its pages, and one it cannot invalidated for its read entry to fetch.
5491
+ */
5492
+ const impactOf = (remote, answer, before) => {
5493
+ const changes = changesOf(answer, before);
5494
+ if (changes.length === 0) return remote;
5495
+ const named = /* @__PURE__ */ new Map();
5496
+ for (const change of answer.connections ?? []) {
5497
+ const keys = named.get(change.connection) ?? /* @__PURE__ */ new Set();
5498
+ keys.add(change.edge.key);
5499
+ named.set(change.connection, keys);
5500
+ }
5501
+ const visible = visibleStoreOf(remote.entities, remote.optimistic);
5502
+ const placed = {};
5503
+ const invalidated = [];
5504
+ for (const identity of Object.keys(remote.connections)) {
5505
+ const answered = named.get(identity);
5506
+ const judged = answered === void 0 ? changes : changes.filter((change) => !answered.has(entityKey(change.entity, change.id)));
5507
+ if (judged.length === 0) continue;
5508
+ const query = queryOf(identity);
5509
+ if (Option.isNone(query)) continue;
5510
+ const original = remote.connections[identity];
5511
+ const holds = new Set(visibleItems(original, identity, remote.optimistic.overlays, remote.entities).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
5512
+ let pages = original;
5513
+ let invalid = false;
5514
+ for (const change of judged) {
5515
+ const held = {
5516
+ ...query.value,
5517
+ connection: pages,
5518
+ holds
5519
+ };
5520
+ const after = Impact.$match(impactOn(held, visible, change), {
5521
+ Kept: () => Option.some(pages),
5522
+ Placed: ({ connection }) => Option.some(connection),
5523
+ Invalidated: () => Option.none()
5524
+ });
5525
+ if (Option.isNone(after)) {
5526
+ invalid = true;
5527
+ break;
5528
+ }
5529
+ pages = after.value;
5530
+ }
5531
+ if (invalid) invalidated.push(identity);
5532
+ else if (pages !== original) placed[identity] = pages;
5533
+ }
5534
+ return withPlacements(remote, placed, invalidated);
5535
+ };
5536
+ /**
5537
+ * The lists placed and invalidated. Under `verifyPlacement`, each placed
5538
+ * list is fetched too, and the order it was placed in kept to compare with
5539
+ * the server's page.
5540
+ */
5541
+ const withPlacements = (remote, placed, invalidated) => {
5542
+ const identities = Object.keys(placed);
5543
+ if (identities.length === 0) return invalidateConnections(remote, invalidated);
5544
+ const withPlaced = {
5545
+ ...remote,
5546
+ connections: {
5547
+ ...remote.connections,
5548
+ ...placed
5549
+ }
5550
+ };
5551
+ if (options.verifyPlacement !== true) return invalidateConnections(withPlaced, invalidated);
5552
+ const firstPage = (connection) => (connection?.segments[0]?.edges ?? []).map((edge) => edge.key);
5553
+ const orders = Object.fromEntries(identities.map((identity) => {
5554
+ const order = firstPage(placed[identity]);
5555
+ const kept = new Set(order);
5556
+ return [identity, {
5557
+ order,
5558
+ dropped: firstPage(remote.connections[identity]).filter((key) => !kept.has(key))
5559
+ }];
5560
+ }));
5561
+ return invalidateConnections({
5562
+ ...withPlaced,
5563
+ placed: {
5564
+ ...remote.placed,
5565
+ ...orders
5566
+ }
5567
+ }, [...invalidated, ...identities]);
5568
+ };
4698
5569
  const assertOwned = (entry) => {
4699
5570
  if (bound.contract.owner !== void 0 && entry.owner !== bound.contract.owner) throw new Error(`Remote: Surface "${entry.name}" belongs to another application than domain "${bound.contract.name}"`);
5571
+ const from = entry.from;
5572
+ if (from !== void 0 && bound.contract.owner !== void 0 && from.owner !== bound.contract.owner) throw new Error(`Remote: Surface "${from.name}" belongs to another application than domain "${bound.contract.name}"`);
4700
5573
  };
4701
5574
  const domain = {
4702
5575
  ...definition,
@@ -4726,16 +5599,16 @@ const bindDomain = (definition, store) => {
4726
5599
  const entries = {};
4727
5600
  for (const entry of Object.values(active)) assertOwned(entry);
4728
5601
  for (const [key, entry] of Object.entries(active)) {
4729
- const projectionAt = readers.get(entry) ?? memoizedProjectionOf(entry);
4730
- readers.set(entry, projectionAt);
4731
- const asked = (model) => askedWhile(projectionAt(model));
5602
+ const resolvedAt = readers.get(entry) ?? memoizedResolved(entry);
5603
+ readers.set(entry, resolvedAt);
5604
+ const asked = (model) => askedUnion(resolvedAt(model).map(({ projection }) => projection));
4732
5605
  entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
4733
5606
  entries[`${key}.live`] = liveEntry(bound, (model) => asked(model).requirements.filter((requirement) => requirement.live === true), identityMessage, options);
4734
5607
  }
4735
5608
  for (const identity of options.connections ?? []) keptConnections.add(connectionIdentity(identity));
4736
5609
  entries.retain = {
4737
5610
  dependenciesSchema: retentionRootsSchema,
4738
- modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((projectionAt) => Option.toArray(projectionAt(model))), { connections: [...keptConnections] }),
5611
+ modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((resolvedAt) => resolvedAt(model).map(({ projection }) => projection)), { connections: [...keptConnections] }),
4739
5612
  dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
4740
5613
  _tag: "RetentionChanged",
4741
5614
  roots: current
@@ -4776,22 +5649,19 @@ const bindDomain = (definition, store) => {
4776
5649
  const entries = Object.values(active);
4777
5650
  entries.forEach(assertOwned);
4778
5651
  const planOptions = RemotePolicy.toPlan(RemotePolicy.cacheFirst, now());
4779
- const plans = (current, entry) => {
4780
- const planned = planAsked(store.get(current), askedWhile(projectionOf(entry, current)), planOptions);
5652
+ const plans = (current, projections) => {
5653
+ const planned = planAsked(store.get(current), askedUnion(projections), planOptions);
4781
5654
  return planned.queries.length > 0 || planned.requirements.length > 0;
4782
5655
  };
4783
5656
  let current = model;
4784
5657
  for (let pass = 0; pass < passes; pass++) {
4785
- if (!entries.some((entry) => plans(current, entry))) return current;
4786
- for (const entry of entries) {
4787
- const projection = projectionOf(entry, current);
4788
- if (Option.isSome(projection)) current = yield* domain.prefetch(current, projection.value, { now });
4789
- }
5658
+ if (!entries.some((entry) => plans(current, resolvedOf(entry, current).map(({ projection }) => projection)))) return current;
5659
+ for (const entry of entries) for (const { projection } of resolvedOf(entry, current)) current = yield* domain.prefetch(current, projection, { now });
4790
5660
  }
4791
- const reading = entries.filter((entry) => plans(current, entry));
5661
+ const reading = entries.flatMap((entry) => resolvedOf(entry, current).filter(({ projection }) => plans(current, [projection])));
4792
5662
  if (reading.length === 0) return current;
4793
5663
  return yield* new RemoteUnsatisfied({
4794
- surfaces: reading.map((entry) => entry.name),
5664
+ surfaces: reading.map(({ name }) => name),
4795
5665
  passes
4796
5666
  });
4797
5667
  }),
@@ -4803,10 +5673,10 @@ const bindDomain = (definition, store) => {
4803
5673
  const listed = query.Result.entity;
4804
5674
  if (listed === void 0) throw new Error(`Remote: query "${query.name}" is not a connection over an entity, so it has no page to select`);
4805
5675
  if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
4806
- const { ref, relation, requirement } = readContract(query.ref(input), select, window);
5676
+ const { ref, relation, requirement } = placeableReadContract(readContract(query.ref(input), select, window), query.body);
4807
5677
  const relationKey = stableStringify(relation);
4808
5678
  const sizeOf = (remote) => windowSize(remote, ref);
4809
- const shown = (remote, connection) => cutToWindow(visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities), ref.window, sizeOf(remote));
5679
+ const shown = (remote, connection) => cutToWindow(shownEdges(remote, ref.identity, connection), ref.window, sizeOf(remote));
4810
5680
  const failedItem = (remote, connection) => {
4811
5681
  if (Object.keys(remote.failures.fields).length === 0) return void 0;
4812
5682
  for (const edge of shown(remote, connection).edges) {
@@ -4924,8 +5794,13 @@ const bindDomain = (definition, store) => {
4924
5794
  },
4925
5795
  explain: (model, projection, options) => {
4926
5796
  const { ref } = projection;
4927
- const reading = Object.values(options?.surfaces ?? {}).filter((active) => {
4928
- return Option.exists(active.projectionOf(model), (shown) => connectionsOf(shown).some((connection) => connection.identity === ref.identity));
5797
+ const reading = Object.values(options?.surfaces ?? {}).flatMap((source) => {
5798
+ const matched = resolvedOf(source, model).filter(({ projection: shown }) => connectionsOf(shown).some((connection) => connection.identity === ref.identity));
5799
+ if (matched.length === 0) return [];
5800
+ return [{
5801
+ source,
5802
+ names: matched.map(({ name }) => name)
5803
+ }];
4929
5804
  });
4930
5805
  const body = definition.registry.queries.get(ref.query)?.body;
4931
5806
  const [connection] = RemoteConnections.get(projection.metadata);
@@ -4942,20 +5817,21 @@ const bindDomain = (definition, store) => {
4942
5817
  },
4943
5818
  state: projection.read(model)._tag,
4944
5819
  ...options?.surfaces === void 0 ? {} : {
4945
- surfaces: reading.map((active) => active.name),
4946
- activation: reading.flatMap((active) => active.activation === void 0 ? [] : [{
4947
- surface: active.name,
4948
- ...active.activation
4949
- }])
5820
+ surfaces: reading.flatMap((read) => read.names),
5821
+ activation: reading.flatMap((read) => {
5822
+ const activation = "activation" in read.source ? read.source.activation : void 0;
5823
+ return activation === void 0 ? [] : [{
5824
+ surface: read.source.name,
5825
+ ...activation
5826
+ }];
5827
+ })
4950
5828
  }
4951
5829
  };
4952
5830
  },
4953
5831
  why: (model, projection, options) => {
4954
5832
  const state = projection.read(model);
4955
5833
  const asked = askedOf(projection);
4956
- const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((active) => {
4957
- return Option.exists(active.projectionOf(model), (shown) => covers(askedOf(shown), asked));
4958
- }).map((active) => active.name);
5834
+ const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).flatMap((source) => resolvedOf(source, model).filter(({ projection: shown }) => covers(askedOf(shown), asked)).map(({ name }) => name));
4959
5835
  const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
4960
5836
  switch (state._tag) {
4961
5837
  case "Ready": return {
@@ -5009,8 +5885,17 @@ const bindDomain = (definition, store) => {
5009
5885
  const remote = store.get(model);
5010
5886
  const connection = remote.connections[over.ref.identity];
5011
5887
  const visible = visibleStoreOf(remote.entities, remote.optimistic);
5012
- const among = (connection === void 0 ? [] : visibleItems(connection, over.ref.identity, remote.optimistic.overlays, remote.entities)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
5013
- const judged = matching(visible, by, input, { among });
5888
+ const among = (connection === void 0 ? [] : shownEdges(remote, over.ref.identity, connection)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
5889
+ let judged;
5890
+ try {
5891
+ judged = matching(visible, by, input, { among });
5892
+ } catch (error) {
5893
+ if (error instanceof QueryEvaluateError) return {
5894
+ items: [],
5895
+ complete: false
5896
+ };
5897
+ throw error;
5898
+ }
5014
5899
  const relation = relationOf(over.selection);
5015
5900
  const items = [];
5016
5901
  let assembledAll = true;
@@ -5043,6 +5928,11 @@ const bindDomain = (definition, store) => {
5043
5928
  _tag: "OverlayLifted",
5044
5929
  id
5045
5930
  }),
5931
+ overlays: (model) => overlaysOf(store.get(model)),
5932
+ visibleItems: (remote, connection) => {
5933
+ const identity = connectionIdentity(connection);
5934
+ return shownEdges(remote, identity, remote.connections[identity] ?? emptyConnection);
5935
+ },
5046
5936
  mutate: (model, mutation, input, options = {}) => {
5047
5937
  assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
5048
5938
  const remote = store.get(model);
@@ -5051,7 +5941,7 @@ const bindDomain = (definition, store) => {
5051
5941
  const optimistic = typeof options.optimistic === "function" ? options.optimistic({
5052
5942
  requestId,
5053
5943
  tempId
5054
- }) : options.optimistic;
5944
+ }) : options.optimistic ?? (mutation.write === void 0 ? void 0 : optimisticOfWrite(mutation.write, input, options.keys));
5055
5945
  const started = updateRemote(remote, {
5056
5946
  _tag: "MutationStarted",
5057
5947
  requestId,
@@ -5064,7 +5954,7 @@ const bindDomain = (definition, store) => {
5064
5954
  command: {
5065
5955
  name: `Remote.mutate(${mutation.name})`,
5066
5956
  args: { requestId },
5067
- effect: mutateRemote(mutation, input, requestId).pipe(Effect.match({
5957
+ effect: mutateRemote(mutation, input, requestId, options.keys).pipe(Effect.match({
5068
5958
  onFailure: (error) => ({
5069
5959
  _tag: "MutationFailed",
5070
5960
  requestId,
@@ -5083,8 +5973,108 @@ const bindDomain = (definition, store) => {
5083
5973
  };
5084
5974
  },
5085
5975
  mutation: (model, requestId) => mutationStatus(store.get(model).mutations, requestId),
5976
+ refusal: (model, requestId, mutation) => {
5977
+ const status = mutationStatus(store.get(model).mutations, requestId);
5978
+ if (status._tag !== "Failed") return Option.none();
5979
+ const { refusal } = status.error;
5980
+ return refusal === void 0 ? Option.none() : Schema.decodeUnknownOption(mutation.Refusal)(refusal);
5981
+ },
5982
+ liveStatus: (model, active) => {
5983
+ assertOwned(active);
5984
+ const resolvedAt = readers.get(active) ?? memoizedResolved(active);
5985
+ const requirements = askedUnion(resolvedAt(model).map(({ projection }) => projection)).requirements.filter((requirement) => requirement.live === true);
5986
+ if (requirements.length === 0) return { _tag: "Idle" };
5987
+ const remote = store.get(model);
5988
+ const stream = liveStreamKey(requirements);
5989
+ const health = remote.streams[stream];
5990
+ return health === void 0 || !remote.gaps.has(stream) ? { _tag: "Live" } : {
5991
+ _tag: "Reconnecting",
5992
+ attempt: health.failures,
5993
+ error: health.error
5994
+ };
5995
+ },
5086
5996
  reduce,
5087
5997
  inspect: (model) => inspectRemote(store.get(model)),
5998
+ persistence: (options) => {
5999
+ const connections = (options.connections ?? []).map(connectionIdentity);
6000
+ const snapshotOf = options.snapshot ?? ((remote) => RemotePersistence.snapshotOf(remote, { connections }));
6001
+ const debounce = options.debounce ?? "250 millis";
6002
+ let last = {
6003
+ entities: emptyStore,
6004
+ connections: {},
6005
+ scope: "",
6006
+ text: Option.none()
6007
+ };
6008
+ const textOf = (remote, scope) => {
6009
+ if (last.entities !== remote.entities || last.connections !== remote.connections || last.scope !== scope) last = {
6010
+ entities: remote.entities,
6011
+ connections: remote.connections,
6012
+ scope,
6013
+ text: Option.fromUndefinedOr(RemotePersistence.dehydrate(snapshotOf(remote), {
6014
+ scope,
6015
+ maxBytes: options.maxBytes
6016
+ }))
6017
+ };
6018
+ return last.text;
6019
+ };
6020
+ const restore = {
6021
+ dependenciesSchema: Schema.Struct({
6022
+ key: Schema.String,
6023
+ scope: Schema.String
6024
+ }),
6025
+ modelToDependencies: (model) => ({
6026
+ key: options.key(model),
6027
+ scope: options.scope(model)
6028
+ }),
6029
+ dependenciesToStream: ({ key, scope }) => Stream.fromEffect(RemotePersistence.restore({
6030
+ key,
6031
+ scope,
6032
+ maxBytes: options.maxBytes
6033
+ }).pipe(Effect.option)).pipe(Stream.flatMap((read) => Option.match(read, {
6034
+ onNone: () => Stream.empty,
6035
+ onSome: (snapshot) => Stream.make({
6036
+ _tag: "Hydrated",
6037
+ entities: snapshot.entities,
6038
+ connections: snapshot.connections,
6039
+ merge: "preserve-existing",
6040
+ from: key
6041
+ })
6042
+ })))
6043
+ };
6044
+ const save = {
6045
+ dependenciesSchema: Schema.Struct({
6046
+ key: Schema.String,
6047
+ restored: Schema.Boolean,
6048
+ text: Schema.OptionFromNullOr(Schema.String)
6049
+ }),
6050
+ modelToDependencies: (model) => {
6051
+ const remote = store.get(model);
6052
+ const key = options.key(model);
6053
+ const restored = Option.contains(remote.restoredFrom, key);
6054
+ return {
6055
+ key,
6056
+ restored,
6057
+ text: restored ? textOf(remote, options.scope(model)) : Option.none()
6058
+ };
6059
+ },
6060
+ dependenciesToStream: ({ key, restored, text }) => restored ? Stream.fromEffect(Effect.gen(function* () {
6061
+ yield* Effect.sleep(debounce);
6062
+ const kv = yield* KeyValueStore.KeyValueStore;
6063
+ yield* Option.match(text, {
6064
+ onNone: () => kv.remove(key),
6065
+ onSome: (written) => kv.set(key, written)
6066
+ });
6067
+ }).pipe(Effect.ignore)).pipe(Stream.drain) : Stream.empty
6068
+ };
6069
+ return {
6070
+ key: `persistence:${bound.contract.name}`,
6071
+ handles: [],
6072
+ subscriptions: Subscription.make()(() => ({
6073
+ "persistence.restore": restore,
6074
+ "persistence.save": save
6075
+ }))
6076
+ };
6077
+ },
5088
6078
  wiring: (active, options) => ({
5089
6079
  key: `remote:${bound.contract.name}`,
5090
6080
  handles: Object.keys(remoteMessageCases),
@@ -5131,6 +6121,6 @@ const pickWindow = (window) => ({
5131
6121
  ...window.before === void 0 ? {} : { before: window.before }
5132
6122
  });
5133
6123
  //#endregion
5134
- export { settleSuccess as $, RelationRequest as $t, REMOTE_CACHE_VERSION as A, remove as An, coalesceReads as At, emptyLiveState as B, isRefPage as Bn, MutationRequest as Bt, updateRemote as C, hasField as Cn, RemoteClient as Ct, RemoteData as D, markStale as Dn, mutateRemote as Dt, writeRead as E, isTombstone as En, liveEventOf as Et, plan as F, writeEntity as Fn, LiveEdge as Ft, addOverlay as G, targetsOf as Gn, QueryRequest as Gt, liveHasPrevious as H, refsIn as Hn, NormalizedEntity as Ht, windowKey as I, RELATION_ALIAS as In, LiveRequirement as It, emptyOptimistic as J, Requirement as Jn, REMOTE_PROTOCOL_VERSION as Jt, beginOptimistic as K, RemoteConnections as Kn, QueryResult as Kt, applyConnectionEvent as L, RelationAnnotation as Ln, MAX_FIELDS_PER_REQUEST as Lt, gc as M, setUnavailable as Mn, ConnectionChangeSchema as Mt, reachable as N, tombstone as Nn, Live as Nt, remoteDataSchema as O, missingFields as On, remoteError as Ot, deadlineOf as P, writeEntities as Pn, LiveChange as Pt, settleFailure as Q, ReadRequest as Qt, applyEntityEvent as R, RelationEntityAnnotation as Rn, MAX_RELATION_DEPTH as Rt, retentionRootsSchema as S, entry as Sn, unknown as St, withRefreshRequested as T, isFieldUnavailable as Tn, connectionChangeOf as Tt, ConnectionChange as U, relationAlias as Un, PageSize as Ut, liveHasNext as V, refParts as Vn, MutationResult as Vt, addLayer as W, relationShape as Wn, QueryEdge as Wt, removeLayer as X, requirementsOf as Xn, ReadBatch as Xt, pruneOverlays as Y, connectionsOf as Yn, Read as Yt, removeOverlay as Z, ReadBatchResult as Zt, refreshIsInFlight as _, reconcileMutation as _n, isGapped as _t, RemotePolicy as a, RemoteRpc as an, matching as at, remoteMessageSchema as b, emptyStore as bn, segment as bt, forgetRemote as c, WireBoundary as cn, assemble as ct, isLoading as d, stableStringify as dn, unavailableOf as dt, RemoteLiveError as en, visibleItems as et, isLoadingThrough as f, Mutation as fn, cursor as ft, onlyOverlaid as g, mutationStatus as gn, hasPrevious as gt, noFailures as h, failMutation as hn, hasNext as ht, RemoteJsonRequest as i, RemoteReadError as in, belongsEncoded as it, RemotePersistence as j, setStale as jn, requirementKey as jt, remoteErrorSchema as k, readField as kn, coalesceQueries as kt, initialRemoteModel as l, IDENTITY_SEPARATOR as ln, pageSchema as lt, isRemoteMessage as m, emptyMutationState as mn, emptyConnection as mt, RemoteUnsatisfied as n, RemoteProtocolError as nn, inspectEntity as nt, emptyRefresh as o, SettledFields as on, Entity$1 as ot, isQueryLoading as p, beginMutation as pn, edge as pt, connectionIdentity as q, RemoteRequirements as qn, QueryRpc as qt, RemoteJsonAnswer as r, RemoteQueryError as rn, inspectRemote as rt, failureOf as s, WindowSchema as sn, Selection as st, Remote as t, RemoteMutationError as tn, visibleStore as tt, isFieldFailed as u, Query$1 as un, relationOf as ut, refreshedAt as v, schemaOf as vn, items as vt, windowGrowthKey as w, isFieldStale as wn, coalescedLayer as wt, remoteModelSchema as x, entityKey as xn, terminal as xt, remoteMessageCases as y, clearStale as yn, merge as yt, classifyLive as z, aliasedField as zn, Mutate as zt };
6124
+ export { pruneOverlays as $, relationAlias as $n, QueryRpc as $t, writeRead as A, emptyStore as An, coalescedLayer as At, windowKey as B, remove as Bn, LiveChange as Bt, remoteMessageCases as C, failMutation as Cn, isGapped as Ct, updateRemote as D, reconcileMutation as Dn, terminal as Dt, retentionRootsSchema as E, patchOfWrite as En, segment as Et, RemotePersistence as F, isFieldUnavailable as Fn, coalesceQueries as Ft, liveHasNext as G, writeEntity as Gn, Mutate as Gt, applyEntityEvent as H, setUnavailable as Hn, LiveRequirement as Ht, gc as I, isTombstone as In, coalesceReads as It, addLayer as J, RelationEntityAnnotation as Jn, NormalizedEntity as Jt, liveHasPrevious as K, RELATION_ALIAS as Kn, MutationRequest as Kt, reachable as L, markStale as Ln, requirementKey as Lt, remoteDataSchema as M, entry as Mn, liveEventOf as Mt, remoteErrorSchema as N, hasField as Nn, mutateRemote as Nt, windowGrowthKey as O, schemaOf as On, unknown as Ot, REMOTE_CACHE_VERSION as P, isFieldStale as Pn, remoteError as Pt, emptyOptimistic as Q, refsIn as Qn, QueryResult as Qt, deadlineOf as R, missingFields as Rn, ConnectionChangeSchema as Rt, refreshedAt as S, emptyMutationState as Sn, hasPrevious as St, remoteModelSchema as T, optimisticOfWrite as Tn, merge as Tt, classifyLive as U, tombstone as Un, MAX_FIELDS_PER_REQUEST as Ut, applyConnectionEvent as V, setStale as Vn, LiveEdge as Vt, emptyLiveState as W, writeEntities as Wn, MAX_RELATION_DEPTH as Wt, beginOptimistic as X, isRefPage as Xn, QueryEdge as Xt, addOverlay as Y, aliasedField as Yn, PageSize as Yt, connectionIdentity as Z, refParts as Zn, QueryRequest as Zt, isRemoteMessage as _, Query$1 as _n, unavailableOf as _t, RemoteJsonAnswer as a, RelationRequest as an, connectionsOf as ar, visibleStore as at, overlaysOf as b, Refusal as bn, emptyConnection as bt, emptyRefresh as c, RemoteMutationError as cn, belongsEncoded as ct, initialRemoteModel as d, RemoteReadError as dn, placeIn as dt, REMOTE_PROTOCOL_VERSION as en, relationShape as er, removeLayer as et, invalidateConnections as f, RemoteRpc as fn, Entity$1 as ft, isQueryLoading as g, IDENTITY_SEPARATOR as gn, relationOf as gt, isLoadingThrough as h, WireBoundary as hn, pageSchema as ht, liveFetch as i, ReadRequest as in, Requirement as ir, visibleItems as it, RemoteData as j, entityKey as jn, connectionChangeOf as jt, withRefreshRequested as k, clearStale as kn, RemoteClient as kt, failureOf as l, RemoteProtocolError as ln, matching as lt, isLoading as m, WindowSchema as mn, assemble as mt, RemoteUnsatisfied as n, ReadBatch as nn, RemoteConnections as nr, settleFailure as nt, RemoteJsonRequest as o, RemoteForbidden as on, requirementsOf as or, inspectEntity as ot, isFieldFailed as p, SettledFields as pn, Selection as pt, ConnectionChange as q, RelationAnnotation as qn, MutationResult as qt, httpWithLive as r, ReadBatchResult as rn, RemoteRequirements as rr, settleSuccess as rt, RemotePolicy as s, RemoteLiveError as sn, inspectRemote as st, Remote as t, Read as tn, targetsOf as tr, removeOverlay as tt, forgetRemote as u, RemoteQueryError as un, RowPlace as ut, noFailures as v, stableStringify as vn, cursor as vt, remoteMessageSchema as w, mutationStatus as wn, items as wt, refreshIsInFlight as x, beginMutation as xn, hasNext as xt, onlyOverlaid as y, Mutation as yn, edge as yt, plan as z, readField as zn, Live as zt };
5135
6125
 
5136
- //# sourceMappingURL=src-9NIJGDTJ.mjs.map
6126
+ //# sourceMappingURL=src-CcPPtNuH.mjs.map