foldkit-remote 0.20.0 → 0.22.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 +987 -198
- package/dist/answerGaps.d.ts +16 -0
- package/dist/answerGaps.d.ts.map +1 -0
- package/dist/answerGaps.js +60 -0
- package/dist/answerGaps.js.map +1 -0
- package/dist/client-B055kQuP.d.ts +110 -0
- package/dist/client-B055kQuP.d.ts.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +142 -0
- package/dist/client.js.map +1 -0
- package/dist/coalesce-CLAr92Nk.d.ts +34 -0
- package/dist/coalesce-CLAr92Nk.d.ts.map +1 -0
- package/dist/coalesce.d.ts +2 -0
- package/dist/coalesce.js +131 -0
- package/dist/coalesce.js.map +1 -0
- package/dist/connection-BRyaJy89.d.ts +45 -0
- package/dist/connection-BRyaJy89.d.ts.map +1 -0
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +170 -0
- package/dist/connection.js.map +1 -0
- package/dist/data.d.ts +7 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +21 -0
- package/dist/data.js.map +1 -0
- package/dist/diagnostics-DHhVYflV.d.ts +125 -0
- package/dist/diagnostics-DHhVYflV.d.ts.map +1 -0
- package/dist/diagnostics.d.ts +2 -0
- package/dist/diagnostics.js +60 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/entity-CgsG5Uel.d.ts +196 -0
- package/dist/entity-CgsG5Uel.d.ts.map +1 -0
- package/dist/entity-DuDxaa3n.js +279 -0
- package/dist/entity-DuDxaa3n.js.map +1 -0
- package/dist/entity.d.ts +2 -0
- package/dist/entity.js +1 -0
- package/dist/held.d.ts +19 -0
- package/dist/held.d.ts.map +1 -0
- package/dist/held.js +70 -0
- package/dist/held.js.map +1 -0
- package/dist/impact.d.ts +79 -0
- package/dist/impact.d.ts.map +1 -0
- package/dist/impact.js +166 -0
- package/dist/impact.js.map +1 -0
- package/dist/index.d.ts +1542 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2791 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect-DtRQGCRP.d.ts +134 -0
- package/dist/inspect-DtRQGCRP.d.ts.map +1 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +33 -0
- package/dist/inspect.js.map +1 -0
- package/dist/json-DowLl1eB.d.ts +52 -0
- package/dist/json-DowLl1eB.d.ts.map +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +123 -0
- package/dist/json.js.map +1 -0
- package/dist/law.d.ts +56 -0
- package/dist/law.d.ts.map +1 -0
- package/dist/law.js +201 -0
- package/dist/law.js.map +1 -0
- package/dist/live-BpTzlCnY.d.ts +79 -0
- package/dist/live-BpTzlCnY.d.ts.map +1 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +126 -0
- package/dist/live.js.map +1 -0
- package/dist/matching-DF5F9Us8.d.ts +100 -0
- package/dist/matching-DF5F9Us8.d.ts.map +1 -0
- package/dist/matching.d.ts +2 -0
- package/dist/matching.js +172 -0
- package/dist/matching.js.map +1 -0
- package/dist/model-B6zk1NMB.d.ts +710 -0
- package/dist/model-B6zk1NMB.d.ts.map +1 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +1308 -0
- package/dist/model.js.map +1 -0
- package/dist/mutation.d.ts +2 -0
- package/dist/mutation.js +612 -0
- package/dist/mutation.js.map +1 -0
- package/dist/optimistic-CECsfNGY.d.ts +523 -0
- package/dist/optimistic-CECsfNGY.d.ts.map +1 -0
- package/dist/optimistic.d.ts +2 -0
- package/dist/optimistic.js +336 -0
- package/dist/optimistic.js.map +1 -0
- package/dist/outbox-DNuc4kZp.d.ts +259 -0
- package/dist/outbox-DNuc4kZp.d.ts.map +1 -0
- package/dist/outbox.d.ts +2 -0
- package/dist/outbox.js +326 -0
- package/dist/outbox.js.map +1 -0
- package/dist/patchValues.d.ts +10 -0
- package/dist/patchValues.d.ts.map +1 -0
- package/dist/patchValues.js +57 -0
- package/dist/patchValues.js.map +1 -0
- package/dist/persistence-CCMnvg7-.d.ts +114 -0
- package/dist/persistence-CCMnvg7-.d.ts.map +1 -0
- package/dist/persistence.d.ts +2 -0
- package/dist/persistence.js +205 -0
- package/dist/persistence.js.map +1 -0
- package/dist/placement-jZdsIpmj.d.ts +50 -0
- package/dist/placement-jZdsIpmj.d.ts.map +1 -0
- package/dist/placement.d.ts +2 -0
- package/dist/placement.js +116 -0
- package/dist/placement.js.map +1 -0
- package/dist/plan-D9cftxC_.d.ts +35 -0
- package/dist/plan-D9cftxC_.d.ts.map +1 -0
- package/dist/plan.d.ts +2 -0
- package/dist/plan.js +100 -0
- package/dist/plan.js.map +1 -0
- package/dist/policy-DsWHmIAq.d.ts +42 -0
- package/dist/policy-DsWHmIAq.d.ts.map +1 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +32 -0
- package/dist/policy.js.map +1 -0
- package/dist/{port.d.mts → port.d.ts} +3 -2
- package/dist/port.d.ts.map +1 -0
- package/dist/{port.mjs → port.js} +3 -2
- package/dist/port.js.map +1 -0
- package/dist/query-BuIU93xA.d.ts +141 -0
- package/dist/query-BuIU93xA.d.ts.map +1 -0
- package/dist/query.d.ts +2 -0
- package/dist/query.js +0 -0
- package/dist/query.js.map +1 -0
- package/dist/record.d.ts +11 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +21 -0
- package/dist/record.js.map +1 -0
- package/dist/relation-BLBGOZom.d.ts +59 -0
- package/dist/relation-BLBGOZom.d.ts.map +1 -0
- package/dist/relation.d.ts +2 -0
- package/dist/relation.js +93 -0
- package/dist/relation.js.map +1 -0
- package/dist/remoteData-mK1suYi1.d.ts +148 -0
- package/dist/remoteData-mK1suYi1.d.ts.map +1 -0
- package/dist/remoteData.d.ts +2 -0
- package/dist/remoteData.js +179 -0
- package/dist/remoteData.js.map +1 -0
- package/dist/reporting.d.ts +12 -0
- package/dist/reporting.d.ts.map +1 -0
- package/dist/reporting.js +45 -0
- package/dist/reporting.js.map +1 -0
- package/dist/requirement-D57S2LpH.d.ts +64 -0
- package/dist/requirement-D57S2LpH.d.ts.map +1 -0
- package/dist/requirement.d.ts +2 -0
- package/dist/requirement.js +114 -0
- package/dist/requirement.js.map +1 -0
- package/dist/resume-Ct5R6yli.d.ts +92 -0
- package/dist/resume-Ct5R6yli.d.ts.map +1 -0
- package/dist/resume.d.ts +2 -0
- package/dist/resume.js +222 -0
- package/dist/resume.js.map +1 -0
- package/dist/retain-BfNTvBRb.d.ts +48 -0
- package/dist/retain-BfNTvBRb.d.ts.map +1 -0
- package/dist/retain.d.ts +2 -0
- package/dist/retain.js +107 -0
- package/dist/retain.js.map +1 -0
- package/dist/selection.d.ts +2 -0
- package/dist/selection.js +1 -0
- package/dist/shown.d.ts +20 -0
- package/dist/shown.d.ts.map +1 -0
- package/dist/shown.js +40 -0
- package/dist/shown.js.map +1 -0
- package/dist/sse-C7LhdbiQ.d.ts +17 -0
- package/dist/sse-C7LhdbiQ.d.ts.map +1 -0
- package/dist/sse.d.ts +2 -0
- package/dist/sse.js +127 -0
- package/dist/sse.js.map +1 -0
- package/dist/store-ah2qjcJW.d.ts +100 -0
- package/dist/store-ah2qjcJW.d.ts.map +1 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +218 -0
- package/dist/store.js.map +1 -0
- package/dist/testing.d.ts +133 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +0 -0
- package/dist/testing.js.map +1 -0
- package/dist/why-kgp_Wr3z.d.ts +483 -0
- package/dist/why-kgp_Wr3z.d.ts.map +1 -0
- package/dist/why.d.ts +2 -0
- package/dist/why.js +293 -0
- package/dist/why.js.map +1 -0
- package/dist/whyReasons.d.ts +42 -0
- package/dist/whyReasons.d.ts.map +1 -0
- package/dist/whyReasons.js +211 -0
- package/dist/whyReasons.js.map +1 -0
- package/dist/wire-DthPcTM6.d.ts +787 -0
- package/dist/wire-DthPcTM6.d.ts.map +1 -0
- package/dist/wire.d.ts +2 -0
- package/dist/wire.js +329 -0
- package/dist/wire.js.map +1 -0
- package/package.json +19 -14
- package/dist/index-BEvVAL76.d.mts +0 -4097
- package/dist/index-BEvVAL76.d.mts.map +0 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/port.d.mts.map +0 -1
- package/dist/port.mjs.map +0 -1
- package/dist/src-BDqn7Ocr.mjs +0 -6344
- package/dist/src-BDqn7Ocr.mjs.map +0 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,2791 @@
|
|
|
1
|
+
import { PredictionPart, predictionMismatch, withDiagnostics } from "./diagnostics.js";
|
|
2
|
+
import { RemoteConnections, RemoteRequirements, Requirement, connectionsOf, requirementsOf } from "./requirement.js";
|
|
3
|
+
import { patchedFields, shownOf } from "./shown.js";
|
|
4
|
+
import { sameData } from "./data.js";
|
|
5
|
+
import { RELATION_ALIAS, RelationAnnotation, RelationEntityAnnotation, aliasedField, isRefPage, refParts, refsIn, relationAlias, relationShape, targetsOf } from "./relation.js";
|
|
6
|
+
import { askAgain, clearStale, emptyStore, entityKey, entry, hasField, isFieldStale, isFieldUnavailable, isTombstone, markStale, missingFields, readField, remove, setStale, setUnavailable, tombstone, writeEntities, writeEntity } from "./store.js";
|
|
7
|
+
import { answerGaps } from "./answerGaps.js";
|
|
8
|
+
import { DerivedBy, Mutation, Refusal, beginMutation, brokenByWrite, derivedOfWrite, emptyMutationState, failMutation, forbiddenByWrite, forgetFailure, mutationStatus, optimisticOfWrite, patchOfWrite, predictionOfWrite, reconcileMutation, schemaOf, settledLater } from "./mutation.js";
|
|
9
|
+
import { IDENTITY_SEPARATOR, Query, queryIdentity, stableStringify } from "./query.js";
|
|
10
|
+
import { AnswerRead, ConnectionChangeSchema, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_LIVE_QUERIES, MAX_RELATION_DEPTH, MAX_SHOWN_ENTITIES, Mutate, MutationRequest, MutationResult, NormalizedEntity, OperationId, PageSize, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationRequest, RemoteForbidden, RemoteLiveError, RemoteLiveRefused, RemoteMutationError, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRpc, RemoteUnauthenticated, RemoteUnreachable, SettledFields, ShownSlice, WindowSchema, WireBoundary } from "./wire.js";
|
|
11
|
+
import { coalesceQueries, coalesceReads, requirementKey } from "./coalesce.js";
|
|
12
|
+
import { RemoteClient, coalescedLayer, connectionChangeOf, liveEventOf, mintOperation, mutateRemote, remoteError } from "./client.js";
|
|
13
|
+
import { cursor, edge, emptyConnection, hasNext, hasPrevious, isGapped, items, merge, segment, terminal, unknown } from "./connection.js";
|
|
14
|
+
import { a as relationOf, i as pageSchema, n as Selection, o as unavailableOf, r as assemble, t as Entity } from "./entity-DuDxaa3n.js";
|
|
15
|
+
import { RowPlace, placeIn, withEdge } from "./placement.js";
|
|
16
|
+
import { belongsEncoded, matching } from "./matching.js";
|
|
17
|
+
import { EntityChange, Impact, changesOf, impactOn } from "./impact.js";
|
|
18
|
+
import { wireValues } from "./patchValues.js";
|
|
19
|
+
import { Denied, Freshness, RemoteData, remoteDataSchema, remoteErrorSchema } from "./remoteData.js";
|
|
20
|
+
import { ReadWhy, ShownBy, Why, WhyTerm, readWhy, regressed } from "./why.js";
|
|
21
|
+
import { ConnectionChange, addLayer, addOverlay, beginOptimistic, connectionIdentity, emptyOptimistic, goneIn, heldOnlyByRequests, plannedStore, pruneOverlays, removeLayer, removeOverlay, resolvedPatches, settleFailure, settleSuccess, visibleItems, visibleStore } from "./optimistic.js";
|
|
22
|
+
import { applyConnectionEvent, applyEntityEvent, classifyLive, emptyLiveState, liveHasNext, liveHasPrevious } from "./live.js";
|
|
23
|
+
import { OutboxItemState, StoredWrite, backoff, channelFor, headOf, mergeStored, onlineAgain, operationOf, othersWaiting, outboxStatus, readEntries, readStored, sendLock, settledIn, storeLock, storedFromText, storedOf, storedText, withLock } from "./outbox.js";
|
|
24
|
+
import { deadlineOf, plan, windowKey } from "./plan.js";
|
|
25
|
+
import { departedLimit, gc, reachable, recentRoots } from "./retain.js";
|
|
26
|
+
import { REMOTE_CACHE_VERSION, RemotePersistence } from "./persistence.js";
|
|
27
|
+
import { dismissQueued, emptyRefresh, failureOf, fieldMark, forgetRemote, initialRemoteModel, invalidateConnections, isFieldFailed, isLoading, isLoadingThrough, isQueryLoading, isRemoteMessage, noFailures, onlyOverlaid, overlaysOf, refreshIsInFlight, refreshedAt, remoteMessageCases, remoteMessageSchema, remoteModelSchema, retentionRootsSchema, updateRemote, windowGrowthKey, withRefreshRequested, writeRead } from "./model.js";
|
|
28
|
+
import { failedReasons, reasonsOf } from "./whyReasons.js";
|
|
29
|
+
import { inspectEntity, inspectRemote } from "./inspect.js";
|
|
30
|
+
import { RemotePolicy } from "./policy.js";
|
|
31
|
+
import { markAll, resumePart } from "./resume.js";
|
|
32
|
+
import { reduceReporting } from "./reporting.js";
|
|
33
|
+
import { RemoteJsonAnswer, RemoteJsonRequest, http, json } from "./json.js";
|
|
34
|
+
import { httpWithLive, liveFetch } from "./sse.js";
|
|
35
|
+
import { Clock, Duration, Effect, Layer, Match, Option, Queue, Record, Result, Schema, Stream } from "effect";
|
|
36
|
+
import { Diagnostics, ModelRef } from "foldkit-surface";
|
|
37
|
+
import { Derivation, Entity as Entity$1, Operators, Query as Query$1, QueryEvaluateError, ReadFilter, SelectionTypeId } from "foldkit-entity";
|
|
38
|
+
import { KeyValueStore } from "effect/persistence";
|
|
39
|
+
import { mapMessage } from "foldkit/command";
|
|
40
|
+
import { defineMessageUnion } from "foldkit/message";
|
|
41
|
+
import * as Subscription from "foldkit/subscription";
|
|
42
|
+
// src/index.ts
|
|
43
|
+
/**
|
|
44
|
+
* `foldkit-remote` — normalized application-facing server state.
|
|
45
|
+
*
|
|
46
|
+
* The pure core (entities, selections, the store, the planner, connections,
|
|
47
|
+
* live classification, optimistic layers) performs no I/O. The `Remote.*`
|
|
48
|
+
* helpers that read or mutate go through the `RemoteClient` Effect service.
|
|
49
|
+
*/
|
|
50
|
+
/** A body's dependencies as an explanation carries them: without the owner identity, which holds a symbol. */
|
|
51
|
+
const explainedDependencies = (dependencies) => ({
|
|
52
|
+
fields: dependencies.fields.map(({ entity, key }) => ({
|
|
53
|
+
entity,
|
|
54
|
+
key
|
|
55
|
+
})),
|
|
56
|
+
inputs: dependencies.inputs,
|
|
57
|
+
operations: dependencies.operations,
|
|
58
|
+
operators: dependencies.operators.map(({ id, version }) => ({
|
|
59
|
+
id,
|
|
60
|
+
version
|
|
61
|
+
}))
|
|
62
|
+
});
|
|
63
|
+
const descriptorOf = (entity) => Entity$1.is(entity) ? Entity.from(entity) : entity;
|
|
64
|
+
const selectionOf = (selection) => SelectionTypeId in selection ? Selection.from(selection) : selection;
|
|
65
|
+
const readContract = (source, shape, window) => {
|
|
66
|
+
const ref = {
|
|
67
|
+
...source,
|
|
68
|
+
window: pickWindow(window)
|
|
69
|
+
};
|
|
70
|
+
const relation = relationOf(shape);
|
|
71
|
+
return {
|
|
72
|
+
ref,
|
|
73
|
+
relation,
|
|
74
|
+
requirement: {
|
|
75
|
+
identity: ref.identity,
|
|
76
|
+
window: ref.window,
|
|
77
|
+
select: relation,
|
|
78
|
+
ref
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A read contract whose connection, when a client can place rows in it, also
|
|
84
|
+
* reads every field its body filters and orders by: a changed row is then
|
|
85
|
+
* compared with neighbours that hold those fields. What the view reads, and so
|
|
86
|
+
* decodes, stays its Selection.
|
|
87
|
+
*/
|
|
88
|
+
const placeableReadContract = (contract, body, operators) => {
|
|
89
|
+
if (body === void 0) return contract;
|
|
90
|
+
const { requirement, ref } = contract;
|
|
91
|
+
const encoded = Schema.encodeSync(ref.Input)(ref.input);
|
|
92
|
+
if (Query$1.placement(body, encoded, { operators })._tag !== "Placeable") return contract;
|
|
93
|
+
const missing = Query$1.dependencies(body).fields.filter((field) => !requirement.select.fields.includes(field.key));
|
|
94
|
+
if (missing.length === 0) return contract;
|
|
95
|
+
return {
|
|
96
|
+
...contract,
|
|
97
|
+
requirement: {
|
|
98
|
+
...requirement,
|
|
99
|
+
select: {
|
|
100
|
+
...requirement.select,
|
|
101
|
+
fields: [...requirement.select.fields, ...missing.map((field) => field.key)]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const metaOf = (remote, asked, operators) => {
|
|
107
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
108
|
+
let updatedAt;
|
|
109
|
+
let stale = false;
|
|
110
|
+
let loading = false;
|
|
111
|
+
const seen = /* @__PURE__ */ new Set();
|
|
112
|
+
const touch = (entity, id, requirement) => {
|
|
113
|
+
const key = entityKey(entity, id);
|
|
114
|
+
const memo = `${key}\u0000${stableStringify(requirement)}`;
|
|
115
|
+
if (seen.has(memo)) return;
|
|
116
|
+
seen.add(memo);
|
|
117
|
+
const entry = visible[key];
|
|
118
|
+
if (entry !== void 0 && !entry.tombstone) for (const field of requirement.fields) {
|
|
119
|
+
if (!entry.present.has(field)) continue;
|
|
120
|
+
if (Number.isFinite(entry.updatedAt)) updatedAt = updatedAt === void 0 ? entry.updatedAt : Math.max(updatedAt, entry.updatedAt);
|
|
121
|
+
if (entry.stale.has(field)) stale = true;
|
|
122
|
+
}
|
|
123
|
+
if (isLoadingThrough(remote, entity, id, requirement)) loading = true;
|
|
124
|
+
for (const [field, relation] of Object.entries(requirement.relations ?? {})) {
|
|
125
|
+
const value = readField(visible, key, field);
|
|
126
|
+
if (value._tag === "None" || value.value === null || value.value === void 0) continue;
|
|
127
|
+
for (const ref of targetsOf(value.value, relation)) touch(ref.entity, ref.id, relation);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const requirement of asked.requirements) touch(requirement.entity, requirement.id, requirement);
|
|
131
|
+
for (const connection of asked.connections) {
|
|
132
|
+
const known = remote.connections[connection.identity];
|
|
133
|
+
if (known === void 0) {
|
|
134
|
+
if (isQueryLoading(remote, connection.identity)) loading = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (known.stale) stale = true;
|
|
138
|
+
if (isQueryLoading(remote, connection.identity)) loading = true;
|
|
139
|
+
for (const edge of visibleItems(known, connection.identity, remote.optimistic.overlays, goneIn(remote.entities, remote.optimistic))) {
|
|
140
|
+
if (edge.ref.entity !== connection.select.entity) continue;
|
|
141
|
+
touch(edge.ref.entity, edge.ref.id, connection.select);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
updatedAt,
|
|
146
|
+
stale,
|
|
147
|
+
loading
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* The store a read or plan sees: the base with every pending optimistic layer
|
|
152
|
+
* applied, so a request's patches show until it settles and a temporary id is
|
|
153
|
+
* not planned as a fetch.
|
|
154
|
+
*/
|
|
155
|
+
const storeOf = (bound, model) => {
|
|
156
|
+
const remote = bound.store.get(model);
|
|
157
|
+
return visibleStoreOf(remote.entities, remote.optimistic, bound.operators);
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The Model as it would be with nothing optimistic pending. Every read reaches
|
|
161
|
+
* the layers through the Model it is given, so dropping them here is what makes
|
|
162
|
+
* a read confirmed — no read has to know it is being read confirmed.
|
|
163
|
+
*/
|
|
164
|
+
const withoutOptimistic = (bound, model) => {
|
|
165
|
+
const remote = bound.store.get(model);
|
|
166
|
+
return remote.optimistic.layers.length === 0 && remote.optimistic.overlays.length === 0 ? model : bound.store.set(model, {
|
|
167
|
+
...remote,
|
|
168
|
+
optimistic: emptyOptimistic
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
const confirmed = (bound, projection) => ({
|
|
172
|
+
...projection,
|
|
173
|
+
read: (root) => projection.read(withoutOptimistic(bound, root))
|
|
174
|
+
});
|
|
175
|
+
const visibleStores = /* @__PURE__ */ new WeakMap();
|
|
176
|
+
const visibleStoreOf = (entities, optimistic, operators) => {
|
|
177
|
+
let byLayers = visibleStores.get(operators);
|
|
178
|
+
if (byLayers === void 0) {
|
|
179
|
+
byLayers = /* @__PURE__ */ new WeakMap();
|
|
180
|
+
visibleStores.set(operators, byLayers);
|
|
181
|
+
}
|
|
182
|
+
let byStore = byLayers.get(optimistic.layers);
|
|
183
|
+
if (byStore === void 0) {
|
|
184
|
+
byStore = /* @__PURE__ */ new WeakMap();
|
|
185
|
+
byLayers.set(optimistic.layers, byStore);
|
|
186
|
+
}
|
|
187
|
+
let visible = byStore.get(entities);
|
|
188
|
+
if (visible === void 0) {
|
|
189
|
+
visible = visibleStore(entities, optimistic, operators);
|
|
190
|
+
byStore.set(entities, visible);
|
|
191
|
+
}
|
|
192
|
+
return visible;
|
|
193
|
+
};
|
|
194
|
+
const plannedStores = /* @__PURE__ */ new WeakMap();
|
|
195
|
+
/** `plannedStore` of the visible store, kept by it. */
|
|
196
|
+
const plannedStoreOf = (entities, optimistic, visible) => {
|
|
197
|
+
let planned = plannedStores.get(visible);
|
|
198
|
+
if (planned === void 0) {
|
|
199
|
+
planned = plannedStore(entities, visible, optimistic);
|
|
200
|
+
plannedStores.set(visible, planned);
|
|
201
|
+
}
|
|
202
|
+
return planned;
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* `Failed` naming a field the server settled without a value, when one is
|
|
206
|
+
* among what `relation` reads of the entity; otherwise nothing, and the store
|
|
207
|
+
* merely lacks the value. The reason stays with the server, so a view shows
|
|
208
|
+
* the field it cannot have, not why.
|
|
209
|
+
*/
|
|
210
|
+
const unavailableFailure = (store, key, relation) => {
|
|
211
|
+
const field = unavailableOf(store, key, relation);
|
|
212
|
+
return field === void 0 ? void 0 : {
|
|
213
|
+
_tag: "Failed",
|
|
214
|
+
error: {
|
|
215
|
+
_tag: "Unavailable",
|
|
216
|
+
message: `The server answered without ${field}, and will not answer with it: the field is not available to this client.`
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* A row's decoded value, kept while the data it was assembled from is the same,
|
|
222
|
+
* so a refetch or live patch that brought equal data hands a view the object it
|
|
223
|
+
* already rendered. Keyed on the row's own values object, which the store keeps
|
|
224
|
+
* across equal writes; a relation's targets are covered by comparing what was
|
|
225
|
+
* assembled.
|
|
226
|
+
*/
|
|
227
|
+
const decodedRows = /* @__PURE__ */ new WeakMap();
|
|
228
|
+
const decodeRow = (schema, source, values) => {
|
|
229
|
+
let bySchema = decodedRows.get(schema);
|
|
230
|
+
if (bySchema === void 0) {
|
|
231
|
+
bySchema = {
|
|
232
|
+
decode: Schema.decodeUnknownResult(schema),
|
|
233
|
+
rows: /* @__PURE__ */ new WeakMap()
|
|
234
|
+
};
|
|
235
|
+
decodedRows.set(schema, bySchema);
|
|
236
|
+
}
|
|
237
|
+
const cached = bySchema.rows.get(source);
|
|
238
|
+
if (cached !== void 0 && sameData(cached.values, values)) return cached.decoded;
|
|
239
|
+
const decoded = bySchema.decode(values);
|
|
240
|
+
bySchema.rows.set(source, {
|
|
241
|
+
values,
|
|
242
|
+
decoded
|
|
243
|
+
});
|
|
244
|
+
return decoded;
|
|
245
|
+
};
|
|
246
|
+
const readResults = {
|
|
247
|
+
inner: /* @__PURE__ */ new WeakMap(),
|
|
248
|
+
results: /* @__PURE__ */ new Map()
|
|
249
|
+
};
|
|
250
|
+
const memoRead = (scopes, key, compute) => {
|
|
251
|
+
let scope = readResults;
|
|
252
|
+
for (const by of scopes) {
|
|
253
|
+
let inner = scope.inner.get(by);
|
|
254
|
+
if (inner === void 0) {
|
|
255
|
+
inner = {
|
|
256
|
+
inner: /* @__PURE__ */ new WeakMap(),
|
|
257
|
+
results: /* @__PURE__ */ new Map()
|
|
258
|
+
};
|
|
259
|
+
scope.inner.set(by, inner);
|
|
260
|
+
}
|
|
261
|
+
scope = inner;
|
|
262
|
+
}
|
|
263
|
+
if (scope.results.has(key)) return scope.results.get(key);
|
|
264
|
+
const value = compute();
|
|
265
|
+
scope.results.set(key, value);
|
|
266
|
+
return value;
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* The default clock. It reads `Date.now` when called rather than holding the
|
|
270
|
+
* function, so fake timers installed after the domain was made move it.
|
|
271
|
+
*/
|
|
272
|
+
const wallClock = () => Date.now();
|
|
273
|
+
/** The default `toMessage`: the application reduces `RemoteMessage` itself. */
|
|
274
|
+
const identityMessage = (message) => message;
|
|
275
|
+
/**
|
|
276
|
+
* `Data.satisfy` reached its bound with Surfaces still reading, such as a
|
|
277
|
+
* chain whose every read reveals one more. It names them, so a page that
|
|
278
|
+
* would have rendered them loading fails instead. (A read the server leaves
|
|
279
|
+
* unanswered settles as missing and ends the loop.)
|
|
280
|
+
*/
|
|
281
|
+
var RemoteUnsatisfied = class extends Schema.TaggedError()("RemoteUnsatisfied", {
|
|
282
|
+
surfaces: Schema.Array(Schema.String),
|
|
283
|
+
passes: Schema.Number
|
|
284
|
+
}) {};
|
|
285
|
+
/** A stable key for a live subscription's rows and lists. */
|
|
286
|
+
const liveStreamKey = ({ requirements, queries }) => [...requirements.map((requirement) => `${entityKey(requirement.entity, requirement.id)}:${[...requirement.fields].sort().join(",")}`), ...queries.map((query) => stableStringify(query))].sort().join("|");
|
|
287
|
+
/** Each registered Entity's `Derived.count`s and `Derived.exists`, under the pointing Entity. */
|
|
288
|
+
const derivedMembersBy = (entities) => {
|
|
289
|
+
const byPointing = /* @__PURE__ */ new Map();
|
|
290
|
+
const add = (pointing, member) => {
|
|
291
|
+
const members = byPointing.get(pointing) ?? [];
|
|
292
|
+
members.push(member);
|
|
293
|
+
byPointing.set(pointing, members);
|
|
294
|
+
};
|
|
295
|
+
for (const entity of entities) {
|
|
296
|
+
if (!Entity$1.is(entity)) continue;
|
|
297
|
+
const derived = entity.derived;
|
|
298
|
+
for (const [field, member] of Object.entries(derived)) Derivation.$match(member.derives, {
|
|
299
|
+
Supplied: () => void 0,
|
|
300
|
+
Count: ({ relation }) => add(relation.owner.name, DerivedBy.Count({
|
|
301
|
+
relation: relation.key,
|
|
302
|
+
owner: entity.name,
|
|
303
|
+
field
|
|
304
|
+
})),
|
|
305
|
+
Exists: ({ relation, viewer }) => add(relation.owner.name, DerivedBy.Exists({
|
|
306
|
+
relation: relation.key,
|
|
307
|
+
viewer: viewer.key,
|
|
308
|
+
owner: entity.name,
|
|
309
|
+
field
|
|
310
|
+
}))
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return byPointing;
|
|
314
|
+
};
|
|
315
|
+
/** Declares a Remote domain: its entities, queries, and mutations, plus the submodel. */
|
|
316
|
+
const defineRemote = (config) => {
|
|
317
|
+
const entities = config.entities.map(descriptorOf);
|
|
318
|
+
return {
|
|
319
|
+
entities,
|
|
320
|
+
queries: config.queries ?? [],
|
|
321
|
+
mutations: config.mutations ?? [],
|
|
322
|
+
Model: remoteModelSchema(),
|
|
323
|
+
initial: initialRemoteModel,
|
|
324
|
+
Message: remoteMessageSchema,
|
|
325
|
+
update: updateRemote,
|
|
326
|
+
rpc: RemoteRpc,
|
|
327
|
+
registry: {
|
|
328
|
+
entities: new Map(entities.map((entity) => [entity.name, entity])),
|
|
329
|
+
queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
|
|
330
|
+
mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation])),
|
|
331
|
+
filtered: new Map(config.entities.flatMap((entity) => Entity$1.is(entity) ? [[entity.name, ReadFilter.members(entity.metadata)]] : [])),
|
|
332
|
+
derived: derivedMembersBy(config.entities)
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
};
|
|
336
|
+
/** Binds a Remote domain to its store's location in the application Model. */
|
|
337
|
+
const bindRemote = (definition, ref, operators) => {
|
|
338
|
+
const store = ModelRef.focus(ref);
|
|
339
|
+
return {
|
|
340
|
+
definition,
|
|
341
|
+
store,
|
|
342
|
+
operators,
|
|
343
|
+
contract: {
|
|
344
|
+
kind: "remote",
|
|
345
|
+
name: store.dependency.join(".") || "remote",
|
|
346
|
+
owner: store.owner,
|
|
347
|
+
owns: store.dependency.length === 0 ? [] : [store.dependency],
|
|
348
|
+
observes: store.dependency.length === 0 ? [] : [store.dependency],
|
|
349
|
+
messages: [],
|
|
350
|
+
metadata: []
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
/** The runtime half of `Registered`: a descriptor the domain never declared is an error naming both. */
|
|
355
|
+
const assertRegistered = (bound, kind, registered, name) => {
|
|
356
|
+
if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
|
|
357
|
+
};
|
|
358
|
+
const askedOf = (projection) => ({
|
|
359
|
+
requirements: requirementsOf(projection),
|
|
360
|
+
connections: connectionsOf(projection)
|
|
361
|
+
});
|
|
362
|
+
/**
|
|
363
|
+
* Whether `outer` asks for everything `inner` does of one entity: each field,
|
|
364
|
+
* each relation's own slice of its target, and each page window alike.
|
|
365
|
+
*/
|
|
366
|
+
const sliceCovers = (outer, inner) => outer.entity === inner.entity && inner.fields.every((field) => outer.fields.includes(field)) && Object.entries(inner.relations ?? {}).every(([field, relation]) => {
|
|
367
|
+
const wider = outer.relations?.[field];
|
|
368
|
+
return wider !== void 0 && sliceCovers(wider, relation);
|
|
369
|
+
}) && Object.entries(inner.windows ?? {}).every(([field, window]) => outer.windows?.[field] !== void 0 && stableStringify(outer.windows[field]) === stableStringify(window));
|
|
370
|
+
/**
|
|
371
|
+
* Whether what `outer` asks includes everything `inner` asks: each list with
|
|
372
|
+
* the slice it selects of its rows, and each entity with the slice it reads,
|
|
373
|
+
* relations included. A Surface that shows a read is one whose projection
|
|
374
|
+
* asks for all of it; one that reads part of it cannot be why it is fetched.
|
|
375
|
+
* A Surface that asks for one entity in several pieces is already one
|
|
376
|
+
* requirement here: projection metadata unions them per entity and id.
|
|
377
|
+
*/
|
|
378
|
+
const covers = (outer, inner) => {
|
|
379
|
+
const { connections, requirements } = outer;
|
|
380
|
+
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)));
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Plans what a projection asks against the Model. A connection the Model holds
|
|
384
|
+
* fresh contributes its visible items' selected fields to the entity plan; one
|
|
385
|
+
* it does not hold, or holds stale, is a query to run (its items are planned
|
|
386
|
+
* once the page arrives).
|
|
387
|
+
*
|
|
388
|
+
* A connection whose query failed is not run again here, short of `force`: the
|
|
389
|
+
* failure is what its read shows, and retrying is `Remote.refresh`'s to ask
|
|
390
|
+
* for. The rows it already holds are still planned, since they are on screen.
|
|
391
|
+
*/
|
|
392
|
+
/**
|
|
393
|
+
* One requirement per connection and direction: of the sized windows reading
|
|
394
|
+
* a connection from its start (or end), the widest, which covers the rest.
|
|
395
|
+
* Windows with a cursor are pages of their own and stay as they are.
|
|
396
|
+
*/
|
|
397
|
+
const widest = (remote, connections) => {
|
|
398
|
+
const kept = /* @__PURE__ */ new Map();
|
|
399
|
+
const rest = [];
|
|
400
|
+
for (const connection of connections) {
|
|
401
|
+
const size = windowSize(remote, connection);
|
|
402
|
+
if (size === void 0) {
|
|
403
|
+
rest.push(connection);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const key = `${connection.identity}${String.fromCharCode(0)}${connection.window.first === void 0 ? "last" : "first"}`;
|
|
407
|
+
const current = kept.get(key);
|
|
408
|
+
if (current === void 0) kept.set(key, connection);
|
|
409
|
+
else if (size > (windowSize(remote, current) ?? 0)) kept.set(key, {
|
|
410
|
+
...connection,
|
|
411
|
+
select: Requirement.mergeRelation(current.select, connection.select)
|
|
412
|
+
});
|
|
413
|
+
else kept.set(key, {
|
|
414
|
+
...current,
|
|
415
|
+
select: Requirement.mergeRelation(current.select, connection.select)
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return [...kept.values(), ...rest];
|
|
419
|
+
};
|
|
420
|
+
const withWindow = (connection, window) => ({
|
|
421
|
+
...connection,
|
|
422
|
+
window,
|
|
423
|
+
ref: {
|
|
424
|
+
...connection.ref,
|
|
425
|
+
window
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
/**
|
|
429
|
+
* The page a connection lacks for a window of `size` from its start (or end):
|
|
430
|
+
* the rows after its first segment's end cursor (or before its last's start).
|
|
431
|
+
* Undefined when it holds enough, reaches the end, or does not know where it
|
|
432
|
+
* starts.
|
|
433
|
+
*/
|
|
434
|
+
const missingRows = (known, window, size) => {
|
|
435
|
+
const forward = window.first !== void 0;
|
|
436
|
+
const segment = forward ? known.segments[0] : known.segments[known.segments.length - 1];
|
|
437
|
+
if (segment === void 0) return void 0;
|
|
438
|
+
const [from, to] = forward ? [segment.start, segment.end] : [segment.end, segment.start];
|
|
439
|
+
if (from._tag !== "Terminal" || to._tag !== "Cursor" || segment.edges.length >= size) return void 0;
|
|
440
|
+
const lacking = size - segment.edges.length;
|
|
441
|
+
return forward ? {
|
|
442
|
+
first: lacking,
|
|
443
|
+
after: to.cursor
|
|
444
|
+
} : {
|
|
445
|
+
last: lacking,
|
|
446
|
+
before: to.cursor
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
const planAsked = (remote, asked, options, operators) => {
|
|
450
|
+
const queries = [];
|
|
451
|
+
const items = [];
|
|
452
|
+
const connections = Requirement.mergeConnections(asked.connections);
|
|
453
|
+
for (const connection of widest(remote, connections)) {
|
|
454
|
+
const known = remote.connections[connection.identity];
|
|
455
|
+
const failed = options.force !== true && connection.identity in remote.failures.connections;
|
|
456
|
+
const size = windowSize(remote, connection);
|
|
457
|
+
if (!failed && (known === void 0 || known.stale || options.force === true)) {
|
|
458
|
+
queries.push(size === void 0 ? connection : withWindow(connection, connection.window.first === void 0 ? { last: size } : { first: size }));
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (known === void 0) continue;
|
|
462
|
+
const tail = failed || size === void 0 ? void 0 : missingRows(known, connection.window, size);
|
|
463
|
+
if (tail !== void 0) queries.push(withWindow(connection, tail));
|
|
464
|
+
const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, goneIn(remote.entities, remote.optimistic));
|
|
465
|
+
items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
|
|
466
|
+
}
|
|
467
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
468
|
+
const read = [...asked.requirements, ...items];
|
|
469
|
+
const held = heldOnlyByRequests(remote.entities, remote.optimistic);
|
|
470
|
+
const sent = plan(plannedStoreOf(remote.entities, remote.optimistic, visible), read, options).filter((requirement) => !held.has(entityKey(requirement.entity, requirement.id)));
|
|
471
|
+
return {
|
|
472
|
+
requirements: options.force === true ? sent : withoutFailedFields(remote, sent),
|
|
473
|
+
queries,
|
|
474
|
+
expires: options.freshness === void 0 ? null : deadlineOf(visible, read, options.freshness) ?? null
|
|
475
|
+
};
|
|
476
|
+
};
|
|
477
|
+
/**
|
|
478
|
+
* The plan without the fields whose last read failed. Like a failed query,
|
|
479
|
+
* a failed field is shown rather than retried on its own, so a persistent
|
|
480
|
+
* error is not asked again on every unrelated restart of the read entry. A
|
|
481
|
+
* field leaving the request takes its window and its relation with it.
|
|
482
|
+
*/
|
|
483
|
+
const withoutFailedFields = (remote, planned) => {
|
|
484
|
+
if (Object.keys(remote.failures.fields).length === 0) return planned;
|
|
485
|
+
return planned.flatMap((requirement) => {
|
|
486
|
+
const fields = requirement.fields.filter((field) => !isFieldFailed(remote, requirement.entity, requirement.id, field));
|
|
487
|
+
if (fields.length === requirement.fields.length) return [requirement];
|
|
488
|
+
if (fields.length === 0) return [];
|
|
489
|
+
const kept = new Set(fields);
|
|
490
|
+
const only = (record) => record === void 0 ? void 0 : Object.fromEntries(Object.entries(record).filter(([field]) => kept.has(field)));
|
|
491
|
+
const windows = only(requirement.windows);
|
|
492
|
+
const relations = only(requirement.relations);
|
|
493
|
+
return [{
|
|
494
|
+
entity: requirement.entity,
|
|
495
|
+
id: requirement.id,
|
|
496
|
+
fields,
|
|
497
|
+
...windows === void 0 || Object.keys(windows).length === 0 ? {} : { windows },
|
|
498
|
+
...relations === void 0 || Object.keys(relations).length === 0 ? {} : { relations },
|
|
499
|
+
...requirement.live === void 0 ? {} : { live: requirement.live }
|
|
500
|
+
}];
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
/** The entity requirements a page's edges add under a selection. */
|
|
504
|
+
const itemsOf = (edges, select) => edges.filter((edge) => edge.entity === select.entity).map((edge) => ({
|
|
505
|
+
...select,
|
|
506
|
+
id: edge.id
|
|
507
|
+
}));
|
|
508
|
+
/**
|
|
509
|
+
* The wire request of a planned query; a `QueryRef` identity carries the query
|
|
510
|
+
* name and the canonical encoded input. A connection requirement built by hand
|
|
511
|
+
* with another identity cannot be run, and fails as a query would.
|
|
512
|
+
*/
|
|
513
|
+
const parsedQueryRequest = (query) => {
|
|
514
|
+
const separator = query.identity.indexOf("\0");
|
|
515
|
+
if (separator < 0) return Result.fail(new RemoteQueryError({ message: `connection "${query.identity}" is not a query's: only a QueryRef identity can be run` }));
|
|
516
|
+
try {
|
|
517
|
+
return Result.succeed({
|
|
518
|
+
query: query.identity.slice(0, separator),
|
|
519
|
+
input: JSON.parse(query.identity.slice(separator + 1)),
|
|
520
|
+
window: query.window,
|
|
521
|
+
...query.select === void 0 ? {} : { select: query.select }
|
|
522
|
+
});
|
|
523
|
+
} catch {
|
|
524
|
+
return Result.fail(new RemoteQueryError({ message: `connection "${query.identity}" carries no encoded input` }));
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const queryRequestOf = (query) => Effect.suspend(() => Result.match(parsedQueryRequest(query), {
|
|
528
|
+
onFailure: Effect.fail,
|
|
529
|
+
onSuccess: Effect.succeed
|
|
530
|
+
}));
|
|
531
|
+
/**
|
|
532
|
+
* The live rows and lists of what projections ask. A list read live is a
|
|
533
|
+
* `Data.query`, whose identity is always a query's, so one that is not is
|
|
534
|
+
* this package's bug, and throws.
|
|
535
|
+
*/
|
|
536
|
+
const liveAskOf = (asked) => ({
|
|
537
|
+
requirements: asked.requirements.filter((requirement) => requirement.live === true),
|
|
538
|
+
queries: asked.connections.flatMap((connection) => connection.live === true ? [Result.getOrThrow(parsedQueryRequest(connection))] : [])
|
|
539
|
+
});
|
|
540
|
+
/**
|
|
541
|
+
* The `ConnectionMerged` for a query result, in the client's edge shape.
|
|
542
|
+
* `refreshes` marks the page as answering the connection's refresh, so one
|
|
543
|
+
* Message both merges it and clears `stale`.
|
|
544
|
+
*/
|
|
545
|
+
/**
|
|
546
|
+
* The page a window asked for, or the number of edges that came back instead.
|
|
547
|
+
*
|
|
548
|
+
* A window is a request the client made and the server answered, so more edges
|
|
549
|
+
* than were asked for is a protocol disagreement rather than a windfall: the
|
|
550
|
+
* client cannot tell which of them the window meant, the connection's
|
|
551
|
+
* boundaries stop describing what it holds, and a `first: 25` that quietly
|
|
552
|
+
* becomes a thousand is a memory event with no error attached to it.
|
|
553
|
+
*
|
|
554
|
+
* `undefined` when the page is within its window, or when no size was asked
|
|
555
|
+
* for — `after`/`before` with no `first`/`last` bounds nothing.
|
|
556
|
+
*/
|
|
557
|
+
const overrun = (window, edges) => {
|
|
558
|
+
const asked = window.first ?? window.last;
|
|
559
|
+
return asked !== void 0 && edges.length > asked ? {
|
|
560
|
+
asked,
|
|
561
|
+
got: edges.length
|
|
562
|
+
} : void 0;
|
|
563
|
+
};
|
|
564
|
+
const pageMessage = (connection, result, refreshes = false, window = {}, select, now) => {
|
|
565
|
+
const over = overrun(window, result.edges);
|
|
566
|
+
if (over !== void 0) return {
|
|
567
|
+
_tag: "QueryFailed",
|
|
568
|
+
connection,
|
|
569
|
+
error: {
|
|
570
|
+
_tag: "RemoteProtocolError",
|
|
571
|
+
message: `the server returned ${over.got} edges for a window of ${over.asked}`
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const hasPayload = select !== void 0 && (result.entities !== void 0 && result.entities.length > 0 || result.settled !== void 0 && result.settled.length > 0);
|
|
575
|
+
return {
|
|
576
|
+
_tag: "ConnectionMerged",
|
|
577
|
+
connection,
|
|
578
|
+
page: {
|
|
579
|
+
edges: result.edges.map((edge) => ({
|
|
580
|
+
key: edge.key,
|
|
581
|
+
ref: {
|
|
582
|
+
entity: edge.entity,
|
|
583
|
+
id: edge.id
|
|
584
|
+
}
|
|
585
|
+
})),
|
|
586
|
+
start: result.start,
|
|
587
|
+
end: result.end
|
|
588
|
+
},
|
|
589
|
+
...refreshes ? { refreshes } : {},
|
|
590
|
+
...select === void 0 || !hasPayload ? {} : { select },
|
|
591
|
+
...hasPayload ? {
|
|
592
|
+
entities: [...result.entities ?? []],
|
|
593
|
+
settled: [...result.settled ?? []],
|
|
594
|
+
now: now ?? 0
|
|
595
|
+
} : {}
|
|
596
|
+
};
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* The retention roots of some projections: their requirements, their
|
|
600
|
+
* connections (each with the union of what the projections select of its
|
|
601
|
+
* items), and the connections listed by identity alone.
|
|
602
|
+
*/
|
|
603
|
+
const rootsOf = (projections, options) => {
|
|
604
|
+
const connections = /* @__PURE__ */ new Map();
|
|
605
|
+
for (const identity of (options.connections ?? []).map(connectionIdentity)) connections.set(identity, { identity });
|
|
606
|
+
for (const { identity, select } of projections.flatMap(connectionsOf)) {
|
|
607
|
+
const current = connections.get(identity)?.select;
|
|
608
|
+
connections.set(identity, {
|
|
609
|
+
identity,
|
|
610
|
+
select: current === void 0 ? select : Requirement.mergeRelation(current, select)
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
requirements: Requirement.merge(projections.flatMap(requirementsOf)),
|
|
615
|
+
connections: [...connections.values()].sort((a, b) => a.identity < b.identity ? -1 : 1)
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
/** Reads requirements and reports the Message that settles them. Never fails. */
|
|
619
|
+
const readMessage = (requirements, now) => Effect.gen(function* () {
|
|
620
|
+
const client = yield* RemoteClient;
|
|
621
|
+
const at = now();
|
|
622
|
+
const result = yield* Effect.result(client.read({
|
|
623
|
+
version: 5,
|
|
624
|
+
requests: requirements
|
|
625
|
+
}));
|
|
626
|
+
return Result.isFailure(result) ? {
|
|
627
|
+
_tag: "ReadFailed",
|
|
628
|
+
requests: requirements,
|
|
629
|
+
error: remoteError(result.failure)
|
|
630
|
+
} : {
|
|
631
|
+
_tag: "ReadReceived",
|
|
632
|
+
requests: requirements,
|
|
633
|
+
result: result.success,
|
|
634
|
+
now: at
|
|
635
|
+
};
|
|
636
|
+
});
|
|
637
|
+
/** Runs a query and reports the page that refreshes its connection, or the failure. Never fails. */
|
|
638
|
+
const queryMessage = (query, now = wallClock) => Effect.gen(function* () {
|
|
639
|
+
const client = yield* RemoteClient;
|
|
640
|
+
const result = yield* Effect.result(queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request))));
|
|
641
|
+
return Result.isFailure(result) ? {
|
|
642
|
+
_tag: "QueryFailed",
|
|
643
|
+
connection: query.identity,
|
|
644
|
+
error: remoteError(result.failure)
|
|
645
|
+
} : pageMessage(query.identity, result.success, true, query.window, query.select, now());
|
|
646
|
+
});
|
|
647
|
+
/** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
|
|
648
|
+
const PlannedQuery = Schema.Struct({
|
|
649
|
+
identity: Schema.String,
|
|
650
|
+
window: WindowSchema,
|
|
651
|
+
select: RelationRequest
|
|
652
|
+
});
|
|
653
|
+
/**
|
|
654
|
+
* The read entry: plans what `askedOf(model)` asks against the Model and runs
|
|
655
|
+
* the entity read and the queries concurrently. Each Message it emits changes
|
|
656
|
+
* the Model, so Foldkit recomputes the dependencies and restarts the stream
|
|
657
|
+
* (`switchMap`): a merged page's items are planned by that next computation,
|
|
658
|
+
* against the store as it then is, rather than read here unplanned. Nothing
|
|
659
|
+
* the entry needs is spread over two Messages, since the second could be lost
|
|
660
|
+
* to the restart; a page and its refresh are one `ConnectionMerged`.
|
|
661
|
+
*/
|
|
662
|
+
const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
663
|
+
const { policy = RemotePolicy.cacheFirst, now = wallClock } = options;
|
|
664
|
+
const read = (requirements) => Effect.map(readMessage(requirements, now), toMessage);
|
|
665
|
+
const run = (query) => Effect.map(queryMessage(query, now), toMessage);
|
|
666
|
+
const planned = /* @__PURE__ */ new WeakMap();
|
|
667
|
+
return {
|
|
668
|
+
dependenciesSchema: Schema.Struct({
|
|
669
|
+
requirements: Schema.Array(ReadRequest),
|
|
670
|
+
queries: Schema.Array(PlannedQuery),
|
|
671
|
+
refresh: Schema.Number,
|
|
672
|
+
expires: Schema.NullOr(Schema.Struct({
|
|
673
|
+
at: Schema.Number,
|
|
674
|
+
due: Schema.Array(ReadRequest)
|
|
675
|
+
}))
|
|
676
|
+
}),
|
|
677
|
+
modelToDependencies: (model) => {
|
|
678
|
+
const remote = bound.store.get(model);
|
|
679
|
+
const asked = askedOf(model);
|
|
680
|
+
const askedKey = stableStringify(asked);
|
|
681
|
+
const at = now();
|
|
682
|
+
let byAsked = planned.get(remote);
|
|
683
|
+
const known = byAsked?.get(askedKey);
|
|
684
|
+
if (known !== void 0 && at < known.until) return known.dependencies;
|
|
685
|
+
const plan = planAsked(remote, asked, RemotePolicy.toPlan(policy, at), bound.operators);
|
|
686
|
+
const dependencies = {
|
|
687
|
+
refresh: refreshedAt(remote.refresh, plan.requirements, plan.queries.map((query) => query.identity)),
|
|
688
|
+
requirements: plan.requirements,
|
|
689
|
+
queries: plan.queries.map(({ identity, window, select }) => ({
|
|
690
|
+
identity,
|
|
691
|
+
window,
|
|
692
|
+
select
|
|
693
|
+
})),
|
|
694
|
+
expires: plan.expires
|
|
695
|
+
};
|
|
696
|
+
if (byAsked === void 0) {
|
|
697
|
+
byAsked = /* @__PURE__ */ new Map();
|
|
698
|
+
planned.set(remote, byAsked);
|
|
699
|
+
}
|
|
700
|
+
byAsked.set(askedKey, {
|
|
701
|
+
until: plan.expires?.at ?? Infinity,
|
|
702
|
+
dependencies
|
|
703
|
+
});
|
|
704
|
+
return dependencies;
|
|
705
|
+
},
|
|
706
|
+
dependenciesToStream: ({ requirements, queries, refresh, expires }) => Stream.concat(Stream.fromIterable([...requirements.length === 0 ? [] : [toMessage({
|
|
707
|
+
_tag: "ReadStarted",
|
|
708
|
+
requests: requirements,
|
|
709
|
+
refresh
|
|
710
|
+
}), ...RemotePolicy.refreshes(policy) ? [toMessage({
|
|
711
|
+
_tag: "RefreshStarted",
|
|
712
|
+
requests: requirements
|
|
713
|
+
})] : []], ...queries.length === 0 ? [] : [toMessage({
|
|
714
|
+
_tag: "QueryStarted",
|
|
715
|
+
connections: queries.map((query) => query.identity)
|
|
716
|
+
})]]), Stream.merge(Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }), expires === null ? Stream.empty : Stream.fromEffect(Effect.as(Effect.sleep(Math.max(0, expires.at - now())), toMessage({
|
|
717
|
+
_tag: "RefreshStarted",
|
|
718
|
+
requests: expires.due
|
|
719
|
+
})))))
|
|
720
|
+
};
|
|
721
|
+
};
|
|
722
|
+
const resumeCursor = Schema.Number.pipe(Schema.overrideToEquivalence(() => () => true));
|
|
723
|
+
/** The wait before a stream's restart after `failures` breaks in a row: Sync's transport backoff. */
|
|
724
|
+
const restartDelay = (options, failures) => {
|
|
725
|
+
const base = Duration.toMillis(Duration.fromInputUnsafe(options.retryBase ?? "50 millis"));
|
|
726
|
+
const max = Duration.toMillis(Duration.fromInputUnsafe(options.maxRetryDelay ?? "5 seconds"));
|
|
727
|
+
const backoff = Math.min(max, base * 2 ** Math.min(Math.max(failures, 1) - 1, 30));
|
|
728
|
+
return Duration.millis(backoff * (.8 + Math.random() * .4));
|
|
729
|
+
};
|
|
730
|
+
/** The live entry: subscribes to `liveOf(model)` from the Model's resume cursor. */
|
|
731
|
+
const liveEntry = (bound, liveOf, toMessage, options) => ({
|
|
732
|
+
dependenciesSchema: Schema.Struct({
|
|
733
|
+
requirements: Schema.Array(ReadRequest),
|
|
734
|
+
queries: Schema.Array(QueryRequest),
|
|
735
|
+
cursor: resumeCursor,
|
|
736
|
+
floor: Schema.Number,
|
|
737
|
+
restarts: Schema.Number,
|
|
738
|
+
failures: resumeCursor
|
|
739
|
+
}),
|
|
740
|
+
modelToDependencies: (model) => {
|
|
741
|
+
const asked = liveOf(model);
|
|
742
|
+
const stream = liveStreamKey(asked);
|
|
743
|
+
const remote = bound.store.get(model);
|
|
744
|
+
const health = remote.streams[stream];
|
|
745
|
+
return {
|
|
746
|
+
requirements: asked.requirements,
|
|
747
|
+
queries: asked.queries,
|
|
748
|
+
cursor: remote.live[stream]?.cursor ?? 0,
|
|
749
|
+
floor: remote.refresh.floor,
|
|
750
|
+
restarts: health?.restarts ?? 0,
|
|
751
|
+
failures: health?.failures ?? 0
|
|
752
|
+
};
|
|
753
|
+
},
|
|
754
|
+
dependenciesToStream: ({ requirements, queries, cursor, restarts, failures }) => {
|
|
755
|
+
if (requirements.length === 0 && queries.length === 0) return Stream.empty;
|
|
756
|
+
const stream = liveStreamKey({
|
|
757
|
+
requirements,
|
|
758
|
+
queries
|
|
759
|
+
});
|
|
760
|
+
const live = Stream.unwrap(Effect.gen(function* () {
|
|
761
|
+
return (yield* RemoteClient).live({
|
|
762
|
+
requirements,
|
|
763
|
+
queries,
|
|
764
|
+
after: cursor
|
|
765
|
+
});
|
|
766
|
+
})).pipe(Stream.map((event) => toMessage({
|
|
767
|
+
_tag: "LiveReceived",
|
|
768
|
+
stream,
|
|
769
|
+
event,
|
|
770
|
+
now: (options.now ?? wallClock)()
|
|
771
|
+
})), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
|
|
772
|
+
_tag: "ReadFailed",
|
|
773
|
+
requests: requirements,
|
|
774
|
+
error: remoteError(error),
|
|
775
|
+
stream
|
|
776
|
+
}))));
|
|
777
|
+
if (restarts === 0) return live;
|
|
778
|
+
return Stream.fromEffect(Effect.sleep(restartDelay(options, failures))).pipe(Stream.drain, Stream.concat(Stream.make(toMessage({
|
|
779
|
+
_tag: "RefreshStarted",
|
|
780
|
+
requests: requirements
|
|
781
|
+
}), toMessage({
|
|
782
|
+
_tag: "GapCleared",
|
|
783
|
+
stream
|
|
784
|
+
}))), Stream.concat(live));
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
/** The projection an active entry has for this Model; none while it is inactive. */
|
|
788
|
+
const projectionOf = (entry, model) => {
|
|
789
|
+
if ("projectionOf" in entry) return entry.projectionOf(model);
|
|
790
|
+
if ("projection" in entry) return Option.some(entry.projection());
|
|
791
|
+
return Option.none();
|
|
792
|
+
};
|
|
793
|
+
/**
|
|
794
|
+
* Every projection an entry resolves to for a Model: the lone one of a
|
|
795
|
+
* `Surface.at`/`Surface.when` value or a bare Surface, or a family's parent
|
|
796
|
+
* first and then each instance. The parent joins so an unloaded parent is
|
|
797
|
+
* fetched before what it reveals; `Data.satisfy` reaches the instances in a
|
|
798
|
+
* later pass. A caller that also passes the parent separately double-covers
|
|
799
|
+
* its requirements, as overlapping Surfaces already do.
|
|
800
|
+
*/
|
|
801
|
+
const resolvedOf = (entry, model) => {
|
|
802
|
+
const source = entry;
|
|
803
|
+
if (typeof source.instancesOf === "function") {
|
|
804
|
+
const instances = source.instancesOf(model);
|
|
805
|
+
const from = entry.from;
|
|
806
|
+
if (from === void 0) return instances.map((instance) => ({
|
|
807
|
+
name: entry.name,
|
|
808
|
+
projection: instance.projection
|
|
809
|
+
}));
|
|
810
|
+
return [...Option.toArray(from.projectionOf(model)).map((projection) => ({
|
|
811
|
+
name: from.name,
|
|
812
|
+
projection
|
|
813
|
+
})), ...instances.map((instance) => ({
|
|
814
|
+
name: `${entry.name}[${instance.key}]`,
|
|
815
|
+
projection: instance.projection
|
|
816
|
+
}))];
|
|
817
|
+
}
|
|
818
|
+
return Option.toArray(projectionOf(entry, model)).map((projection) => ({
|
|
819
|
+
name: entry.name,
|
|
820
|
+
projection
|
|
821
|
+
}));
|
|
822
|
+
};
|
|
823
|
+
/** What some projections ask for together: their requirements and connections unioned. */
|
|
824
|
+
const askedUnion = (projections) => ({
|
|
825
|
+
requirements: projections.flatMap(requirementsOf),
|
|
826
|
+
connections: projections.flatMap(connectionsOf)
|
|
827
|
+
});
|
|
828
|
+
/**
|
|
829
|
+
* `resolvedOf` computed once per Model: the read, live, and retain entries
|
|
830
|
+
* all derive their dependencies from the same projections on the same Model
|
|
831
|
+
* change, and a Surface's `model` callback (which lifts and builds schemas)
|
|
832
|
+
* need not run three times for it. Models are objects, so the memo is weak.
|
|
833
|
+
*/
|
|
834
|
+
const memoizedResolved = (entry) => {
|
|
835
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
836
|
+
return (model) => {
|
|
837
|
+
const key = model;
|
|
838
|
+
if (typeof key !== "object" || key === null) return resolvedOf(entry, model);
|
|
839
|
+
const known = cache.get(key);
|
|
840
|
+
if (known !== void 0) return known;
|
|
841
|
+
const resolved = resolvedOf(entry, model);
|
|
842
|
+
cache.set(key, resolved);
|
|
843
|
+
return resolved;
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
/** Why the server refused a write, as `mutation` declares it: none for any other failure, or one that does not decode. */
|
|
847
|
+
const refusalOf = (error, mutation) => error.refusal === void 0 ? Option.none() : Schema.decodeUnknownOption(mutation.Refusal)(error.refusal);
|
|
848
|
+
/** Refuses an entry of another application: two can share a Model type, and the owner token tells them apart. */
|
|
849
|
+
const assertOwnedBy = (contract, entry) => {
|
|
850
|
+
if (contract.owner === void 0) return;
|
|
851
|
+
const from = entry.from;
|
|
852
|
+
for (const surface of from === void 0 ? [entry] : [entry, from]) if (surface.owner !== contract.owner) throw new Error(`Remote: Surface "${surface.name}" belongs to another application than domain "${contract.name}"`);
|
|
853
|
+
};
|
|
854
|
+
/** Whether a refresh target is one list by its query and input. */
|
|
855
|
+
const isQueryRef = (target) => typeof target.identity === "string";
|
|
856
|
+
/** Whether a refresh target is a projection, the one kind that is not a Surface. */
|
|
857
|
+
const isProjection = (target) => typeof target.read === "function";
|
|
858
|
+
/** Whether a refresh target is one entry of an active record rather than the record. */
|
|
859
|
+
const isActiveEntry = (target) => {
|
|
860
|
+
const members = target;
|
|
861
|
+
return typeof members.projectionOf === "function" || typeof members.instancesOf === "function" || typeof members.projection === "function";
|
|
862
|
+
};
|
|
863
|
+
/** Every projection a refresh target reads for this Model, each entry resolved as the read entries resolve it. */
|
|
864
|
+
const targetProjections = (contract, target, model) => {
|
|
865
|
+
if (isProjection(target)) return [target];
|
|
866
|
+
const entries = isActiveEntry(target) ? [target] : Object.values(target);
|
|
867
|
+
for (const entry of entries) assertOwnedBy(contract, entry);
|
|
868
|
+
return entries.flatMap((entry) => resolvedOf(entry, model).map(({ projection }) => projection));
|
|
869
|
+
};
|
|
870
|
+
const Remote = {
|
|
871
|
+
/** The submodel's schema, the same for every domain; embed it in the application Model. */
|
|
872
|
+
Model: remoteModelSchema(),
|
|
873
|
+
/** The submodel's initial value. */
|
|
874
|
+
initial: initialRemoteModel,
|
|
875
|
+
/**
|
|
876
|
+
* Remote's Message cases for `defineMessageUnion`: spread them into the
|
|
877
|
+
* application's union, and reduce the ones `Remote.reduces` recognizes with
|
|
878
|
+
* `RemoteDomain.reduce`.
|
|
879
|
+
*/
|
|
880
|
+
messages: remoteMessageCases,
|
|
881
|
+
/** Whether a Message is one of Remote's, by tag. */
|
|
882
|
+
reduces: isRemoteMessage,
|
|
883
|
+
/**
|
|
884
|
+
* Remote's Messages as one Schema, for a wrapper variant in the
|
|
885
|
+
* application's union: `GotRemoteMessage: { message: Remote.Message }`. With
|
|
886
|
+
* a wrapper, `update` matches the application's union exhaustively and
|
|
887
|
+
* `Remote.fold` reduces what arrives inside it.
|
|
888
|
+
*/
|
|
889
|
+
Message: defineMessageUnion(remoteMessageCases),
|
|
890
|
+
/**
|
|
891
|
+
* A bound domain under one wrapper variant of the application's union, the
|
|
892
|
+
* same shape Foldkit gives a Submodel: `toParentMessage` wraps Remote's
|
|
893
|
+
* Messages, the fold reduces them, and its `fetch`, `mutate`, and
|
|
894
|
+
* `subscriptions` yield the wrapper. Spreading `Remote.messages` and
|
|
895
|
+
* narrowing with `Remote.reduces` remains the shorter path for an
|
|
896
|
+
* application whose `update` only reduces.
|
|
897
|
+
*
|
|
898
|
+
* @example
|
|
899
|
+
* ```ts
|
|
900
|
+
* const Message = defineMessageUnion({ GotRemoteMessage: { message: Remote.Message } })
|
|
901
|
+
* const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
|
|
902
|
+
*
|
|
903
|
+
* const update = (model: Model, message: Message): Return =>
|
|
904
|
+
* Message.match(message, {
|
|
905
|
+
* GotRemoteMessage: ({ message }) => foldData(model, message),
|
|
906
|
+
* })
|
|
907
|
+
* const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
|
|
908
|
+
* foldData.subscriptions({ page: Surface.at(ProjectPage, …) }),
|
|
909
|
+
* )
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
fold: (domain, toParentMessage) => {
|
|
913
|
+
const fold = (model, message) => {
|
|
914
|
+
const reduced = reduceReporting(domain.store, domain.reduce, model, message, true);
|
|
915
|
+
return {
|
|
916
|
+
model: reduced.model,
|
|
917
|
+
commands: (reduced.commands ?? []).map((command) => mapMessage(command, toParentMessage))
|
|
918
|
+
};
|
|
919
|
+
};
|
|
920
|
+
return Object.assign(fold, {
|
|
921
|
+
mutate: (...args) => {
|
|
922
|
+
const started = domain.mutate(...args);
|
|
923
|
+
return {
|
|
924
|
+
...started,
|
|
925
|
+
command: mapMessage(started.command, toParentMessage)
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
retry: (...args) => Option.map(domain.retry(...args), (started) => ({
|
|
929
|
+
...started,
|
|
930
|
+
command: mapMessage(started.command, toParentMessage)
|
|
931
|
+
})),
|
|
932
|
+
preload: (...args) => mapMessage(domain.preload(...args), toParentMessage),
|
|
933
|
+
subscriptions: (active, options) => markAll(messagesAs(brandEntries(domain.subscriptions(active, options)), toParentMessage))
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
/**
|
|
937
|
+
* A patch of one entity's values, for a mutation's `optimistic` list or an
|
|
938
|
+
* overlay. Takes a `foldkit-entity` Entity or a Remote descriptor alike, so
|
|
939
|
+
* the domain you declared once is the one you patch:
|
|
940
|
+
* `Remote.patch(Project, 'p1', { name: 'Apollo II' })`. Values are the
|
|
941
|
+
* fields' wire shape, as the server writes them, except a relation, which is
|
|
942
|
+
* a ref (`owner: Remote.ref(User, 'u1')`), an array of refs, or `null` where
|
|
943
|
+
* it is optional. A ref key typed by hand, or a ref to another Entity, is
|
|
944
|
+
* refused, in the type and when the patch is made.
|
|
945
|
+
*/
|
|
946
|
+
patch: (entity, id, values) => {
|
|
947
|
+
const descriptor = descriptorOf(entity);
|
|
948
|
+
return descriptor.patch(id, wireValues(descriptor, values));
|
|
949
|
+
},
|
|
950
|
+
/**
|
|
951
|
+
* A deletion of one entity, for a mutation's `optimistic` list or an
|
|
952
|
+
* overlay: `Remote.tombstone(Post, 'p1')`. Until it settles the row reads
|
|
953
|
+
* `NotFound` and leaves every list and relation, as an answer's `deleted`
|
|
954
|
+
* does; a failure brings it back. Keeping it gone is the answer's to say.
|
|
955
|
+
*/
|
|
956
|
+
tombstone: (entity, id) => ({
|
|
957
|
+
_tag: "Delete",
|
|
958
|
+
...descriptorOf(entity).ref(id)
|
|
959
|
+
}),
|
|
960
|
+
/**
|
|
961
|
+
* A reference to one entity, for `ConnectionChange` and anything else that
|
|
962
|
+
* names a row: `Remote.ref(Project, 'p3')`. Either kind of entity, as with
|
|
963
|
+
* `Remote.patch`.
|
|
964
|
+
*/
|
|
965
|
+
ref: (entity, id) => descriptorOf(entity).ref(id),
|
|
966
|
+
/**
|
|
967
|
+
* Declares a Remote domain and binds it to its place in the application Model
|
|
968
|
+
* in one step; the result carries the application-facing operations. The
|
|
969
|
+
* descriptor alone is `Remote.define`, the binding alone `Remote.at`.
|
|
970
|
+
*/
|
|
971
|
+
make: (config) => bindDomain(defineRemote(config), config.model, {
|
|
972
|
+
verifyPredictions: config.verifyPredictions,
|
|
973
|
+
operators: config.operators,
|
|
974
|
+
viewer: config.viewer
|
|
975
|
+
}),
|
|
976
|
+
/**
|
|
977
|
+
* Declares a Remote domain without binding it: its entities, queries, and
|
|
978
|
+
* mutations, plus the submodel. For a domain reused across applications or
|
|
979
|
+
* bound in a test; `Remote.make` is the one-step form.
|
|
980
|
+
*/
|
|
981
|
+
define: defineRemote,
|
|
982
|
+
/**
|
|
983
|
+
* Binds a Remote domain to its store's location in the application Model. The
|
|
984
|
+
* registered entity names are carried on the returned value, so `Remote.select`
|
|
985
|
+
* rejects a selection for an entity this domain never declared. `operators`
|
|
986
|
+
* is the client's table, which a pending computed assignment is predicted by
|
|
987
|
+
* (`Operators.kernel` when not given), as `Remote.make`'s is.
|
|
988
|
+
*/
|
|
989
|
+
at: (definition, store, options = {}) => bindRemote(definition, store, options.operators ?? Operators.kernel),
|
|
990
|
+
/**
|
|
991
|
+
* The resume part for this domain, for `foldkit-ssr`'s `SSR.plan({ parts })`:
|
|
992
|
+
* a server render sends what the plan's active Surfaces read from the store,
|
|
993
|
+
* each connection with its boundaries and each live cursor with it, and
|
|
994
|
+
* nothing else. `id` tells two domains in one application apart.
|
|
995
|
+
*/
|
|
996
|
+
resume: resumePart,
|
|
997
|
+
/**
|
|
998
|
+
* A Projection node that reads a `RemoteData` value out of the store. The id
|
|
999
|
+
* is supplied by the caller, usually from a Surface's params. The assembled
|
|
1000
|
+
* value is decoded against the Selection, so malformed server data surfaces
|
|
1001
|
+
* as `Failed` instead of being asserted into `Value`. A present value with a
|
|
1002
|
+
* stale field reads as `Refreshing`: an observer is refetching it.
|
|
1003
|
+
*/
|
|
1004
|
+
select: (bound, given) => {
|
|
1005
|
+
const selection = selectionOf(given);
|
|
1006
|
+
assertRegistered(bound, "Entity", bound.definition.registry.entities, selection.entity);
|
|
1007
|
+
const relation = relationOf(selection);
|
|
1008
|
+
const relationKey = stableStringify(relation);
|
|
1009
|
+
return (id) => ({
|
|
1010
|
+
Model: remoteDataSchema(selection.schema),
|
|
1011
|
+
dependencies: [],
|
|
1012
|
+
metadata: RemoteRequirements.of({
|
|
1013
|
+
...relation,
|
|
1014
|
+
id
|
|
1015
|
+
}),
|
|
1016
|
+
read: (root) => {
|
|
1017
|
+
const store = storeOf(bound, root);
|
|
1018
|
+
const key = entityKey(selection.entity, id);
|
|
1019
|
+
const present = memoRead([store], `${key}\u0000${relationKey}`, () => {
|
|
1020
|
+
if (isTombstone(store, key)) return { _tag: "NotFound" };
|
|
1021
|
+
const assembled = assemble(store, key, relation);
|
|
1022
|
+
if (assembled === void 0) return unavailableFailure(store, key, relation);
|
|
1023
|
+
const decoded = decodeRow(selection.schema, store[key].values, assembled.values);
|
|
1024
|
+
return Result.isFailure(decoded) ? {
|
|
1025
|
+
_tag: "Failed",
|
|
1026
|
+
error: {
|
|
1027
|
+
_tag: "DecodeError",
|
|
1028
|
+
message: decoded.failure.message
|
|
1029
|
+
}
|
|
1030
|
+
} : assembled.refreshing ? {
|
|
1031
|
+
_tag: "Refreshing",
|
|
1032
|
+
value: decoded.success
|
|
1033
|
+
} : {
|
|
1034
|
+
_tag: "Ready",
|
|
1035
|
+
value: decoded.success
|
|
1036
|
+
};
|
|
1037
|
+
});
|
|
1038
|
+
const remote = bound.store.get(root);
|
|
1039
|
+
const failure = failureOf(remote, selection.entity, id, relation);
|
|
1040
|
+
if (present !== void 0) return failure !== void 0 && (present._tag === "Ready" || present._tag === "Refreshing") ? {
|
|
1041
|
+
_tag: "Failed",
|
|
1042
|
+
error: failure,
|
|
1043
|
+
previous: present.value
|
|
1044
|
+
} : present;
|
|
1045
|
+
if (isLoadingThrough(remote, selection.entity, id, relation)) return { _tag: "Loading" };
|
|
1046
|
+
if (failure !== void 0) return {
|
|
1047
|
+
_tag: "Failed",
|
|
1048
|
+
error: failure
|
|
1049
|
+
};
|
|
1050
|
+
if (onlyOverlaid(remote, selection.entity, id)) {
|
|
1051
|
+
const lacking = missingFields(store, key, relation.fields);
|
|
1052
|
+
return {
|
|
1053
|
+
_tag: "Failed",
|
|
1054
|
+
error: {
|
|
1055
|
+
_tag: "Overlaid",
|
|
1056
|
+
message: `${selection.entity} ${id} is shown only by an overlay, which does not hold ${lacking.length === 0 ? "what its relations select" : lacking.join(", ")}; the server has not seen it, so nothing will fetch them.`
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
return { _tag: "Initial" };
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
},
|
|
1064
|
+
/**
|
|
1065
|
+
* When what a projection shows was last received, and whether it is stale
|
|
1066
|
+
* or loading. Pure, so a view can show "updated 5s ago" without I/O.
|
|
1067
|
+
*
|
|
1068
|
+
* `updatedAt` is the newest server write among what is shown, as the
|
|
1069
|
+
* store dates it; `undefined` when nothing shown was received (nothing
|
|
1070
|
+
* loaded, or only an optimistic preview). `stale` is any shown field or
|
|
1071
|
+
* connection marked stale; `loading` is any of them in flight. Optimistic
|
|
1072
|
+
* previews do not date: showing only one reads `updatedAt: undefined`.
|
|
1073
|
+
*/
|
|
1074
|
+
meta: (bound, model, projection) => metaOf(bound.store.get(model), askedOf(projection), bound.operators),
|
|
1075
|
+
/**
|
|
1076
|
+
* The pure plan for a projection against a Model: the requirements its
|
|
1077
|
+
* remote store does not satisfy, under `options` (freshness, force). A
|
|
1078
|
+
* Surface's projection is `surface.projection(params)`.
|
|
1079
|
+
*/
|
|
1080
|
+
plan: (bound, model, projection, options) => planAsked(bound.store.get(model), askedOf(projection), options ?? {}, bound.operators).requirements,
|
|
1081
|
+
/**
|
|
1082
|
+
* The queries a projection needs run before its connections read: those the
|
|
1083
|
+
* Model does not hold, or holds stale (every one under `force`).
|
|
1084
|
+
*/
|
|
1085
|
+
planQueries: (bound, model, projection, options) => planAsked(bound.store.get(model), askedOf(projection), options ?? {}, bound.operators).queries.flatMap((query) => query.ref === void 0 ? [] : [query.ref]),
|
|
1086
|
+
/**
|
|
1087
|
+
* The store reads see: the base store under the pending optimistic layers.
|
|
1088
|
+
* One store is shared by every read and plan of a Model whose remote state
|
|
1089
|
+
* has not changed.
|
|
1090
|
+
*/
|
|
1091
|
+
storeOf: (bound, model) => storeOf(bound, model),
|
|
1092
|
+
/**
|
|
1093
|
+
* The projection read against the server-derived store alone: the same
|
|
1094
|
+
* requirements, planned the same way, with the pending optimistic layers and
|
|
1095
|
+
* connection overlays left off. See the bound `Remote.confirmed`.
|
|
1096
|
+
*/
|
|
1097
|
+
confirmed: (bound, projection) => confirmed(bound, projection),
|
|
1098
|
+
/**
|
|
1099
|
+
* Executes the plan against the `RemoteClient` and returns a new store. Used
|
|
1100
|
+
* for SSR route prefetch, hover prefetch, and tests. Never called during render.
|
|
1101
|
+
* `policy` decides what a present field means (default cache-first); `now`
|
|
1102
|
+
* is the clock it reads, so the planner itself stays pure and time-injected.
|
|
1103
|
+
*/
|
|
1104
|
+
prefetch: Effect.fn("Remote.prefetch")(function* (bound, model, projection, options = {}) {
|
|
1105
|
+
const store = storeOf(bound, model);
|
|
1106
|
+
const { policy = RemotePolicy.cacheFirst, now = wallClock } = options;
|
|
1107
|
+
const at = now();
|
|
1108
|
+
const missing = plan(store, requirementsOf(projection), RemotePolicy.toPlan(policy, at));
|
|
1109
|
+
if (missing.length === 0) return store;
|
|
1110
|
+
yield* Effect.annotateCurrentSpan("requirementCount", missing.length);
|
|
1111
|
+
const result = yield* (yield* RemoteClient).read({
|
|
1112
|
+
version: 5,
|
|
1113
|
+
requests: missing
|
|
1114
|
+
});
|
|
1115
|
+
return writeRead(store, missing, result, at);
|
|
1116
|
+
}),
|
|
1117
|
+
/**
|
|
1118
|
+
* Marks what a read requires as due again: a projection, a Surface without
|
|
1119
|
+
* params, one entry of the active record, or the whole record, each entry
|
|
1120
|
+
* resolved for this Model as `subscriptions` resolves it. Called from
|
|
1121
|
+
* `update`: every selected field the store holds reads `Refreshing`, and
|
|
1122
|
+
* every loaded query connection is invalidated. Nothing is fetched here.
|
|
1123
|
+
* The read entries `Data.subscriptions` derives refetch it, because a stale
|
|
1124
|
+
* field or connection is planned again under every policy, so a refresh never
|
|
1125
|
+
* sends a request beside the entry already observing the same data.
|
|
1126
|
+
*
|
|
1127
|
+
* A refreshed connection's page replaces its pages, so items the server removed
|
|
1128
|
+
* or reordered follow it; pages loaded past the first are dropped and paged
|
|
1129
|
+
* again with `next`/`previous`. The read entries restart, so a read or query
|
|
1130
|
+
* sent before the refresh is not applied after it.
|
|
1131
|
+
*
|
|
1132
|
+
* It is also how a failed read is retried: nothing that failed is asked
|
|
1133
|
+
* again on its own, so its read stays `Failed` until this asks. A failed
|
|
1134
|
+
* read with nothing to show reads `Loading` from here, and `Failed` again if
|
|
1135
|
+
* the retry fails. `{ failed: true }` asks again only what failed, which is
|
|
1136
|
+
* what a "Try again" means: what loaded keeps its value and its pages.
|
|
1137
|
+
*
|
|
1138
|
+
* The projection must be observed, which it is while it is on screen. Live
|
|
1139
|
+
* subscriptions are left as they are. For data nothing observes (SSR, tests),
|
|
1140
|
+
* use `Remote.prefetch` with `RemotePolicy.networkOnly`. Refreshing what is
|
|
1141
|
+
* already being refreshed returns the same Model.
|
|
1142
|
+
*/
|
|
1143
|
+
refresh: (bound, model, target, options = {}) => {
|
|
1144
|
+
const remote = bound.store.get(model);
|
|
1145
|
+
if (isQueryRef(target)) {
|
|
1146
|
+
assertRegistered(bound, "Query", bound.definition.registry.queries, target.query);
|
|
1147
|
+
return Object.hasOwn(remote.failures.connections, target.identity) || options.failed !== true && remote.connections[target.identity] !== void 0 ? bound.store.set(model, invalidateConnections(remote, [target.identity])) : model;
|
|
1148
|
+
}
|
|
1149
|
+
const asked = askedUnion(targetProjections(bound.contract, target, model));
|
|
1150
|
+
const merged = Requirement.mergeConnections(asked.connections);
|
|
1151
|
+
const items = merged.flatMap((connection) => {
|
|
1152
|
+
const known = remote.connections[connection.identity];
|
|
1153
|
+
return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, goneIn(remote.entities, remote.optimistic)).map((edge) => edge.ref), connection.select);
|
|
1154
|
+
});
|
|
1155
|
+
const asking = [...asked.requirements, ...items];
|
|
1156
|
+
const connections = options.failed === true ? merged.filter((connection) => connection.identity in remote.failures.connections) : merged;
|
|
1157
|
+
const requirements = plan(visibleStoreOf(remote.entities, remote.optimistic, bound.operators), options.failed === true ? asking.filter((requirement) => failureOf(remote, requirement.entity, requirement.id, requirement) !== void 0) : asking, { force: true });
|
|
1158
|
+
const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0 || connection.identity in remote.failures.connections).map((connection) => connection.identity);
|
|
1159
|
+
const marked = [...requirements.length === 0 ? [] : [{
|
|
1160
|
+
_tag: "RefreshStarted",
|
|
1161
|
+
requests: requirements
|
|
1162
|
+
}], ...invalidated.map((connection) => ({
|
|
1163
|
+
_tag: "ConnectionInvalidated",
|
|
1164
|
+
connection
|
|
1165
|
+
}))].reduce(updateRemote, remote);
|
|
1166
|
+
const inFlight = requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true && refreshIsInFlight(remote.refresh, requirement, field)));
|
|
1167
|
+
if (marked === remote && !inFlight) return model;
|
|
1168
|
+
const refresh = withRefreshRequested(marked.refresh, requirements, invalidated);
|
|
1169
|
+
return bound.store.set(model, {
|
|
1170
|
+
...marked,
|
|
1171
|
+
refresh
|
|
1172
|
+
});
|
|
1173
|
+
},
|
|
1174
|
+
/**
|
|
1175
|
+
* Forgets everything the server told this Model: values, tombstones,
|
|
1176
|
+
* unavailable fields, connections, live cursors, failures. What is known is
|
|
1177
|
+
* known for a principal, and this is the one boundary for a login, a logout
|
|
1178
|
+
* or a switch of organization: called from `update`, it performs no I/O, and
|
|
1179
|
+
* every active Surface's read and live entries restart, so the screen asks
|
|
1180
|
+
* again as whoever the client now is. A read or stream begun before is
|
|
1181
|
+
* interrupted rather than landing after. A mutation in flight is treated as
|
|
1182
|
+
* applied: its answer writes nothing here.
|
|
1183
|
+
*/
|
|
1184
|
+
forget: (bound, model) => bound.store.set(model, forgetRemote(bound.store.get(model))),
|
|
1185
|
+
/**
|
|
1186
|
+
* Writes a read result into the store, recording each field's applied window.
|
|
1187
|
+
* `requests` are the planned requirements the result answers.
|
|
1188
|
+
*/
|
|
1189
|
+
writeRead,
|
|
1190
|
+
/**
|
|
1191
|
+
* Adapts an Effect RPC client for `RemoteRpc` to `RemoteClient`, so an
|
|
1192
|
+
* application provides the transport's RPC layer instead of writing the
|
|
1193
|
+
* `LiveChange`-to-`LiveEvent` mapping by hand. The client `RpcClient.make`
|
|
1194
|
+
* builds is accepted as it is: when its transport fails (`RpcClientError`),
|
|
1195
|
+
* the call fails with its own Remote error, which the application shows and
|
|
1196
|
+
* retries like any failed read, query, mutation or live stream.
|
|
1197
|
+
*/
|
|
1198
|
+
/**
|
|
1199
|
+
* Remote's client over any way of sending one JSON request and receiving its
|
|
1200
|
+
* answer, for `clientLayer`: a worker, a server in the page. `RemoteServer.answer`
|
|
1201
|
+
* is the other end.
|
|
1202
|
+
*/
|
|
1203
|
+
json,
|
|
1204
|
+
/**
|
|
1205
|
+
* Remote's client over HTTP, for `clientLayer`: each call a `POST` of the JSON
|
|
1206
|
+
* request to `url`. `headers` is read per request (a session token, say).
|
|
1207
|
+
*/
|
|
1208
|
+
http,
|
|
1209
|
+
/**
|
|
1210
|
+
* Remote's client over HTTP with live, for `clientLayer`: reads, queries,
|
|
1211
|
+
* and mutations as `http` sends them, live requirements as one `POST`
|
|
1212
|
+
* answered with a `text/event-stream` of changes.
|
|
1213
|
+
* `foldkit-remote-server/fetch` is the other end.
|
|
1214
|
+
*/
|
|
1215
|
+
httpWithLive,
|
|
1216
|
+
clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
1217
|
+
const context = yield* Effect.context();
|
|
1218
|
+
return {
|
|
1219
|
+
read: (batch) => client.FoldkitRemoteRead(batch).pipe(Effect.catchTag(["RpcClientError", "Unreachable"], (error) => Effect.fail(new RemoteReadError({ message: error.message }))), Effect.provideContext(context)),
|
|
1220
|
+
query: (request) => client.FoldkitRemoteQuery(request).pipe(Effect.catchTag(["RpcClientError", "Unreachable"], (error) => Effect.fail(new RemoteQueryError({ message: error.message }))), Effect.provideContext(context)),
|
|
1221
|
+
mutate: (request) => client.FoldkitRemoteMutate(request).pipe(Effect.catchTag("RpcClientError", (error) => Effect.fail(new RemoteUnreachable({ message: error.message }))), Effect.provideContext(context)),
|
|
1222
|
+
live: ({ requirements, queries, after }) => client.FoldkitRemoteLive({
|
|
1223
|
+
version: 5,
|
|
1224
|
+
requirements,
|
|
1225
|
+
...queries.length === 0 ? {} : { queries },
|
|
1226
|
+
after
|
|
1227
|
+
}).pipe(Stream.catchTag(["RpcClientError", "Unreachable"], (error) => Stream.fail(new RemoteLiveError({ message: error.message }))), Stream.map(liveEventOf), Stream.provideContext(context))
|
|
1228
|
+
};
|
|
1229
|
+
})), options),
|
|
1230
|
+
/**
|
|
1231
|
+
* Wraps a `RemoteClient` layer so its reads coalesce: requirements issued
|
|
1232
|
+
* together become one batch, and a requirement already in flight is joined.
|
|
1233
|
+
* `Remote.clientLayer` applies this; use it on a hand-written client.
|
|
1234
|
+
*/
|
|
1235
|
+
coalesced: coalescedLayer,
|
|
1236
|
+
/**
|
|
1237
|
+
* A Foldkit Subscription entry that keeps the cache to what the active
|
|
1238
|
+
* Surfaces reach. `projections` are the ones the application observes (the
|
|
1239
|
+
* same it passes to `Remote.observe`), `connections` the query connections
|
|
1240
|
+
* it shows; anything else is collected once the roots have been stable for
|
|
1241
|
+
* `grace`, so a route transition that comes straight back does not thrash.
|
|
1242
|
+
*/
|
|
1243
|
+
retain: (projections, toMessage = identityMessage, options = {}) => {
|
|
1244
|
+
const roots = rootsOf(projections, options);
|
|
1245
|
+
return {
|
|
1246
|
+
dependenciesSchema: retentionRootsSchema,
|
|
1247
|
+
modelToDependencies: () => roots,
|
|
1248
|
+
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed(toMessage({
|
|
1249
|
+
_tag: "RetentionChanged",
|
|
1250
|
+
roots: current
|
|
1251
|
+
})).pipe(Effect.delay(options.grace ?? 0)))
|
|
1252
|
+
};
|
|
1253
|
+
},
|
|
1254
|
+
/**
|
|
1255
|
+
* Runs a `Query` through `RemoteClient`, encoding its input from the ref.
|
|
1256
|
+
* Pair the result with `Remote.queryMessage` to merge the page into the Model.
|
|
1257
|
+
* With `select`, the server also returns the selected fields of the page's
|
|
1258
|
+
* items, so one response populates both the connection and the store.
|
|
1259
|
+
*/
|
|
1260
|
+
query: Effect.fn("Remote.query")(function* (ref, select) {
|
|
1261
|
+
const client = yield* RemoteClient;
|
|
1262
|
+
const input = yield* Schema.encodeUnknownEffect(ref.Input)(ref.input).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteQueryError({ message: error.message }))));
|
|
1263
|
+
return yield* client.query({
|
|
1264
|
+
query: ref.query,
|
|
1265
|
+
input,
|
|
1266
|
+
window: ref.window,
|
|
1267
|
+
...select === void 0 ? {} : { select }
|
|
1268
|
+
});
|
|
1269
|
+
}),
|
|
1270
|
+
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
1271
|
+
queryMessage: (ref, result, select, now) => pageMessage(ref.identity, result, false, ref.window, select, now),
|
|
1272
|
+
/** A pure, serializable view of the whole cache. */
|
|
1273
|
+
inspect: inspectRemote,
|
|
1274
|
+
/** A pure, serializable view of one entity, or `undefined` if unknown. */
|
|
1275
|
+
inspectEntity,
|
|
1276
|
+
/**
|
|
1277
|
+
* Runs a mutation through `RemoteClient`, decoding its typed Output and
|
|
1278
|
+
* returning the result's normalized patches so the caller can reconcile them
|
|
1279
|
+
* through `updateRemote`'s `MutationSucceeded` (or `Remote.mutateInto`).
|
|
1280
|
+
*/
|
|
1281
|
+
mutate: mutateRemote,
|
|
1282
|
+
/**
|
|
1283
|
+
* Reads the clock where the reader acts, for `Data.mutate`'s `now`: a
|
|
1284
|
+
* Command that yields `toMessage(now)`, the time in milliseconds since the
|
|
1285
|
+
* epoch. It reads Effect's `Clock`, so a test pins it, and `Trace.run`
|
|
1286
|
+
* runs it at the trace's time. Chirp's compose box answers a submit with
|
|
1287
|
+
* `Remote.now(now => Message.ClockedPost({ post, now }))`, and
|
|
1288
|
+
* `ClockedPost` starts the write.
|
|
1289
|
+
*/
|
|
1290
|
+
now: (toMessage) => ({
|
|
1291
|
+
name: "Remote.now",
|
|
1292
|
+
effect: Effect.map(Clock.currentTimeMillis, toMessage)
|
|
1293
|
+
}),
|
|
1294
|
+
/**
|
|
1295
|
+
* Runs a mutation and reconciles its patches into a `RemoteModel` in one step,
|
|
1296
|
+
* returning the new model alongside the typed Output. The model-level form of
|
|
1297
|
+
* `MutationStarted` → `RemoteClient.mutate` → `MutationSucceeded`; in an
|
|
1298
|
+
* application the three are `update` (with `optimistic`), a Command, and the
|
|
1299
|
+
* Message the Command returns, so the optimistic operations show meanwhile.
|
|
1300
|
+
*/
|
|
1301
|
+
mutateInto: Effect.fn("Remote.mutateInto")(function* (bound, model, mutation, input, requestId, options = {}) {
|
|
1302
|
+
const started = updateRemote(bound.store.get(model), {
|
|
1303
|
+
_tag: "MutationStarted",
|
|
1304
|
+
requestId,
|
|
1305
|
+
...options.optimistic === void 0 ? {} : { optimistic: options.optimistic }
|
|
1306
|
+
});
|
|
1307
|
+
const outcome = yield* mutateRemote(mutation, input, requestId, { now: options.now });
|
|
1308
|
+
const settled = updateRemote(started, {
|
|
1309
|
+
_tag: "MutationSucceeded",
|
|
1310
|
+
requestId,
|
|
1311
|
+
entities: outcome.entities,
|
|
1312
|
+
connections: outcome.connections,
|
|
1313
|
+
deleted: outcome.deleted,
|
|
1314
|
+
now: yield* Clock.currentTimeMillis
|
|
1315
|
+
});
|
|
1316
|
+
return {
|
|
1317
|
+
output: outcome.output,
|
|
1318
|
+
model: bound.store.set(model, settled)
|
|
1319
|
+
};
|
|
1320
|
+
}),
|
|
1321
|
+
/**
|
|
1322
|
+
* A Foldkit Subscription entry that plans a Surface's missing fields from the
|
|
1323
|
+
* Model and fetches them through `RemoteClient`, emitting a `RemoteMessage`
|
|
1324
|
+
* per outcome. Wrap it in an application Message (`toMessage`) and reduce it
|
|
1325
|
+
* with the domain's `update`. Under a refreshing `policy` the entry first
|
|
1326
|
+
* emits `RefreshStarted`, so the fields being refetched read as `Refreshing`
|
|
1327
|
+
* while the request is pending.
|
|
1328
|
+
*/
|
|
1329
|
+
observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => askedOf(surface.projection(params)), toMessage, options),
|
|
1330
|
+
/**
|
|
1331
|
+
* A Foldkit Subscription entry that consumes the live stream for a Surface's
|
|
1332
|
+
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
1333
|
+
* carrying its `stream` when the stream breaks (including
|
|
1334
|
+
* `ResumeUnavailable`). That records a gap on the stream rather than a failed
|
|
1335
|
+
* read, since nothing was being read, and restarts the entry after a
|
|
1336
|
+
* backoff; the restart refetches what it covers. The resume cursor is read
|
|
1337
|
+
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
1338
|
+
*/
|
|
1339
|
+
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => {
|
|
1340
|
+
const projection = surface.projection(params);
|
|
1341
|
+
return {
|
|
1342
|
+
...liveAskOf(askedOf(projection)),
|
|
1343
|
+
requirements: requirementsOf(projection)
|
|
1344
|
+
};
|
|
1345
|
+
}, toMessage, options)
|
|
1346
|
+
};
|
|
1347
|
+
/**
|
|
1348
|
+
* Each entry with its Messages as the parent's and its dependencies as they
|
|
1349
|
+
* were. The Model is the parent's already, and `Subscription.lift` would wrap
|
|
1350
|
+
* every entry's dependencies in `maybeDependencies` (since Foldkit 0.166).
|
|
1351
|
+
*/
|
|
1352
|
+
const messagesAs = (entries, toParentMessage) => Record.map(entries, (entry) => ({
|
|
1353
|
+
...entry,
|
|
1354
|
+
dependenciesToStream: (dependencies, read) => Stream.map(entry.dependenciesToStream(dependencies, read), toParentMessage)
|
|
1355
|
+
}));
|
|
1356
|
+
/**
|
|
1357
|
+
* An answer with what it read folded into its patches, one per row, so a list
|
|
1358
|
+
* is placed by every value the answer wrote, a derived field it read included.
|
|
1359
|
+
*/
|
|
1360
|
+
const withAnswerRead = (answer) => {
|
|
1361
|
+
if (answer.read === void 0) return answer;
|
|
1362
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1363
|
+
for (const patch of [...answer.entities, ...answer.read.result.entities]) {
|
|
1364
|
+
const key = entityKey(patch.entity, patch.id);
|
|
1365
|
+
const held = rows.get(key);
|
|
1366
|
+
rows.set(key, held === void 0 ? patch : {
|
|
1367
|
+
...held,
|
|
1368
|
+
values: {
|
|
1369
|
+
...held.values,
|
|
1370
|
+
...patch.values
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return {
|
|
1375
|
+
...answer,
|
|
1376
|
+
entities: [...rows.values()]
|
|
1377
|
+
};
|
|
1378
|
+
};
|
|
1379
|
+
const brandEntries = (entries) => Subscription.make()(() => entries);
|
|
1380
|
+
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
1381
|
+
const bindDomain = (definition, ref, options = {}) => {
|
|
1382
|
+
const operators = options.operators ?? Operators.kernel;
|
|
1383
|
+
const bound = bindRemote(definition, ref, operators);
|
|
1384
|
+
const store = bound.store;
|
|
1385
|
+
const viewerOf = options.viewer ?? (() => Option.none());
|
|
1386
|
+
const contextOf = (model) => ({
|
|
1387
|
+
viewer: viewerOf(model),
|
|
1388
|
+
now: Option.none()
|
|
1389
|
+
});
|
|
1390
|
+
const readers = /* @__PURE__ */ new Map();
|
|
1391
|
+
const keptConnections = /* @__PURE__ */ new Set();
|
|
1392
|
+
/**
|
|
1393
|
+
* What to do with a row a live event says was inserted into a connection.
|
|
1394
|
+
*
|
|
1395
|
+
* Resolved here rather than in the pure reducer because this is the only
|
|
1396
|
+
* place with both the Model and the registry, and two separate things needed
|
|
1397
|
+
* one or the other.
|
|
1398
|
+
*
|
|
1399
|
+
* **The declared policy reaches the decision.** `Query.connection(E, { live })`
|
|
1400
|
+
* has always been typed, documented, carried on the descriptor and encoded in
|
|
1401
|
+
* the Message schema — and nothing ever put it on a Message, so every live
|
|
1402
|
+
* insert took the default whatever an application asked for. It does now.
|
|
1403
|
+
*
|
|
1404
|
+
* **A row the client can judge does not need a policy.** The policy exists
|
|
1405
|
+
* because nothing could tell whether an inserted row belonged to the query.
|
|
1406
|
+
* Where the body says it does not, `ignore` is not a guess. Where the body
|
|
1407
|
+
* says it does, or the client cannot tell — a row it never fetched, or holds
|
|
1408
|
+
* without a field the body reads — the declared policy is the answer, exactly
|
|
1409
|
+
* as it was meant to be.
|
|
1410
|
+
*
|
|
1411
|
+
* Only membership is decided here. *Where* a row sorts needs text collation,
|
|
1412
|
+
* which is the backend's, so the position the event carries stands.
|
|
1413
|
+
*/
|
|
1414
|
+
const livePolicyFor = (model, event) => {
|
|
1415
|
+
if (event._tag !== "ConnectionInsert") return void 0;
|
|
1416
|
+
const identity = event.connection;
|
|
1417
|
+
const descriptor = definition.registry.queries.get(identity.slice(0, identity.indexOf("\0")));
|
|
1418
|
+
const declared = (descriptor?.Result)?.live;
|
|
1419
|
+
if (descriptor?.body === void 0) return declared;
|
|
1420
|
+
const encoded = JSON.parse(identity.slice(identity.indexOf("\0") + 1));
|
|
1421
|
+
return belongsEncoded(storeOf(bound, model), descriptor, encoded, entityKey(event.edge.ref.entity, event.edge.ref.id), operators, contextOf(model)) === "no" ? {
|
|
1422
|
+
prepend: "ignore",
|
|
1423
|
+
append: "ignore"
|
|
1424
|
+
} : declared;
|
|
1425
|
+
};
|
|
1426
|
+
const reduce = (model, message) => {
|
|
1427
|
+
const live = message._tag === "LiveReceived" ? message : void 0;
|
|
1428
|
+
const resolved = live === void 0 || live.policy !== void 0 ? message : {
|
|
1429
|
+
...live,
|
|
1430
|
+
policy: livePolicyFor(model, live.event)
|
|
1431
|
+
};
|
|
1432
|
+
const remote = store.get(model);
|
|
1433
|
+
const reduced = updateRemote(remote, resolved);
|
|
1434
|
+
const answer = message._tag === "MutationSucceeded" ? message : void 0;
|
|
1435
|
+
const next = answer === void 0 ? placedNews(reduced, newsOf(message), remote.entities, contextOf(model)) : impactOf(reduced, remote.mutations.applied.has(answer.requestId) ? answer : withAnswerRead(answer), remote.entities, contextOf(model));
|
|
1436
|
+
const checked = answer === void 0 || options.verifyPredictions !== true ? next : withAnswerChecked(model, remote, next, answer);
|
|
1437
|
+
return checked === remote ? model : store.set(model, checked);
|
|
1438
|
+
};
|
|
1439
|
+
/**
|
|
1440
|
+
* `after` with each part of the request's prediction the answer disagreed
|
|
1441
|
+
* with (`verifyPredictions`): a field it showed that the answer holds
|
|
1442
|
+
* otherwise (`Value`), and a row it left without fields the client showed
|
|
1443
|
+
* (`Gap`).
|
|
1444
|
+
*/
|
|
1445
|
+
const withAnswerChecked = (model, before, after, answer) => {
|
|
1446
|
+
const { requestId } = answer;
|
|
1447
|
+
if (before.mutations.applied.has(requestId)) return after;
|
|
1448
|
+
const mutation = Option.map(Option.fromUndefinedOr(before.mutations.writes.get(requestId)), (write) => write.mutation);
|
|
1449
|
+
const settled = store.set(model, after);
|
|
1450
|
+
const projections = [...readers.values()].flatMap((resolvedAt) => resolvedAt(settled).map(({ projection }) => projection));
|
|
1451
|
+
const found = [...valueMismatches(requestId, mutation, before, after, answer), ...answerGaps(requestId, mutation, before.optimistic, after.optimistic, visibleStoreOf(after.entities, after.optimistic, operators), planAsked(unverified(after), askedUnion(projections), {}, operators).requirements)];
|
|
1452
|
+
return found.length === 0 ? after : {
|
|
1453
|
+
...after,
|
|
1454
|
+
diagnostics: withDiagnostics(after.diagnostics, found)
|
|
1455
|
+
};
|
|
1456
|
+
};
|
|
1457
|
+
/**
|
|
1458
|
+
* `remote` with each list placed only to be checked (`placed`) read as it
|
|
1459
|
+
* would be without the check: such a list is fetched for its page, and a
|
|
1460
|
+
* stale list plans no reads of its rows, so a gap in them would go unseen.
|
|
1461
|
+
*/
|
|
1462
|
+
const unverified = (remote) => {
|
|
1463
|
+
const checked = Object.keys(remote.placed).filter((identity) => remote.connections[identity] !== void 0);
|
|
1464
|
+
if (checked.length === 0) return remote;
|
|
1465
|
+
const connections = { ...remote.connections };
|
|
1466
|
+
for (const identity of checked) connections[identity] = {
|
|
1467
|
+
...connections[identity],
|
|
1468
|
+
stale: false
|
|
1469
|
+
};
|
|
1470
|
+
return {
|
|
1471
|
+
...remote,
|
|
1472
|
+
connections
|
|
1473
|
+
};
|
|
1474
|
+
};
|
|
1475
|
+
/**
|
|
1476
|
+
* Each field the request's layer showed that its answer carries another
|
|
1477
|
+
* value of. A field the answer does not carry is the `Gap`'s; one a request
|
|
1478
|
+
* still pending also patches shows that request's value before and after,
|
|
1479
|
+
* so it is not judged.
|
|
1480
|
+
*/
|
|
1481
|
+
const valueMismatches = (requestId, mutation, before, after, answer) => {
|
|
1482
|
+
const layer = before.optimistic.layers.find((each) => each.id === requestId);
|
|
1483
|
+
if (layer === void 0) return [];
|
|
1484
|
+
const shown = visibleStoreOf(before.entities, before.optimistic, operators);
|
|
1485
|
+
const answered = /* @__PURE__ */ new Map();
|
|
1486
|
+
for (const row of [...answer.entities, ...answer.read?.result.entities ?? []]) {
|
|
1487
|
+
const key = entityKey(row.entity, row.id);
|
|
1488
|
+
answered.set(key, {
|
|
1489
|
+
...answered.get(key),
|
|
1490
|
+
...row.values
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
const stillPatched = new Set(after.optimistic.layers.flatMap((each) => each.patches.flatMap((patch) => patchedFields(patch).map((field) => `${entityKey(patch.entity, patch.id)}.${field}`))));
|
|
1494
|
+
return layer.patches.flatMap((patch) => {
|
|
1495
|
+
const key = entityKey(patch.entity, patch.id);
|
|
1496
|
+
const values = answered.get(key);
|
|
1497
|
+
const entry = shown[key];
|
|
1498
|
+
if (values === void 0 || entry === void 0 || entry.tombstone) return [];
|
|
1499
|
+
return patchedFields(patch).flatMap((field) => stillPatched.has(`${key}.${field}`) || !entry.present.has(field) || !Object.hasOwn(values, field) || sameData(entry.values[field], values[field]) ? [] : [predictionMismatch(PredictionPart.Value({
|
|
1500
|
+
requestId,
|
|
1501
|
+
mutation,
|
|
1502
|
+
entity: patch.entity,
|
|
1503
|
+
id: patch.id,
|
|
1504
|
+
field,
|
|
1505
|
+
shown: entry.values[field],
|
|
1506
|
+
answered: values[field]
|
|
1507
|
+
}))]);
|
|
1508
|
+
});
|
|
1509
|
+
};
|
|
1510
|
+
/** The registered query a connection identity names, and its encoded input. */
|
|
1511
|
+
const queryOf = (identity) => {
|
|
1512
|
+
const separator = identity.indexOf("\0");
|
|
1513
|
+
const descriptor = definition.registry.queries.get(identity.slice(0, separator));
|
|
1514
|
+
if (descriptor === void 0) return Option.none();
|
|
1515
|
+
const encoded = JSON.parse(identity.slice(separator + 1));
|
|
1516
|
+
const over = descriptor.body?.entity.name;
|
|
1517
|
+
const filtered = (over === void 0 ? void 0 : definition.registry.filtered.get(over)) ?? [];
|
|
1518
|
+
return Option.some({
|
|
1519
|
+
descriptor,
|
|
1520
|
+
encoded,
|
|
1521
|
+
filtered
|
|
1522
|
+
});
|
|
1523
|
+
};
|
|
1524
|
+
/**
|
|
1525
|
+
* `pages` with each change to a row they hold placed, judged against
|
|
1526
|
+
* `store`: a row that moved goes where the body's order puts it, and one
|
|
1527
|
+
* that stopped matching leaves. A row a pending write creates joins where
|
|
1528
|
+
* the body puts it, since the write holds all it is. Another row a change
|
|
1529
|
+
* brings in is not added, since what the list selects of it may not be
|
|
1530
|
+
* loaded, and a change the client cannot place leaves the row where it is.
|
|
1531
|
+
*/
|
|
1532
|
+
const placedHeld = (identity, pages, changes, store, context) => {
|
|
1533
|
+
const query = queryOf(identity);
|
|
1534
|
+
if (Option.isNone(query)) return pages;
|
|
1535
|
+
const holds = new Set(items(pages).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
|
|
1536
|
+
let placed = pages;
|
|
1537
|
+
for (const change of changes) {
|
|
1538
|
+
const created = EntityChange.match(change, {
|
|
1539
|
+
Changed: ({ created }) => created,
|
|
1540
|
+
Deleted: () => false
|
|
1541
|
+
});
|
|
1542
|
+
if (!holds.has(entityKey(change.entity, change.id)) && !created) continue;
|
|
1543
|
+
placed = Impact.$match(impactOn({
|
|
1544
|
+
...query.value,
|
|
1545
|
+
connection: placed,
|
|
1546
|
+
holds
|
|
1547
|
+
}, store, change, operators, context), {
|
|
1548
|
+
Kept: () => placed,
|
|
1549
|
+
Placed: ({ connection }) => connection,
|
|
1550
|
+
Invalidated: () => placed
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
return placed;
|
|
1554
|
+
};
|
|
1555
|
+
/**
|
|
1556
|
+
* What a list body's context reads while pending layers are placed: the
|
|
1557
|
+
* viewer the newest operation was predicted for. The viewer is the
|
|
1558
|
+
* reader's, so every operation since the last `forget` names the same one.
|
|
1559
|
+
*/
|
|
1560
|
+
const layersContext = (layers) => {
|
|
1561
|
+
const viewer = [...layers].reverse().find((layer) => Option.isSome(layer.viewer))?.viewer;
|
|
1562
|
+
return viewer === void 0 ? void 0 : {
|
|
1563
|
+
viewer,
|
|
1564
|
+
now: Option.none()
|
|
1565
|
+
};
|
|
1566
|
+
};
|
|
1567
|
+
/**
|
|
1568
|
+
* A loaded list with the pending layers placed in its pages, so a row a
|
|
1569
|
+
* pending edit moves, or takes out of the list, shows where its answer
|
|
1570
|
+
* will be placed until the edit settles.
|
|
1571
|
+
*/
|
|
1572
|
+
const pendingPlaced = (remote, identity, pages) => {
|
|
1573
|
+
const { layers } = remote.optimistic;
|
|
1574
|
+
if (layers.length === 0) return pages;
|
|
1575
|
+
return memoRead([
|
|
1576
|
+
pages,
|
|
1577
|
+
layers,
|
|
1578
|
+
remote.entities
|
|
1579
|
+
], `placed:${identity}`, () => placedHeld(identity, pages, changesOf({ entities: resolvedPatches(remote.entities, remote.optimistic, operators) }, remote.entities), visibleStoreOf(remote.entities, remote.optimistic, operators), layersContext(layers)));
|
|
1580
|
+
};
|
|
1581
|
+
/**
|
|
1582
|
+
* The edges a loaded list shows: its pages with pending edits placed, and
|
|
1583
|
+
* each row an insert overlay shows (a pending create, a confirmed or a live
|
|
1584
|
+
* insert) where the body's order puts it, or left out when it sorts past
|
|
1585
|
+
* what is loaded. An overlay says a row is in the list; in a list the
|
|
1586
|
+
* client can place, the order says where. A row it cannot place stays at
|
|
1587
|
+
* the end its overlay names.
|
|
1588
|
+
*/
|
|
1589
|
+
const shownEdges = (remote, identity, connection) => {
|
|
1590
|
+
const pages = pendingPlaced(remote, identity, connection);
|
|
1591
|
+
const shown = remote.optimistic.overlays.some((overlay) => overlay.connection === identity && overlay.position !== "remove") ? insertsPlaced(remote, identity, pages) : {
|
|
1592
|
+
pages,
|
|
1593
|
+
overlays: remote.optimistic.overlays
|
|
1594
|
+
};
|
|
1595
|
+
return visibleItems(shown.pages, identity, shown.overlays, goneIn(remote.entities, remote.optimistic));
|
|
1596
|
+
};
|
|
1597
|
+
const insertsPlaced = (remote, identity, pages) => {
|
|
1598
|
+
const { overlays } = remote.optimistic;
|
|
1599
|
+
const store = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
1600
|
+
return memoRead([
|
|
1601
|
+
pages,
|
|
1602
|
+
overlays,
|
|
1603
|
+
store
|
|
1604
|
+
], `inserted:${identity}`, () => {
|
|
1605
|
+
const query = queryOf(identity);
|
|
1606
|
+
const body = Option.isSome(query) ? query.value.descriptor.body : void 0;
|
|
1607
|
+
if (Option.isNone(query) || body === void 0) return {
|
|
1608
|
+
pages,
|
|
1609
|
+
overlays
|
|
1610
|
+
};
|
|
1611
|
+
const inserted = /* @__PURE__ */ new Map();
|
|
1612
|
+
for (const overlay of overlays) {
|
|
1613
|
+
if (overlay.connection !== identity || overlay.position === "remove") continue;
|
|
1614
|
+
for (const edge of overlay.edges) inserted.set(edge.key, edge);
|
|
1615
|
+
}
|
|
1616
|
+
const held = new Set(items(pages).map((edge) => edge.key));
|
|
1617
|
+
let placed = pages;
|
|
1618
|
+
const outside = [];
|
|
1619
|
+
for (const edge of inserted.values()) {
|
|
1620
|
+
if (held.has(edge.key)) continue;
|
|
1621
|
+
const key = entityKey(edge.ref.entity, edge.ref.id);
|
|
1622
|
+
RowPlace.$match(placeIn(store, placed, body, query.value.encoded, key, operators), {
|
|
1623
|
+
Before: (place) => void (placed = withEdge(placed, edge, place)),
|
|
1624
|
+
End: (place) => void (placed = withEdge(placed, edge, place)),
|
|
1625
|
+
Outside: () => void outside.push(edge),
|
|
1626
|
+
Unknown: () => void 0
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return {
|
|
1630
|
+
pages: placed,
|
|
1631
|
+
overlays: outside.length === 0 ? overlays : [...overlays, {
|
|
1632
|
+
id: "placed:outside",
|
|
1633
|
+
connection: identity,
|
|
1634
|
+
edges: outside,
|
|
1635
|
+
position: "remove"
|
|
1636
|
+
}]
|
|
1637
|
+
};
|
|
1638
|
+
});
|
|
1639
|
+
};
|
|
1640
|
+
/**
|
|
1641
|
+
* The loaded lists with what the server said of rows they hold placed: a
|
|
1642
|
+
* read or a live patch that changes a held row's ordering or filtered field
|
|
1643
|
+
* moves it, or takes it out, as a mutation's answer does. A list it cannot
|
|
1644
|
+
* place stands as it is, as a read is no write: its own refetch or live
|
|
1645
|
+
* stream says otherwise. Only a value the client held before counts, so a
|
|
1646
|
+
* row's first read judges nothing.
|
|
1647
|
+
*/
|
|
1648
|
+
const placedNews = (remote, news, before, context) => {
|
|
1649
|
+
if (news.length === 0) return remote;
|
|
1650
|
+
const held = news.map((patch) => {
|
|
1651
|
+
const values = before[entityKey(patch.entity, patch.id)]?.values ?? {};
|
|
1652
|
+
return {
|
|
1653
|
+
...patch,
|
|
1654
|
+
values: Object.fromEntries(Object.entries(patch.values).filter(([field]) => Object.hasOwn(values, field)))
|
|
1655
|
+
};
|
|
1656
|
+
});
|
|
1657
|
+
const changes = changesOf({ entities: held }, before);
|
|
1658
|
+
if (changes.length === 0) return remote;
|
|
1659
|
+
const placed = {};
|
|
1660
|
+
for (const [identity, pages] of Object.entries(remote.connections)) {
|
|
1661
|
+
const after = placedHeld(identity, pages, changes, remote.entities, context);
|
|
1662
|
+
if (after !== pages) placed[identity] = after;
|
|
1663
|
+
}
|
|
1664
|
+
return withPlacements(remote, placed, []);
|
|
1665
|
+
};
|
|
1666
|
+
/** The rows a read or a live patch says the server holds now. */
|
|
1667
|
+
const newsOf = (message) => {
|
|
1668
|
+
if (message._tag === "ReadReceived") return message.result.entities;
|
|
1669
|
+
if (message._tag !== "LiveReceived") return [];
|
|
1670
|
+
const event = message.event;
|
|
1671
|
+
if (event._tag !== "EntityPatched") return [];
|
|
1672
|
+
return [{
|
|
1673
|
+
entity: event.ref.entity,
|
|
1674
|
+
id: event.ref.id,
|
|
1675
|
+
values: event.values
|
|
1676
|
+
}];
|
|
1677
|
+
};
|
|
1678
|
+
/**
|
|
1679
|
+
* The loaded connections as a write left them: each judged by its body
|
|
1680
|
+
* against the store as the write left it, a change it can place placed in
|
|
1681
|
+
* its pages, and one it cannot invalidated for its read entry to fetch.
|
|
1682
|
+
*/
|
|
1683
|
+
const impactOf = (remote, answer, before, context) => {
|
|
1684
|
+
const changes = changesOf(answer, before);
|
|
1685
|
+
if (changes.length === 0) return remote;
|
|
1686
|
+
const named = /* @__PURE__ */ new Map();
|
|
1687
|
+
for (const change of answer.connections ?? []) {
|
|
1688
|
+
const keys = named.get(change.connection) ?? /* @__PURE__ */ new Set();
|
|
1689
|
+
keys.add(change.edge.key);
|
|
1690
|
+
named.set(change.connection, keys);
|
|
1691
|
+
}
|
|
1692
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
1693
|
+
const placed = {};
|
|
1694
|
+
const invalidated = [];
|
|
1695
|
+
for (const identity of Object.keys(remote.connections)) {
|
|
1696
|
+
const answered = named.get(identity);
|
|
1697
|
+
const judged = answered === void 0 ? changes : changes.filter((change) => !answered.has(entityKey(change.entity, change.id)));
|
|
1698
|
+
if (judged.length === 0) continue;
|
|
1699
|
+
const query = queryOf(identity);
|
|
1700
|
+
if (Option.isNone(query)) continue;
|
|
1701
|
+
const original = remote.connections[identity];
|
|
1702
|
+
const holds = new Set(visibleItems(original, identity, remote.optimistic.overlays, goneIn(remote.entities, remote.optimistic)).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
|
|
1703
|
+
let pages = original;
|
|
1704
|
+
let invalid = false;
|
|
1705
|
+
for (const change of judged) {
|
|
1706
|
+
const held = {
|
|
1707
|
+
...query.value,
|
|
1708
|
+
connection: pages,
|
|
1709
|
+
holds
|
|
1710
|
+
};
|
|
1711
|
+
const after = Impact.$match(impactOn(held, visible, change, operators, context), {
|
|
1712
|
+
Kept: () => Option.some(pages),
|
|
1713
|
+
Placed: ({ connection }) => Option.some(connection),
|
|
1714
|
+
Invalidated: () => Option.none()
|
|
1715
|
+
});
|
|
1716
|
+
if (Option.isNone(after)) {
|
|
1717
|
+
invalid = true;
|
|
1718
|
+
break;
|
|
1719
|
+
}
|
|
1720
|
+
pages = after.value;
|
|
1721
|
+
}
|
|
1722
|
+
if (invalid) invalidated.push(identity);
|
|
1723
|
+
else if (pages !== original) placed[identity] = pages;
|
|
1724
|
+
}
|
|
1725
|
+
return withPlacements(remote, placed, invalidated);
|
|
1726
|
+
};
|
|
1727
|
+
/**
|
|
1728
|
+
* The lists placed and invalidated. Under `verifyPredictions`, each placed
|
|
1729
|
+
* list is fetched too, and the order it was placed in kept to compare with
|
|
1730
|
+
* the server's page.
|
|
1731
|
+
*/
|
|
1732
|
+
const withPlacements = (remote, placed, invalidated) => {
|
|
1733
|
+
const identities = Object.keys(placed);
|
|
1734
|
+
if (identities.length === 0) return invalidateConnections(remote, invalidated);
|
|
1735
|
+
const withPlaced = {
|
|
1736
|
+
...remote,
|
|
1737
|
+
connections: {
|
|
1738
|
+
...remote.connections,
|
|
1739
|
+
...placed
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
if (options.verifyPredictions !== true) return invalidateConnections(withPlaced, invalidated);
|
|
1743
|
+
const firstPage = (connection) => (connection?.segments[0]?.edges ?? []).map((edge) => edge.key);
|
|
1744
|
+
const orders = Object.fromEntries(identities.map((identity) => {
|
|
1745
|
+
const order = firstPage(placed[identity]);
|
|
1746
|
+
const kept = new Set(order);
|
|
1747
|
+
return [identity, {
|
|
1748
|
+
order,
|
|
1749
|
+
dropped: firstPage(remote.connections[identity]).filter((key) => !kept.has(key))
|
|
1750
|
+
}];
|
|
1751
|
+
}));
|
|
1752
|
+
return invalidateConnections({
|
|
1753
|
+
...withPlaced,
|
|
1754
|
+
placed: {
|
|
1755
|
+
...remote.placed,
|
|
1756
|
+
...orders
|
|
1757
|
+
}
|
|
1758
|
+
}, [...invalidated, ...identities]);
|
|
1759
|
+
};
|
|
1760
|
+
const assertOwned = (entry) => assertOwnedBy(bound.contract, entry);
|
|
1761
|
+
/**
|
|
1762
|
+
* What the reader is shown of each entity in `model`, and what the write
|
|
1763
|
+
* itself shows (`predicted`), for its answer to carry (#245, #265).
|
|
1764
|
+
*/
|
|
1765
|
+
const shownAt = (model, predicted) => shownOf(askedUnion([...readers.values()].flatMap((resolvedAt) => resolvedAt(model).map(({ projection }) => projection))), predicted);
|
|
1766
|
+
/**
|
|
1767
|
+
* A durable write's request id, which is its operation id: this page's
|
|
1768
|
+
* session and the next in its sequence, unique across tabs and loads. Only
|
|
1769
|
+
* a retry names one, and only one the outbox holds.
|
|
1770
|
+
*/
|
|
1771
|
+
const durableId = (remote, mutation, given) => {
|
|
1772
|
+
if (given !== void 0) {
|
|
1773
|
+
if (remote.outbox.queue.some((queued) => operationOf(queued) === given)) return given;
|
|
1774
|
+
throw new Error(`[foldkit-remote] Data.mutate(${mutation}, { durable: true, requestId: "${given}" }): a durable write is named by the outbox; pass no requestId`);
|
|
1775
|
+
}
|
|
1776
|
+
return Option.match(remote.outbox.session, {
|
|
1777
|
+
onNone: () => {
|
|
1778
|
+
throw new Error(`[foldkit-remote] Data.mutate(${mutation}, { durable: true }) before the outbox's restore has run: start init from Data.outbox(…).restore(model), as the runtime's flags, so a durable write has an id no other tab or page load uses`);
|
|
1779
|
+
},
|
|
1780
|
+
onSome: (session) => `${session}-${remote.mutations.sequence + 1}`
|
|
1781
|
+
});
|
|
1782
|
+
};
|
|
1783
|
+
/** The descriptor a recorded write names; only a hand-built `MutationStarted` names another. */
|
|
1784
|
+
const registeredMutation = (name) => {
|
|
1785
|
+
const descriptor = definition.registry.mutations.get(name);
|
|
1786
|
+
if (descriptor === void 0) throw new Error(`Remote: a failed write names mutation "${name}", which domain "${bound.contract.name}" does not register`);
|
|
1787
|
+
return descriptor;
|
|
1788
|
+
};
|
|
1789
|
+
/**
|
|
1790
|
+
* `prefetch` and `satisfy`, each Message they reduce also kept in `heard`,
|
|
1791
|
+
* so `preload` can hand them to the Model as it is by the time they land.
|
|
1792
|
+
*/
|
|
1793
|
+
const prefetchInto = (model, projection, options, heard) => {
|
|
1794
|
+
const hear = (current, message) => {
|
|
1795
|
+
heard.push(message);
|
|
1796
|
+
return reduce(current, message);
|
|
1797
|
+
};
|
|
1798
|
+
return Effect.gen(function* () {
|
|
1799
|
+
const { policy = RemotePolicy.cacheFirst, now = wallClock } = options;
|
|
1800
|
+
const client = yield* RemoteClient;
|
|
1801
|
+
const planOptions = RemotePolicy.toPlan(policy, now());
|
|
1802
|
+
let current = model;
|
|
1803
|
+
for (const query of planAsked(store.get(current), askedOf(projection), planOptions, operators).queries) {
|
|
1804
|
+
const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
|
|
1805
|
+
current = hear(current, pageMessage(query.identity, page, true, query.window, query.select, now()));
|
|
1806
|
+
}
|
|
1807
|
+
const requirements = planAsked(store.get(current), askedOf(projection), planOptions, operators).requirements;
|
|
1808
|
+
if (requirements.length === 0) return current;
|
|
1809
|
+
const at = now();
|
|
1810
|
+
const result = yield* client.read({
|
|
1811
|
+
version: 5,
|
|
1812
|
+
requests: requirements
|
|
1813
|
+
});
|
|
1814
|
+
return hear(current, {
|
|
1815
|
+
_tag: "ReadReceived",
|
|
1816
|
+
requests: requirements,
|
|
1817
|
+
result,
|
|
1818
|
+
now: at
|
|
1819
|
+
});
|
|
1820
|
+
});
|
|
1821
|
+
};
|
|
1822
|
+
const satisfyInto = (model, active, options, heard) => Effect.gen(function* () {
|
|
1823
|
+
const { passes = 8, now = wallClock } = options;
|
|
1824
|
+
const entries = Object.values(active);
|
|
1825
|
+
entries.forEach(assertOwned);
|
|
1826
|
+
const planOptions = RemotePolicy.toPlan(RemotePolicy.cacheFirst, now());
|
|
1827
|
+
const plans = (current, projections) => {
|
|
1828
|
+
const planned = planAsked(store.get(current), askedUnion(projections), planOptions, operators);
|
|
1829
|
+
return planned.queries.length > 0 || planned.requirements.length > 0;
|
|
1830
|
+
};
|
|
1831
|
+
let current = model;
|
|
1832
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
1833
|
+
if (!entries.some((entry) => plans(current, resolvedOf(entry, current).map(({ projection }) => projection)))) return current;
|
|
1834
|
+
for (const entry of entries) for (const { projection } of resolvedOf(entry, current)) current = yield* prefetchInto(current, projection, { now }, heard);
|
|
1835
|
+
}
|
|
1836
|
+
const reading = entries.flatMap((entry) => resolvedOf(entry, current).filter(({ projection }) => plans(current, [projection])));
|
|
1837
|
+
if (reading.length === 0) return current;
|
|
1838
|
+
return yield* new RemoteUnsatisfied({
|
|
1839
|
+
surfaces: reading.map(({ name }) => name),
|
|
1840
|
+
passes
|
|
1841
|
+
});
|
|
1842
|
+
});
|
|
1843
|
+
const domain = {
|
|
1844
|
+
...definition,
|
|
1845
|
+
...bound,
|
|
1846
|
+
get: (selection, id) => Remote.select(bound, selection)(id),
|
|
1847
|
+
live: (selection, id) => {
|
|
1848
|
+
const projection = Remote.select(bound, selection)(id);
|
|
1849
|
+
return {
|
|
1850
|
+
...projection,
|
|
1851
|
+
metadata: RemoteRequirements.of(...requirementsOf(projection).map((requirement) => ({
|
|
1852
|
+
...requirement,
|
|
1853
|
+
live: true
|
|
1854
|
+
})))
|
|
1855
|
+
};
|
|
1856
|
+
},
|
|
1857
|
+
active: (name, projectionOf) => {
|
|
1858
|
+
const { owner } = bound.contract;
|
|
1859
|
+
if (owner === void 0) throw new Error(`Remote: "${name}" reads domain "${bound.contract.name}", whose Model field is a raw optic that names no application; make the domain from an application's field (App.model.remote) to give it reads`);
|
|
1860
|
+
return {
|
|
1861
|
+
name,
|
|
1862
|
+
owner,
|
|
1863
|
+
messages: [],
|
|
1864
|
+
projectionOf
|
|
1865
|
+
};
|
|
1866
|
+
},
|
|
1867
|
+
subscriptions: (active, options = {}) => {
|
|
1868
|
+
const entries = {};
|
|
1869
|
+
for (const entry of Object.values(active)) assertOwned(entry);
|
|
1870
|
+
for (const [key, entry] of Object.entries(active)) {
|
|
1871
|
+
const resolvedAt = readers.get(entry) ?? memoizedResolved(entry);
|
|
1872
|
+
readers.set(entry, resolvedAt);
|
|
1873
|
+
const asked = (model) => askedUnion(resolvedAt(model).map(({ projection }) => projection));
|
|
1874
|
+
entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
|
|
1875
|
+
entries[`${key}.live`] = liveEntry(bound, (model) => liveAskOf(asked(model)), identityMessage, options);
|
|
1876
|
+
}
|
|
1877
|
+
for (const identity of options.connections ?? []) keptConnections.add(connectionIdentity(identity));
|
|
1878
|
+
entries.retain = {
|
|
1879
|
+
dependenciesSchema: retentionRootsSchema,
|
|
1880
|
+
modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((resolvedAt) => resolvedAt(model).map(({ projection }) => projection)), { connections: [...keptConnections] }),
|
|
1881
|
+
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
|
|
1882
|
+
_tag: "RetentionChanged",
|
|
1883
|
+
roots: current
|
|
1884
|
+
}).pipe(Effect.delay(options.grace ?? 0)))
|
|
1885
|
+
};
|
|
1886
|
+
markAll(entries);
|
|
1887
|
+
return entries;
|
|
1888
|
+
},
|
|
1889
|
+
plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
|
|
1890
|
+
meta: (model, projection) => Remote.meta(bound, model, projection),
|
|
1891
|
+
storeOf: (model) => storeOf(bound, model),
|
|
1892
|
+
confirmed: (projection) => confirmed(bound, projection),
|
|
1893
|
+
prefetch: (model, projection, options = {}) => prefetchInto(model, projection, options, []),
|
|
1894
|
+
satisfy: (model, active, options = {}) => satisfyInto(model, active, options, []),
|
|
1895
|
+
preload: (model, active, options = {}) => {
|
|
1896
|
+
Object.values(active).forEach(assertOwned);
|
|
1897
|
+
return {
|
|
1898
|
+
name: "Remote.preload",
|
|
1899
|
+
args: { active: Object.keys(active).join(", ") },
|
|
1900
|
+
effect: Effect.suspend(() => {
|
|
1901
|
+
const heard = [];
|
|
1902
|
+
return satisfyInto(model, active, options, heard).pipe(Effect.exit, Effect.map(() => ({
|
|
1903
|
+
_tag: "Preloaded",
|
|
1904
|
+
messages: heard
|
|
1905
|
+
})));
|
|
1906
|
+
})
|
|
1907
|
+
};
|
|
1908
|
+
},
|
|
1909
|
+
query: (query, input, options) => {
|
|
1910
|
+
assertRegistered(bound, "Query", definition.registry.queries, query.name);
|
|
1911
|
+
const { select: given, live, ...window } = options;
|
|
1912
|
+
const select = selectionOf(given);
|
|
1913
|
+
for (const [side, size] of [["first", window.first], ["last", window.last]]) if (size !== void 0 && !(Number.isInteger(size) && size >= 0)) throw new Error(`Remote: query "${query.name}" asks for ${side}: ${String(size)}; a page size is a non-negative integer`);
|
|
1914
|
+
if (live === true && window.first === void 0) throw new Error(`Remote: query "${query.name}" is read live without first; a list watched live is read again every tick, so it needs a size`);
|
|
1915
|
+
const listed = query.Result.entity;
|
|
1916
|
+
if (listed === void 0) throw new Error(`Remote: query "${query.name}" is not a connection over an entity, so it has no page to select`);
|
|
1917
|
+
if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
|
|
1918
|
+
const contract = placeableReadContract(readContract(query.ref(input), select, window), query.body, operators);
|
|
1919
|
+
const { ref, relation } = contract;
|
|
1920
|
+
const requirement = live === true ? {
|
|
1921
|
+
...contract.requirement,
|
|
1922
|
+
live
|
|
1923
|
+
} : contract.requirement;
|
|
1924
|
+
const relationKey = stableStringify(relation);
|
|
1925
|
+
const sizeOf = (remote) => windowSize(remote, ref);
|
|
1926
|
+
const shown = (remote, connection) => cutToWindow(shownEdges(remote, ref.identity, connection), ref.window, sizeOf(remote));
|
|
1927
|
+
const failedItem = (remote, connection) => {
|
|
1928
|
+
if (Object.keys(remote.failures.fields).length === 0) return void 0;
|
|
1929
|
+
for (const edge of shown(remote, connection).edges) {
|
|
1930
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
1931
|
+
const error = failureOf(remote, edge.ref.entity, edge.ref.id, relation);
|
|
1932
|
+
if (error !== void 0) return {
|
|
1933
|
+
_tag: "Failed",
|
|
1934
|
+
error
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
const loadingItem = (remote, connection) => {
|
|
1939
|
+
if (remote.loading.size === 0) return void 0;
|
|
1940
|
+
for (const edge of shown(remote, connection).edges) {
|
|
1941
|
+
if (edge.ref.entity !== relation.entity) continue;
|
|
1942
|
+
if (isLoadingThrough(remote, edge.ref.entity, edge.ref.id, relation)) return { _tag: "Loading" };
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
const readPage = (remote, connection) => {
|
|
1946
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
1947
|
+
return memoRead([
|
|
1948
|
+
visible,
|
|
1949
|
+
connection,
|
|
1950
|
+
remote.optimistic.overlays
|
|
1951
|
+
], `${ref.identity}\u0000${relationKey}\u0000${String(sizeOf(remote))}`, () => {
|
|
1952
|
+
const rows = [];
|
|
1953
|
+
let refreshing = connection.stale;
|
|
1954
|
+
let missing = false;
|
|
1955
|
+
const { edges, cutBefore, cutAfter } = shown(remote, connection);
|
|
1956
|
+
for (const edge of edges) {
|
|
1957
|
+
const key = entityKey(edge.ref.entity, edge.ref.id);
|
|
1958
|
+
const assembled = assemble(visible, key, relation);
|
|
1959
|
+
if (assembled === void 0) {
|
|
1960
|
+
const unavailable = unavailableFailure(visible, key, relation);
|
|
1961
|
+
if (unavailable !== void 0) return unavailable;
|
|
1962
|
+
missing = true;
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
const decoded = decodeRow(select.schema, visible[key].values, assembled.values);
|
|
1966
|
+
if (Result.isFailure(decoded)) return {
|
|
1967
|
+
_tag: "Failed",
|
|
1968
|
+
error: {
|
|
1969
|
+
_tag: "DecodeError",
|
|
1970
|
+
message: decoded.failure.message
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
refreshing ||= assembled.refreshing;
|
|
1974
|
+
rows.push(decoded.success);
|
|
1975
|
+
}
|
|
1976
|
+
if (missing && rows.length === 0 && !deletedInWindow(items(connection), new Set(shownEdges(remote, ref.identity, connection).map((edge) => edge.key)), ref.window, sizeOf(remote), goneIn(remote.entities, remote.optimistic))) return;
|
|
1977
|
+
const page = {
|
|
1978
|
+
items: rows,
|
|
1979
|
+
hasNext: cutAfter || hasNext(connection),
|
|
1980
|
+
hasPrevious: cutBefore || hasPrevious(connection)
|
|
1981
|
+
};
|
|
1982
|
+
return refreshing || missing ? {
|
|
1983
|
+
_tag: "Refreshing",
|
|
1984
|
+
value: page
|
|
1985
|
+
} : {
|
|
1986
|
+
_tag: "Ready",
|
|
1987
|
+
value: page
|
|
1988
|
+
};
|
|
1989
|
+
});
|
|
1990
|
+
};
|
|
1991
|
+
return {
|
|
1992
|
+
Model: remoteDataSchema(pageSchema(select.schema)),
|
|
1993
|
+
dependencies: [],
|
|
1994
|
+
metadata: RemoteConnections.of(requirement),
|
|
1995
|
+
ref,
|
|
1996
|
+
selection: select,
|
|
1997
|
+
read: (root) => {
|
|
1998
|
+
const remote = store.get(root);
|
|
1999
|
+
const connection = remote.connections[ref.identity];
|
|
2000
|
+
const failure = remote.failures.connections[ref.identity];
|
|
2001
|
+
if (connection === void 0 || connection.stale && connection.segments.length === 0) {
|
|
2002
|
+
if (isQueryLoading(remote, ref.identity)) return { _tag: "Loading" };
|
|
2003
|
+
return failure === void 0 ? { _tag: "Initial" } : {
|
|
2004
|
+
_tag: "Failed",
|
|
2005
|
+
error: failure
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
const read = readPage(remote, connection) ?? failedItem(remote, connection) ?? loadingItem(remote, connection) ?? { _tag: "Initial" };
|
|
2009
|
+
if (failure === void 0) {
|
|
2010
|
+
if (read._tag !== "Ready" && read._tag !== "Refreshing") return read;
|
|
2011
|
+
const item = failedItem(remote, connection);
|
|
2012
|
+
return item === void 0 ? read : {
|
|
2013
|
+
_tag: "Failed",
|
|
2014
|
+
error: item.error,
|
|
2015
|
+
previous: read.value
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
switch (read._tag) {
|
|
2019
|
+
case "Ready":
|
|
2020
|
+
case "Refreshing": return {
|
|
2021
|
+
_tag: "Failed",
|
|
2022
|
+
error: failure,
|
|
2023
|
+
previous: read.value
|
|
2024
|
+
};
|
|
2025
|
+
case "Failed": return read;
|
|
2026
|
+
case "Initial":
|
|
2027
|
+
case "Loading":
|
|
2028
|
+
case "NotFound": return {
|
|
2029
|
+
_tag: "Failed",
|
|
2030
|
+
error: failure
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
},
|
|
2036
|
+
more: (model, projection) => {
|
|
2037
|
+
const remote = store.get(model);
|
|
2038
|
+
const { ref } = projection;
|
|
2039
|
+
const size = windowSize(remote, ref);
|
|
2040
|
+
const page = ref.window.first ?? ref.window.last;
|
|
2041
|
+
if (size === void 0 || page === void 0 || page === 0) return Option.none();
|
|
2042
|
+
const read = projection.read(model);
|
|
2043
|
+
return (read._tag === "Ready" || read._tag === "Refreshing") && (ref.window.first === void 0 ? read.value.hasPrevious : read.value.hasNext) ? Option.some(bound.store.set(model, updateRemote(remote, {
|
|
2044
|
+
_tag: "WindowGrown",
|
|
2045
|
+
window: windowGrowthKey(ref.identity, stableStringify(ref.window)),
|
|
2046
|
+
size: size + page
|
|
2047
|
+
}))) : Option.none();
|
|
2048
|
+
},
|
|
2049
|
+
explain: (model, projection, options) => {
|
|
2050
|
+
const { ref } = projection;
|
|
2051
|
+
const reading = Object.values(options?.surfaces ?? {}).flatMap((source) => {
|
|
2052
|
+
const matched = resolvedOf(source, model).filter(({ projection: shown }) => connectionsOf(shown).some((connection) => connection.identity === ref.identity));
|
|
2053
|
+
if (matched.length === 0) return [];
|
|
2054
|
+
return [{
|
|
2055
|
+
source,
|
|
2056
|
+
names: matched.map(({ name }) => name)
|
|
2057
|
+
}];
|
|
2058
|
+
});
|
|
2059
|
+
const body = definition.registry.queries.get(ref.query)?.body;
|
|
2060
|
+
const [connection] = RemoteConnections.get(projection.metadata);
|
|
2061
|
+
return {
|
|
2062
|
+
domain: bound.contract.name,
|
|
2063
|
+
query: ref.query,
|
|
2064
|
+
input: Schema.encodeSync(ref.Input)(ref.input),
|
|
2065
|
+
identity: ref.identity,
|
|
2066
|
+
window: ref.window,
|
|
2067
|
+
select: connection.select,
|
|
2068
|
+
...body === void 0 ? {} : {
|
|
2069
|
+
body: Query$1.show(body),
|
|
2070
|
+
dependencies: explainedDependencies(Query$1.dependencies(body))
|
|
2071
|
+
},
|
|
2072
|
+
state: projection.read(model)._tag,
|
|
2073
|
+
...options?.surfaces === void 0 ? {} : {
|
|
2074
|
+
surfaces: reading.flatMap((read) => read.names),
|
|
2075
|
+
activation: reading.flatMap((read) => {
|
|
2076
|
+
const activation = "activation" in read.source ? read.source.activation : void 0;
|
|
2077
|
+
return activation === void 0 ? [] : [{
|
|
2078
|
+
surface: read.source.name,
|
|
2079
|
+
...activation
|
|
2080
|
+
}];
|
|
2081
|
+
})
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
},
|
|
2085
|
+
why: (model, projection, options) => {
|
|
2086
|
+
const data = projection.read(model);
|
|
2087
|
+
const asked = askedOf(projection);
|
|
2088
|
+
const reading = (options?.surfaces === void 0 ? [] : Object.values(options.surfaces)).flatMap((entry) => {
|
|
2089
|
+
const names = resolvedOf(entry, model).filter(({ projection: shown }) => covers(askedOf(shown), asked)).map(({ name }) => name);
|
|
2090
|
+
return names.length === 0 ? [] : [{
|
|
2091
|
+
entry,
|
|
2092
|
+
names
|
|
2093
|
+
}];
|
|
2094
|
+
});
|
|
2095
|
+
const surfaces = options?.surfaces === void 0 ? Option.none() : Option.some(reading.flatMap((read) => read.names));
|
|
2096
|
+
const initial = data._tag !== "Initial" ? [] : [Option.match(surfaces, {
|
|
2097
|
+
onNone: () => Why.NoActiveRecord(),
|
|
2098
|
+
onSome: (names) => names.length === 0 ? Why.NotObserved() : Why.NotFetching({ surfaces: names })
|
|
2099
|
+
})];
|
|
2100
|
+
const remote = store.get(model);
|
|
2101
|
+
const streams = asked.requirements.some((requirement) => requirement.live === true) || asked.connections.some((connection) => connection.live === true) ? reading.flatMap(({ entry }) => {
|
|
2102
|
+
return Match.valueTags(domain.liveStatus(model, entry), {
|
|
2103
|
+
Idle: () => [],
|
|
2104
|
+
Live: () => [],
|
|
2105
|
+
Reconnecting: ({ attempt, error }) => [Why.LiveReconnecting({
|
|
2106
|
+
surface: entry.name,
|
|
2107
|
+
attempt,
|
|
2108
|
+
error
|
|
2109
|
+
})],
|
|
2110
|
+
Refused: ({ error }) => [Why.LiveRefused({
|
|
2111
|
+
surface: entry.name,
|
|
2112
|
+
error
|
|
2113
|
+
})]
|
|
2114
|
+
});
|
|
2115
|
+
}) : [];
|
|
2116
|
+
const reader = {
|
|
2117
|
+
remote,
|
|
2118
|
+
visible: visibleStoreOf(remote.entities, remote.optimistic, operators),
|
|
2119
|
+
listOf: (identity) => ({
|
|
2120
|
+
query: Option.match(queryOf(identity), {
|
|
2121
|
+
onNone: () => identity,
|
|
2122
|
+
onSome: (query) => query.descriptor.name
|
|
2123
|
+
}),
|
|
2124
|
+
identity
|
|
2125
|
+
}),
|
|
2126
|
+
bodyOf: (identity) => Option.flatMap(queryOf(identity), (query) => Option.map(Option.fromUndefinedOr(query.descriptor.body), (body) => ({
|
|
2127
|
+
body,
|
|
2128
|
+
encoded: query.encoded
|
|
2129
|
+
}))),
|
|
2130
|
+
edgesOf: ({ identity, window }) => {
|
|
2131
|
+
const connection = remote.connections[identity] ?? emptyConnection;
|
|
2132
|
+
const placed = shownEdges(remote, identity, connection);
|
|
2133
|
+
return {
|
|
2134
|
+
shown: cutToWindow(placed, window, windowSize(remote, {
|
|
2135
|
+
identity,
|
|
2136
|
+
window
|
|
2137
|
+
})).edges,
|
|
2138
|
+
placed,
|
|
2139
|
+
pages: items(connection)
|
|
2140
|
+
};
|
|
2141
|
+
},
|
|
2142
|
+
operators,
|
|
2143
|
+
context: layersContext(remote.optimistic.layers)
|
|
2144
|
+
};
|
|
2145
|
+
return readWhy(data._tag, [
|
|
2146
|
+
...initial,
|
|
2147
|
+
...failedReasons(data),
|
|
2148
|
+
...reasonsOf(reader, asked),
|
|
2149
|
+
...streams
|
|
2150
|
+
], surfaces);
|
|
2151
|
+
},
|
|
2152
|
+
timeline: (start, history, reads, options) => {
|
|
2153
|
+
let before = start;
|
|
2154
|
+
return history.flatMap((entry, index) => {
|
|
2155
|
+
const from = before;
|
|
2156
|
+
before = entry.model;
|
|
2157
|
+
return Object.entries(reads).flatMap(([read, projection]) => {
|
|
2158
|
+
const was = projection.read(from);
|
|
2159
|
+
const is = projection.read(entry.model);
|
|
2160
|
+
if (was._tag === is._tag) return [];
|
|
2161
|
+
return [{
|
|
2162
|
+
read,
|
|
2163
|
+
index,
|
|
2164
|
+
message: entry.message,
|
|
2165
|
+
from: was._tag,
|
|
2166
|
+
to: is._tag,
|
|
2167
|
+
why: domain.why(entry.model, projection, options),
|
|
2168
|
+
regression: regressed(was, is)
|
|
2169
|
+
}];
|
|
2170
|
+
});
|
|
2171
|
+
});
|
|
2172
|
+
},
|
|
2173
|
+
filtered: (model, over, by, input) => {
|
|
2174
|
+
assertRegistered(bound, "Query", definition.registry.queries, by.name);
|
|
2175
|
+
const filters = by.body?.entity.name;
|
|
2176
|
+
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`);
|
|
2177
|
+
const remote = store.get(model);
|
|
2178
|
+
const connection = remote.connections[over.ref.identity];
|
|
2179
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, operators);
|
|
2180
|
+
const among = (connection === void 0 ? [] : shownEdges(remote, over.ref.identity, connection)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
|
|
2181
|
+
let judged;
|
|
2182
|
+
try {
|
|
2183
|
+
judged = matching(visible, by, input, {
|
|
2184
|
+
among,
|
|
2185
|
+
operators,
|
|
2186
|
+
context: contextOf(model)
|
|
2187
|
+
});
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
if (error instanceof QueryEvaluateError) return {
|
|
2190
|
+
items: [],
|
|
2191
|
+
complete: false
|
|
2192
|
+
};
|
|
2193
|
+
throw error;
|
|
2194
|
+
}
|
|
2195
|
+
const relation = relationOf(over.selection);
|
|
2196
|
+
const items = [];
|
|
2197
|
+
let assembledAll = true;
|
|
2198
|
+
for (const key of judged.matched) {
|
|
2199
|
+
const assembled = assemble(visible, key, relation);
|
|
2200
|
+
if (assembled === void 0) {
|
|
2201
|
+
assembledAll = false;
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
const decoded = decodeRow(over.selection.schema, visible[key].values, assembled.values);
|
|
2205
|
+
if (Result.isFailure(decoded)) {
|
|
2206
|
+
assembledAll = false;
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
items.push(decoded.success);
|
|
2210
|
+
}
|
|
2211
|
+
return {
|
|
2212
|
+
items,
|
|
2213
|
+
complete: judged.skipped.length === 0 && assembledAll && connection !== void 0 && !isGapped(connection) && !hasNext(connection) && !hasPrevious(connection)
|
|
2214
|
+
};
|
|
2215
|
+
},
|
|
2216
|
+
refresh: (model, target, options) => Remote.refresh(bound, model, target, options),
|
|
2217
|
+
forget: (model) => Remote.forget(bound, model),
|
|
2218
|
+
overlay: (model, id, optimistic) => reduce(model, {
|
|
2219
|
+
_tag: "OverlayShown",
|
|
2220
|
+
id,
|
|
2221
|
+
optimistic
|
|
2222
|
+
}),
|
|
2223
|
+
lift: (model, id) => reduce(model, {
|
|
2224
|
+
_tag: "OverlayLifted",
|
|
2225
|
+
id
|
|
2226
|
+
}),
|
|
2227
|
+
overlays: (model) => overlaysOf(store.get(model)),
|
|
2228
|
+
visibleItems: (remote, connection) => {
|
|
2229
|
+
const identity = connectionIdentity(connection);
|
|
2230
|
+
return shownEdges(remote, identity, remote.connections[identity] ?? emptyConnection);
|
|
2231
|
+
},
|
|
2232
|
+
mutate: (model, mutation, input, options = {}) => {
|
|
2233
|
+
assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
|
|
2234
|
+
const remote = store.get(model);
|
|
2235
|
+
const durable = options.durable === true;
|
|
2236
|
+
const requestId = durable ? durableId(remote, mutation.name, options.requestId) : options.requestId ?? `${bound.contract.name}-${remote.mutations.sequence + 1}`;
|
|
2237
|
+
const tempId = `${requestId}.tmp`;
|
|
2238
|
+
const known = Option.fromUndefinedOr(remote.mutations.operations.get(requestId));
|
|
2239
|
+
const viewer = viewerOf(model);
|
|
2240
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic, bound.operators);
|
|
2241
|
+
const predicted = mutation.write === void 0 ? void 0 : predictionOfWrite(mutation.write, input, options.keys, {
|
|
2242
|
+
derived: definition.registry.derived,
|
|
2243
|
+
store: visible,
|
|
2244
|
+
viewer,
|
|
2245
|
+
now: Option.fromUndefinedOr(options.now)
|
|
2246
|
+
});
|
|
2247
|
+
const write = mutation.write;
|
|
2248
|
+
const refused = write === void 0 || options.optimistic !== void 0 ? Option.none() : Option.orElse(Option.map(forbiddenByWrite(write, input, options.keys, visible), ({ reason }) => remoteError(new RemoteForbidden({
|
|
2249
|
+
message: Option.getOrElse(reason, () => "Forbidden"),
|
|
2250
|
+
...Option.match(reason, {
|
|
2251
|
+
onNone: () => ({}),
|
|
2252
|
+
onSome: (said) => ({ reason: said })
|
|
2253
|
+
})
|
|
2254
|
+
}))), () => Option.map(brokenByWrite(write, input, options.keys, {
|
|
2255
|
+
viewer,
|
|
2256
|
+
now: Option.fromUndefinedOr(options.now)
|
|
2257
|
+
}, visible, bound.operators), (held) => remoteError({
|
|
2258
|
+
_tag: "RemoteMutationError",
|
|
2259
|
+
message: held.message,
|
|
2260
|
+
refusal: Schema.encodeUnknownSync(mutation.Refusal)({
|
|
2261
|
+
_tag: "Invalid",
|
|
2262
|
+
entity: Option.some(held.entity),
|
|
2263
|
+
rule: Option.some(held.id),
|
|
2264
|
+
message: held.message,
|
|
2265
|
+
path: held.path
|
|
2266
|
+
})
|
|
2267
|
+
})));
|
|
2268
|
+
const optimistic = Option.isSome(refused) ? void 0 : typeof options.optimistic === "function" ? options.optimistic({
|
|
2269
|
+
requestId,
|
|
2270
|
+
tempId
|
|
2271
|
+
}) : options.optimistic ?? predicted;
|
|
2272
|
+
const queued = durable && Option.isNone(refused);
|
|
2273
|
+
const started = updateRemote(remote, {
|
|
2274
|
+
_tag: "MutationStarted",
|
|
2275
|
+
requestId,
|
|
2276
|
+
...optimistic === void 0 ? {} : { optimistic },
|
|
2277
|
+
...Option.isSome(viewer) ? { viewer: viewer.value } : {},
|
|
2278
|
+
write: {
|
|
2279
|
+
mutation: mutation.name,
|
|
2280
|
+
input,
|
|
2281
|
+
...options.keys === void 0 ? {} : { keys: options.keys },
|
|
2282
|
+
...options.now === void 0 ? {} : { now: options.now },
|
|
2283
|
+
...queued ? { durable: {
|
|
2284
|
+
input: Schema.encodeUnknownSync(mutation.Input)(input),
|
|
2285
|
+
shown: shownAt(model, optimistic ?? [])
|
|
2286
|
+
} } : {}
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
const startedModel = store.set(model, started);
|
|
2290
|
+
return {
|
|
2291
|
+
model: startedModel,
|
|
2292
|
+
requestId,
|
|
2293
|
+
tempId,
|
|
2294
|
+
command: {
|
|
2295
|
+
name: `Remote.mutate(${mutation.name})`,
|
|
2296
|
+
args: { requestId },
|
|
2297
|
+
effect: Effect.flatMap(Effect.sync(() => Option.getOrElse(known, mintOperation)), (operation) => queued ? Effect.succeed({
|
|
2298
|
+
_tag: "MutationQueued",
|
|
2299
|
+
requestId
|
|
2300
|
+
}) : Option.isSome(refused) ? Effect.succeed({
|
|
2301
|
+
_tag: "MutationFailed",
|
|
2302
|
+
requestId,
|
|
2303
|
+
error: refused.value,
|
|
2304
|
+
operation
|
|
2305
|
+
}) : mutateRemote(mutation, input, requestId, {
|
|
2306
|
+
keys: options.keys,
|
|
2307
|
+
operation,
|
|
2308
|
+
shown: shownAt(startedModel, optimistic ?? []),
|
|
2309
|
+
now: options.now
|
|
2310
|
+
}).pipe(Effect.matchEffect({
|
|
2311
|
+
onFailure: (error) => Effect.succeed({
|
|
2312
|
+
_tag: "MutationFailed",
|
|
2313
|
+
requestId,
|
|
2314
|
+
error: remoteError(error),
|
|
2315
|
+
operation
|
|
2316
|
+
}),
|
|
2317
|
+
onSuccess: (outcome) => Effect.map(Clock.currentTimeMillis, (at) => ({
|
|
2318
|
+
_tag: "MutationSucceeded",
|
|
2319
|
+
requestId,
|
|
2320
|
+
entities: outcome.entities,
|
|
2321
|
+
connections: outcome.connections,
|
|
2322
|
+
deleted: outcome.deleted,
|
|
2323
|
+
...outcome.read === void 0 ? {} : { read: outcome.read },
|
|
2324
|
+
now: at
|
|
2325
|
+
}))
|
|
2326
|
+
})))
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
},
|
|
2330
|
+
mutation: (model, requestId) => mutationStatus(store.get(model).mutations, requestId),
|
|
2331
|
+
refusal: (model, requestId, mutation) => {
|
|
2332
|
+
const status = mutationStatus(store.get(model).mutations, requestId);
|
|
2333
|
+
return status._tag === "Failed" ? refusalOf(status.error, mutation) : Option.none();
|
|
2334
|
+
},
|
|
2335
|
+
failedWrites: (model, mutation) => {
|
|
2336
|
+
if (mutation !== void 0) assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
|
|
2337
|
+
const { mutations } = store.get(model);
|
|
2338
|
+
return [...mutations.failed].flatMap((requestId) => {
|
|
2339
|
+
const write = mutations.writes.get(requestId);
|
|
2340
|
+
const error = mutations.errors.get(requestId);
|
|
2341
|
+
if (write === void 0 || error === void 0 || mutations.pending.has(requestId)) return [];
|
|
2342
|
+
if (mutation !== void 0 && write.mutation !== mutation.name) return [];
|
|
2343
|
+
const descriptor = registeredMutation(write.mutation);
|
|
2344
|
+
return [{
|
|
2345
|
+
requestId,
|
|
2346
|
+
mutation: descriptor,
|
|
2347
|
+
input: write.input,
|
|
2348
|
+
error,
|
|
2349
|
+
refusal: refusalOf(error, descriptor)
|
|
2350
|
+
}];
|
|
2351
|
+
});
|
|
2352
|
+
},
|
|
2353
|
+
retry: (model, requestId) => {
|
|
2354
|
+
const { mutations, outbox } = store.get(model);
|
|
2355
|
+
const write = mutations.writes.get(requestId);
|
|
2356
|
+
if (write === void 0 || mutationStatus(mutations, requestId)._tag !== "Failed") return Option.none();
|
|
2357
|
+
return Option.some(domain.mutate(model, registeredMutation(write.mutation), write.input, {
|
|
2358
|
+
requestId,
|
|
2359
|
+
optimistic: write.optimistic,
|
|
2360
|
+
keys: write.keys,
|
|
2361
|
+
now: Option.getOrUndefined(write.now),
|
|
2362
|
+
durable: outbox.queue.some((queued) => operationOf(queued) === requestId)
|
|
2363
|
+
}));
|
|
2364
|
+
},
|
|
2365
|
+
dismiss: (model, requestId) => {
|
|
2366
|
+
const remote = store.get(model);
|
|
2367
|
+
const dismissed = dismissQueued(remote, requestId);
|
|
2368
|
+
if (dismissed !== remote) return store.set(model, dismissed);
|
|
2369
|
+
if (mutationStatus(remote.mutations, requestId)._tag !== "Failed") return model;
|
|
2370
|
+
return store.set(model, {
|
|
2371
|
+
...remote,
|
|
2372
|
+
mutations: forgetFailure(remote.mutations, requestId)
|
|
2373
|
+
});
|
|
2374
|
+
},
|
|
2375
|
+
liveStatus: (model, active) => {
|
|
2376
|
+
assertOwned(active);
|
|
2377
|
+
const resolvedAt = readers.get(active) ?? memoizedResolved(active);
|
|
2378
|
+
const live = liveAskOf(askedUnion(resolvedAt(model).map(({ projection }) => projection)));
|
|
2379
|
+
if (live.requirements.length === 0 && live.queries.length === 0) return { _tag: "Idle" };
|
|
2380
|
+
const remote = store.get(model);
|
|
2381
|
+
const stream = liveStreamKey(live);
|
|
2382
|
+
const health = remote.streams[stream];
|
|
2383
|
+
if (health === void 0) return { _tag: "Live" };
|
|
2384
|
+
return Option.match(health.refused, {
|
|
2385
|
+
onSome: (error) => ({
|
|
2386
|
+
_tag: "Refused",
|
|
2387
|
+
error
|
|
2388
|
+
}),
|
|
2389
|
+
onNone: () => remote.gaps.has(stream) ? {
|
|
2390
|
+
_tag: "Reconnecting",
|
|
2391
|
+
attempt: health.failures,
|
|
2392
|
+
error: health.error
|
|
2393
|
+
} : { _tag: "Live" }
|
|
2394
|
+
});
|
|
2395
|
+
},
|
|
2396
|
+
outboxStatus: (model) => outboxStatus(store.get(model).outbox),
|
|
2397
|
+
reduce,
|
|
2398
|
+
inspect: (model) => inspectRemote(store.get(model)),
|
|
2399
|
+
persistence: (options) => {
|
|
2400
|
+
const connections = (options.connections ?? []).map(connectionIdentity);
|
|
2401
|
+
const snapshotOf = options.snapshot ?? ((remote) => RemotePersistence.snapshotOf(remote, { connections }));
|
|
2402
|
+
const debounce = options.debounce ?? "250 millis";
|
|
2403
|
+
let last = {
|
|
2404
|
+
entities: emptyStore,
|
|
2405
|
+
connections: {},
|
|
2406
|
+
scope: "",
|
|
2407
|
+
text: Option.none()
|
|
2408
|
+
};
|
|
2409
|
+
const textOf = (remote, scope) => {
|
|
2410
|
+
if (last.entities !== remote.entities || last.connections !== remote.connections || last.scope !== scope) last = {
|
|
2411
|
+
entities: remote.entities,
|
|
2412
|
+
connections: remote.connections,
|
|
2413
|
+
scope,
|
|
2414
|
+
text: Option.fromUndefinedOr(RemotePersistence.dehydrate(snapshotOf(remote), {
|
|
2415
|
+
scope,
|
|
2416
|
+
maxBytes: options.maxBytes
|
|
2417
|
+
}))
|
|
2418
|
+
};
|
|
2419
|
+
return last.text;
|
|
2420
|
+
};
|
|
2421
|
+
const hydratedFrom = (key, scope) => RemotePersistence.restore({
|
|
2422
|
+
key,
|
|
2423
|
+
scope,
|
|
2424
|
+
maxBytes: options.maxBytes
|
|
2425
|
+
}).pipe(Effect.map((snapshot) => ({
|
|
2426
|
+
_tag: "Hydrated",
|
|
2427
|
+
entities: snapshot.entities,
|
|
2428
|
+
connections: snapshot.connections,
|
|
2429
|
+
merge: "preserve-existing",
|
|
2430
|
+
from: key
|
|
2431
|
+
})), Effect.option);
|
|
2432
|
+
const restoreEntry = {
|
|
2433
|
+
dependenciesSchema: Schema.Struct({
|
|
2434
|
+
key: Schema.String,
|
|
2435
|
+
scope: Schema.String,
|
|
2436
|
+
restored: Schema.Boolean
|
|
2437
|
+
}),
|
|
2438
|
+
modelToDependencies: (model) => {
|
|
2439
|
+
const key = options.key(model);
|
|
2440
|
+
return {
|
|
2441
|
+
key,
|
|
2442
|
+
scope: options.scope(model),
|
|
2443
|
+
restored: Option.contains(store.get(model).restoredFrom, key)
|
|
2444
|
+
};
|
|
2445
|
+
},
|
|
2446
|
+
dependenciesToStream: ({ key, scope, restored }) => restored ? Stream.empty : Stream.fromEffect(hydratedFrom(key, scope)).pipe(Stream.flatMap(Option.match({
|
|
2447
|
+
onNone: () => Stream.empty,
|
|
2448
|
+
onSome: Stream.make
|
|
2449
|
+
})))
|
|
2450
|
+
};
|
|
2451
|
+
const save = {
|
|
2452
|
+
dependenciesSchema: Schema.Struct({
|
|
2453
|
+
key: Schema.String,
|
|
2454
|
+
restored: Schema.Boolean,
|
|
2455
|
+
text: Schema.OptionFromNullOr(Schema.String)
|
|
2456
|
+
}),
|
|
2457
|
+
modelToDependencies: (model) => {
|
|
2458
|
+
const remote = store.get(model);
|
|
2459
|
+
const key = options.key(model);
|
|
2460
|
+
const restored = Option.contains(remote.restoredFrom, key);
|
|
2461
|
+
return {
|
|
2462
|
+
key,
|
|
2463
|
+
restored,
|
|
2464
|
+
text: restored ? textOf(remote, options.scope(model)) : Option.none()
|
|
2465
|
+
};
|
|
2466
|
+
},
|
|
2467
|
+
dependenciesToStream: ({ key, restored, text }) => restored ? Stream.fromEffect(Effect.gen(function* () {
|
|
2468
|
+
yield* Effect.sleep(debounce);
|
|
2469
|
+
const kv = yield* KeyValueStore.KeyValueStore;
|
|
2470
|
+
yield* Option.match(text, {
|
|
2471
|
+
onNone: () => kv.remove(key),
|
|
2472
|
+
onSome: (written) => kv.set(key, written)
|
|
2473
|
+
});
|
|
2474
|
+
}).pipe(Effect.ignore)).pipe(Stream.drain) : Stream.empty
|
|
2475
|
+
};
|
|
2476
|
+
return {
|
|
2477
|
+
key: `persistence:${bound.contract.name}`,
|
|
2478
|
+
handles: [],
|
|
2479
|
+
subscriptions: Subscription.make()(() => ({
|
|
2480
|
+
"persistence.restore": restoreEntry,
|
|
2481
|
+
"persistence.save": save
|
|
2482
|
+
})),
|
|
2483
|
+
restore: (model) => Effect.map(hydratedFrom(options.key(model), options.scope(model)), Option.match({
|
|
2484
|
+
onNone: () => model,
|
|
2485
|
+
onSome: (message) => reduce(model, message)
|
|
2486
|
+
}))
|
|
2487
|
+
};
|
|
2488
|
+
},
|
|
2489
|
+
outbox: (options) => {
|
|
2490
|
+
/** How this version reads a stored input of `mutation`; none for one it does not register. */
|
|
2491
|
+
const inputOf = (mutation) => Option.map(Option.fromUndefinedOr(definition.registry.mutations.get(mutation)), (descriptor) => (input) => Option.map(Schema.decodeUnknownOption(descriptor.Input)(input), (decoded) => ({ decoded })));
|
|
2492
|
+
const readOf = (key, scope, stored, extra = {}) => ({
|
|
2493
|
+
_tag: "OutboxRead",
|
|
2494
|
+
key,
|
|
2495
|
+
scope,
|
|
2496
|
+
entries: readEntries(stored, inputOf),
|
|
2497
|
+
done: stored.done,
|
|
2498
|
+
...extra
|
|
2499
|
+
});
|
|
2500
|
+
/** Tells the other tabs on `key` the store changed, with the answer that changed it. */
|
|
2501
|
+
const announce = (key, outcome) => Effect.sync(() => Option.map(channelFor(key), (channel) => {
|
|
2502
|
+
channel.postMessage({ outcome: Option.getOrUndefined(outcome) });
|
|
2503
|
+
channel.close();
|
|
2504
|
+
}));
|
|
2505
|
+
/** The store with `change` merged in, under its lock, so two tabs never write it at once. */
|
|
2506
|
+
const writeStored = (key, scope, change) => withLock(storeLock(key), Effect.gen(function* () {
|
|
2507
|
+
if (yield* othersWaiting(key, scope)) return yield* Effect.fail(/* @__PURE__ */ new Error(`the store under ${key} holds another scope's waiting writes; give each principal a key of its own`));
|
|
2508
|
+
const merged = mergeStored(yield* readStored(key, scope), change);
|
|
2509
|
+
yield* (yield* KeyValueStore.KeyValueStore).set(key, storedText(merged, scope));
|
|
2510
|
+
return merged;
|
|
2511
|
+
}));
|
|
2512
|
+
/** What the store holds, as a page reads it; this page's own queue, and why, where it cannot be read. */
|
|
2513
|
+
const restored = (key, scope, session) => readStored(key, scope).pipe(Effect.map((stored) => readOf(key, scope, stored, Option.match(session, {
|
|
2514
|
+
onNone: () => ({}),
|
|
2515
|
+
onSome: (minted) => ({ session: minted })
|
|
2516
|
+
}))), Effect.catch((error) => Effect.succeed(readOf(key, scope, {
|
|
2517
|
+
entries: [],
|
|
2518
|
+
done: []
|
|
2519
|
+
}, {
|
|
2520
|
+
...Option.match(session, {
|
|
2521
|
+
onNone: () => ({}),
|
|
2522
|
+
onSome: (minted) => ({ session: minted })
|
|
2523
|
+
}),
|
|
2524
|
+
unsaved: error.message
|
|
2525
|
+
}))));
|
|
2526
|
+
/** Whether the page's queue is the one stored under `key` for `scope`. */
|
|
2527
|
+
const holding = (outbox, key, scope) => Option.contains(outbox.key, key) && Option.contains(outbox.scope, scope);
|
|
2528
|
+
const minted = (has) => has ? Effect.succeed(Option.none()) : Effect.sync(() => Option.some(mintOperation()));
|
|
2529
|
+
let lastText = {
|
|
2530
|
+
outbox: void 0,
|
|
2531
|
+
scope: "",
|
|
2532
|
+
text: ""
|
|
2533
|
+
};
|
|
2534
|
+
const textOf = (outbox, scope) => {
|
|
2535
|
+
if (lastText.outbox !== outbox || lastText.scope !== scope) lastText = {
|
|
2536
|
+
outbox,
|
|
2537
|
+
scope,
|
|
2538
|
+
text: storedText(storedOf(outbox), scope)
|
|
2539
|
+
};
|
|
2540
|
+
return lastText.text;
|
|
2541
|
+
};
|
|
2542
|
+
const restoreEntry = {
|
|
2543
|
+
dependenciesSchema: Schema.Struct({
|
|
2544
|
+
key: Schema.String,
|
|
2545
|
+
scope: Schema.String,
|
|
2546
|
+
restored: Schema.Boolean,
|
|
2547
|
+
session: Schema.Boolean
|
|
2548
|
+
}),
|
|
2549
|
+
modelToDependencies: (model) => {
|
|
2550
|
+
const { outbox } = store.get(model);
|
|
2551
|
+
const key = options.key(model);
|
|
2552
|
+
const scope = options.scope(model);
|
|
2553
|
+
return {
|
|
2554
|
+
key,
|
|
2555
|
+
scope,
|
|
2556
|
+
restored: holding(outbox, key, scope),
|
|
2557
|
+
session: Option.isSome(outbox.session)
|
|
2558
|
+
};
|
|
2559
|
+
},
|
|
2560
|
+
dependenciesToStream: ({ key, scope, restored: done, session }) => done ? Stream.empty : Stream.fromEffect(Effect.flatMap(minted(session), (mint) => restored(key, scope, mint)))
|
|
2561
|
+
};
|
|
2562
|
+
const save = {
|
|
2563
|
+
dependenciesSchema: Schema.Struct({
|
|
2564
|
+
key: Schema.String,
|
|
2565
|
+
scope: Schema.String,
|
|
2566
|
+
text: Schema.OptionFromNullOr(Schema.String)
|
|
2567
|
+
}),
|
|
2568
|
+
modelToDependencies: (model) => {
|
|
2569
|
+
const { outbox } = store.get(model);
|
|
2570
|
+
const key = options.key(model);
|
|
2571
|
+
const scope = options.scope(model);
|
|
2572
|
+
return {
|
|
2573
|
+
key,
|
|
2574
|
+
scope,
|
|
2575
|
+
text: holding(outbox, key, scope) ? Option.some(textOf(outbox, scope)) : Option.none()
|
|
2576
|
+
};
|
|
2577
|
+
},
|
|
2578
|
+
dependenciesToStream: ({ key, scope, text }) => Option.match(text, {
|
|
2579
|
+
onNone: () => Stream.empty,
|
|
2580
|
+
onSome: (written) => {
|
|
2581
|
+
const mine = storedFromText(written);
|
|
2582
|
+
return Stream.fromEffect(writeStored(key, scope, mine).pipe(Effect.tap(() => announce(key, Option.none())), Effect.map((merged) => readOf(key, scope, merged)), Effect.catch((error) => Effect.succeed(readOf(key, scope, mine, { unsaved: error.message })))));
|
|
2583
|
+
}
|
|
2584
|
+
})
|
|
2585
|
+
};
|
|
2586
|
+
/** Sends `write`, as the one tab on `key` that sends, until the server answers it. */
|
|
2587
|
+
const sent = (key, scope, write) => Stream.callback((queue) => {
|
|
2588
|
+
const emit = (message) => Queue.offer(queue, message);
|
|
2589
|
+
/**
|
|
2590
|
+
* The outcome told this tab and the others first, so each settles
|
|
2591
|
+
* with the server's answer, then written to the store before the
|
|
2592
|
+
* lock goes, so the next sender reads it.
|
|
2593
|
+
*/
|
|
2594
|
+
const settle = (message, change) => emit(message).pipe(Effect.andThen(announce(key, Option.some(message))), Effect.andThen(writeStored(key, scope, change)), Effect.andThen(announce(key, Option.none())), Effect.ignore);
|
|
2595
|
+
const operation = write.operation;
|
|
2596
|
+
return withLock(sendLock(key), Effect.gen(function* () {
|
|
2597
|
+
const there = yield* Effect.option(readStored(key, scope));
|
|
2598
|
+
if (Option.isSome(there) && settledIn(there.value, operation)) {
|
|
2599
|
+
yield* emit(readOf(key, scope, there.value));
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
const mutation = registeredMutation(write.mutation);
|
|
2603
|
+
const input = Schema.decodeUnknownSync(mutation.Input)(write.input);
|
|
2604
|
+
for (let attempt = 1;; attempt++) {
|
|
2605
|
+
const result = yield* Effect.result(mutateRemote(mutation, input, operation, {
|
|
2606
|
+
keys: write.keys,
|
|
2607
|
+
operation,
|
|
2608
|
+
shown: write.shown,
|
|
2609
|
+
now: write.now
|
|
2610
|
+
}));
|
|
2611
|
+
if (Result.isSuccess(result)) {
|
|
2612
|
+
const outcome = result.success;
|
|
2613
|
+
const at = yield* Clock.currentTimeMillis;
|
|
2614
|
+
return yield* settle({
|
|
2615
|
+
_tag: "MutationSucceeded",
|
|
2616
|
+
requestId: operation,
|
|
2617
|
+
entities: outcome.entities,
|
|
2618
|
+
connections: outcome.connections,
|
|
2619
|
+
deleted: outcome.deleted,
|
|
2620
|
+
...outcome.read === void 0 ? {} : { read: outcome.read },
|
|
2621
|
+
now: at
|
|
2622
|
+
}, {
|
|
2623
|
+
entries: [],
|
|
2624
|
+
done: [operation]
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const error = remoteError(result.failure);
|
|
2628
|
+
if (result.failure._tag === "Unreachable") {
|
|
2629
|
+
yield* emit({
|
|
2630
|
+
_tag: "OutboxDelayed",
|
|
2631
|
+
requestId: operation,
|
|
2632
|
+
attempt,
|
|
2633
|
+
error
|
|
2634
|
+
});
|
|
2635
|
+
yield* Effect.race(Effect.flatMap(backoff(attempt, options.retry), Effect.sleep), onlineAgain);
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
return yield* settle({
|
|
2639
|
+
_tag: "MutationFailed",
|
|
2640
|
+
requestId: operation,
|
|
2641
|
+
error,
|
|
2642
|
+
operation
|
|
2643
|
+
}, {
|
|
2644
|
+
entries: [{
|
|
2645
|
+
...write,
|
|
2646
|
+
revision: write.revision + 1,
|
|
2647
|
+
state: {
|
|
2648
|
+
_tag: "Failed",
|
|
2649
|
+
error
|
|
2650
|
+
}
|
|
2651
|
+
}],
|
|
2652
|
+
done: []
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
})).pipe(Effect.ensuring(Queue.end(queue)));
|
|
2656
|
+
});
|
|
2657
|
+
let lastHead = {
|
|
2658
|
+
outbox: void 0,
|
|
2659
|
+
head: Option.none()
|
|
2660
|
+
};
|
|
2661
|
+
const headTextOf = (outbox) => {
|
|
2662
|
+
if (lastHead.outbox !== outbox) lastHead = {
|
|
2663
|
+
outbox,
|
|
2664
|
+
head: Option.map(headOf(outbox), (write) => JSON.stringify(write))
|
|
2665
|
+
};
|
|
2666
|
+
return lastHead.head;
|
|
2667
|
+
};
|
|
2668
|
+
const send = {
|
|
2669
|
+
dependenciesSchema: Schema.Struct({
|
|
2670
|
+
key: Schema.String,
|
|
2671
|
+
scope: Schema.String,
|
|
2672
|
+
head: Schema.OptionFromNullOr(Schema.String)
|
|
2673
|
+
}),
|
|
2674
|
+
modelToDependencies: (model) => {
|
|
2675
|
+
const { outbox } = store.get(model);
|
|
2676
|
+
const key = options.key(model);
|
|
2677
|
+
const scope = options.scope(model);
|
|
2678
|
+
return {
|
|
2679
|
+
key,
|
|
2680
|
+
scope,
|
|
2681
|
+
head: holding(outbox, key, scope) ? headTextOf(outbox) : Option.none()
|
|
2682
|
+
};
|
|
2683
|
+
},
|
|
2684
|
+
dependenciesToStream: ({ key, scope, head }) => Option.match(head, {
|
|
2685
|
+
onNone: () => Stream.empty,
|
|
2686
|
+
onSome: (text) => sent(key, scope, Schema.decodeUnknownSync(StoredWrite)(JSON.parse(text)))
|
|
2687
|
+
})
|
|
2688
|
+
};
|
|
2689
|
+
/** What another tab announced: the answer it heard first, then the store as it now is. */
|
|
2690
|
+
const heard = (key, scope, data) => Effect.map(restored(key, scope, Option.none()), (read) => [...Option.toArray(outcomeOf(data)), read]);
|
|
2691
|
+
const watch = {
|
|
2692
|
+
dependenciesSchema: Schema.Struct({
|
|
2693
|
+
key: Schema.String,
|
|
2694
|
+
scope: Schema.String,
|
|
2695
|
+
restored: Schema.Boolean
|
|
2696
|
+
}),
|
|
2697
|
+
modelToDependencies: (model) => {
|
|
2698
|
+
const key = options.key(model);
|
|
2699
|
+
const scope = options.scope(model);
|
|
2700
|
+
return {
|
|
2701
|
+
key,
|
|
2702
|
+
scope,
|
|
2703
|
+
restored: holding(store.get(model).outbox, key, scope)
|
|
2704
|
+
};
|
|
2705
|
+
},
|
|
2706
|
+
dependenciesToStream: ({ key, scope, restored: read }) => read ? Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => Option.map(channelFor(key), (channel) => {
|
|
2707
|
+
channel.onmessage = (event) => {
|
|
2708
|
+
Queue.offerUnsafe(queue, event.data);
|
|
2709
|
+
};
|
|
2710
|
+
return channel;
|
|
2711
|
+
})), (channel) => Effect.sync(() => Option.map(channel, (open) => open.close())))).pipe(Stream.mapEffect((data) => heard(key, scope, data)), Stream.flatMap((messages) => Stream.fromIterable(messages))) : Stream.empty
|
|
2712
|
+
};
|
|
2713
|
+
return {
|
|
2714
|
+
key: `outbox:${bound.contract.name}`,
|
|
2715
|
+
handles: [],
|
|
2716
|
+
subscriptions: Subscription.make()(() => ({
|
|
2717
|
+
"outbox.restore": restoreEntry,
|
|
2718
|
+
"outbox.save": save,
|
|
2719
|
+
"outbox.send": send,
|
|
2720
|
+
"outbox.watch": watch
|
|
2721
|
+
})),
|
|
2722
|
+
restore: (model) => Effect.map(Effect.flatMap(minted(Option.isSome(store.get(model).outbox.session)), (mint) => restored(options.key(model), options.scope(model), mint)), (message) => reduce(model, message))
|
|
2723
|
+
};
|
|
2724
|
+
},
|
|
2725
|
+
wiring: (active, options = {}) => ({
|
|
2726
|
+
key: `remote:${bound.contract.name}`,
|
|
2727
|
+
shared: options.shared ?? [],
|
|
2728
|
+
handles: Record.keys(remoteMessageCases),
|
|
2729
|
+
route: (model, message) => isRemoteMessage(message) ? Option.some(reduceReporting(bound.store, reduce, model, message, (options.shared ?? []).some((tag) => tag === "MutationFailed"))) : Option.none(),
|
|
2730
|
+
subscriptions: brandEntries(domain.subscriptions(active, options)),
|
|
2731
|
+
contract: bound.contract
|
|
2732
|
+
})
|
|
2733
|
+
};
|
|
2734
|
+
return domain;
|
|
2735
|
+
};
|
|
2736
|
+
/** The outbox's own answers, as another tab announced them: a settled write, and nothing else. */
|
|
2737
|
+
const announcedOutcome = Schema.Struct({ outcome: Schema.optional(remoteMessageSchema) });
|
|
2738
|
+
const outcomeOf = (data) => Option.filter(Option.flatMap(Schema.decodeUnknownOption(announcedOutcome)(data), ({ outcome }) => Option.fromUndefinedOr(outcome)), (outcome) => outcome._tag === "MutationSucceeded" || outcome._tag === "MutationFailed");
|
|
2739
|
+
/** The window keys of a `QueryOptions`, and only those, without the undefined ones. */
|
|
2740
|
+
/**
|
|
2741
|
+
* How many rows a read of `ref` shows: its window's `first` or `last`, as
|
|
2742
|
+
* `Data.more` has grown it. `undefined` for a window with a cursor or no size,
|
|
2743
|
+
* which shows whatever the connection holds.
|
|
2744
|
+
*/
|
|
2745
|
+
const windowSize = (remote, ref) => {
|
|
2746
|
+
const { first, last, after, before } = ref.window;
|
|
2747
|
+
if (after !== void 0 || before !== void 0) return void 0;
|
|
2748
|
+
const size = first ?? last;
|
|
2749
|
+
return size === void 0 ? void 0 : remote.grown[windowGrowthKey(ref.identity, stableStringify(ref.window))] ?? size;
|
|
2750
|
+
};
|
|
2751
|
+
/** The edges a window of `size` shows, from the start (`first`) or the end (`last`), and which end it cut. */
|
|
2752
|
+
const cutToWindow = (edges, window, size) => {
|
|
2753
|
+
if (size === void 0 || edges.length <= size) return {
|
|
2754
|
+
edges,
|
|
2755
|
+
cutBefore: false,
|
|
2756
|
+
cutAfter: false
|
|
2757
|
+
};
|
|
2758
|
+
return window.first === void 0 ? {
|
|
2759
|
+
edges: edges.slice(edges.length - size),
|
|
2760
|
+
cutBefore: true,
|
|
2761
|
+
cutAfter: false
|
|
2762
|
+
} : {
|
|
2763
|
+
edges: edges.slice(0, size),
|
|
2764
|
+
cutBefore: false,
|
|
2765
|
+
cutAfter: true
|
|
2766
|
+
};
|
|
2767
|
+
};
|
|
2768
|
+
/**
|
|
2769
|
+
* Whether a row a window of `size` would show was deleted: one of `edges`
|
|
2770
|
+
* gone before the window, read from its end, fills with rows still `shown`.
|
|
2771
|
+
* A delete past the window is another window's (#287).
|
|
2772
|
+
*/
|
|
2773
|
+
const deletedInWindow = (edges, shown, window, size, gone) => {
|
|
2774
|
+
const ordered = window.first === void 0 ? [...edges].reverse() : edges;
|
|
2775
|
+
let filled = 0;
|
|
2776
|
+
for (const edge of ordered) {
|
|
2777
|
+
if (size !== void 0 && filled >= size) return false;
|
|
2778
|
+
if (gone(entityKey(edge.ref.entity, edge.ref.id))) return true;
|
|
2779
|
+
if (shown.has(edge.key)) filled++;
|
|
2780
|
+
}
|
|
2781
|
+
return false;
|
|
2782
|
+
};
|
|
2783
|
+
const pickWindow = (window) => ({
|
|
2784
|
+
...window.first === void 0 ? {} : { first: window.first },
|
|
2785
|
+
...window.last === void 0 ? {} : { last: window.last },
|
|
2786
|
+
...window.after === void 0 ? {} : { after: window.after },
|
|
2787
|
+
...window.before === void 0 ? {} : { before: window.before }
|
|
2788
|
+
});
|
|
2789
|
+
export { AnswerRead, ConnectionChange, ConnectionChangeSchema, Denied, DerivedBy, Diagnostics, Entity, Freshness, IDENTITY_SEPARATOR, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_LIVE_QUERIES, MAX_RELATION_DEPTH, MAX_SHOWN_ENTITIES, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, OperationId, OutboxItemState, PageSize, PredictionPart, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, RELATION_ALIAS, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, ReadWhy, Refusal, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteForbidden, RemoteJsonAnswer, RemoteJsonRequest, RemoteLiveError, RemoteLiveRefused, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, RemoteUnauthenticated, RemoteUnreachable, RemoteUnsatisfied, Requirement, RowPlace, Selection, SettledFields, ShownBy, ShownSlice, Why, WhyTerm, WindowSchema, WireBoundary, addLayer, addOverlay, aliasedField, applyConnectionEvent, applyEntityEvent, askAgain, assemble, beginMutation, beginOptimistic, belongsEncoded, brokenByWrite, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, deadlineOf, departedLimit, derivedOfWrite, dismissQueued, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, failureOf, fieldMark, forbiddenByWrite, forgetFailure, forgetRemote, gc, goneIn, hasField, hasNext, hasPrevious, heldOnlyByRequests, httpWithLive, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnections, isFieldFailed, isFieldStale, isFieldUnavailable, isGapped, isLoading, isLoadingThrough, isQueryLoading, isRefPage, isRemoteMessage, isTombstone, items, liveEventOf, liveFetch, liveHasNext, liveHasPrevious, markStale, matching, merge, mintOperation, missingFields, mutateRemote, mutationStatus, noFailures, onlyOverlaid, optimisticOfWrite, overlaysOf, pageSchema, patchOfWrite, placeIn, plan, plannedStore, predictionOfWrite, pruneOverlays, queryIdentity, reachable, readField, recentRoots, reconcileMutation, refParts, refreshIsInFlight, refreshedAt, refsIn, relationAlias, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, resolvedPatches, retentionRootsSchema, schemaOf, segment, setStale, setUnavailable, settleFailure, settleSuccess, settledLater, stableStringify, targetsOf, terminal, tombstone, unavailableOf, unknown, updateRemote, visibleItems, visibleStore, windowGrowthKey, windowKey, withRefreshRequested, writeEntities, writeEntity, writeRead };
|
|
2790
|
+
|
|
2791
|
+
//# sourceMappingURL=index.js.map
|