foldkit-remote 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,113 @@
1
1
  import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
2
- import { Requirement } from "foldkit-surface";
2
+ import * as Domain from "foldkit-entity";
3
+ import { Entity as Entity$1, SelectionTypeId } from "foldkit-entity";
4
+ import * as Subscription from "foldkit/subscription";
5
+ import { Metadata } from "foldkit-surface";
3
6
  import { Rpc, RpcGroup } from "effect/unstable/rpc";
4
7
  import { KeyValueStore } from "effect/unstable/persistence";
8
+ //#region src/requirement.ts
9
+ /**
10
+ * What a Remote Projection needs from the server: entity slices and query
11
+ * connections. They ride on `Projection.metadata` under Remote's own keys, so
12
+ * Surface composes them without knowing their shape.
13
+ */
14
+ const windowKey$1 = (window) => JSON.stringify([
15
+ window.first ?? null,
16
+ window.last ?? null,
17
+ window.after ?? null,
18
+ window.before ?? null
19
+ ]);
20
+ /**
21
+ * Merges connection requirements for the same connection and window into one,
22
+ * unioning what they select of each item; the first keeps its other properties.
23
+ */
24
+ function mergeConnections(connections) {
25
+ const merged = /* @__PURE__ */ new Map();
26
+ for (const connection of connections) {
27
+ const key = `${connection.identity}\u0000${windowKey$1(connection.window)}`;
28
+ const current = merged.get(key);
29
+ merged.set(key, current === void 0 ? connection : {
30
+ ...current,
31
+ select: mergeRelation(current.select, connection.select)
32
+ });
33
+ }
34
+ return [...merged.values()];
35
+ }
36
+ /** Unions two relation slices for the same target: fields, windows, and nested relations. */
37
+ function mergeRelation(current, next) {
38
+ const fields = [...current.fields];
39
+ const seen = new Set(fields);
40
+ for (const field of next.fields) {
41
+ if (seen.has(field)) continue;
42
+ seen.add(field);
43
+ fields.push(field);
44
+ }
45
+ const windows = {
46
+ ...current.windows,
47
+ ...next.windows
48
+ };
49
+ const relations = mergeRelations(current.relations, next.relations);
50
+ return {
51
+ entity: current.entity,
52
+ fields,
53
+ ...Object.keys(windows).length === 0 ? {} : { windows },
54
+ ...relations === void 0 ? {} : { relations }
55
+ };
56
+ }
57
+ /** Unions two relation maps field by field; a field in both merges recursively. */
58
+ function mergeRelations(current, next) {
59
+ if (current === void 0) return next;
60
+ if (next === void 0) return current;
61
+ const merged = { ...current };
62
+ for (const [field, relation] of Object.entries(next)) {
63
+ const existing = merged[field];
64
+ merged[field] = existing === void 0 ? relation : mergeRelation(existing, relation);
65
+ }
66
+ return merged;
67
+ }
68
+ /** Unions requirements for the same entity + id, dropping duplicate fields. */
69
+ function mergeRequirements(requirements) {
70
+ const grouped = /* @__PURE__ */ new Map();
71
+ for (const requirement of requirements) {
72
+ const key = `${requirement.entity}\u0000${requirement.id}`;
73
+ const group = grouped.get(key);
74
+ const live = group?.live === true || requirement.live === true;
75
+ grouped.set(key, {
76
+ ...mergeRelation(group ?? {
77
+ entity: requirement.entity,
78
+ fields: []
79
+ }, requirement),
80
+ id: requirement.id,
81
+ ...live ? { live } : {}
82
+ });
83
+ }
84
+ return [...grouped.values()];
85
+ }
86
+ /**
87
+ * Requirement helpers, for the packages that plan and serve requirements.
88
+ * `merge` unions same-entity+id requirements; `mergeRelation` unions two
89
+ * slices of one target (fields, windows, nested relations).
90
+ */
91
+ const Requirement = {
92
+ merge: mergeRequirements,
93
+ mergeRelation,
94
+ mergeConnections
95
+ };
96
+ /** Remote's entity requirements on a Projection, unioned per entity and id as nodes compose. */
97
+ const RemoteRequirements = Metadata.key("remote", {
98
+ merge: mergeRequirements,
99
+ summarize: (requirement) => `${requirement.entity}:${requirement.id}`
100
+ });
101
+ /** Remote's query connections on a Projection, unioned per connection and window. */
102
+ const RemoteConnections = Metadata.key("remote.connection", {
103
+ merge: mergeConnections,
104
+ summarize: (connection) => connection.identity.split("\0").join(" ")
105
+ });
106
+ /** The entity requirements a Projection carries. */
107
+ const requirementsOf = (projection) => RemoteRequirements.get(projection.metadata);
108
+ /** The query connections a Projection carries. */
109
+ const connectionsOf = (projection) => RemoteConnections.get(projection.metadata);
110
+ //#endregion
5
111
  //#region src/store.ts
6
112
  /**
7
113
  * The pure entity store: values, per-field presence, staleness, and tombstones.
@@ -80,6 +186,7 @@ const setStale = (store, marks, stale) => {
80
186
  for (const field of fields) if (stale) {
81
187
  if (previous.present.has(field)) staleFields.add(field);
82
188
  } else staleFields.delete(field);
189
+ if (staleFields.size === previous.stale.size) continue;
83
190
  next ??= { ...store };
84
191
  next[key] = {
85
192
  ...previous,
@@ -162,6 +269,7 @@ const emptyMutationState = {
162
269
  pending: /* @__PURE__ */ new Set(),
163
270
  applied: /* @__PURE__ */ new Set(),
164
271
  failed: /* @__PURE__ */ new Set(),
272
+ errors: /* @__PURE__ */ new Map(),
165
273
  sequence: 0
166
274
  };
167
275
  /**
@@ -184,13 +292,26 @@ const beginMutation = (state, requestId) => ({
184
292
  pending: /* @__PURE__ */ new Set([...state.pending, requestId]),
185
293
  sequence: state.sequence + 1
186
294
  });
187
- const failMutation = (state, requestId) => {
295
+ const failMutation = (state, requestId, error) => {
188
296
  const pending = new Set(state.pending);
189
297
  pending.delete(requestId);
298
+ const failed = remember(state.failed, requestId);
299
+ const errors = new Map([...state.errors, [requestId, error]]);
300
+ for (const id of errors.keys()) if (!failed.has(id)) errors.delete(id);
190
301
  return {
191
302
  ...state,
192
303
  pending,
193
- failed: remember(state.failed, requestId)
304
+ failed,
305
+ errors
306
+ };
307
+ };
308
+ const mutationStatus = (state, requestId) => {
309
+ if (state.pending.has(requestId)) return { _tag: "Pending" };
310
+ if (state.applied.has(requestId)) return { _tag: "Applied" };
311
+ const error = state.errors.get(requestId);
312
+ return error === void 0 ? { _tag: "Unknown" } : {
313
+ _tag: "Failed",
314
+ error
194
315
  };
195
316
  };
196
317
  /**
@@ -198,8 +319,8 @@ const failMutation = (state, requestId) => {
198
319
  * with the same `requestId` (a retry, or a live event describing the same change)
199
320
  * does not re-apply the entities, but still clears `pending`.
200
321
  */
201
- const reconcileMutation = (store, state, requestId, entities) => {
202
- const next = state.applied.has(requestId) ? store : entities.reduce((current, entity) => writeEntity(current, entityKey(entity.entity, entity.id), entity.values), store);
322
+ const reconcileMutation = (store, state, requestId, entities, deleted = []) => {
323
+ const next = state.applied.has(requestId) ? store : deleted.reduce((current, gone) => tombstone(current, entityKey(gone.entity, gone.id)), entities.reduce((current, entity) => writeEntity(current, entityKey(entity.entity, entity.id), entity.values), store));
203
324
  const applied = remember(state.applied, requestId);
204
325
  const pending = new Set(state.pending);
205
326
  pending.delete(requestId);
@@ -397,7 +518,12 @@ const MutationResult = Schema.Struct({
397
518
  output: Schema.Unknown,
398
519
  entities: Schema.Array(NormalizedEntity),
399
520
  /** Connection changes the mutation made, applied alongside its entity patches. */
400
- connections: Schema.optional(Schema.Array(ConnectionChangeSchema))
521
+ connections: Schema.optional(Schema.Array(ConnectionChangeSchema)),
522
+ /** Entities the mutation deleted; the client knows them absent from then on. */
523
+ deleted: Schema.optional(Schema.Array(Schema.Struct({
524
+ entity: Schema.String,
525
+ id: Schema.String
526
+ })))
401
527
  });
402
528
  const LiveRequirement = Schema.Struct({
403
529
  version: Schema.Number,
@@ -721,7 +847,8 @@ const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requ
721
847
  return {
722
848
  output: yield* Schema.decodeUnknownEffect(mutation.Output)(result.output).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message })))),
723
849
  entities: result.entities,
724
- connections: (result.connections ?? []).map(connectionChangeOf)
850
+ connections: (result.connections ?? []).map(connectionChangeOf),
851
+ deleted: result.deleted ?? []
725
852
  };
726
853
  });
727
854
  //#endregion
@@ -889,1324 +1016,1383 @@ const hasNext = (connection) => {
889
1016
  /** A connection with more than one segment has an explicit unknown gap. */
890
1017
  const isGapped = (connection) => connection.segments.length > 1;
891
1018
  //#endregion
892
- //#region src/inspect.ts
893
- const inspectEntry = (key, entry) => ({
894
- key,
895
- present: [...entry.present],
896
- stale: [...entry.stale],
897
- tombstone: entry.tombstone,
898
- updatedAt: entry.updatedAt,
899
- windows: { ...entry.windows }
900
- });
901
- /** A pure, serializable view of the whole cache. */
902
- const inspectRemote = (model) => ({
903
- entities: Object.entries(model.entities).map(([key, entry]) => inspectEntry(key, entry)),
904
- connections: Object.keys(model.connections),
905
- live: Object.keys(model.live),
906
- gaps: [...model.gaps],
907
- mutations: {
908
- pending: [...model.mutations.pending],
909
- failed: [...model.mutations.failed],
910
- applied: model.mutations.applied.size
911
- }
912
- });
913
- /** A pure, serializable view of one entity, or `undefined` if unknown. */
914
- const inspectEntity = (model, key) => {
915
- const entry = model.entities[key];
916
- return entry === void 0 ? void 0 : inspectEntry(key, entry);
1019
+ //#region src/relation.ts
1020
+ /** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
1021
+ const RelationAnnotation = "foldkitRemoteRelation";
1022
+ /** The target entity's name on the same codec, so a nested selection of another entity is refused. */
1023
+ const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
1024
+ /** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
1025
+ const refParts = (encoded) => {
1026
+ const separator = encoded.indexOf(":");
1027
+ return separator === -1 ? {
1028
+ entity: encoded,
1029
+ id: ""
1030
+ } : {
1031
+ entity: encoded.slice(0, separator),
1032
+ id: encoded.slice(separator + 1)
1033
+ };
917
1034
  };
918
- //#endregion
919
- //#region src/optimistic.ts
920
1035
  /**
921
- * Optimistic layers. Pending changes are held as ordered layers over a base
922
- * store and as overlays over a connection; the visible value is recomputed, never
923
- * patched with inverses. Settling is remove-the-layer, so overlapping layers
924
- * rebase for free.
1036
+ * The refs a relation value carries, in order. Anything that is not a ref key,
1037
+ * an array of them, or a page of them contributes nothing.
925
1038
  */
926
- const isConnectionChange = (operation) => "_tag" in operation;
927
- const toOverlays = (id, changes) => changes.map((change) => ({
928
- id,
929
- connection: change.connection,
930
- edges: [change.edge],
931
- position: change._tag === "Insert" ? change.position : "remove"
932
- }));
933
- const connectionIdentity = (connection) => typeof connection === "string" ? connection : connection.identity;
934
- const change = (connection, ref, position) => position === "remove" ? {
935
- _tag: "Remove",
936
- connection: connectionIdentity(connection),
937
- edge: edge(ref)
938
- } : {
939
- _tag: "Insert",
940
- connection: connectionIdentity(connection),
941
- position,
942
- edge: edge(ref)
1039
+ const refsIn = (value) => {
1040
+ if (typeof value === "string") return [refParts(value)];
1041
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
1042
+ if (value !== null && typeof value === "object") {
1043
+ const refs = value.refs;
1044
+ if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
1045
+ }
1046
+ return [];
943
1047
  };
944
- /**
945
- * Constructors for connection changes: what a request shows optimistically
946
- * (`MutateOptions.optimistic`) and what a mutation source reports it made
947
- * (`MutationOutcome.connections`).
948
- */
949
- const ConnectionChange = {
950
- /** Show `ref` at the front of the connection until the request settles. */
951
- prepend: (connection, ref) => change(connection, ref, "prepend"),
952
- /** Show `ref` at the end of the connection until the request settles. */
953
- append: (connection, ref) => change(connection, ref, "append"),
954
- /** Hide `ref` from the connection until the request settles. */
955
- remove: (connection, ref) => change(connection, ref, "remove")
1048
+ /** The refs in a relation value that name the relation's target entity, and no other. */
1049
+ const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
1050
+ const isRefPage = (value) => {
1051
+ if (value === null || typeof value !== "object") return false;
1052
+ const page = value;
1053
+ return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
956
1054
  };
957
1055
  /**
958
- * Applies a request's operations: its patches become one layer and its
959
- * connection changes become overlays, all owned by `requestId` so settling
960
- * removes every one of them together.
1056
+ * The relation shape an entity field schema declares: a ref (`one`), a nullable
1057
+ * ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
1058
+ * scalar field, which cannot take a nested selection.
961
1059
  */
962
- const beginOptimistic = (optimistic, requestId, operations) => {
963
- const patches = operations.filter((operation) => !isConnectionChange(operation));
964
- const changes = operations.filter(isConnectionChange);
965
- return {
966
- layers: patches.length === 0 ? optimistic.layers : [...optimistic.layers, {
967
- id: requestId,
968
- patches
969
- }],
970
- overlays: [...optimistic.overlays, ...toOverlays(requestId, changes)]
1060
+ const relationShape = (schema) => shapeOf(schema.ast, false);
1061
+ const shapeOf = (ast, nullable) => {
1062
+ const annotated = ast.annotations?.[RelationAnnotation];
1063
+ if (annotated === "one" || annotated === "page") return {
1064
+ kind: annotated,
1065
+ nullable,
1066
+ entity: String(ast.annotations?.[RelationEntityAnnotation])
971
1067
  };
1068
+ if (ast._tag === "Arrays") {
1069
+ const item = ast.rest?.[0];
1070
+ const inner = item === void 0 ? void 0 : shapeOf(item, false);
1071
+ return inner?.kind === "one" ? {
1072
+ kind: "many",
1073
+ nullable,
1074
+ entity: inner.entity
1075
+ } : void 0;
1076
+ }
1077
+ if (ast._tag === "Union") {
1078
+ const members = ast.types ?? [];
1079
+ const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
1080
+ for (const member of members) {
1081
+ const shape = shapeOf(member, optional);
1082
+ if (shape !== void 0) return shape;
1083
+ }
1084
+ }
972
1085
  };
1086
+ //#endregion
1087
+ //#region src/selection.ts
973
1088
  /**
974
- * Replaces a request's overlays with the server-confirmed changes, owned by
975
- * `id`, in the position the request's overlays held, so a confirmed edge keeps
976
- * its place among other pending inserts.
1089
+ * Selections: the fields a Surface picks of an entity (nested through
1090
+ * relations, paged through connections), the requirement graph they imply,
1091
+ * and their assembly from the normalized store.
977
1092
  */
978
- const confirm = (optimistic, requestId, id, changes) => {
979
- const confirmed = toOverlays(id, changes);
980
- const at = optimistic.overlays.findIndex((overlay) => overlay.id === requestId);
981
- const others = optimistic.overlays.filter((overlay) => overlay.id !== requestId);
982
- return {
983
- ...optimistic,
984
- overlays: at === -1 ? [...others, ...confirmed] : [
985
- ...others.slice(0, at),
986
- ...confirmed,
987
- ...others.slice(at)
988
- ]
989
- };
990
- };
991
- const emptyOptimistic = {
992
- layers: [],
993
- overlays: []
1093
+ const pageSchemas = /* @__PURE__ */ new WeakMap();
1094
+ const pageSchema = (item) => {
1095
+ const cached = pageSchemas.get(item);
1096
+ if (cached !== void 0) return cached;
1097
+ const built = Schema.Struct({
1098
+ items: Schema.Array(item),
1099
+ hasNext: Schema.Boolean,
1100
+ hasPrevious: Schema.Boolean
1101
+ });
1102
+ pageSchemas.set(item, built);
1103
+ return built;
994
1104
  };
995
- const addLayer = (optimistic, layer) => ({
996
- ...optimistic,
997
- layers: [...optimistic.layers, layer]
998
- });
999
- const removeLayer = (optimistic, id) => ({
1000
- ...optimistic,
1001
- layers: optimistic.layers.filter((layer) => layer.id !== id)
1002
- });
1003
- const addOverlay = (optimistic, overlay) => ({
1004
- ...optimistic,
1005
- overlays: [...optimistic.overlays, overlay]
1006
- });
1007
- const removeOverlay = (optimistic, id) => ({
1008
- ...optimistic,
1009
- overlays: optimistic.overlays.filter((overlay) => overlay.id !== id)
1010
- });
1011
- /** Base store with every layer applied in order. Later layers win. */
1012
- const visibleStore = (base, optimistic) => optimistic.layers.reduce((store, layer) => layer.patches.reduce((current, patch) => writeEntity(current, entityKey(patch.entity, patch.id), patch.values), store), base);
1013
- /**
1014
- * A fresh page is newer than any settled overlay it covers: an edge the page
1015
- * carries no longer needs an insert overlay, and a `remove` overlay hiding it
1016
- * is stale (the server says it is back). A pending request's overlays are
1017
- * left alone; they settle with the request.
1018
- */
1019
- const pruneOverlays = (optimistic, connection, covered, pending) => ({
1020
- ...optimistic,
1021
- overlays: optimistic.overlays.flatMap((overlay) => {
1022
- if (overlay.connection !== connection || pending.has(overlay.id)) return [overlay];
1023
- const edges = overlay.edges.filter((edge) => !covered.has(edge.key));
1024
- return edges.length === 0 ? [] : [{
1025
- ...overlay,
1026
- edges
1027
- }];
1028
- })
1105
+ /** The requirement a nested selection contributes for its relation's target. */
1106
+ const relationOf = (selection) => ({
1107
+ entity: selection.entity,
1108
+ fields: selection.fields,
1109
+ ...selection.connections === void 0 ? {} : { windows: selection.connections },
1110
+ ...selection.relations === void 0 ? {} : { relations: selection.relations }
1029
1111
  });
1030
- /** Everything a request owns: its layer and its overlays. */
1031
- const release = (optimistic, requestId) => removeOverlay(removeLayer(optimistic, requestId), requestId);
1032
- /**
1033
- * Applies a result idempotently and releases the request's layer and overlays:
1034
- * a later layer re-wins on rebase. Confirmed connection changes are recorded
1035
- * once per request, as overlays the request no longer owns.
1036
- */
1037
- const settleSuccess = (base, optimistic, state, requestId, entities, connections = []) => {
1038
- const reconciled = reconcileMutation(base, state, requestId, entities);
1039
- return {
1040
- store: reconciled.store,
1041
- state: reconciled.state,
1042
- optimistic: state.applied.has(requestId) ? release(optimistic, requestId) : confirm(removeLayer(optimistic, requestId), requestId, `confirmed:${requestId}`, connections)
1043
- };
1044
- };
1045
- /** A failed request's layer and overlays are dropped; the base was never mutated. */
1046
- const settleFailure = (optimistic, requestId) => release(optimistic, requestId);
1047
- /**
1048
- * Visible edges for one connection: applicable overlays are placed outside the
1049
- * server-known segments and de-duplicated by edge identity (across overlays too),
1050
- * so a pending insert never corrupts server-known ordering; a `remove` overlay
1051
- * hides its edges wherever they are, and so does a tombstone on the edge's
1052
- * target when `store` is given. A later prepend lands before an earlier one, a
1053
- * later append after, so pending inserts read in the order they were made.
1054
- * Boundaries still come from the connection's segments.
1055
- */
1056
- const visibleItems = (connection, connectionId, overlays, store) => {
1057
- const applicable = overlays.filter((overlay) => overlay.connection === connectionId);
1058
- const hidden = /* @__PURE__ */ new Set();
1059
- for (const overlay of applicable) for (const edge of overlay.edges) if (overlay.position === "remove") hidden.add(edge.key);
1060
- else hidden.delete(edge.key);
1061
- const gone = (edge) => hidden.has(edge.key) || store !== void 0 && isTombstone(store, entityKey(edge.ref.entity, edge.ref.id));
1062
- const known = items(connection).filter((edge) => !gone(edge));
1063
- const seen = new Set(known.map((edge) => edge.key));
1064
- const take = (position) => {
1065
- const edges = [];
1066
- const ordered = applicable.filter((value) => value.position === position);
1067
- for (const overlay of position === "prepend" ? [...ordered].reverse() : ordered) for (const edge of overlay.edges) {
1068
- if (seen.has(edge.key) || gone(edge)) continue;
1069
- seen.add(edge.key);
1070
- edges.push(edge);
1112
+ const Selection = {
1113
+ /**
1114
+ * The Remote Selection of a `foldkit-entity` Selection: the same members, read
1115
+ * from Remote's store. `true` on a relation reads its refs, as it does here.
1116
+ */
1117
+ from: (selection) => {
1118
+ const spec = {};
1119
+ for (const [key, selected] of Object.entries(selection.members)) spec[key] = selected === true ? true : Domain.SelectionPageTypeId in selected ? Selection.connection(Entity.from(selected.selection.entity), selected.window, Selection.from(selected.selection)) : Selection.from(selected);
1120
+ return Selection.make(Entity.from(selection.entity), spec);
1121
+ },
1122
+ /**
1123
+ * The fields to read of `entity`. A nested `Selection` on a relation field
1124
+ * reads through the ref (or refs, or page of refs) the field holds into the
1125
+ * target's fields; a scalar field cannot take one.
1126
+ */
1127
+ make: (entity, selection) => {
1128
+ if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
1129
+ const picked = {};
1130
+ const connections = {};
1131
+ const relations = {};
1132
+ for (const key of Object.keys(selection)) {
1133
+ const choice = selection[key];
1134
+ if (choice === true) {
1135
+ picked[key] = entity.fields[key];
1136
+ continue;
1137
+ }
1138
+ const nested = choice;
1139
+ const shape = relationShape(entity.fields[key]);
1140
+ if (shape === void 0) throw new Error(`Selection.make: "${key}" on "${entity.name}" is not a relation field, so it cannot take a nested selection`);
1141
+ if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
1142
+ const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
1143
+ if (nested.window !== void 0) {
1144
+ connections[key] = nested.window;
1145
+ picked[key] = nullable(nested.schema);
1146
+ if (nested.fields.length > 0) relations[key] = relationOf(nested);
1147
+ continue;
1148
+ }
1149
+ const item = nested.schema;
1150
+ picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
1151
+ relations[key] = relationOf(nested);
1071
1152
  }
1072
- return edges;
1073
- };
1074
- return [
1075
- ...take("prepend"),
1076
- ...known,
1077
- ...take("append")
1078
- ];
1079
- };
1080
- //#endregion
1081
- //#region src/live.ts
1082
- const emptyLiveState = {
1083
- cursor: 0,
1084
- stale: /* @__PURE__ */ new Set(),
1085
- boundary: {}
1153
+ return {
1154
+ entity: entity.name,
1155
+ fields: Object.keys(selection),
1156
+ schema: Schema.Struct(picked),
1157
+ ...Object.keys(connections).length === 0 ? {} : { connections },
1158
+ ...Object.keys(relations).length === 0 ? {} : { relations }
1159
+ };
1160
+ },
1161
+ /**
1162
+ * A paginated relation: one page of refs to `entity` with a window, or, with
1163
+ * a nested selection, one page of targets assembled through it.
1164
+ */
1165
+ connection: ((entity, window, selection) => {
1166
+ if (selection !== void 0 && selection.entity !== entity.name) throw new Error(`Selection.connection: a page of "${entity.name}" cannot select "${selection.entity}"`);
1167
+ return {
1168
+ entity: entity.name,
1169
+ fields: selection?.fields ?? [],
1170
+ schema: selection === void 0 ? Entity.refPage(entity) : pageSchema(selection.schema),
1171
+ window,
1172
+ ...selection?.connections === void 0 ? {} : { connections: selection.connections },
1173
+ ...selection?.relations === void 0 ? {} : { relations: selection.relations }
1174
+ };
1175
+ })
1086
1176
  };
1087
1177
  /**
1088
- * Ordering per stream: at most the next cursor; ahead is a gap. Cursor `0` means
1089
- * "no baseline yet" (cursors are 1-based), so the first event of a stream is
1090
- * accepted whatever its cursor — otherwise a stream not starting at 1 would be a
1091
- * spurious gap.
1178
+ * Reads an entity's selected fields out of the store, following each nested
1179
+ * relation into its targets. `undefined` means some field, at any depth, is
1180
+ * not present yet. A tombstoned target reads as `null` (or is dropped from a
1181
+ * list), so the Selection's codec decides whether that is a failure.
1092
1182
  */
1093
- const classifyLive = (state, cursor) => cursor <= state.cursor ? "duplicate" : state.cursor === 0 || cursor === state.cursor + 1 ? "applied" : "gap";
1094
- const advance = (state, cursor) => ({
1095
- ...state,
1096
- cursor
1097
- });
1098
- const recordBoundary = (state, connection, position) => {
1099
- const current = state.boundary[connection] ?? {
1100
- before: 0,
1101
- after: 0
1102
- };
1103
- const next = position === "prepend" ? {
1104
- ...current,
1105
- before: current.before + 1
1106
- } : {
1107
- ...current,
1108
- after: current.after + 1
1109
- };
1110
- return {
1111
- ...state,
1112
- boundary: {
1113
- ...state.boundary,
1114
- [connection]: next
1183
+ const assemble = (store, key, requirement) => {
1184
+ const values = {};
1185
+ let refreshing = false;
1186
+ for (const field of requirement.fields) {
1187
+ const value = readField(store, key, field);
1188
+ if (Option.isNone(value)) return void 0;
1189
+ refreshing ||= isFieldStale(store, key, field);
1190
+ const relation = requirement.relations?.[field];
1191
+ if (relation === void 0) {
1192
+ values[field] = value.value;
1193
+ continue;
1115
1194
  }
1116
- };
1117
- };
1118
- const invalidateConnection = (state, connection) => ({
1119
- ...state,
1120
- stale: /* @__PURE__ */ new Set([...state.stale, connection])
1121
- });
1122
- /**
1123
- * A subscriber is woken only if the event changed a field it selects. A
1124
- * change to an unselected field is skipped entirely.
1125
- */
1126
- const shouldWake = (changed, selected) => changed.some((field) => selected.includes(field));
1127
- const applyEntityEvent = (state, store, event, now = 0) => {
1128
- const outcome = classifyLive(state, event.cursor);
1129
- if (outcome !== "applied") return {
1130
- state,
1131
- store,
1132
- outcome
1133
- };
1134
- const key = entityKey(event.ref.entity, event.ref.id);
1135
- const next = event._tag === "EntityPatched" ? writeEntity(store, key, event.values, now) : tombstone(store, key);
1195
+ const nested = assembleRelation(store, value.value, relation);
1196
+ if (nested === void 0) return void 0;
1197
+ values[field] = nested.values;
1198
+ refreshing ||= nested.refreshing;
1199
+ }
1136
1200
  return {
1137
- state: advance(state, event.cursor),
1138
- store: next,
1139
- outcome
1201
+ values,
1202
+ refreshing
1140
1203
  };
1141
1204
  };
1142
- const removeEdgeOverlays = (optimistic, connection, key) => ({
1143
- ...optimistic,
1144
- overlays: optimistic.overlays.map((overlay) => overlay.connection !== connection || overlay.position === "remove" ? overlay : {
1145
- ...overlay,
1146
- edges: overlay.edges.filter((edge) => edge.key !== key)
1147
- })
1148
- });
1149
- const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
1150
- const outcome = classifyLive(state, event.cursor);
1151
- if (outcome !== "applied") return {
1152
- state,
1153
- optimistic,
1154
- outcome
1205
+ const assembleTarget = (store, ref, relation) => {
1206
+ const key = entityKey(ref.entity, ref.id);
1207
+ return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
1208
+ };
1209
+ /** Assembles the targets a relation value refers to, in the value's own shape. */
1210
+ const assembleRelation = (store, value, relation) => {
1211
+ if (value === null || value === void 0) return {
1212
+ values: null,
1213
+ refreshing: false
1155
1214
  };
1156
- switch (event._tag) {
1157
- case "ConnectionInsert": switch ((event.position === "prepend" ? policy.prepend : policy.append) ?? "visible") {
1158
- case "visible": return {
1159
- state: advance(state, event.cursor),
1160
- optimistic: addOverlay(optimistic, {
1161
- id: `live:${event.cursor}`,
1162
- connection: event.connection,
1163
- edges: [event.edge],
1164
- position: event.position
1165
- }),
1166
- outcome
1167
- };
1168
- case "boundary": return {
1169
- state: recordBoundary(advance(state, event.cursor), event.connection, event.position),
1170
- optimistic,
1171
- outcome
1172
- };
1173
- case "invalidate": return {
1174
- state: invalidateConnection(advance(state, event.cursor), event.connection),
1175
- optimistic,
1176
- outcome
1177
- };
1178
- case "ignore": return {
1179
- state: advance(state, event.cursor),
1180
- optimistic,
1181
- outcome
1182
- };
1183
- }
1184
- case "ConnectionRemove": return {
1185
- state: advance(state, event.cursor),
1186
- optimistic: addOverlay(removeEdgeOverlays(optimistic, event.connection, event.edge.key), {
1187
- id: `live:${event.cursor}`,
1188
- connection: event.connection,
1189
- edges: [event.edge],
1190
- position: "remove"
1191
- }),
1192
- outcome
1193
- };
1194
- case "ConnectionInvalidate": return {
1195
- state: invalidateConnection(advance(state, event.cursor), event.connection),
1196
- optimistic,
1197
- outcome
1198
- };
1215
+ const targets = [];
1216
+ let refreshing = false;
1217
+ for (const ref of targetsOf(value, relation)) {
1218
+ const target = assembleTarget(store, ref, relation);
1219
+ if (target === void 0) return void 0;
1220
+ if (target === "absent") continue;
1221
+ targets.push(target.values);
1222
+ refreshing ||= target.refreshing;
1199
1223
  }
1200
- };
1201
- const isStale = (state, connection) => state.stale.has(connection);
1202
- /**
1203
- * Clears a connection's stale mark once a fresh page has been adopted. An
1204
- * invalidating event only records that a refetch is due; whoever merges the
1205
- * refetched page calls this so `isStale` stops reporting.
1206
- */
1207
- const refreshConnection = (state, connection) => {
1208
- if (!state.stale.has(connection)) return state;
1209
- const stale = new Set(state.stale);
1210
- stale.delete(connection);
1224
+ if (typeof value === "string") return {
1225
+ values: targets.length === 0 ? null : targets[0],
1226
+ refreshing
1227
+ };
1228
+ if (isRefPage(value)) return {
1229
+ values: {
1230
+ items: targets,
1231
+ hasNext: value.hasNext,
1232
+ hasPrevious: value.hasPrevious
1233
+ },
1234
+ refreshing
1235
+ };
1211
1236
  return {
1212
- ...state,
1213
- stale
1237
+ values: targets,
1238
+ refreshing
1214
1239
  };
1215
1240
  };
1216
- /** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
1217
- const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
1218
- /** `hasNext` accounting for edges recorded outside the loaded boundary. */
1219
- const liveHasNext = (connection, state, connectionId) => hasNext(connection) || (state.boundary[connectionId]?.after ?? 0) > 0;
1220
1241
  //#endregion
1221
- //#region src/relation.ts
1222
- /** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
1223
- const RelationAnnotation = "foldkitRemoteRelation";
1224
- /** The target entity's name on the same codec, so a nested selection of another entity is refused. */
1225
- const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
1226
- /** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
1227
- const refParts = (encoded) => {
1228
- const separator = encoded.indexOf(":");
1229
- return separator === -1 ? {
1230
- entity: encoded,
1231
- id: ""
1232
- } : {
1233
- entity: encoded.slice(0, separator),
1234
- id: encoded.slice(separator + 1)
1235
- };
1236
- };
1242
+ //#region src/entity.ts
1237
1243
  /**
1238
- * The refs a relation value carries, in order. Anything that is not a ref key,
1239
- * an array of them, or a page of them contributes nothing.
1244
+ * Entities: the descriptor an application declares, refs, ref pages, and
1245
+ * patches, with the codecs that carry refs on the wire.
1240
1246
  */
1241
- const refsIn = (value) => {
1242
- if (typeof value === "string") return [refParts(value)];
1243
- if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
1244
- if (value !== null && typeof value === "object") {
1245
- const refs = value.refs;
1246
- if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
1247
- }
1248
- return [];
1249
- };
1250
- /** The refs in a relation value that name the relation's target entity, and no other. */
1251
- const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
1252
- const isRefPage = (value) => {
1253
- if (value === null || typeof value !== "object") return false;
1254
- const page = value;
1255
- return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
1256
- };
1257
1247
  /**
1258
- * The relation shape an entity field schema declares: a ref (`one`), a nullable
1259
- * ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
1260
- * scalar field, which cannot take a nested selection.
1248
+ * Relations are **references**, never inline target schemas: a ref cannot
1249
+ * reconstruct a full entity, and dereferencing is a store concern. Because the
1250
+ * target schema is not inlined, recursive relations cannot arise through the
1251
+ * schema graph.
1261
1252
  */
1262
- const relationShape = (schema) => shapeOf(schema.ast, false);
1263
- const shapeOf = (ast, nullable) => {
1264
- const annotated = ast.annotations?.[RelationAnnotation];
1265
- if (annotated === "one" || annotated === "page") return {
1266
- kind: annotated,
1267
- nullable,
1268
- entity: String(ast.annotations?.[RelationEntityAnnotation])
1269
- };
1270
- if (ast._tag === "Arrays") {
1271
- const item = ast.rest?.[0];
1272
- const inner = item === void 0 ? void 0 : shapeOf(item, false);
1273
- return inner?.kind === "one" ? {
1274
- kind: "many",
1275
- nullable,
1276
- entity: inner.entity
1277
- } : void 0;
1278
- }
1279
- if (ast._tag === "Union") {
1280
- const members = ast.types ?? [];
1281
- const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
1282
- for (const member of members) {
1283
- const shape = shapeOf(member, optional);
1284
- if (shape !== void 0) return shape;
1285
- }
1253
+ const refCodec = (entity) => Schema.Struct({
1254
+ entity: Schema.String,
1255
+ id: Schema.String
1256
+ }).pipe(Schema.encodeTo(Schema.String, {
1257
+ decode: SchemaGetter.transform(refParts),
1258
+ encode: SchemaGetter.transform((ref) => entityKey(ref.entity, ref.id))
1259
+ })).annotate({
1260
+ [RelationAnnotation]: "one",
1261
+ [RelationEntityAnnotation]: entity
1262
+ });
1263
+ const refPageCodec = (entity) => Schema.Struct({
1264
+ refs: Schema.Array(refCodec(entity)),
1265
+ hasNext: Schema.Boolean,
1266
+ hasPrevious: Schema.Boolean
1267
+ }).annotate({
1268
+ [RelationAnnotation]: "page",
1269
+ [RelationEntityAnnotation]: entity
1270
+ });
1271
+ const descriptors = /* @__PURE__ */ new WeakMap();
1272
+ const relationField = (relation) => {
1273
+ const ref = refCodec(relation.target().name);
1274
+ return relation.cardinality === "many" ? Schema.Array(ref) : relation.optional ? Schema.NullOr(ref) : ref;
1275
+ };
1276
+ const Entity = {
1277
+ /**
1278
+ * The Remote descriptor of a `foldkit-entity` Entity. Relations declared with
1279
+ * `Entity.relate` become ref fields, so the store, the planner, and the server
1280
+ * treat it as they treat an `Entity.make` descriptor.
1281
+ */
1282
+ from: (entity) => {
1283
+ const cached = descriptors.get(entity);
1284
+ if (cached !== void 0) return cached;
1285
+ const members = entity.members;
1286
+ if (members.id?._tag !== "Field") throw new Error(`Entity.from: "${entity.name}" needs an "id" field for Remote to key it by`);
1287
+ const fields = {};
1288
+ for (const [key, member] of Object.entries(members)) fields[key] = member._tag === "Relation" ? relationField(member) : member.schema;
1289
+ const descriptor = Entity.make(entity.name, Schema.Struct(fields));
1290
+ descriptors.set(entity, descriptor);
1291
+ return descriptor;
1292
+ },
1293
+ make: (name, schema) => {
1294
+ const descriptor = {
1295
+ name,
1296
+ schema,
1297
+ fields: schema.fields,
1298
+ ref: (id) => ({
1299
+ entity: name,
1300
+ id: String(id)
1301
+ }),
1302
+ select: (selection) => Selection.make(descriptor, selection),
1303
+ patch: (id, values) => ({
1304
+ entity: name,
1305
+ id: String(id),
1306
+ values
1307
+ })
1308
+ };
1309
+ return descriptor;
1310
+ },
1311
+ /** A relation to a known entity, decoded as a reference. */
1312
+ ref: (entity) => refCodec(entity.name),
1313
+ /** A relation by name, for recursive or forward references. */
1314
+ refTo: (name) => refCodec(name),
1315
+ /**
1316
+ * The wire key a relation field carries (`"Entity:id"`). Adapters that read a
1317
+ * foreign key emit this so the ref codec can decode it on the client.
1318
+ */
1319
+ refKey: (ref) => entityKey(ref.entity, ref.id),
1320
+ /** Splits a ref key back into its entity and id. */
1321
+ refParts,
1322
+ /** A relation value of one authoritative page of refs. */
1323
+ refPage: (entity) => refPageCodec(entity.name),
1324
+ /**
1325
+ * A normalized patch for an entity, typed against its fields' wire shape:
1326
+ * write a relation as its ref key (`Entity.refKey`), as the server does.
1327
+ */
1328
+ patch: (ref, patch) => ({
1329
+ entity: ref.entity,
1330
+ id: ref.id,
1331
+ values: patch
1332
+ })
1333
+ };
1334
+ //#endregion
1335
+ //#region src/inspect.ts
1336
+ const inspectEntry = (key, entry) => ({
1337
+ key,
1338
+ present: [...entry.present],
1339
+ stale: [...entry.stale],
1340
+ tombstone: entry.tombstone,
1341
+ updatedAt: entry.updatedAt,
1342
+ windows: { ...entry.windows }
1343
+ });
1344
+ /** A pure, serializable view of the whole cache. */
1345
+ const inspectRemote = (model) => ({
1346
+ entities: Object.entries(model.entities).map(([key, entry]) => inspectEntry(key, entry)),
1347
+ connections: Object.keys(model.connections),
1348
+ live: Object.keys(model.live),
1349
+ gaps: [...model.gaps],
1350
+ mutations: {
1351
+ pending: [...model.mutations.pending],
1352
+ failed: [...model.mutations.failed],
1353
+ applied: model.mutations.applied.size
1286
1354
  }
1355
+ });
1356
+ /** A pure, serializable view of one entity, or `undefined` if unknown. */
1357
+ const inspectEntity = (model, key) => {
1358
+ const entry = model.entities[key];
1359
+ return entry === void 0 ? void 0 : inspectEntry(key, entry);
1287
1360
  };
1288
1361
  //#endregion
1289
- //#region src/plan.ts
1362
+ //#region src/optimistic.ts
1290
1363
  /**
1291
- * The pure requirement planner.
1292
- *
1293
- * A `Requirement` is plain data: which entity fields a Remote projection needs,
1294
- * and through `relations`, which fields of each relation's target. `plan` diffs
1295
- * requirements against the store and returns only the missing fields, grouped
1296
- * and deterministically ordered. A relation whose field is being fetched keeps
1297
- * its nested requirement on the request, so the server resolves the graph in
1298
- * one read; a relation whose refs the store already holds is followed into
1299
- * concrete requirements for the targets. Time enters through `PlanFreshness`,
1300
- * never from ambient state; `force` plans every field.
1364
+ * Optimistic layers. Pending changes are held as ordered layers over a base
1365
+ * store and as overlays over a connection; the visible value is recomputed, never
1366
+ * patched with inverses. Settling is remove-the-layer, so overlapping layers
1367
+ * rebase for free.
1301
1368
  */
1302
- /** A stable key for a window, so two equal windows compare equal. */
1303
- const windowKey = (window) => stableStringify(window);
1304
- const pickFields = (record, fields) => {
1305
- if (record === void 0) return void 0;
1306
- const picked = Object.fromEntries(Object.entries(record).filter(([field]) => fields.has(field)));
1307
- return Object.keys(picked).length === 0 ? void 0 : picked;
1308
- };
1309
- /** The concrete requirements a known relation value contributes for its targets. */
1310
- const followRelation = (value, relation) => targetsOf(value, relation).map((ref) => ({
1311
- ...relation,
1312
- id: ref.id
1369
+ const isConnectionChange = (operation) => "_tag" in operation;
1370
+ const toOverlays = (id, changes) => changes.map((change) => ({
1371
+ id,
1372
+ connection: change.connection,
1373
+ edges: [change.edge],
1374
+ position: change._tag === "Insert" ? change.position : "remove"
1313
1375
  }));
1314
- const plan = (store, requirements, options = {}) => {
1315
- const { freshness, force = false } = options;
1316
- const planned = [];
1317
- const followed = [];
1318
- for (const group of Requirement.merge(requirements)) {
1319
- const key = entityKey(group.entity, group.id);
1320
- const entry = store[key];
1321
- const expired = freshness !== void 0 && entry !== void 0 && !entry.tombstone && freshness.now - entry.updatedAt > freshness.freshness;
1322
- const windowKeys = Object.fromEntries(Object.entries(group.windows ?? {}).map(([field, window]) => [field, windowKey(window)]));
1323
- const missing = force || expired ? group.fields : missingFields(store, key, group.fields, windowKeys);
1324
- const missingSet = new Set(missing);
1325
- for (const [field, relation] of Object.entries(group.relations ?? {})) {
1326
- if (missingSet.has(field)) continue;
1327
- const value = readField(store, key, field);
1328
- if (value._tag === "Some") followed.push(...followRelation(value.value, relation));
1329
- }
1330
- if (missing.length === 0) continue;
1331
- const windows = pickFields(group.windows, missingSet);
1332
- const relations = pickFields(group.relations, missingSet);
1333
- planned.push({
1334
- entity: group.entity,
1335
- id: group.id,
1336
- fields: missing,
1337
- ...windows === void 0 ? {} : { windows },
1338
- ...relations === void 0 ? {} : { relations }
1339
- });
1340
- }
1341
- const nested = followed.length === 0 ? [] : plan(store, followed, options);
1342
- return [...Requirement.merge([...planned, ...nested])].sort((a, b) => entityKey(a.entity, a.id) < entityKey(b.entity, b.id) ? -1 : 1);
1376
+ const connectionIdentity = (connection) => typeof connection === "string" ? connection : connection.identity;
1377
+ const change = (connection, ref, position) => position === "remove" ? {
1378
+ _tag: "Remove",
1379
+ connection: connectionIdentity(connection),
1380
+ edge: edge(ref)
1381
+ } : {
1382
+ _tag: "Insert",
1383
+ connection: connectionIdentity(connection),
1384
+ position,
1385
+ edge: edge(ref)
1343
1386
  };
1344
- //#endregion
1345
- //#region src/retain.ts
1346
1387
  /**
1347
- * The entity keys the roots reach: each root entity, the targets its retained
1348
- * fields refer to, and recursively the targets a nested relation selects.
1388
+ * Constructors for connection changes: what a request shows optimistically
1389
+ * (`MutateOptions.optimistic`) and what a mutation source reports it made
1390
+ * (`MutationOutcome.connections`).
1349
1391
  */
1350
- const reachable = (store, roots, connections, optimistic, pending) => {
1351
- const kept = /* @__PURE__ */ new Set();
1352
- const rootConnections = new Set(roots.connections.map((root) => root.identity));
1353
- const walked = /* @__PURE__ */ new Map();
1354
- const visit = (key, requirement) => {
1355
- kept.add(key);
1356
- const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
1357
- if (seen.has(requirement)) return;
1358
- seen.add(requirement);
1359
- walked.set(key, seen);
1360
- for (const field of requirement.fields) {
1361
- const value = readField(store, key, field);
1362
- if (value._tag === "None") continue;
1363
- const relation = requirement.relations?.[field];
1364
- for (const ref of refsIn(value.value)) {
1365
- const target = entityKey(ref.entity, ref.id);
1366
- if (relation === void 0 || ref.entity !== relation.entity) {
1367
- kept.add(target);
1368
- continue;
1369
- }
1370
- visit(target, relation);
1371
- }
1372
- }
1373
- };
1374
- for (const root of roots.requirements) visit(entityKey(root.entity, root.id), root);
1375
- for (const { identity, select } of roots.connections) for (const segment of connections[identity]?.segments ?? []) for (const edge of segment.edges) {
1376
- const target = entityKey(edge.ref.entity, edge.ref.id);
1377
- if (select === void 0 || edge.ref.entity !== select.entity) kept.add(target);
1378
- else visit(target, select);
1379
- }
1380
- for (const layer of optimistic.layers) {
1381
- if (!pending.has(layer.id)) continue;
1382
- for (const patch of layer.patches) kept.add(entityKey(patch.entity, patch.id));
1383
- }
1384
- for (const overlay of optimistic.overlays) {
1385
- if (!pending.has(overlay.id) && !rootConnections.has(overlay.connection)) continue;
1386
- for (const edge of overlay.edges) kept.add(entityKey(edge.ref.entity, edge.ref.id));
1387
- }
1388
- return kept;
1392
+ const ConnectionChange = {
1393
+ /** Show `ref` at the front of the connection until the request settles. */
1394
+ prepend: (connection, ref) => change(connection, ref, "prepend"),
1395
+ /** Show `ref` at the end of the connection until the request settles. */
1396
+ append: (connection, ref) => change(connection, ref, "append"),
1397
+ /** Hide `ref` from the connection until the request settles. */
1398
+ remove: (connection, ref) => change(connection, ref, "remove")
1389
1399
  };
1390
1400
  /**
1391
- * Drops every entity the roots do not reach, every connection they do not
1392
- * name, and every settled overlay on a dropped connection, keeping whatever a
1393
- * pending request's layer or overlays touch. Pure.
1401
+ * Applies a request's operations: its patches become one layer and its
1402
+ * connection changes become overlays, all owned by `requestId` so settling
1403
+ * removes every one of them together.
1394
1404
  */
1395
- const gc = (state, roots) => {
1396
- const pending = state.mutations.pending;
1397
- const kept = reachable(state.entities, roots, state.connections, state.optimistic, pending);
1398
- const entities = Object.fromEntries(Object.entries(state.entities).filter(([key]) => kept.has(key)));
1399
- const keptConnections = /* @__PURE__ */ new Set([...roots.connections.map((root) => root.identity), ...state.optimistic.overlays.filter((overlay) => pending.has(overlay.id)).map((overlay) => overlay.connection)]);
1405
+ const beginOptimistic = (optimistic, requestId, operations) => {
1406
+ const patches = operations.filter((operation) => !isConnectionChange(operation));
1407
+ const changes = operations.filter(isConnectionChange);
1400
1408
  return {
1401
- entities,
1402
- connections: Object.fromEntries(Object.entries(state.connections).filter(([identity]) => keptConnections.has(identity))),
1403
- optimistic: {
1404
- layers: state.optimistic.layers,
1405
- overlays: state.optimistic.overlays.filter((overlay) => pending.has(overlay.id) || keptConnections.has(overlay.connection))
1406
- }
1409
+ layers: patches.length === 0 ? optimistic.layers : [...optimistic.layers, {
1410
+ id: requestId,
1411
+ patches
1412
+ }],
1413
+ overlays: [...optimistic.overlays, ...toOverlays(requestId, changes)]
1407
1414
  };
1408
1415
  };
1409
- //#endregion
1410
- //#region src/persistence.ts
1411
1416
  /**
1412
- * Remote cache persistence: a snapshot of the entity store and nothing else.
1413
- * Runtime state (connections' live cursors, optimistic layers, the mutation
1414
- * ledger, gaps, retention roots) is never in a snapshot; it belongs to the
1415
- * session that produced it.
1416
- *
1417
- * The cache is server-derived and disposable: an incompatible, oversized, or
1418
- * corrupt snapshot is discarded (and removed from a `KeyValueStore`) so the
1419
- * planner refetches. `dehydrate`/`hydrate` are the string forms, for SSR and
1420
- * any other transport; `save`/`restore` put them behind Effect's
1421
- * `KeyValueStore`, so the backend (memory, filesystem, SQL, Web Storage) is the
1422
- * application's choice.
1417
+ * Replaces a request's overlays with the server-confirmed changes, owned by
1418
+ * `id`, in the position the request's overlays held, so a confirmed edge keeps
1419
+ * its place among other pending inserts.
1423
1420
  */
1424
- /** Bump when the serialized shape changes; a mismatch discards the cache. */
1425
- const REMOTE_CACHE_VERSION = 3;
1426
- const sorted = (values) => [...values].sort();
1421
+ const confirm = (optimistic, requestId, id, changes) => {
1422
+ const confirmed = toOverlays(id, changes);
1423
+ const at = optimistic.overlays.findIndex((overlay) => overlay.id === requestId);
1424
+ const others = optimistic.overlays.filter((overlay) => overlay.id !== requestId);
1425
+ return {
1426
+ ...optimistic,
1427
+ overlays: at === -1 ? [...others, ...confirmed] : [
1428
+ ...others.slice(0, at),
1429
+ ...confirmed,
1430
+ ...others.slice(at)
1431
+ ]
1432
+ };
1433
+ };
1434
+ const emptyOptimistic = {
1435
+ layers: [],
1436
+ overlays: []
1437
+ };
1438
+ const addLayer = (optimistic, layer) => ({
1439
+ ...optimistic,
1440
+ layers: [...optimistic.layers, layer]
1441
+ });
1442
+ const removeLayer = (optimistic, id) => ({
1443
+ ...optimistic,
1444
+ layers: optimistic.layers.filter((layer) => layer.id !== id)
1445
+ });
1446
+ const addOverlay = (optimistic, overlay) => ({
1447
+ ...optimistic,
1448
+ overlays: [...optimistic.overlays, overlay]
1449
+ });
1450
+ const removeOverlay = (optimistic, id) => ({
1451
+ ...optimistic,
1452
+ overlays: optimistic.overlays.filter((overlay) => overlay.id !== id)
1453
+ });
1454
+ /** Base store with every layer applied in order. Later layers win. */
1455
+ const visibleStore = (base, optimistic) => optimistic.layers.reduce((store, layer) => layer.patches.reduce((current, patch) => writeEntity(current, entityKey(patch.entity, patch.id), patch.values), store), base);
1427
1456
  /**
1428
- * The snapshot's data shape. `stableStringify` sorts object keys, and the
1429
- * field sets are sorted here, so equal stores give byte-equal snapshots
1430
- * whatever order they were built in.
1457
+ * A fresh page is newer than any settled overlay it covers: an edge the page
1458
+ * carries no longer needs an insert overlay, and a `remove` overlay hiding it
1459
+ * is stale (the server says it is back). A pending request's overlays are
1460
+ * left alone; they settle with the request.
1431
1461
  */
1432
- const serializeStore = (store, scope) => ({
1433
- version: 3,
1434
- scope: scope ?? null,
1435
- entities: Object.fromEntries(Object.entries(store).map(([key, entry]) => [key, {
1436
- values: entry.values,
1437
- present: sorted(entry.present),
1438
- stale: sorted(entry.stale),
1439
- tombstone: entry.tombstone,
1440
- updatedAt: entry.updatedAt,
1441
- windows: entry.windows
1442
- }]))
1462
+ const pruneOverlays = (optimistic, connection, covered, pending) => ({
1463
+ ...optimistic,
1464
+ overlays: optimistic.overlays.flatMap((overlay) => {
1465
+ if (overlay.connection !== connection || pending.has(overlay.id)) return [overlay];
1466
+ const edges = overlay.edges.filter((edge) => !covered.has(edge.key));
1467
+ return edges.length === 0 ? [] : [{
1468
+ ...overlay,
1469
+ edges
1470
+ }];
1471
+ })
1443
1472
  });
1444
- const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
1445
- const isStringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
1446
- /** Throws on a malformed entry so the whole snapshot is discarded. */
1447
- const parseEntry = (value) => {
1448
- if (value === null || typeof value !== "object") throw new Error("entry is not an object");
1449
- const entry = value;
1450
- if (entry.values === null || typeof entry.values !== "object" || Array.isArray(entry.values)) throw new Error("entry.values is not a record");
1451
- if (!isStringArray(entry.present)) throw new Error("entry.present is not a string array");
1452
- if (!isStringArray(entry.stale)) throw new Error("entry.stale is not a string array");
1453
- if (typeof entry.tombstone !== "boolean") throw new Error("entry.tombstone is not a boolean");
1454
- if (typeof entry.updatedAt !== "number") throw new Error("entry.updatedAt is not a number");
1455
- if (!isStringRecord(entry.windows)) throw new Error("entry.windows is not a string record");
1473
+ /** Everything a request owns: its layer and its overlays. */
1474
+ const release = (optimistic, requestId) => removeOverlay(removeLayer(optimistic, requestId), requestId);
1475
+ /**
1476
+ * Applies a result idempotently and releases the request's layer and overlays:
1477
+ * a later layer re-wins on rebase. Confirmed connection changes are recorded
1478
+ * once per request, as overlays the request no longer owns.
1479
+ */
1480
+ const settleSuccess = (base, optimistic, state, requestId, entities, connections = [], deleted = []) => {
1481
+ const reconciled = reconcileMutation(base, state, requestId, entities, deleted);
1456
1482
  return {
1457
- values: entry.values,
1458
- present: new Set(entry.present),
1459
- stale: new Set(entry.stale),
1460
- tombstone: entry.tombstone,
1461
- updatedAt: entry.updatedAt,
1462
- windows: entry.windows
1483
+ store: reconciled.store,
1484
+ state: reconciled.state,
1485
+ optimistic: state.applied.has(requestId) ? release(optimistic, requestId) : confirm(removeLayer(optimistic, requestId), requestId, `confirmed:${requestId}`, connections)
1463
1486
  };
1464
1487
  };
1465
- const deserializeStore = (serialized) => Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)]));
1488
+ /** A failed request's layer and overlays are dropped; the base was never mutated. */
1489
+ const settleFailure = (optimistic, requestId) => release(optimistic, requestId);
1466
1490
  /**
1467
- * The snapshot text, or `undefined` when it would exceed `maxBytes`. Only the
1468
- * entity store goes in; pass `model.entities`, never the whole `RemoteModel`.
1491
+ * Visible edges for one connection: applicable overlays are placed outside the
1492
+ * server-known segments and de-duplicated by edge identity (across overlays too),
1493
+ * so a pending insert never corrupts server-known ordering; a `remove` overlay
1494
+ * hides its edges wherever they are, and so does a tombstone on the edge's
1495
+ * target when `store` is given. A later prepend lands before an earlier one, a
1496
+ * later append after, so pending inserts read in the order they were made.
1497
+ * Boundaries still come from the connection's segments.
1469
1498
  */
1470
- const dehydrate = (store, options = {}) => {
1471
- const text = stableStringify(serializeStore(store, options.scope));
1472
- return exceeds(text, options.maxBytes) ? void 0 : text;
1499
+ const visibleItems = (connection, connectionId, overlays, store) => {
1500
+ const applicable = overlays.filter((overlay) => overlay.connection === connectionId);
1501
+ const hidden = /* @__PURE__ */ new Set();
1502
+ for (const overlay of applicable) for (const edge of overlay.edges) if (overlay.position === "remove") hidden.add(edge.key);
1503
+ else hidden.delete(edge.key);
1504
+ const gone = (edge) => hidden.has(edge.key) || store !== void 0 && isTombstone(store, entityKey(edge.ref.entity, edge.ref.id));
1505
+ const known = items(connection).filter((edge) => !gone(edge));
1506
+ const seen = new Set(known.map((edge) => edge.key));
1507
+ const take = (position) => {
1508
+ const edges = [];
1509
+ const ordered = applicable.filter((value) => value.position === position);
1510
+ for (const overlay of position === "prepend" ? [...ordered].reverse() : ordered) for (const edge of overlay.edges) {
1511
+ if (seen.has(edge.key) || gone(edge)) continue;
1512
+ seen.add(edge.key);
1513
+ edges.push(edge);
1514
+ }
1515
+ return edges;
1516
+ };
1517
+ return [
1518
+ ...take("prepend"),
1519
+ ...known,
1520
+ ...take("append")
1521
+ ];
1522
+ };
1523
+ //#endregion
1524
+ //#region src/live.ts
1525
+ const emptyLiveState = {
1526
+ cursor: 0,
1527
+ stale: /* @__PURE__ */ new Set(),
1528
+ boundary: {}
1473
1529
  };
1474
1530
  /**
1475
- * Whether the UTF-8 size of `text` exceeds `maxBytes`. A character is one to
1476
- * three bytes, so most texts are decided from their length alone; only the
1477
- * rest are measured.
1531
+ * Ordering per stream: at most the next cursor; ahead is a gap. Cursor `0` means
1532
+ * "no baseline yet" (cursors are 1-based), so the first event of a stream is
1533
+ * accepted whatever its cursor — otherwise a stream not starting at 1 would be a
1534
+ * spurious gap.
1478
1535
  */
1479
- const exceeds = (text, maxBytes) => {
1480
- if (maxBytes === void 0) return false;
1481
- if (text.length > maxBytes) return true;
1482
- if (text.length * 3 <= maxBytes) return false;
1483
- return new TextEncoder().encode(text).length > maxBytes;
1536
+ const classifyLive = (state, cursor) => cursor <= state.cursor ? "duplicate" : state.cursor === 0 || cursor === state.cursor + 1 ? "applied" : "gap";
1537
+ const advance = (state, cursor) => ({
1538
+ ...state,
1539
+ cursor
1540
+ });
1541
+ const recordBoundary = (state, connection, position) => {
1542
+ const current = state.boundary[connection] ?? {
1543
+ before: 0,
1544
+ after: 0
1545
+ };
1546
+ const next = position === "prepend" ? {
1547
+ ...current,
1548
+ before: current.before + 1
1549
+ } : {
1550
+ ...current,
1551
+ after: current.after + 1
1552
+ };
1553
+ return {
1554
+ ...state,
1555
+ boundary: {
1556
+ ...state.boundary,
1557
+ [connection]: next
1558
+ }
1559
+ };
1484
1560
  };
1561
+ const invalidateConnection = (state, connection) => ({
1562
+ ...state,
1563
+ stale: /* @__PURE__ */ new Set([...state.stale, connection])
1564
+ });
1485
1565
  /**
1486
- * The store a snapshot text holds, or `undefined` when the text is missing,
1487
- * oversized, not this version, for another scope, or malformed. Hydrating the
1488
- * same text twice yields equal stores.
1566
+ * A subscriber is woken only if the event changed a field it selects. A
1567
+ * change to an unselected field is skipped entirely.
1489
1568
  */
1490
- const hydrate = (raw, options = {}) => {
1491
- if (raw === void 0 || raw === null) return void 0;
1492
- if (exceeds(raw, options.maxBytes)) return void 0;
1493
- let parsed;
1494
- try {
1495
- parsed = JSON.parse(raw);
1496
- } catch {
1497
- return;
1498
- }
1499
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
1500
- const candidate = parsed;
1501
- if (candidate.version !== 3) return void 0;
1502
- if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
1503
- if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
1504
- if (Array.isArray(candidate.entities)) return void 0;
1505
- try {
1506
- return deserializeStore(candidate);
1507
- } catch {
1508
- return;
1509
- }
1510
- };
1511
- /**
1512
- * Brings a snapshot into a store that may already hold entries. `replace`
1513
- * takes the snapshot's entry for every key it has; `preserve-existing` keeps
1514
- * an entry the current store already has (it is at least as fresh as a
1515
- * snapshot taken earlier). Either way, keys only the current store has stay.
1516
- */
1517
- const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
1518
- ...current,
1519
- ...snapshot
1520
- } : {
1521
- ...snapshot,
1522
- ...current
1569
+ const shouldWake = (changed, selected) => changed.some((field) => selected.includes(field));
1570
+ const applyEntityEvent = (state, store, event, now = 0) => {
1571
+ const outcome = classifyLive(state, event.cursor);
1572
+ if (outcome !== "applied") return {
1573
+ state,
1574
+ store,
1575
+ outcome
1576
+ };
1577
+ const key = entityKey(event.ref.entity, event.ref.id);
1578
+ const next = event._tag === "EntityPatched" ? writeEntity(store, key, event.values, now) : tombstone(store, key);
1579
+ return {
1580
+ state: advance(state, event.cursor),
1581
+ store: next,
1582
+ outcome
1583
+ };
1523
1584
  };
1524
- const RemotePersistence = {
1525
- /** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
1526
- dehydrate,
1527
- /** The store a snapshot text holds, or `undefined` when it is refused. */
1528
- hydrate,
1529
- /** A snapshot brought into a store by policy. */
1530
- mergeStores,
1531
- /**
1532
- * Writes the snapshot under `key`, or removes the key when the store would
1533
- * exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
1534
- * store it no longer describes.
1535
- */
1536
- save: (store, options) => Effect.gen(function* () {
1537
- const kv = yield* KeyValueStore.KeyValueStore;
1538
- const text = dehydrate(store, options);
1539
- if (text === void 0) yield* kv.remove(options.key);
1540
- else yield* kv.set(options.key, text);
1541
- }),
1542
- /**
1543
- * Reads the cache. A missing key yields `emptyStore`; anything `hydrate`
1544
- * refuses yields `emptyStore` and removes the key, so the next `restore` is
1545
- * clean and the planner refetches.
1546
- */
1547
- restore: (options) => Effect.gen(function* () {
1548
- const kv = yield* KeyValueStore.KeyValueStore;
1549
- const raw = yield* kv.get(options.key);
1550
- if (raw === void 0) return emptyStore;
1551
- const restored = hydrate(raw, options);
1552
- if (restored === void 0) {
1553
- yield* kv.remove(options.key);
1554
- return emptyStore;
1555
- }
1556
- return restored;
1585
+ const removeEdgeOverlays = (optimistic, connection, key) => ({
1586
+ ...optimistic,
1587
+ overlays: optimistic.overlays.map((overlay) => overlay.connection !== connection || overlay.position === "remove" ? overlay : {
1588
+ ...overlay,
1589
+ edges: overlay.edges.filter((edge) => edge.key !== key)
1557
1590
  })
1558
- };
1559
- //#endregion
1560
- //#region src/remoteData.ts
1561
- /** `RemoteData`: the state of a value the store may not hold yet. */
1562
- const remoteErrorSchema = Schema.Struct({
1563
- _tag: Schema.String,
1564
- message: Schema.String
1565
1591
  });
1566
- /** A `RemoteData` schema, so a projection that reads remote state is typed. */
1567
- const remoteDataSchemas = /* @__PURE__ */ new WeakMap();
1568
- const remoteDataSchema = (value) => {
1569
- const cached = remoteDataSchemas.get(value);
1570
- if (cached !== void 0) return cached;
1571
- const built = Schema.Union([
1572
- Schema.Struct({ _tag: Schema.Literal("Initial") }),
1573
- Schema.Struct({ _tag: Schema.Literal("Loading") }),
1574
- Schema.Struct({
1575
- _tag: Schema.Literal("Ready"),
1576
- value
1577
- }),
1578
- Schema.Struct({
1579
- _tag: Schema.Literal("Refreshing"),
1580
- value
1581
- }),
1582
- Schema.Struct({
1583
- _tag: Schema.Literal("Failed"),
1584
- error: remoteErrorSchema,
1585
- previous: Schema.optional(value)
1586
- }),
1587
- Schema.Struct({ _tag: Schema.Literal("NotFound") })
1588
- ]);
1589
- remoteDataSchemas.set(value, built);
1590
- return built;
1591
- };
1592
- const RemoteData = {
1593
- /**
1594
- * The `RemoteData` schema for a value schema. Exposed so a `RemoteData` can
1595
- * be embedded in a hand-written Model schema, not only through `Remote.select`.
1596
- */
1597
- schema: (value) => remoteDataSchema(value),
1598
- /** Exhaustive: omitting a state is a compile error. */
1599
- match: (data, cases) => {
1600
- switch (data._tag) {
1601
- case "Initial": return cases.Initial();
1602
- case "Loading": return cases.Loading();
1603
- case "Ready": return cases.Ready(data.value);
1604
- case "Refreshing": return cases.Refreshing(data.value);
1605
- case "Failed": return cases.Failed(data.error, data.previous === void 0 ? Option.none() : Option.some(data.previous));
1606
- case "NotFound": return cases.NotFound();
1607
- }
1608
- },
1609
- map: (data, f) => {
1610
- switch (data._tag) {
1611
- case "Ready": return {
1612
- _tag: "Ready",
1613
- value: f(data.value)
1592
+ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
1593
+ const outcome = classifyLive(state, event.cursor);
1594
+ if (outcome !== "applied") return {
1595
+ state,
1596
+ optimistic,
1597
+ outcome
1598
+ };
1599
+ switch (event._tag) {
1600
+ case "ConnectionInsert": switch ((event.position === "prepend" ? policy.prepend : policy.append) ?? "visible") {
1601
+ case "visible": return {
1602
+ state: advance(state, event.cursor),
1603
+ optimistic: addOverlay(optimistic, {
1604
+ id: `live:${event.cursor}`,
1605
+ connection: event.connection,
1606
+ edges: [event.edge],
1607
+ position: event.position
1608
+ }),
1609
+ outcome
1614
1610
  };
1615
- case "Refreshing": return {
1616
- _tag: "Refreshing",
1617
- value: f(data.value)
1611
+ case "boundary": return {
1612
+ state: recordBoundary(advance(state, event.cursor), event.connection, event.position),
1613
+ optimistic,
1614
+ outcome
1618
1615
  };
1619
- case "Failed": return data.previous === void 0 ? {
1620
- _tag: "Failed",
1621
- error: data.error
1622
- } : {
1623
- _tag: "Failed",
1624
- error: data.error,
1625
- previous: f(data.previous)
1616
+ case "invalidate": return {
1617
+ state: invalidateConnection(advance(state, event.cursor), event.connection),
1618
+ optimistic,
1619
+ outcome
1620
+ };
1621
+ case "ignore": return {
1622
+ state: advance(state, event.cursor),
1623
+ optimistic,
1624
+ outcome
1626
1625
  };
1627
- default: return data;
1628
1626
  }
1627
+ case "ConnectionRemove": return {
1628
+ state: advance(state, event.cursor),
1629
+ optimistic: addOverlay(removeEdgeOverlays(optimistic, event.connection, event.edge.key), {
1630
+ id: `live:${event.cursor}`,
1631
+ connection: event.connection,
1632
+ edges: [event.edge],
1633
+ position: "remove"
1634
+ }),
1635
+ outcome
1636
+ };
1637
+ case "ConnectionInvalidate": return {
1638
+ state: invalidateConnection(advance(state, event.cursor), event.connection),
1639
+ optimistic,
1640
+ outcome
1641
+ };
1629
1642
  }
1630
1643
  };
1631
- //#endregion
1632
- //#region src/model.ts
1644
+ const isStale = (state, connection) => state.stale.has(connection);
1633
1645
  /**
1634
- * The Remote submodel: the store, connections, mutations, live state, and
1635
- * optimistic layers an application keeps in its Model, the Messages that
1636
- * change it, and the pure reducer over them.
1646
+ * Clears a connection's stale mark once a fresh page has been adopted. An
1647
+ * invalidating event only records that a refetch is due; whoever merges the
1648
+ * refetched page calls this so `isStale` stops reporting.
1637
1649
  */
1638
- const initialRemoteModel = {
1639
- entities: emptyStore,
1640
- connections: {},
1641
- optimistic: emptyOptimistic,
1642
- live: {},
1643
- mutations: emptyMutationState,
1644
- gaps: /* @__PURE__ */ new Set(),
1645
- loading: /* @__PURE__ */ new Set()
1650
+ const refreshConnection = (state, connection) => {
1651
+ if (!state.stale.has(connection)) return state;
1652
+ const stale = new Set(state.stale);
1653
+ stale.delete(connection);
1654
+ return {
1655
+ ...state,
1656
+ stale
1657
+ };
1646
1658
  };
1647
- /** The entity store and the mutation ledger are runtime values, not wire shapes. */
1648
- const runtimeSchema = Schema.Unknown;
1649
- const remoteModelSchema = () => Schema.Struct({
1650
- entities: runtimeSchema,
1651
- connections: Schema.Record(Schema.String, runtimeSchema),
1652
- optimistic: runtimeSchema,
1653
- live: Schema.Record(Schema.String, runtimeSchema),
1654
- mutations: runtimeSchema,
1655
- gaps: runtimeSchema,
1656
- loading: runtimeSchema
1657
- });
1658
- const retentionRootsSchema = Schema.Struct({
1659
- requirements: Schema.Array(ReadRequest),
1660
- connections: Schema.Array(Schema.Struct({
1661
- identity: Schema.String,
1662
- select: Schema.optional(RelationRequest)
1663
- }))
1664
- });
1659
+ /** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
1660
+ const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
1661
+ /** `hasNext` accounting for edges recorded outside the loaded boundary. */
1662
+ const liveHasNext = (connection, state, connectionId) => hasNext(connection) || (state.boundary[connectionId]?.after ?? 0) > 0;
1663
+ //#endregion
1664
+ //#region src/plan.ts
1665
1665
  /**
1666
- * The Messages' fields by tag, without `_tag`: what `defineMessageUnion` takes,
1667
- * so an application spreads them into its own union (`Remote.messages`).
1666
+ * The pure requirement planner.
1667
+ *
1668
+ * A `Requirement` is plain data: which entity fields a Remote projection needs,
1669
+ * and through `relations`, which fields of each relation's target. `plan` diffs
1670
+ * requirements against the store and returns only the missing fields, grouped
1671
+ * and deterministically ordered. A relation whose field is being fetched keeps
1672
+ * its nested requirement on the request, so the server resolves the graph in
1673
+ * one read; a relation whose refs the store already holds is followed into
1674
+ * concrete requirements for the targets. Time enters through `PlanFreshness`,
1675
+ * never from ambient state; `force` plans every field.
1668
1676
  */
1669
- const remoteMessageCases = {
1670
- ReadReceived: {
1671
- requests: Schema.Array(ReadRequest),
1672
- result: ReadBatchResult,
1673
- now: Schema.Number
1674
- },
1675
- ReadFailed: {
1676
- requests: Schema.Array(ReadRequest),
1677
- error: remoteErrorSchema
1678
- },
1679
- RefreshStarted: { requests: Schema.Array(ReadRequest) },
1680
- ReadStarted: { requests: Schema.Array(ReadRequest) },
1681
- RetentionChanged: { roots: retentionRootsSchema },
1682
- Hydrated: {
1683
- entities: runtimeSchema,
1684
- merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
1685
- },
1686
- MutationStarted: {
1687
- requestId: Schema.String,
1688
- optimistic: Schema.optional(Schema.Array(Schema.Unknown))
1689
- },
1690
- MutationSucceeded: {
1691
- requestId: Schema.String,
1692
- entities: Schema.Array(NormalizedEntity),
1693
- connections: Schema.optional(Schema.Array(Schema.Unknown))
1694
- },
1695
- MutationFailed: {
1696
- requestId: Schema.String,
1697
- error: remoteErrorSchema
1698
- },
1699
- LiveReceived: {
1700
- stream: Schema.String,
1701
- event: Schema.Unknown,
1702
- policy: Schema.optional(Schema.Unknown),
1703
- now: Schema.Number
1704
- },
1705
- GapCleared: { stream: Schema.String },
1706
- ConnectionMerged: {
1707
- connection: Schema.String,
1708
- page: Schema.Unknown,
1709
- refreshes: Schema.optional(Schema.Boolean)
1710
- },
1711
- ConnectionInvalidated: { connection: Schema.String },
1712
- ConnectionRefreshed: { connection: Schema.String },
1713
- QueryFailed: {
1714
- connection: Schema.String,
1715
- error: remoteErrorSchema
1677
+ /** A stable key for a window, so two equal windows compare equal. */
1678
+ const windowKey = (window) => stableStringify(window);
1679
+ const pickFields = (record, fields) => {
1680
+ if (record === void 0) return void 0;
1681
+ const picked = Object.fromEntries(Object.entries(record).filter(([field]) => fields.has(field)));
1682
+ return Object.keys(picked).length === 0 ? void 0 : picked;
1683
+ };
1684
+ /** The concrete requirements a known relation value contributes for its targets. */
1685
+ const followRelation = (value, relation) => targetsOf(value, relation).map((ref) => ({
1686
+ ...relation,
1687
+ id: ref.id
1688
+ }));
1689
+ const plan = (store, requirements, options = {}) => {
1690
+ const { freshness, force = false } = options;
1691
+ const planned = [];
1692
+ const followed = [];
1693
+ for (const group of Requirement.merge(requirements)) {
1694
+ const key = entityKey(group.entity, group.id);
1695
+ const entry = store[key];
1696
+ const expired = freshness !== void 0 && entry !== void 0 && !entry.tombstone && freshness.now - entry.updatedAt > freshness.freshness;
1697
+ const windowKeys = Object.fromEntries(Object.entries(group.windows ?? {}).map(([field, window]) => [field, windowKey(window)]));
1698
+ const missing = force || expired ? group.fields : missingFields(store, key, group.fields, windowKeys);
1699
+ const missingSet = new Set(missing);
1700
+ for (const [field, relation] of Object.entries(group.relations ?? {})) {
1701
+ if (missingSet.has(field)) continue;
1702
+ const value = readField(store, key, field);
1703
+ if (value._tag === "Some") followed.push(...followRelation(value.value, relation));
1704
+ }
1705
+ if (missing.length === 0) continue;
1706
+ const windows = pickFields(group.windows, missingSet);
1707
+ const relations = pickFields(group.relations, missingSet);
1708
+ planned.push({
1709
+ entity: group.entity,
1710
+ id: group.id,
1711
+ fields: missing,
1712
+ ...windows === void 0 ? {} : { windows },
1713
+ ...relations === void 0 ? {} : { relations }
1714
+ });
1716
1715
  }
1716
+ const nested = followed.length === 0 ? [] : plan(store, followed, options);
1717
+ return [...Requirement.merge([...planned, ...nested])].sort((a, b) => entityKey(a.entity, a.id) < entityKey(b.entity, b.id) ? -1 : 1);
1717
1718
  };
1719
+ //#endregion
1720
+ //#region src/retain.ts
1718
1721
  /**
1719
- * Whether a Message is one of Remote's, by tag. Narrows an application's union
1720
- * to its Remote cases, and its complement to the application's own.
1721
- */
1722
- const isRemoteMessage = (message) => Object.hasOwn(remoteMessageCases, message._tag);
1723
- const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(([tag, fields]) => Schema.Struct({
1724
- _tag: Schema.Literal(tag),
1725
- ...fields
1726
- })));
1727
- const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
1728
- /**
1729
- * One mark per requested field. A read is identified by what it asks for, not by
1730
- * a request id: two reads asking for one field share its mark, so the first
1731
- * answer clears it. Over-clearing shows `Initial` rather than a spinner, which
1732
- * is the safer way to be wrong.
1722
+ * The entity keys the roots reach: each root entity, the targets its retained
1723
+ * fields refer to, and recursively the targets a nested relation selects.
1733
1724
  */
1734
- const loadingMarks = function* (requests) {
1735
- for (const request of requests) {
1736
- const key = entityKey(request.entity, request.id);
1737
- for (const field of request.fields) yield `${key}\u0000${field}`;
1725
+ const reachable = (store, roots, connections, optimistic, pending) => {
1726
+ const kept = /* @__PURE__ */ new Set();
1727
+ const rootConnections = new Set(roots.connections.map((root) => root.identity));
1728
+ const walked = /* @__PURE__ */ new Map();
1729
+ const visit = (key, requirement) => {
1730
+ kept.add(key);
1731
+ const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
1732
+ if (seen.has(requirement)) return;
1733
+ seen.add(requirement);
1734
+ walked.set(key, seen);
1735
+ for (const field of requirement.fields) {
1736
+ const value = readField(store, key, field);
1737
+ if (value._tag === "None") continue;
1738
+ const relation = requirement.relations?.[field];
1739
+ for (const ref of refsIn(value.value)) {
1740
+ const target = entityKey(ref.entity, ref.id);
1741
+ if (relation === void 0 || ref.entity !== relation.entity) {
1742
+ kept.add(target);
1743
+ continue;
1744
+ }
1745
+ visit(target, relation);
1746
+ }
1747
+ }
1748
+ };
1749
+ for (const root of roots.requirements) visit(entityKey(root.entity, root.id), root);
1750
+ for (const { identity, select } of roots.connections) for (const segment of connections[identity]?.segments ?? []) for (const edge of segment.edges) {
1751
+ const target = entityKey(edge.ref.entity, edge.ref.id);
1752
+ if (select === void 0 || edge.ref.entity !== select.entity) kept.add(target);
1753
+ else visit(target, select);
1738
1754
  }
1739
- };
1740
- const withLoading = (loading, requests) => {
1741
- const next = new Set(loading);
1742
- for (const mark of loadingMarks(requests)) next.add(mark);
1743
- return next;
1744
- };
1745
- const withoutLoading = (loading, requests) => {
1746
- if (loading.size === 0) return loading;
1747
- const next = new Set(loading);
1748
- for (const mark of loadingMarks(requests)) next.delete(mark);
1749
- return next;
1755
+ for (const layer of optimistic.layers) {
1756
+ if (!pending.has(layer.id)) continue;
1757
+ for (const patch of layer.patches) kept.add(entityKey(patch.entity, patch.id));
1758
+ }
1759
+ for (const overlay of optimistic.overlays) {
1760
+ if (!pending.has(overlay.id) && !rootConnections.has(overlay.connection)) continue;
1761
+ for (const edge of overlay.edges) kept.add(entityKey(edge.ref.entity, edge.ref.id));
1762
+ }
1763
+ return kept;
1750
1764
  };
1751
1765
  /**
1752
- * Whether a read is fetching any of `fields` for this entity. Consulted only
1753
- * when the store lacks the value, which is what separates `Loading` from
1754
- * `Initial`.
1766
+ * Drops every entity the roots do not reach, every connection they do not
1767
+ * name, and every settled overlay on a dropped connection, keeping whatever a
1768
+ * pending request's layer or overlays touch. Pure.
1755
1769
  */
1756
- const isLoading = (model, entity, id, fields) => {
1757
- if (model.loading.size === 0) return false;
1758
- const key = entityKey(entity, id);
1759
- return fields.some((field) => model.loading.has(`${key}\u0000${field}`));
1760
- };
1761
- const setConnectionStale = (connections, connection, stale) => {
1762
- const current = connections[connection] ?? emptyConnection;
1770
+ const gc = (state, roots) => {
1771
+ const pending = state.mutations.pending;
1772
+ const kept = reachable(state.entities, roots, state.connections, state.optimistic, pending);
1773
+ const entities = Object.fromEntries(Object.entries(state.entities).filter(([key]) => kept.has(key)));
1774
+ const keptConnections = /* @__PURE__ */ new Set([...roots.connections.map((root) => root.identity), ...state.optimistic.overlays.filter((overlay) => pending.has(overlay.id)).map((overlay) => overlay.connection)]);
1763
1775
  return {
1764
- ...connections,
1765
- [connection]: {
1766
- ...current,
1767
- stale
1776
+ entities,
1777
+ connections: Object.fromEntries(Object.entries(state.connections).filter(([identity]) => keptConnections.has(identity))),
1778
+ optimistic: {
1779
+ layers: state.optimistic.layers,
1780
+ overlays: state.optimistic.overlays.filter((overlay) => pending.has(overlay.id) || keptConnections.has(overlay.connection))
1768
1781
  }
1769
1782
  };
1770
1783
  };
1771
- const markGap = (model, stream) => model.gaps.has(stream) ? model : {
1772
- ...model,
1773
- gaps: /* @__PURE__ */ new Set([...model.gaps, stream])
1784
+ //#endregion
1785
+ //#region src/persistence.ts
1786
+ /**
1787
+ * Remote cache persistence: a snapshot of the entity store and nothing else.
1788
+ * Runtime state (connections' live cursors, optimistic layers, the mutation
1789
+ * ledger, gaps, retention roots) is never in a snapshot; it belongs to the
1790
+ * session that produced it.
1791
+ *
1792
+ * The cache is server-derived and disposable: an incompatible, oversized, or
1793
+ * corrupt snapshot is discarded (and removed from a `KeyValueStore`) so the
1794
+ * planner refetches. `dehydrate`/`hydrate` are the string forms, for SSR and
1795
+ * any other transport; `save`/`restore` put them behind Effect's
1796
+ * `KeyValueStore`, so the backend (memory, filesystem, SQL, Web Storage) is the
1797
+ * application's choice.
1798
+ */
1799
+ /** Bump when the serialized shape changes; a mismatch discards the cache. */
1800
+ const REMOTE_CACHE_VERSION = 3;
1801
+ const sorted = (values) => [...values].sort();
1802
+ /**
1803
+ * The snapshot's data shape. `stableStringify` sorts object keys, and the
1804
+ * field sets are sorted here, so equal stores give byte-equal snapshots
1805
+ * whatever order they were built in.
1806
+ */
1807
+ const serializeStore = (store, scope) => ({
1808
+ version: 3,
1809
+ scope: scope ?? null,
1810
+ entities: Object.fromEntries(Object.entries(store).map(([key, entry]) => [key, {
1811
+ values: entry.values,
1812
+ present: sorted(entry.present),
1813
+ stale: sorted(entry.stale),
1814
+ tombstone: entry.tombstone,
1815
+ updatedAt: entry.updatedAt,
1816
+ windows: entry.windows
1817
+ }]))
1818
+ });
1819
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
1820
+ const isStringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
1821
+ /** Throws on a malformed entry so the whole snapshot is discarded. */
1822
+ const parseEntry = (value) => {
1823
+ if (value === null || typeof value !== "object") throw new Error("entry is not an object");
1824
+ const entry = value;
1825
+ if (entry.values === null || typeof entry.values !== "object" || Array.isArray(entry.values)) throw new Error("entry.values is not a record");
1826
+ if (!isStringArray(entry.present)) throw new Error("entry.present is not a string array");
1827
+ if (!isStringArray(entry.stale)) throw new Error("entry.stale is not a string array");
1828
+ if (typeof entry.tombstone !== "boolean") throw new Error("entry.tombstone is not a boolean");
1829
+ if (typeof entry.updatedAt !== "number") throw new Error("entry.updatedAt is not a number");
1830
+ if (!isStringRecord(entry.windows)) throw new Error("entry.windows is not a string record");
1831
+ return {
1832
+ values: entry.values,
1833
+ present: new Set(entry.present),
1834
+ stale: new Set(entry.stale),
1835
+ tombstone: entry.tombstone,
1836
+ updatedAt: entry.updatedAt,
1837
+ windows: entry.windows
1838
+ };
1774
1839
  };
1775
- const clearGap = (model, stream) => model.gaps.has(stream) ? {
1776
- ...model,
1777
- gaps: new Set([...model.gaps].filter((entry) => entry !== stream))
1778
- } : model;
1840
+ const deserializeStore = (serialized) => Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)]));
1841
+ function dehydrate(store, options = {}) {
1842
+ const text = stableStringify(serializeStore(store, options.scope));
1843
+ return exceeds(text, options.maxBytes) ? void 0 : text;
1844
+ }
1779
1845
  /**
1780
- * The pure reducer all four producers share. A live event that arrives ahead of
1781
- * its cursor is a gap: it is not applied, and the stream is recorded so the host
1782
- * can resubscribe rather than silently miss facts.
1846
+ * Whether the UTF-8 size of `text` exceeds `maxBytes`. A character is one to
1847
+ * three bytes, so most texts are decided from their length alone; only the
1848
+ * rest are measured.
1783
1849
  */
1784
- const updateRemote = (model, message) => {
1785
- switch (message._tag) {
1786
- case "ReadReceived": return {
1787
- ...model,
1788
- entities: writeRead(model.entities, message.requests, message.result, message.now),
1789
- loading: withoutLoading(model.loading, message.requests)
1790
- };
1791
- case "ReadFailed": return {
1792
- ...model,
1793
- entities: setStale(model.entities, marksOf(message.requests), false),
1794
- loading: withoutLoading(model.loading, message.requests)
1795
- };
1796
- case "RetentionChanged": return {
1797
- ...model,
1798
- ...gc(model, message.roots)
1799
- };
1800
- case "Hydrated": return {
1801
- ...model,
1802
- entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
1803
- };
1804
- case "RefreshStarted": return {
1805
- ...model,
1806
- entities: setStale(model.entities, marksOf(message.requests), true)
1807
- };
1808
- case "ReadStarted": return {
1809
- ...model,
1810
- loading: withLoading(model.loading, message.requests)
1811
- };
1812
- case "MutationStarted": return {
1813
- ...model,
1814
- mutations: beginMutation(model.mutations, message.requestId),
1815
- optimistic: beginOptimistic(model.optimistic, message.requestId, message.optimistic ?? [])
1816
- };
1817
- case "MutationSucceeded": {
1818
- const settled = settleSuccess(model.entities, model.optimistic, model.mutations, message.requestId, message.entities, message.connections ?? []);
1819
- return {
1820
- ...model,
1821
- entities: settled.store,
1822
- mutations: settled.state,
1823
- optimistic: settled.optimistic
1824
- };
1825
- }
1826
- case "MutationFailed": return {
1827
- ...model,
1828
- mutations: failMutation(model.mutations, message.requestId),
1829
- optimistic: settleFailure(model.optimistic, message.requestId)
1830
- };
1831
- case "LiveReceived": {
1832
- const state = model.live[message.stream] ?? emptyLiveState;
1833
- if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
1834
- const applied = applyEntityEvent(state, model.entities, message.event, message.now);
1835
- return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
1836
- ...model,
1837
- entities: applied.store,
1838
- live: {
1839
- ...model.live,
1840
- [message.stream]: applied.state
1841
- }
1842
- }, message.stream);
1843
- }
1844
- const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
1845
- return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
1846
- ...model,
1847
- optimistic: applied.optimistic,
1848
- live: {
1849
- ...model.live,
1850
- [message.stream]: applied.state
1851
- }
1852
- }, message.stream);
1853
- }
1854
- case "GapCleared": return clearGap(model, message.stream);
1855
- case "ConnectionMerged": {
1856
- const current = model.connections[message.connection] ?? emptyConnection;
1857
- const merged = merge(current, message.page);
1858
- return {
1859
- ...model,
1860
- connections: {
1861
- ...model.connections,
1862
- [message.connection]: message.refreshes === true ? {
1863
- ...merged,
1864
- stale: false
1865
- } : merged
1866
- },
1867
- optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
1868
- };
1869
- }
1870
- case "ConnectionInvalidated": return {
1871
- ...model,
1872
- connections: setConnectionStale(model.connections, message.connection, true)
1873
- };
1874
- case "QueryFailed": return message.connection in model.connections ? {
1875
- ...model,
1876
- connections: setConnectionStale(model.connections, message.connection, false)
1877
- } : model;
1878
- case "ConnectionRefreshed": return {
1879
- ...model,
1880
- connections: setConnectionStale(model.connections, message.connection, false)
1881
- };
1882
- }
1883
- };
1884
- /** Appends (after) or prepends (before) an incoming page onto the stored one. */
1885
- const mergeWireRefPages = (current, next, direction) => {
1886
- const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
1887
- return direction === "after" ? {
1888
- refs,
1889
- hasNext: next.hasNext,
1890
- hasPrevious: current.hasPrevious
1891
- } : {
1892
- refs,
1893
- hasNext: current.hasNext,
1894
- hasPrevious: next.hasPrevious
1895
- };
1850
+ const exceeds = (text, maxBytes) => {
1851
+ if (maxBytes === void 0) return false;
1852
+ if (text.length > maxBytes) return true;
1853
+ if (text.length * 3 <= maxBytes) return false;
1854
+ return new TextEncoder().encode(text).length > maxBytes;
1896
1855
  };
1897
1856
  /**
1898
- * Writes a read result into the store, recording each field's applied window so
1899
- * a later request with a different window refetches. A relation page requested
1900
- * with a cursor is merged onto the page already stored, so "load more"
1901
- * accumulates rather than replaces. Every read path should use this rather than
1902
- * reducing `writeEntity` by hand.
1857
+ * The store a snapshot text holds, or `undefined` when the text is missing,
1858
+ * oversized, not this version, for another scope, or malformed. Hydrating the
1859
+ * same text twice yields equal stores.
1903
1860
  */
1904
- const writeRead = (store, requests, result, now = 0) => {
1905
- const returned = /* @__PURE__ */ new Map();
1906
- for (const entity of result.entities) {
1907
- const key = entityKey(entity.entity, entity.id);
1908
- returned.set(key, {
1909
- ...returned.get(key),
1910
- ...entity.values
1911
- });
1861
+ const hydrate = (raw, options = {}) => {
1862
+ if (raw === void 0 || raw === null) return void 0;
1863
+ if (exceeds(raw, options.maxBytes)) return void 0;
1864
+ let parsed;
1865
+ try {
1866
+ parsed = JSON.parse(raw);
1867
+ } catch {
1868
+ return;
1912
1869
  }
1913
- const byEntity = /* @__PURE__ */ new Map();
1914
- const record = (key, windows, relations) => {
1915
- let entry = byEntity.get(key);
1916
- if (entry === void 0) {
1917
- entry = {
1918
- windows: {},
1919
- merge: /* @__PURE__ */ new Map()
1920
- };
1921
- byEntity.set(key, entry);
1870
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
1871
+ const candidate = parsed;
1872
+ if (candidate.version !== 3) return void 0;
1873
+ if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
1874
+ if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
1875
+ if (Array.isArray(candidate.entities)) return void 0;
1876
+ try {
1877
+ return deserializeStore(candidate);
1878
+ } catch {
1879
+ return;
1880
+ }
1881
+ };
1882
+ /**
1883
+ * Brings a snapshot into a store that may already hold entries. `replace`
1884
+ * takes the snapshot's entry for every key it has; `preserve-existing` keeps
1885
+ * an entry the current store already has (it is at least as fresh as a
1886
+ * snapshot taken earlier). Either way, keys only the current store has stay.
1887
+ */
1888
+ const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
1889
+ ...current,
1890
+ ...snapshot
1891
+ } : {
1892
+ ...snapshot,
1893
+ ...current
1894
+ };
1895
+ const RemotePersistence = {
1896
+ /** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
1897
+ dehydrate,
1898
+ /** The store a snapshot text holds, or `undefined` when it is refused. */
1899
+ hydrate,
1900
+ /** A snapshot brought into a store by policy. */
1901
+ mergeStores,
1902
+ /**
1903
+ * Writes the snapshot under `key`, or removes the key when the store would
1904
+ * exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
1905
+ * store it no longer describes.
1906
+ */
1907
+ save: (store, options) => Effect.gen(function* () {
1908
+ const kv = yield* KeyValueStore.KeyValueStore;
1909
+ const text = dehydrate(store, options);
1910
+ if (text === void 0) yield* kv.remove(options.key);
1911
+ else yield* kv.set(options.key, text);
1912
+ }),
1913
+ /**
1914
+ * Reads the cache. A missing key yields `emptyStore`; anything `hydrate`
1915
+ * refuses yields `emptyStore` and removes the key, so the next `restore` is
1916
+ * clean and the planner refetches.
1917
+ */
1918
+ restore: (options) => Effect.gen(function* () {
1919
+ const kv = yield* KeyValueStore.KeyValueStore;
1920
+ const raw = yield* kv.get(options.key);
1921
+ if (raw === void 0) return emptyStore;
1922
+ const restored = hydrate(raw, options);
1923
+ if (restored === void 0) {
1924
+ yield* kv.remove(options.key);
1925
+ return emptyStore;
1922
1926
  }
1923
- for (const [field, window] of Object.entries(windows ?? {})) {
1924
- entry.windows[field] = windowKey(window);
1925
- const direction = window.after !== void 0 ? "after" : window.before !== void 0 ? "before" : void 0;
1926
- if (direction !== void 0) entry.merge.set(field, direction);
1927
+ return restored;
1928
+ })
1929
+ };
1930
+ //#endregion
1931
+ //#region src/remoteData.ts
1932
+ /** `RemoteData`: the state of a value the store may not hold yet. */
1933
+ const remoteErrorSchema = Schema.Struct({
1934
+ _tag: Schema.String,
1935
+ message: Schema.String
1936
+ });
1937
+ /** A `RemoteData` schema, so a projection that reads remote state is typed. */
1938
+ const remoteDataSchemas = /* @__PURE__ */ new WeakMap();
1939
+ const remoteDataSchema = (value) => {
1940
+ const cached = remoteDataSchemas.get(value);
1941
+ if (cached !== void 0) return cached;
1942
+ const built = Schema.Union([
1943
+ Schema.Struct({ _tag: Schema.Literal("Initial") }),
1944
+ Schema.Struct({ _tag: Schema.Literal("Loading") }),
1945
+ Schema.Struct({
1946
+ _tag: Schema.Literal("Ready"),
1947
+ value
1948
+ }),
1949
+ Schema.Struct({
1950
+ _tag: Schema.Literal("Refreshing"),
1951
+ value
1952
+ }),
1953
+ Schema.Struct({
1954
+ _tag: Schema.Literal("Failed"),
1955
+ error: remoteErrorSchema,
1956
+ previous: Schema.optional(value)
1957
+ }),
1958
+ Schema.Struct({ _tag: Schema.Literal("NotFound") })
1959
+ ]);
1960
+ remoteDataSchemas.set(value, built);
1961
+ return built;
1962
+ };
1963
+ const RemoteData = {
1964
+ /**
1965
+ * The `RemoteData` schema for a value schema. Exposed so a `RemoteData` can
1966
+ * be embedded in a hand-written Model schema, not only through `Remote.select`.
1967
+ */
1968
+ schema: (value) => remoteDataSchema(value),
1969
+ /** Exhaustive: omitting a state is a compile error. */
1970
+ match: (data, cases) => {
1971
+ switch (data._tag) {
1972
+ case "Initial": return cases.Initial();
1973
+ case "Loading": return cases.Loading();
1974
+ case "Ready": return cases.Ready(data.value);
1975
+ case "Refreshing": return cases.Refreshing(data.value);
1976
+ case "Failed": return cases.Failed(data.error, data.previous === void 0 ? Option.none() : Option.some(data.previous));
1977
+ case "NotFound": return cases.NotFound();
1927
1978
  }
1928
- const values = returned.get(key);
1929
- if (values === void 0) return;
1930
- for (const [field, relation] of Object.entries(relations ?? {})) for (const ref of targetsOf(values[field], relation)) record(entityKey(ref.entity, ref.id), relation.windows, relation.relations);
1931
- };
1932
- for (const request of requests) record(entityKey(request.entity, request.id), request.windows, request.relations);
1933
- const writes = [];
1934
- const pending = /* @__PURE__ */ new Map();
1935
- for (const entity of result.entities) {
1936
- const key = entityKey(entity.entity, entity.id);
1937
- const entry = byEntity.get(key);
1938
- let values = entity.values;
1939
- if (entry !== void 0 && entry.merge.size > 0) {
1940
- const previous = pending.get(key) ?? store[key]?.values;
1941
- if (previous !== void 0) {
1942
- const merged = { ...values };
1943
- for (const [field, direction] of entry.merge) {
1944
- const incoming = values[field];
1945
- const existing = previous[field];
1946
- if (isRefPage(incoming) && isRefPage(existing)) merged[field] = mergeWireRefPages(existing, incoming, direction);
1947
- }
1948
- values = merged;
1949
- }
1979
+ },
1980
+ map: (data, f) => {
1981
+ switch (data._tag) {
1982
+ case "Ready": return {
1983
+ _tag: "Ready",
1984
+ value: f(data.value)
1985
+ };
1986
+ case "Refreshing": return {
1987
+ _tag: "Refreshing",
1988
+ value: f(data.value)
1989
+ };
1990
+ case "Failed": return data.previous === void 0 ? {
1991
+ _tag: "Failed",
1992
+ error: data.error
1993
+ } : {
1994
+ _tag: "Failed",
1995
+ error: data.error,
1996
+ previous: f(data.previous)
1997
+ };
1998
+ default: return data;
1950
1999
  }
1951
- pending.set(key, {
1952
- ...pending.get(key),
1953
- ...values
1954
- });
1955
- writes.push({
1956
- key,
1957
- values,
1958
- windows: entry?.windows
1959
- });
1960
2000
  }
1961
- return writeEntities(store, writes, now);
1962
2001
  };
1963
2002
  //#endregion
1964
- //#region src/policy.ts
1965
- const RemotePolicy = {
1966
- /** Fetch only what the store lacks. */
1967
- cacheFirst: { _tag: "CacheFirst" },
1968
- /** Keep present values visible, read as `Refreshing`, and refetch an entry older than `maxAge` ms. */
1969
- staleWhileRevalidate: (options) => ({
1970
- _tag: "StaleWhileRevalidate",
1971
- maxAge: options.maxAge
1972
- }),
1973
- /** Fetch every selected field regardless of coverage; cached values stay visible meanwhile. */
1974
- networkOnly: { _tag: "NetworkOnly" },
1975
- /** The planner options a policy compiles to at `now`. */
1976
- toPlan: (policy, now) => {
1977
- switch (policy._tag) {
1978
- case "CacheFirst": return {};
1979
- case "StaleWhileRevalidate": return { freshness: {
1980
- now,
1981
- freshness: policy.maxAge
1982
- } };
1983
- case "NetworkOnly": return { force: true };
1984
- }
2003
+ //#region src/model.ts
2004
+ /**
2005
+ * The Remote submodel: the store, connections, mutations, live state, and
2006
+ * optimistic layers an application keeps in its Model, the Messages that
2007
+ * change it, and the pure reducer over them.
2008
+ */
2009
+ const initialRemoteModel = {
2010
+ entities: emptyStore,
2011
+ connections: {},
2012
+ optimistic: emptyOptimistic,
2013
+ live: {},
2014
+ mutations: emptyMutationState,
2015
+ gaps: /* @__PURE__ */ new Set(),
2016
+ loading: /* @__PURE__ */ new Set(),
2017
+ refresh: {
2018
+ requested: 0,
2019
+ started: 0
2020
+ }
2021
+ };
2022
+ /** The entity store and the mutation ledger are runtime values, not wire shapes. */
2023
+ const runtimeSchema = Schema.Unknown;
2024
+ const remoteModelSchema = () => Schema.Struct({
2025
+ entities: runtimeSchema,
2026
+ connections: Schema.Record(Schema.String, runtimeSchema),
2027
+ optimistic: runtimeSchema,
2028
+ live: Schema.Record(Schema.String, runtimeSchema),
2029
+ mutations: runtimeSchema,
2030
+ gaps: runtimeSchema,
2031
+ loading: runtimeSchema,
2032
+ refresh: Schema.Struct({
2033
+ requested: Schema.Number,
2034
+ started: Schema.Number
2035
+ })
2036
+ });
2037
+ const retentionRootsSchema = Schema.Struct({
2038
+ requirements: Schema.Array(ReadRequest),
2039
+ connections: Schema.Array(Schema.Struct({
2040
+ identity: Schema.String,
2041
+ select: Schema.optional(RelationRequest)
2042
+ }))
2043
+ });
2044
+ /**
2045
+ * The Messages' fields by tag, without `_tag`: what `defineMessageUnion` takes,
2046
+ * so an application spreads them into its own union (`Remote.messages`).
2047
+ */
2048
+ const remoteMessageCases = {
2049
+ ReadReceived: {
2050
+ requests: Schema.Array(ReadRequest),
2051
+ result: ReadBatchResult,
2052
+ now: Schema.Number
2053
+ },
2054
+ ReadFailed: {
2055
+ requests: Schema.Array(ReadRequest),
2056
+ error: remoteErrorSchema
2057
+ },
2058
+ RefreshStarted: { requests: Schema.Array(ReadRequest) },
2059
+ ReadStarted: {
2060
+ requests: Schema.Array(ReadRequest),
2061
+ refresh: Schema.optional(Schema.Number)
2062
+ },
2063
+ RetentionChanged: { roots: retentionRootsSchema },
2064
+ Hydrated: {
2065
+ entities: runtimeSchema,
2066
+ merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
2067
+ },
2068
+ MutationStarted: {
2069
+ requestId: Schema.String,
2070
+ optimistic: Schema.optional(Schema.Array(Schema.Unknown))
2071
+ },
2072
+ MutationSucceeded: {
2073
+ requestId: Schema.String,
2074
+ entities: Schema.Array(NormalizedEntity),
2075
+ connections: Schema.optional(Schema.Array(Schema.Unknown)),
2076
+ deleted: Schema.optional(Schema.Array(Schema.Struct({
2077
+ entity: Schema.String,
2078
+ id: Schema.String
2079
+ })))
2080
+ },
2081
+ MutationFailed: {
2082
+ requestId: Schema.String,
2083
+ error: remoteErrorSchema
2084
+ },
2085
+ LiveReceived: {
2086
+ stream: Schema.String,
2087
+ event: Schema.Unknown,
2088
+ policy: Schema.optional(Schema.Unknown),
2089
+ now: Schema.Number
2090
+ },
2091
+ GapCleared: { stream: Schema.String },
2092
+ ConnectionMerged: {
2093
+ connection: Schema.String,
2094
+ page: Schema.Unknown,
2095
+ refreshes: Schema.optional(Schema.Boolean)
1985
2096
  },
1986
- /**
1987
- * Whether a read under this policy may refetch fields the store already
1988
- * holds, so an observer marks them stale (read as `Refreshing`) while the
1989
- * request is pending.
1990
- */
1991
- refreshes: (policy) => policy._tag !== "CacheFirst"
2097
+ ConnectionInvalidated: { connection: Schema.String },
2098
+ ConnectionRefreshed: { connection: Schema.String },
2099
+ QueryFailed: {
2100
+ connection: Schema.String,
2101
+ error: remoteErrorSchema
2102
+ }
1992
2103
  };
1993
- //#endregion
1994
- //#region src/entity.ts
1995
2104
  /**
1996
- * Entities: the descriptor an application declares, refs, ref pages, and
1997
- * patches, with the codecs that carry refs on the wire.
2105
+ * Whether a Message is one of Remote's, by tag. Narrows an application's union
2106
+ * to its Remote cases, and its complement to the application's own.
1998
2107
  */
2108
+ const isRemoteMessage = (message) => Object.hasOwn(remoteMessageCases, message._tag);
2109
+ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(([tag, fields]) => Schema.Struct({
2110
+ _tag: Schema.Literal(tag),
2111
+ ...fields
2112
+ })));
2113
+ const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
1999
2114
  /**
2000
- * Relations are **references**, never inline target schemas: a ref cannot
2001
- * reconstruct a full entity, and dereferencing is a store concern. Because the
2002
- * target schema is not inlined, recursive relations cannot arise through the
2003
- * schema graph.
2115
+ * One mark per requested field. A read is identified by what it asks for, not by
2116
+ * a request id: two reads asking for one field share its mark, so the first
2117
+ * answer clears it. Over-clearing shows `Initial` rather than a spinner, which
2118
+ * is the safer way to be wrong.
2004
2119
  */
2005
- const refCodec = (entity) => Schema.Struct({
2006
- entity: Schema.String,
2007
- id: Schema.String
2008
- }).pipe(Schema.encodeTo(Schema.String, {
2009
- decode: SchemaGetter.transform(refParts),
2010
- encode: SchemaGetter.transform((ref) => entityKey(ref.entity, ref.id))
2011
- })).annotate({
2012
- [RelationAnnotation]: "one",
2013
- [RelationEntityAnnotation]: entity
2014
- });
2015
- const refPageCodec = (entity) => Schema.Struct({
2016
- refs: Schema.Array(refCodec(entity)),
2017
- hasNext: Schema.Boolean,
2018
- hasPrevious: Schema.Boolean
2019
- }).annotate({
2020
- [RelationAnnotation]: "page",
2021
- [RelationEntityAnnotation]: entity
2022
- });
2023
- const Entity = {
2024
- make: (name, schema) => {
2025
- const descriptor = {
2026
- name,
2027
- schema,
2028
- fields: schema.fields,
2029
- ref: (id) => ({
2030
- entity: name,
2031
- id: String(id)
2032
- }),
2033
- select: (selection) => Selection.make(descriptor, selection),
2034
- patch: (id, values) => ({
2035
- entity: name,
2036
- id: String(id),
2037
- values
2038
- })
2039
- };
2040
- return descriptor;
2041
- },
2042
- /** A relation to a known entity, decoded as a reference. */
2043
- ref: (entity) => refCodec(entity.name),
2044
- /** A relation by name, for recursive or forward references. */
2045
- refTo: (name) => refCodec(name),
2046
- /**
2047
- * The wire key a relation field carries (`"Entity:id"`). Adapters that read a
2048
- * foreign key emit this so the ref codec can decode it on the client.
2049
- */
2050
- refKey: (ref) => entityKey(ref.entity, ref.id),
2051
- /** Splits a ref key back into its entity and id. */
2052
- refParts,
2053
- /** A relation value of one authoritative page of refs. */
2054
- refPage: (entity) => refPageCodec(entity.name),
2055
- /**
2056
- * A normalized patch for an entity, typed against its fields' wire shape:
2057
- * write a relation as its ref key (`Entity.refKey`), as the server does.
2058
- */
2059
- patch: (ref, patch) => ({
2060
- entity: ref.entity,
2061
- id: ref.id,
2062
- values: patch
2063
- })
2120
+ const loadingMarks = function* (requests) {
2121
+ for (const request of requests) {
2122
+ const key = entityKey(request.entity, request.id);
2123
+ for (const field of request.fields) yield `${key}\u0000${field}`;
2124
+ }
2125
+ };
2126
+ const withLoading = (loading, requests) => {
2127
+ const next = new Set(loading);
2128
+ for (const mark of loadingMarks(requests)) next.add(mark);
2129
+ return next;
2130
+ };
2131
+ const withoutLoading = (loading, requests) => {
2132
+ if (loading.size === 0) return loading;
2133
+ const next = new Set(loading);
2134
+ for (const mark of loadingMarks(requests)) next.delete(mark);
2135
+ return next;
2064
2136
  };
2065
- //#endregion
2066
- //#region src/selection.ts
2067
2137
  /**
2068
- * Selections: the fields a Surface picks of an entity (nested through
2069
- * relations, paged through connections), the requirement graph they imply,
2070
- * and their assembly from the normalized store.
2138
+ * Whether a read is fetching any of `fields` for this entity. Consulted only
2139
+ * when the store lacks the value, which is what separates `Loading` from
2140
+ * `Initial`.
2071
2141
  */
2072
- const pageSchemas = /* @__PURE__ */ new WeakMap();
2073
- const pageSchema = (item) => {
2074
- const cached = pageSchemas.get(item);
2075
- if (cached !== void 0) return cached;
2076
- const built = Schema.Struct({
2077
- items: Schema.Array(item),
2078
- hasNext: Schema.Boolean,
2079
- hasPrevious: Schema.Boolean
2080
- });
2081
- pageSchemas.set(item, built);
2082
- return built;
2142
+ const isLoading = (model, entity, id, fields) => {
2143
+ if (model.loading.size === 0) return false;
2144
+ const key = entityKey(entity, id);
2145
+ return fields.some((field) => model.loading.has(`${key}\u0000${field}`));
2083
2146
  };
2084
- /** The requirement a nested selection contributes for its relation's target. */
2085
- const relationOf = (selection) => ({
2086
- entity: selection.entity,
2087
- fields: selection.fields,
2088
- ...selection.connections === void 0 ? {} : { windows: selection.connections },
2089
- ...selection.relations === void 0 ? {} : { relations: selection.relations }
2090
- });
2091
- const Selection = {
2092
- /**
2093
- * The fields to read of `entity`. A nested `Selection` on a relation field
2094
- * reads through the ref (or refs, or page of refs) the field holds into the
2095
- * target's fields; a scalar field cannot take one.
2096
- */
2097
- make: (entity, selection) => {
2098
- if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
2099
- const picked = {};
2100
- const connections = {};
2101
- const relations = {};
2102
- for (const key of Object.keys(selection)) {
2103
- const choice = selection[key];
2104
- if (choice === true) {
2105
- picked[key] = entity.fields[key];
2106
- continue;
2107
- }
2108
- const nested = choice;
2109
- const shape = relationShape(entity.fields[key]);
2110
- if (shape === void 0) throw new Error(`Selection.make: "${key}" on "${entity.name}" is not a relation field, so it cannot take a nested selection`);
2111
- if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
2112
- const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
2113
- if (nested.window !== void 0) {
2114
- connections[key] = nested.window;
2115
- picked[key] = nullable(nested.schema);
2116
- if (nested.fields.length > 0) relations[key] = relationOf(nested);
2117
- continue;
2147
+ const setConnectionStale = (connections, connection, stale) => {
2148
+ const current = connections[connection] ?? emptyConnection;
2149
+ return {
2150
+ ...connections,
2151
+ [connection]: {
2152
+ ...current,
2153
+ stale
2154
+ }
2155
+ };
2156
+ };
2157
+ const markGap = (model, stream) => model.gaps.has(stream) ? model : {
2158
+ ...model,
2159
+ gaps: /* @__PURE__ */ new Set([...model.gaps, stream])
2160
+ };
2161
+ const clearGap = (model, stream) => model.gaps.has(stream) ? {
2162
+ ...model,
2163
+ gaps: new Set([...model.gaps].filter((entry) => entry !== stream))
2164
+ } : model;
2165
+ /**
2166
+ * The pure reducer all four producers share. A live event that arrives ahead of
2167
+ * its cursor is a gap: it is not applied, and the stream is recorded so the host
2168
+ * can resubscribe rather than silently miss facts.
2169
+ */
2170
+ const updateRemote = (model, message) => {
2171
+ switch (message._tag) {
2172
+ case "ReadReceived": return {
2173
+ ...model,
2174
+ entities: writeRead(model.entities, message.requests, message.result, message.now),
2175
+ loading: withoutLoading(model.loading, message.requests)
2176
+ };
2177
+ case "ReadFailed": return {
2178
+ ...model,
2179
+ entities: setStale(model.entities, marksOf(message.requests), false),
2180
+ loading: withoutLoading(model.loading, message.requests)
2181
+ };
2182
+ case "RetentionChanged": return {
2183
+ ...model,
2184
+ ...gc(model, message.roots)
2185
+ };
2186
+ case "Hydrated": return {
2187
+ ...model,
2188
+ entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
2189
+ };
2190
+ case "RefreshStarted": {
2191
+ const entities = setStale(model.entities, marksOf(message.requests), true);
2192
+ return entities === model.entities ? model : {
2193
+ ...model,
2194
+ entities
2195
+ };
2196
+ }
2197
+ case "ReadStarted": {
2198
+ const started = Math.max(model.refresh.started, message.refresh ?? 0);
2199
+ return {
2200
+ ...model,
2201
+ loading: withLoading(model.loading, message.requests),
2202
+ refresh: started === model.refresh.started ? model.refresh : {
2203
+ ...model.refresh,
2204
+ started
2205
+ }
2206
+ };
2207
+ }
2208
+ case "MutationStarted": return {
2209
+ ...model,
2210
+ mutations: beginMutation(model.mutations, message.requestId),
2211
+ optimistic: beginOptimistic(model.optimistic, message.requestId, message.optimistic ?? [])
2212
+ };
2213
+ case "MutationSucceeded": {
2214
+ const settled = settleSuccess(model.entities, model.optimistic, model.mutations, message.requestId, message.entities, message.connections ?? [], message.deleted ?? []);
2215
+ return {
2216
+ ...model,
2217
+ entities: settled.store,
2218
+ mutations: settled.state,
2219
+ optimistic: settled.optimistic
2220
+ };
2221
+ }
2222
+ case "MutationFailed": return {
2223
+ ...model,
2224
+ mutations: failMutation(model.mutations, message.requestId, message.error),
2225
+ optimistic: settleFailure(model.optimistic, message.requestId)
2226
+ };
2227
+ case "LiveReceived": {
2228
+ const state = model.live[message.stream] ?? emptyLiveState;
2229
+ if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
2230
+ const applied = applyEntityEvent(state, model.entities, message.event, message.now);
2231
+ return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
2232
+ ...model,
2233
+ entities: applied.store,
2234
+ live: {
2235
+ ...model.live,
2236
+ [message.stream]: applied.state
2237
+ }
2238
+ }, message.stream);
2118
2239
  }
2119
- const item = nested.schema;
2120
- picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
2121
- relations[key] = relationOf(nested);
2240
+ const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
2241
+ return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
2242
+ ...model,
2243
+ optimistic: applied.optimistic,
2244
+ live: {
2245
+ ...model.live,
2246
+ [message.stream]: applied.state
2247
+ }
2248
+ }, message.stream);
2122
2249
  }
2123
- return {
2124
- entity: entity.name,
2125
- fields: Object.keys(selection),
2126
- schema: Schema.Struct(picked),
2127
- ...Object.keys(connections).length === 0 ? {} : { connections },
2128
- ...Object.keys(relations).length === 0 ? {} : { relations }
2129
- };
2130
- },
2131
- /**
2132
- * A paginated relation: one page of refs to `entity` with a window, or, with
2133
- * a nested selection, one page of targets assembled through it.
2134
- */
2135
- connection: ((entity, window, selection) => {
2136
- if (selection !== void 0 && selection.entity !== entity.name) throw new Error(`Selection.connection: a page of "${entity.name}" cannot select "${selection.entity}"`);
2137
- return {
2138
- entity: entity.name,
2139
- fields: selection?.fields ?? [],
2140
- schema: selection === void 0 ? Entity.refPage(entity) : pageSchema(selection.schema),
2141
- window,
2142
- ...selection?.connections === void 0 ? {} : { connections: selection.connections },
2143
- ...selection?.relations === void 0 ? {} : { relations: selection.relations }
2250
+ case "GapCleared": return clearGap(model, message.stream);
2251
+ case "ConnectionMerged": {
2252
+ const current = model.connections[message.connection] ?? emptyConnection;
2253
+ const replaces = message.refreshes === true && current.stale;
2254
+ const merged = merge(replaces ? emptyConnection : current, message.page);
2255
+ return {
2256
+ ...model,
2257
+ connections: {
2258
+ ...model.connections,
2259
+ [message.connection]: message.refreshes === true ? {
2260
+ ...merged,
2261
+ stale: false
2262
+ } : merged
2263
+ },
2264
+ optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
2265
+ };
2266
+ }
2267
+ case "ConnectionInvalidated":
2268
+ if (model.connections[message.connection]?.stale === true) return model;
2269
+ return {
2270
+ ...model,
2271
+ connections: setConnectionStale(model.connections, message.connection, true)
2272
+ };
2273
+ case "QueryFailed": return message.connection in model.connections ? {
2274
+ ...model,
2275
+ connections: setConnectionStale(model.connections, message.connection, false)
2276
+ } : model;
2277
+ case "ConnectionRefreshed": return {
2278
+ ...model,
2279
+ connections: setConnectionStale(model.connections, message.connection, false)
2144
2280
  };
2145
- })
2281
+ }
2282
+ };
2283
+ /** Appends (after) or prepends (before) an incoming page onto the stored one. */
2284
+ const mergeWireRefPages = (current, next, direction) => {
2285
+ const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
2286
+ return direction === "after" ? {
2287
+ refs,
2288
+ hasNext: next.hasNext,
2289
+ hasPrevious: current.hasPrevious
2290
+ } : {
2291
+ refs,
2292
+ hasNext: current.hasNext,
2293
+ hasPrevious: next.hasPrevious
2294
+ };
2146
2295
  };
2147
2296
  /**
2148
- * Reads an entity's selected fields out of the store, following each nested
2149
- * relation into its targets. `undefined` means some field, at any depth, is
2150
- * not present yet. A tombstoned target reads as `null` (or is dropped from a
2151
- * list), so the Selection's codec decides whether that is a failure.
2297
+ * Writes a read result into the store, recording each field's applied window so
2298
+ * a later request with a different window refetches. A relation page requested
2299
+ * with a cursor is merged onto the page already stored, so "load more"
2300
+ * accumulates rather than replaces. Every read path should use this rather than
2301
+ * reducing `writeEntity` by hand.
2152
2302
  */
2153
- const assemble = (store, key, requirement) => {
2154
- const values = {};
2155
- let refreshing = false;
2156
- for (const field of requirement.fields) {
2157
- const value = readField(store, key, field);
2158
- if (Option.isNone(value)) return void 0;
2159
- refreshing ||= isFieldStale(store, key, field);
2160
- const relation = requirement.relations?.[field];
2161
- if (relation === void 0) {
2162
- values[field] = value.value;
2163
- continue;
2164
- }
2165
- const nested = assembleRelation(store, value.value, relation);
2166
- if (nested === void 0) return void 0;
2167
- values[field] = nested.values;
2168
- refreshing ||= nested.refreshing;
2303
+ const writeRead = (store, requests, result, now = 0) => {
2304
+ const returned = /* @__PURE__ */ new Map();
2305
+ for (const entity of result.entities) {
2306
+ const key = entityKey(entity.entity, entity.id);
2307
+ returned.set(key, {
2308
+ ...returned.get(key),
2309
+ ...entity.values
2310
+ });
2169
2311
  }
2170
- return {
2171
- values,
2172
- refreshing
2173
- };
2174
- };
2175
- const assembleTarget = (store, ref, relation) => {
2176
- const key = entityKey(ref.entity, ref.id);
2177
- return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
2178
- };
2179
- /** Assembles the targets a relation value refers to, in the value's own shape. */
2180
- const assembleRelation = (store, value, relation) => {
2181
- if (value === null || value === void 0) return {
2182
- values: null,
2183
- refreshing: false
2312
+ const byEntity = /* @__PURE__ */ new Map();
2313
+ const record = (key, windows, relations) => {
2314
+ let entry = byEntity.get(key);
2315
+ if (entry === void 0) {
2316
+ entry = {
2317
+ windows: {},
2318
+ merge: /* @__PURE__ */ new Map()
2319
+ };
2320
+ byEntity.set(key, entry);
2321
+ }
2322
+ for (const [field, window] of Object.entries(windows ?? {})) {
2323
+ entry.windows[field] = windowKey(window);
2324
+ const direction = window.after !== void 0 ? "after" : window.before !== void 0 ? "before" : void 0;
2325
+ if (direction !== void 0) entry.merge.set(field, direction);
2326
+ }
2327
+ const values = returned.get(key);
2328
+ if (values === void 0) return;
2329
+ for (const [field, relation] of Object.entries(relations ?? {})) for (const ref of targetsOf(values[field], relation)) record(entityKey(ref.entity, ref.id), relation.windows, relation.relations);
2184
2330
  };
2185
- const targets = [];
2186
- let refreshing = false;
2187
- for (const ref of targetsOf(value, relation)) {
2188
- const target = assembleTarget(store, ref, relation);
2189
- if (target === void 0) return void 0;
2190
- if (target === "absent") continue;
2191
- targets.push(target.values);
2192
- refreshing ||= target.refreshing;
2331
+ for (const request of requests) record(entityKey(request.entity, request.id), request.windows, request.relations);
2332
+ const writes = [];
2333
+ const pending = /* @__PURE__ */ new Map();
2334
+ for (const entity of result.entities) {
2335
+ const key = entityKey(entity.entity, entity.id);
2336
+ const entry = byEntity.get(key);
2337
+ let values = entity.values;
2338
+ if (entry !== void 0 && entry.merge.size > 0) {
2339
+ const previous = pending.get(key) ?? store[key]?.values;
2340
+ if (previous !== void 0) {
2341
+ const merged = { ...values };
2342
+ for (const [field, direction] of entry.merge) {
2343
+ const incoming = values[field];
2344
+ const existing = previous[field];
2345
+ if (isRefPage(incoming) && isRefPage(existing)) merged[field] = mergeWireRefPages(existing, incoming, direction);
2346
+ }
2347
+ values = merged;
2348
+ }
2349
+ }
2350
+ pending.set(key, {
2351
+ ...pending.get(key),
2352
+ ...values
2353
+ });
2354
+ writes.push({
2355
+ key,
2356
+ values,
2357
+ windows: entry?.windows
2358
+ });
2193
2359
  }
2194
- if (typeof value === "string") return {
2195
- values: targets.length === 0 ? null : targets[0],
2196
- refreshing
2197
- };
2198
- if (isRefPage(value)) return {
2199
- values: {
2200
- items: targets,
2201
- hasNext: value.hasNext,
2202
- hasPrevious: value.hasPrevious
2203
- },
2204
- refreshing
2205
- };
2206
- return {
2207
- values: targets,
2208
- refreshing
2209
- };
2360
+ let answered = store;
2361
+ for (const request of requests) {
2362
+ const key = entityKey(request.entity, request.id);
2363
+ if (!returned.has(key)) answered = tombstone(answered, key);
2364
+ }
2365
+ return writeEntities(answered, writes, now);
2366
+ };
2367
+ //#endregion
2368
+ //#region src/policy.ts
2369
+ const RemotePolicy = {
2370
+ /** Fetch only what the store lacks. */
2371
+ cacheFirst: { _tag: "CacheFirst" },
2372
+ /** Keep present values visible, read as `Refreshing`, and refetch an entry older than `maxAge` ms. */
2373
+ staleWhileRevalidate: (options) => ({
2374
+ _tag: "StaleWhileRevalidate",
2375
+ maxAge: options.maxAge
2376
+ }),
2377
+ /** Fetch every selected field regardless of coverage; cached values stay visible meanwhile. */
2378
+ networkOnly: { _tag: "NetworkOnly" },
2379
+ /** The planner options a policy compiles to at `now`. */
2380
+ toPlan: (policy, now) => {
2381
+ switch (policy._tag) {
2382
+ case "CacheFirst": return {};
2383
+ case "StaleWhileRevalidate": return { freshness: {
2384
+ now,
2385
+ freshness: policy.maxAge
2386
+ } };
2387
+ case "NetworkOnly": return { force: true };
2388
+ }
2389
+ },
2390
+ /**
2391
+ * Whether a read under this policy may refetch fields the store already
2392
+ * holds, so an observer marks them stale (read as `Refreshing`) while the
2393
+ * request is pending.
2394
+ */
2395
+ refreshes: (policy) => policy._tag !== "CacheFirst"
2210
2396
  };
2211
2397
  //#endregion
2212
2398
  //#region src/index.ts
@@ -2217,6 +2403,8 @@ const assembleRelation = (store, value, relation) => {
2217
2403
  * live classification, optimistic layers) performs no I/O. The `Remote.*`
2218
2404
  * helpers that read or mutate go through the `RemoteClient` Effect service.
2219
2405
  */
2406
+ const descriptorOf = (entity) => Entity$1.is(entity) ? Entity.from(entity) : entity;
2407
+ const selectionOf = (selection) => SelectionTypeId in selection ? Selection.from(selection) : selection;
2220
2408
  /**
2221
2409
  * The store a read or plan sees: the base with every pending optimistic layer
2222
2410
  * applied, so a request's patches show until it settles and a temporary id is
@@ -2262,21 +2450,24 @@ const identityMessage = (message) => message;
2262
2450
  /** A stable key for a live subscription's requirement set. */
2263
2451
  const liveStreamKey = (requirements) => requirements.map((requirement) => `${entityKey(requirement.entity, requirement.id)}:${[...requirement.fields].sort().join(",")}`).sort().join("|");
2264
2452
  /** Declares a Remote domain: its entities, queries, and mutations, plus the submodel. */
2265
- const defineRemote = (config) => ({
2266
- entities: config.entities,
2267
- queries: config.queries ?? [],
2268
- mutations: config.mutations ?? [],
2269
- Model: remoteModelSchema(),
2270
- initial: initialRemoteModel,
2271
- Message: remoteMessageSchema,
2272
- update: updateRemote,
2273
- rpc: RemoteRpc,
2274
- registry: {
2275
- entities: new Map(config.entities.map((entity) => [entity.name, entity])),
2276
- queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
2277
- mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
2278
- }
2279
- });
2453
+ const defineRemote = (config) => {
2454
+ const entities = config.entities.map(descriptorOf);
2455
+ return {
2456
+ entities,
2457
+ queries: config.queries ?? [],
2458
+ mutations: config.mutations ?? [],
2459
+ Model: remoteModelSchema(),
2460
+ initial: initialRemoteModel,
2461
+ Message: remoteMessageSchema,
2462
+ update: updateRemote,
2463
+ rpc: RemoteRpc,
2464
+ registry: {
2465
+ entities: new Map(entities.map((entity) => [entity.name, entity])),
2466
+ queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
2467
+ mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
2468
+ }
2469
+ };
2470
+ };
2280
2471
  /** Binds a Remote domain to its store's location in the application Model. */
2281
2472
  const bindRemote = (definition, store) => ({
2282
2473
  definition,
@@ -2288,7 +2479,7 @@ const bindRemote = (definition, store) => ({
2288
2479
  owns: store.dependency.length === 0 ? [] : [store.dependency],
2289
2480
  observes: store.dependency.length === 0 ? [] : [store.dependency],
2290
2481
  messages: [],
2291
- requirements: []
2482
+ metadata: []
2292
2483
  }
2293
2484
  });
2294
2485
  /** The runtime half of `Registered`: a descriptor the domain never declared is an error naming both. */
@@ -2299,7 +2490,10 @@ const nothingAsked = {
2299
2490
  requirements: [],
2300
2491
  connections: []
2301
2492
  };
2302
- const askedOf = (projection) => projection === void 0 ? nothingAsked : projection;
2493
+ const askedOf = (projection) => projection === void 0 ? nothingAsked : {
2494
+ requirements: requirementsOf(projection),
2495
+ connections: connectionsOf(projection)
2496
+ };
2303
2497
  /**
2304
2498
  * Plans what a projection asks against the Model. A connection the Model holds
2305
2499
  * fresh contributes its visible items' selected fields to the entity plan; one
@@ -2375,7 +2569,7 @@ const pageMessage = (connection, result, refreshes = false) => ({
2375
2569
  const rootsOf = (projections, options) => {
2376
2570
  const connections = /* @__PURE__ */ new Map();
2377
2571
  for (const identity of (options.connections ?? []).map(connectionIdentity)) connections.set(identity, { identity });
2378
- for (const { identity, select } of projections.flatMap((projection) => projection.connections)) {
2572
+ for (const { identity, select } of projections.flatMap(connectionsOf)) {
2379
2573
  const current = connections.get(identity)?.select;
2380
2574
  connections.set(identity, {
2381
2575
  identity,
@@ -2383,10 +2577,39 @@ const rootsOf = (projections, options) => {
2383
2577
  });
2384
2578
  }
2385
2579
  return {
2386
- requirements: Requirement.merge(projections.flatMap((projection) => projection.requirements)),
2580
+ requirements: Requirement.merge(projections.flatMap(requirementsOf)),
2387
2581
  connections: [...connections.values()].sort((a, b) => a.identity < b.identity ? -1 : 1)
2388
2582
  };
2389
2583
  };
2584
+ /** Reads requirements and reports the Message that settles them. Never fails. */
2585
+ const readMessage = (requirements, now) => Effect.gen(function* () {
2586
+ const client = yield* RemoteClient;
2587
+ const at = now();
2588
+ const result = yield* Effect.result(client.read({
2589
+ version: 3,
2590
+ requests: requirements
2591
+ }));
2592
+ return Result.isFailure(result) ? {
2593
+ _tag: "ReadFailed",
2594
+ requests: requirements,
2595
+ error: remoteError(result.failure)
2596
+ } : {
2597
+ _tag: "ReadReceived",
2598
+ requests: requirements,
2599
+ result: result.success,
2600
+ now: at
2601
+ };
2602
+ });
2603
+ /** Runs a query and reports the page that refreshes its connection, or the failure. Never fails. */
2604
+ const queryMessage = (query) => Effect.gen(function* () {
2605
+ const client = yield* RemoteClient;
2606
+ const result = yield* Effect.result(queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request))));
2607
+ return Result.isFailure(result) ? {
2608
+ _tag: "QueryFailed",
2609
+ connection: query.identity,
2610
+ error: remoteError(result.failure)
2611
+ } : pageMessage(query.identity, result.success, true);
2612
+ });
2390
2613
  /** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
2391
2614
  const PlannedQuery = Schema.Struct({
2392
2615
  identity: Schema.String,
@@ -2404,41 +2627,19 @@ const PlannedQuery = Schema.Struct({
2404
2627
  */
2405
2628
  const observeEntry = (bound, askedOf, toMessage, options) => {
2406
2629
  const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
2407
- const read = (requirements) => Effect.gen(function* () {
2408
- const client = yield* RemoteClient;
2409
- const at = now();
2410
- const result = yield* Effect.result(client.read({
2411
- version: 3,
2412
- requests: requirements
2413
- }));
2414
- return Result.isFailure(result) ? toMessage({
2415
- _tag: "ReadFailed",
2416
- requests: requirements,
2417
- error: remoteError(result.failure)
2418
- }) : toMessage({
2419
- _tag: "ReadReceived",
2420
- requests: requirements,
2421
- result: result.success,
2422
- now: at
2423
- });
2424
- });
2425
- const run = (query) => Effect.gen(function* () {
2426
- const client = yield* RemoteClient;
2427
- const result = yield* Effect.result(queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request))));
2428
- return Result.isFailure(result) ? toMessage({
2429
- _tag: "QueryFailed",
2430
- connection: query.identity,
2431
- error: remoteError(result.failure)
2432
- }) : toMessage(pageMessage(query.identity, result.success, true));
2433
- });
2630
+ const read = (requirements) => Effect.map(readMessage(requirements, now), toMessage);
2631
+ const run = (query) => Effect.map(queryMessage(query), toMessage);
2434
2632
  return {
2435
2633
  dependenciesSchema: Schema.Struct({
2436
2634
  requirements: Schema.Array(ReadRequest),
2437
- queries: Schema.Array(PlannedQuery)
2635
+ queries: Schema.Array(PlannedQuery),
2636
+ refresh: Schema.Number
2438
2637
  }),
2439
2638
  modelToDependencies: (model) => {
2440
- const planned = planAsked(bound.store.get(model), askedOf(model), RemotePolicy.toPlan(policy, now()));
2639
+ const remote = bound.store.get(model);
2640
+ const planned = planAsked(remote, askedOf(model), RemotePolicy.toPlan(policy, now()));
2441
2641
  return {
2642
+ refresh: remote.refresh.requested,
2442
2643
  requirements: planned.requirements,
2443
2644
  queries: planned.queries.map(({ identity, window, select }) => ({
2444
2645
  identity,
@@ -2447,9 +2648,10 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
2447
2648
  }))
2448
2649
  };
2449
2650
  },
2450
- dependenciesToStream: ({ requirements, queries }) => Stream.concat(requirements.length === 0 ? Stream.empty : Stream.fromIterable([toMessage({
2651
+ dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(requirements.length === 0 ? Stream.empty : Stream.fromIterable([toMessage({
2451
2652
  _tag: "ReadStarted",
2452
- requests: requirements
2653
+ requests: requirements,
2654
+ refresh
2453
2655
  }), ...RemotePolicy.refreshes(policy) ? [toMessage({
2454
2656
  _tag: "RefreshStarted",
2455
2657
  requests: requirements
@@ -2543,17 +2745,17 @@ const Remote = {
2543
2745
  * as `Failed` instead of being asserted into `Value`. A present value with a
2544
2746
  * stale field reads as `Refreshing`: an observer is refetching it.
2545
2747
  */
2546
- select: (bound, selection) => {
2748
+ select: (bound, given) => {
2749
+ const selection = selectionOf(given);
2547
2750
  assertRegistered(bound, "Entity", bound.definition.registry.entities, selection.entity);
2548
2751
  const relation = relationOf(selection);
2549
2752
  return (id) => ({
2550
2753
  Model: remoteDataSchema(selection.schema),
2551
2754
  dependencies: [],
2552
- requirements: [{
2755
+ metadata: RemoteRequirements.of({
2553
2756
  ...relation,
2554
2757
  id
2555
- }],
2556
- connections: [],
2758
+ }),
2557
2759
  read: (root) => {
2558
2760
  const store = storeOf(bound, root);
2559
2761
  const key = entityKey(selection.entity, id);
@@ -2586,12 +2788,12 @@ const Remote = {
2586
2788
  * remote store does not satisfy, under `options` (freshness, force). A
2587
2789
  * Surface's projection is `surface.projection(params)`.
2588
2790
  */
2589
- plan: (bound, model, projection, options) => planAsked(bound.store.get(model), projection, options ?? {}).requirements,
2791
+ plan: (bound, model, projection, options) => planAsked(bound.store.get(model), askedOf(projection), options ?? {}).requirements,
2590
2792
  /**
2591
2793
  * The queries a projection needs run before its connections read: those the
2592
2794
  * Model does not hold, or holds stale (every one under `force`).
2593
2795
  */
2594
- planQueries: (bound, model, projection, options) => planAsked(bound.store.get(model), projection, options ?? {}).queries.flatMap((query) => query.ref === void 0 ? [] : [query.ref]),
2796
+ planQueries: (bound, model, projection, options) => planAsked(bound.store.get(model), askedOf(projection), options ?? {}).queries.flatMap((query) => query.ref === void 0 ? [] : [query.ref]),
2595
2797
  /**
2596
2798
  * The store reads see: the base store under the pending optimistic layers.
2597
2799
  * One store is shared by every read and plan of a Model whose remote state
@@ -2608,7 +2810,7 @@ const Remote = {
2608
2810
  const store = storeOf(bound, model);
2609
2811
  const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
2610
2812
  const at = now();
2611
- const missing = plan(store, projection.requirements, RemotePolicy.toPlan(policy, at));
2813
+ const missing = plan(store, requirementsOf(projection), RemotePolicy.toPlan(policy, at));
2612
2814
  if (missing.length === 0) return store;
2613
2815
  yield* Effect.annotateCurrentSpan("requirementCount", missing.length);
2614
2816
  const result = yield* (yield* RemoteClient).read({
@@ -2618,6 +2820,52 @@ const Remote = {
2618
2820
  return writeRead(store, missing, result, at);
2619
2821
  }),
2620
2822
  /**
2823
+ * Marks what a projection, or a Surface without params, requires as due again.
2824
+ * Called from `update`: every selected field the store holds reads `Refreshing`,
2825
+ * and every loaded query connection is invalidated. Nothing is fetched here.
2826
+ * The read entries `Data.subscriptions` derives refetch it, because a stale
2827
+ * field or connection is planned again under every policy, so a refresh never
2828
+ * sends a request beside the entry already observing the same data.
2829
+ *
2830
+ * A refreshed connection's page replaces its pages, so items the server removed
2831
+ * or reordered follow it; pages loaded past the first are dropped and paged
2832
+ * again with `next`/`previous`. The read entries restart, so a read or query
2833
+ * sent before the refresh is not applied after it.
2834
+ *
2835
+ * The projection must be observed, which it is while it is on screen. Live
2836
+ * subscriptions are left as they are. For data nothing observes (SSR, tests),
2837
+ * use `Remote.prefetch` with `RemotePolicy.networkOnly`. Refreshing what is
2838
+ * already being refreshed returns the same Model.
2839
+ */
2840
+ refresh: (bound, model, target) => {
2841
+ const projection = "read" in target ? target : target.projection(void 0);
2842
+ const remote = bound.store.get(model);
2843
+ const asked = askedOf(projection);
2844
+ const connections = Requirement.mergeConnections(asked.connections);
2845
+ const items = connections.flatMap((connection) => {
2846
+ const known = remote.connections[connection.identity];
2847
+ return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
2848
+ });
2849
+ const requirements = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], { force: true });
2850
+ const marked = [...requirements.length === 0 ? [] : [{
2851
+ _tag: "RefreshStarted",
2852
+ requests: requirements
2853
+ }], ...connections.filter((connection) => remote.connections[connection.identity] !== void 0).map((connection) => ({
2854
+ _tag: "ConnectionInvalidated",
2855
+ connection: connection.identity
2856
+ }))].reduce(updateRemote, remote);
2857
+ const inFlight = remote.refresh.started === remote.refresh.requested && requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true));
2858
+ if (marked === remote && !inFlight) return model;
2859
+ const refresh = {
2860
+ ...marked.refresh,
2861
+ requested: marked.refresh.requested + 1
2862
+ };
2863
+ return bound.store.set(model, {
2864
+ ...marked,
2865
+ refresh
2866
+ });
2867
+ },
2868
+ /**
2621
2869
  * Writes a read result into the store, recording each field's applied window.
2622
2870
  * `requests` are the planned requirements the result answers.
2623
2871
  */
@@ -2716,7 +2964,8 @@ const Remote = {
2716
2964
  _tag: "MutationSucceeded",
2717
2965
  requestId,
2718
2966
  entities: outcome.entities,
2719
- connections: outcome.connections
2967
+ connections: outcome.connections,
2968
+ deleted: outcome.deleted
2720
2969
  });
2721
2970
  return {
2722
2971
  output: outcome.output,
@@ -2731,20 +2980,21 @@ const Remote = {
2731
2980
  * emits `RefreshStarted`, so the fields being refetched read as `Refreshing`
2732
2981
  * while the request is pending.
2733
2982
  */
2734
- observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => surface.projection(params), toMessage, options),
2983
+ observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => askedOf(surface.projection(params)), toMessage, options),
2735
2984
  /**
2736
2985
  * A Foldkit Subscription entry that consumes the live stream for a Surface's
2737
2986
  * requirements, emitting a `LiveReceived` per event and a `ReadFailed` when
2738
2987
  * the stream breaks (including `ResumeUnavailable`). The resume cursor is read
2739
2988
  * from `RemoteModel.live`, so the application tracks no cursor of its own.
2740
2989
  */
2741
- live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => surface.projection(params).requirements, toMessage, options)
2990
+ live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
2742
2991
  };
2992
+ const brandEntries = (entries) => Subscription.make()(() => entries);
2743
2993
  /** The bound domain: the descriptor, the binding, and the operations over them. */
2744
2994
  const bindDomain = (definition, store) => {
2745
2995
  const bound = bindRemote(definition, store);
2746
2996
  const reduce = (model, message) => store.set(model, updateRemote(store.get(model), message));
2747
- return {
2997
+ const domain = {
2748
2998
  ...definition,
2749
2999
  ...bound,
2750
3000
  get: (selection, id) => Remote.select(bound, selection)(id),
@@ -2752,10 +3002,10 @@ const bindDomain = (definition, store) => {
2752
3002
  const projection = Remote.select(bound, selection)(id);
2753
3003
  return {
2754
3004
  ...projection,
2755
- requirements: projection.requirements.map((requirement) => ({
3005
+ metadata: RemoteRequirements.of(...requirementsOf(projection).map((requirement) => ({
2756
3006
  ...requirement,
2757
3007
  live: true
2758
- }))
3008
+ })))
2759
3009
  };
2760
3010
  },
2761
3011
  subscriptions: (active, options = {}) => {
@@ -2789,11 +3039,11 @@ const bindDomain = (definition, store) => {
2789
3039
  const client = yield* RemoteClient;
2790
3040
  const planOptions = RemotePolicy.toPlan(policy, now());
2791
3041
  let current = model;
2792
- for (const query of planAsked(store.get(current), projection, planOptions).queries) {
3042
+ for (const query of planAsked(store.get(current), askedOf(projection), planOptions).queries) {
2793
3043
  const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
2794
3044
  current = reduce(current, pageMessage(query.identity, page, true));
2795
3045
  }
2796
- const requirements = planAsked(store.get(current), projection, planOptions).requirements;
3046
+ const requirements = planAsked(store.get(current), askedOf(projection), planOptions).requirements;
2797
3047
  if (requirements.length === 0) return current;
2798
3048
  const at = now();
2799
3049
  const result = yield* client.read({
@@ -2809,7 +3059,8 @@ const bindDomain = (definition, store) => {
2809
3059
  }),
2810
3060
  query: (query, input, options) => {
2811
3061
  assertRegistered(bound, "Query", definition.registry.queries, query.name);
2812
- const { select, ...window } = options;
3062
+ const { select: given, ...window } = options;
3063
+ const select = selectionOf(given);
2813
3064
  for (const [side, size] of [["first", window.first], ["last", window.last]]) if (size !== void 0 && !(Number.isInteger(size) && size >= 0)) throw new Error(`Remote: query "${query.name}" asks for ${side}: ${String(size)}; a page size is a non-negative integer`);
2814
3065
  const listed = query.Result.entity;
2815
3066
  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`);
@@ -2829,8 +3080,7 @@ const bindDomain = (definition, store) => {
2829
3080
  return {
2830
3081
  Model: remoteDataSchema(pageSchema(select.schema)),
2831
3082
  dependencies: [],
2832
- requirements: [],
2833
- connections: [requirement],
3083
+ metadata: RemoteConnections.of(requirement),
2834
3084
  ref,
2835
3085
  read: (root) => {
2836
3086
  const remote = store.get(root);
@@ -2892,6 +3142,7 @@ const bindDomain = (definition, store) => {
2892
3142
  }
2893
3143
  } : void 0;
2894
3144
  },
3145
+ refresh: (model, target) => Remote.refresh(bound, model, target),
2895
3146
  fetch: (ref) => ({
2896
3147
  name: `Remote.query(${ref.query})`,
2897
3148
  args: {
@@ -2938,15 +3189,25 @@ const bindDomain = (definition, store) => {
2938
3189
  _tag: "MutationSucceeded",
2939
3190
  requestId,
2940
3191
  entities: outcome.entities,
2941
- connections: outcome.connections
3192
+ connections: outcome.connections,
3193
+ deleted: outcome.deleted
2942
3194
  })
2943
3195
  }))
2944
3196
  }
2945
3197
  };
2946
3198
  },
3199
+ mutation: (model, requestId) => mutationStatus(store.get(model).mutations, requestId),
2947
3200
  reduce,
2948
- inspect: (model) => inspectRemote(store.get(model))
3201
+ inspect: (model) => inspectRemote(store.get(model)),
3202
+ wiring: (active, options) => ({
3203
+ key: `remote:${bound.contract.name}`,
3204
+ handles: Object.keys(remoteMessageCases),
3205
+ route: (model, message) => isRemoteMessage(message) ? Option.some({ model: reduce(model, message) }) : Option.none(),
3206
+ subscriptions: brandEntries(domain.subscriptions(active, options)),
3207
+ contract: bound.contract
3208
+ })
2949
3209
  };
3210
+ return domain;
2950
3211
  };
2951
3212
  /** The window keys of a `QueryOptions`, and only those, without the undefined ones. */
2952
3213
  const pickWindow = (window) => ({
@@ -2961,6 +3222,6 @@ const pageSize = (window, key) => {
2961
3222
  return size === void 0 ? {} : { [key]: size };
2962
3223
  };
2963
3224
  //#endregion
2964
- export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRpc, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refsIn, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, writeEntities, writeEntity, writeRead };
3225
+ export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, mutationStatus, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refsIn, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, writeEntities, writeEntity, writeRead };
2965
3226
 
2966
3227
  //# sourceMappingURL=index.mjs.map