foldkit-remote 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +312 -83
- package/dist/index.d.mts +433 -31
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +846 -138
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
|
|
2
2
|
import * as Domain from "foldkit-entity";
|
|
3
|
-
import { Entity as Entity$1, 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}
|
|
511
|
+
identity: `${name}${stableStringify(encode(input))}`
|
|
502
512
|
})
|
|
503
513
|
};
|
|
504
514
|
},
|
|
@@ -1334,12 +1344,6 @@ const assembleRelation = (store, value, relation) => {
|
|
|
1334
1344
|
* Entities: the descriptor an application declares, refs, ref pages, and
|
|
1335
1345
|
* patches, with the codecs that carry refs on the wire.
|
|
1336
1346
|
*/
|
|
1337
|
-
/**
|
|
1338
|
-
* Relations are **references**, never inline target schemas: a ref cannot
|
|
1339
|
-
* reconstruct a full entity, and dereferencing is a store concern. Because the
|
|
1340
|
-
* target schema is not inlined, recursive relations cannot arise through the
|
|
1341
|
-
* schema graph.
|
|
1342
|
-
*/
|
|
1343
1347
|
const refCodec = (entity) => Schema.Struct({
|
|
1344
1348
|
entity: Schema.String,
|
|
1345
1349
|
id: Schema.String
|
|
@@ -1424,6 +1428,118 @@ const Entity = {
|
|
|
1424
1428
|
})
|
|
1425
1429
|
};
|
|
1426
1430
|
//#endregion
|
|
1431
|
+
//#region src/matching.ts
|
|
1432
|
+
/**
|
|
1433
|
+
* Running a query body against the rows the client already holds.
|
|
1434
|
+
*
|
|
1435
|
+
* local-execution-DESIGN phase 3. A connection's rows are edges the server
|
|
1436
|
+
* delivered, and until now nothing on the client ever evaluated a body — which
|
|
1437
|
+
* is why an optimistic insert has to guess between `prepend` and `append`, and
|
|
1438
|
+
* why `LivePolicy` exists to be told in advance what to do with a row nobody
|
|
1439
|
+
* could judge.
|
|
1440
|
+
*
|
|
1441
|
+
* This judges them. It is pure, it performs no I/O, and it reuses the reference
|
|
1442
|
+
* interpreter in `foldkit-entity` rather than reimplementing the semantics —
|
|
1443
|
+
* so a body means here exactly what it means on the server, checked by the same
|
|
1444
|
+
* conformance suite.
|
|
1445
|
+
*
|
|
1446
|
+
* **It answers "which of the rows I hold match", never "which rows match".**
|
|
1447
|
+
* Those are different questions and the difference is not recoverable from the
|
|
1448
|
+
* result, so it is in the shape: `matched` is what satisfied the body, and
|
|
1449
|
+
* `skipped` is what was held but could not be judged. A caller that knows it
|
|
1450
|
+
* holds the whole population — a connection terminal at both ends — is the one
|
|
1451
|
+
* that can turn this into a complete answer. Nothing here can.
|
|
1452
|
+
*
|
|
1453
|
+
* **It is not authorization.** On the server a compiled `where` is conjoined
|
|
1454
|
+
* with the binding's `visible` rule. There is no `visible` here, and this is
|
|
1455
|
+
* safe only because the client holds only rows the server already released to
|
|
1456
|
+
* it. A local filter is not an access decision.
|
|
1457
|
+
*/
|
|
1458
|
+
/**
|
|
1459
|
+
* The fields of the body's own Entity that a row must have for the body to be
|
|
1460
|
+
* decidable about it — its predicates and its ordering terms.
|
|
1461
|
+
*
|
|
1462
|
+
* `id` is never among them: it is the key, so it is known for every row in the
|
|
1463
|
+
* store whether or not anything fetched it as a field.
|
|
1464
|
+
*/
|
|
1465
|
+
const decidableOn = (body) => Query$1.dependencies(body).fields.filter((field) => field.entity === body.entity.name && field.key !== "id").map((field) => field.key);
|
|
1466
|
+
/**
|
|
1467
|
+
* The rows of `descriptor`'s Entity that the store holds and the body matches.
|
|
1468
|
+
*
|
|
1469
|
+
* The input is given **decoded**, as an application writes it, and encoded here
|
|
1470
|
+
* through the descriptor's own `Input` codec. That is deliberate and is the
|
|
1471
|
+
* whole reason this takes a descriptor rather than a bare body: a store holds
|
|
1472
|
+
* wire values, so an input has to be in that space to be compared against them,
|
|
1473
|
+
* and handing an interpreter a decoded value is a mistake **no runtime error
|
|
1474
|
+
* catches** — the reference interpreter answers "no rows", which reads exactly
|
|
1475
|
+
* like a correct answer. Doing it here means a caller cannot get it wrong.
|
|
1476
|
+
*
|
|
1477
|
+
* Literals in the body are deliberately *not* encoded. A literal written in
|
|
1478
|
+
* domain terms is already broken against the server, which binds it to a column
|
|
1479
|
+
* the same way; accepting it here would make the client quietly more permissive
|
|
1480
|
+
* than the thing it has to agree with.
|
|
1481
|
+
*/
|
|
1482
|
+
const matching = (store, descriptor, input, options = {}) => matchingEncoded(store, descriptor, Schema.encodeSync(descriptor.Input)(input), options);
|
|
1483
|
+
/**
|
|
1484
|
+
* As `matching`, for the callers in this module that already hold the input
|
|
1485
|
+
* **encoded**. Not exported: `matching` is the way in, and it cannot be given
|
|
1486
|
+
* the wrong space.
|
|
1487
|
+
*/
|
|
1488
|
+
const matchingEncoded = (store, descriptor, encoded, options = {}) => {
|
|
1489
|
+
const body = descriptor.body;
|
|
1490
|
+
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.`);
|
|
1491
|
+
const prefix = `${body.entity.name}:`;
|
|
1492
|
+
const needed = decidableOn(body);
|
|
1493
|
+
const candidates = options.among ?? Object.keys(store).filter((key) => key.startsWith(prefix));
|
|
1494
|
+
const rows = [];
|
|
1495
|
+
const skipped = [];
|
|
1496
|
+
for (const key of candidates) {
|
|
1497
|
+
if (!key.startsWith(prefix)) continue;
|
|
1498
|
+
const entry = store[key];
|
|
1499
|
+
if (entry === void 0) {
|
|
1500
|
+
skipped.push(key);
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
if (entry.tombstone) continue;
|
|
1504
|
+
if (needed.some((field) => !entry.present.has(field))) {
|
|
1505
|
+
skipped.push(key);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
rows.push("id" in entry.values ? {
|
|
1509
|
+
...entry.values,
|
|
1510
|
+
__key: key
|
|
1511
|
+
} : {
|
|
1512
|
+
...entry.values,
|
|
1513
|
+
id: key.slice(prefix.length),
|
|
1514
|
+
__key: key
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
matched: evaluate(body, encoded, rows).map((row) => row.__key),
|
|
1519
|
+
skipped
|
|
1520
|
+
};
|
|
1521
|
+
};
|
|
1522
|
+
/**
|
|
1523
|
+
* Takes the input **encoded**, because its one caller holds it that way: a
|
|
1524
|
+
* connection identity carries the canonical encoded input, and making it decode
|
|
1525
|
+
* only for this to encode again would be a round trip that can fail.
|
|
1526
|
+
*
|
|
1527
|
+
* A decoded-input sibling was written first and deleted: nothing called it. The
|
|
1528
|
+
* asymmetry with `matching`, which takes a decoded input, is the shape of the
|
|
1529
|
+
* two real callers rather than an oversight.
|
|
1530
|
+
*/
|
|
1531
|
+
const belongsEncoded = (store, descriptor, encoded, key) => {
|
|
1532
|
+
const entry = store[key];
|
|
1533
|
+
if (entry === void 0) return "unknown";
|
|
1534
|
+
if (entry.tombstone) return "no";
|
|
1535
|
+
const body = descriptor.body;
|
|
1536
|
+
if (body !== void 0 && decidableOn(body).some((field) => entry.stale.has(field))) return "unknown";
|
|
1537
|
+
const judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
|
|
1538
|
+
if (judged.matched.includes(key)) return "yes";
|
|
1539
|
+
if (judged.skipped.includes(key)) return "unknown";
|
|
1540
|
+
return "no";
|
|
1541
|
+
};
|
|
1542
|
+
//#endregion
|
|
1427
1543
|
//#region src/inspect.ts
|
|
1428
1544
|
const inspectEntry = (key, entry) => ({
|
|
1429
1545
|
key,
|
|
@@ -1440,6 +1556,7 @@ const inspectRemote = (model) => ({
|
|
|
1440
1556
|
live: Object.keys(model.live),
|
|
1441
1557
|
gaps: [...model.gaps],
|
|
1442
1558
|
loading: [...model.loading],
|
|
1559
|
+
failures: model.failures,
|
|
1443
1560
|
mutations: {
|
|
1444
1561
|
pending: [...model.mutations.pending],
|
|
1445
1562
|
failed: [...model.mutations.failed],
|
|
@@ -1617,7 +1734,6 @@ const visibleItems = (connection, connectionId, overlays, store) => {
|
|
|
1617
1734
|
//#region src/live.ts
|
|
1618
1735
|
const emptyLiveState = {
|
|
1619
1736
|
cursor: 0,
|
|
1620
|
-
stale: /* @__PURE__ */ new Set(),
|
|
1621
1737
|
boundary: {}
|
|
1622
1738
|
};
|
|
1623
1739
|
/**
|
|
@@ -1651,10 +1767,6 @@ const recordBoundary = (state, connection, position) => {
|
|
|
1651
1767
|
}
|
|
1652
1768
|
};
|
|
1653
1769
|
};
|
|
1654
|
-
const invalidateConnection = (state, connection) => ({
|
|
1655
|
-
...state,
|
|
1656
|
-
stale: /* @__PURE__ */ new Set([...state.stale, connection])
|
|
1657
|
-
});
|
|
1658
1770
|
/**
|
|
1659
1771
|
* A subscriber is woken only if the event changed a field it selects. A
|
|
1660
1772
|
* change to an unselected field is skipped entirely.
|
|
@@ -1707,9 +1819,10 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
|
|
|
1707
1819
|
outcome
|
|
1708
1820
|
};
|
|
1709
1821
|
case "invalidate": return {
|
|
1710
|
-
state:
|
|
1822
|
+
state: advance(state, event.cursor),
|
|
1711
1823
|
optimistic,
|
|
1712
|
-
outcome
|
|
1824
|
+
outcome,
|
|
1825
|
+
invalidated: event.connection
|
|
1713
1826
|
};
|
|
1714
1827
|
case "ignore": return {
|
|
1715
1828
|
state: advance(state, event.cursor),
|
|
@@ -1728,27 +1841,13 @@ const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
|
|
|
1728
1841
|
outcome
|
|
1729
1842
|
};
|
|
1730
1843
|
case "ConnectionInvalidate": return {
|
|
1731
|
-
state:
|
|
1844
|
+
state: advance(state, event.cursor),
|
|
1845
|
+
invalidated: event.connection,
|
|
1732
1846
|
optimistic,
|
|
1733
1847
|
outcome
|
|
1734
1848
|
};
|
|
1735
1849
|
}
|
|
1736
1850
|
};
|
|
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
1851
|
/** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
|
|
1753
1852
|
const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
|
|
1754
1853
|
/** `hasNext` accounting for edges recorded outside the loaded boundary. */
|
|
@@ -1890,17 +1989,33 @@ const gc = (state, roots) => {
|
|
|
1890
1989
|
* application's choice.
|
|
1891
1990
|
*/
|
|
1892
1991
|
/** Bump when the serialized shape changes; a mismatch discards the cache. */
|
|
1893
|
-
const REMOTE_CACHE_VERSION =
|
|
1992
|
+
const REMOTE_CACHE_VERSION = 4;
|
|
1993
|
+
/**
|
|
1994
|
+
* A snapshot of a Model, keeping the entity cache and only the connections
|
|
1995
|
+
* named.
|
|
1996
|
+
*
|
|
1997
|
+
* Naming none — the default — is exactly what Remote did before connections
|
|
1998
|
+
* could be kept at all: a disposable, server-derived cache.
|
|
1999
|
+
*/
|
|
2000
|
+
const snapshotOf = (model, options = {}) => ({
|
|
2001
|
+
entities: model.entities,
|
|
2002
|
+
connections: Object.fromEntries((options.connections ?? []).flatMap((connection) => {
|
|
2003
|
+
const identity = connectionIdentity(connection);
|
|
2004
|
+
const held = model.connections[identity];
|
|
2005
|
+
return held === void 0 ? [] : [[identity, held.segments.map((segment) => segment.edges)]];
|
|
2006
|
+
}))
|
|
2007
|
+
});
|
|
1894
2008
|
const sorted = (values) => [...values].sort();
|
|
1895
2009
|
/**
|
|
1896
2010
|
* The snapshot's data shape. `stableStringify` sorts object keys, and the
|
|
1897
2011
|
* field sets are sorted here, so equal stores give byte-equal snapshots
|
|
1898
2012
|
* whatever order they were built in.
|
|
1899
2013
|
*/
|
|
1900
|
-
const serializeStore = (
|
|
1901
|
-
version:
|
|
2014
|
+
const serializeStore = (snapshot, scope) => ({
|
|
2015
|
+
version: 4,
|
|
1902
2016
|
scope: scope ?? null,
|
|
1903
|
-
|
|
2017
|
+
connections: snapshot.connections,
|
|
2018
|
+
entities: Object.fromEntries(Object.entries(snapshot.entities).map(([key, entry]) => [key, {
|
|
1904
2019
|
values: entry.values,
|
|
1905
2020
|
present: sorted(entry.present),
|
|
1906
2021
|
stale: sorted(entry.stale),
|
|
@@ -1930,9 +2045,30 @@ const parseEntry = (value) => {
|
|
|
1930
2045
|
windows: entry.windows
|
|
1931
2046
|
};
|
|
1932
2047
|
};
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2048
|
+
/** Throws on a malformed edge so the whole snapshot is discarded. */
|
|
2049
|
+
const parseEdges = (value) => {
|
|
2050
|
+
if (!Array.isArray(value)) throw new Error("segment is not an array of edges");
|
|
2051
|
+
return value.map((edge) => {
|
|
2052
|
+
const candidate = edge;
|
|
2053
|
+
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 } }");
|
|
2054
|
+
return {
|
|
2055
|
+
key: candidate.key,
|
|
2056
|
+
ref: {
|
|
2057
|
+
entity: candidate.ref.entity,
|
|
2058
|
+
id: candidate.ref.id
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
});
|
|
2062
|
+
};
|
|
2063
|
+
const deserializeStore = (serialized) => ({
|
|
2064
|
+
entities: Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)])),
|
|
2065
|
+
connections: Object.fromEntries(Object.entries(serialized.connections ?? {}).map(([key, segments]) => {
|
|
2066
|
+
if (!Array.isArray(segments)) throw new Error("connection is not an array of segments");
|
|
2067
|
+
return [key, segments.map(parseEdges)];
|
|
2068
|
+
}))
|
|
2069
|
+
});
|
|
2070
|
+
function dehydrate(snapshot, options = {}) {
|
|
2071
|
+
const text = stableStringify(serializeStore(snapshot, options.scope));
|
|
1936
2072
|
return exceeds(text, options.maxBytes) ? void 0 : text;
|
|
1937
2073
|
}
|
|
1938
2074
|
/**
|
|
@@ -1962,7 +2098,7 @@ const hydrate = (raw, options = {}) => {
|
|
|
1962
2098
|
}
|
|
1963
2099
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
1964
2100
|
const candidate = parsed;
|
|
1965
|
-
if (candidate.version !==
|
|
2101
|
+
if (candidate.version !== 4) return void 0;
|
|
1966
2102
|
if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
|
|
1967
2103
|
if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
|
|
1968
2104
|
if (Array.isArray(candidate.entities)) return void 0;
|
|
@@ -1985,37 +2121,49 @@ const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
|
|
|
1985
2121
|
...snapshot,
|
|
1986
2122
|
...current
|
|
1987
2123
|
};
|
|
2124
|
+
/** An empty snapshot: the cache as a fresh session has it. */
|
|
2125
|
+
const emptySnapshot = {
|
|
2126
|
+
entities: emptyStore,
|
|
2127
|
+
connections: {}
|
|
2128
|
+
};
|
|
1988
2129
|
const RemotePersistence = {
|
|
1989
2130
|
/** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
|
|
1990
2131
|
dehydrate,
|
|
1991
|
-
/**
|
|
2132
|
+
/** What a snapshot text holds, or `undefined` when it is refused. */
|
|
1992
2133
|
hydrate,
|
|
1993
|
-
/**
|
|
2134
|
+
/**
|
|
2135
|
+
* What to keep of a Model, naming the connections that should survive a
|
|
2136
|
+
* reload. Naming none is the default and is what Remote always did.
|
|
2137
|
+
*/
|
|
2138
|
+
snapshotOf,
|
|
2139
|
+
/** A snapshot's entities brought into a store by policy. */
|
|
1994
2140
|
mergeStores,
|
|
2141
|
+
/** The cache as a fresh session has it; what a refused `restore` yields. */
|
|
2142
|
+
emptySnapshot,
|
|
1995
2143
|
/**
|
|
1996
2144
|
* Writes the snapshot under `key`, or removes the key when the store would
|
|
1997
2145
|
* exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
|
|
1998
2146
|
* store it no longer describes.
|
|
1999
2147
|
*/
|
|
2000
|
-
save: (
|
|
2148
|
+
save: (snapshot, options) => Effect.gen(function* () {
|
|
2001
2149
|
const kv = yield* KeyValueStore.KeyValueStore;
|
|
2002
|
-
const text = dehydrate(
|
|
2150
|
+
const text = dehydrate(snapshot, options);
|
|
2003
2151
|
if (text === void 0) yield* kv.remove(options.key);
|
|
2004
2152
|
else yield* kv.set(options.key, text);
|
|
2005
2153
|
}),
|
|
2006
2154
|
/**
|
|
2007
|
-
* Reads the cache. A missing key yields `
|
|
2008
|
-
* refuses yields `
|
|
2009
|
-
* clean and the planner refetches.
|
|
2155
|
+
* Reads the cache. A missing key yields `emptySnapshot`; anything `hydrate`
|
|
2156
|
+
* refuses yields `emptySnapshot` and removes the key, so the next `restore`
|
|
2157
|
+
* is clean and the planner refetches.
|
|
2010
2158
|
*/
|
|
2011
2159
|
restore: (options) => Effect.gen(function* () {
|
|
2012
2160
|
const kv = yield* KeyValueStore.KeyValueStore;
|
|
2013
2161
|
const raw = yield* kv.get(options.key);
|
|
2014
|
-
if (raw === void 0) return
|
|
2162
|
+
if (raw === void 0) return emptySnapshot;
|
|
2015
2163
|
const restored = hydrate(raw, options);
|
|
2016
2164
|
if (restored === void 0) {
|
|
2017
2165
|
yield* kv.remove(options.key);
|
|
2018
|
-
return
|
|
2166
|
+
return emptySnapshot;
|
|
2019
2167
|
}
|
|
2020
2168
|
return restored;
|
|
2021
2169
|
})
|
|
@@ -2138,6 +2286,10 @@ const RemoteData = {
|
|
|
2138
2286
|
* optimistic layers an application keeps in its Model, the Messages that
|
|
2139
2287
|
* change it, and the pure reducer over them.
|
|
2140
2288
|
*/
|
|
2289
|
+
const noFailures = {
|
|
2290
|
+
connections: {},
|
|
2291
|
+
fields: {}
|
|
2292
|
+
};
|
|
2141
2293
|
const emptyRefresh = {
|
|
2142
2294
|
generation: 0,
|
|
2143
2295
|
requested: /* @__PURE__ */ new Map(),
|
|
@@ -2151,7 +2303,8 @@ const initialRemoteModel = {
|
|
|
2151
2303
|
mutations: emptyMutationState,
|
|
2152
2304
|
gaps: /* @__PURE__ */ new Set(),
|
|
2153
2305
|
loading: /* @__PURE__ */ new Set(),
|
|
2154
|
-
refresh: emptyRefresh
|
|
2306
|
+
refresh: emptyRefresh,
|
|
2307
|
+
failures: noFailures
|
|
2155
2308
|
};
|
|
2156
2309
|
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
2157
2310
|
const runtimeSchema = Schema.Unknown;
|
|
@@ -2163,7 +2316,11 @@ const remoteModelSchema = () => Schema.Struct({
|
|
|
2163
2316
|
mutations: runtimeSchema,
|
|
2164
2317
|
gaps: runtimeSchema,
|
|
2165
2318
|
loading: runtimeSchema,
|
|
2166
|
-
refresh: runtimeSchema
|
|
2319
|
+
refresh: runtimeSchema,
|
|
2320
|
+
failures: Schema.Struct({
|
|
2321
|
+
connections: Schema.Record(Schema.String, remoteErrorSchema),
|
|
2322
|
+
fields: Schema.Record(Schema.String, remoteErrorSchema)
|
|
2323
|
+
})
|
|
2167
2324
|
});
|
|
2168
2325
|
const retentionRootsSchema = Schema.Struct({
|
|
2169
2326
|
requirements: Schema.Array(ReadRequest),
|
|
@@ -2184,15 +2341,18 @@ const remoteMessageCases = {
|
|
|
2184
2341
|
},
|
|
2185
2342
|
ReadFailed: {
|
|
2186
2343
|
requests: Schema.Array(ReadRequest),
|
|
2187
|
-
error: remoteErrorSchema
|
|
2344
|
+
error: remoteErrorSchema,
|
|
2345
|
+
stream: Schema.optional(Schema.String)
|
|
2188
2346
|
},
|
|
2189
2347
|
RefreshStarted: { requests: Schema.Array(ReadRequest) },
|
|
2190
2348
|
ReadStarted: {
|
|
2191
2349
|
requests: Schema.Array(ReadRequest),
|
|
2192
2350
|
refresh: Schema.optional(Schema.Number)
|
|
2193
2351
|
},
|
|
2352
|
+
QueryStarted: { connections: Schema.Array(Schema.String) },
|
|
2194
2353
|
RetentionChanged: { roots: retentionRootsSchema },
|
|
2195
2354
|
Hydrated: {
|
|
2355
|
+
connections: Schema.optional(Schema.Unknown),
|
|
2196
2356
|
entities: runtimeSchema,
|
|
2197
2357
|
merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
|
|
2198
2358
|
},
|
|
@@ -2277,6 +2437,26 @@ const withoutLoading = (loading, requests) => {
|
|
|
2277
2437
|
*/
|
|
2278
2438
|
const connectionPrefix = "\0connection\0";
|
|
2279
2439
|
const connectionMark = (identity) => `${connectionPrefix}${identity}`;
|
|
2440
|
+
/** Whether a query for this connection is in flight. */
|
|
2441
|
+
const isQueryLoading = (model, identity) => model.loading.size !== 0 && model.loading.has(connectionMark(identity));
|
|
2442
|
+
const withoutQueryLoading = (loading, identity) => {
|
|
2443
|
+
if (!loading.has(connectionMark(identity))) return loading;
|
|
2444
|
+
const next = new Set(loading);
|
|
2445
|
+
next.delete(connectionMark(identity));
|
|
2446
|
+
return next;
|
|
2447
|
+
};
|
|
2448
|
+
/**
|
|
2449
|
+
* The in-flight marks of connections the roots still name. A query the entry
|
|
2450
|
+
* stopped waiting for — it was restarted or deactivated before an answer came —
|
|
2451
|
+
* leaves its mark behind, and a connection released with that mark would read
|
|
2452
|
+
* `Loading` forever the next time something asked for it before sending.
|
|
2453
|
+
*/
|
|
2454
|
+
const prunedLoading = (loading, roots, reached) => {
|
|
2455
|
+
if (loading.size === 0) return loading;
|
|
2456
|
+
const named = new Set(roots.connections.map((root) => connectionMark(root.identity)));
|
|
2457
|
+
const kept = [...loading].filter((mark) => mark.startsWith(connectionPrefix) ? named.has(mark) : reached().has(markEntity(mark)));
|
|
2458
|
+
return kept.length === loading.size ? loading : new Set(kept);
|
|
2459
|
+
};
|
|
2280
2460
|
/**
|
|
2281
2461
|
* Drops the refresh marks of entities and connections that were collected.
|
|
2282
2462
|
* Nothing observes a collected entity — the retention roots are the active
|
|
@@ -2391,31 +2571,68 @@ const updateRemote = (model, message) => {
|
|
|
2391
2571
|
case "ReadReceived": return {
|
|
2392
2572
|
...model,
|
|
2393
2573
|
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)
|
|
2574
|
+
loading: withoutLoading(model.loading, message.requests),
|
|
2575
|
+
failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
|
|
2400
2576
|
};
|
|
2577
|
+
case "ReadFailed":
|
|
2578
|
+
if (message.stream !== void 0) return markGap(model, message.stream);
|
|
2579
|
+
return {
|
|
2580
|
+
...model,
|
|
2581
|
+
entities: setStale(model.entities, marksOf(message.requests), false),
|
|
2582
|
+
loading: withoutLoading(model.loading, message.requests),
|
|
2583
|
+
failures: withFieldFailures(model.failures, message.requests, message.error)
|
|
2584
|
+
};
|
|
2401
2585
|
case "RetentionChanged": {
|
|
2402
2586
|
const retained = gc(model, message.roots);
|
|
2587
|
+
let reachedKeys;
|
|
2588
|
+
const reached = () => reachedKeys ??= reachable(model.entities, message.roots, model.connections, model.optimistic, model.mutations.pending);
|
|
2403
2589
|
return {
|
|
2404
2590
|
...model,
|
|
2405
2591
|
...retained,
|
|
2406
|
-
refresh: prunedRefresh(model.refresh, retained)
|
|
2592
|
+
refresh: prunedRefresh(model.refresh, retained),
|
|
2593
|
+
failures: prunedFailures(model.failures, message.roots, reached),
|
|
2594
|
+
loading: prunedLoading(model.loading, message.roots, reached)
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
case "Hydrated": {
|
|
2598
|
+
const restored = Object.entries(message.connections ?? {}).map(([identity, segments]) => [identity, {
|
|
2599
|
+
segments: segments.map((edges) => ({
|
|
2600
|
+
edges: [...edges],
|
|
2601
|
+
start: unknown,
|
|
2602
|
+
end: unknown
|
|
2603
|
+
})),
|
|
2604
|
+
stale: true
|
|
2605
|
+
}]);
|
|
2606
|
+
const written = Object.entries(message.entities).flatMap(([key, entry]) => message.merge === "replace" || !(key in model.entities) ? [...entry.present].map((field) => `${key}\u0000${field}`) : []);
|
|
2607
|
+
return {
|
|
2608
|
+
...model,
|
|
2609
|
+
entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge),
|
|
2610
|
+
failures: withoutFieldFailures(model.failures, written),
|
|
2611
|
+
connections: message.merge === "replace" ? {
|
|
2612
|
+
...model.connections,
|
|
2613
|
+
...Object.fromEntries(restored)
|
|
2614
|
+
} : {
|
|
2615
|
+
...Object.fromEntries(restored),
|
|
2616
|
+
...model.connections
|
|
2617
|
+
}
|
|
2407
2618
|
};
|
|
2408
2619
|
}
|
|
2409
|
-
case "Hydrated": return {
|
|
2410
|
-
...model,
|
|
2411
|
-
entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
|
|
2412
|
-
};
|
|
2413
2620
|
case "RefreshStarted": {
|
|
2414
2621
|
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
2622
|
const entities = setStale(asked, marksOf(message.requests), true);
|
|
2416
|
-
|
|
2623
|
+
const failures = withoutFieldFailures(model.failures, reachedMarks(model.entities, message.requests));
|
|
2624
|
+
return entities === model.entities && failures === model.failures ? model : {
|
|
2625
|
+
...model,
|
|
2626
|
+
entities,
|
|
2627
|
+
failures
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
case "QueryStarted": {
|
|
2631
|
+
const loading = new Set(model.loading);
|
|
2632
|
+
for (const identity of message.connections) loading.add(connectionMark(identity));
|
|
2633
|
+
return loading.size === model.loading.size ? model : {
|
|
2417
2634
|
...model,
|
|
2418
|
-
|
|
2635
|
+
loading
|
|
2419
2636
|
};
|
|
2420
2637
|
}
|
|
2421
2638
|
case "ReadStarted": return {
|
|
@@ -2434,7 +2651,8 @@ const updateRemote = (model, message) => {
|
|
|
2434
2651
|
...model,
|
|
2435
2652
|
entities: settled.store,
|
|
2436
2653
|
mutations: settled.state,
|
|
2437
|
-
optimistic: settled.optimistic
|
|
2654
|
+
optimistic: settled.optimistic,
|
|
2655
|
+
failures: withoutFieldFailures(model.failures, [...patchedMarks(message.entities), ...(message.deleted ?? []).flatMap((ref) => entityFailureMarks(model.failures, entityKey(ref.entity, ref.id)))])
|
|
2438
2656
|
};
|
|
2439
2657
|
}
|
|
2440
2658
|
case "OverlayShown": return {
|
|
@@ -2456,18 +2674,26 @@ const updateRemote = (model, message) => {
|
|
|
2456
2674
|
case "LiveReceived": {
|
|
2457
2675
|
const state = model.live[message.stream] ?? emptyLiveState;
|
|
2458
2676
|
if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
|
|
2459
|
-
const
|
|
2460
|
-
|
|
2677
|
+
const event = message.event;
|
|
2678
|
+
const applied = applyEntityEvent(state, model.entities, event, message.now);
|
|
2679
|
+
if (applied.outcome === "gap") return markGap(model, message.stream);
|
|
2680
|
+
const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
|
|
2681
|
+
...event.ref,
|
|
2682
|
+
values: event.values
|
|
2683
|
+
}])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
|
|
2684
|
+
return clearGap({
|
|
2461
2685
|
...model,
|
|
2462
2686
|
entities: applied.store,
|
|
2463
2687
|
live: {
|
|
2464
2688
|
...model.live,
|
|
2465
2689
|
[message.stream]: applied.state
|
|
2466
|
-
}
|
|
2690
|
+
},
|
|
2691
|
+
failures: withoutFieldFailures(model.failures, settled)
|
|
2467
2692
|
}, message.stream);
|
|
2468
2693
|
}
|
|
2469
2694
|
const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
|
|
2470
|
-
|
|
2695
|
+
if (applied.outcome === "gap") return markGap(model, message.stream);
|
|
2696
|
+
const next = clearGap({
|
|
2471
2697
|
...model,
|
|
2472
2698
|
optimistic: applied.optimistic,
|
|
2473
2699
|
live: {
|
|
@@ -2475,10 +2701,16 @@ const updateRemote = (model, message) => {
|
|
|
2475
2701
|
[message.stream]: applied.state
|
|
2476
2702
|
}
|
|
2477
2703
|
}, message.stream);
|
|
2704
|
+
return applied.invalidated === void 0 ? next : updateRemote(next, {
|
|
2705
|
+
_tag: "ConnectionInvalidated",
|
|
2706
|
+
connection: applied.invalidated
|
|
2707
|
+
});
|
|
2478
2708
|
}
|
|
2479
2709
|
case "GapCleared": return clearGap(model, message.stream);
|
|
2480
2710
|
case "ConnectionMerged": {
|
|
2481
2711
|
const current = model.connections[message.connection] ?? emptyConnection;
|
|
2712
|
+
const failures = withoutConnectionFailure(model.failures, message.connection);
|
|
2713
|
+
const loading = withoutQueryLoading(model.loading, message.connection);
|
|
2482
2714
|
const replaces = message.refreshes === true && current.stale;
|
|
2483
2715
|
const merged = merge(replaces ? emptyConnection : current, message.page);
|
|
2484
2716
|
return {
|
|
@@ -2490,25 +2722,181 @@ const updateRemote = (model, message) => {
|
|
|
2490
2722
|
stale: false
|
|
2491
2723
|
} : merged
|
|
2492
2724
|
},
|
|
2493
|
-
optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
|
|
2725
|
+
optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending),
|
|
2726
|
+
failures,
|
|
2727
|
+
loading
|
|
2494
2728
|
};
|
|
2495
2729
|
}
|
|
2496
2730
|
case "ConnectionInvalidated":
|
|
2497
|
-
if (model.connections[message.connection]?.stale === true) return model;
|
|
2731
|
+
if (model.connections[message.connection]?.stale === true && !(message.connection in model.failures.connections)) return model;
|
|
2498
2732
|
return {
|
|
2499
2733
|
...model,
|
|
2500
|
-
connections: setConnectionStale(model.connections, message.connection, true)
|
|
2734
|
+
connections: setConnectionStale(model.connections, message.connection, true),
|
|
2735
|
+
failures: withoutConnectionFailure(model.failures, message.connection)
|
|
2501
2736
|
};
|
|
2502
|
-
case "QueryFailed": return
|
|
2737
|
+
case "QueryFailed": return {
|
|
2503
2738
|
...model,
|
|
2504
|
-
|
|
2505
|
-
|
|
2739
|
+
failures: {
|
|
2740
|
+
...model.failures,
|
|
2741
|
+
connections: {
|
|
2742
|
+
...model.failures.connections,
|
|
2743
|
+
[message.connection]: message.error
|
|
2744
|
+
}
|
|
2745
|
+
},
|
|
2746
|
+
loading: withoutQueryLoading(model.loading, message.connection)
|
|
2747
|
+
};
|
|
2506
2748
|
case "ConnectionRefreshed": return {
|
|
2507
2749
|
...model,
|
|
2508
2750
|
connections: setConnectionStale(model.connections, message.connection, false)
|
|
2509
2751
|
};
|
|
2510
2752
|
}
|
|
2511
2753
|
};
|
|
2754
|
+
/** The failures without one connection's, keeping identity when it had none. */
|
|
2755
|
+
const withoutConnectionFailure = (failures, connection) => {
|
|
2756
|
+
if (!(connection in failures.connections)) return failures;
|
|
2757
|
+
const { [connection]: _settled, ...connections } = failures.connections;
|
|
2758
|
+
return {
|
|
2759
|
+
...failures,
|
|
2760
|
+
connections
|
|
2761
|
+
};
|
|
2762
|
+
};
|
|
2763
|
+
/** The failures with every field a read asked for failed with `error`. */
|
|
2764
|
+
const withFieldFailures = (failures, requests, error) => {
|
|
2765
|
+
const fields = { ...failures.fields };
|
|
2766
|
+
for (const mark of fieldMarks(requests)) fields[mark] = error;
|
|
2767
|
+
return {
|
|
2768
|
+
...failures,
|
|
2769
|
+
fields
|
|
2770
|
+
};
|
|
2771
|
+
};
|
|
2772
|
+
/**
|
|
2773
|
+
* The failures without the fields `settled` names, keeping identity when none
|
|
2774
|
+
* of them had failed, which is nearly always: this runs on every write.
|
|
2775
|
+
*/
|
|
2776
|
+
const withoutFieldFailures = (failures, settled) => {
|
|
2777
|
+
if (Object.keys(failures.fields).length === 0) return failures;
|
|
2778
|
+
const marks = [...settled].filter((mark) => mark in failures.fields);
|
|
2779
|
+
if (marks.length === 0) return failures;
|
|
2780
|
+
const fields = { ...failures.fields };
|
|
2781
|
+
for (const mark of marks) delete fields[mark];
|
|
2782
|
+
return {
|
|
2783
|
+
...failures,
|
|
2784
|
+
fields
|
|
2785
|
+
};
|
|
2786
|
+
};
|
|
2787
|
+
/** Every failed field of one entity: what deleting the entity settles. */
|
|
2788
|
+
const entityFailureMarks = (failures, key) => Object.keys(failures.fields).filter((mark) => mark.startsWith(`${key}\u0000`));
|
|
2789
|
+
/** The field marks of written values: each patch settles the fields it carries. */
|
|
2790
|
+
const patchedMarks = function* (patches) {
|
|
2791
|
+
for (const patch of patches) for (const field of Object.keys(patch.values)) yield fieldMark(patch.entity, patch.id, field);
|
|
2792
|
+
};
|
|
2793
|
+
/**
|
|
2794
|
+
* Every field a read's requests reach: their own, and through each relation
|
|
2795
|
+
* the store already holds, the fields they ask of its targets. A request with
|
|
2796
|
+
* a relation riding on it names no target id; the store does, and a failure
|
|
2797
|
+
* recorded on a target is reached only this way.
|
|
2798
|
+
*/
|
|
2799
|
+
const reachedMarks = (store, requests) => {
|
|
2800
|
+
const marks = [];
|
|
2801
|
+
const walked = /* @__PURE__ */ new Map();
|
|
2802
|
+
const walk = (entity, id, requirement) => {
|
|
2803
|
+
const key = entityKey(entity, id);
|
|
2804
|
+
const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
|
|
2805
|
+
if (seen.has(requirement)) return;
|
|
2806
|
+
seen.add(requirement);
|
|
2807
|
+
walked.set(key, seen);
|
|
2808
|
+
for (const field of requirement.fields) {
|
|
2809
|
+
marks.push(fieldMark(entity, id, field));
|
|
2810
|
+
const nested = requirement.relations?.[field];
|
|
2811
|
+
if (nested === void 0) continue;
|
|
2812
|
+
const value = readField(store, key, field);
|
|
2813
|
+
if (value._tag === "None") continue;
|
|
2814
|
+
for (const ref of targetsOf(value.value, nested)) walk(ref.entity, ref.id, nested);
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2817
|
+
for (const request of requests) walk(request.entity, request.id, request);
|
|
2818
|
+
return marks;
|
|
2819
|
+
};
|
|
2820
|
+
/** The entity key a field mark belongs to. */
|
|
2821
|
+
const markEntity = (mark) => mark.slice(0, mark.indexOf("\0"));
|
|
2822
|
+
/**
|
|
2823
|
+
* The failures of what the roots still reach. A released connection's or
|
|
2824
|
+
* entity's failure goes with it, so asking for it again later asks the server
|
|
2825
|
+
* again rather than repeating an error that may no longer hold.
|
|
2826
|
+
*
|
|
2827
|
+
* Reached is retention's own walk, taken over the Model before collection. It
|
|
2828
|
+
* counts a relation's target and a list's row that the store does not hold,
|
|
2829
|
+
* which is exactly a value whose first read failed: it is still on screen,
|
|
2830
|
+
* behind the parent that refers to it.
|
|
2831
|
+
*/
|
|
2832
|
+
const prunedFailures = (failures, roots, reached) => {
|
|
2833
|
+
const named = new Set(roots.connections.map((root) => root.identity));
|
|
2834
|
+
const connections = Object.entries(failures.connections).filter(([identity]) => named.has(identity));
|
|
2835
|
+
const fields = Object.keys(failures.fields).length === 0 ? [] : Object.entries(failures.fields).filter(([mark]) => reached().has(markEntity(mark)));
|
|
2836
|
+
return connections.length === Object.keys(failures.connections).length && fields.length === Object.keys(failures.fields).length ? failures : {
|
|
2837
|
+
connections: Object.fromEntries(connections),
|
|
2838
|
+
fields: Object.fromEntries(fields)
|
|
2839
|
+
};
|
|
2840
|
+
};
|
|
2841
|
+
/** Whether this field's last read failed and nothing has settled it since. */
|
|
2842
|
+
const isFieldFailed = (model, entity, id, field) => fieldMark(entity, id, field) in model.failures.fields;
|
|
2843
|
+
/**
|
|
2844
|
+
* Whether a read is fetching any field `relation` reads of an entity, its own
|
|
2845
|
+
* or, through each relation the store holds, its targets'. A value whose owner
|
|
2846
|
+
* is being fetched is loading, not `Initial`: nothing is missing a fetch.
|
|
2847
|
+
*/
|
|
2848
|
+
const isLoadingThrough = (model, entity, id, relation) => {
|
|
2849
|
+
if (model.loading.size === 0) return false;
|
|
2850
|
+
const walked = /* @__PURE__ */ new Map();
|
|
2851
|
+
const walk = (target, targetId, requirement) => {
|
|
2852
|
+
const key = entityKey(target, targetId);
|
|
2853
|
+
const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
|
|
2854
|
+
if (seen.has(requirement)) return false;
|
|
2855
|
+
seen.add(requirement);
|
|
2856
|
+
walked.set(key, seen);
|
|
2857
|
+
for (const field of requirement.fields) {
|
|
2858
|
+
if (model.loading.has(fieldMark(target, targetId, field))) return true;
|
|
2859
|
+
const nested = requirement.relations?.[field];
|
|
2860
|
+
if (nested === void 0) continue;
|
|
2861
|
+
const value = readField(model.entities, key, field);
|
|
2862
|
+
if (value._tag === "None") continue;
|
|
2863
|
+
for (const ref of targetsOf(value.value, nested)) if (walk(ref.entity, ref.id, nested)) return true;
|
|
2864
|
+
}
|
|
2865
|
+
return false;
|
|
2866
|
+
};
|
|
2867
|
+
return walk(entity, id, relation);
|
|
2868
|
+
};
|
|
2869
|
+
/**
|
|
2870
|
+
* The first failure among the fields `relation` reads of an entity, following
|
|
2871
|
+
* each relation the store holds into its targets, as `assemble` does. A read
|
|
2872
|
+
* that consults it shows `Failed` for a related entity's failure too, rather
|
|
2873
|
+
* than waiting on a field nothing will fetch.
|
|
2874
|
+
*/
|
|
2875
|
+
const failureOf = (model, entity, id, relation) => {
|
|
2876
|
+
const failed = model.failures.fields;
|
|
2877
|
+
if (Object.keys(failed).length === 0) return void 0;
|
|
2878
|
+
const walked = /* @__PURE__ */ new Map();
|
|
2879
|
+
const walk = (target, targetId, requirement) => {
|
|
2880
|
+
const key = entityKey(target, targetId);
|
|
2881
|
+
const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
|
|
2882
|
+
if (seen.has(requirement)) return void 0;
|
|
2883
|
+
seen.add(requirement);
|
|
2884
|
+
walked.set(key, seen);
|
|
2885
|
+
for (const field of requirement.fields) {
|
|
2886
|
+
const error = failed[fieldMark(target, targetId, field)];
|
|
2887
|
+
if (error !== void 0) return error;
|
|
2888
|
+
const nested = requirement.relations?.[field];
|
|
2889
|
+
if (nested === void 0) continue;
|
|
2890
|
+
const value = readField(model.entities, key, field);
|
|
2891
|
+
if (value._tag === "None") continue;
|
|
2892
|
+
for (const ref of targetsOf(value.value, nested)) {
|
|
2893
|
+
const found = walk(ref.entity, ref.id, nested);
|
|
2894
|
+
if (found !== void 0) return found;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
return walk(entity, id, relation);
|
|
2899
|
+
};
|
|
2512
2900
|
/** Appends (after) or prepends (before) an incoming page onto the stored one. */
|
|
2513
2901
|
const mergeWireRefPages = (current, next, direction) => {
|
|
2514
2902
|
const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
|
|
@@ -2740,10 +3128,34 @@ const askedOf = (projection) => projection === void 0 ? nothingAsked : {
|
|
|
2740
3128
|
connections: connectionsOf(projection)
|
|
2741
3129
|
};
|
|
2742
3130
|
/**
|
|
3131
|
+
* Whether `outer` asks for everything `inner` does of one entity: each field,
|
|
3132
|
+
* each relation's own slice of its target, and each page window alike.
|
|
3133
|
+
*/
|
|
3134
|
+
const sliceCovers = (outer, inner) => outer.entity === inner.entity && inner.fields.every((field) => outer.fields.includes(field)) && Object.entries(inner.relations ?? {}).every(([field, relation]) => {
|
|
3135
|
+
const wider = outer.relations?.[field];
|
|
3136
|
+
return wider !== void 0 && sliceCovers(wider, relation);
|
|
3137
|
+
}) && Object.entries(inner.windows ?? {}).every(([field, window]) => outer.windows?.[field] !== void 0 && stableStringify(outer.windows[field]) === stableStringify(window));
|
|
3138
|
+
/**
|
|
3139
|
+
* Whether what `outer` asks includes everything `inner` asks: each list with
|
|
3140
|
+
* the slice it selects of its rows, and each entity with the slice it reads,
|
|
3141
|
+
* relations included. A Surface that shows a read is one whose projection
|
|
3142
|
+
* asks for all of it; one that reads part of it cannot be why it is fetched.
|
|
3143
|
+
* A Surface that asks for one entity in several pieces is already one
|
|
3144
|
+
* requirement here: projection metadata unions them per entity and id.
|
|
3145
|
+
*/
|
|
3146
|
+
const covers = (outer, inner) => {
|
|
3147
|
+
const { connections, requirements } = outer;
|
|
3148
|
+
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)));
|
|
3149
|
+
};
|
|
3150
|
+
/**
|
|
2743
3151
|
* Plans what a projection asks against the Model. A connection the Model holds
|
|
2744
3152
|
* fresh contributes its visible items' selected fields to the entity plan; one
|
|
2745
3153
|
* it does not hold, or holds stale, is a query to run (its items are planned
|
|
2746
3154
|
* once the page arrives).
|
|
3155
|
+
*
|
|
3156
|
+
* A connection whose query failed is not run again here, short of `force`: the
|
|
3157
|
+
* failure is what its read shows, and retrying is `Remote.refresh`'s to ask
|
|
3158
|
+
* for. The rows it already holds are still planned, since they are on screen.
|
|
2747
3159
|
*/
|
|
2748
3160
|
const planAsked = (remote, asked, options) => {
|
|
2749
3161
|
const queries = [];
|
|
@@ -2751,18 +3163,46 @@ const planAsked = (remote, asked, options) => {
|
|
|
2751
3163
|
const connections = Requirement.mergeConnections(asked.connections);
|
|
2752
3164
|
for (const connection of connections) {
|
|
2753
3165
|
const known = remote.connections[connection.identity];
|
|
2754
|
-
if (known === void 0 || known.stale || options.force === true) {
|
|
3166
|
+
if (!(options.force !== true && connection.identity in remote.failures.connections) && (known === void 0 || known.stale || options.force === true)) {
|
|
2755
3167
|
queries.push(connection);
|
|
2756
3168
|
continue;
|
|
2757
3169
|
}
|
|
3170
|
+
if (known === void 0) continue;
|
|
2758
3171
|
const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities);
|
|
2759
3172
|
items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
|
|
2760
3173
|
}
|
|
3174
|
+
const planned = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options);
|
|
2761
3175
|
return {
|
|
2762
|
-
requirements:
|
|
3176
|
+
requirements: options.force === true ? planned : withoutFailedFields(remote, planned),
|
|
2763
3177
|
queries
|
|
2764
3178
|
};
|
|
2765
3179
|
};
|
|
3180
|
+
/**
|
|
3181
|
+
* The plan without the fields whose last read failed. Like a failed query,
|
|
3182
|
+
* a failed field is shown rather than retried on its own, so a persistent
|
|
3183
|
+
* error is not asked again on every unrelated restart of the read entry. A
|
|
3184
|
+
* field leaving the request takes its window and its relation with it.
|
|
3185
|
+
*/
|
|
3186
|
+
const withoutFailedFields = (remote, planned) => {
|
|
3187
|
+
if (Object.keys(remote.failures.fields).length === 0) return planned;
|
|
3188
|
+
return planned.flatMap((requirement) => {
|
|
3189
|
+
const fields = requirement.fields.filter((field) => !isFieldFailed(remote, requirement.entity, requirement.id, field));
|
|
3190
|
+
if (fields.length === requirement.fields.length) return [requirement];
|
|
3191
|
+
if (fields.length === 0) return [];
|
|
3192
|
+
const kept = new Set(fields);
|
|
3193
|
+
const only = (record) => record === void 0 ? void 0 : Object.fromEntries(Object.entries(record).filter(([field]) => kept.has(field)));
|
|
3194
|
+
const windows = only(requirement.windows);
|
|
3195
|
+
const relations = only(requirement.relations);
|
|
3196
|
+
return [{
|
|
3197
|
+
entity: requirement.entity,
|
|
3198
|
+
id: requirement.id,
|
|
3199
|
+
fields,
|
|
3200
|
+
...windows === void 0 || Object.keys(windows).length === 0 ? {} : { windows },
|
|
3201
|
+
...relations === void 0 || Object.keys(relations).length === 0 ? {} : { relations },
|
|
3202
|
+
...requirement.live === void 0 ? {} : { live: requirement.live }
|
|
3203
|
+
}];
|
|
3204
|
+
});
|
|
3205
|
+
};
|
|
2766
3206
|
/** The entity requirements a page's edges add under a selection. */
|
|
2767
3207
|
const itemsOf = (edges, select) => edges.filter((edge) => edge.entity === select.entity).map((edge) => ({
|
|
2768
3208
|
...select,
|
|
@@ -2790,22 +3230,52 @@ const queryRequestOf = (query) => {
|
|
|
2790
3230
|
* `refreshes` marks the page as answering the connection's refresh, so one
|
|
2791
3231
|
* Message both merges it and clears `stale`.
|
|
2792
3232
|
*/
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
3233
|
+
/**
|
|
3234
|
+
* The page a window asked for, or the number of edges that came back instead.
|
|
3235
|
+
*
|
|
3236
|
+
* A window is a request the client made and the server answered, so more edges
|
|
3237
|
+
* than were asked for is a protocol disagreement rather than a windfall: the
|
|
3238
|
+
* client cannot tell which of them the window meant, the connection's
|
|
3239
|
+
* boundaries stop describing what it holds, and a `first: 25` that quietly
|
|
3240
|
+
* becomes a thousand is a memory event with no error attached to it.
|
|
3241
|
+
*
|
|
3242
|
+
* `undefined` when the page is within its window, or when no size was asked
|
|
3243
|
+
* for — `after`/`before` with no `first`/`last` bounds nothing.
|
|
3244
|
+
*/
|
|
3245
|
+
const overrun = (window, edges) => {
|
|
3246
|
+
const asked = window.first ?? window.last;
|
|
3247
|
+
return asked !== void 0 && edges.length > asked ? {
|
|
3248
|
+
asked,
|
|
3249
|
+
got: edges.length
|
|
3250
|
+
} : void 0;
|
|
3251
|
+
};
|
|
3252
|
+
const pageMessage = (connection, result, refreshes = false, window = {}) => {
|
|
3253
|
+
const over = overrun(window, result.edges);
|
|
3254
|
+
if (over !== void 0) return {
|
|
3255
|
+
_tag: "QueryFailed",
|
|
3256
|
+
connection,
|
|
3257
|
+
error: {
|
|
3258
|
+
_tag: "RemoteProtocolError",
|
|
3259
|
+
message: `the server returned ${over.got} edges for a window of ${over.asked}`
|
|
3260
|
+
}
|
|
3261
|
+
};
|
|
3262
|
+
return {
|
|
3263
|
+
_tag: "ConnectionMerged",
|
|
3264
|
+
connection,
|
|
3265
|
+
page: {
|
|
3266
|
+
edges: result.edges.map((edge) => ({
|
|
3267
|
+
key: edge.key,
|
|
3268
|
+
ref: {
|
|
3269
|
+
entity: edge.entity,
|
|
3270
|
+
id: edge.id
|
|
3271
|
+
}
|
|
3272
|
+
})),
|
|
3273
|
+
start: result.start,
|
|
3274
|
+
end: result.end
|
|
3275
|
+
},
|
|
3276
|
+
...refreshes ? { refreshes } : {}
|
|
3277
|
+
};
|
|
3278
|
+
};
|
|
2809
3279
|
/**
|
|
2810
3280
|
* The retention roots of some projections: their requirements, their
|
|
2811
3281
|
* connections (each with the union of what the projections select of its
|
|
@@ -2853,7 +3323,7 @@ const queryMessage = (query) => Effect.gen(function* () {
|
|
|
2853
3323
|
_tag: "QueryFailed",
|
|
2854
3324
|
connection: query.identity,
|
|
2855
3325
|
error: remoteError(result.failure)
|
|
2856
|
-
} : pageMessage(query.identity, result.success, true);
|
|
3326
|
+
} : pageMessage(query.identity, result.success, true, query.window);
|
|
2857
3327
|
});
|
|
2858
3328
|
/** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
|
|
2859
3329
|
const PlannedQuery = Schema.Struct({
|
|
@@ -2893,14 +3363,17 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
2893
3363
|
}))
|
|
2894
3364
|
};
|
|
2895
3365
|
},
|
|
2896
|
-
dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(requirements.length === 0 ?
|
|
3366
|
+
dependenciesToStream: ({ requirements, queries, refresh }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
|
|
2897
3367
|
_tag: "ReadStarted",
|
|
2898
3368
|
requests: requirements,
|
|
2899
3369
|
refresh
|
|
2900
3370
|
}), ...RemotePolicy.refreshes(policy) ? [toMessage({
|
|
2901
3371
|
_tag: "RefreshStarted",
|
|
2902
3372
|
requests: requirements
|
|
2903
|
-
})] : []]
|
|
3373
|
+
})] : []], ...queries.length === 0 ? [] : [toMessage({
|
|
3374
|
+
_tag: "QueryStarted",
|
|
3375
|
+
connections: queries.map((query) => query.identity)
|
|
3376
|
+
})]]), Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
|
|
2904
3377
|
};
|
|
2905
3378
|
};
|
|
2906
3379
|
/** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
|
|
@@ -2930,7 +3403,8 @@ const liveEntry = (bound, requirementsOf, toMessage, options) => ({
|
|
|
2930
3403
|
})), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
|
|
2931
3404
|
_tag: "ReadFailed",
|
|
2932
3405
|
requests: requirements,
|
|
2933
|
-
error: remoteError(error)
|
|
3406
|
+
error: remoteError(error),
|
|
3407
|
+
stream: liveStreamKey(requirements)
|
|
2934
3408
|
}))))
|
|
2935
3409
|
});
|
|
2936
3410
|
/** The projection an active Surface has for this Model, if it is active. */
|
|
@@ -2966,6 +3440,21 @@ const Remote = {
|
|
|
2966
3440
|
/** Whether a Message is one of Remote's, by tag. */
|
|
2967
3441
|
reduces: isRemoteMessage,
|
|
2968
3442
|
/**
|
|
3443
|
+
* A patch of one entity's values, for a mutation's `optimistic` list or an
|
|
3444
|
+
* overlay. Takes a `foldkit-entity` Entity or a Remote descriptor alike, so
|
|
3445
|
+
* the domain you declared once is the one you patch:
|
|
3446
|
+
* `Remote.patch(Project, 'p1', { name: 'Apollo II' })`. Values are the
|
|
3447
|
+
* fields' wire shape, as the server writes them; a relation is its ref key
|
|
3448
|
+
* (`'User:u1'`).
|
|
3449
|
+
*/
|
|
3450
|
+
patch: (entity, id, values) => descriptorOf(entity).patch(id, values),
|
|
3451
|
+
/**
|
|
3452
|
+
* A reference to one entity, for `ConnectionChange` and anything else that
|
|
3453
|
+
* names a row: `Remote.ref(Project, 'p3')`. Either kind of entity, as with
|
|
3454
|
+
* `Remote.patch`.
|
|
3455
|
+
*/
|
|
3456
|
+
ref: (entity, id) => descriptorOf(entity).ref(id),
|
|
3457
|
+
/**
|
|
2969
3458
|
* Declares a Remote domain and binds it to its place in the application Model
|
|
2970
3459
|
* in one step; the result carries the application-facing operations. The
|
|
2971
3460
|
* descriptor alone is `Remote.define`, the binding alone `Remote.at`.
|
|
@@ -3023,8 +3512,18 @@ const Remote = {
|
|
|
3023
3512
|
value: decoded.success
|
|
3024
3513
|
};
|
|
3025
3514
|
});
|
|
3026
|
-
|
|
3027
|
-
|
|
3515
|
+
const remote = bound.store.get(root);
|
|
3516
|
+
const failure = failureOf(remote, selection.entity, id, relation);
|
|
3517
|
+
if (present !== void 0) return failure !== void 0 && (present._tag === "Ready" || present._tag === "Refreshing") ? {
|
|
3518
|
+
_tag: "Failed",
|
|
3519
|
+
error: failure,
|
|
3520
|
+
previous: present.value
|
|
3521
|
+
} : present;
|
|
3522
|
+
if (isLoadingThrough(remote, selection.entity, id, relation)) return { _tag: "Loading" };
|
|
3523
|
+
return failure === void 0 ? { _tag: "Initial" } : {
|
|
3524
|
+
_tag: "Failed",
|
|
3525
|
+
error: failure
|
|
3526
|
+
};
|
|
3028
3527
|
}
|
|
3029
3528
|
});
|
|
3030
3529
|
},
|
|
@@ -3083,6 +3582,9 @@ const Remote = {
|
|
|
3083
3582
|
* again with `next`/`previous`. The read entries restart, so a read or query
|
|
3084
3583
|
* sent before the refresh is not applied after it.
|
|
3085
3584
|
*
|
|
3585
|
+
* It is also how a failed query is retried: a connection whose query failed
|
|
3586
|
+
* is not run again on its own, so its read stays `Failed` until this asks.
|
|
3587
|
+
*
|
|
3086
3588
|
* The projection must be observed, which it is while it is on screen. Live
|
|
3087
3589
|
* subscriptions are left as they are. For data nothing observes (SSR, tests),
|
|
3088
3590
|
* use `Remote.prefetch` with `RemotePolicy.networkOnly`. Refreshing what is
|
|
@@ -3098,7 +3600,7 @@ const Remote = {
|
|
|
3098
3600
|
return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
|
|
3099
3601
|
});
|
|
3100
3602
|
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);
|
|
3603
|
+
const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0 || connection.identity in remote.failures.connections).map((connection) => connection.identity);
|
|
3102
3604
|
const marked = [...requirements.length === 0 ? [] : [{
|
|
3103
3605
|
_tag: "RefreshStarted",
|
|
3104
3606
|
requests: requirements
|
|
@@ -3175,7 +3677,7 @@ const Remote = {
|
|
|
3175
3677
|
});
|
|
3176
3678
|
}),
|
|
3177
3679
|
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
3178
|
-
queryMessage: (ref, result) => pageMessage(ref.identity, result),
|
|
3680
|
+
queryMessage: (ref, result) => pageMessage(ref.identity, result, false, ref.window),
|
|
3179
3681
|
/**
|
|
3180
3682
|
* The edges a connection shows: its server-known region with pending and
|
|
3181
3683
|
* confirmed overlays placed around it, minus removed edges and edges whose
|
|
@@ -3232,8 +3734,10 @@ const Remote = {
|
|
|
3232
3734
|
observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => askedOf(surface.projection(params)), toMessage, options),
|
|
3233
3735
|
/**
|
|
3234
3736
|
* A Foldkit Subscription entry that consumes the live stream for a Surface's
|
|
3235
|
-
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
3236
|
-
*
|
|
3737
|
+
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
3738
|
+
* carrying its `stream` when the stream breaks (including
|
|
3739
|
+
* `ResumeUnavailable`). That records a gap on the stream rather than a failed
|
|
3740
|
+
* read, since nothing was being read. The resume cursor is read
|
|
3237
3741
|
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
3238
3742
|
*/
|
|
3239
3743
|
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
|
|
@@ -3242,7 +3746,48 @@ const brandEntries = (entries) => Subscription.make()(() => entries);
|
|
|
3242
3746
|
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
3243
3747
|
const bindDomain = (definition, store) => {
|
|
3244
3748
|
const bound = bindRemote(definition, store);
|
|
3245
|
-
|
|
3749
|
+
/**
|
|
3750
|
+
* What to do with a row a live event says was inserted into a connection.
|
|
3751
|
+
*
|
|
3752
|
+
* Resolved here rather than in the pure reducer because this is the only
|
|
3753
|
+
* place with both the Model and the registry, and two separate things needed
|
|
3754
|
+
* one or the other.
|
|
3755
|
+
*
|
|
3756
|
+
* **The declared policy reaches the decision.** `Query.connection(E, { live })`
|
|
3757
|
+
* has always been typed, documented, carried on the descriptor and encoded in
|
|
3758
|
+
* the Message schema — and nothing ever put it on a Message, so every live
|
|
3759
|
+
* insert took the default whatever an application asked for. It does now.
|
|
3760
|
+
*
|
|
3761
|
+
* **A row the client can judge does not need a policy.** The policy exists
|
|
3762
|
+
* because nothing could tell whether an inserted row belonged to the query.
|
|
3763
|
+
* Where the body says it does not, `ignore` is not a guess. Where the body
|
|
3764
|
+
* says it does, or the client cannot tell — a row it never fetched, or holds
|
|
3765
|
+
* without a field the body reads — the declared policy is the answer, exactly
|
|
3766
|
+
* as it was meant to be.
|
|
3767
|
+
*
|
|
3768
|
+
* Only membership is decided here. *Where* a row sorts needs text collation,
|
|
3769
|
+
* which is the backend's, so the position the event carries stands.
|
|
3770
|
+
*/
|
|
3771
|
+
const livePolicyFor = (model, event) => {
|
|
3772
|
+
if (event._tag !== "ConnectionInsert") return void 0;
|
|
3773
|
+
const identity = event.connection;
|
|
3774
|
+
const descriptor = definition.registry.queries.get(identity.slice(0, identity.indexOf("\0")));
|
|
3775
|
+
const declared = (descriptor?.Result)?.live;
|
|
3776
|
+
if (descriptor?.body === void 0) return declared;
|
|
3777
|
+
const encoded = JSON.parse(identity.slice(identity.indexOf("\0") + 1));
|
|
3778
|
+
return belongsEncoded(storeOf(bound, model), descriptor, encoded, entityKey(event.edge.ref.entity, event.edge.ref.id)) === "no" ? {
|
|
3779
|
+
prepend: "ignore",
|
|
3780
|
+
append: "ignore"
|
|
3781
|
+
} : declared;
|
|
3782
|
+
};
|
|
3783
|
+
const reduce = (model, message) => {
|
|
3784
|
+
const live = message._tag === "LiveReceived" ? message : void 0;
|
|
3785
|
+
const resolved = live === void 0 || live.policy !== void 0 ? message : {
|
|
3786
|
+
...live,
|
|
3787
|
+
policy: livePolicyFor(model, live.event)
|
|
3788
|
+
};
|
|
3789
|
+
return store.set(model, updateRemote(store.get(model), resolved));
|
|
3790
|
+
};
|
|
3246
3791
|
const domain = {
|
|
3247
3792
|
...definition,
|
|
3248
3793
|
...bound,
|
|
@@ -3291,7 +3836,7 @@ const bindDomain = (definition, store) => {
|
|
|
3291
3836
|
let current = model;
|
|
3292
3837
|
for (const query of planAsked(store.get(current), askedOf(projection), planOptions).queries) {
|
|
3293
3838
|
const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
|
|
3294
|
-
current = reduce(current, pageMessage(query.identity, page, true));
|
|
3839
|
+
current = reduce(current, pageMessage(query.identity, page, true, query.window));
|
|
3295
3840
|
}
|
|
3296
3841
|
const requirements = planAsked(store.get(current), askedOf(projection), planOptions).requirements;
|
|
3297
3842
|
if (requirements.length === 0) return current;
|
|
@@ -3321,6 +3866,58 @@ const bindDomain = (definition, store) => {
|
|
|
3321
3866
|
};
|
|
3322
3867
|
const relation = relationOf(select);
|
|
3323
3868
|
const relationKey = stableStringify(relation);
|
|
3869
|
+
const failedItem = (remote, connection) => {
|
|
3870
|
+
if (Object.keys(remote.failures.fields).length === 0) return void 0;
|
|
3871
|
+
for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
|
|
3872
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
3873
|
+
const error = failureOf(remote, edge.ref.entity, edge.ref.id, relation);
|
|
3874
|
+
if (error !== void 0) return {
|
|
3875
|
+
_tag: "Failed",
|
|
3876
|
+
error
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
};
|
|
3880
|
+
const loadingItem = (remote, connection) => {
|
|
3881
|
+
if (remote.loading.size === 0) return void 0;
|
|
3882
|
+
for (const edge of visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities)) {
|
|
3883
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
3884
|
+
if (isLoadingThrough(remote, edge.ref.entity, edge.ref.id, relation)) return { _tag: "Loading" };
|
|
3885
|
+
}
|
|
3886
|
+
};
|
|
3887
|
+
const readPage = (remote, connection) => {
|
|
3888
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
3889
|
+
return memoRead(visible, connection, `${ref.identity}\u0000${relationKey}`, () => {
|
|
3890
|
+
const items = [];
|
|
3891
|
+
let refreshing = connection.stale;
|
|
3892
|
+
const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
|
|
3893
|
+
for (const edge of edges) {
|
|
3894
|
+
const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
|
|
3895
|
+
if (assembled === void 0) return void 0;
|
|
3896
|
+
const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
|
|
3897
|
+
if (Result.isFailure(decoded)) return {
|
|
3898
|
+
_tag: "Failed",
|
|
3899
|
+
error: {
|
|
3900
|
+
_tag: "DecodeError",
|
|
3901
|
+
message: decoded.failure.message
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
refreshing ||= assembled.refreshing;
|
|
3905
|
+
items.push(decoded.success);
|
|
3906
|
+
}
|
|
3907
|
+
const page = {
|
|
3908
|
+
items,
|
|
3909
|
+
hasNext: hasNext(connection),
|
|
3910
|
+
hasPrevious: hasPrevious(connection)
|
|
3911
|
+
};
|
|
3912
|
+
return refreshing ? {
|
|
3913
|
+
_tag: "Refreshing",
|
|
3914
|
+
value: page
|
|
3915
|
+
} : {
|
|
3916
|
+
_tag: "Ready",
|
|
3917
|
+
value: page
|
|
3918
|
+
};
|
|
3919
|
+
});
|
|
3920
|
+
};
|
|
3324
3921
|
const requirement = {
|
|
3325
3922
|
identity: ref.identity,
|
|
3326
3923
|
window: ref.window,
|
|
@@ -3332,42 +3929,41 @@ const bindDomain = (definition, store) => {
|
|
|
3332
3929
|
dependencies: [],
|
|
3333
3930
|
metadata: RemoteConnections.of(requirement),
|
|
3334
3931
|
ref,
|
|
3932
|
+
selection: select,
|
|
3335
3933
|
read: (root) => {
|
|
3336
3934
|
const remote = store.get(root);
|
|
3337
3935
|
const connection = remote.connections[ref.identity];
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3936
|
+
const failure = remote.failures.connections[ref.identity];
|
|
3937
|
+
if (connection === void 0 || connection.stale && connection.segments.length === 0) {
|
|
3938
|
+
if (isQueryLoading(remote, ref.identity)) return { _tag: "Loading" };
|
|
3939
|
+
return failure === void 0 ? { _tag: "Initial" } : {
|
|
3940
|
+
_tag: "Failed",
|
|
3941
|
+
error: failure
|
|
3942
|
+
};
|
|
3943
|
+
}
|
|
3944
|
+
const read = readPage(remote, connection) ?? failedItem(remote, connection) ?? loadingItem(remote, connection) ?? { _tag: "Initial" };
|
|
3945
|
+
if (failure === void 0) {
|
|
3946
|
+
if (read._tag !== "Ready" && read._tag !== "Refreshing") return read;
|
|
3947
|
+
const item = failedItem(remote, connection);
|
|
3948
|
+
return item === void 0 ? read : {
|
|
3949
|
+
_tag: "Failed",
|
|
3950
|
+
error: item.error,
|
|
3951
|
+
previous: read.value
|
|
3952
|
+
};
|
|
3953
|
+
}
|
|
3954
|
+
switch (read._tag) {
|
|
3955
|
+
case "Ready":
|
|
3956
|
+
case "Refreshing": return {
|
|
3957
|
+
_tag: "Failed",
|
|
3958
|
+
error: failure,
|
|
3959
|
+
previous: read.value
|
|
3362
3960
|
};
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
_tag: "Ready",
|
|
3368
|
-
value: page
|
|
3961
|
+
case "Failed": return read;
|
|
3962
|
+
default: return {
|
|
3963
|
+
_tag: "Failed",
|
|
3964
|
+
error: failure
|
|
3369
3965
|
};
|
|
3370
|
-
}
|
|
3966
|
+
}
|
|
3371
3967
|
}
|
|
3372
3968
|
};
|
|
3373
3969
|
},
|
|
@@ -3392,6 +3988,118 @@ const bindDomain = (definition, store) => {
|
|
|
3392
3988
|
}
|
|
3393
3989
|
} : void 0;
|
|
3394
3990
|
},
|
|
3991
|
+
explain: (model, projection, options) => {
|
|
3992
|
+
const { ref } = projection;
|
|
3993
|
+
const reading = Object.values(options?.surfaces ?? {}).filter((active) => {
|
|
3994
|
+
const active_ = active.projectionOf(model);
|
|
3995
|
+
return active_ !== void 0 && connectionsOf(active_).some((connection) => connection.identity === ref.identity);
|
|
3996
|
+
});
|
|
3997
|
+
const body = definition.registry.queries.get(ref.query)?.body;
|
|
3998
|
+
const [connection] = RemoteConnections.get(projection.metadata);
|
|
3999
|
+
return {
|
|
4000
|
+
domain: bound.contract.name,
|
|
4001
|
+
query: ref.query,
|
|
4002
|
+
input: Schema.encodeSync(ref.Input)(ref.input),
|
|
4003
|
+
identity: ref.identity,
|
|
4004
|
+
window: ref.window,
|
|
4005
|
+
select: connection.select,
|
|
4006
|
+
...body === void 0 ? {} : {
|
|
4007
|
+
body: Query$1.show(body),
|
|
4008
|
+
dependencies: Query$1.dependencies(body)
|
|
4009
|
+
},
|
|
4010
|
+
state: projection.read(model)._tag,
|
|
4011
|
+
...options?.surfaces === void 0 ? {} : {
|
|
4012
|
+
surfaces: reading.map((active) => active.name),
|
|
4013
|
+
activation: reading.flatMap((active) => active.activation === void 0 ? [] : [{
|
|
4014
|
+
surface: active.name,
|
|
4015
|
+
...active.activation
|
|
4016
|
+
}])
|
|
4017
|
+
}
|
|
4018
|
+
};
|
|
4019
|
+
},
|
|
4020
|
+
why: (model, projection, options) => {
|
|
4021
|
+
const state = projection.read(model);
|
|
4022
|
+
const asked = askedOf(projection);
|
|
4023
|
+
const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((active) => {
|
|
4024
|
+
const shown = active.projectionOf(model);
|
|
4025
|
+
return shown !== void 0 && covers(askedOf(shown), asked);
|
|
4026
|
+
}).map((active) => active.name);
|
|
4027
|
+
const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
|
|
4028
|
+
switch (state._tag) {
|
|
4029
|
+
case "Ready": return {
|
|
4030
|
+
state: state._tag,
|
|
4031
|
+
message: "Everything it selects is here.",
|
|
4032
|
+
...withSurfaces
|
|
4033
|
+
};
|
|
4034
|
+
case "Refreshing": return {
|
|
4035
|
+
state: state._tag,
|
|
4036
|
+
message: "It is shown while a newer value is fetched.",
|
|
4037
|
+
...withSurfaces
|
|
4038
|
+
};
|
|
4039
|
+
case "Loading": return {
|
|
4040
|
+
state: state._tag,
|
|
4041
|
+
message: "A request for it is in flight.",
|
|
4042
|
+
...withSurfaces
|
|
4043
|
+
};
|
|
4044
|
+
case "NotFound": return {
|
|
4045
|
+
state: state._tag,
|
|
4046
|
+
message: "The server answered without it: it does not exist, or this principal may not see it.",
|
|
4047
|
+
...withSurfaces
|
|
4048
|
+
};
|
|
4049
|
+
case "Failed": return {
|
|
4050
|
+
state: state._tag,
|
|
4051
|
+
message: state.error._tag === "DecodeError" ? `What the server sent does not decode against the Selection: ${state.error.message}` : `Its request failed: ${state.error.message}. Nothing retries a failed read on its own; Data.refresh asks again.`,
|
|
4052
|
+
...withSurfaces
|
|
4053
|
+
};
|
|
4054
|
+
case "Initial":
|
|
4055
|
+
if (reading === void 0) return {
|
|
4056
|
+
state: state._tag,
|
|
4057
|
+
reason: "Unknown",
|
|
4058
|
+
message: "Nothing is fetching it. Pass the active record you give Data.subscriptions as `surfaces` to tell whether an active Surface reads it."
|
|
4059
|
+
};
|
|
4060
|
+
return reading.length === 0 ? {
|
|
4061
|
+
state: state._tag,
|
|
4062
|
+
reason: "NotObserved",
|
|
4063
|
+
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.",
|
|
4064
|
+
surfaces: reading
|
|
4065
|
+
} : {
|
|
4066
|
+
state: state._tag,
|
|
4067
|
+
reason: "NotFetching",
|
|
4068
|
+
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.`,
|
|
4069
|
+
surfaces: reading
|
|
4070
|
+
};
|
|
4071
|
+
}
|
|
4072
|
+
},
|
|
4073
|
+
filtered: (model, over, by, input) => {
|
|
4074
|
+
assertRegistered(bound, "Query", definition.registry.queries, by.name);
|
|
4075
|
+
const filters = by.body?.entity.name;
|
|
4076
|
+
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`);
|
|
4077
|
+
const remote = store.get(model);
|
|
4078
|
+
const connection = remote.connections[over.ref.identity];
|
|
4079
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
4080
|
+
const among = (connection === void 0 ? [] : visibleItems(connection, over.ref.identity, remote.optimistic.overlays, remote.entities)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
|
|
4081
|
+
const judged = matching(visible, by, input, { among });
|
|
4082
|
+
const relation = relationOf(over.selection);
|
|
4083
|
+
const items = [];
|
|
4084
|
+
let assembledAll = true;
|
|
4085
|
+
for (const key of judged.matched) {
|
|
4086
|
+
const assembled = assemble(visible, key, relation);
|
|
4087
|
+
if (assembled === void 0) {
|
|
4088
|
+
assembledAll = false;
|
|
4089
|
+
continue;
|
|
4090
|
+
}
|
|
4091
|
+
const decoded = Schema.decodeUnknownResult(over.selection.schema)(assembled.values);
|
|
4092
|
+
if (Result.isFailure(decoded)) {
|
|
4093
|
+
assembledAll = false;
|
|
4094
|
+
continue;
|
|
4095
|
+
}
|
|
4096
|
+
items.push(decoded.success);
|
|
4097
|
+
}
|
|
4098
|
+
return {
|
|
4099
|
+
items,
|
|
4100
|
+
complete: judged.skipped.length === 0 && assembledAll && connection !== void 0 && !isGapped(connection) && !hasNext(connection) && !hasPrevious(connection)
|
|
4101
|
+
};
|
|
4102
|
+
},
|
|
3395
4103
|
refresh: (model, target) => Remote.refresh(bound, model, target),
|
|
3396
4104
|
overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
|
|
3397
4105
|
_tag: "OverlayShown",
|
|
@@ -3418,7 +4126,7 @@ const bindDomain = (definition, store) => {
|
|
|
3418
4126
|
connection: ref.identity,
|
|
3419
4127
|
error: remoteError(error)
|
|
3420
4128
|
}),
|
|
3421
|
-
onSuccess: (page) => pageMessage(ref.identity, page)
|
|
4129
|
+
onSuccess: (page) => pageMessage(ref.identity, page, false, ref.window)
|
|
3422
4130
|
}))
|
|
3423
4131
|
}),
|
|
3424
4132
|
mutate: (model, mutation, input, options = {}) => {
|
|
@@ -3485,6 +4193,6 @@ const pageSize = (window, key) => {
|
|
|
3485
4193
|
return size === void 0 ? {} : { [key]: size };
|
|
3486
4194
|
};
|
|
3487
4195
|
//#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,
|
|
4196
|
+
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, 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, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, withRefreshRequested, writeEntities, writeEntity, writeRead };
|
|
3489
4197
|
|
|
3490
4198
|
//# sourceMappingURL=index.mjs.map
|