foldkit-remote 0.3.0 → 0.5.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 +266 -6
- package/dist/index.d.mts +452 -70
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +709 -365
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
|
|
2
|
+
import * as Domain from "foldkit-entity";
|
|
3
|
+
import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId } from "foldkit-entity";
|
|
4
|
+
import * as Subscription from "foldkit/subscription";
|
|
2
5
|
import { Metadata } from "foldkit-surface";
|
|
3
6
|
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
4
7
|
import { KeyValueStore } from "effect/unstable/persistence";
|
|
@@ -105,6 +108,91 @@ const requirementsOf = (projection) => RemoteRequirements.get(projection.metadat
|
|
|
105
108
|
/** The query connections a Projection carries. */
|
|
106
109
|
const connectionsOf = (projection) => RemoteConnections.get(projection.metadata);
|
|
107
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
|
|
108
196
|
//#region src/store.ts
|
|
109
197
|
/**
|
|
110
198
|
* The pure entity store: values, per-field presence, staleness, and tombstones.
|
|
@@ -139,6 +227,9 @@ const written = (previous, values, now, windows) => {
|
|
|
139
227
|
const requested = windows?.[field];
|
|
140
228
|
if (requested !== void 0 && requested !== "") nextWindows[field] = requested;
|
|
141
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
|
+
}
|
|
142
233
|
}
|
|
143
234
|
return {
|
|
144
235
|
values: {
|
|
@@ -266,6 +357,7 @@ const emptyMutationState = {
|
|
|
266
357
|
pending: /* @__PURE__ */ new Set(),
|
|
267
358
|
applied: /* @__PURE__ */ new Set(),
|
|
268
359
|
failed: /* @__PURE__ */ new Set(),
|
|
360
|
+
errors: /* @__PURE__ */ new Map(),
|
|
269
361
|
sequence: 0
|
|
270
362
|
};
|
|
271
363
|
/**
|
|
@@ -288,13 +380,26 @@ const beginMutation = (state, requestId) => ({
|
|
|
288
380
|
pending: /* @__PURE__ */ new Set([...state.pending, requestId]),
|
|
289
381
|
sequence: state.sequence + 1
|
|
290
382
|
});
|
|
291
|
-
const failMutation = (state, requestId) => {
|
|
383
|
+
const failMutation = (state, requestId, error) => {
|
|
292
384
|
const pending = new Set(state.pending);
|
|
293
385
|
pending.delete(requestId);
|
|
386
|
+
const failed = remember(state.failed, requestId);
|
|
387
|
+
const errors = new Map([...state.errors, [requestId, error]]);
|
|
388
|
+
for (const id of errors.keys()) if (!failed.has(id)) errors.delete(id);
|
|
294
389
|
return {
|
|
295
390
|
...state,
|
|
296
391
|
pending,
|
|
297
|
-
failed
|
|
392
|
+
failed,
|
|
393
|
+
errors
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
const mutationStatus = (state, requestId) => {
|
|
397
|
+
if (state.pending.has(requestId)) return { _tag: "Pending" };
|
|
398
|
+
if (state.applied.has(requestId)) return { _tag: "Applied" };
|
|
399
|
+
const error = state.errors.get(requestId);
|
|
400
|
+
return error === void 0 ? { _tag: "Unknown" } : {
|
|
401
|
+
_tag: "Failed",
|
|
402
|
+
error
|
|
298
403
|
};
|
|
299
404
|
};
|
|
300
405
|
/**
|
|
@@ -302,8 +407,8 @@ const failMutation = (state, requestId) => {
|
|
|
302
407
|
* with the same `requestId` (a retry, or a live event describing the same change)
|
|
303
408
|
* does not re-apply the entities, but still clears `pending`.
|
|
304
409
|
*/
|
|
305
|
-
const reconcileMutation = (store, state, requestId, entities) => {
|
|
306
|
-
const next = state.applied.has(requestId) ? store : entities.reduce((current, entity) => writeEntity(current, entityKey(entity.entity, entity.id), entity.values), store);
|
|
410
|
+
const reconcileMutation = (store, state, requestId, entities, deleted = []) => {
|
|
411
|
+
const next = state.applied.has(requestId) ? store : deleted.reduce((current, gone) => tombstone(current, entityKey(gone.entity, gone.id)), entities.reduce((current, entity) => writeEntity(current, entityKey(entity.entity, entity.id), entity.values), store));
|
|
307
412
|
const applied = remember(state.applied, requestId);
|
|
308
413
|
const pending = new Set(state.pending);
|
|
309
414
|
pending.delete(requestId);
|
|
@@ -344,7 +449,32 @@ const stableStringify = (value) => {
|
|
|
344
449
|
return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
345
450
|
};
|
|
346
451
|
const isEntityName = (result) => typeof result === "object" && result !== null && typeof result.name === "string";
|
|
452
|
+
/**
|
|
453
|
+
* The relational half comes from `foldkit-entity`, so one `Query` namespace
|
|
454
|
+
* holds both what a query means (`from`, `where`, `orderBy`) and how this
|
|
455
|
+
* client addresses it (`make`, `connection`, the window steps). They are one
|
|
456
|
+
* vocabulary in use — a definition's body is composed and then named — and two
|
|
457
|
+
* namespaces of the same name in one file would be a trap.
|
|
458
|
+
*/
|
|
459
|
+
/**
|
|
460
|
+
* One placeholder per key of the input's schema, each carrying that key's own
|
|
461
|
+
* codec so a comparison against it is checked.
|
|
462
|
+
*
|
|
463
|
+
* A codec that is not a `Schema.Struct` exposes no keys to stand for, while its
|
|
464
|
+
* *type* may well have them — so the body would be handed an empty object,
|
|
465
|
+
* `input.slug` would be `undefined`, and the comparison would silently be
|
|
466
|
+
* against nothing while the types all agreed. That is a wrong query rather than
|
|
467
|
+
* a missing one, so it is refused where the query is declared.
|
|
468
|
+
*/
|
|
469
|
+
const inputsOf = (name, schema) => {
|
|
470
|
+
const fields = schema.fields;
|
|
471
|
+
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\`.`);
|
|
472
|
+
const inputs = {};
|
|
473
|
+
for (const key of Object.keys(fields)) inputs[key] = Expr.input(key, fields[key]);
|
|
474
|
+
return Object.freeze(inputs);
|
|
475
|
+
};
|
|
347
476
|
const Query = {
|
|
477
|
+
...Query$1,
|
|
348
478
|
/** Declares the entity and options a query's result is a connection over. */
|
|
349
479
|
connection: (entity, options) => ({
|
|
350
480
|
entity: entity.name,
|
|
@@ -372,6 +502,41 @@ const Query = {
|
|
|
372
502
|
})
|
|
373
503
|
};
|
|
374
504
|
},
|
|
505
|
+
/**
|
|
506
|
+
* Declares a query by what it means. The body is built once, here, and the
|
|
507
|
+
* `input` it is given holds placeholders rather than values — there is
|
|
508
|
+
* nothing yet to branch on, so a condition that depends on what was passed is
|
|
509
|
+
* a comparison over the placeholder:
|
|
510
|
+
*
|
|
511
|
+
* ```ts
|
|
512
|
+
* const PostsBySlug = Query.define('PostsBySlug', { slug: Schema.String }, ({ input }) =>
|
|
513
|
+
* Query.from(Post).pipe(
|
|
514
|
+
* Query.where(Expr.eq(Post.fields.slug, input.slug)),
|
|
515
|
+
* Query.orderBy(Order.asc(Post.fields.id)),
|
|
516
|
+
* ),
|
|
517
|
+
* )
|
|
518
|
+
* ```
|
|
519
|
+
*
|
|
520
|
+
* What comes back is an ordinary `QueryDescriptor` — the same name, `Input`,
|
|
521
|
+
* `ref` and connection identity `Query.make` gives — carrying its `body`
|
|
522
|
+
* besides. A server can compile that body instead of being told the same
|
|
523
|
+
* thing again in its own dialect, and one that would rather answer the query
|
|
524
|
+
* its own way still can.
|
|
525
|
+
*
|
|
526
|
+
* The result is a connection over the Entity the body reads, so it is not
|
|
527
|
+
* named twice.
|
|
528
|
+
*/
|
|
529
|
+
define: (name, Input, body, options) => {
|
|
530
|
+
const schema = schemaOf(Input);
|
|
531
|
+
const built = body({ input: inputsOf(name, schema) });
|
|
532
|
+
return {
|
|
533
|
+
...Query.make(name, {
|
|
534
|
+
Input,
|
|
535
|
+
Result: Query.connection(built.entity, options)
|
|
536
|
+
}),
|
|
537
|
+
body: built
|
|
538
|
+
};
|
|
539
|
+
},
|
|
375
540
|
first: (count) => (ref) => ({
|
|
376
541
|
...ref,
|
|
377
542
|
window: {
|
|
@@ -501,7 +666,12 @@ const MutationResult = Schema.Struct({
|
|
|
501
666
|
output: Schema.Unknown,
|
|
502
667
|
entities: Schema.Array(NormalizedEntity),
|
|
503
668
|
/** Connection changes the mutation made, applied alongside its entity patches. */
|
|
504
|
-
connections: Schema.optional(Schema.Array(ConnectionChangeSchema))
|
|
669
|
+
connections: Schema.optional(Schema.Array(ConnectionChangeSchema)),
|
|
670
|
+
/** Entities the mutation deleted; the client knows them absent from then on. */
|
|
671
|
+
deleted: Schema.optional(Schema.Array(Schema.Struct({
|
|
672
|
+
entity: Schema.String,
|
|
673
|
+
id: Schema.String
|
|
674
|
+
})))
|
|
505
675
|
});
|
|
506
676
|
const LiveRequirement = Schema.Struct({
|
|
507
677
|
version: Schema.Number,
|
|
@@ -825,7 +995,8 @@ const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requ
|
|
|
825
995
|
return {
|
|
826
996
|
output: yield* Schema.decodeUnknownEffect(mutation.Output)(result.output).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message })))),
|
|
827
997
|
entities: result.entities,
|
|
828
|
-
connections: (result.connections ?? []).map(connectionChangeOf)
|
|
998
|
+
connections: (result.connections ?? []).map(connectionChangeOf),
|
|
999
|
+
deleted: result.deleted ?? []
|
|
829
1000
|
};
|
|
830
1001
|
});
|
|
831
1002
|
//#endregion
|
|
@@ -993,6 +1164,266 @@ const hasNext = (connection) => {
|
|
|
993
1164
|
/** A connection with more than one segment has an explicit unknown gap. */
|
|
994
1165
|
const isGapped = (connection) => connection.segments.length > 1;
|
|
995
1166
|
//#endregion
|
|
1167
|
+
//#region src/selection.ts
|
|
1168
|
+
/**
|
|
1169
|
+
* Selections: the fields a Surface picks of an entity (nested through
|
|
1170
|
+
* relations, paged through connections), the requirement graph they imply,
|
|
1171
|
+
* and their assembly from the normalized store.
|
|
1172
|
+
*/
|
|
1173
|
+
const pageSchemas = /* @__PURE__ */ new WeakMap();
|
|
1174
|
+
const pageSchema = (item) => {
|
|
1175
|
+
const cached = pageSchemas.get(item);
|
|
1176
|
+
if (cached !== void 0) return cached;
|
|
1177
|
+
const built = Schema.Struct({
|
|
1178
|
+
items: Schema.Array(item),
|
|
1179
|
+
hasNext: Schema.Boolean,
|
|
1180
|
+
hasPrevious: Schema.Boolean
|
|
1181
|
+
});
|
|
1182
|
+
pageSchemas.set(item, built);
|
|
1183
|
+
return built;
|
|
1184
|
+
};
|
|
1185
|
+
/** The requirement a nested selection contributes for its relation's target. */
|
|
1186
|
+
const relationOf = (selection) => ({
|
|
1187
|
+
entity: selection.entity,
|
|
1188
|
+
fields: selection.fields,
|
|
1189
|
+
...selection.connections === void 0 ? {} : { windows: selection.connections },
|
|
1190
|
+
...selection.relations === void 0 ? {} : { relations: selection.relations }
|
|
1191
|
+
});
|
|
1192
|
+
const fromEntitySelections = /* @__PURE__ */ new WeakMap();
|
|
1193
|
+
const Selection = {
|
|
1194
|
+
/**
|
|
1195
|
+
* The Remote Selection of a `foldkit-entity` Selection: the same members, read
|
|
1196
|
+
* from Remote's store. `true` on a relation reads its refs, as it does here.
|
|
1197
|
+
*/
|
|
1198
|
+
from: (selection) => {
|
|
1199
|
+
const compiled = fromEntitySelections.get(selection);
|
|
1200
|
+
if (compiled !== void 0) return compiled;
|
|
1201
|
+
const spec = {};
|
|
1202
|
+
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);
|
|
1203
|
+
const made = Selection.make(Entity.from(selection.entity), spec);
|
|
1204
|
+
fromEntitySelections.set(selection, made);
|
|
1205
|
+
return made;
|
|
1206
|
+
},
|
|
1207
|
+
/**
|
|
1208
|
+
* The fields to read of `entity`. A nested `Selection` on a relation field
|
|
1209
|
+
* reads through the ref (or refs, or page of refs) the field holds into the
|
|
1210
|
+
* target's fields; a scalar field cannot take one.
|
|
1211
|
+
*/
|
|
1212
|
+
make: (entity, selection) => {
|
|
1213
|
+
if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
|
|
1214
|
+
const picked = {};
|
|
1215
|
+
const names = [];
|
|
1216
|
+
const connections = {};
|
|
1217
|
+
const relations = {};
|
|
1218
|
+
for (const key of Object.keys(selection)) {
|
|
1219
|
+
const choice = selection[key];
|
|
1220
|
+
if (choice === true) {
|
|
1221
|
+
picked[key] = entity.fields[key];
|
|
1222
|
+
names.push(key);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const nested = choice;
|
|
1226
|
+
const shape = relationShape(entity.fields[key]);
|
|
1227
|
+
if (shape === void 0) throw new Error(`Selection.make: "${key}" on "${entity.name}" is not a relation field, so it cannot take a nested selection`);
|
|
1228
|
+
if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
|
|
1229
|
+
const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
|
|
1230
|
+
if (nested.window !== void 0) {
|
|
1231
|
+
const name = shape.kind === "many" ? relationAlias(key, nested.window) : key;
|
|
1232
|
+
names.push(name);
|
|
1233
|
+
connections[name] = nested.window;
|
|
1234
|
+
picked[key] = nullable(nested.schema);
|
|
1235
|
+
if (nested.fields.length > 0) relations[name] = relationOf(nested);
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
names.push(key);
|
|
1239
|
+
const item = nested.schema;
|
|
1240
|
+
picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
|
|
1241
|
+
relations[key] = relationOf(nested);
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
entity: entity.name,
|
|
1245
|
+
fields: names,
|
|
1246
|
+
schema: Schema.Struct(picked),
|
|
1247
|
+
...Object.keys(connections).length === 0 ? {} : { connections },
|
|
1248
|
+
...Object.keys(relations).length === 0 ? {} : { relations }
|
|
1249
|
+
};
|
|
1250
|
+
},
|
|
1251
|
+
/**
|
|
1252
|
+
* A paginated relation: one page of refs to `entity` with a window, or, with
|
|
1253
|
+
* a nested selection, one page of targets assembled through it.
|
|
1254
|
+
*/
|
|
1255
|
+
connection: ((entity, window, selection) => {
|
|
1256
|
+
if (selection !== void 0 && selection.entity !== entity.name) throw new Error(`Selection.connection: a page of "${entity.name}" cannot select "${selection.entity}"`);
|
|
1257
|
+
return {
|
|
1258
|
+
entity: entity.name,
|
|
1259
|
+
fields: selection?.fields ?? [],
|
|
1260
|
+
schema: selection === void 0 ? Entity.refPage(entity) : pageSchema(selection.schema),
|
|
1261
|
+
window,
|
|
1262
|
+
...selection?.connections === void 0 ? {} : { connections: selection.connections },
|
|
1263
|
+
...selection?.relations === void 0 ? {} : { relations: selection.relations }
|
|
1264
|
+
};
|
|
1265
|
+
})
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Reads an entity's selected fields out of the store, following each nested
|
|
1269
|
+
* relation into its targets. `undefined` means some field, at any depth, is
|
|
1270
|
+
* not present yet. A tombstoned target reads as `null` (or is dropped from a
|
|
1271
|
+
* list), so the Selection's codec decides whether that is a failure.
|
|
1272
|
+
*/
|
|
1273
|
+
const assemble = (store, key, requirement) => {
|
|
1274
|
+
const values = {};
|
|
1275
|
+
let refreshing = false;
|
|
1276
|
+
for (const field of requirement.fields) {
|
|
1277
|
+
const value = readField(store, key, field);
|
|
1278
|
+
if (Option.isNone(value)) return void 0;
|
|
1279
|
+
refreshing ||= isFieldStale(store, key, field);
|
|
1280
|
+
const relation = requirement.relations?.[field];
|
|
1281
|
+
if (relation === void 0) {
|
|
1282
|
+
values[aliasedField(field)] = value.value;
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
const nested = assembleRelation(store, value.value, relation);
|
|
1286
|
+
if (nested === void 0) return void 0;
|
|
1287
|
+
values[aliasedField(field)] = nested.values;
|
|
1288
|
+
refreshing ||= nested.refreshing;
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
values,
|
|
1292
|
+
refreshing
|
|
1293
|
+
};
|
|
1294
|
+
};
|
|
1295
|
+
const assembleTarget = (store, ref, relation) => {
|
|
1296
|
+
const key = entityKey(ref.entity, ref.id);
|
|
1297
|
+
return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
|
|
1298
|
+
};
|
|
1299
|
+
/** Assembles the targets a relation value refers to, in the value's own shape. */
|
|
1300
|
+
const assembleRelation = (store, value, relation) => {
|
|
1301
|
+
if (value === null || value === void 0) return {
|
|
1302
|
+
values: null,
|
|
1303
|
+
refreshing: false
|
|
1304
|
+
};
|
|
1305
|
+
const targets = [];
|
|
1306
|
+
let refreshing = false;
|
|
1307
|
+
for (const ref of targetsOf(value, relation)) {
|
|
1308
|
+
const target = assembleTarget(store, ref, relation);
|
|
1309
|
+
if (target === void 0) return void 0;
|
|
1310
|
+
if (target === "absent") continue;
|
|
1311
|
+
targets.push(target.values);
|
|
1312
|
+
refreshing ||= target.refreshing;
|
|
1313
|
+
}
|
|
1314
|
+
if (typeof value === "string") return {
|
|
1315
|
+
values: targets.length === 0 ? null : targets[0],
|
|
1316
|
+
refreshing
|
|
1317
|
+
};
|
|
1318
|
+
if (isRefPage(value)) return {
|
|
1319
|
+
values: {
|
|
1320
|
+
items: targets,
|
|
1321
|
+
hasNext: value.hasNext,
|
|
1322
|
+
hasPrevious: value.hasPrevious
|
|
1323
|
+
},
|
|
1324
|
+
refreshing
|
|
1325
|
+
};
|
|
1326
|
+
return {
|
|
1327
|
+
values: targets,
|
|
1328
|
+
refreshing
|
|
1329
|
+
};
|
|
1330
|
+
};
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/entity.ts
|
|
1333
|
+
/**
|
|
1334
|
+
* Entities: the descriptor an application declares, refs, ref pages, and
|
|
1335
|
+
* patches, with the codecs that carry refs on the wire.
|
|
1336
|
+
*/
|
|
1337
|
+
/**
|
|
1338
|
+
* Relations are **references**, never inline target schemas: a ref cannot
|
|
1339
|
+
* reconstruct a full entity, and dereferencing is a store concern. Because the
|
|
1340
|
+
* target schema is not inlined, recursive relations cannot arise through the
|
|
1341
|
+
* schema graph.
|
|
1342
|
+
*/
|
|
1343
|
+
const refCodec = (entity) => Schema.Struct({
|
|
1344
|
+
entity: Schema.String,
|
|
1345
|
+
id: Schema.String
|
|
1346
|
+
}).pipe(Schema.encodeTo(Schema.String, {
|
|
1347
|
+
decode: SchemaGetter.transform(refParts),
|
|
1348
|
+
encode: SchemaGetter.transform((ref) => entityKey(ref.entity, ref.id))
|
|
1349
|
+
})).annotate({
|
|
1350
|
+
[RelationAnnotation]: "one",
|
|
1351
|
+
[RelationEntityAnnotation]: entity
|
|
1352
|
+
});
|
|
1353
|
+
const refPageCodec = (entity) => Schema.Struct({
|
|
1354
|
+
refs: Schema.Array(refCodec(entity)),
|
|
1355
|
+
hasNext: Schema.Boolean,
|
|
1356
|
+
hasPrevious: Schema.Boolean
|
|
1357
|
+
}).annotate({
|
|
1358
|
+
[RelationAnnotation]: "page",
|
|
1359
|
+
[RelationEntityAnnotation]: entity
|
|
1360
|
+
});
|
|
1361
|
+
const descriptors = /* @__PURE__ */ new WeakMap();
|
|
1362
|
+
const relationField = (relation) => {
|
|
1363
|
+
const ref = refCodec(relation.target().name);
|
|
1364
|
+
return relation.cardinality === "many" ? Schema.Array(ref) : relation.optional ? Schema.NullOr(ref) : ref;
|
|
1365
|
+
};
|
|
1366
|
+
const Entity = {
|
|
1367
|
+
/**
|
|
1368
|
+
* The Remote descriptor of a `foldkit-entity` Entity. Relations declared with
|
|
1369
|
+
* `Entity.relate` become ref fields, so the store, the planner, and the server
|
|
1370
|
+
* treat it as they treat an `Entity.make` descriptor.
|
|
1371
|
+
*/
|
|
1372
|
+
from: (entity) => {
|
|
1373
|
+
const cached = descriptors.get(entity);
|
|
1374
|
+
if (cached !== void 0) return cached;
|
|
1375
|
+
const members = entity.members;
|
|
1376
|
+
if (members.id?._tag !== "Field") throw new Error(`Entity.from: "${entity.name}" needs an "id" field for Remote to key it by`);
|
|
1377
|
+
const fields = {};
|
|
1378
|
+
for (const [key, member] of Object.entries(members)) fields[key] = member._tag === "Relation" ? relationField(member) : member.schema;
|
|
1379
|
+
const descriptor = Entity.make(entity.name, Schema.Struct(fields));
|
|
1380
|
+
descriptors.set(entity, descriptor);
|
|
1381
|
+
return descriptor;
|
|
1382
|
+
},
|
|
1383
|
+
make: (name, schema) => {
|
|
1384
|
+
const reserved = Object.keys(schema.fields).find((field) => field.includes("@"));
|
|
1385
|
+
if (reserved !== void 0) throw new Error(`Entity.make: field "${reserved}" of "${name}" contains "@", which names a page of a relation`);
|
|
1386
|
+
const descriptor = {
|
|
1387
|
+
name,
|
|
1388
|
+
schema,
|
|
1389
|
+
fields: schema.fields,
|
|
1390
|
+
ref: (id) => ({
|
|
1391
|
+
entity: name,
|
|
1392
|
+
id: String(id)
|
|
1393
|
+
}),
|
|
1394
|
+
select: (selection) => Selection.make(descriptor, selection),
|
|
1395
|
+
patch: (id, values) => ({
|
|
1396
|
+
entity: name,
|
|
1397
|
+
id: String(id),
|
|
1398
|
+
values
|
|
1399
|
+
})
|
|
1400
|
+
};
|
|
1401
|
+
return descriptor;
|
|
1402
|
+
},
|
|
1403
|
+
/** A relation to a known entity, decoded as a reference. */
|
|
1404
|
+
ref: (entity) => refCodec(entity.name),
|
|
1405
|
+
/** A relation by name, for recursive or forward references. */
|
|
1406
|
+
refTo: (name) => refCodec(name),
|
|
1407
|
+
/**
|
|
1408
|
+
* The wire key a relation field carries (`"Entity:id"`). Adapters that read a
|
|
1409
|
+
* foreign key emit this so the ref codec can decode it on the client.
|
|
1410
|
+
*/
|
|
1411
|
+
refKey: (ref) => entityKey(ref.entity, ref.id),
|
|
1412
|
+
/** Splits a ref key back into its entity and id. */
|
|
1413
|
+
refParts,
|
|
1414
|
+
/** A relation value of one authoritative page of refs. */
|
|
1415
|
+
refPage: (entity) => refPageCodec(entity.name),
|
|
1416
|
+
/**
|
|
1417
|
+
* A normalized patch for an entity, typed against its fields' wire shape:
|
|
1418
|
+
* write a relation as its ref key (`Entity.refKey`), as the server does.
|
|
1419
|
+
*/
|
|
1420
|
+
patch: (ref, patch) => ({
|
|
1421
|
+
entity: ref.entity,
|
|
1422
|
+
id: ref.id,
|
|
1423
|
+
values: patch
|
|
1424
|
+
})
|
|
1425
|
+
};
|
|
1426
|
+
//#endregion
|
|
996
1427
|
//#region src/inspect.ts
|
|
997
1428
|
const inspectEntry = (key, entry) => ({
|
|
998
1429
|
key,
|
|
@@ -1008,6 +1439,7 @@ const inspectRemote = (model) => ({
|
|
|
1008
1439
|
connections: Object.keys(model.connections),
|
|
1009
1440
|
live: Object.keys(model.live),
|
|
1010
1441
|
gaps: [...model.gaps],
|
|
1442
|
+
loading: [...model.loading],
|
|
1011
1443
|
mutations: {
|
|
1012
1444
|
pending: [...model.mutations.pending],
|
|
1013
1445
|
failed: [...model.mutations.failed],
|
|
@@ -1138,8 +1570,8 @@ const release = (optimistic, requestId) => removeOverlay(removeLayer(optimistic,
|
|
|
1138
1570
|
* a later layer re-wins on rebase. Confirmed connection changes are recorded
|
|
1139
1571
|
* once per request, as overlays the request no longer owns.
|
|
1140
1572
|
*/
|
|
1141
|
-
const settleSuccess = (base, optimistic, state, requestId, entities, connections = []) => {
|
|
1142
|
-
const reconciled = reconcileMutation(base, state, requestId, entities);
|
|
1573
|
+
const settleSuccess = (base, optimistic, state, requestId, entities, connections = [], deleted = []) => {
|
|
1574
|
+
const reconciled = reconcileMutation(base, state, requestId, entities, deleted);
|
|
1143
1575
|
return {
|
|
1144
1576
|
store: reconciled.store,
|
|
1145
1577
|
state: reconciled.state,
|
|
@@ -1322,74 +1754,6 @@ const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connect
|
|
|
1322
1754
|
/** `hasNext` accounting for edges recorded outside the loaded boundary. */
|
|
1323
1755
|
const liveHasNext = (connection, state, connectionId) => hasNext(connection) || (state.boundary[connectionId]?.after ?? 0) > 0;
|
|
1324
1756
|
//#endregion
|
|
1325
|
-
//#region src/relation.ts
|
|
1326
|
-
/** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
|
|
1327
|
-
const RelationAnnotation = "foldkitRemoteRelation";
|
|
1328
|
-
/** The target entity's name on the same codec, so a nested selection of another entity is refused. */
|
|
1329
|
-
const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
|
|
1330
|
-
/** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
|
|
1331
|
-
const refParts = (encoded) => {
|
|
1332
|
-
const separator = encoded.indexOf(":");
|
|
1333
|
-
return separator === -1 ? {
|
|
1334
|
-
entity: encoded,
|
|
1335
|
-
id: ""
|
|
1336
|
-
} : {
|
|
1337
|
-
entity: encoded.slice(0, separator),
|
|
1338
|
-
id: encoded.slice(separator + 1)
|
|
1339
|
-
};
|
|
1340
|
-
};
|
|
1341
|
-
/**
|
|
1342
|
-
* The refs a relation value carries, in order. Anything that is not a ref key,
|
|
1343
|
-
* an array of them, or a page of them contributes nothing.
|
|
1344
|
-
*/
|
|
1345
|
-
const refsIn = (value) => {
|
|
1346
|
-
if (typeof value === "string") return [refParts(value)];
|
|
1347
|
-
if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
|
|
1348
|
-
if (value !== null && typeof value === "object") {
|
|
1349
|
-
const refs = value.refs;
|
|
1350
|
-
if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
|
|
1351
|
-
}
|
|
1352
|
-
return [];
|
|
1353
|
-
};
|
|
1354
|
-
/** The refs in a relation value that name the relation's target entity, and no other. */
|
|
1355
|
-
const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
|
|
1356
|
-
const isRefPage = (value) => {
|
|
1357
|
-
if (value === null || typeof value !== "object") return false;
|
|
1358
|
-
const page = value;
|
|
1359
|
-
return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
|
|
1360
|
-
};
|
|
1361
|
-
/**
|
|
1362
|
-
* The relation shape an entity field schema declares: a ref (`one`), a nullable
|
|
1363
|
-
* ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
|
|
1364
|
-
* scalar field, which cannot take a nested selection.
|
|
1365
|
-
*/
|
|
1366
|
-
const relationShape = (schema) => shapeOf(schema.ast, false);
|
|
1367
|
-
const shapeOf = (ast, nullable) => {
|
|
1368
|
-
const annotated = ast.annotations?.[RelationAnnotation];
|
|
1369
|
-
if (annotated === "one" || annotated === "page") return {
|
|
1370
|
-
kind: annotated,
|
|
1371
|
-
nullable,
|
|
1372
|
-
entity: String(ast.annotations?.[RelationEntityAnnotation])
|
|
1373
|
-
};
|
|
1374
|
-
if (ast._tag === "Arrays") {
|
|
1375
|
-
const item = ast.rest?.[0];
|
|
1376
|
-
const inner = item === void 0 ? void 0 : shapeOf(item, false);
|
|
1377
|
-
return inner?.kind === "one" ? {
|
|
1378
|
-
kind: "many",
|
|
1379
|
-
nullable,
|
|
1380
|
-
entity: inner.entity
|
|
1381
|
-
} : void 0;
|
|
1382
|
-
}
|
|
1383
|
-
if (ast._tag === "Union") {
|
|
1384
|
-
const members = ast.types ?? [];
|
|
1385
|
-
const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
|
|
1386
|
-
for (const member of members) {
|
|
1387
|
-
const shape = shapeOf(member, optional);
|
|
1388
|
-
if (shape !== void 0) return shape;
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
};
|
|
1392
|
-
//#endregion
|
|
1393
1757
|
//#region src/plan.ts
|
|
1394
1758
|
/**
|
|
1395
1759
|
* The pure requirement planner.
|
|
@@ -1659,6 +2023,8 @@ const RemotePersistence = {
|
|
|
1659
2023
|
//#endregion
|
|
1660
2024
|
//#region src/remoteData.ts
|
|
1661
2025
|
/** `RemoteData`: the state of a value the store may not hold yet. */
|
|
2026
|
+
const fresh = { _tag: "Fresh" };
|
|
2027
|
+
const refreshing = { _tag: "Refreshing" };
|
|
1662
2028
|
const remoteErrorSchema = Schema.Struct({
|
|
1663
2029
|
_tag: Schema.String,
|
|
1664
2030
|
message: Schema.String
|
|
@@ -1706,6 +2072,43 @@ const RemoteData = {
|
|
|
1706
2072
|
case "NotFound": return cases.NotFound();
|
|
1707
2073
|
}
|
|
1708
2074
|
},
|
|
2075
|
+
/**
|
|
2076
|
+
* The view-oriented fold: the six states as the three things a view actually
|
|
2077
|
+
* draws, under the policy that useful data stays on screen. A value that is
|
|
2078
|
+
* being refetched, or that a later read failed to replace, still reaches
|
|
2079
|
+
* `data` — with `freshness` saying which — instead of being replaced by a
|
|
2080
|
+
* spinner or an error the reader cannot act on.
|
|
2081
|
+
*
|
|
2082
|
+
* ```ts
|
|
2083
|
+
* RemoteData.render(model.user, {
|
|
2084
|
+
* loading: () => UserSkeleton(),
|
|
2085
|
+
* notFound: () => NoSuchUser(),
|
|
2086
|
+
* failed: error => ErrorView(error),
|
|
2087
|
+
* data: (user, freshness) => UserView({ user, dimmed: freshness._tag !== 'Fresh' }),
|
|
2088
|
+
* })
|
|
2089
|
+
* ```
|
|
2090
|
+
*
|
|
2091
|
+
* `notFound` is its own branch and not optional: a row the server answered
|
|
2092
|
+
* for and does not have is neither loading nor a failure, and drawing it as
|
|
2093
|
+
* either is a spinner that never ends or an error nobody can fix.
|
|
2094
|
+
*
|
|
2095
|
+
* This does not replace `match`, which is the exhaustive fold over the states
|
|
2096
|
+
* themselves. Reach for `match` when the six states really do draw
|
|
2097
|
+
* differently, and for this when they draw the usual three ways.
|
|
2098
|
+
*/
|
|
2099
|
+
render: (data, cases) => {
|
|
2100
|
+
switch (data._tag) {
|
|
2101
|
+
case "Initial":
|
|
2102
|
+
case "Loading": return cases.loading();
|
|
2103
|
+
case "NotFound": return cases.notFound();
|
|
2104
|
+
case "Ready": return cases.data(data.value, fresh);
|
|
2105
|
+
case "Refreshing": return cases.data(data.value, refreshing);
|
|
2106
|
+
case "Failed": return data.previous === void 0 ? cases.failed(data.error) : cases.data(data.previous, {
|
|
2107
|
+
_tag: "Stale",
|
|
2108
|
+
error: data.error
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
},
|
|
1709
2112
|
map: (data, f) => {
|
|
1710
2113
|
switch (data._tag) {
|
|
1711
2114
|
case "Ready": return {
|
|
@@ -1735,6 +2138,11 @@ const RemoteData = {
|
|
|
1735
2138
|
* optimistic layers an application keeps in its Model, the Messages that
|
|
1736
2139
|
* change it, and the pure reducer over them.
|
|
1737
2140
|
*/
|
|
2141
|
+
const emptyRefresh = {
|
|
2142
|
+
generation: 0,
|
|
2143
|
+
requested: /* @__PURE__ */ new Map(),
|
|
2144
|
+
started: /* @__PURE__ */ new Map()
|
|
2145
|
+
};
|
|
1738
2146
|
const initialRemoteModel = {
|
|
1739
2147
|
entities: emptyStore,
|
|
1740
2148
|
connections: {},
|
|
@@ -1743,10 +2151,7 @@ const initialRemoteModel = {
|
|
|
1743
2151
|
mutations: emptyMutationState,
|
|
1744
2152
|
gaps: /* @__PURE__ */ new Set(),
|
|
1745
2153
|
loading: /* @__PURE__ */ new Set(),
|
|
1746
|
-
refresh:
|
|
1747
|
-
requested: 0,
|
|
1748
|
-
started: 0
|
|
1749
|
-
}
|
|
2154
|
+
refresh: emptyRefresh
|
|
1750
2155
|
};
|
|
1751
2156
|
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
1752
2157
|
const runtimeSchema = Schema.Unknown;
|
|
@@ -1758,10 +2163,7 @@ const remoteModelSchema = () => Schema.Struct({
|
|
|
1758
2163
|
mutations: runtimeSchema,
|
|
1759
2164
|
gaps: runtimeSchema,
|
|
1760
2165
|
loading: runtimeSchema,
|
|
1761
|
-
refresh:
|
|
1762
|
-
requested: Schema.Number,
|
|
1763
|
-
started: Schema.Number
|
|
1764
|
-
})
|
|
2166
|
+
refresh: runtimeSchema
|
|
1765
2167
|
});
|
|
1766
2168
|
const retentionRootsSchema = Schema.Struct({
|
|
1767
2169
|
requirements: Schema.Array(ReadRequest),
|
|
@@ -1801,12 +2203,21 @@ const remoteMessageCases = {
|
|
|
1801
2203
|
MutationSucceeded: {
|
|
1802
2204
|
requestId: Schema.String,
|
|
1803
2205
|
entities: Schema.Array(NormalizedEntity),
|
|
1804
|
-
connections: Schema.optional(Schema.Array(Schema.Unknown))
|
|
2206
|
+
connections: Schema.optional(Schema.Array(Schema.Unknown)),
|
|
2207
|
+
deleted: Schema.optional(Schema.Array(Schema.Struct({
|
|
2208
|
+
entity: Schema.String,
|
|
2209
|
+
id: Schema.String
|
|
2210
|
+
})))
|
|
1805
2211
|
},
|
|
1806
2212
|
MutationFailed: {
|
|
1807
2213
|
requestId: Schema.String,
|
|
1808
2214
|
error: remoteErrorSchema
|
|
1809
2215
|
},
|
|
2216
|
+
OverlayShown: {
|
|
2217
|
+
id: Schema.String,
|
|
2218
|
+
optimistic: Schema.Array(Schema.Unknown)
|
|
2219
|
+
},
|
|
2220
|
+
OverlayLifted: { id: Schema.String },
|
|
1810
2221
|
LiveReceived: {
|
|
1811
2222
|
stream: Schema.String,
|
|
1812
2223
|
event: Schema.Unknown,
|
|
@@ -1835,39 +2246,122 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
|
|
|
1835
2246
|
_tag: Schema.Literal(tag),
|
|
1836
2247
|
...fields
|
|
1837
2248
|
})));
|
|
2249
|
+
/** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
|
|
2250
|
+
const overlayLayer = (id) => `overlay:${id}`;
|
|
1838
2251
|
const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
|
|
1839
2252
|
/**
|
|
1840
|
-
* One mark per requested field
|
|
1841
|
-
* a request id: two reads asking for one field share
|
|
1842
|
-
* answer clears it. Over-clearing shows `Initial` rather
|
|
1843
|
-
* is the safer way to be wrong.
|
|
2253
|
+
* One mark per requested field, `entity\0id\0field`. A read is identified by
|
|
2254
|
+
* what it asks for, not by a request id: two reads asking for one field share
|
|
2255
|
+
* its mark, so the first answer clears it. Over-clearing shows `Initial` rather
|
|
2256
|
+
* than a spinner, which is the safer way to be wrong. The refresh generations
|
|
2257
|
+
* below key off the same marks, so "what a read asks for" means one thing.
|
|
1844
2258
|
*/
|
|
1845
|
-
const
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
for (const field of request.fields) yield `${key}\u0000${field}`;
|
|
1849
|
-
}
|
|
2259
|
+
const fieldMark = (entity, id, field) => `${entityKey(entity, id)}\u0000${field}`;
|
|
2260
|
+
const fieldMarks = function* (requests) {
|
|
2261
|
+
for (const request of requests) for (const field of request.fields) yield fieldMark(request.entity, request.id, field);
|
|
1850
2262
|
};
|
|
1851
2263
|
const withLoading = (loading, requests) => {
|
|
1852
2264
|
const next = new Set(loading);
|
|
1853
|
-
for (const mark of
|
|
2265
|
+
for (const mark of fieldMarks(requests)) next.add(mark);
|
|
1854
2266
|
return next;
|
|
1855
2267
|
};
|
|
1856
2268
|
const withoutLoading = (loading, requests) => {
|
|
1857
2269
|
if (loading.size === 0) return loading;
|
|
1858
2270
|
const next = new Set(loading);
|
|
1859
|
-
for (const mark of
|
|
2271
|
+
for (const mark of fieldMarks(requests)) next.delete(mark);
|
|
1860
2272
|
return next;
|
|
1861
2273
|
};
|
|
1862
2274
|
/**
|
|
2275
|
+
* A connection's mark. The leading separator keeps it out of the field marks'
|
|
2276
|
+
* space, whatever a connection identity happens to spell.
|
|
2277
|
+
*/
|
|
2278
|
+
const connectionPrefix = "\0connection\0";
|
|
2279
|
+
const connectionMark = (identity) => `${connectionPrefix}${identity}`;
|
|
2280
|
+
/**
|
|
2281
|
+
* Drops the refresh marks of entities and connections that were collected.
|
|
2282
|
+
* Nothing observes a collected entity — the retention roots are the active
|
|
2283
|
+
* Surfaces — so no read entry's generation can fall back when its mark goes.
|
|
2284
|
+
* Without this the generations would be bounded by how many things the
|
|
2285
|
+
* application has ever refreshed rather than by what it currently holds, which
|
|
2286
|
+
* for a list refreshed on a timer is the same as not bounded at all.
|
|
2287
|
+
*/
|
|
2288
|
+
const prunedRefresh = (refresh, retained) => {
|
|
2289
|
+
const live = (mark) => mark.startsWith(connectionPrefix) ? retained.connections[mark.slice(12)] !== void 0 : retained.entities[mark.slice(0, mark.indexOf("\0"))] !== void 0;
|
|
2290
|
+
const keep = (marks) => {
|
|
2291
|
+
const next = /* @__PURE__ */ new Map();
|
|
2292
|
+
for (const [mark, generation] of marks) if (live(mark)) next.set(mark, generation);
|
|
2293
|
+
return next.size === marks.size ? marks : next;
|
|
2294
|
+
};
|
|
2295
|
+
const requested = keep(refresh.requested);
|
|
2296
|
+
const started = keep(refresh.started);
|
|
2297
|
+
return requested === refresh.requested && started === refresh.started ? refresh : {
|
|
2298
|
+
...refresh,
|
|
2299
|
+
requested,
|
|
2300
|
+
started
|
|
2301
|
+
};
|
|
2302
|
+
};
|
|
2303
|
+
/**
|
|
2304
|
+
* The generation a read is planned under: the highest any of the fields it
|
|
2305
|
+
* reads or connections it runs was refreshed at. A read entry carries this as a
|
|
2306
|
+
* dependency, so it restarts when what it observes is refreshed and not when
|
|
2307
|
+
* anything else is.
|
|
2308
|
+
*/
|
|
2309
|
+
const refreshedAt = (refresh, requests, connections = []) => {
|
|
2310
|
+
let at = 0;
|
|
2311
|
+
for (const mark of fieldMarks(requests)) at = Math.max(at, refresh.requested.get(mark) ?? 0);
|
|
2312
|
+
for (const identity of connections) at = Math.max(at, refresh.requested.get(connectionMark(identity)) ?? 0);
|
|
2313
|
+
return at;
|
|
2314
|
+
};
|
|
2315
|
+
/**
|
|
2316
|
+
* Marks every field of `requests` and every connection in `connections`
|
|
2317
|
+
* refreshed at the next generation.
|
|
2318
|
+
*/
|
|
2319
|
+
const withRefreshRequested = (refresh, requests, connections = []) => {
|
|
2320
|
+
const generation = refresh.generation + 1;
|
|
2321
|
+
const requested = new Map(refresh.requested);
|
|
2322
|
+
for (const mark of fieldMarks(requests)) requested.set(mark, generation);
|
|
2323
|
+
for (const identity of connections) requested.set(connectionMark(identity), generation);
|
|
2324
|
+
return {
|
|
2325
|
+
...refresh,
|
|
2326
|
+
generation,
|
|
2327
|
+
requested
|
|
2328
|
+
};
|
|
2329
|
+
};
|
|
2330
|
+
/**
|
|
2331
|
+
* Records that a read of `requests` began under `generation`. A read that
|
|
2332
|
+
* carries no generation began under none, and leaves the marks as they are.
|
|
2333
|
+
*/
|
|
2334
|
+
const withRefreshStarted = (refresh, requests, generation) => {
|
|
2335
|
+
if (generation === void 0 || generation === 0) return refresh;
|
|
2336
|
+
let started;
|
|
2337
|
+
for (const mark of fieldMarks(requests)) {
|
|
2338
|
+
if ((refresh.started.get(mark) ?? 0) >= generation) continue;
|
|
2339
|
+
started ??= new Map(refresh.started);
|
|
2340
|
+
started.set(mark, generation);
|
|
2341
|
+
}
|
|
2342
|
+
return started === void 0 ? refresh : {
|
|
2343
|
+
...refresh,
|
|
2344
|
+
started
|
|
2345
|
+
};
|
|
2346
|
+
};
|
|
2347
|
+
/**
|
|
2348
|
+
* Whether one field's read already began under the generation that field was
|
|
2349
|
+
* last refreshed at. Such a read is in flight and would otherwise outlive the
|
|
2350
|
+
* refresh, so the refresh takes a new generation to restart it even though the
|
|
2351
|
+
* field is already stale.
|
|
2352
|
+
*/
|
|
2353
|
+
const refreshIsInFlight = (refresh, request, field) => {
|
|
2354
|
+
const mark = fieldMark(request.entity, request.id, field);
|
|
2355
|
+
return (refresh.started.get(mark) ?? 0) === (refresh.requested.get(mark) ?? 0);
|
|
2356
|
+
};
|
|
2357
|
+
/**
|
|
1863
2358
|
* Whether a read is fetching any of `fields` for this entity. Consulted only
|
|
1864
2359
|
* when the store lacks the value, which is what separates `Loading` from
|
|
1865
2360
|
* `Initial`.
|
|
1866
2361
|
*/
|
|
1867
2362
|
const isLoading = (model, entity, id, fields) => {
|
|
1868
2363
|
if (model.loading.size === 0) return false;
|
|
1869
|
-
|
|
1870
|
-
return fields.some((field) => model.loading.has(`${key}\u0000${field}`));
|
|
2364
|
+
return fields.some((field) => model.loading.has(fieldMark(entity, id, field)));
|
|
1871
2365
|
};
|
|
1872
2366
|
const setConnectionStale = (connections, connection, stale) => {
|
|
1873
2367
|
const current = connections[connection] ?? emptyConnection;
|
|
@@ -1904,39 +2398,38 @@ const updateRemote = (model, message) => {
|
|
|
1904
2398
|
entities: setStale(model.entities, marksOf(message.requests), false),
|
|
1905
2399
|
loading: withoutLoading(model.loading, message.requests)
|
|
1906
2400
|
};
|
|
1907
|
-
case "RetentionChanged":
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2401
|
+
case "RetentionChanged": {
|
|
2402
|
+
const retained = gc(model, message.roots);
|
|
2403
|
+
return {
|
|
2404
|
+
...model,
|
|
2405
|
+
...retained,
|
|
2406
|
+
refresh: prunedRefresh(model.refresh, retained)
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
1911
2409
|
case "Hydrated": return {
|
|
1912
2410
|
...model,
|
|
1913
2411
|
entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
|
|
1914
2412
|
};
|
|
1915
2413
|
case "RefreshStarted": {
|
|
1916
|
-
const
|
|
2414
|
+
const asked = message.requests.reduce((store, request) => isTombstone(store, entityKey(request.entity, request.id)) ? remove(store, entityKey(request.entity, request.id)) : store, model.entities);
|
|
2415
|
+
const entities = setStale(asked, marksOf(message.requests), true);
|
|
1917
2416
|
return entities === model.entities ? model : {
|
|
1918
2417
|
...model,
|
|
1919
2418
|
entities
|
|
1920
2419
|
};
|
|
1921
2420
|
}
|
|
1922
|
-
case "ReadStarted": {
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
refresh: started === model.refresh.started ? model.refresh : {
|
|
1928
|
-
...model.refresh,
|
|
1929
|
-
started
|
|
1930
|
-
}
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
2421
|
+
case "ReadStarted": return {
|
|
2422
|
+
...model,
|
|
2423
|
+
loading: withLoading(model.loading, message.requests),
|
|
2424
|
+
refresh: withRefreshStarted(model.refresh, message.requests, message.refresh)
|
|
2425
|
+
};
|
|
1933
2426
|
case "MutationStarted": return {
|
|
1934
2427
|
...model,
|
|
1935
2428
|
mutations: beginMutation(model.mutations, message.requestId),
|
|
1936
2429
|
optimistic: beginOptimistic(model.optimistic, message.requestId, message.optimistic ?? [])
|
|
1937
2430
|
};
|
|
1938
2431
|
case "MutationSucceeded": {
|
|
1939
|
-
const settled = settleSuccess(model.entities, model.optimistic, model.mutations, message.requestId, message.entities, message.connections ?? []);
|
|
2432
|
+
const settled = settleSuccess(model.entities, model.optimistic, model.mutations, message.requestId, message.entities, message.connections ?? [], message.deleted ?? []);
|
|
1940
2433
|
return {
|
|
1941
2434
|
...model,
|
|
1942
2435
|
entities: settled.store,
|
|
@@ -1944,9 +2437,20 @@ const updateRemote = (model, message) => {
|
|
|
1944
2437
|
optimistic: settled.optimistic
|
|
1945
2438
|
};
|
|
1946
2439
|
}
|
|
2440
|
+
case "OverlayShown": return {
|
|
2441
|
+
...model,
|
|
2442
|
+
optimistic: beginOptimistic(settleFailure(model.optimistic, overlayLayer(message.id)), overlayLayer(message.id), message.optimistic)
|
|
2443
|
+
};
|
|
2444
|
+
case "OverlayLifted": {
|
|
2445
|
+
const optimistic = settleFailure(model.optimistic, overlayLayer(message.id));
|
|
2446
|
+
return optimistic.layers.length === model.optimistic.layers.length && optimistic.overlays.length === model.optimistic.overlays.length ? model : {
|
|
2447
|
+
...model,
|
|
2448
|
+
optimistic
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
1947
2451
|
case "MutationFailed": return {
|
|
1948
2452
|
...model,
|
|
1949
|
-
mutations: failMutation(model.mutations, message.requestId),
|
|
2453
|
+
mutations: failMutation(model.mutations, message.requestId, message.error),
|
|
1950
2454
|
optimistic: settleFailure(model.optimistic, message.requestId)
|
|
1951
2455
|
};
|
|
1952
2456
|
case "LiveReceived": {
|
|
@@ -2082,7 +2586,12 @@ const writeRead = (store, requests, result, now = 0) => {
|
|
|
2082
2586
|
windows: entry?.windows
|
|
2083
2587
|
});
|
|
2084
2588
|
}
|
|
2085
|
-
|
|
2589
|
+
let answered = store;
|
|
2590
|
+
for (const request of requests) {
|
|
2591
|
+
const key = entityKey(request.entity, request.id);
|
|
2592
|
+
if (!returned.has(key)) answered = tombstone(answered, key);
|
|
2593
|
+
}
|
|
2594
|
+
return writeEntities(answered, writes, now);
|
|
2086
2595
|
};
|
|
2087
2596
|
//#endregion
|
|
2088
2597
|
//#region src/policy.ts
|
|
@@ -2115,224 +2624,6 @@ const RemotePolicy = {
|
|
|
2115
2624
|
refreshes: (policy) => policy._tag !== "CacheFirst"
|
|
2116
2625
|
};
|
|
2117
2626
|
//#endregion
|
|
2118
|
-
//#region src/entity.ts
|
|
2119
|
-
/**
|
|
2120
|
-
* Entities: the descriptor an application declares, refs, ref pages, and
|
|
2121
|
-
* patches, with the codecs that carry refs on the wire.
|
|
2122
|
-
*/
|
|
2123
|
-
/**
|
|
2124
|
-
* Relations are **references**, never inline target schemas: a ref cannot
|
|
2125
|
-
* reconstruct a full entity, and dereferencing is a store concern. Because the
|
|
2126
|
-
* target schema is not inlined, recursive relations cannot arise through the
|
|
2127
|
-
* schema graph.
|
|
2128
|
-
*/
|
|
2129
|
-
const refCodec = (entity) => Schema.Struct({
|
|
2130
|
-
entity: Schema.String,
|
|
2131
|
-
id: Schema.String
|
|
2132
|
-
}).pipe(Schema.encodeTo(Schema.String, {
|
|
2133
|
-
decode: SchemaGetter.transform(refParts),
|
|
2134
|
-
encode: SchemaGetter.transform((ref) => entityKey(ref.entity, ref.id))
|
|
2135
|
-
})).annotate({
|
|
2136
|
-
[RelationAnnotation]: "one",
|
|
2137
|
-
[RelationEntityAnnotation]: entity
|
|
2138
|
-
});
|
|
2139
|
-
const refPageCodec = (entity) => Schema.Struct({
|
|
2140
|
-
refs: Schema.Array(refCodec(entity)),
|
|
2141
|
-
hasNext: Schema.Boolean,
|
|
2142
|
-
hasPrevious: Schema.Boolean
|
|
2143
|
-
}).annotate({
|
|
2144
|
-
[RelationAnnotation]: "page",
|
|
2145
|
-
[RelationEntityAnnotation]: entity
|
|
2146
|
-
});
|
|
2147
|
-
const Entity = {
|
|
2148
|
-
make: (name, schema) => {
|
|
2149
|
-
const descriptor = {
|
|
2150
|
-
name,
|
|
2151
|
-
schema,
|
|
2152
|
-
fields: schema.fields,
|
|
2153
|
-
ref: (id) => ({
|
|
2154
|
-
entity: name,
|
|
2155
|
-
id: String(id)
|
|
2156
|
-
}),
|
|
2157
|
-
select: (selection) => Selection.make(descriptor, selection),
|
|
2158
|
-
patch: (id, values) => ({
|
|
2159
|
-
entity: name,
|
|
2160
|
-
id: String(id),
|
|
2161
|
-
values
|
|
2162
|
-
})
|
|
2163
|
-
};
|
|
2164
|
-
return descriptor;
|
|
2165
|
-
},
|
|
2166
|
-
/** A relation to a known entity, decoded as a reference. */
|
|
2167
|
-
ref: (entity) => refCodec(entity.name),
|
|
2168
|
-
/** A relation by name, for recursive or forward references. */
|
|
2169
|
-
refTo: (name) => refCodec(name),
|
|
2170
|
-
/**
|
|
2171
|
-
* The wire key a relation field carries (`"Entity:id"`). Adapters that read a
|
|
2172
|
-
* foreign key emit this so the ref codec can decode it on the client.
|
|
2173
|
-
*/
|
|
2174
|
-
refKey: (ref) => entityKey(ref.entity, ref.id),
|
|
2175
|
-
/** Splits a ref key back into its entity and id. */
|
|
2176
|
-
refParts,
|
|
2177
|
-
/** A relation value of one authoritative page of refs. */
|
|
2178
|
-
refPage: (entity) => refPageCodec(entity.name),
|
|
2179
|
-
/**
|
|
2180
|
-
* A normalized patch for an entity, typed against its fields' wire shape:
|
|
2181
|
-
* write a relation as its ref key (`Entity.refKey`), as the server does.
|
|
2182
|
-
*/
|
|
2183
|
-
patch: (ref, patch) => ({
|
|
2184
|
-
entity: ref.entity,
|
|
2185
|
-
id: ref.id,
|
|
2186
|
-
values: patch
|
|
2187
|
-
})
|
|
2188
|
-
};
|
|
2189
|
-
//#endregion
|
|
2190
|
-
//#region src/selection.ts
|
|
2191
|
-
/**
|
|
2192
|
-
* Selections: the fields a Surface picks of an entity (nested through
|
|
2193
|
-
* relations, paged through connections), the requirement graph they imply,
|
|
2194
|
-
* and their assembly from the normalized store.
|
|
2195
|
-
*/
|
|
2196
|
-
const pageSchemas = /* @__PURE__ */ new WeakMap();
|
|
2197
|
-
const pageSchema = (item) => {
|
|
2198
|
-
const cached = pageSchemas.get(item);
|
|
2199
|
-
if (cached !== void 0) return cached;
|
|
2200
|
-
const built = Schema.Struct({
|
|
2201
|
-
items: Schema.Array(item),
|
|
2202
|
-
hasNext: Schema.Boolean,
|
|
2203
|
-
hasPrevious: Schema.Boolean
|
|
2204
|
-
});
|
|
2205
|
-
pageSchemas.set(item, built);
|
|
2206
|
-
return built;
|
|
2207
|
-
};
|
|
2208
|
-
/** The requirement a nested selection contributes for its relation's target. */
|
|
2209
|
-
const relationOf = (selection) => ({
|
|
2210
|
-
entity: selection.entity,
|
|
2211
|
-
fields: selection.fields,
|
|
2212
|
-
...selection.connections === void 0 ? {} : { windows: selection.connections },
|
|
2213
|
-
...selection.relations === void 0 ? {} : { relations: selection.relations }
|
|
2214
|
-
});
|
|
2215
|
-
const Selection = {
|
|
2216
|
-
/**
|
|
2217
|
-
* The fields to read of `entity`. A nested `Selection` on a relation field
|
|
2218
|
-
* reads through the ref (or refs, or page of refs) the field holds into the
|
|
2219
|
-
* target's fields; a scalar field cannot take one.
|
|
2220
|
-
*/
|
|
2221
|
-
make: (entity, selection) => {
|
|
2222
|
-
if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
|
|
2223
|
-
const picked = {};
|
|
2224
|
-
const connections = {};
|
|
2225
|
-
const relations = {};
|
|
2226
|
-
for (const key of Object.keys(selection)) {
|
|
2227
|
-
const choice = selection[key];
|
|
2228
|
-
if (choice === true) {
|
|
2229
|
-
picked[key] = entity.fields[key];
|
|
2230
|
-
continue;
|
|
2231
|
-
}
|
|
2232
|
-
const nested = choice;
|
|
2233
|
-
const shape = relationShape(entity.fields[key]);
|
|
2234
|
-
if (shape === void 0) throw new Error(`Selection.make: "${key}" on "${entity.name}" is not a relation field, so it cannot take a nested selection`);
|
|
2235
|
-
if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
|
|
2236
|
-
const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
|
|
2237
|
-
if (nested.window !== void 0) {
|
|
2238
|
-
connections[key] = nested.window;
|
|
2239
|
-
picked[key] = nullable(nested.schema);
|
|
2240
|
-
if (nested.fields.length > 0) relations[key] = relationOf(nested);
|
|
2241
|
-
continue;
|
|
2242
|
-
}
|
|
2243
|
-
const item = nested.schema;
|
|
2244
|
-
picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
|
|
2245
|
-
relations[key] = relationOf(nested);
|
|
2246
|
-
}
|
|
2247
|
-
return {
|
|
2248
|
-
entity: entity.name,
|
|
2249
|
-
fields: Object.keys(selection),
|
|
2250
|
-
schema: Schema.Struct(picked),
|
|
2251
|
-
...Object.keys(connections).length === 0 ? {} : { connections },
|
|
2252
|
-
...Object.keys(relations).length === 0 ? {} : { relations }
|
|
2253
|
-
};
|
|
2254
|
-
},
|
|
2255
|
-
/**
|
|
2256
|
-
* A paginated relation: one page of refs to `entity` with a window, or, with
|
|
2257
|
-
* a nested selection, one page of targets assembled through it.
|
|
2258
|
-
*/
|
|
2259
|
-
connection: ((entity, window, selection) => {
|
|
2260
|
-
if (selection !== void 0 && selection.entity !== entity.name) throw new Error(`Selection.connection: a page of "${entity.name}" cannot select "${selection.entity}"`);
|
|
2261
|
-
return {
|
|
2262
|
-
entity: entity.name,
|
|
2263
|
-
fields: selection?.fields ?? [],
|
|
2264
|
-
schema: selection === void 0 ? Entity.refPage(entity) : pageSchema(selection.schema),
|
|
2265
|
-
window,
|
|
2266
|
-
...selection?.connections === void 0 ? {} : { connections: selection.connections },
|
|
2267
|
-
...selection?.relations === void 0 ? {} : { relations: selection.relations }
|
|
2268
|
-
};
|
|
2269
|
-
})
|
|
2270
|
-
};
|
|
2271
|
-
/**
|
|
2272
|
-
* Reads an entity's selected fields out of the store, following each nested
|
|
2273
|
-
* relation into its targets. `undefined` means some field, at any depth, is
|
|
2274
|
-
* not present yet. A tombstoned target reads as `null` (or is dropped from a
|
|
2275
|
-
* list), so the Selection's codec decides whether that is a failure.
|
|
2276
|
-
*/
|
|
2277
|
-
const assemble = (store, key, requirement) => {
|
|
2278
|
-
const values = {};
|
|
2279
|
-
let refreshing = false;
|
|
2280
|
-
for (const field of requirement.fields) {
|
|
2281
|
-
const value = readField(store, key, field);
|
|
2282
|
-
if (Option.isNone(value)) return void 0;
|
|
2283
|
-
refreshing ||= isFieldStale(store, key, field);
|
|
2284
|
-
const relation = requirement.relations?.[field];
|
|
2285
|
-
if (relation === void 0) {
|
|
2286
|
-
values[field] = value.value;
|
|
2287
|
-
continue;
|
|
2288
|
-
}
|
|
2289
|
-
const nested = assembleRelation(store, value.value, relation);
|
|
2290
|
-
if (nested === void 0) return void 0;
|
|
2291
|
-
values[field] = nested.values;
|
|
2292
|
-
refreshing ||= nested.refreshing;
|
|
2293
|
-
}
|
|
2294
|
-
return {
|
|
2295
|
-
values,
|
|
2296
|
-
refreshing
|
|
2297
|
-
};
|
|
2298
|
-
};
|
|
2299
|
-
const assembleTarget = (store, ref, relation) => {
|
|
2300
|
-
const key = entityKey(ref.entity, ref.id);
|
|
2301
|
-
return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
|
|
2302
|
-
};
|
|
2303
|
-
/** Assembles the targets a relation value refers to, in the value's own shape. */
|
|
2304
|
-
const assembleRelation = (store, value, relation) => {
|
|
2305
|
-
if (value === null || value === void 0) return {
|
|
2306
|
-
values: null,
|
|
2307
|
-
refreshing: false
|
|
2308
|
-
};
|
|
2309
|
-
const targets = [];
|
|
2310
|
-
let refreshing = false;
|
|
2311
|
-
for (const ref of targetsOf(value, relation)) {
|
|
2312
|
-
const target = assembleTarget(store, ref, relation);
|
|
2313
|
-
if (target === void 0) return void 0;
|
|
2314
|
-
if (target === "absent") continue;
|
|
2315
|
-
targets.push(target.values);
|
|
2316
|
-
refreshing ||= target.refreshing;
|
|
2317
|
-
}
|
|
2318
|
-
if (typeof value === "string") return {
|
|
2319
|
-
values: targets.length === 0 ? null : targets[0],
|
|
2320
|
-
refreshing
|
|
2321
|
-
};
|
|
2322
|
-
if (isRefPage(value)) return {
|
|
2323
|
-
values: {
|
|
2324
|
-
items: targets,
|
|
2325
|
-
hasNext: value.hasNext,
|
|
2326
|
-
hasPrevious: value.hasPrevious
|
|
2327
|
-
},
|
|
2328
|
-
refreshing
|
|
2329
|
-
};
|
|
2330
|
-
return {
|
|
2331
|
-
values: targets,
|
|
2332
|
-
refreshing
|
|
2333
|
-
};
|
|
2334
|
-
};
|
|
2335
|
-
//#endregion
|
|
2336
2627
|
//#region src/index.ts
|
|
2337
2628
|
/**
|
|
2338
2629
|
* `foldkit-remote` — normalized application-facing server state.
|
|
@@ -2341,6 +2632,8 @@ const assembleRelation = (store, value, relation) => {
|
|
|
2341
2632
|
* live classification, optimistic layers) performs no I/O. The `Remote.*`
|
|
2342
2633
|
* helpers that read or mutate go through the `RemoteClient` Effect service.
|
|
2343
2634
|
*/
|
|
2635
|
+
const descriptorOf = (entity) => Entity$1.is(entity) ? Entity.from(entity) : entity;
|
|
2636
|
+
const selectionOf = (selection) => SelectionTypeId in selection ? Selection.from(selection) : selection;
|
|
2344
2637
|
/**
|
|
2345
2638
|
* The store a read or plan sees: the base with every pending optimistic layer
|
|
2346
2639
|
* applied, so a request's patches show until it settles and a temporary id is
|
|
@@ -2350,6 +2643,22 @@ const storeOf = (bound, model) => {
|
|
|
2350
2643
|
const remote = bound.store.get(model);
|
|
2351
2644
|
return visibleStoreOf(remote.entities, remote.optimistic);
|
|
2352
2645
|
};
|
|
2646
|
+
/**
|
|
2647
|
+
* The Model as it would be with nothing optimistic pending. Every read reaches
|
|
2648
|
+
* the layers through the Model it is given, so dropping them here is what makes
|
|
2649
|
+
* a read confirmed — no read has to know it is being read confirmed.
|
|
2650
|
+
*/
|
|
2651
|
+
const withoutOptimistic = (bound, model) => {
|
|
2652
|
+
const remote = bound.store.get(model);
|
|
2653
|
+
return remote.optimistic.layers.length === 0 && remote.optimistic.overlays.length === 0 ? model : bound.store.set(model, {
|
|
2654
|
+
...remote,
|
|
2655
|
+
optimistic: emptyOptimistic
|
|
2656
|
+
});
|
|
2657
|
+
};
|
|
2658
|
+
const confirmed = (bound, projection) => ({
|
|
2659
|
+
...projection,
|
|
2660
|
+
read: (root) => projection.read(withoutOptimistic(bound, root))
|
|
2661
|
+
});
|
|
2353
2662
|
const visibleStores = /* @__PURE__ */ new WeakMap();
|
|
2354
2663
|
const visibleStoreOf = (entities, optimistic) => {
|
|
2355
2664
|
let byStore = visibleStores.get(optimistic);
|
|
@@ -2386,21 +2695,24 @@ const identityMessage = (message) => message;
|
|
|
2386
2695
|
/** A stable key for a live subscription's requirement set. */
|
|
2387
2696
|
const liveStreamKey = (requirements) => requirements.map((requirement) => `${entityKey(requirement.entity, requirement.id)}:${[...requirement.fields].sort().join(",")}`).sort().join("|");
|
|
2388
2697
|
/** Declares a Remote domain: its entities, queries, and mutations, plus the submodel. */
|
|
2389
|
-
const defineRemote = (config) =>
|
|
2390
|
-
entities
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2698
|
+
const defineRemote = (config) => {
|
|
2699
|
+
const entities = config.entities.map(descriptorOf);
|
|
2700
|
+
return {
|
|
2701
|
+
entities,
|
|
2702
|
+
queries: config.queries ?? [],
|
|
2703
|
+
mutations: config.mutations ?? [],
|
|
2704
|
+
Model: remoteModelSchema(),
|
|
2705
|
+
initial: initialRemoteModel,
|
|
2706
|
+
Message: remoteMessageSchema,
|
|
2707
|
+
update: updateRemote,
|
|
2708
|
+
rpc: RemoteRpc,
|
|
2709
|
+
registry: {
|
|
2710
|
+
entities: new Map(entities.map((entity) => [entity.name, entity])),
|
|
2711
|
+
queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
|
|
2712
|
+
mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
};
|
|
2404
2716
|
/** Binds a Remote domain to its store's location in the application Model. */
|
|
2405
2717
|
const bindRemote = (definition, store) => ({
|
|
2406
2718
|
definition,
|
|
@@ -2572,7 +2884,7 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
2572
2884
|
const remote = bound.store.get(model);
|
|
2573
2885
|
const planned = planAsked(remote, askedOf(model), RemotePolicy.toPlan(policy, now()));
|
|
2574
2886
|
return {
|
|
2575
|
-
refresh: remote.refresh.
|
|
2887
|
+
refresh: refreshedAt(remote.refresh, planned.requirements, planned.queries.map((query) => query.identity)),
|
|
2576
2888
|
requirements: planned.requirements,
|
|
2577
2889
|
queries: planned.queries.map(({ identity, window, select }) => ({
|
|
2578
2890
|
identity,
|
|
@@ -2678,7 +2990,8 @@ const Remote = {
|
|
|
2678
2990
|
* as `Failed` instead of being asserted into `Value`. A present value with a
|
|
2679
2991
|
* stale field reads as `Refreshing`: an observer is refetching it.
|
|
2680
2992
|
*/
|
|
2681
|
-
select: (bound,
|
|
2993
|
+
select: (bound, given) => {
|
|
2994
|
+
const selection = selectionOf(given);
|
|
2682
2995
|
assertRegistered(bound, "Entity", bound.definition.registry.entities, selection.entity);
|
|
2683
2996
|
const relation = relationOf(selection);
|
|
2684
2997
|
return (id) => ({
|
|
@@ -2733,6 +3046,12 @@ const Remote = {
|
|
|
2733
3046
|
*/
|
|
2734
3047
|
storeOf: (bound, model) => storeOf(bound, model),
|
|
2735
3048
|
/**
|
|
3049
|
+
* The projection read against the server-derived store alone: the same
|
|
3050
|
+
* requirements, planned the same way, with the pending optimistic layers and
|
|
3051
|
+
* connection overlays left off. See the bound `Remote.confirmed`.
|
|
3052
|
+
*/
|
|
3053
|
+
confirmed: (bound, projection) => confirmed(bound, projection),
|
|
3054
|
+
/**
|
|
2736
3055
|
* Executes the plan against the `RemoteClient` and returns a new store. Used
|
|
2737
3056
|
* for SSR route prefetch, hover prefetch, and tests. Never called during render.
|
|
2738
3057
|
* `policy` decides what a present field means (default cache-first); `now`
|
|
@@ -2779,19 +3098,17 @@ const Remote = {
|
|
|
2779
3098
|
return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
|
|
2780
3099
|
});
|
|
2781
3100
|
const requirements = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], { force: true });
|
|
3101
|
+
const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0).map((connection) => connection.identity);
|
|
2782
3102
|
const marked = [...requirements.length === 0 ? [] : [{
|
|
2783
3103
|
_tag: "RefreshStarted",
|
|
2784
3104
|
requests: requirements
|
|
2785
|
-
}], ...
|
|
3105
|
+
}], ...invalidated.map((connection) => ({
|
|
2786
3106
|
_tag: "ConnectionInvalidated",
|
|
2787
|
-
connection
|
|
3107
|
+
connection
|
|
2788
3108
|
}))].reduce(updateRemote, remote);
|
|
2789
|
-
const inFlight =
|
|
3109
|
+
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)));
|
|
2790
3110
|
if (marked === remote && !inFlight) return model;
|
|
2791
|
-
const refresh =
|
|
2792
|
-
...marked.refresh,
|
|
2793
|
-
requested: marked.refresh.requested + 1
|
|
2794
|
-
};
|
|
3111
|
+
const refresh = withRefreshRequested(marked.refresh, requirements, invalidated);
|
|
2795
3112
|
return bound.store.set(model, {
|
|
2796
3113
|
...marked,
|
|
2797
3114
|
refresh
|
|
@@ -2896,7 +3213,8 @@ const Remote = {
|
|
|
2896
3213
|
_tag: "MutationSucceeded",
|
|
2897
3214
|
requestId,
|
|
2898
3215
|
entities: outcome.entities,
|
|
2899
|
-
connections: outcome.connections
|
|
3216
|
+
connections: outcome.connections,
|
|
3217
|
+
deleted: outcome.deleted
|
|
2900
3218
|
});
|
|
2901
3219
|
return {
|
|
2902
3220
|
output: outcome.output,
|
|
@@ -2920,11 +3238,12 @@ const Remote = {
|
|
|
2920
3238
|
*/
|
|
2921
3239
|
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
|
|
2922
3240
|
};
|
|
3241
|
+
const brandEntries = (entries) => Subscription.make()(() => entries);
|
|
2923
3242
|
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
2924
3243
|
const bindDomain = (definition, store) => {
|
|
2925
3244
|
const bound = bindRemote(definition, store);
|
|
2926
3245
|
const reduce = (model, message) => store.set(model, updateRemote(store.get(model), message));
|
|
2927
|
-
|
|
3246
|
+
const domain = {
|
|
2928
3247
|
...definition,
|
|
2929
3248
|
...bound,
|
|
2930
3249
|
get: (selection, id) => Remote.select(bound, selection)(id),
|
|
@@ -2964,6 +3283,7 @@ const bindDomain = (definition, store) => {
|
|
|
2964
3283
|
},
|
|
2965
3284
|
plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
|
|
2966
3285
|
storeOf: (model) => storeOf(bound, model),
|
|
3286
|
+
confirmed: (projection) => confirmed(bound, projection),
|
|
2967
3287
|
prefetch: (model, projection, options = {}) => Effect.gen(function* () {
|
|
2968
3288
|
const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
|
|
2969
3289
|
const client = yield* RemoteClient;
|
|
@@ -2989,7 +3309,8 @@ const bindDomain = (definition, store) => {
|
|
|
2989
3309
|
}),
|
|
2990
3310
|
query: (query, input, options) => {
|
|
2991
3311
|
assertRegistered(bound, "Query", definition.registry.queries, query.name);
|
|
2992
|
-
const { select, ...window } = options;
|
|
3312
|
+
const { select: given, ...window } = options;
|
|
3313
|
+
const select = selectionOf(given);
|
|
2993
3314
|
for (const [side, size] of [["first", window.first], ["last", window.last]]) if (size !== void 0 && !(Number.isInteger(size) && size >= 0)) throw new Error(`Remote: query "${query.name}" asks for ${side}: ${String(size)}; a page size is a non-negative integer`);
|
|
2994
3315
|
const listed = query.Result.entity;
|
|
2995
3316
|
if (listed === void 0) throw new Error(`Remote: query "${query.name}" is not a connection over an entity, so it has no page to select`);
|
|
@@ -3072,6 +3393,19 @@ const bindDomain = (definition, store) => {
|
|
|
3072
3393
|
} : void 0;
|
|
3073
3394
|
},
|
|
3074
3395
|
refresh: (model, target) => Remote.refresh(bound, model, target),
|
|
3396
|
+
overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
|
|
3397
|
+
_tag: "OverlayShown",
|
|
3398
|
+
id,
|
|
3399
|
+
optimistic
|
|
3400
|
+
})),
|
|
3401
|
+
lift: (model, id) => {
|
|
3402
|
+
const remote = bound.store.get(model);
|
|
3403
|
+
const lifted = updateRemote(remote, {
|
|
3404
|
+
_tag: "OverlayLifted",
|
|
3405
|
+
id
|
|
3406
|
+
});
|
|
3407
|
+
return lifted === remote ? model : bound.store.set(model, lifted);
|
|
3408
|
+
},
|
|
3075
3409
|
fetch: (ref) => ({
|
|
3076
3410
|
name: `Remote.query(${ref.query})`,
|
|
3077
3411
|
args: {
|
|
@@ -3118,15 +3452,25 @@ const bindDomain = (definition, store) => {
|
|
|
3118
3452
|
_tag: "MutationSucceeded",
|
|
3119
3453
|
requestId,
|
|
3120
3454
|
entities: outcome.entities,
|
|
3121
|
-
connections: outcome.connections
|
|
3455
|
+
connections: outcome.connections,
|
|
3456
|
+
deleted: outcome.deleted
|
|
3122
3457
|
})
|
|
3123
3458
|
}))
|
|
3124
3459
|
}
|
|
3125
3460
|
};
|
|
3126
3461
|
},
|
|
3462
|
+
mutation: (model, requestId) => mutationStatus(store.get(model).mutations, requestId),
|
|
3127
3463
|
reduce,
|
|
3128
|
-
inspect: (model) => inspectRemote(store.get(model))
|
|
3464
|
+
inspect: (model) => inspectRemote(store.get(model)),
|
|
3465
|
+
wiring: (active, options) => ({
|
|
3466
|
+
key: `remote:${bound.contract.name}`,
|
|
3467
|
+
handles: Object.keys(remoteMessageCases),
|
|
3468
|
+
route: (model, message) => isRemoteMessage(message) ? Option.some({ model: reduce(model, message) }) : Option.none(),
|
|
3469
|
+
subscriptions: brandEntries(domain.subscriptions(active, options)),
|
|
3470
|
+
contract: bound.contract
|
|
3471
|
+
})
|
|
3129
3472
|
};
|
|
3473
|
+
return domain;
|
|
3130
3474
|
};
|
|
3131
3475
|
/** The window keys of a `QueryOptions`, and only those, without the undefined ones. */
|
|
3132
3476
|
const pickWindow = (window) => ({
|
|
@@ -3141,6 +3485,6 @@ const pageSize = (window, key) => {
|
|
|
3141
3485
|
return size === void 0 ? {} : { [key]: size };
|
|
3142
3486
|
};
|
|
3143
3487
|
//#endregion
|
|
3144
|
-
export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refsIn, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, writeEntities, writeEntity, writeRead };
|
|
3488
|
+
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, 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, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, mutationStatus, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, 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 };
|
|
3145
3489
|
|
|
3146
3490
|
//# sourceMappingURL=index.mjs.map
|