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/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: remember(state.failed, requestId)
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: Schema.Struct({
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. A read is identified by what it asks for, not by
1841
- * a request id: two reads asking for one field share its mark, so the first
1842
- * answer clears it. Over-clearing shows `Initial` rather than a spinner, which
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 loadingMarks = function* (requests) {
1846
- for (const request of requests) {
1847
- const key = entityKey(request.entity, request.id);
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 loadingMarks(requests)) next.add(mark);
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 loadingMarks(requests)) next.delete(mark);
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
- const key = entityKey(entity, id);
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": return {
1908
- ...model,
1909
- ...gc(model, message.roots)
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 entities = setStale(model.entities, marksOf(message.requests), true);
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
- const started = Math.max(model.refresh.started, message.refresh ?? 0);
1924
- return {
1925
- ...model,
1926
- loading: withLoading(model.loading, message.requests),
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
- return writeEntities(store, writes, now);
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: config.entities,
2391
- queries: config.queries ?? [],
2392
- mutations: config.mutations ?? [],
2393
- Model: remoteModelSchema(),
2394
- initial: initialRemoteModel,
2395
- Message: remoteMessageSchema,
2396
- update: updateRemote,
2397
- rpc: RemoteRpc,
2398
- registry: {
2399
- entities: new Map(config.entities.map((entity) => [entity.name, entity])),
2400
- queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
2401
- mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
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.requested,
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, selection) => {
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
- }], ...connections.filter((connection) => remote.connections[connection.identity] !== void 0).map((connection) => ({
3105
+ }], ...invalidated.map((connection) => ({
2786
3106
  _tag: "ConnectionInvalidated",
2787
- connection: connection.identity
3107
+ connection
2788
3108
  }))].reduce(updateRemote, remote);
2789
- const inFlight = remote.refresh.started === remote.refresh.requested && requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true));
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
- return {
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