foldkit-remote 0.6.0 → 0.8.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,6 +1,8 @@
1
1
  import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
2
2
  import * as Domain from "foldkit-entity";
3
3
  import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId, evaluate } from "foldkit-entity";
4
+ import { mapMessage } from "foldkit/command";
5
+ import { defineMessageUnion } from "foldkit/message";
4
6
  import * as Subscription from "foldkit/subscription";
5
7
  import { Metadata } from "foldkit-surface";
6
8
  import { Rpc, RpcGroup } from "effect/unstable/rpc";
@@ -208,6 +210,7 @@ const emptyEntry = {
208
210
  values: {},
209
211
  present: /* @__PURE__ */ new Set(),
210
212
  stale: /* @__PURE__ */ new Set(),
213
+ unavailable: /* @__PURE__ */ new Set(),
211
214
  tombstone: false,
212
215
  updatedAt: 0,
213
216
  windows: {}
@@ -220,10 +223,12 @@ const replace = (store, key, entry) => ({
220
223
  const written = (previous, values, now, windows) => {
221
224
  const present = new Set(previous.present);
222
225
  const stale = new Set(previous.stale);
226
+ const unavailable = new Set(previous.unavailable);
223
227
  const nextWindows = { ...previous.windows };
224
228
  for (const field of Object.keys(values)) {
225
229
  present.add(field);
226
230
  stale.delete(field);
231
+ unavailable.delete(field);
227
232
  const requested = windows?.[field];
228
233
  if (requested !== void 0 && requested !== "") nextWindows[field] = requested;
229
234
  else delete nextWindows[field];
@@ -238,6 +243,7 @@ const written = (previous, values, now, windows) => {
238
243
  },
239
244
  present,
240
245
  stale,
246
+ unavailable,
241
247
  tombstone: false,
242
248
  updatedAt: now,
243
249
  windows: nextWindows
@@ -287,11 +293,40 @@ const setStale = (store, marks, stale) => {
287
293
  const markStale = (store, key, fields) => setStale(store, [[key, fields]], true);
288
294
  /** Ends a refresh that did not land: the fields are present again as they were. */
289
295
  const clearStale = (store, key, fields) => setStale(store, [[key, fields]], false);
296
+ /**
297
+ * Records fields the server settled without a value. A present field is left
298
+ * as it is: the answer that settled it was not about the value it holds.
299
+ * `false` forgets the marks, which is how a refresh asks again.
300
+ */
301
+ const setUnavailable = (store, marks, unavailable) => {
302
+ let next;
303
+ for (const [key, fields] of marks) {
304
+ const previous = (next ?? store)[key] ?? emptyEntry;
305
+ if (previous.tombstone) continue;
306
+ const marked = new Set(previous.unavailable);
307
+ for (const field of fields) if (unavailable) {
308
+ if (!previous.present.has(field)) marked.add(field);
309
+ } else marked.delete(field);
310
+ if (marked.size === previous.unavailable.size) continue;
311
+ next ??= { ...store };
312
+ next[key] = {
313
+ ...previous,
314
+ unavailable: marked
315
+ };
316
+ }
317
+ return next ?? store;
318
+ };
319
+ /** Whether the server settled this field without a value, and nothing has written it since. */
320
+ const isFieldUnavailable = (store, key, field) => {
321
+ const value = store[key];
322
+ return value !== void 0 && !value.tombstone && value.unavailable.has(field);
323
+ };
290
324
  /** Records that the entity is known to be absent, so it is not refetched. */
291
325
  const tombstone = (store, key) => replace(store, key, {
292
326
  values: {},
293
327
  present: /* @__PURE__ */ new Set(),
294
328
  stale: /* @__PURE__ */ new Set(),
329
+ unavailable: /* @__PURE__ */ new Set(),
295
330
  tombstone: true,
296
331
  updatedAt: 0,
297
332
  windows: {}
@@ -329,7 +364,8 @@ const readField = (store, key, field) => {
329
364
  };
330
365
  /**
331
366
  * The fields the planner must fetch. A tombstone makes every field known
332
- * (absent), so it returns an empty list and the entity is not refetched. A
367
+ * (absent), so it returns an empty list and the entity is not refetched, and a
368
+ * field the server settled without a value is known the same way. A
333
369
  * requested window that differs from the one a field was fetched with also
334
370
  * marks it missing, but only when the stored window is known (never `""`), so a
335
371
  * writer that does not record windows cannot cause a refetch loop.
@@ -338,7 +374,9 @@ const missingFields = (store, key, fields, windows) => {
338
374
  const value = store[key];
339
375
  if (value !== void 0 && value.tombstone) return [];
340
376
  return [...fields].filter((field) => {
341
- if (value === void 0 || !value.present.has(field) || value.stale.has(field)) return true;
377
+ if (value === void 0) return true;
378
+ if (!value.present.has(field)) return !value.unavailable.has(field);
379
+ if (value.stale.has(field)) return true;
342
380
  const requested = windows?.[field];
343
381
  const stored = value.windows[field];
344
382
  return requested !== void 0 && stored !== void 0 && stored !== "" && stored !== requested;
@@ -588,7 +626,7 @@ const Query = {
588
626
  * server refuses a mismatch with `RemoteProtocolError`, so a shape change never
589
627
  * drifts silently: bump it whenever `ReadRequest` or `LiveRequirement` change.
590
628
  */
591
- const REMOTE_PROTOCOL_VERSION = 3;
629
+ const REMOTE_PROTOCOL_VERSION = 4;
592
630
  var RemoteReadError = class extends Schema.TaggedError()("RemoteReadError", { message: Schema.String }) {};
593
631
  var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", { message: Schema.String }) {};
594
632
  var RemoteLiveError = class extends Schema.TaggedError()("RemoteLiveError", { message: Schema.String }) {};
@@ -649,7 +687,20 @@ const NormalizedEntity = Schema.Struct({
649
687
  id: Schema.String,
650
688
  values: Schema.Record(Schema.String, Schema.Unknown)
651
689
  });
652
- const ReadBatchResult = Schema.Struct({ entities: Schema.Array(NormalizedEntity) });
690
+ /**
691
+ * Fields a read asked for that the server answered without, and will not
692
+ * answer with: the client stops asking. It says nothing about why, so a rule
693
+ * that withholds a field is not itself on the wire.
694
+ */
695
+ const SettledFields = Schema.Struct({
696
+ entity: Schema.String,
697
+ id: Schema.String,
698
+ fields: Schema.Array(Schema.String)
699
+ });
700
+ const ReadBatchResult = Schema.Struct({
701
+ entities: Schema.Array(NormalizedEntity),
702
+ settled: Schema.Array(SettledFields)
703
+ });
653
704
  const MutationRequest = Schema.Struct({
654
705
  /** Stable across transport retries so a mutation is not applied twice. */
655
706
  requestId: Schema.String,
@@ -867,7 +918,7 @@ const coalesceReads = (read, options = {}) => Effect.gen(function* () {
867
918
  for (const entry of entries) if (own.has(entry.request.key)) owned.set(entry.request.key, entry.request.requirement);
868
919
  yield* Effect.forEach(readsOf(owned), ({ keys, requests }) => Effect.gen(function* () {
869
920
  const exit = yield* Effect.exit(read({
870
- version: 3,
921
+ version: 4,
871
922
  requests
872
923
  }));
873
924
  for (const key of keys) {
@@ -883,10 +934,19 @@ const coalesceReads = (read, options = {}) => Effect.gen(function* () {
883
934
  });
884
935
  let resolver = RequestResolver.make(runAll);
885
936
  if (options.window !== void 0) resolver = RequestResolver.setDelay(resolver, Duration.fromInputUnsafe(options.window));
886
- return (batch) => batch.requests.length === 0 ? Effect.succeed({ entities: [] }) : Effect.forEach(batch.requests, (requirement) => Effect.request(new ReadRequirement({
937
+ return (batch) => batch.requests.length === 0 ? Effect.succeed({
938
+ entities: [],
939
+ settled: []
940
+ }) : Effect.forEach(batch.requests, (requirement) => Effect.request(new ReadRequirement({
887
941
  key: requirementKey(requirement),
888
942
  requirement
889
- }), resolver), { concurrency: "unbounded" }).pipe(Effect.map((results) => ({ entities: [...new Set(results)].flatMap((result) => result.entities) })));
943
+ }), resolver), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
944
+ const distinct = [...new Set(results)];
945
+ return {
946
+ entities: distinct.flatMap((result) => result.entities),
947
+ settled: distinct.flatMap((result) => result.settled)
948
+ };
949
+ }));
890
950
  });
891
951
  //#endregion
892
952
  //#region src/client.ts
@@ -1302,6 +1362,24 @@ const assemble = (store, key, requirement) => {
1302
1362
  refreshing
1303
1363
  };
1304
1364
  };
1365
+ /**
1366
+ * The first field `requirement` reads, at any depth, that the server settled
1367
+ * without a value, as `Entity:id.field`. A read that consults it says why it
1368
+ * cannot assemble rather than waiting on a field nothing will fetch.
1369
+ */
1370
+ const unavailableOf = (store, key, requirement) => {
1371
+ for (const field of requirement.fields) {
1372
+ if (isFieldUnavailable(store, key, field)) return `${key}.${field}`;
1373
+ const relation = requirement.relations?.[field];
1374
+ if (relation === void 0) continue;
1375
+ const value = readField(store, key, field);
1376
+ if (Option.isNone(value) || value.value === null || value.value === void 0) continue;
1377
+ for (const ref of targetsOf(value.value, relation)) {
1378
+ const found = unavailableOf(store, entityKey(ref.entity, ref.id), relation);
1379
+ if (found !== void 0) return found;
1380
+ }
1381
+ }
1382
+ };
1305
1383
  const assembleTarget = (store, ref, relation) => {
1306
1384
  const key = entityKey(ref.entity, ref.id);
1307
1385
  return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
@@ -1344,12 +1422,6 @@ const assembleRelation = (store, value, relation) => {
1344
1422
  * Entities: the descriptor an application declares, refs, ref pages, and
1345
1423
  * patches, with the codecs that carry refs on the wire.
1346
1424
  */
1347
- /**
1348
- * Relations are **references**, never inline target schemas: a ref cannot
1349
- * reconstruct a full entity, and dereferencing is a store concern. Because the
1350
- * target schema is not inlined, recursive relations cannot arise through the
1351
- * schema graph.
1352
- */
1353
1425
  const refCodec = (entity) => Schema.Struct({
1354
1426
  entity: Schema.String,
1355
1427
  id: Schema.String
@@ -1551,6 +1623,7 @@ const inspectEntry = (key, entry) => ({
1551
1623
  key,
1552
1624
  present: [...entry.present],
1553
1625
  stale: [...entry.stale],
1626
+ unavailable: [...entry.unavailable],
1554
1627
  tombstone: entry.tombstone,
1555
1628
  updatedAt: entry.updatedAt,
1556
1629
  windows: { ...entry.windows }
@@ -1884,6 +1957,41 @@ const followRelation = (value, relation) => targetsOf(value, relation).map((ref)
1884
1957
  ...relation,
1885
1958
  id: ref.id
1886
1959
  }));
1960
+ /**
1961
+ * The earliest moment a value `requirements` read, following the relations the
1962
+ * store holds as `plan` does, goes stale under `freshness`, with the
1963
+ * requirements of the entities that go stale then. Nothing when no value is
1964
+ * both present and still fresh: what is already expired or stale is planned,
1965
+ * not awaited, which is what keeps a timer from firing on what it already
1966
+ * fired on.
1967
+ */
1968
+ const deadlineOf = (store, requirements, freshness) => {
1969
+ let at;
1970
+ let due = [];
1971
+ const walk = (group) => {
1972
+ const key = entityKey(group.entity, group.id);
1973
+ const entry = store[key];
1974
+ if (entry === void 0 || entry.tombstone) return;
1975
+ if (group.fields.some((field) => entry.present.has(field) && !entry.stale.has(field))) {
1976
+ const expires = entry.updatedAt + freshness.freshness;
1977
+ if (expires > freshness.now) {
1978
+ if (at === void 0 || expires < at) {
1979
+ at = expires;
1980
+ due = [group];
1981
+ } else if (expires === at) due.push(group);
1982
+ }
1983
+ }
1984
+ for (const [field, relation] of Object.entries(group.relations ?? {})) {
1985
+ const value = readField(store, key, field);
1986
+ if (value._tag === "Some") for (const next of followRelation(value.value, relation)) walk(next);
1987
+ }
1988
+ };
1989
+ for (const group of Requirement.merge(requirements)) walk(group);
1990
+ return at === void 0 ? void 0 : {
1991
+ at,
1992
+ due: Requirement.merge(due)
1993
+ };
1994
+ };
1887
1995
  const plan = (store, requirements, options = {}) => {
1888
1996
  const { freshness, force = false } = options;
1889
1997
  const planned = [];
@@ -1995,7 +2103,7 @@ const gc = (state, roots) => {
1995
2103
  * application's choice.
1996
2104
  */
1997
2105
  /** Bump when the serialized shape changes; a mismatch discards the cache. */
1998
- const REMOTE_CACHE_VERSION = 4;
2106
+ const REMOTE_CACHE_VERSION = 5;
1999
2107
  /**
2000
2108
  * A snapshot of a Model, keeping the entity cache and only the connections
2001
2109
  * named.
@@ -2018,13 +2126,14 @@ const sorted = (values) => [...values].sort();
2018
2126
  * whatever order they were built in.
2019
2127
  */
2020
2128
  const serializeStore = (snapshot, scope) => ({
2021
- version: 4,
2129
+ version: 5,
2022
2130
  scope: scope ?? null,
2023
2131
  connections: snapshot.connections,
2024
2132
  entities: Object.fromEntries(Object.entries(snapshot.entities).map(([key, entry]) => [key, {
2025
2133
  values: entry.values,
2026
2134
  present: sorted(entry.present),
2027
2135
  stale: sorted(entry.stale),
2136
+ unavailable: sorted(entry.unavailable),
2028
2137
  tombstone: entry.tombstone,
2029
2138
  updatedAt: entry.updatedAt,
2030
2139
  windows: entry.windows
@@ -2039,6 +2148,7 @@ const parseEntry = (value) => {
2039
2148
  if (entry.values === null || typeof entry.values !== "object" || Array.isArray(entry.values)) throw new Error("entry.values is not a record");
2040
2149
  if (!isStringArray(entry.present)) throw new Error("entry.present is not a string array");
2041
2150
  if (!isStringArray(entry.stale)) throw new Error("entry.stale is not a string array");
2151
+ if (!isStringArray(entry.unavailable)) throw new Error("entry.unavailable is not a string array");
2042
2152
  if (typeof entry.tombstone !== "boolean") throw new Error("entry.tombstone is not a boolean");
2043
2153
  if (typeof entry.updatedAt !== "number") throw new Error("entry.updatedAt is not a number");
2044
2154
  if (!isStringRecord(entry.windows)) throw new Error("entry.windows is not a string record");
@@ -2046,6 +2156,7 @@ const parseEntry = (value) => {
2046
2156
  values: entry.values,
2047
2157
  present: new Set(entry.present),
2048
2158
  stale: new Set(entry.stale),
2159
+ unavailable: new Set(entry.unavailable),
2049
2160
  tombstone: entry.tombstone,
2050
2161
  updatedAt: entry.updatedAt,
2051
2162
  windows: entry.windows
@@ -2104,7 +2215,7 @@ const hydrate = (raw, options = {}) => {
2104
2215
  }
2105
2216
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
2106
2217
  const candidate = parsed;
2107
- if (candidate.version !== 4) return void 0;
2218
+ if (candidate.version !== 5) return void 0;
2108
2219
  if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
2109
2220
  if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
2110
2221
  if (Array.isArray(candidate.entities)) return void 0;
@@ -2299,7 +2410,32 @@ const noFailures = {
2299
2410
  const emptyRefresh = {
2300
2411
  generation: 0,
2301
2412
  requested: /* @__PURE__ */ new Map(),
2302
- started: /* @__PURE__ */ new Map()
2413
+ started: /* @__PURE__ */ new Map(),
2414
+ floor: 0
2415
+ };
2416
+ /**
2417
+ * The Model with everything the server told it forgotten: values, tombstones,
2418
+ * unavailable fields, connections, live cursors, failures, and what is in
2419
+ * flight. For a change of principal, whose facts these were. The mutation
2420
+ * sequence is kept, so request ids stay unique, and a mutation in flight is
2421
+ * treated as applied: its answer, when it comes, writes nothing here.
2422
+ */
2423
+ const forgetRemote = (model) => {
2424
+ const generation = model.refresh.generation + 1;
2425
+ return {
2426
+ ...initialRemoteModel,
2427
+ refresh: {
2428
+ generation,
2429
+ requested: /* @__PURE__ */ new Map(),
2430
+ started: /* @__PURE__ */ new Map(),
2431
+ floor: generation
2432
+ },
2433
+ mutations: {
2434
+ ...emptyMutationState,
2435
+ sequence: model.mutations.sequence,
2436
+ applied: /* @__PURE__ */ new Set([...model.mutations.applied, ...model.mutations.pending])
2437
+ }
2438
+ };
2303
2439
  };
2304
2440
  const initialRemoteModel = {
2305
2441
  entities: emptyStore,
@@ -2457,9 +2593,10 @@ const withoutQueryLoading = (loading, identity) => {
2457
2593
  * leaves its mark behind, and a connection released with that mark would read
2458
2594
  * `Loading` forever the next time something asked for it before sending.
2459
2595
  */
2460
- const prunedQueryLoading = (loading, roots) => {
2596
+ const prunedLoading = (loading, roots, reached) => {
2597
+ if (loading.size === 0) return loading;
2461
2598
  const named = new Set(roots.connections.map((root) => connectionMark(root.identity)));
2462
- const kept = [...loading].filter((mark) => !mark.startsWith(connectionPrefix) || named.has(mark));
2599
+ const kept = [...loading].filter((mark) => mark.startsWith(connectionPrefix) ? named.has(mark) : reached().has(markEntity(mark)));
2463
2600
  return kept.length === loading.size ? loading : new Set(kept);
2464
2601
  };
2465
2602
  /**
@@ -2492,7 +2629,7 @@ const prunedRefresh = (refresh, retained) => {
2492
2629
  * anything else is.
2493
2630
  */
2494
2631
  const refreshedAt = (refresh, requests, connections = []) => {
2495
- let at = 0;
2632
+ let at = refresh.floor;
2496
2633
  for (const mark of fieldMarks(requests)) at = Math.max(at, refresh.requested.get(mark) ?? 0);
2497
2634
  for (const identity of connections) at = Math.max(at, refresh.requested.get(connectionMark(identity)) ?? 0);
2498
2635
  return at;
@@ -2589,12 +2726,14 @@ const updateRemote = (model, message) => {
2589
2726
  };
2590
2727
  case "RetentionChanged": {
2591
2728
  const retained = gc(model, message.roots);
2729
+ let reachedKeys;
2730
+ const reached = () => reachedKeys ??= reachable(model.entities, message.roots, model.connections, model.optimistic, model.mutations.pending);
2592
2731
  return {
2593
2732
  ...model,
2594
2733
  ...retained,
2595
2734
  refresh: prunedRefresh(model.refresh, retained),
2596
- failures: prunedFailures(model, message.roots),
2597
- loading: prunedQueryLoading(model.loading, message.roots)
2735
+ failures: prunedFailures(model.failures, message.roots, reached),
2736
+ loading: prunedLoading(model.loading, message.roots, reached)
2598
2737
  };
2599
2738
  }
2600
2739
  case "Hydrated": {
@@ -2606,9 +2745,11 @@ const updateRemote = (model, message) => {
2606
2745
  })),
2607
2746
  stale: true
2608
2747
  }]);
2748
+ const written = Object.entries(message.entities).flatMap(([key, entry]) => message.merge === "replace" || !(key in model.entities) ? [...entry.present].map((field) => `${key}\u0000${field}`) : []);
2609
2749
  return {
2610
2750
  ...model,
2611
2751
  entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge),
2752
+ failures: withoutFieldFailures(model.failures, written),
2612
2753
  connections: message.merge === "replace" ? {
2613
2754
  ...model.connections,
2614
2755
  ...Object.fromEntries(restored)
@@ -2620,8 +2761,9 @@ const updateRemote = (model, message) => {
2620
2761
  }
2621
2762
  case "RefreshStarted": {
2622
2763
  const asked = message.requests.reduce((store, request) => isTombstone(store, entityKey(request.entity, request.id)) ? remove(store, entityKey(request.entity, request.id)) : store, model.entities);
2623
- const entities = setStale(asked, marksOf(message.requests), true);
2624
- const failures = withoutFieldFailures(model.failures, fieldMarks(message.requests));
2764
+ const marks = marksOf(message.requests);
2765
+ const entities = setUnavailable(setStale(asked, marks, true), marks, false);
2766
+ const failures = withoutFieldFailures(model.failures, reachedMarks(model.entities, message.requests));
2625
2767
  return entities === model.entities && failures === model.failures ? model : {
2626
2768
  ...model,
2627
2769
  entities,
@@ -2737,7 +2879,6 @@ const updateRemote = (model, message) => {
2737
2879
  };
2738
2880
  case "QueryFailed": return {
2739
2881
  ...model,
2740
- connections: message.connection in model.connections ? setConnectionStale(model.connections, message.connection, false) : model.connections,
2741
2882
  failures: {
2742
2883
  ...model.failures,
2743
2884
  connections: {
@@ -2793,6 +2934,35 @@ const patchedMarks = function* (patches) {
2793
2934
  for (const patch of patches) for (const field of Object.keys(patch.values)) yield fieldMark(patch.entity, patch.id, field);
2794
2935
  };
2795
2936
  /**
2937
+ * Every field a read's requests reach: their own, and through each relation
2938
+ * the store already holds, the fields they ask of its targets. A request with
2939
+ * a relation riding on it names no target id; the store does, and a failure
2940
+ * recorded on a target is reached only this way.
2941
+ */
2942
+ const reachedMarks = (store, requests) => {
2943
+ const marks = [];
2944
+ const walked = /* @__PURE__ */ new Map();
2945
+ const walk = (entity, id, requirement) => {
2946
+ const key = entityKey(entity, id);
2947
+ const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
2948
+ if (seen.has(requirement)) return;
2949
+ seen.add(requirement);
2950
+ walked.set(key, seen);
2951
+ for (const field of requirement.fields) {
2952
+ marks.push(fieldMark(entity, id, field));
2953
+ const nested = requirement.relations?.[field];
2954
+ if (nested === void 0) continue;
2955
+ const value = readField(store, key, field);
2956
+ if (value._tag === "None") continue;
2957
+ for (const ref of targetsOf(value.value, nested)) walk(ref.entity, ref.id, nested);
2958
+ }
2959
+ };
2960
+ for (const request of requests) walk(request.entity, request.id, request);
2961
+ return marks;
2962
+ };
2963
+ /** The entity key a field mark belongs to. */
2964
+ const markEntity = (mark) => mark.slice(0, mark.indexOf("\0"));
2965
+ /**
2796
2966
  * The failures of what the roots still reach. A released connection's or
2797
2967
  * entity's failure goes with it, so asking for it again later asks the server
2798
2968
  * again rather than repeating an error that may no longer hold.
@@ -2802,12 +2972,10 @@ const patchedMarks = function* (patches) {
2802
2972
  * which is exactly a value whose first read failed: it is still on screen,
2803
2973
  * behind the parent that refers to it.
2804
2974
  */
2805
- const prunedFailures = (model, roots) => {
2806
- const { failures } = model;
2975
+ const prunedFailures = (failures, roots, reached) => {
2807
2976
  const named = new Set(roots.connections.map((root) => root.identity));
2808
2977
  const connections = Object.entries(failures.connections).filter(([identity]) => named.has(identity));
2809
- const kept = Object.keys(failures.fields).length === 0 ? /* @__PURE__ */ new Set() : reachable(model.entities, roots, model.connections, model.optimistic, model.mutations.pending);
2810
- const fields = Object.entries(failures.fields).filter(([mark]) => kept.has(mark.slice(0, mark.indexOf("\0"))));
2978
+ const fields = Object.keys(failures.fields).length === 0 ? [] : Object.entries(failures.fields).filter(([mark]) => reached().has(markEntity(mark)));
2811
2979
  return connections.length === Object.keys(failures.connections).length && fields.length === Object.keys(failures.fields).length ? failures : {
2812
2980
  connections: Object.fromEntries(connections),
2813
2981
  fields: Object.fromEntries(fields)
@@ -2816,6 +2984,32 @@ const prunedFailures = (model, roots) => {
2816
2984
  /** Whether this field's last read failed and nothing has settled it since. */
2817
2985
  const isFieldFailed = (model, entity, id, field) => fieldMark(entity, id, field) in model.failures.fields;
2818
2986
  /**
2987
+ * Whether a read is fetching any field `relation` reads of an entity, its own
2988
+ * or, through each relation the store holds, its targets'. A value whose owner
2989
+ * is being fetched is loading, not `Initial`: nothing is missing a fetch.
2990
+ */
2991
+ const isLoadingThrough = (model, entity, id, relation) => {
2992
+ if (model.loading.size === 0) return false;
2993
+ const walked = /* @__PURE__ */ new Map();
2994
+ const walk = (target, targetId, requirement) => {
2995
+ const key = entityKey(target, targetId);
2996
+ const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
2997
+ if (seen.has(requirement)) return false;
2998
+ seen.add(requirement);
2999
+ walked.set(key, seen);
3000
+ for (const field of requirement.fields) {
3001
+ if (model.loading.has(fieldMark(target, targetId, field))) return true;
3002
+ const nested = requirement.relations?.[field];
3003
+ if (nested === void 0) continue;
3004
+ const value = readField(model.entities, key, field);
3005
+ if (value._tag === "None") continue;
3006
+ for (const ref of targetsOf(value.value, nested)) if (walk(ref.entity, ref.id, nested)) return true;
3007
+ }
3008
+ return false;
3009
+ };
3010
+ return walk(entity, id, relation);
3011
+ };
3012
+ /**
2819
3013
  * The first failure among the fields `relation` reads of an entity, following
2820
3014
  * each relation the store holds into its targets, as `assemble` does. A read
2821
3015
  * that consults it shows `Failed` for a related entity's failure too, rather
@@ -2923,12 +3117,13 @@ const writeRead = (store, requests, result, now = 0) => {
2923
3117
  windows: entry?.windows
2924
3118
  });
2925
3119
  }
3120
+ const settled = new Set(result.settled.map((entry) => entityKey(entry.entity, entry.id)));
2926
3121
  let answered = store;
2927
3122
  for (const request of requests) {
2928
3123
  const key = entityKey(request.entity, request.id);
2929
- if (!returned.has(key)) answered = tombstone(answered, key);
3124
+ if (!returned.has(key) && !settled.has(key)) answered = tombstone(answered, key);
2930
3125
  }
2931
- return writeEntities(answered, writes, now);
3126
+ return setUnavailable(writeEntities(answered, writes, now), result.settled.map((entry) => [entityKey(entry.entity, entry.id), entry.fields]), true);
2932
3127
  };
2933
3128
  //#endregion
2934
3129
  //#region src/policy.ts
@@ -2961,6 +3156,221 @@ const RemotePolicy = {
2961
3156
  refreshes: (policy) => policy._tag !== "CacheFirst"
2962
3157
  };
2963
3158
  //#endregion
3159
+ //#region src/resume.ts
3160
+ /**
3161
+ * What of Remote's state crosses from a server render to the browser.
3162
+ *
3163
+ * A page rendered a moment ago sends exactly what its active Surfaces read:
3164
+ * for each requirement the fields it names, following relations through the
3165
+ * refs the store holds, and each connection whole, with its boundaries, so
3166
+ * "load more" knows where the server stopped. A live subscription's cursor
3167
+ * crosses with the entities it covers, so a subscription started in the
3168
+ * browser resumes where the server's left off.
3169
+ *
3170
+ * This is not a `Snapshot`, which is built for a cache that survived a reload
3171
+ * and deliberately keeps no cursors. Nothing else of `RemoteModel` crosses:
3172
+ * loading marks, failures, the mutation ledger, optimistic layers, gaps and
3173
+ * retention start from their initial values in the browser.
3174
+ */
3175
+ const CapturedEntry = Schema.Struct({
3176
+ values: Schema.Record(Schema.String, Schema.Unknown),
3177
+ present: Schema.Array(Schema.String),
3178
+ stale: Schema.Array(Schema.String),
3179
+ unavailable: Schema.Array(Schema.String),
3180
+ windows: Schema.Record(Schema.String, Schema.String),
3181
+ tombstone: Schema.Boolean,
3182
+ updatedAt: Schema.Number
3183
+ });
3184
+ const CapturedBoundary = Schema.Union([
3185
+ Schema.TaggedStruct("Terminal", {}),
3186
+ Schema.TaggedStruct("Cursor", { cursor: Schema.String }),
3187
+ Schema.TaggedStruct("Unknown", {})
3188
+ ]);
3189
+ const CapturedConnection = Schema.Struct({
3190
+ segments: Schema.Array(Schema.Struct({
3191
+ edges: Schema.Array(Schema.Struct({
3192
+ key: Schema.String,
3193
+ ref: Schema.Struct({
3194
+ entity: Schema.String,
3195
+ id: Schema.String
3196
+ })
3197
+ })),
3198
+ start: CapturedBoundary,
3199
+ end: CapturedBoundary
3200
+ })),
3201
+ stale: Schema.Boolean
3202
+ });
3203
+ const CapturedLive = Schema.Struct({
3204
+ cursor: Schema.Number,
3205
+ boundary: Schema.Record(Schema.String, Schema.Struct({
3206
+ before: Schema.Number,
3207
+ after: Schema.Number
3208
+ }))
3209
+ });
3210
+ /**
3211
+ * Marks a Subscription entry Remote built as one that may start late under
3212
+ * `foldkit-ssr`'s deferred boot (its decision 10): a read with resumed data
3213
+ * plans nothing, a live subscription resumes from the cursor the envelope
3214
+ * carries, and retention running late collects late. Non-enumerable, so the
3215
+ * runtime's iteration of an entry never sees it.
3216
+ */
3217
+ const DEFERRABLE = Symbol.for("foldkit-remote/deferrable");
3218
+ /** Marks every entry of a record Remote built, lifted or not. */
3219
+ const markAll = (entries) => {
3220
+ for (const entry of Object.values(entries)) Object.defineProperty(entry, DEFERRABLE, {
3221
+ value: true,
3222
+ enumerable: false
3223
+ });
3224
+ return entries;
3225
+ };
3226
+ /** The captured part of a Remote store, as JSON. */
3227
+ const RemoteCapture = Schema.Struct({
3228
+ entities: Schema.Record(Schema.String, CapturedEntry),
3229
+ connections: Schema.Record(Schema.String, CapturedConnection),
3230
+ live: Schema.Record(Schema.String, CapturedLive)
3231
+ });
3232
+ /** What some projections read, from their metadata. */
3233
+ const askedOf$1 = (projections) => ({
3234
+ requirements: projections.flatMap((projection) => RemoteRequirements.get(projection.metadata)),
3235
+ connections: projections.flatMap((projection) => RemoteConnections.get(projection.metadata))
3236
+ });
3237
+ /** Whether every `entity:id:fields` part of a live stream key was captured with those fields. */
3238
+ const liveCovered = (stream, entries) => stream.split("|").every((part) => {
3239
+ const split = part.lastIndexOf(":");
3240
+ const entry = entries.get(part.slice(0, split));
3241
+ const fields = part.slice(split + 1).split(",").filter((field) => field !== "");
3242
+ return entry !== void 0 && fields.every((field) => entry.present.has(field));
3243
+ });
3244
+ /**
3245
+ * Captures what some projections read from a store: the fields each
3246
+ * requirement names, through its relations; each connection whole, and the
3247
+ * fields its selection reads of each item; and each live cursor whose
3248
+ * entities were all captured with the fields it follows.
3249
+ */
3250
+ const captureRemote = (remote, projections) => {
3251
+ const store = remote.entities;
3252
+ const entries = /* @__PURE__ */ new Map();
3253
+ const walked = /* @__PURE__ */ new Map();
3254
+ const walk = (entity, id, requirement) => {
3255
+ const key = entityKey(entity, id);
3256
+ const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
3257
+ if (seen.has(requirement)) return;
3258
+ seen.add(requirement);
3259
+ walked.set(key, seen);
3260
+ const stored = store[key];
3261
+ if (stored === void 0) return;
3262
+ const entry = entries.get(key) ?? {
3263
+ values: {},
3264
+ present: /* @__PURE__ */ new Set(),
3265
+ stale: /* @__PURE__ */ new Set(),
3266
+ unavailable: /* @__PURE__ */ new Set(),
3267
+ windows: {},
3268
+ tombstone: stored.tombstone,
3269
+ updatedAt: stored.updatedAt
3270
+ };
3271
+ entries.set(key, entry);
3272
+ for (const field of requirement.fields) {
3273
+ if (stored.unavailable.has(field)) entry.unavailable.add(field);
3274
+ if (!stored.present.has(field)) continue;
3275
+ entry.values[field] = stored.values[field];
3276
+ entry.present.add(field);
3277
+ if (stored.stale.has(field)) entry.stale.add(field);
3278
+ const window = stored.windows[field];
3279
+ if (window !== void 0) entry.windows[field] = window;
3280
+ const nested = requirement.relations?.[field];
3281
+ if (nested === void 0) continue;
3282
+ const value = readField(store, key, field);
3283
+ if (value._tag === "None") continue;
3284
+ for (const ref of targetsOf(value.value, nested)) walk(ref.entity, ref.id, nested);
3285
+ }
3286
+ };
3287
+ const asked = askedOf$1(projections);
3288
+ for (const requirement of asked.requirements) walk(requirement.entity, requirement.id, requirement);
3289
+ const connections = {};
3290
+ for (const { identity, select } of asked.connections) {
3291
+ const connection = remote.connections[identity];
3292
+ if (connection === void 0) continue;
3293
+ connections[identity] = connection;
3294
+ for (const segment of connection.segments) for (const edge of segment.edges) walk(edge.ref.entity, edge.ref.id, select);
3295
+ }
3296
+ const live = {};
3297
+ for (const [stream, state] of Object.entries(remote.live)) if (liveCovered(stream, entries)) live[stream] = state;
3298
+ return {
3299
+ entities: Object.fromEntries([...entries].map(([key, entry]) => [key, {
3300
+ values: entry.values,
3301
+ present: [...entry.present],
3302
+ stale: [...entry.stale],
3303
+ unavailable: [...entry.unavailable],
3304
+ windows: entry.windows,
3305
+ tombstone: entry.tombstone,
3306
+ updatedAt: entry.updatedAt
3307
+ }])),
3308
+ connections: Object.fromEntries(Object.entries(connections).map(([identity, connection]) => [identity, {
3309
+ segments: connection.segments.map((segment) => ({
3310
+ edges: segment.edges.map((edge) => ({
3311
+ key: edge.key,
3312
+ ref: edge.ref
3313
+ })),
3314
+ start: segment.start,
3315
+ end: segment.end
3316
+ })),
3317
+ stale: connection.stale
3318
+ }])),
3319
+ live
3320
+ };
3321
+ };
3322
+ /**
3323
+ * Sets a capture onto a store: its entities, connections and live cursors
3324
+ * replace any held under the same keys, and nothing else of the store changes.
3325
+ */
3326
+ const restoreRemote = (remote, capture) => ({
3327
+ ...remote,
3328
+ entities: {
3329
+ ...remote.entities,
3330
+ ...Object.fromEntries(Object.entries(capture.entities).map(([key, entry]) => [key, {
3331
+ values: entry.values,
3332
+ present: new Set(entry.present),
3333
+ stale: new Set(entry.stale),
3334
+ unavailable: new Set(entry.unavailable),
3335
+ windows: entry.windows,
3336
+ tombstone: entry.tombstone,
3337
+ updatedAt: entry.updatedAt
3338
+ }]))
3339
+ },
3340
+ connections: {
3341
+ ...remote.connections,
3342
+ ...Object.fromEntries(Object.entries(capture.connections).map(([identity, connection]) => [identity, {
3343
+ segments: connection.segments.map((segment) => ({
3344
+ edges: segment.edges,
3345
+ start: segment.start,
3346
+ end: segment.end
3347
+ })),
3348
+ stale: connection.stale
3349
+ }]))
3350
+ },
3351
+ live: {
3352
+ ...remote.live,
3353
+ ...capture.live
3354
+ }
3355
+ });
3356
+ const encodeCapture = Schema.encodeSync(RemoteCapture);
3357
+ const decodeCapture = Schema.decodeUnknownResult(RemoteCapture);
3358
+ /**
3359
+ * The resume part for a Remote domain, for `SSR.plan`'s `parts`: it sends
3360
+ * what the plan's active Surfaces read from this domain's store, and nothing
3361
+ * else of it. `id` tells two domains in one application apart.
3362
+ */
3363
+ const resumePart = (domain, options = {}) => ({
3364
+ id: options.id ?? "remote",
3365
+ covers: [RemoteRequirements.name, RemoteConnections.name],
3366
+ deferrable: (_key, entry) => typeof entry === "object" && entry !== null && DEFERRABLE in entry,
3367
+ capture: (model, projections) => encodeCapture(captureRemote(domain.store.get(model), projections)),
3368
+ restore: (model, value) => Result.match(decodeCapture(value), {
3369
+ onFailure: (error) => Result.fail(`Remote's state does not decode: ${error.message}`),
3370
+ onSuccess: (capture) => Result.succeed(domain.store.set(model, restoreRemote(domain.store.get(model), capture)))
3371
+ })
3372
+ });
3373
+ //#endregion
2964
3374
  //#region src/index.ts
2965
3375
  /**
2966
3376
  * `foldkit-remote` — normalized application-facing server state.
@@ -3010,6 +3420,22 @@ const visibleStoreOf = (entities, optimistic) => {
3010
3420
  }
3011
3421
  return visible;
3012
3422
  };
3423
+ /**
3424
+ * `Failed` naming a field the server settled without a value, when one is
3425
+ * among what `relation` reads of the entity; otherwise nothing, and the store
3426
+ * merely lacks the value. The reason stays with the server, so a view shows
3427
+ * the field it cannot have, not why.
3428
+ */
3429
+ const unavailableFailure = (store, key, relation) => {
3430
+ const field = unavailableOf(store, key, relation);
3431
+ return field === void 0 ? void 0 : {
3432
+ _tag: "Failed",
3433
+ error: {
3434
+ _tag: "Unavailable",
3435
+ message: `The server answered without ${field}, and will not answer with it: the field is not available to this client.`
3436
+ }
3437
+ };
3438
+ };
3013
3439
  const readResults = /* @__PURE__ */ new WeakMap();
3014
3440
  const memoRead = (snapshot, by, key, compute) => {
3015
3441
  let byScope = readResults.get(snapshot);
@@ -3077,6 +3503,26 @@ const askedOf = (projection) => projection === void 0 ? nothingAsked : {
3077
3503
  connections: connectionsOf(projection)
3078
3504
  };
3079
3505
  /**
3506
+ * Whether `outer` asks for everything `inner` does of one entity: each field,
3507
+ * each relation's own slice of its target, and each page window alike.
3508
+ */
3509
+ const sliceCovers = (outer, inner) => outer.entity === inner.entity && inner.fields.every((field) => outer.fields.includes(field)) && Object.entries(inner.relations ?? {}).every(([field, relation]) => {
3510
+ const wider = outer.relations?.[field];
3511
+ return wider !== void 0 && sliceCovers(wider, relation);
3512
+ }) && Object.entries(inner.windows ?? {}).every(([field, window]) => outer.windows?.[field] !== void 0 && stableStringify(outer.windows[field]) === stableStringify(window));
3513
+ /**
3514
+ * Whether what `outer` asks includes everything `inner` asks: each list with
3515
+ * the slice it selects of its rows, and each entity with the slice it reads,
3516
+ * relations included. A Surface that shows a read is one whose projection
3517
+ * asks for all of it; one that reads part of it cannot be why it is fetched.
3518
+ * A Surface that asks for one entity in several pieces is already one
3519
+ * requirement here: projection metadata unions them per entity and id.
3520
+ */
3521
+ const covers = (outer, inner) => {
3522
+ const { connections, requirements } = outer;
3523
+ return inner.connections.every((connection) => connections.some((wider) => wider.identity === connection.identity && (connection.select === void 0 || wider.select !== void 0 && sliceCovers(wider.select, connection.select)))) && inner.requirements.every((requirement) => requirements.some((wider) => wider.id === requirement.id && sliceCovers(wider, requirement)));
3524
+ };
3525
+ /**
3080
3526
  * Plans what a projection asks against the Model. A connection the Model holds
3081
3527
  * fresh contributes its visible items' selected fields to the entity plan; one
3082
3528
  * it does not hold, or holds stale, is a query to run (its items are planned
@@ -3100,10 +3546,13 @@ const planAsked = (remote, asked, options) => {
3100
3546
  const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities);
3101
3547
  items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
3102
3548
  }
3103
- const planned = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options);
3549
+ const visible = visibleStoreOf(remote.entities, remote.optimistic);
3550
+ const read = [...asked.requirements, ...items];
3551
+ const planned = plan(visible, read, options);
3104
3552
  return {
3105
3553
  requirements: options.force === true ? planned : withoutFailedFields(remote, planned),
3106
- queries
3554
+ queries,
3555
+ expires: options.freshness === void 0 ? null : deadlineOf(visible, read, options.freshness) ?? null
3107
3556
  };
3108
3557
  };
3109
3558
  /**
@@ -3230,7 +3679,7 @@ const readMessage = (requirements, now) => Effect.gen(function* () {
3230
3679
  const client = yield* RemoteClient;
3231
3680
  const at = now();
3232
3681
  const result = yield* Effect.result(client.read({
3233
- version: 3,
3682
+ version: 4,
3234
3683
  requests: requirements
3235
3684
  }));
3236
3685
  return Result.isFailure(result) ? {
@@ -3277,7 +3726,11 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
3277
3726
  dependenciesSchema: Schema.Struct({
3278
3727
  requirements: Schema.Array(ReadRequest),
3279
3728
  queries: Schema.Array(PlannedQuery),
3280
- refresh: Schema.Number
3729
+ refresh: Schema.Number,
3730
+ expires: Schema.NullOr(Schema.Struct({
3731
+ at: Schema.Number,
3732
+ due: Schema.Array(ReadRequest)
3733
+ }))
3281
3734
  }),
3282
3735
  modelToDependencies: (model) => {
3283
3736
  const remote = bound.store.get(model);
@@ -3289,10 +3742,11 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
3289
3742
  identity,
3290
3743
  window,
3291
3744
  select
3292
- }))
3745
+ })),
3746
+ expires: planned.expires
3293
3747
  };
3294
3748
  },
3295
- dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
3749
+ dependenciesToStream: ({ requirements, queries, refresh, expires }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
3296
3750
  _tag: "ReadStarted",
3297
3751
  requests: requirements,
3298
3752
  refresh
@@ -3302,21 +3756,27 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
3302
3756
  })] : []], ...queries.length === 0 ? [] : [toMessage({
3303
3757
  _tag: "QueryStarted",
3304
3758
  connections: queries.map((query) => query.identity)
3305
- })]]), Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
3759
+ })]]), Stream.merge(Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }), expires === null ? Stream.empty : Stream.fromEffect(Effect.as(Effect.sleep(Math.max(0, expires.at - now())), toMessage({
3760
+ _tag: "RefreshStarted",
3761
+ requests: expires.due
3762
+ })))))
3306
3763
  };
3307
3764
  };
3308
3765
  /** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
3309
3766
  const liveEntry = (bound, requirementsOf, toMessage, options) => ({
3310
3767
  dependenciesSchema: Schema.Struct({
3311
3768
  requirements: Schema.Array(ReadRequest),
3312
- cursor: Schema.Number
3769
+ cursor: Schema.Number,
3770
+ floor: Schema.Number
3313
3771
  }),
3314
3772
  modelToDependencies: (model) => {
3315
3773
  const requirements = requirementsOf(model);
3316
3774
  const stream = liveStreamKey(requirements);
3775
+ const remote = bound.store.get(model);
3317
3776
  return {
3318
3777
  requirements,
3319
- cursor: bound.store.get(model).live[stream]?.cursor ?? 0
3778
+ cursor: remote.live[stream]?.cursor ?? 0,
3779
+ floor: remote.refresh.floor
3320
3780
  };
3321
3781
  },
3322
3782
  dependenciesToStream: ({ requirements, cursor }) => requirements.length === 0 ? Stream.empty : Stream.unwrap(Effect.gen(function* () {
@@ -3369,6 +3829,67 @@ const Remote = {
3369
3829
  /** Whether a Message is one of Remote's, by tag. */
3370
3830
  reduces: isRemoteMessage,
3371
3831
  /**
3832
+ * Remote's Messages as one Schema, for a wrapper variant in the
3833
+ * application's union: `GotRemoteMessage: { message: Remote.Message }`. With
3834
+ * a wrapper, `update` matches the application's union exhaustively and
3835
+ * `Remote.fold` reduces what arrives inside it.
3836
+ */
3837
+ Message: defineMessageUnion(remoteMessageCases),
3838
+ /**
3839
+ * A bound domain under one wrapper variant of the application's union, the
3840
+ * same shape Foldkit gives a Submodel: `toParentMessage` wraps Remote's
3841
+ * Messages, the fold reduces them, and its `fetch`, `mutate`, and
3842
+ * `subscriptions` yield the wrapper. Spreading `Remote.messages` and
3843
+ * narrowing with `Remote.reduces` remains the shorter path for an
3844
+ * application whose `update` only reduces.
3845
+ *
3846
+ * @example
3847
+ * ```ts
3848
+ * const Message = defineMessageUnion({ GotRemoteMessage: { message: Remote.Message } })
3849
+ * const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
3850
+ *
3851
+ * const update = (model: Model, message: Message): Return =>
3852
+ * Message.match(message, {
3853
+ * GotRemoteMessage: ({ message }) => foldData(model, message),
3854
+ * })
3855
+ * const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
3856
+ * foldData.subscriptions({ page: Surface.at(ProjectPage, …) }),
3857
+ * )
3858
+ * ```
3859
+ */
3860
+ fold: (domain, toParentMessage) => {
3861
+ const fold = (model, message) => ({ model: domain.reduce(model, message) });
3862
+ return Object.assign(fold, {
3863
+ fetch: (ref) => mapMessage(domain.fetch(ref), toParentMessage),
3864
+ mutate: (...args) => {
3865
+ const started = domain.mutate(...args);
3866
+ return {
3867
+ ...started,
3868
+ command: mapMessage(started.command, toParentMessage)
3869
+ };
3870
+ },
3871
+ subscriptions: (active, options) => markAll(Subscription.lift(brandEntries(domain.subscriptions(active, options)))({
3872
+ toChildModel: (model) => model,
3873
+ toParentMessage
3874
+ }))
3875
+ });
3876
+ },
3877
+ /**
3878
+ * A patch of one entity's values, for a mutation's `optimistic` list or an
3879
+ * overlay. Takes a `foldkit-entity` Entity or a Remote descriptor alike, so
3880
+ * the domain you declared once is the one you patch:
3881
+ * `Remote.patch(Project, 'p1', { name: 'Apollo II' })`. Values are the
3882
+ * fields' wire shape, as the server writes them; a relation is its ref key
3883
+ * (`'User:u1'`).
3884
+ */
3885
+ patch: (entity, id, values) => descriptorOf(entity).patch(id, values),
3886
+ /**
3887
+ * A reference to one entity, for `ConnectionChange` and anything else that
3888
+ * names a row: `Remote.ref(Project, 'p3')`. Either kind of entity, as with
3889
+ * `Remote.patch`.
3890
+ */
3891
+ ref: (entity, id) => descriptorOf(entity).ref(id),
3892
+ /**
3372
3893
  * Declares a Remote domain and binds it to its place in the application Model
3373
3894
  * in one step; the result carries the application-facing operations. The
3374
3895
  * descriptor alone is `Remote.define`, the binding alone `Remote.at`.
@@ -3387,6 +3908,13 @@ const Remote = {
3387
3908
  */
3388
3909
  at: bindRemote,
3389
3910
  /**
3911
+ * The resume part for this domain, for `foldkit-ssr`'s `SSR.plan({ parts })`:
3912
+ * a server render sends what the plan's active Surfaces read from the store,
3913
+ * each connection with its boundaries and each live cursor with it, and
3914
+ * nothing else. `id` tells two domains in one application apart.
3915
+ */
3916
+ resume: resumePart,
3917
+ /**
3390
3918
  * A Projection node that reads a `RemoteData` value out of the store. The id
3391
3919
  * is supplied by the caller, usually from a Surface's params. The assembled
3392
3920
  * value is decoded against the Selection, so malformed server data surfaces
@@ -3410,7 +3938,7 @@ const Remote = {
3410
3938
  const present = memoRead(store, store, `${key}\u0000${stableStringify(relation)}`, () => {
3411
3939
  if (isTombstone(store, key)) return { _tag: "NotFound" };
3412
3940
  const assembled = assemble(store, key, relation);
3413
- if (assembled === void 0) return void 0;
3941
+ if (assembled === void 0) return unavailableFailure(store, key, relation);
3414
3942
  const decoded = Schema.decodeUnknownResult(selection.schema)(assembled.values);
3415
3943
  return Result.isFailure(decoded) ? {
3416
3944
  _tag: "Failed",
@@ -3433,7 +3961,7 @@ const Remote = {
3433
3961
  error: failure,
3434
3962
  previous: present.value
3435
3963
  } : present;
3436
- if (isLoading(remote, selection.entity, id, relation.fields)) return { _tag: "Loading" };
3964
+ if (isLoadingThrough(remote, selection.entity, id, relation)) return { _tag: "Loading" };
3437
3965
  return failure === void 0 ? { _tag: "Initial" } : {
3438
3966
  _tag: "Failed",
3439
3967
  error: failure
@@ -3478,7 +4006,7 @@ const Remote = {
3478
4006
  if (missing.length === 0) return store;
3479
4007
  yield* Effect.annotateCurrentSpan("requirementCount", missing.length);
3480
4008
  const result = yield* (yield* RemoteClient).read({
3481
- version: 3,
4009
+ version: 4,
3482
4010
  requests: missing
3483
4011
  });
3484
4012
  return writeRead(store, missing, result, at);
@@ -3531,6 +4059,17 @@ const Remote = {
3531
4059
  });
3532
4060
  },
3533
4061
  /**
4062
+ * Forgets everything the server told this Model: values, tombstones,
4063
+ * unavailable fields, connections, live cursors, failures. What is known is
4064
+ * known for a principal, and this is the one boundary for a login, a logout
4065
+ * or a switch of organization: called from `update`, it performs no I/O, and
4066
+ * every active Surface's read and live entries restart, so the screen asks
4067
+ * again as whoever the client now is. A read or stream begun before is
4068
+ * interrupted rather than landing after. A mutation in flight is treated as
4069
+ * applied: its answer writes nothing here.
4070
+ */
4071
+ forget: (bound, model) => bound.store.set(model, forgetRemote(bound.store.get(model))),
4072
+ /**
3534
4073
  * Writes a read result into the store, recording each field's applied window.
3535
4074
  * `requests` are the planned requirements the result answers.
3536
4075
  */
@@ -3547,7 +4086,7 @@ const Remote = {
3547
4086
  query: (request) => client.FoldkitRemoteQuery(request).pipe(Effect.provideContext(context)),
3548
4087
  mutate: (request) => client.FoldkitRemoteMutate(request).pipe(Effect.provideContext(context)),
3549
4088
  live: ({ requirements, after }) => client.FoldkitRemoteLive({
3550
- version: 3,
4089
+ version: 4,
3551
4090
  requirements,
3552
4091
  after
3553
4092
  }).pipe(Stream.map(liveEventOf), Stream.provideContext(context))
@@ -3738,6 +4277,7 @@ const bindDomain = (definition, store) => {
3738
4277
  roots: current
3739
4278
  }).pipe(Effect.delay(options.grace ?? 0)))
3740
4279
  };
4280
+ markAll(entries);
3741
4281
  return entries;
3742
4282
  },
3743
4283
  plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
@@ -3756,7 +4296,7 @@ const bindDomain = (definition, store) => {
3756
4296
  if (requirements.length === 0) return current;
3757
4297
  const at = now();
3758
4298
  const result = yield* client.read({
3759
- version: 3,
4299
+ version: 4,
3760
4300
  requests: requirements
3761
4301
  });
3762
4302
  return reduce(current, {
@@ -3795,7 +4335,7 @@ const bindDomain = (definition, store) => {
3795
4335
  if (remote.loading.size === 0) return void 0;
3796
4336
  for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
3797
4337
  if (edge.ref.entity !== relation.entity) continue;
3798
- if (isLoading(remote, edge.ref.entity, edge.ref.id, relation.fields)) return { _tag: "Loading" };
4338
+ if (isLoadingThrough(remote, edge.ref.entity, edge.ref.id, relation)) return { _tag: "Loading" };
3799
4339
  }
3800
4340
  };
3801
4341
  const readPage = (remote, connection) => {
@@ -3805,8 +4345,9 @@ const bindDomain = (definition, store) => {
3805
4345
  let refreshing = connection.stale;
3806
4346
  const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
3807
4347
  for (const edge of edges) {
3808
- const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
3809
- if (assembled === void 0) return void 0;
4348
+ const key = entityKey(edge.ref.entity, edge.ref.id);
4349
+ const assembled = assemble(visible, key, relation);
4350
+ if (assembled === void 0) return unavailableFailure(visible, key, relation);
3810
4351
  const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
3811
4352
  if (Result.isFailure(decoded)) return {
3812
4353
  _tag: "Failed",
@@ -3931,6 +4472,59 @@ const bindDomain = (definition, store) => {
3931
4472
  }
3932
4473
  };
3933
4474
  },
4475
+ why: (model, projection, options) => {
4476
+ const state = projection.read(model);
4477
+ const asked = askedOf(projection);
4478
+ const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((active) => {
4479
+ const shown = active.projectionOf(model);
4480
+ return shown !== void 0 && covers(askedOf(shown), asked);
4481
+ }).map((active) => active.name);
4482
+ const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
4483
+ switch (state._tag) {
4484
+ case "Ready": return {
4485
+ state: state._tag,
4486
+ message: "Everything it selects is here.",
4487
+ ...withSurfaces
4488
+ };
4489
+ case "Refreshing": return {
4490
+ state: state._tag,
4491
+ message: "It is shown while a newer value is fetched.",
4492
+ ...withSurfaces
4493
+ };
4494
+ case "Loading": return {
4495
+ state: state._tag,
4496
+ message: "A request for it is in flight.",
4497
+ ...withSurfaces
4498
+ };
4499
+ case "NotFound": return {
4500
+ state: state._tag,
4501
+ message: "The server answered without it: it does not exist, or this principal may not see it.",
4502
+ ...withSurfaces
4503
+ };
4504
+ case "Failed": return {
4505
+ state: state._tag,
4506
+ message: state.error._tag === "DecodeError" ? `What the server sent does not decode against the Selection: ${state.error.message}` : state.error._tag === "Unavailable" ? `${state.error.message} Select without it, or Data.refresh asks again.` : `Its request failed: ${state.error.message}. Nothing retries a failed read on its own; Data.refresh asks again.`,
4507
+ ...withSurfaces
4508
+ };
4509
+ case "Initial":
4510
+ if (reading === void 0) return {
4511
+ state: state._tag,
4512
+ reason: "Unknown",
4513
+ message: "Nothing is fetching it. Pass the active record you give Data.subscriptions as `surfaces` to tell whether an active Surface reads it."
4514
+ };
4515
+ return reading.length === 0 ? {
4516
+ state: state._tag,
4517
+ reason: "NotObserved",
4518
+ message: "No active Surface reads it, so nothing fetches it. Include it in a Surface that is active for this Model, in the record given to Data.subscriptions.",
4519
+ surfaces: reading
4520
+ } : {
4521
+ state: state._tag,
4522
+ reason: "NotFetching",
4523
+ message: `${reading.join(", ")} ${reading.length === 1 ? "reads it and is" : "read it and are"} active, yet nothing is fetching it. Remote's Subscriptions are most likely not installed: give the runtime Data.subscriptions (or Data.wiring) and a RemoteClient.`,
4524
+ surfaces: reading
4525
+ };
4526
+ }
4527
+ },
3934
4528
  filtered: (model, over, by, input) => {
3935
4529
  assertRegistered(bound, "Query", definition.registry.queries, by.name);
3936
4530
  const filters = by.body?.entity.name;
@@ -3962,6 +4556,7 @@ const bindDomain = (definition, store) => {
3962
4556
  };
3963
4557
  },
3964
4558
  refresh: (model, target) => Remote.refresh(bound, model, target),
4559
+ forget: (model) => Remote.forget(bound, model),
3965
4560
  overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
3966
4561
  _tag: "OverlayShown",
3967
4562
  id,
@@ -4054,6 +4649,6 @@ const pageSize = (window, key) => {
4054
4649
  return size === void 0 ? {} : { [key]: size };
4055
4650
  };
4056
4651
  //#endregion
4057
- export { ConnectionChange, ConnectionChangeSchema, Entity, IDENTITY_SEPARATOR, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, RELATION_ALIAS, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, aliasedField, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, belongsEncoded, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, failureOf, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, isFieldFailed, isFieldStale, isGapped, isLoading, isQueryLoading, isRefPage, isRemoteMessage, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, matching, merge, missingFields, mutateRemote, mutationStatus, noFailures, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshIsInFlight, refreshedAt, refsIn, relationAlias, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, withRefreshRequested, writeEntities, writeEntity, writeRead };
4652
+ export { ConnectionChange, ConnectionChangeSchema, Entity, IDENTITY_SEPARATOR, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, RELATION_ALIAS, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, SettledFields, WindowSchema, WireBoundary, addLayer, addOverlay, aliasedField, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, belongsEncoded, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, deadlineOf, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, failureOf, forgetRemote, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, isFieldFailed, isFieldStale, isFieldUnavailable, isGapped, isLoading, isLoadingThrough, isQueryLoading, isRefPage, isRemoteMessage, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, matching, merge, missingFields, mutateRemote, mutationStatus, noFailures, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshIsInFlight, refreshedAt, refsIn, relationAlias, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, setUnavailable, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unavailableOf, unknown, updateRemote, visibleItems, visibleStore, windowKey, withRefreshRequested, writeEntities, writeEntity, writeRead };
4058
4653
 
4059
4654
  //# sourceMappingURL=index.mjs.map