foldkit-remote 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
2
2
  import * as Domain from "foldkit-entity";
3
- import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId } from "foldkit-entity";
3
+ import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId, evaluate } from "foldkit-entity";
4
4
  import * as Subscription from "foldkit/subscription";
5
5
  import { Metadata } from "foldkit-surface";
6
6
  import { Rpc, RpcGroup } from "effect/unstable/rpc";
@@ -440,6 +440,16 @@ make: (name, config) => ({
440
440
  * canonical encoded filter/sort input — **never** the pagination window, so
441
441
  * `first(25)` and `after(cursor).first(25)` address the same logical connection.
442
442
  */
443
+ /**
444
+ * What separates a query's name from its canonical encoded input in a
445
+ * connection identity.
446
+ *
447
+ * NUL, because `JSON.stringify` escapes it inside a string (`\u0000`) and a
448
+ * query name is a TypeScript identifier — so the first occurrence is always the
449
+ * boundary, and a name or an input value cannot forge one. Anything recovering
450
+ * the query from an identity splits here.
451
+ */
452
+ const IDENTITY_SEPARATOR = "\0";
443
453
  /** Stable stringify: object keys sorted, undefined-valued keys dropped, so equal inputs encode equally. */
444
454
  const stableStringify = (value) => {
445
455
  if (value === void 0) return "null";
@@ -498,7 +508,7 @@ const Query = {
498
508
  input,
499
509
  Input,
500
510
  window: {},
501
- identity: `${name}\u0000${stableStringify(encode(input))}`
511
+ identity: `${name}${stableStringify(encode(input))}`
502
512
  })
503
513
  };
504
514
  },
@@ -1424,6 +1434,118 @@ const Entity = {
1424
1434
  })
1425
1435
  };
1426
1436
  //#endregion
1437
+ //#region src/matching.ts
1438
+ /**
1439
+ * Running a query body against the rows the client already holds.
1440
+ *
1441
+ * local-execution-DESIGN phase 3. A connection's rows are edges the server
1442
+ * delivered, and until now nothing on the client ever evaluated a body — which
1443
+ * is why an optimistic insert has to guess between `prepend` and `append`, and
1444
+ * why `LivePolicy` exists to be told in advance what to do with a row nobody
1445
+ * could judge.
1446
+ *
1447
+ * This judges them. It is pure, it performs no I/O, and it reuses the reference
1448
+ * interpreter in `foldkit-entity` rather than reimplementing the semantics —
1449
+ * so a body means here exactly what it means on the server, checked by the same
1450
+ * conformance suite.
1451
+ *
1452
+ * **It answers "which of the rows I hold match", never "which rows match".**
1453
+ * Those are different questions and the difference is not recoverable from the
1454
+ * result, so it is in the shape: `matched` is what satisfied the body, and
1455
+ * `skipped` is what was held but could not be judged. A caller that knows it
1456
+ * holds the whole population — a connection terminal at both ends — is the one
1457
+ * that can turn this into a complete answer. Nothing here can.
1458
+ *
1459
+ * **It is not authorization.** On the server a compiled `where` is conjoined
1460
+ * with the binding's `visible` rule. There is no `visible` here, and this is
1461
+ * safe only because the client holds only rows the server already released to
1462
+ * it. A local filter is not an access decision.
1463
+ */
1464
+ /**
1465
+ * The fields of the body's own Entity that a row must have for the body to be
1466
+ * decidable about it — its predicates and its ordering terms.
1467
+ *
1468
+ * `id` is never among them: it is the key, so it is known for every row in the
1469
+ * store whether or not anything fetched it as a field.
1470
+ */
1471
+ const decidableOn = (body) => Query$1.dependencies(body).fields.filter((field) => field.entity === body.entity.name && field.key !== "id").map((field) => field.key);
1472
+ /**
1473
+ * The rows of `descriptor`'s Entity that the store holds and the body matches.
1474
+ *
1475
+ * The input is given **decoded**, as an application writes it, and encoded here
1476
+ * through the descriptor's own `Input` codec. That is deliberate and is the
1477
+ * whole reason this takes a descriptor rather than a bare body: a store holds
1478
+ * wire values, so an input has to be in that space to be compared against them,
1479
+ * and handing an interpreter a decoded value is a mistake **no runtime error
1480
+ * catches** — the reference interpreter answers "no rows", which reads exactly
1481
+ * like a correct answer. Doing it here means a caller cannot get it wrong.
1482
+ *
1483
+ * Literals in the body are deliberately *not* encoded. A literal written in
1484
+ * domain terms is already broken against the server, which binds it to a column
1485
+ * the same way; accepting it here would make the client quietly more permissive
1486
+ * than the thing it has to agree with.
1487
+ */
1488
+ const matching = (store, descriptor, input, options = {}) => matchingEncoded(store, descriptor, Schema.encodeSync(descriptor.Input)(input), options);
1489
+ /**
1490
+ * As `matching`, for the callers in this module that already hold the input
1491
+ * **encoded**. Not exported: `matching` is the way in, and it cannot be given
1492
+ * the wrong space.
1493
+ */
1494
+ const matchingEncoded = (store, descriptor, encoded, options = {}) => {
1495
+ const body = descriptor.body;
1496
+ if (body === void 0) throw new Error(`[foldkit-remote] Remote.matching: query "${descriptor.name}" carries no body, so there is nothing to judge rows against. Declare it with Query.define.`);
1497
+ const prefix = `${body.entity.name}:`;
1498
+ const needed = decidableOn(body);
1499
+ const candidates = options.among ?? Object.keys(store).filter((key) => key.startsWith(prefix));
1500
+ const rows = [];
1501
+ const skipped = [];
1502
+ for (const key of candidates) {
1503
+ if (!key.startsWith(prefix)) continue;
1504
+ const entry = store[key];
1505
+ if (entry === void 0) {
1506
+ skipped.push(key);
1507
+ continue;
1508
+ }
1509
+ if (entry.tombstone) continue;
1510
+ if (needed.some((field) => !entry.present.has(field))) {
1511
+ skipped.push(key);
1512
+ continue;
1513
+ }
1514
+ rows.push("id" in entry.values ? {
1515
+ ...entry.values,
1516
+ __key: key
1517
+ } : {
1518
+ ...entry.values,
1519
+ id: key.slice(prefix.length),
1520
+ __key: key
1521
+ });
1522
+ }
1523
+ return {
1524
+ matched: evaluate(body, encoded, rows).map((row) => row.__key),
1525
+ skipped
1526
+ };
1527
+ };
1528
+ /**
1529
+ * Takes the input **encoded**, because its one caller holds it that way: a
1530
+ * connection identity carries the canonical encoded input, and making it decode
1531
+ * only for this to encode again would be a round trip that can fail.
1532
+ *
1533
+ * A decoded-input sibling was written first and deleted: nothing called it. The
1534
+ * asymmetry with `matching`, which takes a decoded input, is the shape of the
1535
+ * two real callers rather than an oversight.
1536
+ */
1537
+ const belongsEncoded = (store, descriptor, encoded, key) => {
1538
+ const entry = store[key];
1539
+ if (entry === void 0) return "unknown";
1540
+ if (entry.tombstone) return "no";
1541
+ const body = descriptor.body;
1542
+ if (body !== void 0 && decidableOn(body).some((field) => entry.stale.has(field))) return "unknown";
1543
+ const judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
1544
+ if (judged.matched.includes(key)) return "yes";
1545
+ if (judged.skipped.includes(key)) return "unknown";
1546
+ return "no";
1547
+ };
1548
+ //#endregion
1427
1549
  //#region src/inspect.ts
1428
1550
  const inspectEntry = (key, entry) => ({
1429
1551
  key,
@@ -1440,6 +1562,7 @@ const inspectRemote = (model) => ({
1440
1562
  live: Object.keys(model.live),
1441
1563
  gaps: [...model.gaps],
1442
1564
  loading: [...model.loading],
1565
+ failures: model.failures,
1443
1566
  mutations: {
1444
1567
  pending: [...model.mutations.pending],
1445
1568
  failed: [...model.mutations.failed],
@@ -1617,7 +1740,6 @@ const visibleItems = (connection, connectionId, overlays, store) => {
1617
1740
  //#region src/live.ts
1618
1741
  const emptyLiveState = {
1619
1742
  cursor: 0,
1620
- stale: /* @__PURE__ */ new Set(),
1621
1743
  boundary: {}
1622
1744
  };
1623
1745
  /**
@@ -1651,10 +1773,6 @@ const recordBoundary = (state, connection, position) => {
1651
1773
  }
1652
1774
  };
1653
1775
  };
1654
- const invalidateConnection = (state, connection) => ({
1655
- ...state,
1656
- stale: /* @__PURE__ */ new Set([...state.stale, connection])
1657
- });
1658
1776
  /**
1659
1777
  * A subscriber is woken only if the event changed a field it selects. A
1660
1778
  * change to an unselected field is skipped entirely.
@@ -1707,9 +1825,10 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
1707
1825
  outcome
1708
1826
  };
1709
1827
  case "invalidate": return {
1710
- state: invalidateConnection(advance(state, event.cursor), event.connection),
1828
+ state: advance(state, event.cursor),
1711
1829
  optimistic,
1712
- outcome
1830
+ outcome,
1831
+ invalidated: event.connection
1713
1832
  };
1714
1833
  case "ignore": return {
1715
1834
  state: advance(state, event.cursor),
@@ -1728,27 +1847,13 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
1728
1847
  outcome
1729
1848
  };
1730
1849
  case "ConnectionInvalidate": return {
1731
- state: invalidateConnection(advance(state, event.cursor), event.connection),
1850
+ state: advance(state, event.cursor),
1851
+ invalidated: event.connection,
1732
1852
  optimistic,
1733
1853
  outcome
1734
1854
  };
1735
1855
  }
1736
1856
  };
1737
- const isStale = (state, connection) => state.stale.has(connection);
1738
- /**
1739
- * Clears a connection's stale mark once a fresh page has been adopted. An
1740
- * invalidating event only records that a refetch is due; whoever merges the
1741
- * refetched page calls this so `isStale` stops reporting.
1742
- */
1743
- const refreshConnection = (state, connection) => {
1744
- if (!state.stale.has(connection)) return state;
1745
- const stale = new Set(state.stale);
1746
- stale.delete(connection);
1747
- return {
1748
- ...state,
1749
- stale
1750
- };
1751
- };
1752
1857
  /** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
1753
1858
  const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
1754
1859
  /** `hasNext` accounting for edges recorded outside the loaded boundary. */
@@ -1890,17 +1995,33 @@ const gc = (state, roots) => {
1890
1995
  * application's choice.
1891
1996
  */
1892
1997
  /** Bump when the serialized shape changes; a mismatch discards the cache. */
1893
- const REMOTE_CACHE_VERSION = 3;
1998
+ const REMOTE_CACHE_VERSION = 4;
1999
+ /**
2000
+ * A snapshot of a Model, keeping the entity cache and only the connections
2001
+ * named.
2002
+ *
2003
+ * Naming none — the default — is exactly what Remote did before connections
2004
+ * could be kept at all: a disposable, server-derived cache.
2005
+ */
2006
+ const snapshotOf = (model, options = {}) => ({
2007
+ entities: model.entities,
2008
+ connections: Object.fromEntries((options.connections ?? []).flatMap((connection) => {
2009
+ const identity = connectionIdentity(connection);
2010
+ const held = model.connections[identity];
2011
+ return held === void 0 ? [] : [[identity, held.segments.map((segment) => segment.edges)]];
2012
+ }))
2013
+ });
1894
2014
  const sorted = (values) => [...values].sort();
1895
2015
  /**
1896
2016
  * The snapshot's data shape. `stableStringify` sorts object keys, and the
1897
2017
  * field sets are sorted here, so equal stores give byte-equal snapshots
1898
2018
  * whatever order they were built in.
1899
2019
  */
1900
- const serializeStore = (store, scope) => ({
1901
- version: 3,
2020
+ const serializeStore = (snapshot, scope) => ({
2021
+ version: 4,
1902
2022
  scope: scope ?? null,
1903
- entities: Object.fromEntries(Object.entries(store).map(([key, entry]) => [key, {
2023
+ connections: snapshot.connections,
2024
+ entities: Object.fromEntries(Object.entries(snapshot.entities).map(([key, entry]) => [key, {
1904
2025
  values: entry.values,
1905
2026
  present: sorted(entry.present),
1906
2027
  stale: sorted(entry.stale),
@@ -1930,9 +2051,30 @@ const parseEntry = (value) => {
1930
2051
  windows: entry.windows
1931
2052
  };
1932
2053
  };
1933
- const deserializeStore = (serialized) => Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)]));
1934
- function dehydrate(store, options = {}) {
1935
- const text = stableStringify(serializeStore(store, options.scope));
2054
+ /** Throws on a malformed edge so the whole snapshot is discarded. */
2055
+ const parseEdges = (value) => {
2056
+ if (!Array.isArray(value)) throw new Error("segment is not an array of edges");
2057
+ return value.map((edge) => {
2058
+ const candidate = edge;
2059
+ if (typeof candidate.key !== "string" || typeof candidate.ref?.entity !== "string" || typeof candidate.ref?.id !== "string") throw new Error("edge is not { key, ref: { entity, id } }");
2060
+ return {
2061
+ key: candidate.key,
2062
+ ref: {
2063
+ entity: candidate.ref.entity,
2064
+ id: candidate.ref.id
2065
+ }
2066
+ };
2067
+ });
2068
+ };
2069
+ const deserializeStore = (serialized) => ({
2070
+ entities: Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)])),
2071
+ connections: Object.fromEntries(Object.entries(serialized.connections ?? {}).map(([key, segments]) => {
2072
+ if (!Array.isArray(segments)) throw new Error("connection is not an array of segments");
2073
+ return [key, segments.map(parseEdges)];
2074
+ }))
2075
+ });
2076
+ function dehydrate(snapshot, options = {}) {
2077
+ const text = stableStringify(serializeStore(snapshot, options.scope));
1936
2078
  return exceeds(text, options.maxBytes) ? void 0 : text;
1937
2079
  }
1938
2080
  /**
@@ -1962,7 +2104,7 @@ const hydrate = (raw, options = {}) => {
1962
2104
  }
1963
2105
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
1964
2106
  const candidate = parsed;
1965
- if (candidate.version !== 3) return void 0;
2107
+ if (candidate.version !== 4) return void 0;
1966
2108
  if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
1967
2109
  if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
1968
2110
  if (Array.isArray(candidate.entities)) return void 0;
@@ -1985,37 +2127,49 @@ const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
1985
2127
  ...snapshot,
1986
2128
  ...current
1987
2129
  };
2130
+ /** An empty snapshot: the cache as a fresh session has it. */
2131
+ const emptySnapshot = {
2132
+ entities: emptyStore,
2133
+ connections: {}
2134
+ };
1988
2135
  const RemotePersistence = {
1989
2136
  /** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
1990
2137
  dehydrate,
1991
- /** The store a snapshot text holds, or `undefined` when it is refused. */
2138
+ /** What a snapshot text holds, or `undefined` when it is refused. */
1992
2139
  hydrate,
1993
- /** A snapshot brought into a store by policy. */
2140
+ /**
2141
+ * What to keep of a Model, naming the connections that should survive a
2142
+ * reload. Naming none is the default and is what Remote always did.
2143
+ */
2144
+ snapshotOf,
2145
+ /** A snapshot's entities brought into a store by policy. */
1994
2146
  mergeStores,
2147
+ /** The cache as a fresh session has it; what a refused `restore` yields. */
2148
+ emptySnapshot,
1995
2149
  /**
1996
2150
  * Writes the snapshot under `key`, or removes the key when the store would
1997
2151
  * exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
1998
2152
  * store it no longer describes.
1999
2153
  */
2000
- save: (store, options) => Effect.gen(function* () {
2154
+ save: (snapshot, options) => Effect.gen(function* () {
2001
2155
  const kv = yield* KeyValueStore.KeyValueStore;
2002
- const text = dehydrate(store, options);
2156
+ const text = dehydrate(snapshot, options);
2003
2157
  if (text === void 0) yield* kv.remove(options.key);
2004
2158
  else yield* kv.set(options.key, text);
2005
2159
  }),
2006
2160
  /**
2007
- * Reads the cache. A missing key yields `emptyStore`; anything `hydrate`
2008
- * refuses yields `emptyStore` and removes the key, so the next `restore` is
2009
- * clean and the planner refetches.
2161
+ * Reads the cache. A missing key yields `emptySnapshot`; anything `hydrate`
2162
+ * refuses yields `emptySnapshot` and removes the key, so the next `restore`
2163
+ * is clean and the planner refetches.
2010
2164
  */
2011
2165
  restore: (options) => Effect.gen(function* () {
2012
2166
  const kv = yield* KeyValueStore.KeyValueStore;
2013
2167
  const raw = yield* kv.get(options.key);
2014
- if (raw === void 0) return emptyStore;
2168
+ if (raw === void 0) return emptySnapshot;
2015
2169
  const restored = hydrate(raw, options);
2016
2170
  if (restored === void 0) {
2017
2171
  yield* kv.remove(options.key);
2018
- return emptyStore;
2172
+ return emptySnapshot;
2019
2173
  }
2020
2174
  return restored;
2021
2175
  })
@@ -2138,6 +2292,10 @@ const RemoteData = {
2138
2292
  * optimistic layers an application keeps in its Model, the Messages that
2139
2293
  * change it, and the pure reducer over them.
2140
2294
  */
2295
+ const noFailures = {
2296
+ connections: {},
2297
+ fields: {}
2298
+ };
2141
2299
  const emptyRefresh = {
2142
2300
  generation: 0,
2143
2301
  requested: /* @__PURE__ */ new Map(),
@@ -2151,7 +2309,8 @@ const initialRemoteModel = {
2151
2309
  mutations: emptyMutationState,
2152
2310
  gaps: /* @__PURE__ */ new Set(),
2153
2311
  loading: /* @__PURE__ */ new Set(),
2154
- refresh: emptyRefresh
2312
+ refresh: emptyRefresh,
2313
+ failures: noFailures
2155
2314
  };
2156
2315
  /** The entity store and the mutation ledger are runtime values, not wire shapes. */
2157
2316
  const runtimeSchema = Schema.Unknown;
@@ -2163,7 +2322,11 @@ const remoteModelSchema = () => Schema.Struct({
2163
2322
  mutations: runtimeSchema,
2164
2323
  gaps: runtimeSchema,
2165
2324
  loading: runtimeSchema,
2166
- refresh: runtimeSchema
2325
+ refresh: runtimeSchema,
2326
+ failures: Schema.Struct({
2327
+ connections: Schema.Record(Schema.String, remoteErrorSchema),
2328
+ fields: Schema.Record(Schema.String, remoteErrorSchema)
2329
+ })
2167
2330
  });
2168
2331
  const retentionRootsSchema = Schema.Struct({
2169
2332
  requirements: Schema.Array(ReadRequest),
@@ -2184,15 +2347,18 @@ const remoteMessageCases = {
2184
2347
  },
2185
2348
  ReadFailed: {
2186
2349
  requests: Schema.Array(ReadRequest),
2187
- error: remoteErrorSchema
2350
+ error: remoteErrorSchema,
2351
+ stream: Schema.optional(Schema.String)
2188
2352
  },
2189
2353
  RefreshStarted: { requests: Schema.Array(ReadRequest) },
2190
2354
  ReadStarted: {
2191
2355
  requests: Schema.Array(ReadRequest),
2192
2356
  refresh: Schema.optional(Schema.Number)
2193
2357
  },
2358
+ QueryStarted: { connections: Schema.Array(Schema.String) },
2194
2359
  RetentionChanged: { roots: retentionRootsSchema },
2195
2360
  Hydrated: {
2361
+ connections: Schema.optional(Schema.Unknown),
2196
2362
  entities: runtimeSchema,
2197
2363
  merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
2198
2364
  },
@@ -2277,6 +2443,25 @@ const withoutLoading = (loading, requests) => {
2277
2443
  */
2278
2444
  const connectionPrefix = "\0connection\0";
2279
2445
  const connectionMark = (identity) => `${connectionPrefix}${identity}`;
2446
+ /** Whether a query for this connection is in flight. */
2447
+ const isQueryLoading = (model, identity) => model.loading.size !== 0 && model.loading.has(connectionMark(identity));
2448
+ const withoutQueryLoading = (loading, identity) => {
2449
+ if (!loading.has(connectionMark(identity))) return loading;
2450
+ const next = new Set(loading);
2451
+ next.delete(connectionMark(identity));
2452
+ return next;
2453
+ };
2454
+ /**
2455
+ * The in-flight marks of connections the roots still name. A query the entry
2456
+ * stopped waiting for — it was restarted or deactivated before an answer came —
2457
+ * leaves its mark behind, and a connection released with that mark would read
2458
+ * `Loading` forever the next time something asked for it before sending.
2459
+ */
2460
+ const prunedQueryLoading = (loading, roots) => {
2461
+ const named = new Set(roots.connections.map((root) => connectionMark(root.identity)));
2462
+ const kept = [...loading].filter((mark) => !mark.startsWith(connectionPrefix) || named.has(mark));
2463
+ return kept.length === loading.size ? loading : new Set(kept);
2464
+ };
2280
2465
  /**
2281
2466
  * Drops the refresh marks of entities and connections that were collected.
2282
2467
  * Nothing observes a collected entity — the retention roots are the active
@@ -2391,31 +2576,64 @@ const updateRemote = (model, message) => {
2391
2576
  case "ReadReceived": return {
2392
2577
  ...model,
2393
2578
  entities: writeRead(model.entities, message.requests, message.result, message.now),
2394
- loading: withoutLoading(model.loading, message.requests)
2395
- };
2396
- case "ReadFailed": return {
2397
- ...model,
2398
- entities: setStale(model.entities, marksOf(message.requests), false),
2399
- loading: withoutLoading(model.loading, message.requests)
2579
+ loading: withoutLoading(model.loading, message.requests),
2580
+ failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
2400
2581
  };
2582
+ case "ReadFailed":
2583
+ if (message.stream !== void 0) return markGap(model, message.stream);
2584
+ return {
2585
+ ...model,
2586
+ entities: setStale(model.entities, marksOf(message.requests), false),
2587
+ loading: withoutLoading(model.loading, message.requests),
2588
+ failures: withFieldFailures(model.failures, message.requests, message.error)
2589
+ };
2401
2590
  case "RetentionChanged": {
2402
2591
  const retained = gc(model, message.roots);
2403
2592
  return {
2404
2593
  ...model,
2405
2594
  ...retained,
2406
- refresh: prunedRefresh(model.refresh, retained)
2595
+ refresh: prunedRefresh(model.refresh, retained),
2596
+ failures: prunedFailures(model, message.roots),
2597
+ loading: prunedQueryLoading(model.loading, message.roots)
2598
+ };
2599
+ }
2600
+ case "Hydrated": {
2601
+ const restored = Object.entries(message.connections ?? {}).map(([identity, segments]) => [identity, {
2602
+ segments: segments.map((edges) => ({
2603
+ edges: [...edges],
2604
+ start: unknown,
2605
+ end: unknown
2606
+ })),
2607
+ stale: true
2608
+ }]);
2609
+ return {
2610
+ ...model,
2611
+ entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge),
2612
+ connections: message.merge === "replace" ? {
2613
+ ...model.connections,
2614
+ ...Object.fromEntries(restored)
2615
+ } : {
2616
+ ...Object.fromEntries(restored),
2617
+ ...model.connections
2618
+ }
2407
2619
  };
2408
2620
  }
2409
- case "Hydrated": return {
2410
- ...model,
2411
- entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
2412
- };
2413
2621
  case "RefreshStarted": {
2414
2622
  const asked = message.requests.reduce((store, request) => isTombstone(store, entityKey(request.entity, request.id)) ? remove(store, entityKey(request.entity, request.id)) : store, model.entities);
2415
2623
  const entities = setStale(asked, marksOf(message.requests), true);
2416
- return entities === model.entities ? model : {
2624
+ const failures = withoutFieldFailures(model.failures, fieldMarks(message.requests));
2625
+ return entities === model.entities && failures === model.failures ? model : {
2626
+ ...model,
2627
+ entities,
2628
+ failures
2629
+ };
2630
+ }
2631
+ case "QueryStarted": {
2632
+ const loading = new Set(model.loading);
2633
+ for (const identity of message.connections) loading.add(connectionMark(identity));
2634
+ return loading.size === model.loading.size ? model : {
2417
2635
  ...model,
2418
- entities
2636
+ loading
2419
2637
  };
2420
2638
  }
2421
2639
  case "ReadStarted": return {
@@ -2434,7 +2652,8 @@ const updateRemote = (model, message) => {
2434
2652
  ...model,
2435
2653
  entities: settled.store,
2436
2654
  mutations: settled.state,
2437
- optimistic: settled.optimistic
2655
+ optimistic: settled.optimistic,
2656
+ failures: withoutFieldFailures(model.failures, [...patchedMarks(message.entities), ...(message.deleted ?? []).flatMap((ref) => entityFailureMarks(model.failures, entityKey(ref.entity, ref.id)))])
2438
2657
  };
2439
2658
  }
2440
2659
  case "OverlayShown": return {
@@ -2456,18 +2675,26 @@ const updateRemote = (model, message) => {
2456
2675
  case "LiveReceived": {
2457
2676
  const state = model.live[message.stream] ?? emptyLiveState;
2458
2677
  if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
2459
- const applied = applyEntityEvent(state, model.entities, message.event, message.now);
2460
- return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
2678
+ const event = message.event;
2679
+ const applied = applyEntityEvent(state, model.entities, event, message.now);
2680
+ if (applied.outcome === "gap") return markGap(model, message.stream);
2681
+ const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
2682
+ ...event.ref,
2683
+ values: event.values
2684
+ }])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
2685
+ return clearGap({
2461
2686
  ...model,
2462
2687
  entities: applied.store,
2463
2688
  live: {
2464
2689
  ...model.live,
2465
2690
  [message.stream]: applied.state
2466
- }
2691
+ },
2692
+ failures: withoutFieldFailures(model.failures, settled)
2467
2693
  }, message.stream);
2468
2694
  }
2469
2695
  const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
2470
- return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
2696
+ if (applied.outcome === "gap") return markGap(model, message.stream);
2697
+ const next = clearGap({
2471
2698
  ...model,
2472
2699
  optimistic: applied.optimistic,
2473
2700
  live: {
@@ -2475,10 +2702,16 @@ const updateRemote = (model, message) => {
2475
2702
  [message.stream]: applied.state
2476
2703
  }
2477
2704
  }, message.stream);
2705
+ return applied.invalidated === void 0 ? next : updateRemote(next, {
2706
+ _tag: "ConnectionInvalidated",
2707
+ connection: applied.invalidated
2708
+ });
2478
2709
  }
2479
2710
  case "GapCleared": return clearGap(model, message.stream);
2480
2711
  case "ConnectionMerged": {
2481
2712
  const current = model.connections[message.connection] ?? emptyConnection;
2713
+ const failures = withoutConnectionFailure(model.failures, message.connection);
2714
+ const loading = withoutQueryLoading(model.loading, message.connection);
2482
2715
  const replaces = message.refreshes === true && current.stale;
2483
2716
  const merged = merge(replaces ? emptyConnection : current, message.page);
2484
2717
  return {
@@ -2490,25 +2723,129 @@ const updateRemote = (model, message) => {
2490
2723
  stale: false
2491
2724
  } : merged
2492
2725
  },
2493
- optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
2726
+ optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending),
2727
+ failures,
2728
+ loading
2494
2729
  };
2495
2730
  }
2496
2731
  case "ConnectionInvalidated":
2497
- if (model.connections[message.connection]?.stale === true) return model;
2732
+ if (model.connections[message.connection]?.stale === true && !(message.connection in model.failures.connections)) return model;
2498
2733
  return {
2499
2734
  ...model,
2500
- connections: setConnectionStale(model.connections, message.connection, true)
2735
+ connections: setConnectionStale(model.connections, message.connection, true),
2736
+ failures: withoutConnectionFailure(model.failures, message.connection)
2501
2737
  };
2502
- case "QueryFailed": return message.connection in model.connections ? {
2738
+ case "QueryFailed": return {
2503
2739
  ...model,
2504
- connections: setConnectionStale(model.connections, message.connection, false)
2505
- } : model;
2740
+ connections: message.connection in model.connections ? setConnectionStale(model.connections, message.connection, false) : model.connections,
2741
+ failures: {
2742
+ ...model.failures,
2743
+ connections: {
2744
+ ...model.failures.connections,
2745
+ [message.connection]: message.error
2746
+ }
2747
+ },
2748
+ loading: withoutQueryLoading(model.loading, message.connection)
2749
+ };
2506
2750
  case "ConnectionRefreshed": return {
2507
2751
  ...model,
2508
2752
  connections: setConnectionStale(model.connections, message.connection, false)
2509
2753
  };
2510
2754
  }
2511
2755
  };
2756
+ /** The failures without one connection's, keeping identity when it had none. */
2757
+ const withoutConnectionFailure = (failures, connection) => {
2758
+ if (!(connection in failures.connections)) return failures;
2759
+ const { [connection]: _settled, ...connections } = failures.connections;
2760
+ return {
2761
+ ...failures,
2762
+ connections
2763
+ };
2764
+ };
2765
+ /** The failures with every field a read asked for failed with `error`. */
2766
+ const withFieldFailures = (failures, requests, error) => {
2767
+ const fields = { ...failures.fields };
2768
+ for (const mark of fieldMarks(requests)) fields[mark] = error;
2769
+ return {
2770
+ ...failures,
2771
+ fields
2772
+ };
2773
+ };
2774
+ /**
2775
+ * The failures without the fields `settled` names, keeping identity when none
2776
+ * of them had failed, which is nearly always: this runs on every write.
2777
+ */
2778
+ const withoutFieldFailures = (failures, settled) => {
2779
+ if (Object.keys(failures.fields).length === 0) return failures;
2780
+ const marks = [...settled].filter((mark) => mark in failures.fields);
2781
+ if (marks.length === 0) return failures;
2782
+ const fields = { ...failures.fields };
2783
+ for (const mark of marks) delete fields[mark];
2784
+ return {
2785
+ ...failures,
2786
+ fields
2787
+ };
2788
+ };
2789
+ /** Every failed field of one entity: what deleting the entity settles. */
2790
+ const entityFailureMarks = (failures, key) => Object.keys(failures.fields).filter((mark) => mark.startsWith(`${key}\u0000`));
2791
+ /** The field marks of written values: each patch settles the fields it carries. */
2792
+ const patchedMarks = function* (patches) {
2793
+ for (const patch of patches) for (const field of Object.keys(patch.values)) yield fieldMark(patch.entity, patch.id, field);
2794
+ };
2795
+ /**
2796
+ * The failures of what the roots still reach. A released connection's or
2797
+ * entity's failure goes with it, so asking for it again later asks the server
2798
+ * again rather than repeating an error that may no longer hold.
2799
+ *
2800
+ * Reached is retention's own walk, taken over the Model before collection. It
2801
+ * counts a relation's target and a list's row that the store does not hold,
2802
+ * which is exactly a value whose first read failed: it is still on screen,
2803
+ * behind the parent that refers to it.
2804
+ */
2805
+ const prunedFailures = (model, roots) => {
2806
+ const { failures } = model;
2807
+ const named = new Set(roots.connections.map((root) => root.identity));
2808
+ const connections = Object.entries(failures.connections).filter(([identity]) => named.has(identity));
2809
+ const kept = Object.keys(failures.fields).length === 0 ? /* @__PURE__ */ new Set() : reachable(model.entities, roots, model.connections, model.optimistic, model.mutations.pending);
2810
+ const fields = Object.entries(failures.fields).filter(([mark]) => kept.has(mark.slice(0, mark.indexOf("\0"))));
2811
+ return connections.length === Object.keys(failures.connections).length && fields.length === Object.keys(failures.fields).length ? failures : {
2812
+ connections: Object.fromEntries(connections),
2813
+ fields: Object.fromEntries(fields)
2814
+ };
2815
+ };
2816
+ /** Whether this field's last read failed and nothing has settled it since. */
2817
+ const isFieldFailed = (model, entity, id, field) => fieldMark(entity, id, field) in model.failures.fields;
2818
+ /**
2819
+ * The first failure among the fields `relation` reads of an entity, following
2820
+ * each relation the store holds into its targets, as `assemble` does. A read
2821
+ * that consults it shows `Failed` for a related entity's failure too, rather
2822
+ * than waiting on a field nothing will fetch.
2823
+ */
2824
+ const failureOf = (model, entity, id, relation) => {
2825
+ const failed = model.failures.fields;
2826
+ if (Object.keys(failed).length === 0) return void 0;
2827
+ const walked = /* @__PURE__ */ new Map();
2828
+ const walk = (target, targetId, requirement) => {
2829
+ const key = entityKey(target, targetId);
2830
+ const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
2831
+ if (seen.has(requirement)) return void 0;
2832
+ seen.add(requirement);
2833
+ walked.set(key, seen);
2834
+ for (const field of requirement.fields) {
2835
+ const error = failed[fieldMark(target, targetId, field)];
2836
+ if (error !== void 0) return error;
2837
+ const nested = requirement.relations?.[field];
2838
+ if (nested === void 0) continue;
2839
+ const value = readField(model.entities, key, field);
2840
+ if (value._tag === "None") continue;
2841
+ for (const ref of targetsOf(value.value, nested)) {
2842
+ const found = walk(ref.entity, ref.id, nested);
2843
+ if (found !== void 0) return found;
2844
+ }
2845
+ }
2846
+ };
2847
+ return walk(entity, id, relation);
2848
+ };
2512
2849
  /** Appends (after) or prepends (before) an incoming page onto the stored one. */
2513
2850
  const mergeWireRefPages = (current, next, direction) => {
2514
2851
  const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
@@ -2744,6 +3081,10 @@ const askedOf = (projection) => projection === void 0 ? nothingAsked : {
2744
3081
  * fresh contributes its visible items' selected fields to the entity plan; one
2745
3082
  * it does not hold, or holds stale, is a query to run (its items are planned
2746
3083
  * once the page arrives).
3084
+ *
3085
+ * A connection whose query failed is not run again here, short of `force`: the
3086
+ * failure is what its read shows, and retrying is `Remote.refresh`'s to ask
3087
+ * for. The rows it already holds are still planned, since they are on screen.
2747
3088
  */
2748
3089
  const planAsked = (remote, asked, options) => {
2749
3090
  const queries = [];
@@ -2751,18 +3092,46 @@ const planAsked = (remote, asked, options) => {
2751
3092
  const connections = Requirement.mergeConnections(asked.connections);
2752
3093
  for (const connection of connections) {
2753
3094
  const known = remote.connections[connection.identity];
2754
- if (known === void 0 || known.stale || options.force === true) {
3095
+ if (!(options.force !== true && connection.identity in remote.failures.connections) && (known === void 0 || known.stale || options.force === true)) {
2755
3096
  queries.push(connection);
2756
3097
  continue;
2757
3098
  }
3099
+ if (known === void 0) continue;
2758
3100
  const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities);
2759
3101
  items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
2760
3102
  }
3103
+ const planned = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options);
2761
3104
  return {
2762
- requirements: plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options),
3105
+ requirements: options.force === true ? planned : withoutFailedFields(remote, planned),
2763
3106
  queries
2764
3107
  };
2765
3108
  };
3109
+ /**
3110
+ * The plan without the fields whose last read failed. Like a failed query,
3111
+ * a failed field is shown rather than retried on its own, so a persistent
3112
+ * error is not asked again on every unrelated restart of the read entry. A
3113
+ * field leaving the request takes its window and its relation with it.
3114
+ */
3115
+ const withoutFailedFields = (remote, planned) => {
3116
+ if (Object.keys(remote.failures.fields).length === 0) return planned;
3117
+ return planned.flatMap((requirement) => {
3118
+ const fields = requirement.fields.filter((field) => !isFieldFailed(remote, requirement.entity, requirement.id, field));
3119
+ if (fields.length === requirement.fields.length) return [requirement];
3120
+ if (fields.length === 0) return [];
3121
+ const kept = new Set(fields);
3122
+ const only = (record) => record === void 0 ? void 0 : Object.fromEntries(Object.entries(record).filter(([field]) => kept.has(field)));
3123
+ const windows = only(requirement.windows);
3124
+ const relations = only(requirement.relations);
3125
+ return [{
3126
+ entity: requirement.entity,
3127
+ id: requirement.id,
3128
+ fields,
3129
+ ...windows === void 0 || Object.keys(windows).length === 0 ? {} : { windows },
3130
+ ...relations === void 0 || Object.keys(relations).length === 0 ? {} : { relations },
3131
+ ...requirement.live === void 0 ? {} : { live: requirement.live }
3132
+ }];
3133
+ });
3134
+ };
2766
3135
  /** The entity requirements a page's edges add under a selection. */
2767
3136
  const itemsOf = (edges, select) => edges.filter((edge) => edge.entity === select.entity).map((edge) => ({
2768
3137
  ...select,
@@ -2790,22 +3159,52 @@ const queryRequestOf = (query) => {
2790
3159
  * `refreshes` marks the page as answering the connection's refresh, so one
2791
3160
  * Message both merges it and clears `stale`.
2792
3161
  */
2793
- const pageMessage = (connection, result, refreshes = false) => ({
2794
- _tag: "ConnectionMerged",
2795
- connection,
2796
- page: {
2797
- edges: result.edges.map((edge) => ({
2798
- key: edge.key,
2799
- ref: {
2800
- entity: edge.entity,
2801
- id: edge.id
2802
- }
2803
- })),
2804
- start: result.start,
2805
- end: result.end
2806
- },
2807
- ...refreshes ? { refreshes } : {}
2808
- });
3162
+ /**
3163
+ * The page a window asked for, or the number of edges that came back instead.
3164
+ *
3165
+ * A window is a request the client made and the server answered, so more edges
3166
+ * than were asked for is a protocol disagreement rather than a windfall: the
3167
+ * client cannot tell which of them the window meant, the connection's
3168
+ * boundaries stop describing what it holds, and a `first: 25` that quietly
3169
+ * becomes a thousand is a memory event with no error attached to it.
3170
+ *
3171
+ * `undefined` when the page is within its window, or when no size was asked
3172
+ * for — `after`/`before` with no `first`/`last` bounds nothing.
3173
+ */
3174
+ const overrun = (window, edges) => {
3175
+ const asked = window.first ?? window.last;
3176
+ return asked !== void 0 && edges.length > asked ? {
3177
+ asked,
3178
+ got: edges.length
3179
+ } : void 0;
3180
+ };
3181
+ const pageMessage = (connection, result, refreshes = false, window = {}) => {
3182
+ const over = overrun(window, result.edges);
3183
+ if (over !== void 0) return {
3184
+ _tag: "QueryFailed",
3185
+ connection,
3186
+ error: {
3187
+ _tag: "RemoteProtocolError",
3188
+ message: `the server returned ${over.got} edges for a window of ${over.asked}`
3189
+ }
3190
+ };
3191
+ return {
3192
+ _tag: "ConnectionMerged",
3193
+ connection,
3194
+ page: {
3195
+ edges: result.edges.map((edge) => ({
3196
+ key: edge.key,
3197
+ ref: {
3198
+ entity: edge.entity,
3199
+ id: edge.id
3200
+ }
3201
+ })),
3202
+ start: result.start,
3203
+ end: result.end
3204
+ },
3205
+ ...refreshes ? { refreshes } : {}
3206
+ };
3207
+ };
2809
3208
  /**
2810
3209
  * The retention roots of some projections: their requirements, their
2811
3210
  * connections (each with the union of what the projections select of its
@@ -2853,7 +3252,7 @@ const queryMessage = (query) => Effect.gen(function* () {
2853
3252
  _tag: "QueryFailed",
2854
3253
  connection: query.identity,
2855
3254
  error: remoteError(result.failure)
2856
- } : pageMessage(query.identity, result.success, true);
3255
+ } : pageMessage(query.identity, result.success, true, query.window);
2857
3256
  });
2858
3257
  /** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
2859
3258
  const PlannedQuery = Schema.Struct({
@@ -2893,14 +3292,17 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
2893
3292
  }))
2894
3293
  };
2895
3294
  },
2896
- dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(requirements.length === 0 ? Stream.empty : Stream.fromIterable([toMessage({
3295
+ dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
2897
3296
  _tag: "ReadStarted",
2898
3297
  requests: requirements,
2899
3298
  refresh
2900
3299
  }), ...RemotePolicy.refreshes(policy) ? [toMessage({
2901
3300
  _tag: "RefreshStarted",
2902
3301
  requests: requirements
2903
- })] : []]), Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
3302
+ })] : []], ...queries.length === 0 ? [] : [toMessage({
3303
+ _tag: "QueryStarted",
3304
+ connections: queries.map((query) => query.identity)
3305
+ })]]), Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
2904
3306
  };
2905
3307
  };
2906
3308
  /** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
@@ -2930,7 +3332,8 @@ const liveEntry = (bound, requirementsOf, toMessage, options) => ({
2930
3332
  })), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
2931
3333
  _tag: "ReadFailed",
2932
3334
  requests: requirements,
2933
- error: remoteError(error)
3335
+ error: remoteError(error),
3336
+ stream: liveStreamKey(requirements)
2934
3337
  }))))
2935
3338
  });
2936
3339
  /** The projection an active Surface has for this Model, if it is active. */
@@ -3023,8 +3426,18 @@ const Remote = {
3023
3426
  value: decoded.success
3024
3427
  };
3025
3428
  });
3026
- if (present !== void 0) return present;
3027
- return isLoading(bound.store.get(root), selection.entity, id, relation.fields) ? { _tag: "Loading" } : { _tag: "Initial" };
3429
+ const remote = bound.store.get(root);
3430
+ const failure = failureOf(remote, selection.entity, id, relation);
3431
+ if (present !== void 0) return failure !== void 0 && (present._tag === "Ready" || present._tag === "Refreshing") ? {
3432
+ _tag: "Failed",
3433
+ error: failure,
3434
+ previous: present.value
3435
+ } : present;
3436
+ if (isLoading(remote, selection.entity, id, relation.fields)) return { _tag: "Loading" };
3437
+ return failure === void 0 ? { _tag: "Initial" } : {
3438
+ _tag: "Failed",
3439
+ error: failure
3440
+ };
3028
3441
  }
3029
3442
  });
3030
3443
  },
@@ -3083,6 +3496,9 @@ const Remote = {
3083
3496
  * again with `next`/`previous`. The read entries restart, so a read or query
3084
3497
  * sent before the refresh is not applied after it.
3085
3498
  *
3499
+ * It is also how a failed query is retried: a connection whose query failed
3500
+ * is not run again on its own, so its read stays `Failed` until this asks.
3501
+ *
3086
3502
  * The projection must be observed, which it is while it is on screen. Live
3087
3503
  * subscriptions are left as they are. For data nothing observes (SSR, tests),
3088
3504
  * use `Remote.prefetch` with `RemotePolicy.networkOnly`. Refreshing what is
@@ -3098,7 +3514,7 @@ const Remote = {
3098
3514
  return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
3099
3515
  });
3100
3516
  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);
3517
+ const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0 || connection.identity in remote.failures.connections).map((connection) => connection.identity);
3102
3518
  const marked = [...requirements.length === 0 ? [] : [{
3103
3519
  _tag: "RefreshStarted",
3104
3520
  requests: requirements
@@ -3175,7 +3591,7 @@ const Remote = {
3175
3591
  });
3176
3592
  }),
3177
3593
  /** The `RemoteMessage` that merges a query page into its connection. */
3178
- queryMessage: (ref, result) => pageMessage(ref.identity, result),
3594
+ queryMessage: (ref, result) => pageMessage(ref.identity, result, false, ref.window),
3179
3595
  /**
3180
3596
  * The edges a connection shows: its server-known region with pending and
3181
3597
  * confirmed overlays placed around it, minus removed edges and edges whose
@@ -3232,8 +3648,10 @@ const Remote = {
3232
3648
  observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => askedOf(surface.projection(params)), toMessage, options),
3233
3649
  /**
3234
3650
  * A Foldkit Subscription entry that consumes the live stream for a Surface's
3235
- * requirements, emitting a `LiveReceived` per event and a `ReadFailed` when
3236
- * the stream breaks (including `ResumeUnavailable`). The resume cursor is read
3651
+ * requirements, emitting a `LiveReceived` per event and a `ReadFailed`
3652
+ * carrying its `stream` when the stream breaks (including
3653
+ * `ResumeUnavailable`). That records a gap on the stream rather than a failed
3654
+ * read, since nothing was being read. The resume cursor is read
3237
3655
  * from `RemoteModel.live`, so the application tracks no cursor of its own.
3238
3656
  */
3239
3657
  live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
@@ -3242,7 +3660,48 @@ const brandEntries = (entries) => Subscription.make()(() => entries);
3242
3660
  /** The bound domain: the descriptor, the binding, and the operations over them. */
3243
3661
  const bindDomain = (definition, store) => {
3244
3662
  const bound = bindRemote(definition, store);
3245
- const reduce = (model, message) => store.set(model, updateRemote(store.get(model), message));
3663
+ /**
3664
+ * What to do with a row a live event says was inserted into a connection.
3665
+ *
3666
+ * Resolved here rather than in the pure reducer because this is the only
3667
+ * place with both the Model and the registry, and two separate things needed
3668
+ * one or the other.
3669
+ *
3670
+ * **The declared policy reaches the decision.** `Query.connection(E, { live })`
3671
+ * has always been typed, documented, carried on the descriptor and encoded in
3672
+ * the Message schema — and nothing ever put it on a Message, so every live
3673
+ * insert took the default whatever an application asked for. It does now.
3674
+ *
3675
+ * **A row the client can judge does not need a policy.** The policy exists
3676
+ * because nothing could tell whether an inserted row belonged to the query.
3677
+ * Where the body says it does not, `ignore` is not a guess. Where the body
3678
+ * says it does, or the client cannot tell — a row it never fetched, or holds
3679
+ * without a field the body reads — the declared policy is the answer, exactly
3680
+ * as it was meant to be.
3681
+ *
3682
+ * Only membership is decided here. *Where* a row sorts needs text collation,
3683
+ * which is the backend's, so the position the event carries stands.
3684
+ */
3685
+ const livePolicyFor = (model, event) => {
3686
+ if (event._tag !== "ConnectionInsert") return void 0;
3687
+ const identity = event.connection;
3688
+ const descriptor = definition.registry.queries.get(identity.slice(0, identity.indexOf("\0")));
3689
+ const declared = (descriptor?.Result)?.live;
3690
+ if (descriptor?.body === void 0) return declared;
3691
+ const encoded = JSON.parse(identity.slice(identity.indexOf("\0") + 1));
3692
+ return belongsEncoded(storeOf(bound, model), descriptor, encoded, entityKey(event.edge.ref.entity, event.edge.ref.id)) === "no" ? {
3693
+ prepend: "ignore",
3694
+ append: "ignore"
3695
+ } : declared;
3696
+ };
3697
+ const reduce = (model, message) => {
3698
+ const live = message._tag === "LiveReceived" ? message : void 0;
3699
+ const resolved = live === void 0 || live.policy !== void 0 ? message : {
3700
+ ...live,
3701
+ policy: livePolicyFor(model, live.event)
3702
+ };
3703
+ return store.set(model, updateRemote(store.get(model), resolved));
3704
+ };
3246
3705
  const domain = {
3247
3706
  ...definition,
3248
3707
  ...bound,
@@ -3291,7 +3750,7 @@ const bindDomain = (definition, store) => {
3291
3750
  let current = model;
3292
3751
  for (const query of planAsked(store.get(current), askedOf(projection), planOptions).queries) {
3293
3752
  const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
3294
- current = reduce(current, pageMessage(query.identity, page, true));
3753
+ current = reduce(current, pageMessage(query.identity, page, true, query.window));
3295
3754
  }
3296
3755
  const requirements = planAsked(store.get(current), askedOf(projection), planOptions).requirements;
3297
3756
  if (requirements.length === 0) return current;
@@ -3321,6 +3780,58 @@ const bindDomain = (definition, store) => {
3321
3780
  };
3322
3781
  const relation = relationOf(select);
3323
3782
  const relationKey = stableStringify(relation);
3783
+ const failedItem = (remote, connection) => {
3784
+ if (Object.keys(remote.failures.fields).length === 0) return void 0;
3785
+ for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
3786
+ if (edge.ref.entity !== relation.entity) continue;
3787
+ const error = failureOf(remote, edge.ref.entity, edge.ref.id, relation);
3788
+ if (error !== void 0) return {
3789
+ _tag: "Failed",
3790
+ error
3791
+ };
3792
+ }
3793
+ };
3794
+ const loadingItem = (remote, connection) => {
3795
+ if (remote.loading.size === 0) return void 0;
3796
+ for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
3797
+ if (edge.ref.entity !== relation.entity) continue;
3798
+ if (isLoading(remote, edge.ref.entity, edge.ref.id, relation.fields)) return { _tag: "Loading" };
3799
+ }
3800
+ };
3801
+ const readPage = (remote, connection) => {
3802
+ const visible = visibleStoreOf(remote.entities, remote.optimistic);
3803
+ return memoRead(visible, connection, `${ref.identity}\u0000${relationKey}`, () => {
3804
+ const items = [];
3805
+ let refreshing = connection.stale;
3806
+ const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
3807
+ for (const edge of edges) {
3808
+ const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
3809
+ if (assembled === void 0) return void 0;
3810
+ const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
3811
+ if (Result.isFailure(decoded)) return {
3812
+ _tag: "Failed",
3813
+ error: {
3814
+ _tag: "DecodeError",
3815
+ message: decoded.failure.message
3816
+ }
3817
+ };
3818
+ refreshing ||= assembled.refreshing;
3819
+ items.push(decoded.success);
3820
+ }
3821
+ const page = {
3822
+ items,
3823
+ hasNext: hasNext(connection),
3824
+ hasPrevious: hasPrevious(connection)
3825
+ };
3826
+ return refreshing ? {
3827
+ _tag: "Refreshing",
3828
+ value: page
3829
+ } : {
3830
+ _tag: "Ready",
3831
+ value: page
3832
+ };
3833
+ });
3834
+ };
3324
3835
  const requirement = {
3325
3836
  identity: ref.identity,
3326
3837
  window: ref.window,
@@ -3332,42 +3843,41 @@ const bindDomain = (definition, store) => {
3332
3843
  dependencies: [],
3333
3844
  metadata: RemoteConnections.of(requirement),
3334
3845
  ref,
3846
+ selection: select,
3335
3847
  read: (root) => {
3336
3848
  const remote = store.get(root);
3337
3849
  const connection = remote.connections[ref.identity];
3338
- if (connection === void 0 || connection.stale && connection.segments.length === 0) return { _tag: "Initial" };
3339
- const visible = visibleStoreOf(remote.entities, remote.optimistic);
3340
- return memoRead(visible, connection, `${ref.identity}\u0000${relationKey}`, () => {
3341
- const items = [];
3342
- let refreshing = connection.stale;
3343
- const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
3344
- for (const edge of edges) {
3345
- const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
3346
- if (assembled === void 0) return { _tag: "Initial" };
3347
- const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
3348
- if (Result.isFailure(decoded)) return {
3349
- _tag: "Failed",
3350
- error: {
3351
- _tag: "DecodeError",
3352
- message: decoded.failure.message
3353
- }
3354
- };
3355
- refreshing ||= assembled.refreshing;
3356
- items.push(decoded.success);
3357
- }
3358
- const page = {
3359
- items,
3360
- hasNext: hasNext(connection),
3361
- hasPrevious: hasPrevious(connection)
3850
+ const failure = remote.failures.connections[ref.identity];
3851
+ if (connection === void 0 || connection.stale && connection.segments.length === 0) {
3852
+ if (isQueryLoading(remote, ref.identity)) return { _tag: "Loading" };
3853
+ return failure === void 0 ? { _tag: "Initial" } : {
3854
+ _tag: "Failed",
3855
+ error: failure
3856
+ };
3857
+ }
3858
+ const read = readPage(remote, connection) ?? failedItem(remote, connection) ?? loadingItem(remote, connection) ?? { _tag: "Initial" };
3859
+ if (failure === void 0) {
3860
+ if (read._tag !== "Ready" && read._tag !== "Refreshing") return read;
3861
+ const item = failedItem(remote, connection);
3862
+ return item === void 0 ? read : {
3863
+ _tag: "Failed",
3864
+ error: item.error,
3865
+ previous: read.value
3866
+ };
3867
+ }
3868
+ switch (read._tag) {
3869
+ case "Ready":
3870
+ case "Refreshing": return {
3871
+ _tag: "Failed",
3872
+ error: failure,
3873
+ previous: read.value
3362
3874
  };
3363
- return refreshing ? {
3364
- _tag: "Refreshing",
3365
- value: page
3366
- } : {
3367
- _tag: "Ready",
3368
- value: page
3875
+ case "Failed": return read;
3876
+ default: return {
3877
+ _tag: "Failed",
3878
+ error: failure
3369
3879
  };
3370
- });
3880
+ }
3371
3881
  }
3372
3882
  };
3373
3883
  },
@@ -3392,6 +3902,65 @@ const bindDomain = (definition, store) => {
3392
3902
  }
3393
3903
  } : void 0;
3394
3904
  },
3905
+ explain: (model, projection, options) => {
3906
+ const { ref } = projection;
3907
+ const reading = Object.values(options?.surfaces ?? {}).filter((active) => {
3908
+ const active_ = active.projectionOf(model);
3909
+ return active_ !== void 0 && connectionsOf(active_).some((connection) => connection.identity === ref.identity);
3910
+ });
3911
+ const body = definition.registry.queries.get(ref.query)?.body;
3912
+ const [connection] = RemoteConnections.get(projection.metadata);
3913
+ return {
3914
+ domain: bound.contract.name,
3915
+ query: ref.query,
3916
+ input: Schema.encodeSync(ref.Input)(ref.input),
3917
+ identity: ref.identity,
3918
+ window: ref.window,
3919
+ select: connection.select,
3920
+ ...body === void 0 ? {} : {
3921
+ body: Query$1.show(body),
3922
+ dependencies: Query$1.dependencies(body)
3923
+ },
3924
+ state: projection.read(model)._tag,
3925
+ ...options?.surfaces === void 0 ? {} : {
3926
+ surfaces: reading.map((active) => active.name),
3927
+ activation: reading.flatMap((active) => active.activation === void 0 ? [] : [{
3928
+ surface: active.name,
3929
+ ...active.activation
3930
+ }])
3931
+ }
3932
+ };
3933
+ },
3934
+ filtered: (model, over, by, input) => {
3935
+ assertRegistered(bound, "Query", definition.registry.queries, by.name);
3936
+ const filters = by.body?.entity.name;
3937
+ if (filters !== void 0 && filters !== over.selection.entity) throw new Error(`Remote: query "${by.name}" is over "${filters}", but the list is of "${over.selection.entity}", so it cannot filter it`);
3938
+ const remote = store.get(model);
3939
+ const connection = remote.connections[over.ref.identity];
3940
+ const visible = visibleStoreOf(remote.entities, remote.optimistic);
3941
+ const among = (connection === void 0 ? [] : visibleItems(connection, over.ref.identity, remote.optimistic.overlays, remote.entities)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
3942
+ const judged = matching(visible, by, input, { among });
3943
+ const relation = relationOf(over.selection);
3944
+ const items = [];
3945
+ let assembledAll = true;
3946
+ for (const key of judged.matched) {
3947
+ const assembled = assemble(visible, key, relation);
3948
+ if (assembled === void 0) {
3949
+ assembledAll = false;
3950
+ continue;
3951
+ }
3952
+ const decoded = Schema.decodeUnknownResult(over.selection.schema)(assembled.values);
3953
+ if (Result.isFailure(decoded)) {
3954
+ assembledAll = false;
3955
+ continue;
3956
+ }
3957
+ items.push(decoded.success);
3958
+ }
3959
+ return {
3960
+ items,
3961
+ complete: judged.skipped.length === 0 && assembledAll && connection !== void 0 && !isGapped(connection) && !hasNext(connection) && !hasPrevious(connection)
3962
+ };
3963
+ },
3395
3964
  refresh: (model, target) => Remote.refresh(bound, model, target),
3396
3965
  overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
3397
3966
  _tag: "OverlayShown",
@@ -3418,7 +3987,7 @@ const bindDomain = (definition, store) => {
3418
3987
  connection: ref.identity,
3419
3988
  error: remoteError(error)
3420
3989
  }),
3421
- onSuccess: (page) => pageMessage(ref.identity, page)
3990
+ onSuccess: (page) => pageMessage(ref.identity, page, false, ref.window)
3422
3991
  }))
3423
3992
  }),
3424
3993
  mutate: (model, mutation, input, options = {}) => {
@@ -3485,6 +4054,6 @@ const pageSize = (window, key) => {
3485
4054
  return size === void 0 ? {} : { [key]: size };
3486
4055
  };
3487
4056
  //#endregion
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 };
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 };
3489
4058
 
3490
4059
  //# sourceMappingURL=index.mjs.map