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.
@@ -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();
@@ -2536,9 +2859,13 @@ const initialRemoteModel = {
2536
2859
  live: {},
2537
2860
  mutations: emptyMutationState,
2538
2861
  gaps: /* @__PURE__ */ new Set(),
2862
+ streams: {},
2539
2863
  loading: /* @__PURE__ */ new Set(),
2540
2864
  refresh: emptyRefresh,
2541
- failures: noFailures
2865
+ failures: noFailures,
2866
+ restoredFrom: Option.none(),
2867
+ placed: {},
2868
+ placementMismatches: []
2542
2869
  };
2543
2870
  /** The entity store and the mutation ledger are runtime values, not wire shapes. */
2544
2871
  const runtimeSchema = Schema.Unknown;
@@ -2550,12 +2877,16 @@ const remoteModelSchema = () => Schema.Struct({
2550
2877
  live: Schema.Record(Schema.String, runtimeSchema),
2551
2878
  mutations: runtimeSchema,
2552
2879
  gaps: runtimeSchema,
2880
+ streams: Schema.Record(Schema.String, runtimeSchema),
2553
2881
  loading: runtimeSchema,
2554
2882
  refresh: runtimeSchema,
2555
2883
  failures: Schema.Struct({
2556
2884
  connections: Schema.Record(Schema.String, remoteErrorSchema),
2557
2885
  fields: Schema.Record(Schema.String, remoteErrorSchema)
2558
- })
2886
+ }),
2887
+ restoredFrom: runtimeSchema,
2888
+ placed: runtimeSchema,
2889
+ placementMismatches: runtimeSchema
2559
2890
  });
2560
2891
  const retentionRootsSchema = Schema.Struct({
2561
2892
  requirements: Schema.Array(ReadRequest),
@@ -2588,6 +2919,7 @@ const remoteMessageCases = {
2588
2919
  RetentionChanged: { roots: retentionRootsSchema },
2589
2920
  Hydrated: {
2590
2921
  connections: Schema.optional(Schema.Unknown),
2922
+ from: Schema.optional(Schema.String),
2591
2923
  entities: runtimeSchema,
2592
2924
  merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
2593
2925
  },
@@ -2651,7 +2983,10 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
2651
2983
  ...fields
2652
2984
  })));
2653
2985
  /** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
2654
- 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)] : []))];
2655
2990
  /** The key a grown window is kept under: its connection and the window first asked for. */
2656
2991
  const windowGrowthKey = (identity, window) => `${identity}\u0000${window}`;
2657
2992
  /**
@@ -2772,6 +3107,22 @@ const withRefreshRequested = (refresh, requests, connections = []) => {
2772
3107
  };
2773
3108
  };
2774
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
+ /**
2775
3126
  * Records that a read of `requests` began under `generation`. A read that
2776
3127
  * carries no generation began under none, and leaves the marks as they are.
2777
3128
  */
@@ -2822,9 +3173,49 @@ const withLiveState = (live, stream, state) => live[stream] === state ? live : {
2822
3173
  ...live,
2823
3174
  [stream]: state
2824
3175
  };
2825
- const markGap = (model, stream) => model.gaps.has(stream) ? model : {
2826
- ...model,
2827
- 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
+ };
2828
3219
  };
2829
3220
  const clearGap = (model, stream) => model.gaps.has(stream) ? {
2830
3221
  ...model,
@@ -2832,8 +3223,8 @@ const clearGap = (model, stream) => model.gaps.has(stream) ? {
2832
3223
  } : model;
2833
3224
  /**
2834
3225
  * The pure reducer all four producers share. A live event that arrives ahead of
2835
- * its cursor is a gap: it is not applied, and the stream is recorded so the host
2836
- * 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
2837
3228
  * touches keeps its identity, `model` itself is returned, so the application's
2838
3229
  * root keeps its identity and Foldkit does not render. The common no-ops do (a
2839
3230
  * duplicate live event, a repeated `ReadStarted`); a repeated failure, or a
@@ -2849,6 +3240,32 @@ const sameFields = (model, next) => {
2849
3240
  const keys = Object.keys(next);
2850
3241
  return keys.length === Object.keys(model).length && keys.every((key) => Object.is(model[key], next[key]));
2851
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
+ };
2852
3269
  const reduceRemote = (model, message) => {
2853
3270
  switch (message._tag) {
2854
3271
  case "ReadReceived": return {
@@ -2858,7 +3275,7 @@ const reduceRemote = (model, message) => {
2858
3275
  failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
2859
3276
  };
2860
3277
  case "ReadFailed":
2861
- 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));
2862
3279
  return {
2863
3280
  ...model,
2864
3281
  entities: setStale(model.entities, marksOf(message.requests), false),
@@ -2898,7 +3315,8 @@ const reduceRemote = (model, message) => {
2898
3315
  } : {
2899
3316
  ...Object.fromEntries(restored),
2900
3317
  ...model.connections
2901
- }
3318
+ },
3319
+ restoredFrom: message.from === void 0 ? model.restoredFrom : Option.some(message.from)
2902
3320
  };
2903
3321
  }
2904
3322
  case "RefreshStarted": {
@@ -2968,29 +3386,28 @@ const reduceRemote = (model, message) => {
2968
3386
  if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
2969
3387
  const event = message.event;
2970
3388
  const applied = applyEntityEvent(state, model.entities, event, message.now);
2971
- if (applied.outcome === "gap") return markGap(model, message.stream);
3389
+ if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
2972
3390
  const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
2973
3391
  ...event.ref,
2974
3392
  values: event.values
2975
3393
  }])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
2976
- return clearGap({
3394
+ const next = {
2977
3395
  ...model,
2978
3396
  entities: applied.store,
2979
3397
  live: withLiveState(model.live, message.stream, applied.state),
2980
3398
  failures: withoutFieldFailures(model.failures, settled)
2981
- }, message.stream);
3399
+ };
3400
+ return applied.outcome === "applied" ? healthy(next, message.stream) : next;
2982
3401
  }
2983
3402
  const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
2984
- if (applied.outcome === "gap") return markGap(model, message.stream);
2985
- const next = clearGap({
3403
+ if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
3404
+ const written = {
2986
3405
  ...model,
2987
3406
  optimistic: applied.optimistic,
2988
3407
  live: withLiveState(model.live, message.stream, applied.state)
2989
- }, message.stream);
2990
- return applied.invalidated === void 0 ? next : updateRemote(next, {
2991
- _tag: "ConnectionInvalidated",
2992
- connection: applied.invalidated
2993
- });
3408
+ };
3409
+ const next = applied.outcome === "applied" ? healthy(written, message.stream) : written;
3410
+ return applied.invalidated === void 0 ? next : invalidateConnections(next, [applied.invalidated]);
2994
3411
  }
2995
3412
  case "GapCleared": return clearGap(model, message.stream);
2996
3413
  case "ConnectionMerged": {
@@ -3011,6 +3428,7 @@ const reduceRemote = (model, message) => {
3011
3428
  const failures = withoutFieldFailures(withoutConnectionFailure(model.failures, message.connection), [...fieldMarks(requests), ...patchedMarks(payload)]);
3012
3429
  return {
3013
3430
  ...model,
3431
+ ...checkedPlacement(model, message.connection, message.page, message.refreshes === true),
3014
3432
  entities,
3015
3433
  connections: {
3016
3434
  ...model.connections,
@@ -3541,29 +3959,46 @@ const RemoteJsonRequest = Schema.Struct({
3541
3959
  payload: Schema.Unknown
3542
3960
  });
3543
3961
  /** An answer: the call's result, or why there is none. */
3544
- 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
+ })]);
3545
3968
  const decodeAnswer = Schema.decodeUnknownEffect(RemoteJsonAnswer);
3546
3969
  const call = (send, operation, payload, fail) => Effect.tryPromise({
3547
3970
  try: () => send(JSON.stringify({
3548
3971
  operation,
3549
3972
  payload
3550
3973
  })),
3551
- catch: (error) => fail(error instanceof Error ? error.message : String(error))
3552
- }).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)));
3553
3976
  /** Remote's client over `send`, for `Remote.clientLayer`. */
3554
3977
  const json = (send) => ({
3555
- FoldkitRemoteRead: (payload) => call(send, "read", payload, (message) => new RemoteReadError({ message })),
3556
- FoldkitRemoteQuery: (payload) => call(send, "query", payload, (message) => new RemoteQueryError({ message })),
3557
- 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
+ })),
3558
3987
  FoldkitRemoteLive: () => Stream.empty
3559
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");
3560
3991
  /**
3561
3992
  * Remote's client over HTTP: each call a `POST` of the request to `url`, the
3562
- * answer its JSON body whatever its status. `headers` is read per request, so
3563
- * 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.
3564
3999
  */
3565
4000
  const http = (url, options = {}) => json(async (request) => {
3566
- const response = await fetch(url, {
4001
+ const response = await (options.fetch ?? fetch)(url, {
3567
4002
  method: "POST",
3568
4003
  headers: {
3569
4004
  "content-type": "application/json",
@@ -3571,9 +4006,120 @@ const http = (url, options = {}) => json(async (request) => {
3571
4006
  },
3572
4007
  body: request
3573
4008
  });
3574
- return response.json().catch(() => {
3575
- 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
3576
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)
3577
4123
  });
3578
4124
  //#endregion
3579
4125
  //#region src/index.ts
@@ -3612,6 +4158,30 @@ const readContract = (source, shape, window) => {
3612
4158
  }
3613
4159
  };
3614
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
+ };
3615
4185
  const metaOf = (remote, asked) => {
3616
4186
  const visible = visibleStoreOf(remote.entities, remote.optimistic);
3617
4187
  let updatedAt;
@@ -3793,7 +4363,8 @@ const defineRemote = (config) => {
3793
4363
  registry: {
3794
4364
  entities: new Map(entities.map((entity) => [entity.name, entity])),
3795
4365
  queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
3796
- 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)]] : []))
3797
4368
  }
3798
4369
  };
3799
4370
  };
@@ -3815,19 +4386,10 @@ const bindRemote = (definition, store) => ({
3815
4386
  const assertRegistered = (bound, kind, registered, name) => {
3816
4387
  if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
3817
4388
  };
3818
- const nothingAsked = {
3819
- requirements: [],
3820
- connections: []
3821
- };
3822
4389
  const askedOf = (projection) => ({
3823
4390
  requirements: requirementsOf(projection),
3824
4391
  connections: connectionsOf(projection)
3825
4392
  });
3826
- /** What an active Surface asks for: nothing while it is inactive. */
3827
- const askedWhile = (projection) => Option.match(projection, {
3828
- onNone: () => nothingAsked,
3829
- onSome: askedOf
3830
- });
3831
4393
  /**
3832
4394
  * Whether `outer` asks for everything `inner` does of one entity: each field,
3833
4395
  * each relation's own slice of its target, and each page window alike.
@@ -4174,58 +4736,121 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
4174
4736
  };
4175
4737
  };
4176
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
+ };
4177
4746
  /** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
4178
4747
  const liveEntry = (bound, requirementsOf, toMessage, options) => ({
4179
4748
  dependenciesSchema: Schema.Struct({
4180
4749
  requirements: Schema.Array(ReadRequest),
4181
4750
  cursor: resumeCursor,
4182
- floor: Schema.Number
4751
+ floor: Schema.Number,
4752
+ restarts: Schema.Number,
4753
+ failures: resumeCursor
4183
4754
  }),
4184
4755
  modelToDependencies: (model) => {
4185
4756
  const requirements = requirementsOf(model);
4186
4757
  const stream = liveStreamKey(requirements);
4187
4758
  const remote = bound.store.get(model);
4759
+ const health = remote.streams[stream];
4188
4760
  return {
4189
4761
  requirements,
4190
4762
  cursor: remote.live[stream]?.cursor ?? 0,
4191
- floor: remote.refresh.floor
4763
+ floor: remote.refresh.floor,
4764
+ restarts: health?.restarts ?? 0,
4765
+ failures: health?.failures ?? 0
4192
4766
  };
4193
4767
  },
4194
- dependenciesToStream: ({ requirements, cursor }) => requirements.length === 0 ? Stream.empty : Stream.unwrap(Effect.gen(function* () {
4195
- return (yield* RemoteClient).live({
4196
- requirements,
4197
- after: cursor
4198
- });
4199
- })).pipe(Stream.map((event) => toMessage({
4200
- _tag: "LiveReceived",
4201
- stream: liveStreamKey(requirements),
4202
- event,
4203
- now: (options.now ?? wallClock)()
4204
- })), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
4205
- _tag: "ReadFailed",
4206
- requests: requirements,
4207
- error: remoteError(error),
4208
- stream: liveStreamKey(requirements)
4209
- }))))
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)
4210
4837
  });
4211
- /** The projection an active Surface has for this Model; none while it is inactive. */
4212
- const projectionOf = (entry, model) => "projectionOf" in entry ? entry.projectionOf(model) : Option.some(entry.projection());
4213
4838
  /**
4214
- * `projectionOf` computed once per Model: the read, live, and retain entries
4215
- * 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
4216
4841
  * change, and a Surface's `model` callback (which lifts and builds schemas)
4217
4842
  * need not run three times for it. Models are objects, so the memo is weak.
4218
4843
  */
4219
- const memoizedProjectionOf = (entry) => {
4844
+ const memoizedResolved = (entry) => {
4220
4845
  const cache = /* @__PURE__ */ new WeakMap();
4221
4846
  return (model) => {
4222
4847
  const key = model;
4223
- if (typeof key !== "object" || key === null) return projectionOf(entry, model);
4848
+ if (typeof key !== "object" || key === null) return resolvedOf(entry, model);
4224
4849
  const known = cache.get(key);
4225
4850
  if (known !== void 0) return known;
4226
- const projection = projectionOf(entry, model);
4227
- cache.set(key, projection);
4228
- return projection;
4851
+ const resolved = resolvedOf(entry, model);
4852
+ cache.set(key, resolved);
4853
+ return resolved;
4229
4854
  };
4230
4855
  };
4231
4856
  const Remote = {
@@ -4306,7 +4931,7 @@ const Remote = {
4306
4931
  * in one step; the result carries the application-facing operations. The
4307
4932
  * descriptor alone is `Remote.define`, the binding alone `Remote.at`.
4308
4933
  */
4309
- make: (config) => bindDomain(defineRemote(config), config.model),
4934
+ make: (config) => bindDomain(defineRemote(config), config.model, { verifyPlacement: config.verifyPlacement }),
4310
4935
  /**
4311
4936
  * Declares a Remote domain without binding it: its entities, queries, and
4312
4937
  * mutations, plus the submodel. For a domain reused across applications or
@@ -4528,6 +5153,13 @@ const Remote = {
4528
5153
  * request to `url`. `headers` is read per request (a session token, say).
4529
5154
  */
4530
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,
4531
5163
  clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
4532
5164
  const context = yield* Effect.context();
4533
5165
  return {
@@ -4583,15 +5215,6 @@ const Remote = {
4583
5215
  }),
4584
5216
  /** The `RemoteMessage` that merges a query page into its connection. */
4585
5217
  queryMessage: (ref, result, select, now) => pageMessage(ref.identity, result, false, ref.window, select, now),
4586
- /**
4587
- * The edges a connection shows: its server-known region with pending and
4588
- * confirmed overlays placed around it, minus removed edges and edges whose
4589
- * target is a tombstone.
4590
- */
4591
- visibleItems: (model, connection) => {
4592
- const identity = connectionIdentity(connection);
4593
- return visibleItems(model.connections[identity] ?? emptyConnection, identity, model.optimistic.overlays, model.entities);
4594
- },
4595
5218
  /** A pure, serializable view of the whole cache. */
4596
5219
  inspect: inspectRemote,
4597
5220
  /** A pure, serializable view of one entity, or `undefined` if unknown. */
@@ -4643,7 +5266,8 @@ const Remote = {
4643
5266
  * requirements, emitting a `LiveReceived` per event and a `ReadFailed`
4644
5267
  * carrying its `stream` when the stream breaks (including
4645
5268
  * `ResumeUnavailable`). That records a gap on the stream rather than a failed
4646
- * 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
4647
5271
  * from `RemoteModel.live`, so the application tracks no cursor of its own.
4648
5272
  */
4649
5273
  live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
@@ -4659,7 +5283,7 @@ const failedReadWords = (error) => {
4659
5283
  }
4660
5284
  };
4661
5285
  /** The bound domain: the descriptor, the binding, and the operations over them. */
4662
- const bindDomain = (definition, store) => {
5286
+ const bindDomain = (definition, store, options = {}) => {
4663
5287
  const bound = bindRemote(definition, store);
4664
5288
  const readers = /* @__PURE__ */ new Map();
4665
5289
  const keptConnections = /* @__PURE__ */ new Set();
@@ -4704,11 +5328,248 @@ const bindDomain = (definition, store) => {
4704
5328
  policy: livePolicyFor(model, live.event)
4705
5329
  };
4706
5330
  const remote = store.get(model);
4707
- 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);
4708
5334
  return next === remote ? model : store.set(model, next);
4709
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
+ };
4710
5569
  const assertOwned = (entry) => {
4711
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}"`);
4712
5573
  };
4713
5574
  const domain = {
4714
5575
  ...definition,
@@ -4738,16 +5599,16 @@ const bindDomain = (definition, store) => {
4738
5599
  const entries = {};
4739
5600
  for (const entry of Object.values(active)) assertOwned(entry);
4740
5601
  for (const [key, entry] of Object.entries(active)) {
4741
- const projectionAt = readers.get(entry) ?? memoizedProjectionOf(entry);
4742
- readers.set(entry, projectionAt);
4743
- 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));
4744
5605
  entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
4745
5606
  entries[`${key}.live`] = liveEntry(bound, (model) => asked(model).requirements.filter((requirement) => requirement.live === true), identityMessage, options);
4746
5607
  }
4747
5608
  for (const identity of options.connections ?? []) keptConnections.add(connectionIdentity(identity));
4748
5609
  entries.retain = {
4749
5610
  dependenciesSchema: retentionRootsSchema,
4750
- 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] }),
4751
5612
  dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
4752
5613
  _tag: "RetentionChanged",
4753
5614
  roots: current
@@ -4788,22 +5649,19 @@ const bindDomain = (definition, store) => {
4788
5649
  const entries = Object.values(active);
4789
5650
  entries.forEach(assertOwned);
4790
5651
  const planOptions = RemotePolicy.toPlan(RemotePolicy.cacheFirst, now());
4791
- const plans = (current, entry) => {
4792
- 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);
4793
5654
  return planned.queries.length > 0 || planned.requirements.length > 0;
4794
5655
  };
4795
5656
  let current = model;
4796
5657
  for (let pass = 0; pass < passes; pass++) {
4797
- if (!entries.some((entry) => plans(current, entry))) return current;
4798
- for (const entry of entries) {
4799
- const projection = projectionOf(entry, current);
4800
- if (Option.isSome(projection)) current = yield* domain.prefetch(current, projection.value, { now });
4801
- }
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 });
4802
5660
  }
4803
- const reading = entries.filter((entry) => plans(current, entry));
5661
+ const reading = entries.flatMap((entry) => resolvedOf(entry, current).filter(({ projection }) => plans(current, [projection])));
4804
5662
  if (reading.length === 0) return current;
4805
5663
  return yield* new RemoteUnsatisfied({
4806
- surfaces: reading.map((entry) => entry.name),
5664
+ surfaces: reading.map(({ name }) => name),
4807
5665
  passes
4808
5666
  });
4809
5667
  }),
@@ -4815,10 +5673,10 @@ const bindDomain = (definition, store) => {
4815
5673
  const listed = query.Result.entity;
4816
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`);
4817
5675
  if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
4818
- const { ref, relation, requirement } = readContract(query.ref(input), select, window);
5676
+ const { ref, relation, requirement } = placeableReadContract(readContract(query.ref(input), select, window), query.body);
4819
5677
  const relationKey = stableStringify(relation);
4820
5678
  const sizeOf = (remote) => windowSize(remote, ref);
4821
- 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));
4822
5680
  const failedItem = (remote, connection) => {
4823
5681
  if (Object.keys(remote.failures.fields).length === 0) return void 0;
4824
5682
  for (const edge of shown(remote, connection).edges) {
@@ -4936,8 +5794,13 @@ const bindDomain = (definition, store) => {
4936
5794
  },
4937
5795
  explain: (model, projection, options) => {
4938
5796
  const { ref } = projection;
4939
- const reading = Object.values(options?.surfaces ?? {}).filter((active) => {
4940
- 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
+ }];
4941
5804
  });
4942
5805
  const body = definition.registry.queries.get(ref.query)?.body;
4943
5806
  const [connection] = RemoteConnections.get(projection.metadata);
@@ -4954,20 +5817,21 @@ const bindDomain = (definition, store) => {
4954
5817
  },
4955
5818
  state: projection.read(model)._tag,
4956
5819
  ...options?.surfaces === void 0 ? {} : {
4957
- surfaces: reading.map((active) => active.name),
4958
- activation: reading.flatMap((active) => active.activation === void 0 ? [] : [{
4959
- surface: active.name,
4960
- ...active.activation
4961
- }])
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
+ })
4962
5828
  }
4963
5829
  };
4964
5830
  },
4965
5831
  why: (model, projection, options) => {
4966
5832
  const state = projection.read(model);
4967
5833
  const asked = askedOf(projection);
4968
- const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((active) => {
4969
- return Option.exists(active.projectionOf(model), (shown) => covers(askedOf(shown), asked));
4970
- }).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));
4971
5835
  const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
4972
5836
  switch (state._tag) {
4973
5837
  case "Ready": return {
@@ -5021,8 +5885,17 @@ const bindDomain = (definition, store) => {
5021
5885
  const remote = store.get(model);
5022
5886
  const connection = remote.connections[over.ref.identity];
5023
5887
  const visible = visibleStoreOf(remote.entities, remote.optimistic);
5024
- const among = (connection === void 0 ? [] : visibleItems(connection, over.ref.identity, remote.optimistic.overlays, remote.entities)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
5025
- 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
+ }
5026
5899
  const relation = relationOf(over.selection);
5027
5900
  const items = [];
5028
5901
  let assembledAll = true;
@@ -5055,6 +5928,11 @@ const bindDomain = (definition, store) => {
5055
5928
  _tag: "OverlayLifted",
5056
5929
  id
5057
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
+ },
5058
5936
  mutate: (model, mutation, input, options = {}) => {
5059
5937
  assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
5060
5938
  const remote = store.get(model);
@@ -5063,7 +5941,7 @@ const bindDomain = (definition, store) => {
5063
5941
  const optimistic = typeof options.optimistic === "function" ? options.optimistic({
5064
5942
  requestId,
5065
5943
  tempId
5066
- }) : options.optimistic;
5944
+ }) : options.optimistic ?? (mutation.write === void 0 ? void 0 : optimisticOfWrite(mutation.write, input, options.keys));
5067
5945
  const started = updateRemote(remote, {
5068
5946
  _tag: "MutationStarted",
5069
5947
  requestId,
@@ -5076,7 +5954,7 @@ const bindDomain = (definition, store) => {
5076
5954
  command: {
5077
5955
  name: `Remote.mutate(${mutation.name})`,
5078
5956
  args: { requestId },
5079
- effect: mutateRemote(mutation, input, requestId).pipe(Effect.match({
5957
+ effect: mutateRemote(mutation, input, requestId, options.keys).pipe(Effect.match({
5080
5958
  onFailure: (error) => ({
5081
5959
  _tag: "MutationFailed",
5082
5960
  requestId,
@@ -5095,8 +5973,108 @@ const bindDomain = (definition, store) => {
5095
5973
  };
5096
5974
  },
5097
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
+ },
5098
5996
  reduce,
5099
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
+ },
5100
6078
  wiring: (active, options) => ({
5101
6079
  key: `remote:${bound.contract.name}`,
5102
6080
  handles: Object.keys(remoteMessageCases),
@@ -5143,6 +6121,6 @@ const pickWindow = (window) => ({
5143
6121
  ...window.before === void 0 ? {} : { before: window.before }
5144
6122
  });
5145
6123
  //#endregion
5146
- 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 };
5147
6125
 
5148
- //# sourceMappingURL=src-C53_fLA2.mjs.map
6126
+ //# sourceMappingURL=src-CcPPtNuH.mjs.map