foldkit-remote 0.4.0 → 0.6.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 +428 -79
- package/dist/index.d.mts +588 -38
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1074 -242
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
|
|
2
2
|
import * as Domain from "foldkit-entity";
|
|
3
|
-
import { Entity as Entity$1, SelectionTypeId } from "foldkit-entity";
|
|
3
|
+
import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId, evaluate } from "foldkit-entity";
|
|
4
4
|
import * as Subscription from "foldkit/subscription";
|
|
5
5
|
import { Metadata } from "foldkit-surface";
|
|
6
6
|
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
@@ -108,6 +108,91 @@ const requirementsOf = (projection) => RemoteRequirements.get(projection.metadat
|
|
|
108
108
|
/** The query connections a Projection carries. */
|
|
109
109
|
const connectionsOf = (projection) => RemoteConnections.get(projection.metadata);
|
|
110
110
|
//#endregion
|
|
111
|
+
//#region src/relation.ts
|
|
112
|
+
/** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
|
|
113
|
+
const RelationAnnotation = "foldkitRemoteRelation";
|
|
114
|
+
/** The target entity's name on the same codec, so a nested selection of another entity is refused. */
|
|
115
|
+
const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
|
|
116
|
+
/**
|
|
117
|
+
* A page of a relation that is a whole list is read under a name of its own, the
|
|
118
|
+
* field's name and the page's size: `comments@first=10`. It is a field like any
|
|
119
|
+
* other to the store, the planner, and the wire, so the whole list and a page of
|
|
120
|
+
* it are held side by side, merged, marked stale, and persisted each on its own.
|
|
121
|
+
* The server reads the name apart, reads the relation with the window, and
|
|
122
|
+
* answers under the alias. A cursor is not part of the name: a page read from a
|
|
123
|
+
* cursor merges onto the page it continues.
|
|
124
|
+
*/
|
|
125
|
+
const RELATION_ALIAS = "@";
|
|
126
|
+
/** The alias a relation field is read under, for a window of this size. */
|
|
127
|
+
const relationAlias = (field, window) => `${field}@${window.last !== void 0 ? `last=${window.last}` : `first=${window.first ?? ""}`}`;
|
|
128
|
+
/** The field an alias reads; a name that is no alias is its own field. */
|
|
129
|
+
const aliasedField = (name) => {
|
|
130
|
+
const at = name.indexOf("@");
|
|
131
|
+
return at === -1 ? name : name.slice(0, at);
|
|
132
|
+
};
|
|
133
|
+
/** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
|
|
134
|
+
const refParts = (encoded) => {
|
|
135
|
+
const separator = encoded.indexOf(":");
|
|
136
|
+
return separator === -1 ? {
|
|
137
|
+
entity: encoded,
|
|
138
|
+
id: ""
|
|
139
|
+
} : {
|
|
140
|
+
entity: encoded.slice(0, separator),
|
|
141
|
+
id: encoded.slice(separator + 1)
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* The refs a relation value carries, in order. Anything that is not a ref key,
|
|
146
|
+
* an array of them, or a page of them contributes nothing.
|
|
147
|
+
*/
|
|
148
|
+
const refsIn = (value) => {
|
|
149
|
+
if (typeof value === "string") return [refParts(value)];
|
|
150
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
|
|
151
|
+
if (value !== null && typeof value === "object") {
|
|
152
|
+
const refs = value.refs;
|
|
153
|
+
if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
};
|
|
157
|
+
/** The refs in a relation value that name the relation's target entity, and no other. */
|
|
158
|
+
const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
|
|
159
|
+
const isRefPage = (value) => {
|
|
160
|
+
if (value === null || typeof value !== "object") return false;
|
|
161
|
+
const page = value;
|
|
162
|
+
return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* The relation shape an entity field schema declares: a ref (`one`), a nullable
|
|
166
|
+
* ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
|
|
167
|
+
* scalar field, which cannot take a nested selection.
|
|
168
|
+
*/
|
|
169
|
+
const relationShape = (schema) => shapeOf(schema.ast, false);
|
|
170
|
+
const shapeOf = (ast, nullable) => {
|
|
171
|
+
const annotated = ast.annotations?.[RelationAnnotation];
|
|
172
|
+
if (annotated === "one" || annotated === "page") return {
|
|
173
|
+
kind: annotated,
|
|
174
|
+
nullable,
|
|
175
|
+
entity: String(ast.annotations?.[RelationEntityAnnotation])
|
|
176
|
+
};
|
|
177
|
+
if (ast._tag === "Arrays") {
|
|
178
|
+
const item = ast.rest?.[0];
|
|
179
|
+
const inner = item === void 0 ? void 0 : shapeOf(item, false);
|
|
180
|
+
return inner?.kind === "one" ? {
|
|
181
|
+
kind: "many",
|
|
182
|
+
nullable,
|
|
183
|
+
entity: inner.entity
|
|
184
|
+
} : void 0;
|
|
185
|
+
}
|
|
186
|
+
if (ast._tag === "Union") {
|
|
187
|
+
const members = ast.types ?? [];
|
|
188
|
+
const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
|
|
189
|
+
for (const member of members) {
|
|
190
|
+
const shape = shapeOf(member, optional);
|
|
191
|
+
if (shape !== void 0) return shape;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
111
196
|
//#region src/store.ts
|
|
112
197
|
/**
|
|
113
198
|
* The pure entity store: values, per-field presence, staleness, and tombstones.
|
|
@@ -142,6 +227,9 @@ const written = (previous, values, now, windows) => {
|
|
|
142
227
|
const requested = windows?.[field];
|
|
143
228
|
if (requested !== void 0 && requested !== "") nextWindows[field] = requested;
|
|
144
229
|
else delete nextWindows[field];
|
|
230
|
+
if (!field.includes("@")) {
|
|
231
|
+
for (const held of present) if (held.startsWith(`${field}@`) && !(held in values)) stale.add(held);
|
|
232
|
+
}
|
|
145
233
|
}
|
|
146
234
|
return {
|
|
147
235
|
values: {
|
|
@@ -352,6 +440,16 @@ make: (name, config) => ({
|
|
|
352
440
|
* canonical encoded filter/sort input — **never** the pagination window, so
|
|
353
441
|
* `first(25)` and `after(cursor).first(25)` address the same logical connection.
|
|
354
442
|
*/
|
|
443
|
+
/**
|
|
444
|
+
* What separates a query's name from its canonical encoded input in a
|
|
445
|
+
* connection identity.
|
|
446
|
+
*
|
|
447
|
+
* NUL, because `JSON.stringify` escapes it inside a string (`\u0000`) and a
|
|
448
|
+
* query name is a TypeScript identifier — so the first occurrence is always the
|
|
449
|
+
* boundary, and a name or an input value cannot forge one. Anything recovering
|
|
450
|
+
* the query from an identity splits here.
|
|
451
|
+
*/
|
|
452
|
+
const IDENTITY_SEPARATOR = "\0";
|
|
355
453
|
/** Stable stringify: object keys sorted, undefined-valued keys dropped, so equal inputs encode equally. */
|
|
356
454
|
const stableStringify = (value) => {
|
|
357
455
|
if (value === void 0) return "null";
|
|
@@ -361,7 +459,32 @@ const stableStringify = (value) => {
|
|
|
361
459
|
return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
362
460
|
};
|
|
363
461
|
const isEntityName = (result) => typeof result === "object" && result !== null && typeof result.name === "string";
|
|
462
|
+
/**
|
|
463
|
+
* The relational half comes from `foldkit-entity`, so one `Query` namespace
|
|
464
|
+
* holds both what a query means (`from`, `where`, `orderBy`) and how this
|
|
465
|
+
* client addresses it (`make`, `connection`, the window steps). They are one
|
|
466
|
+
* vocabulary in use — a definition's body is composed and then named — and two
|
|
467
|
+
* namespaces of the same name in one file would be a trap.
|
|
468
|
+
*/
|
|
469
|
+
/**
|
|
470
|
+
* One placeholder per key of the input's schema, each carrying that key's own
|
|
471
|
+
* codec so a comparison against it is checked.
|
|
472
|
+
*
|
|
473
|
+
* A codec that is not a `Schema.Struct` exposes no keys to stand for, while its
|
|
474
|
+
* *type* may well have them — so the body would be handed an empty object,
|
|
475
|
+
* `input.slug` would be `undefined`, and the comparison would silently be
|
|
476
|
+
* against nothing while the types all agreed. That is a wrong query rather than
|
|
477
|
+
* a missing one, so it is refused where the query is declared.
|
|
478
|
+
*/
|
|
479
|
+
const inputsOf = (name, schema) => {
|
|
480
|
+
const fields = schema.fields;
|
|
481
|
+
if (fields === void 0) throw new Error(`[foldkit-remote] Query.define: the Input of "${name}" is not a Schema.Struct, so its body has no inputs to read. Declare it as fields (\`{ slug: Schema.String }\`) or a plain \`Schema.Struct\`.`);
|
|
482
|
+
const inputs = {};
|
|
483
|
+
for (const key of Object.keys(fields)) inputs[key] = Expr.input(key, fields[key]);
|
|
484
|
+
return Object.freeze(inputs);
|
|
485
|
+
};
|
|
364
486
|
const Query = {
|
|
487
|
+
...Query$1,
|
|
365
488
|
/** Declares the entity and options a query's result is a connection over. */
|
|
366
489
|
connection: (entity, options) => ({
|
|
367
490
|
entity: entity.name,
|
|
@@ -385,10 +508,45 @@ const Query = {
|
|
|
385
508
|
input,
|
|
386
509
|
Input,
|
|
387
510
|
window: {},
|
|
388
|
-
identity: `${name}
|
|
511
|
+
identity: `${name}${stableStringify(encode(input))}`
|
|
389
512
|
})
|
|
390
513
|
};
|
|
391
514
|
},
|
|
515
|
+
/**
|
|
516
|
+
* Declares a query by what it means. The body is built once, here, and the
|
|
517
|
+
* `input` it is given holds placeholders rather than values — there is
|
|
518
|
+
* nothing yet to branch on, so a condition that depends on what was passed is
|
|
519
|
+
* a comparison over the placeholder:
|
|
520
|
+
*
|
|
521
|
+
* ```ts
|
|
522
|
+
* const PostsBySlug = Query.define('PostsBySlug', { slug: Schema.String }, ({ input }) =>
|
|
523
|
+
* Query.from(Post).pipe(
|
|
524
|
+
* Query.where(Expr.eq(Post.fields.slug, input.slug)),
|
|
525
|
+
* Query.orderBy(Order.asc(Post.fields.id)),
|
|
526
|
+
* ),
|
|
527
|
+
* )
|
|
528
|
+
* ```
|
|
529
|
+
*
|
|
530
|
+
* What comes back is an ordinary `QueryDescriptor` — the same name, `Input`,
|
|
531
|
+
* `ref` and connection identity `Query.make` gives — carrying its `body`
|
|
532
|
+
* besides. A server can compile that body instead of being told the same
|
|
533
|
+
* thing again in its own dialect, and one that would rather answer the query
|
|
534
|
+
* its own way still can.
|
|
535
|
+
*
|
|
536
|
+
* The result is a connection over the Entity the body reads, so it is not
|
|
537
|
+
* named twice.
|
|
538
|
+
*/
|
|
539
|
+
define: (name, Input, body, options) => {
|
|
540
|
+
const schema = schemaOf(Input);
|
|
541
|
+
const built = body({ input: inputsOf(name, schema) });
|
|
542
|
+
return {
|
|
543
|
+
...Query.make(name, {
|
|
544
|
+
Input,
|
|
545
|
+
Result: Query.connection(built.entity, options)
|
|
546
|
+
}),
|
|
547
|
+
body: built
|
|
548
|
+
};
|
|
549
|
+
},
|
|
392
550
|
first: (count) => (ref) => ({
|
|
393
551
|
...ref,
|
|
394
552
|
window: {
|
|
@@ -1016,74 +1174,6 @@ const hasNext = (connection) => {
|
|
|
1016
1174
|
/** A connection with more than one segment has an explicit unknown gap. */
|
|
1017
1175
|
const isGapped = (connection) => connection.segments.length > 1;
|
|
1018
1176
|
//#endregion
|
|
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
|
-
};
|
|
1034
|
-
};
|
|
1035
|
-
/**
|
|
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.
|
|
1038
|
-
*/
|
|
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 [];
|
|
1047
|
-
};
|
|
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";
|
|
1054
|
-
};
|
|
1055
|
-
/**
|
|
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.
|
|
1059
|
-
*/
|
|
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])
|
|
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
|
-
}
|
|
1085
|
-
};
|
|
1086
|
-
//#endregion
|
|
1087
1177
|
//#region src/selection.ts
|
|
1088
1178
|
/**
|
|
1089
1179
|
* Selections: the fields a Surface picks of an entity (nested through
|
|
@@ -1109,15 +1199,20 @@ const relationOf = (selection) => ({
|
|
|
1109
1199
|
...selection.connections === void 0 ? {} : { windows: selection.connections },
|
|
1110
1200
|
...selection.relations === void 0 ? {} : { relations: selection.relations }
|
|
1111
1201
|
});
|
|
1202
|
+
const fromEntitySelections = /* @__PURE__ */ new WeakMap();
|
|
1112
1203
|
const Selection = {
|
|
1113
1204
|
/**
|
|
1114
1205
|
* The Remote Selection of a `foldkit-entity` Selection: the same members, read
|
|
1115
1206
|
* from Remote's store. `true` on a relation reads its refs, as it does here.
|
|
1116
1207
|
*/
|
|
1117
1208
|
from: (selection) => {
|
|
1209
|
+
const compiled = fromEntitySelections.get(selection);
|
|
1210
|
+
if (compiled !== void 0) return compiled;
|
|
1118
1211
|
const spec = {};
|
|
1119
1212
|
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
|
-
|
|
1213
|
+
const made = Selection.make(Entity.from(selection.entity), spec);
|
|
1214
|
+
fromEntitySelections.set(selection, made);
|
|
1215
|
+
return made;
|
|
1121
1216
|
},
|
|
1122
1217
|
/**
|
|
1123
1218
|
* The fields to read of `entity`. A nested `Selection` on a relation field
|
|
@@ -1127,12 +1222,14 @@ const Selection = {
|
|
|
1127
1222
|
make: (entity, selection) => {
|
|
1128
1223
|
if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
|
|
1129
1224
|
const picked = {};
|
|
1225
|
+
const names = [];
|
|
1130
1226
|
const connections = {};
|
|
1131
1227
|
const relations = {};
|
|
1132
1228
|
for (const key of Object.keys(selection)) {
|
|
1133
1229
|
const choice = selection[key];
|
|
1134
1230
|
if (choice === true) {
|
|
1135
1231
|
picked[key] = entity.fields[key];
|
|
1232
|
+
names.push(key);
|
|
1136
1233
|
continue;
|
|
1137
1234
|
}
|
|
1138
1235
|
const nested = choice;
|
|
@@ -1141,18 +1238,21 @@ const Selection = {
|
|
|
1141
1238
|
if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
|
|
1142
1239
|
const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
|
|
1143
1240
|
if (nested.window !== void 0) {
|
|
1144
|
-
|
|
1241
|
+
const name = shape.kind === "many" ? relationAlias(key, nested.window) : key;
|
|
1242
|
+
names.push(name);
|
|
1243
|
+
connections[name] = nested.window;
|
|
1145
1244
|
picked[key] = nullable(nested.schema);
|
|
1146
|
-
if (nested.fields.length > 0) relations[
|
|
1245
|
+
if (nested.fields.length > 0) relations[name] = relationOf(nested);
|
|
1147
1246
|
continue;
|
|
1148
1247
|
}
|
|
1248
|
+
names.push(key);
|
|
1149
1249
|
const item = nested.schema;
|
|
1150
1250
|
picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
|
|
1151
1251
|
relations[key] = relationOf(nested);
|
|
1152
1252
|
}
|
|
1153
1253
|
return {
|
|
1154
1254
|
entity: entity.name,
|
|
1155
|
-
fields:
|
|
1255
|
+
fields: names,
|
|
1156
1256
|
schema: Schema.Struct(picked),
|
|
1157
1257
|
...Object.keys(connections).length === 0 ? {} : { connections },
|
|
1158
1258
|
...Object.keys(relations).length === 0 ? {} : { relations }
|
|
@@ -1189,12 +1289,12 @@ const assemble = (store, key, requirement) => {
|
|
|
1189
1289
|
refreshing ||= isFieldStale(store, key, field);
|
|
1190
1290
|
const relation = requirement.relations?.[field];
|
|
1191
1291
|
if (relation === void 0) {
|
|
1192
|
-
values[field] = value.value;
|
|
1292
|
+
values[aliasedField(field)] = value.value;
|
|
1193
1293
|
continue;
|
|
1194
1294
|
}
|
|
1195
1295
|
const nested = assembleRelation(store, value.value, relation);
|
|
1196
1296
|
if (nested === void 0) return void 0;
|
|
1197
|
-
values[field] = nested.values;
|
|
1297
|
+
values[aliasedField(field)] = nested.values;
|
|
1198
1298
|
refreshing ||= nested.refreshing;
|
|
1199
1299
|
}
|
|
1200
1300
|
return {
|
|
@@ -1291,6 +1391,8 @@ const Entity = {
|
|
|
1291
1391
|
return descriptor;
|
|
1292
1392
|
},
|
|
1293
1393
|
make: (name, schema) => {
|
|
1394
|
+
const reserved = Object.keys(schema.fields).find((field) => field.includes("@"));
|
|
1395
|
+
if (reserved !== void 0) throw new Error(`Entity.make: field "${reserved}" of "${name}" contains "@", which names a page of a relation`);
|
|
1294
1396
|
const descriptor = {
|
|
1295
1397
|
name,
|
|
1296
1398
|
schema,
|
|
@@ -1332,6 +1434,118 @@ const Entity = {
|
|
|
1332
1434
|
})
|
|
1333
1435
|
};
|
|
1334
1436
|
//#endregion
|
|
1437
|
+
//#region src/matching.ts
|
|
1438
|
+
/**
|
|
1439
|
+
* Running a query body against the rows the client already holds.
|
|
1440
|
+
*
|
|
1441
|
+
* local-execution-DESIGN phase 3. A connection's rows are edges the server
|
|
1442
|
+
* delivered, and until now nothing on the client ever evaluated a body — which
|
|
1443
|
+
* is why an optimistic insert has to guess between `prepend` and `append`, and
|
|
1444
|
+
* why `LivePolicy` exists to be told in advance what to do with a row nobody
|
|
1445
|
+
* could judge.
|
|
1446
|
+
*
|
|
1447
|
+
* This judges them. It is pure, it performs no I/O, and it reuses the reference
|
|
1448
|
+
* interpreter in `foldkit-entity` rather than reimplementing the semantics —
|
|
1449
|
+
* so a body means here exactly what it means on the server, checked by the same
|
|
1450
|
+
* conformance suite.
|
|
1451
|
+
*
|
|
1452
|
+
* **It answers "which of the rows I hold match", never "which rows match".**
|
|
1453
|
+
* Those are different questions and the difference is not recoverable from the
|
|
1454
|
+
* result, so it is in the shape: `matched` is what satisfied the body, and
|
|
1455
|
+
* `skipped` is what was held but could not be judged. A caller that knows it
|
|
1456
|
+
* holds the whole population — a connection terminal at both ends — is the one
|
|
1457
|
+
* that can turn this into a complete answer. Nothing here can.
|
|
1458
|
+
*
|
|
1459
|
+
* **It is not authorization.** On the server a compiled `where` is conjoined
|
|
1460
|
+
* with the binding's `visible` rule. There is no `visible` here, and this is
|
|
1461
|
+
* safe only because the client holds only rows the server already released to
|
|
1462
|
+
* it. A local filter is not an access decision.
|
|
1463
|
+
*/
|
|
1464
|
+
/**
|
|
1465
|
+
* The fields of the body's own Entity that a row must have for the body to be
|
|
1466
|
+
* decidable about it — its predicates and its ordering terms.
|
|
1467
|
+
*
|
|
1468
|
+
* `id` is never among them: it is the key, so it is known for every row in the
|
|
1469
|
+
* store whether or not anything fetched it as a field.
|
|
1470
|
+
*/
|
|
1471
|
+
const decidableOn = (body) => Query$1.dependencies(body).fields.filter((field) => field.entity === body.entity.name && field.key !== "id").map((field) => field.key);
|
|
1472
|
+
/**
|
|
1473
|
+
* The rows of `descriptor`'s Entity that the store holds and the body matches.
|
|
1474
|
+
*
|
|
1475
|
+
* The input is given **decoded**, as an application writes it, and encoded here
|
|
1476
|
+
* through the descriptor's own `Input` codec. That is deliberate and is the
|
|
1477
|
+
* whole reason this takes a descriptor rather than a bare body: a store holds
|
|
1478
|
+
* wire values, so an input has to be in that space to be compared against them,
|
|
1479
|
+
* and handing an interpreter a decoded value is a mistake **no runtime error
|
|
1480
|
+
* catches** — the reference interpreter answers "no rows", which reads exactly
|
|
1481
|
+
* like a correct answer. Doing it here means a caller cannot get it wrong.
|
|
1482
|
+
*
|
|
1483
|
+
* Literals in the body are deliberately *not* encoded. A literal written in
|
|
1484
|
+
* domain terms is already broken against the server, which binds it to a column
|
|
1485
|
+
* the same way; accepting it here would make the client quietly more permissive
|
|
1486
|
+
* than the thing it has to agree with.
|
|
1487
|
+
*/
|
|
1488
|
+
const matching = (store, descriptor, input, options = {}) => matchingEncoded(store, descriptor, Schema.encodeSync(descriptor.Input)(input), options);
|
|
1489
|
+
/**
|
|
1490
|
+
* As `matching`, for the callers in this module that already hold the input
|
|
1491
|
+
* **encoded**. Not exported: `matching` is the way in, and it cannot be given
|
|
1492
|
+
* the wrong space.
|
|
1493
|
+
*/
|
|
1494
|
+
const matchingEncoded = (store, descriptor, encoded, options = {}) => {
|
|
1495
|
+
const body = descriptor.body;
|
|
1496
|
+
if (body === void 0) throw new Error(`[foldkit-remote] Remote.matching: query "${descriptor.name}" carries no body, so there is nothing to judge rows against. Declare it with Query.define.`);
|
|
1497
|
+
const prefix = `${body.entity.name}:`;
|
|
1498
|
+
const needed = decidableOn(body);
|
|
1499
|
+
const candidates = options.among ?? Object.keys(store).filter((key) => key.startsWith(prefix));
|
|
1500
|
+
const rows = [];
|
|
1501
|
+
const skipped = [];
|
|
1502
|
+
for (const key of candidates) {
|
|
1503
|
+
if (!key.startsWith(prefix)) continue;
|
|
1504
|
+
const entry = store[key];
|
|
1505
|
+
if (entry === void 0) {
|
|
1506
|
+
skipped.push(key);
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
if (entry.tombstone) continue;
|
|
1510
|
+
if (needed.some((field) => !entry.present.has(field))) {
|
|
1511
|
+
skipped.push(key);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
rows.push("id" in entry.values ? {
|
|
1515
|
+
...entry.values,
|
|
1516
|
+
__key: key
|
|
1517
|
+
} : {
|
|
1518
|
+
...entry.values,
|
|
1519
|
+
id: key.slice(prefix.length),
|
|
1520
|
+
__key: key
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
matched: evaluate(body, encoded, rows).map((row) => row.__key),
|
|
1525
|
+
skipped
|
|
1526
|
+
};
|
|
1527
|
+
};
|
|
1528
|
+
/**
|
|
1529
|
+
* Takes the input **encoded**, because its one caller holds it that way: a
|
|
1530
|
+
* connection identity carries the canonical encoded input, and making it decode
|
|
1531
|
+
* only for this to encode again would be a round trip that can fail.
|
|
1532
|
+
*
|
|
1533
|
+
* A decoded-input sibling was written first and deleted: nothing called it. The
|
|
1534
|
+
* asymmetry with `matching`, which takes a decoded input, is the shape of the
|
|
1535
|
+
* two real callers rather than an oversight.
|
|
1536
|
+
*/
|
|
1537
|
+
const belongsEncoded = (store, descriptor, encoded, key) => {
|
|
1538
|
+
const entry = store[key];
|
|
1539
|
+
if (entry === void 0) return "unknown";
|
|
1540
|
+
if (entry.tombstone) return "no";
|
|
1541
|
+
const body = descriptor.body;
|
|
1542
|
+
if (body !== void 0 && decidableOn(body).some((field) => entry.stale.has(field))) return "unknown";
|
|
1543
|
+
const judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
|
|
1544
|
+
if (judged.matched.includes(key)) return "yes";
|
|
1545
|
+
if (judged.skipped.includes(key)) return "unknown";
|
|
1546
|
+
return "no";
|
|
1547
|
+
};
|
|
1548
|
+
//#endregion
|
|
1335
1549
|
//#region src/inspect.ts
|
|
1336
1550
|
const inspectEntry = (key, entry) => ({
|
|
1337
1551
|
key,
|
|
@@ -1347,6 +1561,8 @@ const inspectRemote = (model) => ({
|
|
|
1347
1561
|
connections: Object.keys(model.connections),
|
|
1348
1562
|
live: Object.keys(model.live),
|
|
1349
1563
|
gaps: [...model.gaps],
|
|
1564
|
+
loading: [...model.loading],
|
|
1565
|
+
failures: model.failures,
|
|
1350
1566
|
mutations: {
|
|
1351
1567
|
pending: [...model.mutations.pending],
|
|
1352
1568
|
failed: [...model.mutations.failed],
|
|
@@ -1524,7 +1740,6 @@ const visibleItems = (connection, connectionId, overlays, store) => {
|
|
|
1524
1740
|
//#region src/live.ts
|
|
1525
1741
|
const emptyLiveState = {
|
|
1526
1742
|
cursor: 0,
|
|
1527
|
-
stale: /* @__PURE__ */ new Set(),
|
|
1528
1743
|
boundary: {}
|
|
1529
1744
|
};
|
|
1530
1745
|
/**
|
|
@@ -1558,10 +1773,6 @@ const recordBoundary = (state, connection, position) => {
|
|
|
1558
1773
|
}
|
|
1559
1774
|
};
|
|
1560
1775
|
};
|
|
1561
|
-
const invalidateConnection = (state, connection) => ({
|
|
1562
|
-
...state,
|
|
1563
|
-
stale: /* @__PURE__ */ new Set([...state.stale, connection])
|
|
1564
|
-
});
|
|
1565
1776
|
/**
|
|
1566
1777
|
* A subscriber is woken only if the event changed a field it selects. A
|
|
1567
1778
|
* change to an unselected field is skipped entirely.
|
|
@@ -1614,9 +1825,10 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
|
|
|
1614
1825
|
outcome
|
|
1615
1826
|
};
|
|
1616
1827
|
case "invalidate": return {
|
|
1617
|
-
state:
|
|
1828
|
+
state: advance(state, event.cursor),
|
|
1618
1829
|
optimistic,
|
|
1619
|
-
outcome
|
|
1830
|
+
outcome,
|
|
1831
|
+
invalidated: event.connection
|
|
1620
1832
|
};
|
|
1621
1833
|
case "ignore": return {
|
|
1622
1834
|
state: advance(state, event.cursor),
|
|
@@ -1635,27 +1847,13 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
|
|
|
1635
1847
|
outcome
|
|
1636
1848
|
};
|
|
1637
1849
|
case "ConnectionInvalidate": return {
|
|
1638
|
-
state:
|
|
1850
|
+
state: advance(state, event.cursor),
|
|
1851
|
+
invalidated: event.connection,
|
|
1639
1852
|
optimistic,
|
|
1640
1853
|
outcome
|
|
1641
1854
|
};
|
|
1642
1855
|
}
|
|
1643
1856
|
};
|
|
1644
|
-
const isStale = (state, connection) => state.stale.has(connection);
|
|
1645
|
-
/**
|
|
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.
|
|
1649
|
-
*/
|
|
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
|
-
};
|
|
1658
|
-
};
|
|
1659
1857
|
/** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
|
|
1660
1858
|
const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
|
|
1661
1859
|
/** `hasNext` accounting for edges recorded outside the loaded boundary. */
|
|
@@ -1797,17 +1995,33 @@ const gc = (state, roots) => {
|
|
|
1797
1995
|
* application's choice.
|
|
1798
1996
|
*/
|
|
1799
1997
|
/** Bump when the serialized shape changes; a mismatch discards the cache. */
|
|
1800
|
-
const REMOTE_CACHE_VERSION =
|
|
1998
|
+
const REMOTE_CACHE_VERSION = 4;
|
|
1999
|
+
/**
|
|
2000
|
+
* A snapshot of a Model, keeping the entity cache and only the connections
|
|
2001
|
+
* named.
|
|
2002
|
+
*
|
|
2003
|
+
* Naming none — the default — is exactly what Remote did before connections
|
|
2004
|
+
* could be kept at all: a disposable, server-derived cache.
|
|
2005
|
+
*/
|
|
2006
|
+
const snapshotOf = (model, options = {}) => ({
|
|
2007
|
+
entities: model.entities,
|
|
2008
|
+
connections: Object.fromEntries((options.connections ?? []).flatMap((connection) => {
|
|
2009
|
+
const identity = connectionIdentity(connection);
|
|
2010
|
+
const held = model.connections[identity];
|
|
2011
|
+
return held === void 0 ? [] : [[identity, held.segments.map((segment) => segment.edges)]];
|
|
2012
|
+
}))
|
|
2013
|
+
});
|
|
1801
2014
|
const sorted = (values) => [...values].sort();
|
|
1802
2015
|
/**
|
|
1803
2016
|
* The snapshot's data shape. `stableStringify` sorts object keys, and the
|
|
1804
2017
|
* field sets are sorted here, so equal stores give byte-equal snapshots
|
|
1805
2018
|
* whatever order they were built in.
|
|
1806
2019
|
*/
|
|
1807
|
-
const serializeStore = (
|
|
1808
|
-
version:
|
|
2020
|
+
const serializeStore = (snapshot, scope) => ({
|
|
2021
|
+
version: 4,
|
|
1809
2022
|
scope: scope ?? null,
|
|
1810
|
-
|
|
2023
|
+
connections: snapshot.connections,
|
|
2024
|
+
entities: Object.fromEntries(Object.entries(snapshot.entities).map(([key, entry]) => [key, {
|
|
1811
2025
|
values: entry.values,
|
|
1812
2026
|
present: sorted(entry.present),
|
|
1813
2027
|
stale: sorted(entry.stale),
|
|
@@ -1837,9 +2051,30 @@ const parseEntry = (value) => {
|
|
|
1837
2051
|
windows: entry.windows
|
|
1838
2052
|
};
|
|
1839
2053
|
};
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2054
|
+
/** Throws on a malformed edge so the whole snapshot is discarded. */
|
|
2055
|
+
const parseEdges = (value) => {
|
|
2056
|
+
if (!Array.isArray(value)) throw new Error("segment is not an array of edges");
|
|
2057
|
+
return value.map((edge) => {
|
|
2058
|
+
const candidate = edge;
|
|
2059
|
+
if (typeof candidate.key !== "string" || typeof candidate.ref?.entity !== "string" || typeof candidate.ref?.id !== "string") throw new Error("edge is not { key, ref: { entity, id } }");
|
|
2060
|
+
return {
|
|
2061
|
+
key: candidate.key,
|
|
2062
|
+
ref: {
|
|
2063
|
+
entity: candidate.ref.entity,
|
|
2064
|
+
id: candidate.ref.id
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
});
|
|
2068
|
+
};
|
|
2069
|
+
const deserializeStore = (serialized) => ({
|
|
2070
|
+
entities: Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)])),
|
|
2071
|
+
connections: Object.fromEntries(Object.entries(serialized.connections ?? {}).map(([key, segments]) => {
|
|
2072
|
+
if (!Array.isArray(segments)) throw new Error("connection is not an array of segments");
|
|
2073
|
+
return [key, segments.map(parseEdges)];
|
|
2074
|
+
}))
|
|
2075
|
+
});
|
|
2076
|
+
function dehydrate(snapshot, options = {}) {
|
|
2077
|
+
const text = stableStringify(serializeStore(snapshot, options.scope));
|
|
1843
2078
|
return exceeds(text, options.maxBytes) ? void 0 : text;
|
|
1844
2079
|
}
|
|
1845
2080
|
/**
|
|
@@ -1869,7 +2104,7 @@ const hydrate = (raw, options = {}) => {
|
|
|
1869
2104
|
}
|
|
1870
2105
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
1871
2106
|
const candidate = parsed;
|
|
1872
|
-
if (candidate.version !==
|
|
2107
|
+
if (candidate.version !== 4) return void 0;
|
|
1873
2108
|
if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
|
|
1874
2109
|
if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
|
|
1875
2110
|
if (Array.isArray(candidate.entities)) return void 0;
|
|
@@ -1892,37 +2127,49 @@ const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
|
|
|
1892
2127
|
...snapshot,
|
|
1893
2128
|
...current
|
|
1894
2129
|
};
|
|
2130
|
+
/** An empty snapshot: the cache as a fresh session has it. */
|
|
2131
|
+
const emptySnapshot = {
|
|
2132
|
+
entities: emptyStore,
|
|
2133
|
+
connections: {}
|
|
2134
|
+
};
|
|
1895
2135
|
const RemotePersistence = {
|
|
1896
2136
|
/** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
|
|
1897
2137
|
dehydrate,
|
|
1898
|
-
/**
|
|
2138
|
+
/** What a snapshot text holds, or `undefined` when it is refused. */
|
|
1899
2139
|
hydrate,
|
|
1900
|
-
/**
|
|
2140
|
+
/**
|
|
2141
|
+
* What to keep of a Model, naming the connections that should survive a
|
|
2142
|
+
* reload. Naming none is the default and is what Remote always did.
|
|
2143
|
+
*/
|
|
2144
|
+
snapshotOf,
|
|
2145
|
+
/** A snapshot's entities brought into a store by policy. */
|
|
1901
2146
|
mergeStores,
|
|
2147
|
+
/** The cache as a fresh session has it; what a refused `restore` yields. */
|
|
2148
|
+
emptySnapshot,
|
|
1902
2149
|
/**
|
|
1903
2150
|
* Writes the snapshot under `key`, or removes the key when the store would
|
|
1904
2151
|
* exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
|
|
1905
2152
|
* store it no longer describes.
|
|
1906
2153
|
*/
|
|
1907
|
-
save: (
|
|
2154
|
+
save: (snapshot, options) => Effect.gen(function* () {
|
|
1908
2155
|
const kv = yield* KeyValueStore.KeyValueStore;
|
|
1909
|
-
const text = dehydrate(
|
|
2156
|
+
const text = dehydrate(snapshot, options);
|
|
1910
2157
|
if (text === void 0) yield* kv.remove(options.key);
|
|
1911
2158
|
else yield* kv.set(options.key, text);
|
|
1912
2159
|
}),
|
|
1913
2160
|
/**
|
|
1914
|
-
* Reads the cache. A missing key yields `
|
|
1915
|
-
* refuses yields `
|
|
1916
|
-
* clean and the planner refetches.
|
|
2161
|
+
* Reads the cache. A missing key yields `emptySnapshot`; anything `hydrate`
|
|
2162
|
+
* refuses yields `emptySnapshot` and removes the key, so the next `restore`
|
|
2163
|
+
* is clean and the planner refetches.
|
|
1917
2164
|
*/
|
|
1918
2165
|
restore: (options) => Effect.gen(function* () {
|
|
1919
2166
|
const kv = yield* KeyValueStore.KeyValueStore;
|
|
1920
2167
|
const raw = yield* kv.get(options.key);
|
|
1921
|
-
if (raw === void 0) return
|
|
2168
|
+
if (raw === void 0) return emptySnapshot;
|
|
1922
2169
|
const restored = hydrate(raw, options);
|
|
1923
2170
|
if (restored === void 0) {
|
|
1924
2171
|
yield* kv.remove(options.key);
|
|
1925
|
-
return
|
|
2172
|
+
return emptySnapshot;
|
|
1926
2173
|
}
|
|
1927
2174
|
return restored;
|
|
1928
2175
|
})
|
|
@@ -1930,6 +2177,8 @@ const RemotePersistence = {
|
|
|
1930
2177
|
//#endregion
|
|
1931
2178
|
//#region src/remoteData.ts
|
|
1932
2179
|
/** `RemoteData`: the state of a value the store may not hold yet. */
|
|
2180
|
+
const fresh = { _tag: "Fresh" };
|
|
2181
|
+
const refreshing = { _tag: "Refreshing" };
|
|
1933
2182
|
const remoteErrorSchema = Schema.Struct({
|
|
1934
2183
|
_tag: Schema.String,
|
|
1935
2184
|
message: Schema.String
|
|
@@ -1977,6 +2226,43 @@ const RemoteData = {
|
|
|
1977
2226
|
case "NotFound": return cases.NotFound();
|
|
1978
2227
|
}
|
|
1979
2228
|
},
|
|
2229
|
+
/**
|
|
2230
|
+
* The view-oriented fold: the six states as the three things a view actually
|
|
2231
|
+
* draws, under the policy that useful data stays on screen. A value that is
|
|
2232
|
+
* being refetched, or that a later read failed to replace, still reaches
|
|
2233
|
+
* `data` — with `freshness` saying which — instead of being replaced by a
|
|
2234
|
+
* spinner or an error the reader cannot act on.
|
|
2235
|
+
*
|
|
2236
|
+
* ```ts
|
|
2237
|
+
* RemoteData.render(model.user, {
|
|
2238
|
+
* loading: () => UserSkeleton(),
|
|
2239
|
+
* notFound: () => NoSuchUser(),
|
|
2240
|
+
* failed: error => ErrorView(error),
|
|
2241
|
+
* data: (user, freshness) => UserView({ user, dimmed: freshness._tag !== 'Fresh' }),
|
|
2242
|
+
* })
|
|
2243
|
+
* ```
|
|
2244
|
+
*
|
|
2245
|
+
* `notFound` is its own branch and not optional: a row the server answered
|
|
2246
|
+
* for and does not have is neither loading nor a failure, and drawing it as
|
|
2247
|
+
* either is a spinner that never ends or an error nobody can fix.
|
|
2248
|
+
*
|
|
2249
|
+
* This does not replace `match`, which is the exhaustive fold over the states
|
|
2250
|
+
* themselves. Reach for `match` when the six states really do draw
|
|
2251
|
+
* differently, and for this when they draw the usual three ways.
|
|
2252
|
+
*/
|
|
2253
|
+
render: (data, cases) => {
|
|
2254
|
+
switch (data._tag) {
|
|
2255
|
+
case "Initial":
|
|
2256
|
+
case "Loading": return cases.loading();
|
|
2257
|
+
case "NotFound": return cases.notFound();
|
|
2258
|
+
case "Ready": return cases.data(data.value, fresh);
|
|
2259
|
+
case "Refreshing": return cases.data(data.value, refreshing);
|
|
2260
|
+
case "Failed": return data.previous === void 0 ? cases.failed(data.error) : cases.data(data.previous, {
|
|
2261
|
+
_tag: "Stale",
|
|
2262
|
+
error: data.error
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
1980
2266
|
map: (data, f) => {
|
|
1981
2267
|
switch (data._tag) {
|
|
1982
2268
|
case "Ready": return {
|
|
@@ -2006,6 +2292,15 @@ const RemoteData = {
|
|
|
2006
2292
|
* optimistic layers an application keeps in its Model, the Messages that
|
|
2007
2293
|
* change it, and the pure reducer over them.
|
|
2008
2294
|
*/
|
|
2295
|
+
const noFailures = {
|
|
2296
|
+
connections: {},
|
|
2297
|
+
fields: {}
|
|
2298
|
+
};
|
|
2299
|
+
const emptyRefresh = {
|
|
2300
|
+
generation: 0,
|
|
2301
|
+
requested: /* @__PURE__ */ new Map(),
|
|
2302
|
+
started: /* @__PURE__ */ new Map()
|
|
2303
|
+
};
|
|
2009
2304
|
const initialRemoteModel = {
|
|
2010
2305
|
entities: emptyStore,
|
|
2011
2306
|
connections: {},
|
|
@@ -2014,10 +2309,8 @@ const initialRemoteModel = {
|
|
|
2014
2309
|
mutations: emptyMutationState,
|
|
2015
2310
|
gaps: /* @__PURE__ */ new Set(),
|
|
2016
2311
|
loading: /* @__PURE__ */ new Set(),
|
|
2017
|
-
refresh:
|
|
2018
|
-
|
|
2019
|
-
started: 0
|
|
2020
|
-
}
|
|
2312
|
+
refresh: emptyRefresh,
|
|
2313
|
+
failures: noFailures
|
|
2021
2314
|
};
|
|
2022
2315
|
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
2023
2316
|
const runtimeSchema = Schema.Unknown;
|
|
@@ -2029,9 +2322,10 @@ const remoteModelSchema = () => Schema.Struct({
|
|
|
2029
2322
|
mutations: runtimeSchema,
|
|
2030
2323
|
gaps: runtimeSchema,
|
|
2031
2324
|
loading: runtimeSchema,
|
|
2032
|
-
refresh:
|
|
2033
|
-
|
|
2034
|
-
|
|
2325
|
+
refresh: runtimeSchema,
|
|
2326
|
+
failures: Schema.Struct({
|
|
2327
|
+
connections: Schema.Record(Schema.String, remoteErrorSchema),
|
|
2328
|
+
fields: Schema.Record(Schema.String, remoteErrorSchema)
|
|
2035
2329
|
})
|
|
2036
2330
|
});
|
|
2037
2331
|
const retentionRootsSchema = Schema.Struct({
|
|
@@ -2053,15 +2347,18 @@ const remoteMessageCases = {
|
|
|
2053
2347
|
},
|
|
2054
2348
|
ReadFailed: {
|
|
2055
2349
|
requests: Schema.Array(ReadRequest),
|
|
2056
|
-
error: remoteErrorSchema
|
|
2350
|
+
error: remoteErrorSchema,
|
|
2351
|
+
stream: Schema.optional(Schema.String)
|
|
2057
2352
|
},
|
|
2058
2353
|
RefreshStarted: { requests: Schema.Array(ReadRequest) },
|
|
2059
2354
|
ReadStarted: {
|
|
2060
2355
|
requests: Schema.Array(ReadRequest),
|
|
2061
2356
|
refresh: Schema.optional(Schema.Number)
|
|
2062
2357
|
},
|
|
2358
|
+
QueryStarted: { connections: Schema.Array(Schema.String) },
|
|
2063
2359
|
RetentionChanged: { roots: retentionRootsSchema },
|
|
2064
2360
|
Hydrated: {
|
|
2361
|
+
connections: Schema.optional(Schema.Unknown),
|
|
2065
2362
|
entities: runtimeSchema,
|
|
2066
2363
|
merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
|
|
2067
2364
|
},
|
|
@@ -2082,6 +2379,11 @@ const remoteMessageCases = {
|
|
|
2082
2379
|
requestId: Schema.String,
|
|
2083
2380
|
error: remoteErrorSchema
|
|
2084
2381
|
},
|
|
2382
|
+
OverlayShown: {
|
|
2383
|
+
id: Schema.String,
|
|
2384
|
+
optimistic: Schema.Array(Schema.Unknown)
|
|
2385
|
+
},
|
|
2386
|
+
OverlayLifted: { id: Schema.String },
|
|
2085
2387
|
LiveReceived: {
|
|
2086
2388
|
stream: Schema.String,
|
|
2087
2389
|
event: Schema.Unknown,
|
|
@@ -2110,39 +2412,141 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
|
|
|
2110
2412
|
_tag: Schema.Literal(tag),
|
|
2111
2413
|
...fields
|
|
2112
2414
|
})));
|
|
2415
|
+
/** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
|
|
2416
|
+
const overlayLayer = (id) => `overlay:${id}`;
|
|
2113
2417
|
const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
|
|
2114
2418
|
/**
|
|
2115
|
-
* One mark per requested field
|
|
2116
|
-
* a request id: two reads asking for one field share
|
|
2117
|
-
* answer clears it. Over-clearing shows `Initial` rather
|
|
2118
|
-
* is the safer way to be wrong.
|
|
2419
|
+
* One mark per requested field, `entity\0id\0field`. A read is identified by
|
|
2420
|
+
* what it asks for, not by a request id: two reads asking for one field share
|
|
2421
|
+
* its mark, so the first answer clears it. Over-clearing shows `Initial` rather
|
|
2422
|
+
* than a spinner, which is the safer way to be wrong. The refresh generations
|
|
2423
|
+
* below key off the same marks, so "what a read asks for" means one thing.
|
|
2119
2424
|
*/
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
for (const field of request.fields) yield `${key}\u0000${field}`;
|
|
2124
|
-
}
|
|
2425
|
+
const fieldMark = (entity, id, field) => `${entityKey(entity, id)}\u0000${field}`;
|
|
2426
|
+
const fieldMarks = function* (requests) {
|
|
2427
|
+
for (const request of requests) for (const field of request.fields) yield fieldMark(request.entity, request.id, field);
|
|
2125
2428
|
};
|
|
2126
2429
|
const withLoading = (loading, requests) => {
|
|
2127
2430
|
const next = new Set(loading);
|
|
2128
|
-
for (const mark of
|
|
2431
|
+
for (const mark of fieldMarks(requests)) next.add(mark);
|
|
2129
2432
|
return next;
|
|
2130
2433
|
};
|
|
2131
2434
|
const withoutLoading = (loading, requests) => {
|
|
2132
2435
|
if (loading.size === 0) return loading;
|
|
2133
2436
|
const next = new Set(loading);
|
|
2134
|
-
for (const mark of
|
|
2437
|
+
for (const mark of fieldMarks(requests)) next.delete(mark);
|
|
2135
2438
|
return next;
|
|
2136
2439
|
};
|
|
2137
2440
|
/**
|
|
2441
|
+
* A connection's mark. The leading separator keeps it out of the field marks'
|
|
2442
|
+
* space, whatever a connection identity happens to spell.
|
|
2443
|
+
*/
|
|
2444
|
+
const connectionPrefix = "\0connection\0";
|
|
2445
|
+
const connectionMark = (identity) => `${connectionPrefix}${identity}`;
|
|
2446
|
+
/** Whether a query for this connection is in flight. */
|
|
2447
|
+
const isQueryLoading = (model, identity) => model.loading.size !== 0 && model.loading.has(connectionMark(identity));
|
|
2448
|
+
const withoutQueryLoading = (loading, identity) => {
|
|
2449
|
+
if (!loading.has(connectionMark(identity))) return loading;
|
|
2450
|
+
const next = new Set(loading);
|
|
2451
|
+
next.delete(connectionMark(identity));
|
|
2452
|
+
return next;
|
|
2453
|
+
};
|
|
2454
|
+
/**
|
|
2455
|
+
* The in-flight marks of connections the roots still name. A query the entry
|
|
2456
|
+
* stopped waiting for — it was restarted or deactivated before an answer came —
|
|
2457
|
+
* leaves its mark behind, and a connection released with that mark would read
|
|
2458
|
+
* `Loading` forever the next time something asked for it before sending.
|
|
2459
|
+
*/
|
|
2460
|
+
const prunedQueryLoading = (loading, roots) => {
|
|
2461
|
+
const named = new Set(roots.connections.map((root) => connectionMark(root.identity)));
|
|
2462
|
+
const kept = [...loading].filter((mark) => !mark.startsWith(connectionPrefix) || named.has(mark));
|
|
2463
|
+
return kept.length === loading.size ? loading : new Set(kept);
|
|
2464
|
+
};
|
|
2465
|
+
/**
|
|
2466
|
+
* Drops the refresh marks of entities and connections that were collected.
|
|
2467
|
+
* Nothing observes a collected entity — the retention roots are the active
|
|
2468
|
+
* Surfaces — so no read entry's generation can fall back when its mark goes.
|
|
2469
|
+
* Without this the generations would be bounded by how many things the
|
|
2470
|
+
* application has ever refreshed rather than by what it currently holds, which
|
|
2471
|
+
* for a list refreshed on a timer is the same as not bounded at all.
|
|
2472
|
+
*/
|
|
2473
|
+
const prunedRefresh = (refresh, retained) => {
|
|
2474
|
+
const live = (mark) => mark.startsWith(connectionPrefix) ? retained.connections[mark.slice(12)] !== void 0 : retained.entities[mark.slice(0, mark.indexOf("\0"))] !== void 0;
|
|
2475
|
+
const keep = (marks) => {
|
|
2476
|
+
const next = /* @__PURE__ */ new Map();
|
|
2477
|
+
for (const [mark, generation] of marks) if (live(mark)) next.set(mark, generation);
|
|
2478
|
+
return next.size === marks.size ? marks : next;
|
|
2479
|
+
};
|
|
2480
|
+
const requested = keep(refresh.requested);
|
|
2481
|
+
const started = keep(refresh.started);
|
|
2482
|
+
return requested === refresh.requested && started === refresh.started ? refresh : {
|
|
2483
|
+
...refresh,
|
|
2484
|
+
requested,
|
|
2485
|
+
started
|
|
2486
|
+
};
|
|
2487
|
+
};
|
|
2488
|
+
/**
|
|
2489
|
+
* The generation a read is planned under: the highest any of the fields it
|
|
2490
|
+
* reads or connections it runs was refreshed at. A read entry carries this as a
|
|
2491
|
+
* dependency, so it restarts when what it observes is refreshed and not when
|
|
2492
|
+
* anything else is.
|
|
2493
|
+
*/
|
|
2494
|
+
const refreshedAt = (refresh, requests, connections = []) => {
|
|
2495
|
+
let at = 0;
|
|
2496
|
+
for (const mark of fieldMarks(requests)) at = Math.max(at, refresh.requested.get(mark) ?? 0);
|
|
2497
|
+
for (const identity of connections) at = Math.max(at, refresh.requested.get(connectionMark(identity)) ?? 0);
|
|
2498
|
+
return at;
|
|
2499
|
+
};
|
|
2500
|
+
/**
|
|
2501
|
+
* Marks every field of `requests` and every connection in `connections`
|
|
2502
|
+
* refreshed at the next generation.
|
|
2503
|
+
*/
|
|
2504
|
+
const withRefreshRequested = (refresh, requests, connections = []) => {
|
|
2505
|
+
const generation = refresh.generation + 1;
|
|
2506
|
+
const requested = new Map(refresh.requested);
|
|
2507
|
+
for (const mark of fieldMarks(requests)) requested.set(mark, generation);
|
|
2508
|
+
for (const identity of connections) requested.set(connectionMark(identity), generation);
|
|
2509
|
+
return {
|
|
2510
|
+
...refresh,
|
|
2511
|
+
generation,
|
|
2512
|
+
requested
|
|
2513
|
+
};
|
|
2514
|
+
};
|
|
2515
|
+
/**
|
|
2516
|
+
* Records that a read of `requests` began under `generation`. A read that
|
|
2517
|
+
* carries no generation began under none, and leaves the marks as they are.
|
|
2518
|
+
*/
|
|
2519
|
+
const withRefreshStarted = (refresh, requests, generation) => {
|
|
2520
|
+
if (generation === void 0 || generation === 0) return refresh;
|
|
2521
|
+
let started;
|
|
2522
|
+
for (const mark of fieldMarks(requests)) {
|
|
2523
|
+
if ((refresh.started.get(mark) ?? 0) >= generation) continue;
|
|
2524
|
+
started ??= new Map(refresh.started);
|
|
2525
|
+
started.set(mark, generation);
|
|
2526
|
+
}
|
|
2527
|
+
return started === void 0 ? refresh : {
|
|
2528
|
+
...refresh,
|
|
2529
|
+
started
|
|
2530
|
+
};
|
|
2531
|
+
};
|
|
2532
|
+
/**
|
|
2533
|
+
* Whether one field's read already began under the generation that field was
|
|
2534
|
+
* last refreshed at. Such a read is in flight and would otherwise outlive the
|
|
2535
|
+
* refresh, so the refresh takes a new generation to restart it even though the
|
|
2536
|
+
* field is already stale.
|
|
2537
|
+
*/
|
|
2538
|
+
const refreshIsInFlight = (refresh, request, field) => {
|
|
2539
|
+
const mark = fieldMark(request.entity, request.id, field);
|
|
2540
|
+
return (refresh.started.get(mark) ?? 0) === (refresh.requested.get(mark) ?? 0);
|
|
2541
|
+
};
|
|
2542
|
+
/**
|
|
2138
2543
|
* Whether a read is fetching any of `fields` for this entity. Consulted only
|
|
2139
2544
|
* when the store lacks the value, which is what separates `Loading` from
|
|
2140
2545
|
* `Initial`.
|
|
2141
2546
|
*/
|
|
2142
2547
|
const isLoading = (model, entity, id, fields) => {
|
|
2143
2548
|
if (model.loading.size === 0) return false;
|
|
2144
|
-
|
|
2145
|
-
return fields.some((field) => model.loading.has(`${key}\u0000${field}`));
|
|
2549
|
+
return fields.some((field) => model.loading.has(fieldMark(entity, id, field)));
|
|
2146
2550
|
};
|
|
2147
2551
|
const setConnectionStale = (connections, connection, stale) => {
|
|
2148
2552
|
const current = connections[connection] ?? emptyConnection;
|
|
@@ -2172,39 +2576,71 @@ const updateRemote = (model, message) => {
|
|
|
2172
2576
|
case "ReadReceived": return {
|
|
2173
2577
|
...model,
|
|
2174
2578
|
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)
|
|
2579
|
+
loading: withoutLoading(model.loading, message.requests),
|
|
2580
|
+
failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
|
|
2189
2581
|
};
|
|
2190
|
-
case "
|
|
2191
|
-
|
|
2192
|
-
return
|
|
2582
|
+
case "ReadFailed":
|
|
2583
|
+
if (message.stream !== void 0) return markGap(model, message.stream);
|
|
2584
|
+
return {
|
|
2193
2585
|
...model,
|
|
2194
|
-
entities
|
|
2586
|
+
entities: setStale(model.entities, marksOf(message.requests), false),
|
|
2587
|
+
loading: withoutLoading(model.loading, message.requests),
|
|
2588
|
+
failures: withFieldFailures(model.failures, message.requests, message.error)
|
|
2589
|
+
};
|
|
2590
|
+
case "RetentionChanged": {
|
|
2591
|
+
const retained = gc(model, message.roots);
|
|
2592
|
+
return {
|
|
2593
|
+
...model,
|
|
2594
|
+
...retained,
|
|
2595
|
+
refresh: prunedRefresh(model.refresh, retained),
|
|
2596
|
+
failures: prunedFailures(model, message.roots),
|
|
2597
|
+
loading: prunedQueryLoading(model.loading, message.roots)
|
|
2195
2598
|
};
|
|
2196
2599
|
}
|
|
2197
|
-
case "
|
|
2198
|
-
const
|
|
2600
|
+
case "Hydrated": {
|
|
2601
|
+
const restored = Object.entries(message.connections ?? {}).map(([identity, segments]) => [identity, {
|
|
2602
|
+
segments: segments.map((edges) => ({
|
|
2603
|
+
edges: [...edges],
|
|
2604
|
+
start: unknown,
|
|
2605
|
+
end: unknown
|
|
2606
|
+
})),
|
|
2607
|
+
stale: true
|
|
2608
|
+
}]);
|
|
2199
2609
|
return {
|
|
2200
2610
|
...model,
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
...model.
|
|
2204
|
-
|
|
2611
|
+
entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge),
|
|
2612
|
+
connections: message.merge === "replace" ? {
|
|
2613
|
+
...model.connections,
|
|
2614
|
+
...Object.fromEntries(restored)
|
|
2615
|
+
} : {
|
|
2616
|
+
...Object.fromEntries(restored),
|
|
2617
|
+
...model.connections
|
|
2205
2618
|
}
|
|
2206
2619
|
};
|
|
2207
2620
|
}
|
|
2621
|
+
case "RefreshStarted": {
|
|
2622
|
+
const asked = message.requests.reduce((store, request) => isTombstone(store, entityKey(request.entity, request.id)) ? remove(store, entityKey(request.entity, request.id)) : store, model.entities);
|
|
2623
|
+
const entities = setStale(asked, marksOf(message.requests), true);
|
|
2624
|
+
const failures = withoutFieldFailures(model.failures, fieldMarks(message.requests));
|
|
2625
|
+
return entities === model.entities && failures === model.failures ? model : {
|
|
2626
|
+
...model,
|
|
2627
|
+
entities,
|
|
2628
|
+
failures
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
case "QueryStarted": {
|
|
2632
|
+
const loading = new Set(model.loading);
|
|
2633
|
+
for (const identity of message.connections) loading.add(connectionMark(identity));
|
|
2634
|
+
return loading.size === model.loading.size ? model : {
|
|
2635
|
+
...model,
|
|
2636
|
+
loading
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
case "ReadStarted": return {
|
|
2640
|
+
...model,
|
|
2641
|
+
loading: withLoading(model.loading, message.requests),
|
|
2642
|
+
refresh: withRefreshStarted(model.refresh, message.requests, message.refresh)
|
|
2643
|
+
};
|
|
2208
2644
|
case "MutationStarted": return {
|
|
2209
2645
|
...model,
|
|
2210
2646
|
mutations: beginMutation(model.mutations, message.requestId),
|
|
@@ -2216,7 +2652,19 @@ const updateRemote = (model, message) => {
|
|
|
2216
2652
|
...model,
|
|
2217
2653
|
entities: settled.store,
|
|
2218
2654
|
mutations: settled.state,
|
|
2219
|
-
optimistic: settled.optimistic
|
|
2655
|
+
optimistic: settled.optimistic,
|
|
2656
|
+
failures: withoutFieldFailures(model.failures, [...patchedMarks(message.entities), ...(message.deleted ?? []).flatMap((ref) => entityFailureMarks(model.failures, entityKey(ref.entity, ref.id)))])
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
case "OverlayShown": return {
|
|
2660
|
+
...model,
|
|
2661
|
+
optimistic: beginOptimistic(settleFailure(model.optimistic, overlayLayer(message.id)), overlayLayer(message.id), message.optimistic)
|
|
2662
|
+
};
|
|
2663
|
+
case "OverlayLifted": {
|
|
2664
|
+
const optimistic = settleFailure(model.optimistic, overlayLayer(message.id));
|
|
2665
|
+
return optimistic.layers.length === model.optimistic.layers.length && optimistic.overlays.length === model.optimistic.overlays.length ? model : {
|
|
2666
|
+
...model,
|
|
2667
|
+
optimistic
|
|
2220
2668
|
};
|
|
2221
2669
|
}
|
|
2222
2670
|
case "MutationFailed": return {
|
|
@@ -2227,18 +2675,26 @@ const updateRemote = (model, message) => {
|
|
|
2227
2675
|
case "LiveReceived": {
|
|
2228
2676
|
const state = model.live[message.stream] ?? emptyLiveState;
|
|
2229
2677
|
if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
|
|
2230
|
-
const
|
|
2231
|
-
|
|
2678
|
+
const event = message.event;
|
|
2679
|
+
const applied = applyEntityEvent(state, model.entities, event, message.now);
|
|
2680
|
+
if (applied.outcome === "gap") return markGap(model, message.stream);
|
|
2681
|
+
const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
|
|
2682
|
+
...event.ref,
|
|
2683
|
+
values: event.values
|
|
2684
|
+
}])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
|
|
2685
|
+
return clearGap({
|
|
2232
2686
|
...model,
|
|
2233
2687
|
entities: applied.store,
|
|
2234
2688
|
live: {
|
|
2235
2689
|
...model.live,
|
|
2236
2690
|
[message.stream]: applied.state
|
|
2237
|
-
}
|
|
2691
|
+
},
|
|
2692
|
+
failures: withoutFieldFailures(model.failures, settled)
|
|
2238
2693
|
}, message.stream);
|
|
2239
2694
|
}
|
|
2240
2695
|
const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
|
|
2241
|
-
|
|
2696
|
+
if (applied.outcome === "gap") return markGap(model, message.stream);
|
|
2697
|
+
const next = clearGap({
|
|
2242
2698
|
...model,
|
|
2243
2699
|
optimistic: applied.optimistic,
|
|
2244
2700
|
live: {
|
|
@@ -2246,10 +2702,16 @@ const updateRemote = (model, message) => {
|
|
|
2246
2702
|
[message.stream]: applied.state
|
|
2247
2703
|
}
|
|
2248
2704
|
}, message.stream);
|
|
2705
|
+
return applied.invalidated === void 0 ? next : updateRemote(next, {
|
|
2706
|
+
_tag: "ConnectionInvalidated",
|
|
2707
|
+
connection: applied.invalidated
|
|
2708
|
+
});
|
|
2249
2709
|
}
|
|
2250
2710
|
case "GapCleared": return clearGap(model, message.stream);
|
|
2251
2711
|
case "ConnectionMerged": {
|
|
2252
2712
|
const current = model.connections[message.connection] ?? emptyConnection;
|
|
2713
|
+
const failures = withoutConnectionFailure(model.failures, message.connection);
|
|
2714
|
+
const loading = withoutQueryLoading(model.loading, message.connection);
|
|
2253
2715
|
const replaces = message.refreshes === true && current.stale;
|
|
2254
2716
|
const merged = merge(replaces ? emptyConnection : current, message.page);
|
|
2255
2717
|
return {
|
|
@@ -2261,25 +2723,129 @@ const updateRemote = (model, message) => {
|
|
|
2261
2723
|
stale: false
|
|
2262
2724
|
} : merged
|
|
2263
2725
|
},
|
|
2264
|
-
optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
|
|
2726
|
+
optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending),
|
|
2727
|
+
failures,
|
|
2728
|
+
loading
|
|
2265
2729
|
};
|
|
2266
2730
|
}
|
|
2267
2731
|
case "ConnectionInvalidated":
|
|
2268
|
-
if (model.connections[message.connection]?.stale === true) return model;
|
|
2732
|
+
if (model.connections[message.connection]?.stale === true && !(message.connection in model.failures.connections)) return model;
|
|
2269
2733
|
return {
|
|
2270
2734
|
...model,
|
|
2271
|
-
connections: setConnectionStale(model.connections, message.connection, true)
|
|
2735
|
+
connections: setConnectionStale(model.connections, message.connection, true),
|
|
2736
|
+
failures: withoutConnectionFailure(model.failures, message.connection)
|
|
2272
2737
|
};
|
|
2273
|
-
case "QueryFailed": return
|
|
2738
|
+
case "QueryFailed": return {
|
|
2274
2739
|
...model,
|
|
2275
|
-
connections: setConnectionStale(model.connections, message.connection, false)
|
|
2276
|
-
|
|
2740
|
+
connections: message.connection in model.connections ? setConnectionStale(model.connections, message.connection, false) : model.connections,
|
|
2741
|
+
failures: {
|
|
2742
|
+
...model.failures,
|
|
2743
|
+
connections: {
|
|
2744
|
+
...model.failures.connections,
|
|
2745
|
+
[message.connection]: message.error
|
|
2746
|
+
}
|
|
2747
|
+
},
|
|
2748
|
+
loading: withoutQueryLoading(model.loading, message.connection)
|
|
2749
|
+
};
|
|
2277
2750
|
case "ConnectionRefreshed": return {
|
|
2278
2751
|
...model,
|
|
2279
2752
|
connections: setConnectionStale(model.connections, message.connection, false)
|
|
2280
2753
|
};
|
|
2281
2754
|
}
|
|
2282
2755
|
};
|
|
2756
|
+
/** The failures without one connection's, keeping identity when it had none. */
|
|
2757
|
+
const withoutConnectionFailure = (failures, connection) => {
|
|
2758
|
+
if (!(connection in failures.connections)) return failures;
|
|
2759
|
+
const { [connection]: _settled, ...connections } = failures.connections;
|
|
2760
|
+
return {
|
|
2761
|
+
...failures,
|
|
2762
|
+
connections
|
|
2763
|
+
};
|
|
2764
|
+
};
|
|
2765
|
+
/** The failures with every field a read asked for failed with `error`. */
|
|
2766
|
+
const withFieldFailures = (failures, requests, error) => {
|
|
2767
|
+
const fields = { ...failures.fields };
|
|
2768
|
+
for (const mark of fieldMarks(requests)) fields[mark] = error;
|
|
2769
|
+
return {
|
|
2770
|
+
...failures,
|
|
2771
|
+
fields
|
|
2772
|
+
};
|
|
2773
|
+
};
|
|
2774
|
+
/**
|
|
2775
|
+
* The failures without the fields `settled` names, keeping identity when none
|
|
2776
|
+
* of them had failed, which is nearly always: this runs on every write.
|
|
2777
|
+
*/
|
|
2778
|
+
const withoutFieldFailures = (failures, settled) => {
|
|
2779
|
+
if (Object.keys(failures.fields).length === 0) return failures;
|
|
2780
|
+
const marks = [...settled].filter((mark) => mark in failures.fields);
|
|
2781
|
+
if (marks.length === 0) return failures;
|
|
2782
|
+
const fields = { ...failures.fields };
|
|
2783
|
+
for (const mark of marks) delete fields[mark];
|
|
2784
|
+
return {
|
|
2785
|
+
...failures,
|
|
2786
|
+
fields
|
|
2787
|
+
};
|
|
2788
|
+
};
|
|
2789
|
+
/** Every failed field of one entity: what deleting the entity settles. */
|
|
2790
|
+
const entityFailureMarks = (failures, key) => Object.keys(failures.fields).filter((mark) => mark.startsWith(`${key}\u0000`));
|
|
2791
|
+
/** The field marks of written values: each patch settles the fields it carries. */
|
|
2792
|
+
const patchedMarks = function* (patches) {
|
|
2793
|
+
for (const patch of patches) for (const field of Object.keys(patch.values)) yield fieldMark(patch.entity, patch.id, field);
|
|
2794
|
+
};
|
|
2795
|
+
/**
|
|
2796
|
+
* The failures of what the roots still reach. A released connection's or
|
|
2797
|
+
* entity's failure goes with it, so asking for it again later asks the server
|
|
2798
|
+
* again rather than repeating an error that may no longer hold.
|
|
2799
|
+
*
|
|
2800
|
+
* Reached is retention's own walk, taken over the Model before collection. It
|
|
2801
|
+
* counts a relation's target and a list's row that the store does not hold,
|
|
2802
|
+
* which is exactly a value whose first read failed: it is still on screen,
|
|
2803
|
+
* behind the parent that refers to it.
|
|
2804
|
+
*/
|
|
2805
|
+
const prunedFailures = (model, roots) => {
|
|
2806
|
+
const { failures } = model;
|
|
2807
|
+
const named = new Set(roots.connections.map((root) => root.identity));
|
|
2808
|
+
const connections = Object.entries(failures.connections).filter(([identity]) => named.has(identity));
|
|
2809
|
+
const kept = Object.keys(failures.fields).length === 0 ? /* @__PURE__ */ new Set() : reachable(model.entities, roots, model.connections, model.optimistic, model.mutations.pending);
|
|
2810
|
+
const fields = Object.entries(failures.fields).filter(([mark]) => kept.has(mark.slice(0, mark.indexOf("\0"))));
|
|
2811
|
+
return connections.length === Object.keys(failures.connections).length && fields.length === Object.keys(failures.fields).length ? failures : {
|
|
2812
|
+
connections: Object.fromEntries(connections),
|
|
2813
|
+
fields: Object.fromEntries(fields)
|
|
2814
|
+
};
|
|
2815
|
+
};
|
|
2816
|
+
/** Whether this field's last read failed and nothing has settled it since. */
|
|
2817
|
+
const isFieldFailed = (model, entity, id, field) => fieldMark(entity, id, field) in model.failures.fields;
|
|
2818
|
+
/**
|
|
2819
|
+
* The first failure among the fields `relation` reads of an entity, following
|
|
2820
|
+
* each relation the store holds into its targets, as `assemble` does. A read
|
|
2821
|
+
* that consults it shows `Failed` for a related entity's failure too, rather
|
|
2822
|
+
* than waiting on a field nothing will fetch.
|
|
2823
|
+
*/
|
|
2824
|
+
const failureOf = (model, entity, id, relation) => {
|
|
2825
|
+
const failed = model.failures.fields;
|
|
2826
|
+
if (Object.keys(failed).length === 0) return void 0;
|
|
2827
|
+
const walked = /* @__PURE__ */ new Map();
|
|
2828
|
+
const walk = (target, targetId, requirement) => {
|
|
2829
|
+
const key = entityKey(target, targetId);
|
|
2830
|
+
const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
|
|
2831
|
+
if (seen.has(requirement)) return void 0;
|
|
2832
|
+
seen.add(requirement);
|
|
2833
|
+
walked.set(key, seen);
|
|
2834
|
+
for (const field of requirement.fields) {
|
|
2835
|
+
const error = failed[fieldMark(target, targetId, field)];
|
|
2836
|
+
if (error !== void 0) return error;
|
|
2837
|
+
const nested = requirement.relations?.[field];
|
|
2838
|
+
if (nested === void 0) continue;
|
|
2839
|
+
const value = readField(model.entities, key, field);
|
|
2840
|
+
if (value._tag === "None") continue;
|
|
2841
|
+
for (const ref of targetsOf(value.value, nested)) {
|
|
2842
|
+
const found = walk(ref.entity, ref.id, nested);
|
|
2843
|
+
if (found !== void 0) return found;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
};
|
|
2847
|
+
return walk(entity, id, relation);
|
|
2848
|
+
};
|
|
2283
2849
|
/** Appends (after) or prepends (before) an incoming page onto the stored one. */
|
|
2284
2850
|
const mergeWireRefPages = (current, next, direction) => {
|
|
2285
2851
|
const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
|
|
@@ -2414,6 +2980,22 @@ const storeOf = (bound, model) => {
|
|
|
2414
2980
|
const remote = bound.store.get(model);
|
|
2415
2981
|
return visibleStoreOf(remote.entities, remote.optimistic);
|
|
2416
2982
|
};
|
|
2983
|
+
/**
|
|
2984
|
+
* The Model as it would be with nothing optimistic pending. Every read reaches
|
|
2985
|
+
* the layers through the Model it is given, so dropping them here is what makes
|
|
2986
|
+
* a read confirmed — no read has to know it is being read confirmed.
|
|
2987
|
+
*/
|
|
2988
|
+
const withoutOptimistic = (bound, model) => {
|
|
2989
|
+
const remote = bound.store.get(model);
|
|
2990
|
+
return remote.optimistic.layers.length === 0 && remote.optimistic.overlays.length === 0 ? model : bound.store.set(model, {
|
|
2991
|
+
...remote,
|
|
2992
|
+
optimistic: emptyOptimistic
|
|
2993
|
+
});
|
|
2994
|
+
};
|
|
2995
|
+
const confirmed = (bound, projection) => ({
|
|
2996
|
+
...projection,
|
|
2997
|
+
read: (root) => projection.read(withoutOptimistic(bound, root))
|
|
2998
|
+
});
|
|
2417
2999
|
const visibleStores = /* @__PURE__ */ new WeakMap();
|
|
2418
3000
|
const visibleStoreOf = (entities, optimistic) => {
|
|
2419
3001
|
let byStore = visibleStores.get(optimistic);
|
|
@@ -2499,6 +3081,10 @@ const askedOf = (projection) => projection === void 0 ? nothingAsked : {
|
|
|
2499
3081
|
* fresh contributes its visible items' selected fields to the entity plan; one
|
|
2500
3082
|
* it does not hold, or holds stale, is a query to run (its items are planned
|
|
2501
3083
|
* once the page arrives).
|
|
3084
|
+
*
|
|
3085
|
+
* A connection whose query failed is not run again here, short of `force`: the
|
|
3086
|
+
* failure is what its read shows, and retrying is `Remote.refresh`'s to ask
|
|
3087
|
+
* for. The rows it already holds are still planned, since they are on screen.
|
|
2502
3088
|
*/
|
|
2503
3089
|
const planAsked = (remote, asked, options) => {
|
|
2504
3090
|
const queries = [];
|
|
@@ -2506,18 +3092,46 @@ const planAsked = (remote, asked, options) => {
|
|
|
2506
3092
|
const connections = Requirement.mergeConnections(asked.connections);
|
|
2507
3093
|
for (const connection of connections) {
|
|
2508
3094
|
const known = remote.connections[connection.identity];
|
|
2509
|
-
if (known === void 0 || known.stale || options.force === true) {
|
|
3095
|
+
if (!(options.force !== true && connection.identity in remote.failures.connections) && (known === void 0 || known.stale || options.force === true)) {
|
|
2510
3096
|
queries.push(connection);
|
|
2511
3097
|
continue;
|
|
2512
3098
|
}
|
|
3099
|
+
if (known === void 0) continue;
|
|
2513
3100
|
const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities);
|
|
2514
3101
|
items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
|
|
2515
3102
|
}
|
|
3103
|
+
const planned = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options);
|
|
2516
3104
|
return {
|
|
2517
|
-
requirements:
|
|
3105
|
+
requirements: options.force === true ? planned : withoutFailedFields(remote, planned),
|
|
2518
3106
|
queries
|
|
2519
3107
|
};
|
|
2520
3108
|
};
|
|
3109
|
+
/**
|
|
3110
|
+
* The plan without the fields whose last read failed. Like a failed query,
|
|
3111
|
+
* a failed field is shown rather than retried on its own, so a persistent
|
|
3112
|
+
* error is not asked again on every unrelated restart of the read entry. A
|
|
3113
|
+
* field leaving the request takes its window and its relation with it.
|
|
3114
|
+
*/
|
|
3115
|
+
const withoutFailedFields = (remote, planned) => {
|
|
3116
|
+
if (Object.keys(remote.failures.fields).length === 0) return planned;
|
|
3117
|
+
return planned.flatMap((requirement) => {
|
|
3118
|
+
const fields = requirement.fields.filter((field) => !isFieldFailed(remote, requirement.entity, requirement.id, field));
|
|
3119
|
+
if (fields.length === requirement.fields.length) return [requirement];
|
|
3120
|
+
if (fields.length === 0) return [];
|
|
3121
|
+
const kept = new Set(fields);
|
|
3122
|
+
const only = (record) => record === void 0 ? void 0 : Object.fromEntries(Object.entries(record).filter(([field]) => kept.has(field)));
|
|
3123
|
+
const windows = only(requirement.windows);
|
|
3124
|
+
const relations = only(requirement.relations);
|
|
3125
|
+
return [{
|
|
3126
|
+
entity: requirement.entity,
|
|
3127
|
+
id: requirement.id,
|
|
3128
|
+
fields,
|
|
3129
|
+
...windows === void 0 || Object.keys(windows).length === 0 ? {} : { windows },
|
|
3130
|
+
...relations === void 0 || Object.keys(relations).length === 0 ? {} : { relations },
|
|
3131
|
+
...requirement.live === void 0 ? {} : { live: requirement.live }
|
|
3132
|
+
}];
|
|
3133
|
+
});
|
|
3134
|
+
};
|
|
2521
3135
|
/** The entity requirements a page's edges add under a selection. */
|
|
2522
3136
|
const itemsOf = (edges, select) => edges.filter((edge) => edge.entity === select.entity).map((edge) => ({
|
|
2523
3137
|
...select,
|
|
@@ -2545,22 +3159,52 @@ const queryRequestOf = (query) => {
|
|
|
2545
3159
|
* `refreshes` marks the page as answering the connection's refresh, so one
|
|
2546
3160
|
* Message both merges it and clears `stale`.
|
|
2547
3161
|
*/
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
3162
|
+
/**
|
|
3163
|
+
* The page a window asked for, or the number of edges that came back instead.
|
|
3164
|
+
*
|
|
3165
|
+
* A window is a request the client made and the server answered, so more edges
|
|
3166
|
+
* than were asked for is a protocol disagreement rather than a windfall: the
|
|
3167
|
+
* client cannot tell which of them the window meant, the connection's
|
|
3168
|
+
* boundaries stop describing what it holds, and a `first: 25` that quietly
|
|
3169
|
+
* becomes a thousand is a memory event with no error attached to it.
|
|
3170
|
+
*
|
|
3171
|
+
* `undefined` when the page is within its window, or when no size was asked
|
|
3172
|
+
* for — `after`/`before` with no `first`/`last` bounds nothing.
|
|
3173
|
+
*/
|
|
3174
|
+
const overrun = (window, edges) => {
|
|
3175
|
+
const asked = window.first ?? window.last;
|
|
3176
|
+
return asked !== void 0 && edges.length > asked ? {
|
|
3177
|
+
asked,
|
|
3178
|
+
got: edges.length
|
|
3179
|
+
} : void 0;
|
|
3180
|
+
};
|
|
3181
|
+
const pageMessage = (connection, result, refreshes = false, window = {}) => {
|
|
3182
|
+
const over = overrun(window, result.edges);
|
|
3183
|
+
if (over !== void 0) return {
|
|
3184
|
+
_tag: "QueryFailed",
|
|
3185
|
+
connection,
|
|
3186
|
+
error: {
|
|
3187
|
+
_tag: "RemoteProtocolError",
|
|
3188
|
+
message: `the server returned ${over.got} edges for a window of ${over.asked}`
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3191
|
+
return {
|
|
3192
|
+
_tag: "ConnectionMerged",
|
|
3193
|
+
connection,
|
|
3194
|
+
page: {
|
|
3195
|
+
edges: result.edges.map((edge) => ({
|
|
3196
|
+
key: edge.key,
|
|
3197
|
+
ref: {
|
|
3198
|
+
entity: edge.entity,
|
|
3199
|
+
id: edge.id
|
|
3200
|
+
}
|
|
3201
|
+
})),
|
|
3202
|
+
start: result.start,
|
|
3203
|
+
end: result.end
|
|
3204
|
+
},
|
|
3205
|
+
...refreshes ? { refreshes } : {}
|
|
3206
|
+
};
|
|
3207
|
+
};
|
|
2564
3208
|
/**
|
|
2565
3209
|
* The retention roots of some projections: their requirements, their
|
|
2566
3210
|
* connections (each with the union of what the projections select of its
|
|
@@ -2608,7 +3252,7 @@ const queryMessage = (query) => Effect.gen(function* () {
|
|
|
2608
3252
|
_tag: "QueryFailed",
|
|
2609
3253
|
connection: query.identity,
|
|
2610
3254
|
error: remoteError(result.failure)
|
|
2611
|
-
} : pageMessage(query.identity, result.success, true);
|
|
3255
|
+
} : pageMessage(query.identity, result.success, true, query.window);
|
|
2612
3256
|
});
|
|
2613
3257
|
/** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
|
|
2614
3258
|
const PlannedQuery = Schema.Struct({
|
|
@@ -2639,7 +3283,7 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
2639
3283
|
const remote = bound.store.get(model);
|
|
2640
3284
|
const planned = planAsked(remote, askedOf(model), RemotePolicy.toPlan(policy, now()));
|
|
2641
3285
|
return {
|
|
2642
|
-
refresh: remote.refresh.
|
|
3286
|
+
refresh: refreshedAt(remote.refresh, planned.requirements, planned.queries.map((query) => query.identity)),
|
|
2643
3287
|
requirements: planned.requirements,
|
|
2644
3288
|
queries: planned.queries.map(({ identity, window, select }) => ({
|
|
2645
3289
|
identity,
|
|
@@ -2648,14 +3292,17 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
2648
3292
|
}))
|
|
2649
3293
|
};
|
|
2650
3294
|
},
|
|
2651
|
-
dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(requirements.length === 0 ?
|
|
3295
|
+
dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
|
|
2652
3296
|
_tag: "ReadStarted",
|
|
2653
3297
|
requests: requirements,
|
|
2654
3298
|
refresh
|
|
2655
3299
|
}), ...RemotePolicy.refreshes(policy) ? [toMessage({
|
|
2656
3300
|
_tag: "RefreshStarted",
|
|
2657
3301
|
requests: requirements
|
|
2658
|
-
})] : []]
|
|
3302
|
+
})] : []], ...queries.length === 0 ? [] : [toMessage({
|
|
3303
|
+
_tag: "QueryStarted",
|
|
3304
|
+
connections: queries.map((query) => query.identity)
|
|
3305
|
+
})]]), Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
|
|
2659
3306
|
};
|
|
2660
3307
|
};
|
|
2661
3308
|
/** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
|
|
@@ -2685,7 +3332,8 @@ const liveEntry = (bound, requirementsOf, toMessage, options) => ({
|
|
|
2685
3332
|
})), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
|
|
2686
3333
|
_tag: "ReadFailed",
|
|
2687
3334
|
requests: requirements,
|
|
2688
|
-
error: remoteError(error)
|
|
3335
|
+
error: remoteError(error),
|
|
3336
|
+
stream: liveStreamKey(requirements)
|
|
2689
3337
|
}))))
|
|
2690
3338
|
});
|
|
2691
3339
|
/** The projection an active Surface has for this Model, if it is active. */
|
|
@@ -2778,8 +3426,18 @@ const Remote = {
|
|
|
2778
3426
|
value: decoded.success
|
|
2779
3427
|
};
|
|
2780
3428
|
});
|
|
2781
|
-
|
|
2782
|
-
|
|
3429
|
+
const remote = bound.store.get(root);
|
|
3430
|
+
const failure = failureOf(remote, selection.entity, id, relation);
|
|
3431
|
+
if (present !== void 0) return failure !== void 0 && (present._tag === "Ready" || present._tag === "Refreshing") ? {
|
|
3432
|
+
_tag: "Failed",
|
|
3433
|
+
error: failure,
|
|
3434
|
+
previous: present.value
|
|
3435
|
+
} : present;
|
|
3436
|
+
if (isLoading(remote, selection.entity, id, relation.fields)) return { _tag: "Loading" };
|
|
3437
|
+
return failure === void 0 ? { _tag: "Initial" } : {
|
|
3438
|
+
_tag: "Failed",
|
|
3439
|
+
error: failure
|
|
3440
|
+
};
|
|
2783
3441
|
}
|
|
2784
3442
|
});
|
|
2785
3443
|
},
|
|
@@ -2801,6 +3459,12 @@ const Remote = {
|
|
|
2801
3459
|
*/
|
|
2802
3460
|
storeOf: (bound, model) => storeOf(bound, model),
|
|
2803
3461
|
/**
|
|
3462
|
+
* The projection read against the server-derived store alone: the same
|
|
3463
|
+
* requirements, planned the same way, with the pending optimistic layers and
|
|
3464
|
+
* connection overlays left off. See the bound `Remote.confirmed`.
|
|
3465
|
+
*/
|
|
3466
|
+
confirmed: (bound, projection) => confirmed(bound, projection),
|
|
3467
|
+
/**
|
|
2804
3468
|
* Executes the plan against the `RemoteClient` and returns a new store. Used
|
|
2805
3469
|
* for SSR route prefetch, hover prefetch, and tests. Never called during render.
|
|
2806
3470
|
* `policy` decides what a present field means (default cache-first); `now`
|
|
@@ -2832,6 +3496,9 @@ const Remote = {
|
|
|
2832
3496
|
* again with `next`/`previous`. The read entries restart, so a read or query
|
|
2833
3497
|
* sent before the refresh is not applied after it.
|
|
2834
3498
|
*
|
|
3499
|
+
* It is also how a failed query is retried: a connection whose query failed
|
|
3500
|
+
* is not run again on its own, so its read stays `Failed` until this asks.
|
|
3501
|
+
*
|
|
2835
3502
|
* The projection must be observed, which it is while it is on screen. Live
|
|
2836
3503
|
* subscriptions are left as they are. For data nothing observes (SSR, tests),
|
|
2837
3504
|
* use `Remote.prefetch` with `RemotePolicy.networkOnly`. Refreshing what is
|
|
@@ -2847,19 +3514,17 @@ const Remote = {
|
|
|
2847
3514
|
return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
|
|
2848
3515
|
});
|
|
2849
3516
|
const requirements = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], { force: true });
|
|
3517
|
+
const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0 || connection.identity in remote.failures.connections).map((connection) => connection.identity);
|
|
2850
3518
|
const marked = [...requirements.length === 0 ? [] : [{
|
|
2851
3519
|
_tag: "RefreshStarted",
|
|
2852
3520
|
requests: requirements
|
|
2853
|
-
}], ...
|
|
3521
|
+
}], ...invalidated.map((connection) => ({
|
|
2854
3522
|
_tag: "ConnectionInvalidated",
|
|
2855
|
-
connection
|
|
3523
|
+
connection
|
|
2856
3524
|
}))].reduce(updateRemote, remote);
|
|
2857
|
-
const inFlight =
|
|
3525
|
+
const inFlight = requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true && refreshIsInFlight(remote.refresh, requirement, field)));
|
|
2858
3526
|
if (marked === remote && !inFlight) return model;
|
|
2859
|
-
const refresh =
|
|
2860
|
-
...marked.refresh,
|
|
2861
|
-
requested: marked.refresh.requested + 1
|
|
2862
|
-
};
|
|
3527
|
+
const refresh = withRefreshRequested(marked.refresh, requirements, invalidated);
|
|
2863
3528
|
return bound.store.set(model, {
|
|
2864
3529
|
...marked,
|
|
2865
3530
|
refresh
|
|
@@ -2926,7 +3591,7 @@ const Remote = {
|
|
|
2926
3591
|
});
|
|
2927
3592
|
}),
|
|
2928
3593
|
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
2929
|
-
queryMessage: (ref, result) => pageMessage(ref.identity, result),
|
|
3594
|
+
queryMessage: (ref, result) => pageMessage(ref.identity, result, false, ref.window),
|
|
2930
3595
|
/**
|
|
2931
3596
|
* The edges a connection shows: its server-known region with pending and
|
|
2932
3597
|
* confirmed overlays placed around it, minus removed edges and edges whose
|
|
@@ -2983,8 +3648,10 @@ const Remote = {
|
|
|
2983
3648
|
observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => askedOf(surface.projection(params)), toMessage, options),
|
|
2984
3649
|
/**
|
|
2985
3650
|
* A Foldkit Subscription entry that consumes the live stream for a Surface's
|
|
2986
|
-
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
2987
|
-
*
|
|
3651
|
+
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
3652
|
+
* carrying its `stream` when the stream breaks (including
|
|
3653
|
+
* `ResumeUnavailable`). That records a gap on the stream rather than a failed
|
|
3654
|
+
* read, since nothing was being read. The resume cursor is read
|
|
2988
3655
|
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
2989
3656
|
*/
|
|
2990
3657
|
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
|
|
@@ -2993,7 +3660,48 @@ const brandEntries = (entries) => Subscription.make()(() => entries);
|
|
|
2993
3660
|
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
2994
3661
|
const bindDomain = (definition, store) => {
|
|
2995
3662
|
const bound = bindRemote(definition, store);
|
|
2996
|
-
|
|
3663
|
+
/**
|
|
3664
|
+
* What to do with a row a live event says was inserted into a connection.
|
|
3665
|
+
*
|
|
3666
|
+
* Resolved here rather than in the pure reducer because this is the only
|
|
3667
|
+
* place with both the Model and the registry, and two separate things needed
|
|
3668
|
+
* one or the other.
|
|
3669
|
+
*
|
|
3670
|
+
* **The declared policy reaches the decision.** `Query.connection(E, { live })`
|
|
3671
|
+
* has always been typed, documented, carried on the descriptor and encoded in
|
|
3672
|
+
* the Message schema — and nothing ever put it on a Message, so every live
|
|
3673
|
+
* insert took the default whatever an application asked for. It does now.
|
|
3674
|
+
*
|
|
3675
|
+
* **A row the client can judge does not need a policy.** The policy exists
|
|
3676
|
+
* because nothing could tell whether an inserted row belonged to the query.
|
|
3677
|
+
* Where the body says it does not, `ignore` is not a guess. Where the body
|
|
3678
|
+
* says it does, or the client cannot tell — a row it never fetched, or holds
|
|
3679
|
+
* without a field the body reads — the declared policy is the answer, exactly
|
|
3680
|
+
* as it was meant to be.
|
|
3681
|
+
*
|
|
3682
|
+
* Only membership is decided here. *Where* a row sorts needs text collation,
|
|
3683
|
+
* which is the backend's, so the position the event carries stands.
|
|
3684
|
+
*/
|
|
3685
|
+
const livePolicyFor = (model, event) => {
|
|
3686
|
+
if (event._tag !== "ConnectionInsert") return void 0;
|
|
3687
|
+
const identity = event.connection;
|
|
3688
|
+
const descriptor = definition.registry.queries.get(identity.slice(0, identity.indexOf("\0")));
|
|
3689
|
+
const declared = (descriptor?.Result)?.live;
|
|
3690
|
+
if (descriptor?.body === void 0) return declared;
|
|
3691
|
+
const encoded = JSON.parse(identity.slice(identity.indexOf("\0") + 1));
|
|
3692
|
+
return belongsEncoded(storeOf(bound, model), descriptor, encoded, entityKey(event.edge.ref.entity, event.edge.ref.id)) === "no" ? {
|
|
3693
|
+
prepend: "ignore",
|
|
3694
|
+
append: "ignore"
|
|
3695
|
+
} : declared;
|
|
3696
|
+
};
|
|
3697
|
+
const reduce = (model, message) => {
|
|
3698
|
+
const live = message._tag === "LiveReceived" ? message : void 0;
|
|
3699
|
+
const resolved = live === void 0 || live.policy !== void 0 ? message : {
|
|
3700
|
+
...live,
|
|
3701
|
+
policy: livePolicyFor(model, live.event)
|
|
3702
|
+
};
|
|
3703
|
+
return store.set(model, updateRemote(store.get(model), resolved));
|
|
3704
|
+
};
|
|
2997
3705
|
const domain = {
|
|
2998
3706
|
...definition,
|
|
2999
3707
|
...bound,
|
|
@@ -3034,6 +3742,7 @@ const bindDomain = (definition, store) => {
|
|
|
3034
3742
|
},
|
|
3035
3743
|
plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
|
|
3036
3744
|
storeOf: (model) => storeOf(bound, model),
|
|
3745
|
+
confirmed: (projection) => confirmed(bound, projection),
|
|
3037
3746
|
prefetch: (model, projection, options = {}) => Effect.gen(function* () {
|
|
3038
3747
|
const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
|
|
3039
3748
|
const client = yield* RemoteClient;
|
|
@@ -3041,7 +3750,7 @@ const bindDomain = (definition, store) => {
|
|
|
3041
3750
|
let current = model;
|
|
3042
3751
|
for (const query of planAsked(store.get(current), askedOf(projection), planOptions).queries) {
|
|
3043
3752
|
const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
|
|
3044
|
-
current = reduce(current, pageMessage(query.identity, page, true));
|
|
3753
|
+
current = reduce(current, pageMessage(query.identity, page, true, query.window));
|
|
3045
3754
|
}
|
|
3046
3755
|
const requirements = planAsked(store.get(current), askedOf(projection), planOptions).requirements;
|
|
3047
3756
|
if (requirements.length === 0) return current;
|
|
@@ -3071,6 +3780,58 @@ const bindDomain = (definition, store) => {
|
|
|
3071
3780
|
};
|
|
3072
3781
|
const relation = relationOf(select);
|
|
3073
3782
|
const relationKey = stableStringify(relation);
|
|
3783
|
+
const failedItem = (remote, connection) => {
|
|
3784
|
+
if (Object.keys(remote.failures.fields).length === 0) return void 0;
|
|
3785
|
+
for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
|
|
3786
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
3787
|
+
const error = failureOf(remote, edge.ref.entity, edge.ref.id, relation);
|
|
3788
|
+
if (error !== void 0) return {
|
|
3789
|
+
_tag: "Failed",
|
|
3790
|
+
error
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3793
|
+
};
|
|
3794
|
+
const loadingItem = (remote, connection) => {
|
|
3795
|
+
if (remote.loading.size === 0) return void 0;
|
|
3796
|
+
for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
|
|
3797
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
3798
|
+
if (isLoading(remote, edge.ref.entity, edge.ref.id, relation.fields)) return { _tag: "Loading" };
|
|
3799
|
+
}
|
|
3800
|
+
};
|
|
3801
|
+
const readPage = (remote, connection) => {
|
|
3802
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
3803
|
+
return memoRead(visible, connection, `${ref.identity}\u0000${relationKey}`, () => {
|
|
3804
|
+
const items = [];
|
|
3805
|
+
let refreshing = connection.stale;
|
|
3806
|
+
const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
|
|
3807
|
+
for (const edge of edges) {
|
|
3808
|
+
const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
|
|
3809
|
+
if (assembled === void 0) return void 0;
|
|
3810
|
+
const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
|
|
3811
|
+
if (Result.isFailure(decoded)) return {
|
|
3812
|
+
_tag: "Failed",
|
|
3813
|
+
error: {
|
|
3814
|
+
_tag: "DecodeError",
|
|
3815
|
+
message: decoded.failure.message
|
|
3816
|
+
}
|
|
3817
|
+
};
|
|
3818
|
+
refreshing ||= assembled.refreshing;
|
|
3819
|
+
items.push(decoded.success);
|
|
3820
|
+
}
|
|
3821
|
+
const page = {
|
|
3822
|
+
items,
|
|
3823
|
+
hasNext: hasNext(connection),
|
|
3824
|
+
hasPrevious: hasPrevious(connection)
|
|
3825
|
+
};
|
|
3826
|
+
return refreshing ? {
|
|
3827
|
+
_tag: "Refreshing",
|
|
3828
|
+
value: page
|
|
3829
|
+
} : {
|
|
3830
|
+
_tag: "Ready",
|
|
3831
|
+
value: page
|
|
3832
|
+
};
|
|
3833
|
+
});
|
|
3834
|
+
};
|
|
3074
3835
|
const requirement = {
|
|
3075
3836
|
identity: ref.identity,
|
|
3076
3837
|
window: ref.window,
|
|
@@ -3082,42 +3843,41 @@ const bindDomain = (definition, store) => {
|
|
|
3082
3843
|
dependencies: [],
|
|
3083
3844
|
metadata: RemoteConnections.of(requirement),
|
|
3084
3845
|
ref,
|
|
3846
|
+
selection: select,
|
|
3085
3847
|
read: (root) => {
|
|
3086
3848
|
const remote = store.get(root);
|
|
3087
3849
|
const connection = remote.connections[ref.identity];
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
for (const edge of edges) {
|
|
3095
|
-
const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
|
|
3096
|
-
if (assembled === void 0) return { _tag: "Initial" };
|
|
3097
|
-
const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
|
|
3098
|
-
if (Result.isFailure(decoded)) return {
|
|
3099
|
-
_tag: "Failed",
|
|
3100
|
-
error: {
|
|
3101
|
-
_tag: "DecodeError",
|
|
3102
|
-
message: decoded.failure.message
|
|
3103
|
-
}
|
|
3104
|
-
};
|
|
3105
|
-
refreshing ||= assembled.refreshing;
|
|
3106
|
-
items.push(decoded.success);
|
|
3107
|
-
}
|
|
3108
|
-
const page = {
|
|
3109
|
-
items,
|
|
3110
|
-
hasNext: hasNext(connection),
|
|
3111
|
-
hasPrevious: hasPrevious(connection)
|
|
3850
|
+
const failure = remote.failures.connections[ref.identity];
|
|
3851
|
+
if (connection === void 0 || connection.stale && connection.segments.length === 0) {
|
|
3852
|
+
if (isQueryLoading(remote, ref.identity)) return { _tag: "Loading" };
|
|
3853
|
+
return failure === void 0 ? { _tag: "Initial" } : {
|
|
3854
|
+
_tag: "Failed",
|
|
3855
|
+
error: failure
|
|
3112
3856
|
};
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3857
|
+
}
|
|
3858
|
+
const read = readPage(remote, connection) ?? failedItem(remote, connection) ?? loadingItem(remote, connection) ?? { _tag: "Initial" };
|
|
3859
|
+
if (failure === void 0) {
|
|
3860
|
+
if (read._tag !== "Ready" && read._tag !== "Refreshing") return read;
|
|
3861
|
+
const item = failedItem(remote, connection);
|
|
3862
|
+
return item === void 0 ? read : {
|
|
3863
|
+
_tag: "Failed",
|
|
3864
|
+
error: item.error,
|
|
3865
|
+
previous: read.value
|
|
3119
3866
|
};
|
|
3120
|
-
}
|
|
3867
|
+
}
|
|
3868
|
+
switch (read._tag) {
|
|
3869
|
+
case "Ready":
|
|
3870
|
+
case "Refreshing": return {
|
|
3871
|
+
_tag: "Failed",
|
|
3872
|
+
error: failure,
|
|
3873
|
+
previous: read.value
|
|
3874
|
+
};
|
|
3875
|
+
case "Failed": return read;
|
|
3876
|
+
default: return {
|
|
3877
|
+
_tag: "Failed",
|
|
3878
|
+
error: failure
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3121
3881
|
}
|
|
3122
3882
|
};
|
|
3123
3883
|
},
|
|
@@ -3142,7 +3902,79 @@ const bindDomain = (definition, store) => {
|
|
|
3142
3902
|
}
|
|
3143
3903
|
} : void 0;
|
|
3144
3904
|
},
|
|
3905
|
+
explain: (model, projection, options) => {
|
|
3906
|
+
const { ref } = projection;
|
|
3907
|
+
const reading = Object.values(options?.surfaces ?? {}).filter((active) => {
|
|
3908
|
+
const active_ = active.projectionOf(model);
|
|
3909
|
+
return active_ !== void 0 && connectionsOf(active_).some((connection) => connection.identity === ref.identity);
|
|
3910
|
+
});
|
|
3911
|
+
const body = definition.registry.queries.get(ref.query)?.body;
|
|
3912
|
+
const [connection] = RemoteConnections.get(projection.metadata);
|
|
3913
|
+
return {
|
|
3914
|
+
domain: bound.contract.name,
|
|
3915
|
+
query: ref.query,
|
|
3916
|
+
input: Schema.encodeSync(ref.Input)(ref.input),
|
|
3917
|
+
identity: ref.identity,
|
|
3918
|
+
window: ref.window,
|
|
3919
|
+
select: connection.select,
|
|
3920
|
+
...body === void 0 ? {} : {
|
|
3921
|
+
body: Query$1.show(body),
|
|
3922
|
+
dependencies: Query$1.dependencies(body)
|
|
3923
|
+
},
|
|
3924
|
+
state: projection.read(model)._tag,
|
|
3925
|
+
...options?.surfaces === void 0 ? {} : {
|
|
3926
|
+
surfaces: reading.map((active) => active.name),
|
|
3927
|
+
activation: reading.flatMap((active) => active.activation === void 0 ? [] : [{
|
|
3928
|
+
surface: active.name,
|
|
3929
|
+
...active.activation
|
|
3930
|
+
}])
|
|
3931
|
+
}
|
|
3932
|
+
};
|
|
3933
|
+
},
|
|
3934
|
+
filtered: (model, over, by, input) => {
|
|
3935
|
+
assertRegistered(bound, "Query", definition.registry.queries, by.name);
|
|
3936
|
+
const filters = by.body?.entity.name;
|
|
3937
|
+
if (filters !== void 0 && filters !== over.selection.entity) throw new Error(`Remote: query "${by.name}" is over "${filters}", but the list is of "${over.selection.entity}", so it cannot filter it`);
|
|
3938
|
+
const remote = store.get(model);
|
|
3939
|
+
const connection = remote.connections[over.ref.identity];
|
|
3940
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
3941
|
+
const among = (connection === void 0 ? [] : visibleItems(connection, over.ref.identity, remote.optimistic.overlays, remote.entities)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
|
|
3942
|
+
const judged = matching(visible, by, input, { among });
|
|
3943
|
+
const relation = relationOf(over.selection);
|
|
3944
|
+
const items = [];
|
|
3945
|
+
let assembledAll = true;
|
|
3946
|
+
for (const key of judged.matched) {
|
|
3947
|
+
const assembled = assemble(visible, key, relation);
|
|
3948
|
+
if (assembled === void 0) {
|
|
3949
|
+
assembledAll = false;
|
|
3950
|
+
continue;
|
|
3951
|
+
}
|
|
3952
|
+
const decoded = Schema.decodeUnknownResult(over.selection.schema)(assembled.values);
|
|
3953
|
+
if (Result.isFailure(decoded)) {
|
|
3954
|
+
assembledAll = false;
|
|
3955
|
+
continue;
|
|
3956
|
+
}
|
|
3957
|
+
items.push(decoded.success);
|
|
3958
|
+
}
|
|
3959
|
+
return {
|
|
3960
|
+
items,
|
|
3961
|
+
complete: judged.skipped.length === 0 && assembledAll && connection !== void 0 && !isGapped(connection) && !hasNext(connection) && !hasPrevious(connection)
|
|
3962
|
+
};
|
|
3963
|
+
},
|
|
3145
3964
|
refresh: (model, target) => Remote.refresh(bound, model, target),
|
|
3965
|
+
overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
|
|
3966
|
+
_tag: "OverlayShown",
|
|
3967
|
+
id,
|
|
3968
|
+
optimistic
|
|
3969
|
+
})),
|
|
3970
|
+
lift: (model, id) => {
|
|
3971
|
+
const remote = bound.store.get(model);
|
|
3972
|
+
const lifted = updateRemote(remote, {
|
|
3973
|
+
_tag: "OverlayLifted",
|
|
3974
|
+
id
|
|
3975
|
+
});
|
|
3976
|
+
return lifted === remote ? model : bound.store.set(model, lifted);
|
|
3977
|
+
},
|
|
3146
3978
|
fetch: (ref) => ({
|
|
3147
3979
|
name: `Remote.query(${ref.query})`,
|
|
3148
3980
|
args: {
|
|
@@ -3155,7 +3987,7 @@ const bindDomain = (definition, store) => {
|
|
|
3155
3987
|
connection: ref.identity,
|
|
3156
3988
|
error: remoteError(error)
|
|
3157
3989
|
}),
|
|
3158
|
-
onSuccess: (page) => pageMessage(ref.identity, page)
|
|
3990
|
+
onSuccess: (page) => pageMessage(ref.identity, page, false, ref.window)
|
|
3159
3991
|
}))
|
|
3160
3992
|
}),
|
|
3161
3993
|
mutate: (model, mutation, input, options = {}) => {
|
|
@@ -3222,6 +4054,6 @@ const pageSize = (window, key) => {
|
|
|
3222
4054
|
return size === void 0 ? {} : { [key]: size };
|
|
3223
4055
|
};
|
|
3224
4056
|
//#endregion
|
|
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,
|
|
4057
|
+
export { ConnectionChange, ConnectionChangeSchema, Entity, IDENTITY_SEPARATOR, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, RELATION_ALIAS, 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, aliasedField, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, belongsEncoded, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, failureOf, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, isFieldFailed, isFieldStale, isGapped, isLoading, isQueryLoading, isRefPage, isRemoteMessage, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, matching, merge, missingFields, mutateRemote, mutationStatus, noFailures, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshIsInFlight, refreshedAt, refsIn, relationAlias, 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, withRefreshRequested, writeEntities, writeEntity, writeRead };
|
|
3226
4058
|
|
|
3227
4059
|
//# sourceMappingURL=index.mjs.map
|