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/README.md +877 -446
- package/dist/index.d.mts +445 -172
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1537 -1276
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
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
|
|
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
|
|
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/
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
*
|
|
922
|
-
*
|
|
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
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
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
|
|
963
|
-
|
|
964
|
-
const
|
|
965
|
-
return {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
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
|
|
979
|
-
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
...
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
*
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
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
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
outcome
|
|
1201
|
+
values,
|
|
1202
|
+
refreshing
|
|
1140
1203
|
};
|
|
1141
1204
|
};
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
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/
|
|
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
|
-
*
|
|
1239
|
-
*
|
|
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
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
*
|
|
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
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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/
|
|
1362
|
+
//#region src/optimistic.ts
|
|
1290
1363
|
/**
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
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
|
-
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
*
|
|
1348
|
-
*
|
|
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
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
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
|
|
1396
|
-
const
|
|
1397
|
-
const
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
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
|
-
|
|
1425
|
-
const
|
|
1426
|
-
const
|
|
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
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
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
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
1445
|
-
const
|
|
1446
|
-
/**
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1468
|
-
*
|
|
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
|
|
1471
|
-
const
|
|
1472
|
-
|
|
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
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
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
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
*
|
|
1487
|
-
*
|
|
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
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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 "
|
|
1616
|
-
|
|
1617
|
-
|
|
1611
|
+
case "boundary": return {
|
|
1612
|
+
state: recordBoundary(advance(state, event.cursor), event.connection, event.position),
|
|
1613
|
+
optimistic,
|
|
1614
|
+
outcome
|
|
1618
1615
|
};
|
|
1619
|
-
case "
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
1632
|
-
//#region src/model.ts
|
|
1644
|
+
const isStale = (state, connection) => state.stale.has(connection);
|
|
1633
1645
|
/**
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
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
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
/**
|
|
1648
|
-
const
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
|
1667
|
-
*
|
|
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
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
-
*
|
|
1720
|
-
*
|
|
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
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
-
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
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
|
|
1757
|
-
|
|
1758
|
-
const
|
|
1759
|
-
|
|
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
|
-
|
|
1765
|
-
[
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
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
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
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
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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/
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
-
*
|
|
1997
|
-
*
|
|
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
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
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
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
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
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
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
|
|
2073
|
-
|
|
2074
|
-
const
|
|
2075
|
-
|
|
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
|
-
|
|
2085
|
-
const
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
*/
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
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
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2151
|
-
*
|
|
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
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
const
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
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
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
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
|
-
|
|
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 :
|
|
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(
|
|
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(
|
|
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.
|
|
2408
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|