foldkit-remote 0.1.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/LICENSE +21 -0
- package/README.md +564 -0
- package/dist/index.d.mts +2000 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2919 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +46 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2919 @@
|
|
|
1
|
+
import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
|
|
2
|
+
import { Requirement } from "foldkit-surface";
|
|
3
|
+
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
4
|
+
import { KeyValueStore } from "effect/unstable/persistence";
|
|
5
|
+
//#region src/store.ts
|
|
6
|
+
/**
|
|
7
|
+
* The pure entity store: values, per-field presence, staleness, and tombstones.
|
|
8
|
+
*
|
|
9
|
+
* Presence is tracked **separately** from values, so `undefined`/`null`/absent/
|
|
10
|
+
* stale/not-found are distinct states and a missing field is never inferred from
|
|
11
|
+
* `value === undefined`. Nothing here performs I/O; every operation returns a
|
|
12
|
+
* new store.
|
|
13
|
+
*/
|
|
14
|
+
/** The separator keeps an untrusted id from colliding with a prototype key. */
|
|
15
|
+
const entityKey = (entity, id) => `${entity}:${id}`;
|
|
16
|
+
const emptyEntry = {
|
|
17
|
+
values: {},
|
|
18
|
+
present: /* @__PURE__ */ new Set(),
|
|
19
|
+
stale: /* @__PURE__ */ new Set(),
|
|
20
|
+
tombstone: false,
|
|
21
|
+
updatedAt: 0,
|
|
22
|
+
windows: {}
|
|
23
|
+
};
|
|
24
|
+
const emptyStore = {};
|
|
25
|
+
const replace = (store, key, entry) => ({
|
|
26
|
+
...store,
|
|
27
|
+
[key]: entry
|
|
28
|
+
});
|
|
29
|
+
const written = (previous, values, now, windows) => {
|
|
30
|
+
const present = new Set(previous.present);
|
|
31
|
+
const stale = new Set(previous.stale);
|
|
32
|
+
const nextWindows = { ...previous.windows };
|
|
33
|
+
for (const field of Object.keys(values)) {
|
|
34
|
+
present.add(field);
|
|
35
|
+
stale.delete(field);
|
|
36
|
+
const requested = windows?.[field];
|
|
37
|
+
if (requested !== void 0 && requested !== "") nextWindows[field] = requested;
|
|
38
|
+
else delete nextWindows[field];
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
values: {
|
|
42
|
+
...previous.values,
|
|
43
|
+
...values
|
|
44
|
+
},
|
|
45
|
+
present,
|
|
46
|
+
stale,
|
|
47
|
+
tombstone: false,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
windows: nextWindows
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Merges known values into many entities, copying the store once. Every
|
|
54
|
+
* written field becomes present and non-stale; a tombstone is cleared because
|
|
55
|
+
* the entity is evidently not absent.
|
|
56
|
+
*/
|
|
57
|
+
const writeEntities = (store, writes, now = 0) => {
|
|
58
|
+
if (writes.length === 0) return store;
|
|
59
|
+
const next = { ...store };
|
|
60
|
+
for (const write of writes) next[write.key] = written(next[write.key] ?? emptyEntry, write.values, now, write.windows);
|
|
61
|
+
return next;
|
|
62
|
+
};
|
|
63
|
+
/** `writeEntities` for one entity. */
|
|
64
|
+
const writeEntity = (store, key, values, now = 0, windows) => writeEntities(store, [{
|
|
65
|
+
key,
|
|
66
|
+
values,
|
|
67
|
+
windows
|
|
68
|
+
}], now);
|
|
69
|
+
/**
|
|
70
|
+
* Marks present fields stale (`true`) or ends their staleness (`false`) across
|
|
71
|
+
* many entities, copying the store once. A field never present stays missing;
|
|
72
|
+
* an unknown entity is left alone.
|
|
73
|
+
*/
|
|
74
|
+
const setStale = (store, marks, stale) => {
|
|
75
|
+
let next;
|
|
76
|
+
for (const [key, fields] of marks) {
|
|
77
|
+
const previous = (next ?? store)[key];
|
|
78
|
+
if (previous === void 0) continue;
|
|
79
|
+
const staleFields = new Set(previous.stale);
|
|
80
|
+
for (const field of fields) if (stale) {
|
|
81
|
+
if (previous.present.has(field)) staleFields.add(field);
|
|
82
|
+
} else staleFields.delete(field);
|
|
83
|
+
next ??= { ...store };
|
|
84
|
+
next[key] = {
|
|
85
|
+
...previous,
|
|
86
|
+
stale: staleFields
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return next ?? store;
|
|
90
|
+
};
|
|
91
|
+
/** Marks present fields stale; fields that were never present stay missing. */
|
|
92
|
+
const markStale = (store, key, fields) => setStale(store, [[key, fields]], true);
|
|
93
|
+
/** Ends a refresh that did not land: the fields are present again as they were. */
|
|
94
|
+
const clearStale = (store, key, fields) => setStale(store, [[key, fields]], false);
|
|
95
|
+
/** Records that the entity is known to be absent, so it is not refetched. */
|
|
96
|
+
const tombstone = (store, key) => replace(store, key, {
|
|
97
|
+
values: {},
|
|
98
|
+
present: /* @__PURE__ */ new Set(),
|
|
99
|
+
stale: /* @__PURE__ */ new Set(),
|
|
100
|
+
tombstone: true,
|
|
101
|
+
updatedAt: 0,
|
|
102
|
+
windows: {}
|
|
103
|
+
});
|
|
104
|
+
/** Forgets everything known about the entity, including a tombstone. */
|
|
105
|
+
const remove = (store, key) => {
|
|
106
|
+
if (store[key] === void 0) return store;
|
|
107
|
+
const next = { ...store };
|
|
108
|
+
delete next[key];
|
|
109
|
+
return next;
|
|
110
|
+
};
|
|
111
|
+
const entry = (store, key) => {
|
|
112
|
+
const value = store[key];
|
|
113
|
+
return value === void 0 ? Option.none() : Option.some(value);
|
|
114
|
+
};
|
|
115
|
+
const isTombstone = (store, key) => store[key]?.tombstone === true;
|
|
116
|
+
/** A field is known only if it is present and not stale. */
|
|
117
|
+
const hasField = (store, key, field) => {
|
|
118
|
+
const value = store[key];
|
|
119
|
+
return value !== void 0 && !value.tombstone && value.present.has(field) && !value.stale.has(field);
|
|
120
|
+
};
|
|
121
|
+
/** A present field whose value may be outdated; absent fields are not stale. */
|
|
122
|
+
const isFieldStale = (store, key, field) => {
|
|
123
|
+
const value = store[key];
|
|
124
|
+
return value !== void 0 && !value.tombstone && value.stale.has(field);
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Reads a present field, stale or not. Absence and a tombstone both yield
|
|
128
|
+
* `Option.none()`; a present `undefined`/`null` yields `Option.some`.
|
|
129
|
+
*/
|
|
130
|
+
const readField = (store, key, field) => {
|
|
131
|
+
const value = store[key];
|
|
132
|
+
if (value === void 0 || value.tombstone || !value.present.has(field)) return Option.none();
|
|
133
|
+
return Option.some(value.values[field]);
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The fields the planner must fetch. A tombstone makes every field known
|
|
137
|
+
* (absent), so it returns an empty list and the entity is not refetched. A
|
|
138
|
+
* requested window that differs from the one a field was fetched with also
|
|
139
|
+
* marks it missing, but only when the stored window is known (never `""`), so a
|
|
140
|
+
* writer that does not record windows cannot cause a refetch loop.
|
|
141
|
+
*/
|
|
142
|
+
const missingFields = (store, key, fields, windows) => {
|
|
143
|
+
const value = store[key];
|
|
144
|
+
if (value !== void 0 && value.tombstone) return [];
|
|
145
|
+
return [...fields].filter((field) => {
|
|
146
|
+
if (value === void 0 || !value.present.has(field) || value.stale.has(field)) return true;
|
|
147
|
+
const requested = windows?.[field];
|
|
148
|
+
const stored = value.windows[field];
|
|
149
|
+
return requested !== void 0 && stored !== void 0 && stored !== "" && stored !== requested;
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/mutation.ts
|
|
154
|
+
/**
|
|
155
|
+
* Mutation status and idempotent reconciliation.
|
|
156
|
+
*
|
|
157
|
+
* A mutation result is reconciled into the store **at most once per
|
|
158
|
+
* `requestId`**, so a transport retry cannot apply the same change twice. An
|
|
159
|
+
* unknown or already-applied result is a no-op.
|
|
160
|
+
*/
|
|
161
|
+
const emptyMutationState = {
|
|
162
|
+
pending: /* @__PURE__ */ new Set(),
|
|
163
|
+
applied: /* @__PURE__ */ new Set(),
|
|
164
|
+
failed: /* @__PURE__ */ new Set(),
|
|
165
|
+
sequence: 0
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* How many settled request ids a mutation state retains. A retry settles well
|
|
169
|
+
* inside this window, so bounding the ledger stops a long-lived application
|
|
170
|
+
* from growing it without limit; a retry older than the window would re-apply
|
|
171
|
+
* its entities.
|
|
172
|
+
*/
|
|
173
|
+
const MUTATION_ID_WINDOW = 1024;
|
|
174
|
+
/** Records `id` as most recent, evicting the oldest once the window is full. */
|
|
175
|
+
const remember = (ids, id) => {
|
|
176
|
+
const next = new Set(ids);
|
|
177
|
+
next.delete(id);
|
|
178
|
+
next.add(id);
|
|
179
|
+
if (next.size <= MUTATION_ID_WINDOW) return next;
|
|
180
|
+
return new Set([...next].slice(next.size - MUTATION_ID_WINDOW));
|
|
181
|
+
};
|
|
182
|
+
const beginMutation = (state, requestId) => ({
|
|
183
|
+
...state,
|
|
184
|
+
pending: /* @__PURE__ */ new Set([...state.pending, requestId]),
|
|
185
|
+
sequence: state.sequence + 1
|
|
186
|
+
});
|
|
187
|
+
const failMutation = (state, requestId) => {
|
|
188
|
+
const pending = new Set(state.pending);
|
|
189
|
+
pending.delete(requestId);
|
|
190
|
+
return {
|
|
191
|
+
...state,
|
|
192
|
+
pending,
|
|
193
|
+
failed: remember(state.failed, requestId)
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Applies a mutation result once and marks the request settled. A second call
|
|
198
|
+
* with the same `requestId` (a retry, or a live event describing the same change)
|
|
199
|
+
* does not re-apply the entities, but still clears `pending`.
|
|
200
|
+
*/
|
|
201
|
+
const reconcileMutation = (store, state, requestId, entities) => {
|
|
202
|
+
const next = state.applied.has(requestId) ? store : entities.reduce((current, entity) => writeEntity(current, entityKey(entity.entity, entity.id), entity.values), store);
|
|
203
|
+
const applied = remember(state.applied, requestId);
|
|
204
|
+
const pending = new Set(state.pending);
|
|
205
|
+
pending.delete(requestId);
|
|
206
|
+
return {
|
|
207
|
+
store: next,
|
|
208
|
+
state: {
|
|
209
|
+
...state,
|
|
210
|
+
applied,
|
|
211
|
+
pending
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
/** The codec itself, or a `Schema.Struct` over the fields. */
|
|
216
|
+
const schemaOf = (shape) => Schema.isSchema(shape) ? shape : Schema.Struct(shape);
|
|
217
|
+
const Mutation = {
|
|
218
|
+
/**
|
|
219
|
+
* Declares a mutation. `Input` and `Output` are codecs, or the fields of the
|
|
220
|
+
* `Schema.Struct` they would be (`{ id: ProjectId, name: Schema.String }`).
|
|
221
|
+
*/
|
|
222
|
+
make: (name, config) => ({
|
|
223
|
+
name,
|
|
224
|
+
Input: schemaOf(config.Input),
|
|
225
|
+
Output: schemaOf(config.Output)
|
|
226
|
+
}) };
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/query.ts
|
|
229
|
+
/**
|
|
230
|
+
* Queries and connections. A connection's identity is its descriptor plus the
|
|
231
|
+
* canonical encoded filter/sort input — **never** the pagination window, so
|
|
232
|
+
* `first(25)` and `after(cursor).first(25)` address the same logical connection.
|
|
233
|
+
*/
|
|
234
|
+
/** Stable stringify: object keys sorted, undefined-valued keys dropped, so equal inputs encode equally. */
|
|
235
|
+
const stableStringify = (value) => {
|
|
236
|
+
if (value === void 0) return "null";
|
|
237
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
238
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
239
|
+
const record = value;
|
|
240
|
+
return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
241
|
+
};
|
|
242
|
+
const isEntityName = (result) => typeof result === "object" && result !== null && typeof result.name === "string";
|
|
243
|
+
const Query = {
|
|
244
|
+
/** Declares the entity and options a query's result is a connection over. */
|
|
245
|
+
connection: (entity, options) => ({
|
|
246
|
+
entity: entity.name,
|
|
247
|
+
...options?.edgeKey === void 0 ? {} : { edgeKey: options.edgeKey },
|
|
248
|
+
...options?.live === void 0 ? {} : { live: options.live }
|
|
249
|
+
}),
|
|
250
|
+
/**
|
|
251
|
+
* Declares a query. `Input` is a codec or the fields of the `Schema.Struct` it
|
|
252
|
+
* would be; `Result` is a `Query.connection(...)`, or the entity the result is
|
|
253
|
+
* a connection over.
|
|
254
|
+
*/
|
|
255
|
+
make: (name, config) => {
|
|
256
|
+
const Input = schemaOf(config.Input);
|
|
257
|
+
const encode = Schema.encodeSync(Input);
|
|
258
|
+
return {
|
|
259
|
+
name,
|
|
260
|
+
Input,
|
|
261
|
+
Result: isEntityName(config.Result) ? Query.connection(config.Result) : config.Result,
|
|
262
|
+
ref: (input) => ({
|
|
263
|
+
query: name,
|
|
264
|
+
input,
|
|
265
|
+
Input,
|
|
266
|
+
window: {},
|
|
267
|
+
identity: `${name}\u0000${stableStringify(encode(input))}`
|
|
268
|
+
})
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
first: (count) => (ref) => ({
|
|
272
|
+
...ref,
|
|
273
|
+
window: {
|
|
274
|
+
...ref.window,
|
|
275
|
+
first: count
|
|
276
|
+
}
|
|
277
|
+
}),
|
|
278
|
+
last: (count) => (ref) => ({
|
|
279
|
+
...ref,
|
|
280
|
+
window: {
|
|
281
|
+
...ref.window,
|
|
282
|
+
last: count
|
|
283
|
+
}
|
|
284
|
+
}),
|
|
285
|
+
after: (cursor) => (ref) => ({
|
|
286
|
+
...ref,
|
|
287
|
+
window: {
|
|
288
|
+
...ref.window,
|
|
289
|
+
after: cursor
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
before: (cursor) => (ref) => ({
|
|
293
|
+
...ref,
|
|
294
|
+
window: {
|
|
295
|
+
...ref.window,
|
|
296
|
+
before: cursor
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
};
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/wire.ts
|
|
302
|
+
/**
|
|
303
|
+
* The Remote wire: Effect RPC semantics over `effect/unstable/rpc`. Remote owns
|
|
304
|
+
* the message shapes; Effect owns the transport. `Make`ing reads batch, mutations
|
|
305
|
+
* preserve order, and live data is a stream.
|
|
306
|
+
*/
|
|
307
|
+
/**
|
|
308
|
+
* The read/live protocol version. A batch names the version it speaks and the
|
|
309
|
+
* server refuses a mismatch with `RemoteProtocolError`, so a shape change never
|
|
310
|
+
* drifts silently: bump it whenever `ReadRequest` or `LiveRequirement` change.
|
|
311
|
+
*/
|
|
312
|
+
const REMOTE_PROTOCOL_VERSION = 3;
|
|
313
|
+
var RemoteReadError = class extends Schema.TaggedError()("RemoteReadError", { message: Schema.String }) {};
|
|
314
|
+
var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", { message: Schema.String }) {};
|
|
315
|
+
var RemoteLiveError = class extends Schema.TaggedError()("RemoteLiveError", { message: Schema.String }) {};
|
|
316
|
+
/** The peer speaks another protocol version; nothing was read. */
|
|
317
|
+
var RemoteProtocolError = class extends Schema.TaggedError()("RemoteProtocolError", {
|
|
318
|
+
message: Schema.String,
|
|
319
|
+
expected: Schema.Number,
|
|
320
|
+
received: Schema.Number
|
|
321
|
+
}) {};
|
|
322
|
+
/** A page size: a non-negative integer, refused at decode otherwise rather than defaulted. */
|
|
323
|
+
const PageSize = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0));
|
|
324
|
+
const WindowSchema = Schema.Struct({
|
|
325
|
+
first: Schema.optional(PageSize),
|
|
326
|
+
last: Schema.optional(PageSize),
|
|
327
|
+
after: Schema.optional(Schema.String),
|
|
328
|
+
before: Schema.optional(Schema.String)
|
|
329
|
+
});
|
|
330
|
+
/** A request may not name more fields of one entity than this; a selection never does. */
|
|
331
|
+
const MAX_FIELDS_PER_REQUEST = 256;
|
|
332
|
+
/** A selection may not nest relations deeper than this; the wire refuses more. */
|
|
333
|
+
const MAX_RELATION_DEPTH = 8;
|
|
334
|
+
const Fields = Schema.Array(Schema.String).check(Schema.isMaxLength(256));
|
|
335
|
+
/**
|
|
336
|
+
* The slice required of a relation's target; the ids come from the parent's
|
|
337
|
+
* refs. Built as `MAX_RELATION_DEPTH` nested structs rather than a recursive
|
|
338
|
+
* schema, so a deeper selection is refused at decode with a schema error.
|
|
339
|
+
*/
|
|
340
|
+
const relationLevel = (depth) => {
|
|
341
|
+
const slice = {
|
|
342
|
+
entity: Schema.String,
|
|
343
|
+
fields: Fields,
|
|
344
|
+
windows: Schema.optional(Schema.Record(Schema.String, WindowSchema))
|
|
345
|
+
};
|
|
346
|
+
return depth === 0 ? Schema.Struct({
|
|
347
|
+
...slice,
|
|
348
|
+
relations: Schema.optionalKey(Schema.Never)
|
|
349
|
+
}) : Schema.Struct({
|
|
350
|
+
...slice,
|
|
351
|
+
relations: Schema.optional(Schema.Record(Schema.String, relationLevel(depth - 1)))
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
const RelationRequest = relationLevel(7);
|
|
355
|
+
const ReadRequest = Schema.Struct({
|
|
356
|
+
entity: Schema.String,
|
|
357
|
+
id: Schema.String,
|
|
358
|
+
fields: Fields,
|
|
359
|
+
/** Pagination window per relation field. */
|
|
360
|
+
windows: Schema.optional(Schema.Record(Schema.String, WindowSchema)),
|
|
361
|
+
/** The slice required of each relation field's target, resolved in the same read. */
|
|
362
|
+
relations: Schema.optional(Schema.Record(Schema.String, RelationRequest))
|
|
363
|
+
});
|
|
364
|
+
const ReadBatch = Schema.Struct({
|
|
365
|
+
version: Schema.Number,
|
|
366
|
+
requests: Schema.Array(ReadRequest)
|
|
367
|
+
});
|
|
368
|
+
const NormalizedEntity = Schema.Struct({
|
|
369
|
+
entity: Schema.String,
|
|
370
|
+
id: Schema.String,
|
|
371
|
+
values: Schema.Record(Schema.String, Schema.Unknown)
|
|
372
|
+
});
|
|
373
|
+
const ReadBatchResult = Schema.Struct({ entities: Schema.Array(NormalizedEntity) });
|
|
374
|
+
const MutationRequest = Schema.Struct({
|
|
375
|
+
/** Stable across transport retries so a mutation is not applied twice. */
|
|
376
|
+
requestId: Schema.String,
|
|
377
|
+
mutation: Schema.String,
|
|
378
|
+
input: Schema.Unknown
|
|
379
|
+
});
|
|
380
|
+
const LiveEdge = Schema.Struct({
|
|
381
|
+
entity: Schema.String,
|
|
382
|
+
id: Schema.String,
|
|
383
|
+
key: Schema.String
|
|
384
|
+
});
|
|
385
|
+
/** A connection change a mutation confirms: the same facts a live event carries, without a cursor. */
|
|
386
|
+
const ConnectionChangeSchema = Schema.Union([Schema.Struct({
|
|
387
|
+
_tag: Schema.Literal("Insert"),
|
|
388
|
+
connection: Schema.String,
|
|
389
|
+
position: Schema.Union([Schema.Literal("prepend"), Schema.Literal("append")]),
|
|
390
|
+
edge: LiveEdge
|
|
391
|
+
}), Schema.Struct({
|
|
392
|
+
_tag: Schema.Literal("Remove"),
|
|
393
|
+
connection: Schema.String,
|
|
394
|
+
edge: LiveEdge
|
|
395
|
+
})]);
|
|
396
|
+
const MutationResult = Schema.Struct({
|
|
397
|
+
output: Schema.Unknown,
|
|
398
|
+
entities: Schema.Array(NormalizedEntity),
|
|
399
|
+
/** Connection changes the mutation made, applied alongside its entity patches. */
|
|
400
|
+
connections: Schema.optional(Schema.Array(ConnectionChangeSchema))
|
|
401
|
+
});
|
|
402
|
+
const LiveRequirement = Schema.Struct({
|
|
403
|
+
version: Schema.Number,
|
|
404
|
+
requirements: Schema.Array(ReadRequest),
|
|
405
|
+
/** Resume cursor; events at or before it are duplicates. */
|
|
406
|
+
after: Schema.Number
|
|
407
|
+
});
|
|
408
|
+
/**
|
|
409
|
+
* A live change. Entity changes update the store; connection changes alter
|
|
410
|
+
* membership and ordering; both carry a per-stream cursor.
|
|
411
|
+
*/
|
|
412
|
+
const LiveChange = Schema.Union([
|
|
413
|
+
Schema.Struct({
|
|
414
|
+
_tag: Schema.Literal("EntityPatched"),
|
|
415
|
+
cursor: Schema.Number,
|
|
416
|
+
entity: Schema.String,
|
|
417
|
+
id: Schema.String,
|
|
418
|
+
values: Schema.Record(Schema.String, Schema.Unknown),
|
|
419
|
+
changed: Schema.Array(Schema.String)
|
|
420
|
+
}),
|
|
421
|
+
Schema.Struct({
|
|
422
|
+
_tag: Schema.Literal("EntityDeleted"),
|
|
423
|
+
cursor: Schema.Number,
|
|
424
|
+
entity: Schema.String,
|
|
425
|
+
id: Schema.String
|
|
426
|
+
}),
|
|
427
|
+
Schema.Struct({
|
|
428
|
+
_tag: Schema.Literal("ConnectionInsert"),
|
|
429
|
+
cursor: Schema.Number,
|
|
430
|
+
connection: Schema.String,
|
|
431
|
+
position: Schema.Union([Schema.Literal("prepend"), Schema.Literal("append")]),
|
|
432
|
+
edge: LiveEdge
|
|
433
|
+
}),
|
|
434
|
+
Schema.Struct({
|
|
435
|
+
_tag: Schema.Literal("ConnectionRemove"),
|
|
436
|
+
cursor: Schema.Number,
|
|
437
|
+
connection: Schema.String,
|
|
438
|
+
edge: LiveEdge
|
|
439
|
+
}),
|
|
440
|
+
Schema.Struct({
|
|
441
|
+
_tag: Schema.Literal("ConnectionInvalidate"),
|
|
442
|
+
cursor: Schema.Number,
|
|
443
|
+
connection: Schema.String
|
|
444
|
+
})
|
|
445
|
+
]);
|
|
446
|
+
const Read = Rpc.make("FoldkitRemoteRead", {
|
|
447
|
+
payload: ReadBatch,
|
|
448
|
+
success: ReadBatchResult,
|
|
449
|
+
error: Schema.Union([RemoteReadError, RemoteProtocolError])
|
|
450
|
+
});
|
|
451
|
+
const Mutate = Rpc.make("FoldkitRemoteMutate", {
|
|
452
|
+
payload: MutationRequest,
|
|
453
|
+
success: MutationResult,
|
|
454
|
+
error: RemoteMutationError
|
|
455
|
+
});
|
|
456
|
+
const Live = Rpc.make("FoldkitRemoteLive", {
|
|
457
|
+
payload: LiveRequirement,
|
|
458
|
+
success: LiveChange,
|
|
459
|
+
error: Schema.Union([RemoteLiveError, RemoteProtocolError]),
|
|
460
|
+
stream: true
|
|
461
|
+
});
|
|
462
|
+
var RemoteQueryError = class extends Schema.TaggedError()("RemoteQueryError", { message: Schema.String }) {};
|
|
463
|
+
const WireBoundary = Schema.Union([
|
|
464
|
+
Schema.Struct({ _tag: Schema.Literal("Terminal") }),
|
|
465
|
+
Schema.Struct({
|
|
466
|
+
_tag: Schema.Literal("Cursor"),
|
|
467
|
+
cursor: Schema.String
|
|
468
|
+
}),
|
|
469
|
+
Schema.Struct({ _tag: Schema.Literal("Unknown") })
|
|
470
|
+
]);
|
|
471
|
+
const QueryRequest = Schema.Struct({
|
|
472
|
+
query: Schema.String,
|
|
473
|
+
input: Schema.Unknown,
|
|
474
|
+
window: WindowSchema
|
|
475
|
+
});
|
|
476
|
+
const QueryEdge = Schema.Struct({
|
|
477
|
+
entity: Schema.String,
|
|
478
|
+
id: Schema.String,
|
|
479
|
+
key: Schema.String
|
|
480
|
+
});
|
|
481
|
+
const QueryResult = Schema.Struct({
|
|
482
|
+
edges: Schema.Array(QueryEdge),
|
|
483
|
+
start: WireBoundary,
|
|
484
|
+
end: WireBoundary
|
|
485
|
+
});
|
|
486
|
+
const QueryRpc = Rpc.make("FoldkitRemoteQuery", {
|
|
487
|
+
payload: QueryRequest,
|
|
488
|
+
success: QueryResult,
|
|
489
|
+
error: RemoteQueryError
|
|
490
|
+
});
|
|
491
|
+
const RemoteRpc = RpcGroup.make(Read, Mutate, QueryRpc, Live);
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/coalesce.ts
|
|
494
|
+
/**
|
|
495
|
+
* Read coalescing: every requirement a client asks for goes through one
|
|
496
|
+
* `RequestResolver`, so requirements issued together become one `ReadBatch`
|
|
497
|
+
* (ids batched, fields unioned) and a requirement already in flight is joined
|
|
498
|
+
* rather than re-requested. The planner stays pure; this is the transport
|
|
499
|
+
* seam's concern, and the store is still the only cache.
|
|
500
|
+
*/
|
|
501
|
+
/** A stable key for a requirement, so an equal requirement in flight is joined. */
|
|
502
|
+
const requirementKey = (requirement) => stableStringify([
|
|
503
|
+
requirement.entity,
|
|
504
|
+
requirement.id,
|
|
505
|
+
[...requirement.fields].sort(),
|
|
506
|
+
requirement.windows ?? null,
|
|
507
|
+
requirement.relations ?? null
|
|
508
|
+
]);
|
|
509
|
+
/** Whether a requirement pages a relation anywhere in its graph. */
|
|
510
|
+
const hasWindows = (requirement) => requirement.windows !== void 0 || Object.values(requirement.relations ?? {}).some(hasWindows);
|
|
511
|
+
/**
|
|
512
|
+
* The reads a batch runs. Requirements that page nothing union into one read
|
|
513
|
+
* per entity and id, and every waiter may write its whole result, since
|
|
514
|
+
* plain values are the same whoever asked. A requirement that pages a
|
|
515
|
+
* relation reads alone: a page answers exactly one window, and a waiter
|
|
516
|
+
* cannot tell which of two pages for the same entity was its own.
|
|
517
|
+
*/
|
|
518
|
+
const readsOf = (owned) => {
|
|
519
|
+
const plain = [...owned].filter(([, requirement]) => !hasWindows(requirement));
|
|
520
|
+
const paged = [...owned].filter(([, requirement]) => hasWindows(requirement));
|
|
521
|
+
return [...plain.length === 0 ? [] : [{
|
|
522
|
+
keys: plain.map(([key]) => key),
|
|
523
|
+
requests: Requirement.merge(plain.map(([, requirement]) => requirement))
|
|
524
|
+
}], ...paged.map(([key, requirement]) => ({
|
|
525
|
+
keys: [key],
|
|
526
|
+
requests: [requirement]
|
|
527
|
+
}))];
|
|
528
|
+
};
|
|
529
|
+
var QueryRead = class extends Request.Class {};
|
|
530
|
+
/**
|
|
531
|
+
* Wraps a raw query so that an identical query in flight (same query, input,
|
|
532
|
+
* and window) is joined rather than run again; distinct queries run
|
|
533
|
+
* concurrently. A page answers exactly one window, so nothing is batched.
|
|
534
|
+
*/
|
|
535
|
+
const coalesceQueries = (query, options = {}) => Effect.gen(function* () {
|
|
536
|
+
const runAll = (entries) => Effect.gen(function* () {
|
|
537
|
+
const distinct = /* @__PURE__ */ new Map();
|
|
538
|
+
for (const entry of entries) distinct.set(entry.request.key, entry.request.request);
|
|
539
|
+
const results = /* @__PURE__ */ new Map();
|
|
540
|
+
yield* Effect.forEach(distinct, ([key, request]) => Effect.exit(query(request)).pipe(Effect.map((exit) => void results.set(key, exit))), {
|
|
541
|
+
concurrency: "unbounded",
|
|
542
|
+
discard: true
|
|
543
|
+
});
|
|
544
|
+
for (const entry of entries) yield* Request.completeEffect(entry, results.get(entry.request.key));
|
|
545
|
+
});
|
|
546
|
+
let resolver = RequestResolver.make(runAll);
|
|
547
|
+
if (options.window !== void 0) resolver = RequestResolver.setDelay(resolver, Duration.fromInputUnsafe(options.window));
|
|
548
|
+
return (request) => Effect.request(new QueryRead({
|
|
549
|
+
key: stableStringify([
|
|
550
|
+
request.query,
|
|
551
|
+
request.input,
|
|
552
|
+
request.window
|
|
553
|
+
]),
|
|
554
|
+
request
|
|
555
|
+
}), resolver);
|
|
556
|
+
});
|
|
557
|
+
var ReadRequirement = class extends Request.Class {};
|
|
558
|
+
/**
|
|
559
|
+
* Wraps a raw batch read so that requirements are batched and deduplicated.
|
|
560
|
+
* Every waiter receives the whole batch result; `Remote.writeRead` is
|
|
561
|
+
* idempotent, so writing it more than once is harmless.
|
|
562
|
+
*/
|
|
563
|
+
const coalesceReads = (read, options = {}) => Effect.gen(function* () {
|
|
564
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
565
|
+
const runAll = (entries) => Effect.gen(function* () {
|
|
566
|
+
const joins = /* @__PURE__ */ new Map();
|
|
567
|
+
const own = /* @__PURE__ */ new Map();
|
|
568
|
+
for (const entry of entries) {
|
|
569
|
+
const key = entry.request.key;
|
|
570
|
+
if (joins.has(key)) continue;
|
|
571
|
+
const existing = inFlight.get(key);
|
|
572
|
+
if (existing !== void 0) {
|
|
573
|
+
joins.set(key, existing);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const deferred = yield* Deferred.make();
|
|
577
|
+
inFlight.set(key, deferred);
|
|
578
|
+
own.set(key, deferred);
|
|
579
|
+
joins.set(key, deferred);
|
|
580
|
+
}
|
|
581
|
+
if (own.size > 0) {
|
|
582
|
+
const owned = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const entry of entries) if (own.has(entry.request.key)) owned.set(entry.request.key, entry.request.requirement);
|
|
584
|
+
yield* Effect.forEach(readsOf(owned), ({ keys, requests }) => Effect.gen(function* () {
|
|
585
|
+
const exit = yield* Effect.exit(read({
|
|
586
|
+
version: 3,
|
|
587
|
+
requests
|
|
588
|
+
}));
|
|
589
|
+
for (const key of keys) {
|
|
590
|
+
inFlight.delete(key);
|
|
591
|
+
yield* Deferred.done(own.get(key), exit);
|
|
592
|
+
}
|
|
593
|
+
}), {
|
|
594
|
+
concurrency: "unbounded",
|
|
595
|
+
discard: true
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
for (const entry of entries) yield* Request.completeEffect(entry, Deferred.await(joins.get(entry.request.key)));
|
|
599
|
+
});
|
|
600
|
+
let resolver = RequestResolver.make(runAll);
|
|
601
|
+
if (options.window !== void 0) resolver = RequestResolver.setDelay(resolver, Duration.fromInputUnsafe(options.window));
|
|
602
|
+
return (batch) => batch.requests.length === 0 ? Effect.succeed({ entities: [] }) : Effect.forEach(batch.requests, (requirement) => Effect.request(new ReadRequirement({
|
|
603
|
+
key: requirementKey(requirement),
|
|
604
|
+
requirement
|
|
605
|
+
}), resolver), { concurrency: "unbounded" }).pipe(Effect.map((results) => ({ entities: [...new Set(results)].flatMap((result) => result.entities) })));
|
|
606
|
+
});
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/client.ts
|
|
609
|
+
/**
|
|
610
|
+
* The transport seam: the `RemoteClient` Effect service, the RPC client it is
|
|
611
|
+
* built from, the wire-to-client reconstructions, and the mutation call.
|
|
612
|
+
*/
|
|
613
|
+
/**
|
|
614
|
+
* The transport boundary. `foldkit-remote` never talks to a transport directly;
|
|
615
|
+
* it depends on this Effect service, which a later phase wires to Effect RPC.
|
|
616
|
+
*/
|
|
617
|
+
var RemoteClient = class extends Context.Service()("foldkit-remote/RemoteClient") {};
|
|
618
|
+
/** Reconstructs the client's `ConnectionChange` from the wire's flattened edge. */
|
|
619
|
+
const connectionChangeOf = (change) => {
|
|
620
|
+
const edge = {
|
|
621
|
+
key: change.edge.key,
|
|
622
|
+
ref: {
|
|
623
|
+
entity: change.edge.entity,
|
|
624
|
+
id: change.edge.id
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
return change._tag === "Insert" ? {
|
|
628
|
+
_tag: "Insert",
|
|
629
|
+
connection: change.connection,
|
|
630
|
+
position: change.position,
|
|
631
|
+
edge
|
|
632
|
+
} : {
|
|
633
|
+
_tag: "Remove",
|
|
634
|
+
connection: change.connection,
|
|
635
|
+
edge
|
|
636
|
+
};
|
|
637
|
+
};
|
|
638
|
+
/** Reconstructs the client's `LiveEvent` from the wire's flattened `LiveChange`. */
|
|
639
|
+
const liveEventOf = (change) => {
|
|
640
|
+
switch (change._tag) {
|
|
641
|
+
case "EntityPatched": return {
|
|
642
|
+
_tag: "EntityPatched",
|
|
643
|
+
ref: {
|
|
644
|
+
entity: change.entity,
|
|
645
|
+
id: change.id
|
|
646
|
+
},
|
|
647
|
+
values: change.values,
|
|
648
|
+
changed: change.changed,
|
|
649
|
+
cursor: change.cursor
|
|
650
|
+
};
|
|
651
|
+
case "EntityDeleted": return {
|
|
652
|
+
_tag: "EntityDeleted",
|
|
653
|
+
ref: {
|
|
654
|
+
entity: change.entity,
|
|
655
|
+
id: change.id
|
|
656
|
+
},
|
|
657
|
+
cursor: change.cursor
|
|
658
|
+
};
|
|
659
|
+
case "ConnectionInsert": return {
|
|
660
|
+
_tag: "ConnectionInsert",
|
|
661
|
+
connection: change.connection,
|
|
662
|
+
position: change.position,
|
|
663
|
+
edge: {
|
|
664
|
+
key: change.edge.key,
|
|
665
|
+
ref: {
|
|
666
|
+
entity: change.edge.entity,
|
|
667
|
+
id: change.edge.id
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
cursor: change.cursor
|
|
671
|
+
};
|
|
672
|
+
case "ConnectionRemove": return {
|
|
673
|
+
_tag: "ConnectionRemove",
|
|
674
|
+
connection: change.connection,
|
|
675
|
+
edge: {
|
|
676
|
+
key: change.edge.key,
|
|
677
|
+
ref: {
|
|
678
|
+
entity: change.edge.entity,
|
|
679
|
+
id: change.edge.id
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
cursor: change.cursor
|
|
683
|
+
};
|
|
684
|
+
case "ConnectionInvalidate": return {
|
|
685
|
+
_tag: "ConnectionInvalidate",
|
|
686
|
+
connection: change.connection,
|
|
687
|
+
cursor: change.cursor
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
const remoteError = (error) => ({
|
|
692
|
+
_tag: error._tag,
|
|
693
|
+
message: error.message
|
|
694
|
+
});
|
|
695
|
+
const coalescedLayer = (layer, options = {}) => Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
696
|
+
const client = yield* RemoteClient;
|
|
697
|
+
const read = yield* coalesceReads(client.read, options);
|
|
698
|
+
const query = yield* coalesceQueries(client.query, options);
|
|
699
|
+
return {
|
|
700
|
+
...client,
|
|
701
|
+
read,
|
|
702
|
+
query
|
|
703
|
+
};
|
|
704
|
+
})).pipe(Layer.provide(layer));
|
|
705
|
+
/**
|
|
706
|
+
* `Remote.mutate` as a standalone effect, so `Remote.mutateInto` can reuse it
|
|
707
|
+
* without the object literal referencing itself.
|
|
708
|
+
*/
|
|
709
|
+
const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requestId) {
|
|
710
|
+
yield* Effect.annotateCurrentSpan({
|
|
711
|
+
mutation: mutation.name,
|
|
712
|
+
requestId
|
|
713
|
+
});
|
|
714
|
+
const client = yield* RemoteClient;
|
|
715
|
+
const encoded = yield* Schema.encodeUnknownEffect(mutation.Input)(input).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message }))));
|
|
716
|
+
const result = yield* client.mutate({
|
|
717
|
+
requestId,
|
|
718
|
+
mutation: mutation.name,
|
|
719
|
+
input: encoded
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
output: yield* Schema.decodeUnknownEffect(mutation.Output)(result.output).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message })))),
|
|
723
|
+
entities: result.entities,
|
|
724
|
+
connections: (result.connections ?? []).map(connectionChangeOf)
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/connection.ts
|
|
729
|
+
/**
|
|
730
|
+
* The pure connection model: an ordered normalized structure of edges with
|
|
731
|
+
* explicit known boundaries. Pages, live events, and optimistic inserts are all
|
|
732
|
+
* evidence about one connection; `merge` is the single deterministic reducer and
|
|
733
|
+
* performs no I/O.
|
|
734
|
+
*
|
|
735
|
+
* Optimistic overlays and live state arrive in later phases.
|
|
736
|
+
*/
|
|
737
|
+
const edge = (ref, key = entityKey(ref.entity, ref.id)) => ({
|
|
738
|
+
key,
|
|
739
|
+
ref
|
|
740
|
+
});
|
|
741
|
+
const terminal = { _tag: "Terminal" };
|
|
742
|
+
const unknown = { _tag: "Unknown" };
|
|
743
|
+
const cursor = (value) => ({
|
|
744
|
+
_tag: "Cursor",
|
|
745
|
+
cursor: value
|
|
746
|
+
});
|
|
747
|
+
const emptyConnection = {
|
|
748
|
+
segments: [],
|
|
749
|
+
stale: false
|
|
750
|
+
};
|
|
751
|
+
const segment = (edges, start, end) => ({
|
|
752
|
+
edges,
|
|
753
|
+
start,
|
|
754
|
+
end
|
|
755
|
+
});
|
|
756
|
+
const dedupeEdges = (edges) => {
|
|
757
|
+
const seen = /* @__PURE__ */ new Set();
|
|
758
|
+
const out = [];
|
|
759
|
+
for (const value of edges) {
|
|
760
|
+
if (seen.has(value.key)) continue;
|
|
761
|
+
seen.add(value.key);
|
|
762
|
+
out.push(value);
|
|
763
|
+
}
|
|
764
|
+
return out;
|
|
765
|
+
};
|
|
766
|
+
const normalize = (segment) => ({
|
|
767
|
+
...segment,
|
|
768
|
+
edges: dedupeEdges(segment.edges)
|
|
769
|
+
});
|
|
770
|
+
/** Longest suffix of `a` that is a prefix of `b`, matched by edge identity. */
|
|
771
|
+
const overlapLength = (a, b) => {
|
|
772
|
+
const most = Math.min(a.length, b.length);
|
|
773
|
+
for (let length = most; length > 0; length--) {
|
|
774
|
+
let matches = true;
|
|
775
|
+
for (let i = 0; i < length; i++) if (a[a.length - length + i].key !== b[i].key) {
|
|
776
|
+
matches = false;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
if (matches) return length;
|
|
780
|
+
}
|
|
781
|
+
return 0;
|
|
782
|
+
};
|
|
783
|
+
const cursorJoinable = (a, b) => a._tag === "Cursor" && b._tag === "Cursor" && a.cursor === b.cursor;
|
|
784
|
+
/** Joins `a` immediately before `b`, or returns `undefined` when they are not contiguous. */
|
|
785
|
+
const join = (a, b) => {
|
|
786
|
+
if (cursorJoinable(a.end, b.start)) return {
|
|
787
|
+
edges: dedupeEdges([...a.edges, ...b.edges]),
|
|
788
|
+
start: a.start,
|
|
789
|
+
end: b.end
|
|
790
|
+
};
|
|
791
|
+
if (overlapLength(a.edges, b.edges) > 0) return {
|
|
792
|
+
edges: dedupeEdges([...a.edges, ...b.edges]),
|
|
793
|
+
start: a.start,
|
|
794
|
+
end: b.end
|
|
795
|
+
};
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* Global by edge identity: an edge appears once per connection. Dropping a
|
|
799
|
+
* shared edge from the middle of a segment would otherwise imply that its
|
|
800
|
+
* surviving neighbours are adjacent, so a segment with holes is split at each
|
|
801
|
+
* hole and the new inner boundaries are `Unknown`.
|
|
802
|
+
*/
|
|
803
|
+
const dedupeConnection = (segments) => {
|
|
804
|
+
const seen = /* @__PURE__ */ new Set();
|
|
805
|
+
const out = [];
|
|
806
|
+
for (const segmentValue of segments) {
|
|
807
|
+
const runs = [];
|
|
808
|
+
let run = [];
|
|
809
|
+
for (const edgeValue of segmentValue.edges) {
|
|
810
|
+
if (seen.has(edgeValue.key)) {
|
|
811
|
+
if (run.length > 0) {
|
|
812
|
+
runs.push(run);
|
|
813
|
+
run = [];
|
|
814
|
+
}
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
seen.add(edgeValue.key);
|
|
818
|
+
run.push(edgeValue);
|
|
819
|
+
}
|
|
820
|
+
if (run.length > 0) runs.push(run);
|
|
821
|
+
if (runs.length === 0) continue;
|
|
822
|
+
if (runs.length === 1) {
|
|
823
|
+
out.push({
|
|
824
|
+
...segmentValue,
|
|
825
|
+
edges: runs[0]
|
|
826
|
+
});
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
runs.forEach((edges, index) => {
|
|
830
|
+
out.push({
|
|
831
|
+
edges,
|
|
832
|
+
start: index === 0 ? segmentValue.start : unknown,
|
|
833
|
+
end: index === runs.length - 1 ? segmentValue.end : unknown
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return out;
|
|
838
|
+
};
|
|
839
|
+
/** Adds a page to the connection, joining contiguous or overlapping segments. */
|
|
840
|
+
const merge = (current, page) => {
|
|
841
|
+
const incoming = normalize(page);
|
|
842
|
+
if (incoming.edges.length === 0) return current;
|
|
843
|
+
const segments = [...current.segments.map(normalize), incoming];
|
|
844
|
+
let joined = true;
|
|
845
|
+
while (joined) {
|
|
846
|
+
joined = false;
|
|
847
|
+
outer: for (let i = 0; i < segments.length; i++) for (let j = 0; j < segments.length; j++) {
|
|
848
|
+
if (i === j) continue;
|
|
849
|
+
const result = join(segments[i], segments[j]);
|
|
850
|
+
if (result === void 0) continue;
|
|
851
|
+
segments[i] = result;
|
|
852
|
+
segments.splice(j, 1);
|
|
853
|
+
joined = true;
|
|
854
|
+
break outer;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
...current,
|
|
859
|
+
segments: orderSegments(dedupeConnection(segments))
|
|
860
|
+
};
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* Disjoint segments have no cursor linking them, so their relative order is
|
|
864
|
+
* whatever it was; the two exceptions are known: a segment starting at the
|
|
865
|
+
* terminal boundary is first, one ending at it is last.
|
|
866
|
+
*/
|
|
867
|
+
const orderSegments = (segments) => {
|
|
868
|
+
const first = segments.filter((value) => value.start._tag === "Terminal");
|
|
869
|
+
const last = segments.filter((value) => value.end._tag === "Terminal" && !first.includes(value));
|
|
870
|
+
const middle = segments.filter((value) => !first.includes(value) && !last.includes(value));
|
|
871
|
+
return [
|
|
872
|
+
...first,
|
|
873
|
+
...middle,
|
|
874
|
+
...last
|
|
875
|
+
];
|
|
876
|
+
};
|
|
877
|
+
/** Visible edges, segment by segment. */
|
|
878
|
+
const items = (connection) => connection.segments.flatMap((value) => value.edges);
|
|
879
|
+
/** There is more before the known region. Derived from boundaries, never row count. */
|
|
880
|
+
const hasPrevious = (connection) => {
|
|
881
|
+
const first = connection.segments[0];
|
|
882
|
+
return first !== void 0 && first.start._tag !== "Terminal";
|
|
883
|
+
};
|
|
884
|
+
/** There is more after the known region. Derived from boundaries, never row count. */
|
|
885
|
+
const hasNext = (connection) => {
|
|
886
|
+
const last = connection.segments[connection.segments.length - 1];
|
|
887
|
+
return last !== void 0 && last.end._tag !== "Terminal";
|
|
888
|
+
};
|
|
889
|
+
/** A connection with more than one segment has an explicit unknown gap. */
|
|
890
|
+
const isGapped = (connection) => connection.segments.length > 1;
|
|
891
|
+
//#endregion
|
|
892
|
+
//#region src/inspect.ts
|
|
893
|
+
const inspectEntry = (key, entry) => ({
|
|
894
|
+
key,
|
|
895
|
+
present: [...entry.present],
|
|
896
|
+
stale: [...entry.stale],
|
|
897
|
+
tombstone: entry.tombstone,
|
|
898
|
+
updatedAt: entry.updatedAt,
|
|
899
|
+
windows: { ...entry.windows }
|
|
900
|
+
});
|
|
901
|
+
/** A pure, serializable view of the whole cache. */
|
|
902
|
+
const inspectRemote = (model) => ({
|
|
903
|
+
entities: Object.entries(model.entities).map(([key, entry]) => inspectEntry(key, entry)),
|
|
904
|
+
connections: Object.keys(model.connections),
|
|
905
|
+
live: Object.keys(model.live),
|
|
906
|
+
gaps: [...model.gaps],
|
|
907
|
+
mutations: {
|
|
908
|
+
pending: [...model.mutations.pending],
|
|
909
|
+
failed: [...model.mutations.failed],
|
|
910
|
+
applied: model.mutations.applied.size
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
/** A pure, serializable view of one entity, or `undefined` if unknown. */
|
|
914
|
+
const inspectEntity = (model, key) => {
|
|
915
|
+
const entry = model.entities[key];
|
|
916
|
+
return entry === void 0 ? void 0 : inspectEntry(key, entry);
|
|
917
|
+
};
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/optimistic.ts
|
|
920
|
+
/**
|
|
921
|
+
* Optimistic layers. Pending changes are held as ordered layers over a base
|
|
922
|
+
* store and as overlays over a connection; the visible value is recomputed, never
|
|
923
|
+
* patched with inverses. Settling is remove-the-layer, so overlapping layers
|
|
924
|
+
* rebase for free.
|
|
925
|
+
*/
|
|
926
|
+
const isConnectionChange = (operation) => "_tag" in operation;
|
|
927
|
+
const toOverlays = (id, changes) => changes.map((change) => ({
|
|
928
|
+
id,
|
|
929
|
+
connection: change.connection,
|
|
930
|
+
edges: [change.edge],
|
|
931
|
+
position: change._tag === "Insert" ? change.position : "remove"
|
|
932
|
+
}));
|
|
933
|
+
const connectionIdentity = (connection) => typeof connection === "string" ? connection : connection.identity;
|
|
934
|
+
const change = (connection, ref, position) => position === "remove" ? {
|
|
935
|
+
_tag: "Remove",
|
|
936
|
+
connection: connectionIdentity(connection),
|
|
937
|
+
edge: edge(ref)
|
|
938
|
+
} : {
|
|
939
|
+
_tag: "Insert",
|
|
940
|
+
connection: connectionIdentity(connection),
|
|
941
|
+
position,
|
|
942
|
+
edge: edge(ref)
|
|
943
|
+
};
|
|
944
|
+
/**
|
|
945
|
+
* Constructors for connection changes: what a request shows optimistically
|
|
946
|
+
* (`MutateOptions.optimistic`) and what a mutation source reports it made
|
|
947
|
+
* (`MutationOutcome.connections`).
|
|
948
|
+
*/
|
|
949
|
+
const ConnectionChange = {
|
|
950
|
+
/** Show `ref` at the front of the connection until the request settles. */
|
|
951
|
+
prepend: (connection, ref) => change(connection, ref, "prepend"),
|
|
952
|
+
/** Show `ref` at the end of the connection until the request settles. */
|
|
953
|
+
append: (connection, ref) => change(connection, ref, "append"),
|
|
954
|
+
/** Hide `ref` from the connection until the request settles. */
|
|
955
|
+
remove: (connection, ref) => change(connection, ref, "remove")
|
|
956
|
+
};
|
|
957
|
+
/**
|
|
958
|
+
* Applies a request's operations: its patches become one layer and its
|
|
959
|
+
* connection changes become overlays, all owned by `requestId` so settling
|
|
960
|
+
* removes every one of them together.
|
|
961
|
+
*/
|
|
962
|
+
const beginOptimistic = (optimistic, requestId, operations) => {
|
|
963
|
+
const patches = operations.filter((operation) => !isConnectionChange(operation));
|
|
964
|
+
const changes = operations.filter(isConnectionChange);
|
|
965
|
+
return {
|
|
966
|
+
layers: patches.length === 0 ? optimistic.layers : [...optimistic.layers, {
|
|
967
|
+
id: requestId,
|
|
968
|
+
patches
|
|
969
|
+
}],
|
|
970
|
+
overlays: [...optimistic.overlays, ...toOverlays(requestId, changes)]
|
|
971
|
+
};
|
|
972
|
+
};
|
|
973
|
+
/**
|
|
974
|
+
* Replaces a request's overlays with the server-confirmed changes, owned by
|
|
975
|
+
* `id`, in the position the request's overlays held, so a confirmed edge keeps
|
|
976
|
+
* its place among other pending inserts.
|
|
977
|
+
*/
|
|
978
|
+
const confirm = (optimistic, requestId, id, changes) => {
|
|
979
|
+
const confirmed = toOverlays(id, changes);
|
|
980
|
+
const at = optimistic.overlays.findIndex((overlay) => overlay.id === requestId);
|
|
981
|
+
const others = optimistic.overlays.filter((overlay) => overlay.id !== requestId);
|
|
982
|
+
return {
|
|
983
|
+
...optimistic,
|
|
984
|
+
overlays: at === -1 ? [...others, ...confirmed] : [
|
|
985
|
+
...others.slice(0, at),
|
|
986
|
+
...confirmed,
|
|
987
|
+
...others.slice(at)
|
|
988
|
+
]
|
|
989
|
+
};
|
|
990
|
+
};
|
|
991
|
+
const emptyOptimistic = {
|
|
992
|
+
layers: [],
|
|
993
|
+
overlays: []
|
|
994
|
+
};
|
|
995
|
+
const addLayer = (optimistic, layer) => ({
|
|
996
|
+
...optimistic,
|
|
997
|
+
layers: [...optimistic.layers, layer]
|
|
998
|
+
});
|
|
999
|
+
const removeLayer = (optimistic, id) => ({
|
|
1000
|
+
...optimistic,
|
|
1001
|
+
layers: optimistic.layers.filter((layer) => layer.id !== id)
|
|
1002
|
+
});
|
|
1003
|
+
const addOverlay = (optimistic, overlay) => ({
|
|
1004
|
+
...optimistic,
|
|
1005
|
+
overlays: [...optimistic.overlays, overlay]
|
|
1006
|
+
});
|
|
1007
|
+
const removeOverlay = (optimistic, id) => ({
|
|
1008
|
+
...optimistic,
|
|
1009
|
+
overlays: optimistic.overlays.filter((overlay) => overlay.id !== id)
|
|
1010
|
+
});
|
|
1011
|
+
/** Base store with every layer applied in order. Later layers win. */
|
|
1012
|
+
const visibleStore = (base, optimistic) => optimistic.layers.reduce((store, layer) => layer.patches.reduce((current, patch) => writeEntity(current, entityKey(patch.entity, patch.id), patch.values), store), base);
|
|
1013
|
+
/**
|
|
1014
|
+
* A fresh page is newer than any settled overlay it covers: an edge the page
|
|
1015
|
+
* carries no longer needs an insert overlay, and a `remove` overlay hiding it
|
|
1016
|
+
* is stale (the server says it is back). A pending request's overlays are
|
|
1017
|
+
* left alone; they settle with the request.
|
|
1018
|
+
*/
|
|
1019
|
+
const pruneOverlays = (optimistic, connection, covered, pending) => ({
|
|
1020
|
+
...optimistic,
|
|
1021
|
+
overlays: optimistic.overlays.flatMap((overlay) => {
|
|
1022
|
+
if (overlay.connection !== connection || pending.has(overlay.id)) return [overlay];
|
|
1023
|
+
const edges = overlay.edges.filter((edge) => !covered.has(edge.key));
|
|
1024
|
+
return edges.length === 0 ? [] : [{
|
|
1025
|
+
...overlay,
|
|
1026
|
+
edges
|
|
1027
|
+
}];
|
|
1028
|
+
})
|
|
1029
|
+
});
|
|
1030
|
+
/** Everything a request owns: its layer and its overlays. */
|
|
1031
|
+
const release = (optimistic, requestId) => removeOverlay(removeLayer(optimistic, requestId), requestId);
|
|
1032
|
+
/**
|
|
1033
|
+
* Applies a result idempotently and releases the request's layer and overlays:
|
|
1034
|
+
* a later layer re-wins on rebase. Confirmed connection changes are recorded
|
|
1035
|
+
* once per request, as overlays the request no longer owns.
|
|
1036
|
+
*/
|
|
1037
|
+
const settleSuccess = (base, optimistic, state, requestId, entities, connections = []) => {
|
|
1038
|
+
const reconciled = reconcileMutation(base, state, requestId, entities);
|
|
1039
|
+
return {
|
|
1040
|
+
store: reconciled.store,
|
|
1041
|
+
state: reconciled.state,
|
|
1042
|
+
optimistic: state.applied.has(requestId) ? release(optimistic, requestId) : confirm(removeLayer(optimistic, requestId), requestId, `confirmed:${requestId}`, connections)
|
|
1043
|
+
};
|
|
1044
|
+
};
|
|
1045
|
+
/** A failed request's layer and overlays are dropped; the base was never mutated. */
|
|
1046
|
+
const settleFailure = (optimistic, requestId) => release(optimistic, requestId);
|
|
1047
|
+
/**
|
|
1048
|
+
* Visible edges for one connection: applicable overlays are placed outside the
|
|
1049
|
+
* server-known segments and de-duplicated by edge identity (across overlays too),
|
|
1050
|
+
* so a pending insert never corrupts server-known ordering; a `remove` overlay
|
|
1051
|
+
* hides its edges wherever they are, and so does a tombstone on the edge's
|
|
1052
|
+
* target when `store` is given. A later prepend lands before an earlier one, a
|
|
1053
|
+
* later append after, so pending inserts read in the order they were made.
|
|
1054
|
+
* Boundaries still come from the connection's segments.
|
|
1055
|
+
*/
|
|
1056
|
+
const visibleItems = (connection, connectionId, overlays, store) => {
|
|
1057
|
+
const applicable = overlays.filter((overlay) => overlay.connection === connectionId);
|
|
1058
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
1059
|
+
for (const overlay of applicable) for (const edge of overlay.edges) if (overlay.position === "remove") hidden.add(edge.key);
|
|
1060
|
+
else hidden.delete(edge.key);
|
|
1061
|
+
const gone = (edge) => hidden.has(edge.key) || store !== void 0 && isTombstone(store, entityKey(edge.ref.entity, edge.ref.id));
|
|
1062
|
+
const known = items(connection).filter((edge) => !gone(edge));
|
|
1063
|
+
const seen = new Set(known.map((edge) => edge.key));
|
|
1064
|
+
const take = (position) => {
|
|
1065
|
+
const edges = [];
|
|
1066
|
+
const ordered = applicable.filter((value) => value.position === position);
|
|
1067
|
+
for (const overlay of position === "prepend" ? [...ordered].reverse() : ordered) for (const edge of overlay.edges) {
|
|
1068
|
+
if (seen.has(edge.key) || gone(edge)) continue;
|
|
1069
|
+
seen.add(edge.key);
|
|
1070
|
+
edges.push(edge);
|
|
1071
|
+
}
|
|
1072
|
+
return edges;
|
|
1073
|
+
};
|
|
1074
|
+
return [
|
|
1075
|
+
...take("prepend"),
|
|
1076
|
+
...known,
|
|
1077
|
+
...take("append")
|
|
1078
|
+
];
|
|
1079
|
+
};
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region src/live.ts
|
|
1082
|
+
const emptyLiveState = {
|
|
1083
|
+
cursor: 0,
|
|
1084
|
+
stale: /* @__PURE__ */ new Set(),
|
|
1085
|
+
boundary: {}
|
|
1086
|
+
};
|
|
1087
|
+
/**
|
|
1088
|
+
* Ordering per stream: at most the next cursor; ahead is a gap. Cursor `0` means
|
|
1089
|
+
* "no baseline yet" (cursors are 1-based), so the first event of a stream is
|
|
1090
|
+
* accepted whatever its cursor — otherwise a stream not starting at 1 would be a
|
|
1091
|
+
* spurious gap.
|
|
1092
|
+
*/
|
|
1093
|
+
const classifyLive = (state, cursor) => cursor <= state.cursor ? "duplicate" : state.cursor === 0 || cursor === state.cursor + 1 ? "applied" : "gap";
|
|
1094
|
+
const advance = (state, cursor) => ({
|
|
1095
|
+
...state,
|
|
1096
|
+
cursor
|
|
1097
|
+
});
|
|
1098
|
+
const recordBoundary = (state, connection, position) => {
|
|
1099
|
+
const current = state.boundary[connection] ?? {
|
|
1100
|
+
before: 0,
|
|
1101
|
+
after: 0
|
|
1102
|
+
};
|
|
1103
|
+
const next = position === "prepend" ? {
|
|
1104
|
+
...current,
|
|
1105
|
+
before: current.before + 1
|
|
1106
|
+
} : {
|
|
1107
|
+
...current,
|
|
1108
|
+
after: current.after + 1
|
|
1109
|
+
};
|
|
1110
|
+
return {
|
|
1111
|
+
...state,
|
|
1112
|
+
boundary: {
|
|
1113
|
+
...state.boundary,
|
|
1114
|
+
[connection]: next
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
};
|
|
1118
|
+
const invalidateConnection = (state, connection) => ({
|
|
1119
|
+
...state,
|
|
1120
|
+
stale: /* @__PURE__ */ new Set([...state.stale, connection])
|
|
1121
|
+
});
|
|
1122
|
+
/**
|
|
1123
|
+
* A subscriber is woken only if the event changed a field it selects. A
|
|
1124
|
+
* change to an unselected field is skipped entirely.
|
|
1125
|
+
*/
|
|
1126
|
+
const shouldWake = (changed, selected) => changed.some((field) => selected.includes(field));
|
|
1127
|
+
const applyEntityEvent = (state, store, event, now = 0) => {
|
|
1128
|
+
const outcome = classifyLive(state, event.cursor);
|
|
1129
|
+
if (outcome !== "applied") return {
|
|
1130
|
+
state,
|
|
1131
|
+
store,
|
|
1132
|
+
outcome
|
|
1133
|
+
};
|
|
1134
|
+
const key = entityKey(event.ref.entity, event.ref.id);
|
|
1135
|
+
const next = event._tag === "EntityPatched" ? writeEntity(store, key, event.values, now) : tombstone(store, key);
|
|
1136
|
+
return {
|
|
1137
|
+
state: advance(state, event.cursor),
|
|
1138
|
+
store: next,
|
|
1139
|
+
outcome
|
|
1140
|
+
};
|
|
1141
|
+
};
|
|
1142
|
+
const removeEdgeOverlays = (optimistic, connection, key) => ({
|
|
1143
|
+
...optimistic,
|
|
1144
|
+
overlays: optimistic.overlays.map((overlay) => overlay.connection !== connection || overlay.position === "remove" ? overlay : {
|
|
1145
|
+
...overlay,
|
|
1146
|
+
edges: overlay.edges.filter((edge) => edge.key !== key)
|
|
1147
|
+
})
|
|
1148
|
+
});
|
|
1149
|
+
const applyConnectionEvent = (state, optimistic, event, policy = {}) => {
|
|
1150
|
+
const outcome = classifyLive(state, event.cursor);
|
|
1151
|
+
if (outcome !== "applied") return {
|
|
1152
|
+
state,
|
|
1153
|
+
optimistic,
|
|
1154
|
+
outcome
|
|
1155
|
+
};
|
|
1156
|
+
switch (event._tag) {
|
|
1157
|
+
case "ConnectionInsert": switch ((event.position === "prepend" ? policy.prepend : policy.append) ?? "visible") {
|
|
1158
|
+
case "visible": return {
|
|
1159
|
+
state: advance(state, event.cursor),
|
|
1160
|
+
optimistic: addOverlay(optimistic, {
|
|
1161
|
+
id: `live:${event.cursor}`,
|
|
1162
|
+
connection: event.connection,
|
|
1163
|
+
edges: [event.edge],
|
|
1164
|
+
position: event.position
|
|
1165
|
+
}),
|
|
1166
|
+
outcome
|
|
1167
|
+
};
|
|
1168
|
+
case "boundary": return {
|
|
1169
|
+
state: recordBoundary(advance(state, event.cursor), event.connection, event.position),
|
|
1170
|
+
optimistic,
|
|
1171
|
+
outcome
|
|
1172
|
+
};
|
|
1173
|
+
case "invalidate": return {
|
|
1174
|
+
state: invalidateConnection(advance(state, event.cursor), event.connection),
|
|
1175
|
+
optimistic,
|
|
1176
|
+
outcome
|
|
1177
|
+
};
|
|
1178
|
+
case "ignore": return {
|
|
1179
|
+
state: advance(state, event.cursor),
|
|
1180
|
+
optimistic,
|
|
1181
|
+
outcome
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
case "ConnectionRemove": return {
|
|
1185
|
+
state: advance(state, event.cursor),
|
|
1186
|
+
optimistic: addOverlay(removeEdgeOverlays(optimistic, event.connection, event.edge.key), {
|
|
1187
|
+
id: `live:${event.cursor}`,
|
|
1188
|
+
connection: event.connection,
|
|
1189
|
+
edges: [event.edge],
|
|
1190
|
+
position: "remove"
|
|
1191
|
+
}),
|
|
1192
|
+
outcome
|
|
1193
|
+
};
|
|
1194
|
+
case "ConnectionInvalidate": return {
|
|
1195
|
+
state: invalidateConnection(advance(state, event.cursor), event.connection),
|
|
1196
|
+
optimistic,
|
|
1197
|
+
outcome
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
const isStale = (state, connection) => state.stale.has(connection);
|
|
1202
|
+
/**
|
|
1203
|
+
* Clears a connection's stale mark once a fresh page has been adopted. An
|
|
1204
|
+
* invalidating event only records that a refetch is due; whoever merges the
|
|
1205
|
+
* refetched page calls this so `isStale` stops reporting.
|
|
1206
|
+
*/
|
|
1207
|
+
const refreshConnection = (state, connection) => {
|
|
1208
|
+
if (!state.stale.has(connection)) return state;
|
|
1209
|
+
const stale = new Set(state.stale);
|
|
1210
|
+
stale.delete(connection);
|
|
1211
|
+
return {
|
|
1212
|
+
...state,
|
|
1213
|
+
stale
|
|
1214
|
+
};
|
|
1215
|
+
};
|
|
1216
|
+
/** `hasPrevious` accounting for edges recorded outside the loaded boundary. */
|
|
1217
|
+
const liveHasPrevious = (connection, state, connectionId) => hasPrevious(connection) || (state.boundary[connectionId]?.before ?? 0) > 0;
|
|
1218
|
+
/** `hasNext` accounting for edges recorded outside the loaded boundary. */
|
|
1219
|
+
const liveHasNext = (connection, state, connectionId) => hasNext(connection) || (state.boundary[connectionId]?.after ?? 0) > 0;
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/relation.ts
|
|
1222
|
+
/** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
|
|
1223
|
+
const RelationAnnotation = "foldkitRemoteRelation";
|
|
1224
|
+
/** The target entity's name on the same codec, so a nested selection of another entity is refused. */
|
|
1225
|
+
const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
|
|
1226
|
+
/** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
|
|
1227
|
+
const refParts = (encoded) => {
|
|
1228
|
+
const separator = encoded.indexOf(":");
|
|
1229
|
+
return separator === -1 ? {
|
|
1230
|
+
entity: encoded,
|
|
1231
|
+
id: ""
|
|
1232
|
+
} : {
|
|
1233
|
+
entity: encoded.slice(0, separator),
|
|
1234
|
+
id: encoded.slice(separator + 1)
|
|
1235
|
+
};
|
|
1236
|
+
};
|
|
1237
|
+
/**
|
|
1238
|
+
* The refs a relation value carries, in order. Anything that is not a ref key,
|
|
1239
|
+
* an array of them, or a page of them contributes nothing.
|
|
1240
|
+
*/
|
|
1241
|
+
const refsIn = (value) => {
|
|
1242
|
+
if (typeof value === "string") return [refParts(value)];
|
|
1243
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
|
|
1244
|
+
if (value !== null && typeof value === "object") {
|
|
1245
|
+
const refs = value.refs;
|
|
1246
|
+
if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
|
|
1247
|
+
}
|
|
1248
|
+
return [];
|
|
1249
|
+
};
|
|
1250
|
+
/** The refs in a relation value that name the relation's target entity, and no other. */
|
|
1251
|
+
const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
|
|
1252
|
+
const isRefPage = (value) => {
|
|
1253
|
+
if (value === null || typeof value !== "object") return false;
|
|
1254
|
+
const page = value;
|
|
1255
|
+
return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
|
|
1256
|
+
};
|
|
1257
|
+
/**
|
|
1258
|
+
* The relation shape an entity field schema declares: a ref (`one`), a nullable
|
|
1259
|
+
* ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
|
|
1260
|
+
* scalar field, which cannot take a nested selection.
|
|
1261
|
+
*/
|
|
1262
|
+
const relationShape = (schema) => shapeOf(schema.ast, false);
|
|
1263
|
+
const shapeOf = (ast, nullable) => {
|
|
1264
|
+
const annotated = ast.annotations?.[RelationAnnotation];
|
|
1265
|
+
if (annotated === "one" || annotated === "page") return {
|
|
1266
|
+
kind: annotated,
|
|
1267
|
+
nullable,
|
|
1268
|
+
entity: String(ast.annotations?.[RelationEntityAnnotation])
|
|
1269
|
+
};
|
|
1270
|
+
if (ast._tag === "Arrays") {
|
|
1271
|
+
const item = ast.rest?.[0];
|
|
1272
|
+
const inner = item === void 0 ? void 0 : shapeOf(item, false);
|
|
1273
|
+
return inner?.kind === "one" ? {
|
|
1274
|
+
kind: "many",
|
|
1275
|
+
nullable,
|
|
1276
|
+
entity: inner.entity
|
|
1277
|
+
} : void 0;
|
|
1278
|
+
}
|
|
1279
|
+
if (ast._tag === "Union") {
|
|
1280
|
+
const members = ast.types ?? [];
|
|
1281
|
+
const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
|
|
1282
|
+
for (const member of members) {
|
|
1283
|
+
const shape = shapeOf(member, optional);
|
|
1284
|
+
if (shape !== void 0) return shape;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/plan.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* The pure requirement planner.
|
|
1292
|
+
*
|
|
1293
|
+
* A `Requirement` is plain data: which entity fields a Remote projection needs,
|
|
1294
|
+
* and through `relations`, which fields of each relation's target. `plan` diffs
|
|
1295
|
+
* requirements against the store and returns only the missing fields, grouped
|
|
1296
|
+
* and deterministically ordered. A relation whose field is being fetched keeps
|
|
1297
|
+
* its nested requirement on the request, so the server resolves the graph in
|
|
1298
|
+
* one read; a relation whose refs the store already holds is followed into
|
|
1299
|
+
* concrete requirements for the targets. Time enters through `PlanFreshness`,
|
|
1300
|
+
* never from ambient state; `force` plans every field.
|
|
1301
|
+
*/
|
|
1302
|
+
/** A stable key for a window, so two equal windows compare equal. */
|
|
1303
|
+
const windowKey = (window) => stableStringify(window);
|
|
1304
|
+
const pickFields = (record, fields) => {
|
|
1305
|
+
if (record === void 0) return void 0;
|
|
1306
|
+
const picked = Object.fromEntries(Object.entries(record).filter(([field]) => fields.has(field)));
|
|
1307
|
+
return Object.keys(picked).length === 0 ? void 0 : picked;
|
|
1308
|
+
};
|
|
1309
|
+
/** The concrete requirements a known relation value contributes for its targets. */
|
|
1310
|
+
const followRelation = (value, relation) => targetsOf(value, relation).map((ref) => ({
|
|
1311
|
+
...relation,
|
|
1312
|
+
id: ref.id
|
|
1313
|
+
}));
|
|
1314
|
+
const plan = (store, requirements, options = {}) => {
|
|
1315
|
+
const { freshness, force = false } = options;
|
|
1316
|
+
const planned = [];
|
|
1317
|
+
const followed = [];
|
|
1318
|
+
for (const group of Requirement.merge(requirements)) {
|
|
1319
|
+
const key = entityKey(group.entity, group.id);
|
|
1320
|
+
const entry = store[key];
|
|
1321
|
+
const expired = freshness !== void 0 && entry !== void 0 && !entry.tombstone && freshness.now - entry.updatedAt > freshness.freshness;
|
|
1322
|
+
const windowKeys = Object.fromEntries(Object.entries(group.windows ?? {}).map(([field, window]) => [field, windowKey(window)]));
|
|
1323
|
+
const missing = force || expired ? group.fields : missingFields(store, key, group.fields, windowKeys);
|
|
1324
|
+
const missingSet = new Set(missing);
|
|
1325
|
+
for (const [field, relation] of Object.entries(group.relations ?? {})) {
|
|
1326
|
+
if (missingSet.has(field)) continue;
|
|
1327
|
+
const value = readField(store, key, field);
|
|
1328
|
+
if (value._tag === "Some") followed.push(...followRelation(value.value, relation));
|
|
1329
|
+
}
|
|
1330
|
+
if (missing.length === 0) continue;
|
|
1331
|
+
const windows = pickFields(group.windows, missingSet);
|
|
1332
|
+
const relations = pickFields(group.relations, missingSet);
|
|
1333
|
+
planned.push({
|
|
1334
|
+
entity: group.entity,
|
|
1335
|
+
id: group.id,
|
|
1336
|
+
fields: missing,
|
|
1337
|
+
...windows === void 0 ? {} : { windows },
|
|
1338
|
+
...relations === void 0 ? {} : { relations }
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
const nested = followed.length === 0 ? [] : plan(store, followed, options);
|
|
1342
|
+
return [...Requirement.merge([...planned, ...nested])].sort((a, b) => entityKey(a.entity, a.id) < entityKey(b.entity, b.id) ? -1 : 1);
|
|
1343
|
+
};
|
|
1344
|
+
//#endregion
|
|
1345
|
+
//#region src/retain.ts
|
|
1346
|
+
/**
|
|
1347
|
+
* The entity keys the roots reach: each root entity, the targets its retained
|
|
1348
|
+
* fields refer to, and recursively the targets a nested relation selects.
|
|
1349
|
+
*/
|
|
1350
|
+
const reachable = (store, roots, connections, optimistic, pending) => {
|
|
1351
|
+
const kept = /* @__PURE__ */ new Set();
|
|
1352
|
+
const rootConnections = new Set(roots.connections.map((root) => root.identity));
|
|
1353
|
+
const walked = /* @__PURE__ */ new Map();
|
|
1354
|
+
const visit = (key, requirement) => {
|
|
1355
|
+
kept.add(key);
|
|
1356
|
+
const seen = walked.get(key) ?? /* @__PURE__ */ new Set();
|
|
1357
|
+
if (seen.has(requirement)) return;
|
|
1358
|
+
seen.add(requirement);
|
|
1359
|
+
walked.set(key, seen);
|
|
1360
|
+
for (const field of requirement.fields) {
|
|
1361
|
+
const value = readField(store, key, field);
|
|
1362
|
+
if (value._tag === "None") continue;
|
|
1363
|
+
const relation = requirement.relations?.[field];
|
|
1364
|
+
for (const ref of refsIn(value.value)) {
|
|
1365
|
+
const target = entityKey(ref.entity, ref.id);
|
|
1366
|
+
if (relation === void 0 || ref.entity !== relation.entity) {
|
|
1367
|
+
kept.add(target);
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
visit(target, relation);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
for (const root of roots.requirements) visit(entityKey(root.entity, root.id), root);
|
|
1375
|
+
for (const { identity, select } of roots.connections) for (const segment of connections[identity]?.segments ?? []) for (const edge of segment.edges) {
|
|
1376
|
+
const target = entityKey(edge.ref.entity, edge.ref.id);
|
|
1377
|
+
if (select === void 0 || edge.ref.entity !== select.entity) kept.add(target);
|
|
1378
|
+
else visit(target, select);
|
|
1379
|
+
}
|
|
1380
|
+
for (const layer of optimistic.layers) {
|
|
1381
|
+
if (!pending.has(layer.id)) continue;
|
|
1382
|
+
for (const patch of layer.patches) kept.add(entityKey(patch.entity, patch.id));
|
|
1383
|
+
}
|
|
1384
|
+
for (const overlay of optimistic.overlays) {
|
|
1385
|
+
if (!pending.has(overlay.id) && !rootConnections.has(overlay.connection)) continue;
|
|
1386
|
+
for (const edge of overlay.edges) kept.add(entityKey(edge.ref.entity, edge.ref.id));
|
|
1387
|
+
}
|
|
1388
|
+
return kept;
|
|
1389
|
+
};
|
|
1390
|
+
/**
|
|
1391
|
+
* Drops every entity the roots do not reach, every connection they do not
|
|
1392
|
+
* name, and every settled overlay on a dropped connection, keeping whatever a
|
|
1393
|
+
* pending request's layer or overlays touch. Pure.
|
|
1394
|
+
*/
|
|
1395
|
+
const gc = (state, roots) => {
|
|
1396
|
+
const pending = state.mutations.pending;
|
|
1397
|
+
const kept = reachable(state.entities, roots, state.connections, state.optimistic, pending);
|
|
1398
|
+
const entities = Object.fromEntries(Object.entries(state.entities).filter(([key]) => kept.has(key)));
|
|
1399
|
+
const keptConnections = /* @__PURE__ */ new Set([...roots.connections.map((root) => root.identity), ...state.optimistic.overlays.filter((overlay) => pending.has(overlay.id)).map((overlay) => overlay.connection)]);
|
|
1400
|
+
return {
|
|
1401
|
+
entities,
|
|
1402
|
+
connections: Object.fromEntries(Object.entries(state.connections).filter(([identity]) => keptConnections.has(identity))),
|
|
1403
|
+
optimistic: {
|
|
1404
|
+
layers: state.optimistic.layers,
|
|
1405
|
+
overlays: state.optimistic.overlays.filter((overlay) => pending.has(overlay.id) || keptConnections.has(overlay.connection))
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
};
|
|
1409
|
+
//#endregion
|
|
1410
|
+
//#region src/persistence.ts
|
|
1411
|
+
/**
|
|
1412
|
+
* Remote cache persistence: a snapshot of the entity store and nothing else.
|
|
1413
|
+
* Runtime state (connections' live cursors, optimistic layers, the mutation
|
|
1414
|
+
* ledger, gaps, retention roots) is never in a snapshot; it belongs to the
|
|
1415
|
+
* session that produced it.
|
|
1416
|
+
*
|
|
1417
|
+
* The cache is server-derived and disposable: an incompatible, oversized, or
|
|
1418
|
+
* corrupt snapshot is discarded (and removed from a `KeyValueStore`) so the
|
|
1419
|
+
* planner refetches. `dehydrate`/`hydrate` are the string forms, for SSR and
|
|
1420
|
+
* any other transport; `save`/`restore` put them behind Effect's
|
|
1421
|
+
* `KeyValueStore`, so the backend (memory, filesystem, SQL, Web Storage) is the
|
|
1422
|
+
* application's choice.
|
|
1423
|
+
*/
|
|
1424
|
+
/** Bump when the serialized shape changes; a mismatch discards the cache. */
|
|
1425
|
+
const REMOTE_CACHE_VERSION = 3;
|
|
1426
|
+
const sorted = (values) => [...values].sort();
|
|
1427
|
+
/**
|
|
1428
|
+
* The snapshot's data shape. `stableStringify` sorts object keys, and the
|
|
1429
|
+
* field sets are sorted here, so equal stores give byte-equal snapshots
|
|
1430
|
+
* whatever order they were built in.
|
|
1431
|
+
*/
|
|
1432
|
+
const serializeStore = (store, scope) => ({
|
|
1433
|
+
version: 3,
|
|
1434
|
+
scope: scope ?? null,
|
|
1435
|
+
entities: Object.fromEntries(Object.entries(store).map(([key, entry]) => [key, {
|
|
1436
|
+
values: entry.values,
|
|
1437
|
+
present: sorted(entry.present),
|
|
1438
|
+
stale: sorted(entry.stale),
|
|
1439
|
+
tombstone: entry.tombstone,
|
|
1440
|
+
updatedAt: entry.updatedAt,
|
|
1441
|
+
windows: entry.windows
|
|
1442
|
+
}]))
|
|
1443
|
+
});
|
|
1444
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1445
|
+
const isStringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
1446
|
+
/** Throws on a malformed entry so the whole snapshot is discarded. */
|
|
1447
|
+
const parseEntry = (value) => {
|
|
1448
|
+
if (value === null || typeof value !== "object") throw new Error("entry is not an object");
|
|
1449
|
+
const entry = value;
|
|
1450
|
+
if (entry.values === null || typeof entry.values !== "object" || Array.isArray(entry.values)) throw new Error("entry.values is not a record");
|
|
1451
|
+
if (!isStringArray(entry.present)) throw new Error("entry.present is not a string array");
|
|
1452
|
+
if (!isStringArray(entry.stale)) throw new Error("entry.stale is not a string array");
|
|
1453
|
+
if (typeof entry.tombstone !== "boolean") throw new Error("entry.tombstone is not a boolean");
|
|
1454
|
+
if (typeof entry.updatedAt !== "number") throw new Error("entry.updatedAt is not a number");
|
|
1455
|
+
if (!isStringRecord(entry.windows)) throw new Error("entry.windows is not a string record");
|
|
1456
|
+
return {
|
|
1457
|
+
values: entry.values,
|
|
1458
|
+
present: new Set(entry.present),
|
|
1459
|
+
stale: new Set(entry.stale),
|
|
1460
|
+
tombstone: entry.tombstone,
|
|
1461
|
+
updatedAt: entry.updatedAt,
|
|
1462
|
+
windows: entry.windows
|
|
1463
|
+
};
|
|
1464
|
+
};
|
|
1465
|
+
const deserializeStore = (serialized) => Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)]));
|
|
1466
|
+
/**
|
|
1467
|
+
* The snapshot text, or `undefined` when it would exceed `maxBytes`. Only the
|
|
1468
|
+
* entity store goes in; pass `model.entities`, never the whole `RemoteModel`.
|
|
1469
|
+
*/
|
|
1470
|
+
const dehydrate = (store, options = {}) => {
|
|
1471
|
+
const text = stableStringify(serializeStore(store, options.scope));
|
|
1472
|
+
return exceeds(text, options.maxBytes) ? void 0 : text;
|
|
1473
|
+
};
|
|
1474
|
+
/**
|
|
1475
|
+
* Whether the UTF-8 size of `text` exceeds `maxBytes`. A character is one to
|
|
1476
|
+
* three bytes, so most texts are decided from their length alone; only the
|
|
1477
|
+
* rest are measured.
|
|
1478
|
+
*/
|
|
1479
|
+
const exceeds = (text, maxBytes) => {
|
|
1480
|
+
if (maxBytes === void 0) return false;
|
|
1481
|
+
if (text.length > maxBytes) return true;
|
|
1482
|
+
if (text.length * 3 <= maxBytes) return false;
|
|
1483
|
+
return new TextEncoder().encode(text).length > maxBytes;
|
|
1484
|
+
};
|
|
1485
|
+
/**
|
|
1486
|
+
* The store a snapshot text holds, or `undefined` when the text is missing,
|
|
1487
|
+
* oversized, not this version, for another scope, or malformed. Hydrating the
|
|
1488
|
+
* same text twice yields equal stores.
|
|
1489
|
+
*/
|
|
1490
|
+
const hydrate = (raw, options = {}) => {
|
|
1491
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
1492
|
+
if (exceeds(raw, options.maxBytes)) return void 0;
|
|
1493
|
+
let parsed;
|
|
1494
|
+
try {
|
|
1495
|
+
parsed = JSON.parse(raw);
|
|
1496
|
+
} catch {
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
1500
|
+
const candidate = parsed;
|
|
1501
|
+
if (candidate.version !== 3) return void 0;
|
|
1502
|
+
if ((candidate.scope ?? null) !== (options.scope ?? null)) return void 0;
|
|
1503
|
+
if (candidate.entities === null || typeof candidate.entities !== "object") return void 0;
|
|
1504
|
+
if (Array.isArray(candidate.entities)) return void 0;
|
|
1505
|
+
try {
|
|
1506
|
+
return deserializeStore(candidate);
|
|
1507
|
+
} catch {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
/**
|
|
1512
|
+
* Brings a snapshot into a store that may already hold entries. `replace`
|
|
1513
|
+
* takes the snapshot's entry for every key it has; `preserve-existing` keeps
|
|
1514
|
+
* an entry the current store already has (it is at least as fresh as a
|
|
1515
|
+
* snapshot taken earlier). Either way, keys only the current store has stay.
|
|
1516
|
+
*/
|
|
1517
|
+
const mergeStores = (current, snapshot, policy) => policy === "replace" ? {
|
|
1518
|
+
...current,
|
|
1519
|
+
...snapshot
|
|
1520
|
+
} : {
|
|
1521
|
+
...snapshot,
|
|
1522
|
+
...current
|
|
1523
|
+
};
|
|
1524
|
+
const RemotePersistence = {
|
|
1525
|
+
/** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
|
|
1526
|
+
dehydrate,
|
|
1527
|
+
/** The store a snapshot text holds, or `undefined` when it is refused. */
|
|
1528
|
+
hydrate,
|
|
1529
|
+
/** A snapshot brought into a store by policy. */
|
|
1530
|
+
mergeStores,
|
|
1531
|
+
/**
|
|
1532
|
+
* Writes the snapshot under `key`, or removes the key when the store would
|
|
1533
|
+
* exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
|
|
1534
|
+
* store it no longer describes.
|
|
1535
|
+
*/
|
|
1536
|
+
save: (store, options) => Effect.gen(function* () {
|
|
1537
|
+
const kv = yield* KeyValueStore.KeyValueStore;
|
|
1538
|
+
const text = dehydrate(store, options);
|
|
1539
|
+
if (text === void 0) yield* kv.remove(options.key);
|
|
1540
|
+
else yield* kv.set(options.key, text);
|
|
1541
|
+
}),
|
|
1542
|
+
/**
|
|
1543
|
+
* Reads the cache. A missing key yields `emptyStore`; anything `hydrate`
|
|
1544
|
+
* refuses yields `emptyStore` and removes the key, so the next `restore` is
|
|
1545
|
+
* clean and the planner refetches.
|
|
1546
|
+
*/
|
|
1547
|
+
restore: (options) => Effect.gen(function* () {
|
|
1548
|
+
const kv = yield* KeyValueStore.KeyValueStore;
|
|
1549
|
+
const raw = yield* kv.get(options.key);
|
|
1550
|
+
if (raw === void 0) return emptyStore;
|
|
1551
|
+
const restored = hydrate(raw, options);
|
|
1552
|
+
if (restored === void 0) {
|
|
1553
|
+
yield* kv.remove(options.key);
|
|
1554
|
+
return emptyStore;
|
|
1555
|
+
}
|
|
1556
|
+
return restored;
|
|
1557
|
+
})
|
|
1558
|
+
};
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/remoteData.ts
|
|
1561
|
+
/** `RemoteData`: the state of a value the store may not hold yet. */
|
|
1562
|
+
const remoteErrorSchema = Schema.Struct({
|
|
1563
|
+
_tag: Schema.String,
|
|
1564
|
+
message: Schema.String
|
|
1565
|
+
});
|
|
1566
|
+
/** A `RemoteData` schema, so a projection that reads remote state is typed. */
|
|
1567
|
+
const remoteDataSchemas = /* @__PURE__ */ new WeakMap();
|
|
1568
|
+
const remoteDataSchema = (value) => {
|
|
1569
|
+
const cached = remoteDataSchemas.get(value);
|
|
1570
|
+
if (cached !== void 0) return cached;
|
|
1571
|
+
const built = Schema.Union([
|
|
1572
|
+
Schema.Struct({ _tag: Schema.Literal("Initial") }),
|
|
1573
|
+
Schema.Struct({ _tag: Schema.Literal("Loading") }),
|
|
1574
|
+
Schema.Struct({
|
|
1575
|
+
_tag: Schema.Literal("Ready"),
|
|
1576
|
+
value
|
|
1577
|
+
}),
|
|
1578
|
+
Schema.Struct({
|
|
1579
|
+
_tag: Schema.Literal("Refreshing"),
|
|
1580
|
+
value
|
|
1581
|
+
}),
|
|
1582
|
+
Schema.Struct({
|
|
1583
|
+
_tag: Schema.Literal("Failed"),
|
|
1584
|
+
error: remoteErrorSchema,
|
|
1585
|
+
previous: Schema.optional(value)
|
|
1586
|
+
}),
|
|
1587
|
+
Schema.Struct({ _tag: Schema.Literal("NotFound") })
|
|
1588
|
+
]);
|
|
1589
|
+
remoteDataSchemas.set(value, built);
|
|
1590
|
+
return built;
|
|
1591
|
+
};
|
|
1592
|
+
const RemoteData = {
|
|
1593
|
+
/**
|
|
1594
|
+
* The `RemoteData` schema for a value schema. Exposed so a `RemoteData` can
|
|
1595
|
+
* be embedded in a hand-written Model schema, not only through `Remote.select`.
|
|
1596
|
+
*/
|
|
1597
|
+
schema: (value) => remoteDataSchema(value),
|
|
1598
|
+
/** Exhaustive: omitting a state is a compile error. */
|
|
1599
|
+
match: (data, cases) => {
|
|
1600
|
+
switch (data._tag) {
|
|
1601
|
+
case "Initial": return cases.Initial();
|
|
1602
|
+
case "Loading": return cases.Loading();
|
|
1603
|
+
case "Ready": return cases.Ready(data.value);
|
|
1604
|
+
case "Refreshing": return cases.Refreshing(data.value);
|
|
1605
|
+
case "Failed": return cases.Failed(data.error, data.previous === void 0 ? Option.none() : Option.some(data.previous));
|
|
1606
|
+
case "NotFound": return cases.NotFound();
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
map: (data, f) => {
|
|
1610
|
+
switch (data._tag) {
|
|
1611
|
+
case "Ready": return {
|
|
1612
|
+
_tag: "Ready",
|
|
1613
|
+
value: f(data.value)
|
|
1614
|
+
};
|
|
1615
|
+
case "Refreshing": return {
|
|
1616
|
+
_tag: "Refreshing",
|
|
1617
|
+
value: f(data.value)
|
|
1618
|
+
};
|
|
1619
|
+
case "Failed": return data.previous === void 0 ? {
|
|
1620
|
+
_tag: "Failed",
|
|
1621
|
+
error: data.error
|
|
1622
|
+
} : {
|
|
1623
|
+
_tag: "Failed",
|
|
1624
|
+
error: data.error,
|
|
1625
|
+
previous: f(data.previous)
|
|
1626
|
+
};
|
|
1627
|
+
default: return data;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
//#endregion
|
|
1632
|
+
//#region src/model.ts
|
|
1633
|
+
/**
|
|
1634
|
+
* The Remote submodel: the store, connections, mutations, live state, and
|
|
1635
|
+
* optimistic layers an application keeps in its Model, the Messages that
|
|
1636
|
+
* change it, and the pure reducer over them.
|
|
1637
|
+
*/
|
|
1638
|
+
const initialRemoteModel = {
|
|
1639
|
+
entities: emptyStore,
|
|
1640
|
+
connections: {},
|
|
1641
|
+
optimistic: emptyOptimistic,
|
|
1642
|
+
live: {},
|
|
1643
|
+
mutations: emptyMutationState,
|
|
1644
|
+
gaps: /* @__PURE__ */ new Set()
|
|
1645
|
+
};
|
|
1646
|
+
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
1647
|
+
const runtimeSchema = Schema.Unknown;
|
|
1648
|
+
const remoteModelSchema = () => Schema.Struct({
|
|
1649
|
+
entities: runtimeSchema,
|
|
1650
|
+
connections: Schema.Record(Schema.String, runtimeSchema),
|
|
1651
|
+
optimistic: runtimeSchema,
|
|
1652
|
+
live: Schema.Record(Schema.String, runtimeSchema),
|
|
1653
|
+
mutations: runtimeSchema,
|
|
1654
|
+
gaps: runtimeSchema
|
|
1655
|
+
});
|
|
1656
|
+
const retentionRootsSchema = Schema.Struct({
|
|
1657
|
+
requirements: Schema.Array(ReadRequest),
|
|
1658
|
+
connections: Schema.Array(Schema.Struct({
|
|
1659
|
+
identity: Schema.String,
|
|
1660
|
+
select: Schema.optional(RelationRequest)
|
|
1661
|
+
}))
|
|
1662
|
+
});
|
|
1663
|
+
/**
|
|
1664
|
+
* The Messages' fields by tag, without `_tag`: what `defineMessageUnion` takes,
|
|
1665
|
+
* so an application spreads them into its own union (`Remote.messages`).
|
|
1666
|
+
*/
|
|
1667
|
+
const remoteMessageCases = {
|
|
1668
|
+
ReadReceived: {
|
|
1669
|
+
requests: Schema.Array(ReadRequest),
|
|
1670
|
+
result: ReadBatchResult,
|
|
1671
|
+
now: Schema.Number
|
|
1672
|
+
},
|
|
1673
|
+
ReadFailed: {
|
|
1674
|
+
requests: Schema.Array(ReadRequest),
|
|
1675
|
+
error: remoteErrorSchema
|
|
1676
|
+
},
|
|
1677
|
+
RefreshStarted: { requests: Schema.Array(ReadRequest) },
|
|
1678
|
+
RetentionChanged: { roots: retentionRootsSchema },
|
|
1679
|
+
Hydrated: {
|
|
1680
|
+
entities: runtimeSchema,
|
|
1681
|
+
merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
|
|
1682
|
+
},
|
|
1683
|
+
MutationStarted: {
|
|
1684
|
+
requestId: Schema.String,
|
|
1685
|
+
optimistic: Schema.optional(Schema.Array(Schema.Unknown))
|
|
1686
|
+
},
|
|
1687
|
+
MutationSucceeded: {
|
|
1688
|
+
requestId: Schema.String,
|
|
1689
|
+
entities: Schema.Array(NormalizedEntity),
|
|
1690
|
+
connections: Schema.optional(Schema.Array(Schema.Unknown))
|
|
1691
|
+
},
|
|
1692
|
+
MutationFailed: {
|
|
1693
|
+
requestId: Schema.String,
|
|
1694
|
+
error: remoteErrorSchema
|
|
1695
|
+
},
|
|
1696
|
+
LiveReceived: {
|
|
1697
|
+
stream: Schema.String,
|
|
1698
|
+
event: Schema.Unknown,
|
|
1699
|
+
policy: Schema.optional(Schema.Unknown),
|
|
1700
|
+
now: Schema.Number
|
|
1701
|
+
},
|
|
1702
|
+
GapCleared: { stream: Schema.String },
|
|
1703
|
+
ConnectionMerged: {
|
|
1704
|
+
connection: Schema.String,
|
|
1705
|
+
page: Schema.Unknown,
|
|
1706
|
+
refreshes: Schema.optional(Schema.Boolean)
|
|
1707
|
+
},
|
|
1708
|
+
ConnectionInvalidated: { connection: Schema.String },
|
|
1709
|
+
ConnectionRefreshed: { connection: Schema.String },
|
|
1710
|
+
QueryFailed: {
|
|
1711
|
+
connection: Schema.String,
|
|
1712
|
+
error: remoteErrorSchema
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
/**
|
|
1716
|
+
* Whether a Message is one of Remote's, by tag. Narrows an application's union
|
|
1717
|
+
* to its Remote cases, and its complement to the application's own.
|
|
1718
|
+
*/
|
|
1719
|
+
const isRemoteMessage = (message) => Object.hasOwn(remoteMessageCases, message._tag);
|
|
1720
|
+
const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(([tag, fields]) => Schema.Struct({
|
|
1721
|
+
_tag: Schema.Literal(tag),
|
|
1722
|
+
...fields
|
|
1723
|
+
})));
|
|
1724
|
+
const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
|
|
1725
|
+
const setConnectionStale = (connections, connection, stale) => {
|
|
1726
|
+
const current = connections[connection] ?? emptyConnection;
|
|
1727
|
+
return {
|
|
1728
|
+
...connections,
|
|
1729
|
+
[connection]: {
|
|
1730
|
+
...current,
|
|
1731
|
+
stale
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
};
|
|
1735
|
+
const markGap = (model, stream) => model.gaps.has(stream) ? model : {
|
|
1736
|
+
...model,
|
|
1737
|
+
gaps: /* @__PURE__ */ new Set([...model.gaps, stream])
|
|
1738
|
+
};
|
|
1739
|
+
const clearGap = (model, stream) => model.gaps.has(stream) ? {
|
|
1740
|
+
...model,
|
|
1741
|
+
gaps: new Set([...model.gaps].filter((entry) => entry !== stream))
|
|
1742
|
+
} : model;
|
|
1743
|
+
/**
|
|
1744
|
+
* The pure reducer all four producers share. A live event that arrives ahead of
|
|
1745
|
+
* its cursor is a gap: it is not applied, and the stream is recorded so the host
|
|
1746
|
+
* can resubscribe rather than silently miss facts.
|
|
1747
|
+
*/
|
|
1748
|
+
const updateRemote = (model, message) => {
|
|
1749
|
+
switch (message._tag) {
|
|
1750
|
+
case "ReadReceived": return {
|
|
1751
|
+
...model,
|
|
1752
|
+
entities: writeRead(model.entities, message.requests, message.result, message.now)
|
|
1753
|
+
};
|
|
1754
|
+
case "ReadFailed": return {
|
|
1755
|
+
...model,
|
|
1756
|
+
entities: setStale(model.entities, marksOf(message.requests), false)
|
|
1757
|
+
};
|
|
1758
|
+
case "RetentionChanged": return {
|
|
1759
|
+
...model,
|
|
1760
|
+
...gc(model, message.roots)
|
|
1761
|
+
};
|
|
1762
|
+
case "Hydrated": return {
|
|
1763
|
+
...model,
|
|
1764
|
+
entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
|
|
1765
|
+
};
|
|
1766
|
+
case "RefreshStarted": return {
|
|
1767
|
+
...model,
|
|
1768
|
+
entities: setStale(model.entities, marksOf(message.requests), true)
|
|
1769
|
+
};
|
|
1770
|
+
case "MutationStarted": return {
|
|
1771
|
+
...model,
|
|
1772
|
+
mutations: beginMutation(model.mutations, message.requestId),
|
|
1773
|
+
optimistic: beginOptimistic(model.optimistic, message.requestId, message.optimistic ?? [])
|
|
1774
|
+
};
|
|
1775
|
+
case "MutationSucceeded": {
|
|
1776
|
+
const settled = settleSuccess(model.entities, model.optimistic, model.mutations, message.requestId, message.entities, message.connections ?? []);
|
|
1777
|
+
return {
|
|
1778
|
+
...model,
|
|
1779
|
+
entities: settled.store,
|
|
1780
|
+
mutations: settled.state,
|
|
1781
|
+
optimistic: settled.optimistic
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
case "MutationFailed": return {
|
|
1785
|
+
...model,
|
|
1786
|
+
mutations: failMutation(model.mutations, message.requestId),
|
|
1787
|
+
optimistic: settleFailure(model.optimistic, message.requestId)
|
|
1788
|
+
};
|
|
1789
|
+
case "LiveReceived": {
|
|
1790
|
+
const state = model.live[message.stream] ?? emptyLiveState;
|
|
1791
|
+
if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
|
|
1792
|
+
const applied = applyEntityEvent(state, model.entities, message.event, message.now);
|
|
1793
|
+
return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
|
|
1794
|
+
...model,
|
|
1795
|
+
entities: applied.store,
|
|
1796
|
+
live: {
|
|
1797
|
+
...model.live,
|
|
1798
|
+
[message.stream]: applied.state
|
|
1799
|
+
}
|
|
1800
|
+
}, message.stream);
|
|
1801
|
+
}
|
|
1802
|
+
const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
|
|
1803
|
+
return applied.outcome === "gap" ? markGap(model, message.stream) : clearGap({
|
|
1804
|
+
...model,
|
|
1805
|
+
optimistic: applied.optimistic,
|
|
1806
|
+
live: {
|
|
1807
|
+
...model.live,
|
|
1808
|
+
[message.stream]: applied.state
|
|
1809
|
+
}
|
|
1810
|
+
}, message.stream);
|
|
1811
|
+
}
|
|
1812
|
+
case "GapCleared": return clearGap(model, message.stream);
|
|
1813
|
+
case "ConnectionMerged": {
|
|
1814
|
+
const current = model.connections[message.connection] ?? emptyConnection;
|
|
1815
|
+
const merged = merge(current, message.page);
|
|
1816
|
+
return {
|
|
1817
|
+
...model,
|
|
1818
|
+
connections: {
|
|
1819
|
+
...model.connections,
|
|
1820
|
+
[message.connection]: message.refreshes === true ? {
|
|
1821
|
+
...merged,
|
|
1822
|
+
stale: false
|
|
1823
|
+
} : merged
|
|
1824
|
+
},
|
|
1825
|
+
optimistic: pruneOverlays(model.optimistic, message.connection, new Set(message.page.edges.map((edge) => edge.key)), model.mutations.pending)
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
case "ConnectionInvalidated": return {
|
|
1829
|
+
...model,
|
|
1830
|
+
connections: setConnectionStale(model.connections, message.connection, true)
|
|
1831
|
+
};
|
|
1832
|
+
case "QueryFailed": return message.connection in model.connections ? {
|
|
1833
|
+
...model,
|
|
1834
|
+
connections: setConnectionStale(model.connections, message.connection, false)
|
|
1835
|
+
} : model;
|
|
1836
|
+
case "ConnectionRefreshed": return {
|
|
1837
|
+
...model,
|
|
1838
|
+
connections: setConnectionStale(model.connections, message.connection, false)
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
/** Appends (after) or prepends (before) an incoming page onto the stored one. */
|
|
1843
|
+
const mergeWireRefPages = (current, next, direction) => {
|
|
1844
|
+
const refs = direction === "after" ? [.../* @__PURE__ */ new Set([...current.refs, ...next.refs])] : [.../* @__PURE__ */ new Set([...next.refs, ...current.refs])];
|
|
1845
|
+
return direction === "after" ? {
|
|
1846
|
+
refs,
|
|
1847
|
+
hasNext: next.hasNext,
|
|
1848
|
+
hasPrevious: current.hasPrevious
|
|
1849
|
+
} : {
|
|
1850
|
+
refs,
|
|
1851
|
+
hasNext: current.hasNext,
|
|
1852
|
+
hasPrevious: next.hasPrevious
|
|
1853
|
+
};
|
|
1854
|
+
};
|
|
1855
|
+
/**
|
|
1856
|
+
* Writes a read result into the store, recording each field's applied window so
|
|
1857
|
+
* a later request with a different window refetches. A relation page requested
|
|
1858
|
+
* with a cursor is merged onto the page already stored, so "load more"
|
|
1859
|
+
* accumulates rather than replaces. Every read path should use this rather than
|
|
1860
|
+
* reducing `writeEntity` by hand.
|
|
1861
|
+
*/
|
|
1862
|
+
const writeRead = (store, requests, result, now = 0) => {
|
|
1863
|
+
const returned = /* @__PURE__ */ new Map();
|
|
1864
|
+
for (const entity of result.entities) {
|
|
1865
|
+
const key = entityKey(entity.entity, entity.id);
|
|
1866
|
+
returned.set(key, {
|
|
1867
|
+
...returned.get(key),
|
|
1868
|
+
...entity.values
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
const byEntity = /* @__PURE__ */ new Map();
|
|
1872
|
+
const record = (key, windows, relations) => {
|
|
1873
|
+
let entry = byEntity.get(key);
|
|
1874
|
+
if (entry === void 0) {
|
|
1875
|
+
entry = {
|
|
1876
|
+
windows: {},
|
|
1877
|
+
merge: /* @__PURE__ */ new Map()
|
|
1878
|
+
};
|
|
1879
|
+
byEntity.set(key, entry);
|
|
1880
|
+
}
|
|
1881
|
+
for (const [field, window] of Object.entries(windows ?? {})) {
|
|
1882
|
+
entry.windows[field] = windowKey(window);
|
|
1883
|
+
const direction = window.after !== void 0 ? "after" : window.before !== void 0 ? "before" : void 0;
|
|
1884
|
+
if (direction !== void 0) entry.merge.set(field, direction);
|
|
1885
|
+
}
|
|
1886
|
+
const values = returned.get(key);
|
|
1887
|
+
if (values === void 0) return;
|
|
1888
|
+
for (const [field, relation] of Object.entries(relations ?? {})) for (const ref of targetsOf(values[field], relation)) record(entityKey(ref.entity, ref.id), relation.windows, relation.relations);
|
|
1889
|
+
};
|
|
1890
|
+
for (const request of requests) record(entityKey(request.entity, request.id), request.windows, request.relations);
|
|
1891
|
+
const writes = [];
|
|
1892
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1893
|
+
for (const entity of result.entities) {
|
|
1894
|
+
const key = entityKey(entity.entity, entity.id);
|
|
1895
|
+
const entry = byEntity.get(key);
|
|
1896
|
+
let values = entity.values;
|
|
1897
|
+
if (entry !== void 0 && entry.merge.size > 0) {
|
|
1898
|
+
const previous = pending.get(key) ?? store[key]?.values;
|
|
1899
|
+
if (previous !== void 0) {
|
|
1900
|
+
const merged = { ...values };
|
|
1901
|
+
for (const [field, direction] of entry.merge) {
|
|
1902
|
+
const incoming = values[field];
|
|
1903
|
+
const existing = previous[field];
|
|
1904
|
+
if (isRefPage(incoming) && isRefPage(existing)) merged[field] = mergeWireRefPages(existing, incoming, direction);
|
|
1905
|
+
}
|
|
1906
|
+
values = merged;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
pending.set(key, {
|
|
1910
|
+
...pending.get(key),
|
|
1911
|
+
...values
|
|
1912
|
+
});
|
|
1913
|
+
writes.push({
|
|
1914
|
+
key,
|
|
1915
|
+
values,
|
|
1916
|
+
windows: entry?.windows
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
return writeEntities(store, writes, now);
|
|
1920
|
+
};
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/policy.ts
|
|
1923
|
+
const RemotePolicy = {
|
|
1924
|
+
/** Fetch only what the store lacks. */
|
|
1925
|
+
cacheFirst: { _tag: "CacheFirst" },
|
|
1926
|
+
/** Keep present values visible, read as `Refreshing`, and refetch an entry older than `maxAge` ms. */
|
|
1927
|
+
staleWhileRevalidate: (options) => ({
|
|
1928
|
+
_tag: "StaleWhileRevalidate",
|
|
1929
|
+
maxAge: options.maxAge
|
|
1930
|
+
}),
|
|
1931
|
+
/** Fetch every selected field regardless of coverage; cached values stay visible meanwhile. */
|
|
1932
|
+
networkOnly: { _tag: "NetworkOnly" },
|
|
1933
|
+
/** The planner options a policy compiles to at `now`. */
|
|
1934
|
+
toPlan: (policy, now) => {
|
|
1935
|
+
switch (policy._tag) {
|
|
1936
|
+
case "CacheFirst": return {};
|
|
1937
|
+
case "StaleWhileRevalidate": return { freshness: {
|
|
1938
|
+
now,
|
|
1939
|
+
freshness: policy.maxAge
|
|
1940
|
+
} };
|
|
1941
|
+
case "NetworkOnly": return { force: true };
|
|
1942
|
+
}
|
|
1943
|
+
},
|
|
1944
|
+
/**
|
|
1945
|
+
* Whether a read under this policy may refetch fields the store already
|
|
1946
|
+
* holds, so an observer marks them stale (read as `Refreshing`) while the
|
|
1947
|
+
* request is pending.
|
|
1948
|
+
*/
|
|
1949
|
+
refreshes: (policy) => policy._tag !== "CacheFirst"
|
|
1950
|
+
};
|
|
1951
|
+
//#endregion
|
|
1952
|
+
//#region src/entity.ts
|
|
1953
|
+
/**
|
|
1954
|
+
* Entities: the descriptor an application declares, refs, ref pages, and
|
|
1955
|
+
* patches, with the codecs that carry refs on the wire.
|
|
1956
|
+
*/
|
|
1957
|
+
/**
|
|
1958
|
+
* Relations are **references**, never inline target schemas: a ref cannot
|
|
1959
|
+
* reconstruct a full entity, and dereferencing is a store concern. Because the
|
|
1960
|
+
* target schema is not inlined, recursive relations cannot arise through the
|
|
1961
|
+
* schema graph.
|
|
1962
|
+
*/
|
|
1963
|
+
const refCodec = (entity) => Schema.Struct({
|
|
1964
|
+
entity: Schema.String,
|
|
1965
|
+
id: Schema.String
|
|
1966
|
+
}).pipe(Schema.encodeTo(Schema.String, {
|
|
1967
|
+
decode: SchemaGetter.transform(refParts),
|
|
1968
|
+
encode: SchemaGetter.transform((ref) => entityKey(ref.entity, ref.id))
|
|
1969
|
+
})).annotate({
|
|
1970
|
+
[RelationAnnotation]: "one",
|
|
1971
|
+
[RelationEntityAnnotation]: entity
|
|
1972
|
+
});
|
|
1973
|
+
const refPageCodec = (entity) => Schema.Struct({
|
|
1974
|
+
refs: Schema.Array(refCodec(entity)),
|
|
1975
|
+
hasNext: Schema.Boolean,
|
|
1976
|
+
hasPrevious: Schema.Boolean
|
|
1977
|
+
}).annotate({
|
|
1978
|
+
[RelationAnnotation]: "page",
|
|
1979
|
+
[RelationEntityAnnotation]: entity
|
|
1980
|
+
});
|
|
1981
|
+
const Entity = {
|
|
1982
|
+
make: (name, schema) => {
|
|
1983
|
+
const descriptor = {
|
|
1984
|
+
name,
|
|
1985
|
+
schema,
|
|
1986
|
+
fields: schema.fields,
|
|
1987
|
+
ref: (id) => ({
|
|
1988
|
+
entity: name,
|
|
1989
|
+
id: String(id)
|
|
1990
|
+
}),
|
|
1991
|
+
select: (selection) => Selection.make(descriptor, selection),
|
|
1992
|
+
patch: (id, values) => ({
|
|
1993
|
+
entity: name,
|
|
1994
|
+
id: String(id),
|
|
1995
|
+
values
|
|
1996
|
+
})
|
|
1997
|
+
};
|
|
1998
|
+
return descriptor;
|
|
1999
|
+
},
|
|
2000
|
+
/** A relation to a known entity, decoded as a reference. */
|
|
2001
|
+
ref: (entity) => refCodec(entity.name),
|
|
2002
|
+
/** A relation by name, for recursive or forward references. */
|
|
2003
|
+
refTo: (name) => refCodec(name),
|
|
2004
|
+
/**
|
|
2005
|
+
* The wire key a relation field carries (`"Entity:id"`). Adapters that read a
|
|
2006
|
+
* foreign key emit this so the ref codec can decode it on the client.
|
|
2007
|
+
*/
|
|
2008
|
+
refKey: (ref) => entityKey(ref.entity, ref.id),
|
|
2009
|
+
/** Splits a ref key back into its entity and id. */
|
|
2010
|
+
refParts,
|
|
2011
|
+
/** A relation value of one authoritative page of refs. */
|
|
2012
|
+
refPage: (entity) => refPageCodec(entity.name),
|
|
2013
|
+
/**
|
|
2014
|
+
* A normalized patch for an entity, typed against its fields' wire shape:
|
|
2015
|
+
* write a relation as its ref key (`Entity.refKey`), as the server does.
|
|
2016
|
+
*/
|
|
2017
|
+
patch: (ref, patch) => ({
|
|
2018
|
+
entity: ref.entity,
|
|
2019
|
+
id: ref.id,
|
|
2020
|
+
values: patch
|
|
2021
|
+
})
|
|
2022
|
+
};
|
|
2023
|
+
//#endregion
|
|
2024
|
+
//#region src/selection.ts
|
|
2025
|
+
/**
|
|
2026
|
+
* Selections: the fields a Surface picks of an entity (nested through
|
|
2027
|
+
* relations, paged through connections), the requirement graph they imply,
|
|
2028
|
+
* and their assembly from the normalized store.
|
|
2029
|
+
*/
|
|
2030
|
+
const pageSchemas = /* @__PURE__ */ new WeakMap();
|
|
2031
|
+
const pageSchema = (item) => {
|
|
2032
|
+
const cached = pageSchemas.get(item);
|
|
2033
|
+
if (cached !== void 0) return cached;
|
|
2034
|
+
const built = Schema.Struct({
|
|
2035
|
+
items: Schema.Array(item),
|
|
2036
|
+
hasNext: Schema.Boolean,
|
|
2037
|
+
hasPrevious: Schema.Boolean
|
|
2038
|
+
});
|
|
2039
|
+
pageSchemas.set(item, built);
|
|
2040
|
+
return built;
|
|
2041
|
+
};
|
|
2042
|
+
/** The requirement a nested selection contributes for its relation's target. */
|
|
2043
|
+
const relationOf = (selection) => ({
|
|
2044
|
+
entity: selection.entity,
|
|
2045
|
+
fields: selection.fields,
|
|
2046
|
+
...selection.connections === void 0 ? {} : { windows: selection.connections },
|
|
2047
|
+
...selection.relations === void 0 ? {} : { relations: selection.relations }
|
|
2048
|
+
});
|
|
2049
|
+
const Selection = {
|
|
2050
|
+
/**
|
|
2051
|
+
* The fields to read of `entity`. A nested `Selection` on a relation field
|
|
2052
|
+
* reads through the ref (or refs, or page of refs) the field holds into the
|
|
2053
|
+
* target's fields; a scalar field cannot take one.
|
|
2054
|
+
*/
|
|
2055
|
+
make: (entity, selection) => {
|
|
2056
|
+
if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
|
|
2057
|
+
const picked = {};
|
|
2058
|
+
const connections = {};
|
|
2059
|
+
const relations = {};
|
|
2060
|
+
for (const key of Object.keys(selection)) {
|
|
2061
|
+
const choice = selection[key];
|
|
2062
|
+
if (choice === true) {
|
|
2063
|
+
picked[key] = entity.fields[key];
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
const nested = choice;
|
|
2067
|
+
const shape = relationShape(entity.fields[key]);
|
|
2068
|
+
if (shape === void 0) throw new Error(`Selection.make: "${key}" on "${entity.name}" is not a relation field, so it cannot take a nested selection`);
|
|
2069
|
+
if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
|
|
2070
|
+
const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
|
|
2071
|
+
if (nested.window !== void 0) {
|
|
2072
|
+
connections[key] = nested.window;
|
|
2073
|
+
picked[key] = nullable(nested.schema);
|
|
2074
|
+
if (nested.fields.length > 0) relations[key] = relationOf(nested);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
const item = nested.schema;
|
|
2078
|
+
picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
|
|
2079
|
+
relations[key] = relationOf(nested);
|
|
2080
|
+
}
|
|
2081
|
+
return {
|
|
2082
|
+
entity: entity.name,
|
|
2083
|
+
fields: Object.keys(selection),
|
|
2084
|
+
schema: Schema.Struct(picked),
|
|
2085
|
+
...Object.keys(connections).length === 0 ? {} : { connections },
|
|
2086
|
+
...Object.keys(relations).length === 0 ? {} : { relations }
|
|
2087
|
+
};
|
|
2088
|
+
},
|
|
2089
|
+
/**
|
|
2090
|
+
* A paginated relation: one page of refs to `entity` with a window, or, with
|
|
2091
|
+
* a nested selection, one page of targets assembled through it.
|
|
2092
|
+
*/
|
|
2093
|
+
connection: ((entity, window, selection) => {
|
|
2094
|
+
if (selection !== void 0 && selection.entity !== entity.name) throw new Error(`Selection.connection: a page of "${entity.name}" cannot select "${selection.entity}"`);
|
|
2095
|
+
return {
|
|
2096
|
+
entity: entity.name,
|
|
2097
|
+
fields: selection?.fields ?? [],
|
|
2098
|
+
schema: selection === void 0 ? Entity.refPage(entity) : pageSchema(selection.schema),
|
|
2099
|
+
window,
|
|
2100
|
+
...selection?.connections === void 0 ? {} : { connections: selection.connections },
|
|
2101
|
+
...selection?.relations === void 0 ? {} : { relations: selection.relations }
|
|
2102
|
+
};
|
|
2103
|
+
})
|
|
2104
|
+
};
|
|
2105
|
+
/**
|
|
2106
|
+
* Reads an entity's selected fields out of the store, following each nested
|
|
2107
|
+
* relation into its targets. `undefined` means some field, at any depth, is
|
|
2108
|
+
* not present yet. A tombstoned target reads as `null` (or is dropped from a
|
|
2109
|
+
* list), so the Selection's codec decides whether that is a failure.
|
|
2110
|
+
*/
|
|
2111
|
+
const assemble = (store, key, requirement) => {
|
|
2112
|
+
const values = {};
|
|
2113
|
+
let refreshing = false;
|
|
2114
|
+
for (const field of requirement.fields) {
|
|
2115
|
+
const value = readField(store, key, field);
|
|
2116
|
+
if (Option.isNone(value)) return void 0;
|
|
2117
|
+
refreshing ||= isFieldStale(store, key, field);
|
|
2118
|
+
const relation = requirement.relations?.[field];
|
|
2119
|
+
if (relation === void 0) {
|
|
2120
|
+
values[field] = value.value;
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
const nested = assembleRelation(store, value.value, relation);
|
|
2124
|
+
if (nested === void 0) return void 0;
|
|
2125
|
+
values[field] = nested.values;
|
|
2126
|
+
refreshing ||= nested.refreshing;
|
|
2127
|
+
}
|
|
2128
|
+
return {
|
|
2129
|
+
values,
|
|
2130
|
+
refreshing
|
|
2131
|
+
};
|
|
2132
|
+
};
|
|
2133
|
+
const assembleTarget = (store, ref, relation) => {
|
|
2134
|
+
const key = entityKey(ref.entity, ref.id);
|
|
2135
|
+
return isTombstone(store, key) ? "absent" : assemble(store, key, relation);
|
|
2136
|
+
};
|
|
2137
|
+
/** Assembles the targets a relation value refers to, in the value's own shape. */
|
|
2138
|
+
const assembleRelation = (store, value, relation) => {
|
|
2139
|
+
if (value === null || value === void 0) return {
|
|
2140
|
+
values: null,
|
|
2141
|
+
refreshing: false
|
|
2142
|
+
};
|
|
2143
|
+
const targets = [];
|
|
2144
|
+
let refreshing = false;
|
|
2145
|
+
for (const ref of targetsOf(value, relation)) {
|
|
2146
|
+
const target = assembleTarget(store, ref, relation);
|
|
2147
|
+
if (target === void 0) return void 0;
|
|
2148
|
+
if (target === "absent") continue;
|
|
2149
|
+
targets.push(target.values);
|
|
2150
|
+
refreshing ||= target.refreshing;
|
|
2151
|
+
}
|
|
2152
|
+
if (typeof value === "string") return {
|
|
2153
|
+
values: targets.length === 0 ? null : targets[0],
|
|
2154
|
+
refreshing
|
|
2155
|
+
};
|
|
2156
|
+
if (isRefPage(value)) return {
|
|
2157
|
+
values: {
|
|
2158
|
+
items: targets,
|
|
2159
|
+
hasNext: value.hasNext,
|
|
2160
|
+
hasPrevious: value.hasPrevious
|
|
2161
|
+
},
|
|
2162
|
+
refreshing
|
|
2163
|
+
};
|
|
2164
|
+
return {
|
|
2165
|
+
values: targets,
|
|
2166
|
+
refreshing
|
|
2167
|
+
};
|
|
2168
|
+
};
|
|
2169
|
+
//#endregion
|
|
2170
|
+
//#region src/index.ts
|
|
2171
|
+
/**
|
|
2172
|
+
* `foldkit-remote` — normalized application-facing server state.
|
|
2173
|
+
*
|
|
2174
|
+
* The pure core (entities, selections, the store, the planner, connections,
|
|
2175
|
+
* live classification, optimistic layers) performs no I/O. The `Remote.*`
|
|
2176
|
+
* helpers that read or mutate go through the `RemoteClient` Effect service.
|
|
2177
|
+
*/
|
|
2178
|
+
/**
|
|
2179
|
+
* The store a read or plan sees: the base with every pending optimistic layer
|
|
2180
|
+
* applied, so a request's patches show until it settles and a temporary id is
|
|
2181
|
+
* not planned as a fetch.
|
|
2182
|
+
*/
|
|
2183
|
+
const storeOf = (bound, model) => {
|
|
2184
|
+
const remote = bound.store.get(model);
|
|
2185
|
+
return visibleStoreOf(remote.entities, remote.optimistic);
|
|
2186
|
+
};
|
|
2187
|
+
const visibleStores = /* @__PURE__ */ new WeakMap();
|
|
2188
|
+
const visibleStoreOf = (entities, optimistic) => {
|
|
2189
|
+
let byStore = visibleStores.get(optimistic);
|
|
2190
|
+
if (byStore === void 0) {
|
|
2191
|
+
byStore = /* @__PURE__ */ new WeakMap();
|
|
2192
|
+
visibleStores.set(optimistic, byStore);
|
|
2193
|
+
}
|
|
2194
|
+
let visible = byStore.get(entities);
|
|
2195
|
+
if (visible === void 0) {
|
|
2196
|
+
visible = visibleStore(entities, optimistic);
|
|
2197
|
+
byStore.set(entities, visible);
|
|
2198
|
+
}
|
|
2199
|
+
return visible;
|
|
2200
|
+
};
|
|
2201
|
+
const readResults = /* @__PURE__ */ new WeakMap();
|
|
2202
|
+
const memoRead = (snapshot, by, key, compute) => {
|
|
2203
|
+
let byScope = readResults.get(snapshot);
|
|
2204
|
+
if (byScope === void 0) {
|
|
2205
|
+
byScope = /* @__PURE__ */ new WeakMap();
|
|
2206
|
+
readResults.set(snapshot, byScope);
|
|
2207
|
+
}
|
|
2208
|
+
let results = byScope.get(by);
|
|
2209
|
+
if (results === void 0) {
|
|
2210
|
+
results = /* @__PURE__ */ new Map();
|
|
2211
|
+
byScope.set(by, results);
|
|
2212
|
+
}
|
|
2213
|
+
if (results.has(key)) return results.get(key);
|
|
2214
|
+
const value = compute();
|
|
2215
|
+
results.set(key, value);
|
|
2216
|
+
return value;
|
|
2217
|
+
};
|
|
2218
|
+
/** The default `toMessage`: the application reduces `RemoteMessage` itself. */
|
|
2219
|
+
const identityMessage = (message) => message;
|
|
2220
|
+
/** A stable key for a live subscription's requirement set. */
|
|
2221
|
+
const liveStreamKey = (requirements) => requirements.map((requirement) => `${entityKey(requirement.entity, requirement.id)}:${[...requirement.fields].sort().join(",")}`).sort().join("|");
|
|
2222
|
+
/** Declares a Remote domain: its entities, queries, and mutations, plus the submodel. */
|
|
2223
|
+
const defineRemote = (config) => ({
|
|
2224
|
+
entities: config.entities,
|
|
2225
|
+
queries: config.queries ?? [],
|
|
2226
|
+
mutations: config.mutations ?? [],
|
|
2227
|
+
Model: remoteModelSchema(),
|
|
2228
|
+
initial: initialRemoteModel,
|
|
2229
|
+
Message: remoteMessageSchema,
|
|
2230
|
+
update: updateRemote,
|
|
2231
|
+
rpc: RemoteRpc,
|
|
2232
|
+
registry: {
|
|
2233
|
+
entities: new Map(config.entities.map((entity) => [entity.name, entity])),
|
|
2234
|
+
queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
|
|
2235
|
+
mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
/** Binds a Remote domain to its store's location in the application Model. */
|
|
2239
|
+
const bindRemote = (definition, store) => ({
|
|
2240
|
+
definition,
|
|
2241
|
+
store,
|
|
2242
|
+
contract: {
|
|
2243
|
+
kind: "remote",
|
|
2244
|
+
name: store.dependency.join(".") || "remote",
|
|
2245
|
+
owner: store.owner,
|
|
2246
|
+
owns: store.dependency.length === 0 ? [] : [store.dependency],
|
|
2247
|
+
observes: store.dependency.length === 0 ? [] : [store.dependency],
|
|
2248
|
+
messages: [],
|
|
2249
|
+
requirements: []
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
/** The runtime half of `Registered`: a descriptor the domain never declared is an error naming both. */
|
|
2253
|
+
const assertRegistered = (bound, kind, registered, name) => {
|
|
2254
|
+
if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
|
|
2255
|
+
};
|
|
2256
|
+
const nothingAsked = {
|
|
2257
|
+
requirements: [],
|
|
2258
|
+
connections: []
|
|
2259
|
+
};
|
|
2260
|
+
const askedOf = (projection) => projection === void 0 ? nothingAsked : projection;
|
|
2261
|
+
/**
|
|
2262
|
+
* Plans what a projection asks against the Model. A connection the Model holds
|
|
2263
|
+
* fresh contributes its visible items' selected fields to the entity plan; one
|
|
2264
|
+
* it does not hold, or holds stale, is a query to run (its items are planned
|
|
2265
|
+
* once the page arrives).
|
|
2266
|
+
*/
|
|
2267
|
+
const planAsked = (remote, asked, options) => {
|
|
2268
|
+
const queries = [];
|
|
2269
|
+
const items = [];
|
|
2270
|
+
const connections = Requirement.mergeConnections(asked.connections);
|
|
2271
|
+
for (const connection of connections) {
|
|
2272
|
+
const known = remote.connections[connection.identity];
|
|
2273
|
+
if (known === void 0 || known.stale || options.force === true) {
|
|
2274
|
+
queries.push(connection);
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
2277
|
+
const edges = visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities);
|
|
2278
|
+
items.push(...itemsOf(edges.map((edge) => edge.ref), connection.select));
|
|
2279
|
+
}
|
|
2280
|
+
return {
|
|
2281
|
+
requirements: plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], options),
|
|
2282
|
+
queries
|
|
2283
|
+
};
|
|
2284
|
+
};
|
|
2285
|
+
/** The entity requirements a page's edges add under a selection. */
|
|
2286
|
+
const itemsOf = (edges, select) => edges.filter((edge) => edge.entity === select.entity).map((edge) => ({
|
|
2287
|
+
...select,
|
|
2288
|
+
id: edge.id
|
|
2289
|
+
}));
|
|
2290
|
+
/**
|
|
2291
|
+
* The wire request of a planned query; a `QueryRef` identity carries the query
|
|
2292
|
+
* name and the canonical encoded input. A connection requirement built by hand
|
|
2293
|
+
* with another identity cannot be run, and fails as a query would.
|
|
2294
|
+
*/
|
|
2295
|
+
const queryRequestOf = (query) => {
|
|
2296
|
+
const separator = query.identity.indexOf("\0");
|
|
2297
|
+
if (separator < 0) return Effect.fail(new RemoteQueryError({ message: `connection "${query.identity}" is not a query's: only a QueryRef identity can be run` }));
|
|
2298
|
+
return Effect.try({
|
|
2299
|
+
try: () => ({
|
|
2300
|
+
query: query.identity.slice(0, separator),
|
|
2301
|
+
input: JSON.parse(query.identity.slice(separator + 1)),
|
|
2302
|
+
window: query.window
|
|
2303
|
+
}),
|
|
2304
|
+
catch: () => new RemoteQueryError({ message: `connection "${query.identity}" carries no encoded input` })
|
|
2305
|
+
});
|
|
2306
|
+
};
|
|
2307
|
+
/**
|
|
2308
|
+
* The `ConnectionMerged` for a query result, in the client's edge shape.
|
|
2309
|
+
* `refreshes` marks the page as answering the connection's refresh, so one
|
|
2310
|
+
* Message both merges it and clears `stale`.
|
|
2311
|
+
*/
|
|
2312
|
+
const pageMessage = (connection, result, refreshes = false) => ({
|
|
2313
|
+
_tag: "ConnectionMerged",
|
|
2314
|
+
connection,
|
|
2315
|
+
page: {
|
|
2316
|
+
edges: result.edges.map((edge) => ({
|
|
2317
|
+
key: edge.key,
|
|
2318
|
+
ref: {
|
|
2319
|
+
entity: edge.entity,
|
|
2320
|
+
id: edge.id
|
|
2321
|
+
}
|
|
2322
|
+
})),
|
|
2323
|
+
start: result.start,
|
|
2324
|
+
end: result.end
|
|
2325
|
+
},
|
|
2326
|
+
...refreshes ? { refreshes } : {}
|
|
2327
|
+
});
|
|
2328
|
+
/**
|
|
2329
|
+
* The retention roots of some projections: their requirements, their
|
|
2330
|
+
* connections (each with the union of what the projections select of its
|
|
2331
|
+
* items), and the connections listed by identity alone.
|
|
2332
|
+
*/
|
|
2333
|
+
const rootsOf = (projections, options) => {
|
|
2334
|
+
const connections = /* @__PURE__ */ new Map();
|
|
2335
|
+
for (const identity of (options.connections ?? []).map(connectionIdentity)) connections.set(identity, { identity });
|
|
2336
|
+
for (const { identity, select } of projections.flatMap((projection) => projection.connections)) {
|
|
2337
|
+
const current = connections.get(identity)?.select;
|
|
2338
|
+
connections.set(identity, {
|
|
2339
|
+
identity,
|
|
2340
|
+
select: current === void 0 ? select : Requirement.mergeRelation(current, select)
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
return {
|
|
2344
|
+
requirements: Requirement.merge(projections.flatMap((projection) => projection.requirements)),
|
|
2345
|
+
connections: [...connections.values()].sort((a, b) => a.identity < b.identity ? -1 : 1)
|
|
2346
|
+
};
|
|
2347
|
+
};
|
|
2348
|
+
/** A query the read entry runs, as its dependencies carry it: plain data Foldkit compares. */
|
|
2349
|
+
const PlannedQuery = Schema.Struct({
|
|
2350
|
+
identity: Schema.String,
|
|
2351
|
+
window: WindowSchema,
|
|
2352
|
+
select: RelationRequest
|
|
2353
|
+
});
|
|
2354
|
+
/**
|
|
2355
|
+
* The read entry: plans what `askedOf(model)` asks against the Model and runs
|
|
2356
|
+
* the entity read and the queries concurrently. Each Message it emits changes
|
|
2357
|
+
* the Model, so Foldkit recomputes the dependencies and restarts the stream
|
|
2358
|
+
* (`switchMap`): a merged page's items are planned by that next computation,
|
|
2359
|
+
* against the store as it then is, rather than read here unplanned. Nothing
|
|
2360
|
+
* the entry needs is spread over two Messages, since the second could be lost
|
|
2361
|
+
* to the restart; a page and its refresh are one `ConnectionMerged`.
|
|
2362
|
+
*/
|
|
2363
|
+
const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
2364
|
+
const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
|
|
2365
|
+
const read = (requirements) => Effect.gen(function* () {
|
|
2366
|
+
const client = yield* RemoteClient;
|
|
2367
|
+
const at = now();
|
|
2368
|
+
const result = yield* Effect.result(client.read({
|
|
2369
|
+
version: 3,
|
|
2370
|
+
requests: requirements
|
|
2371
|
+
}));
|
|
2372
|
+
return Result.isFailure(result) ? toMessage({
|
|
2373
|
+
_tag: "ReadFailed",
|
|
2374
|
+
requests: requirements,
|
|
2375
|
+
error: remoteError(result.failure)
|
|
2376
|
+
}) : toMessage({
|
|
2377
|
+
_tag: "ReadReceived",
|
|
2378
|
+
requests: requirements,
|
|
2379
|
+
result: result.success,
|
|
2380
|
+
now: at
|
|
2381
|
+
});
|
|
2382
|
+
});
|
|
2383
|
+
const run = (query) => Effect.gen(function* () {
|
|
2384
|
+
const client = yield* RemoteClient;
|
|
2385
|
+
const result = yield* Effect.result(queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request))));
|
|
2386
|
+
return Result.isFailure(result) ? toMessage({
|
|
2387
|
+
_tag: "QueryFailed",
|
|
2388
|
+
connection: query.identity,
|
|
2389
|
+
error: remoteError(result.failure)
|
|
2390
|
+
}) : toMessage(pageMessage(query.identity, result.success, true));
|
|
2391
|
+
});
|
|
2392
|
+
return {
|
|
2393
|
+
dependenciesSchema: Schema.Struct({
|
|
2394
|
+
requirements: Schema.Array(ReadRequest),
|
|
2395
|
+
queries: Schema.Array(PlannedQuery)
|
|
2396
|
+
}),
|
|
2397
|
+
modelToDependencies: (model) => {
|
|
2398
|
+
const planned = planAsked(bound.store.get(model), askedOf(model), RemotePolicy.toPlan(policy, now()));
|
|
2399
|
+
return {
|
|
2400
|
+
requirements: planned.requirements,
|
|
2401
|
+
queries: planned.queries.map(({ identity, window, select }) => ({
|
|
2402
|
+
identity,
|
|
2403
|
+
window,
|
|
2404
|
+
select
|
|
2405
|
+
}))
|
|
2406
|
+
};
|
|
2407
|
+
},
|
|
2408
|
+
dependenciesToStream: ({ requirements, queries }) => Stream.concat(RemotePolicy.refreshes(policy) && requirements.length > 0 ? Stream.succeed(toMessage({
|
|
2409
|
+
_tag: "RefreshStarted",
|
|
2410
|
+
requests: requirements
|
|
2411
|
+
})) : Stream.empty, Stream.mergeAll([...requirements.length === 0 ? [] : [Stream.fromEffect(read(requirements))], ...queries.map((query) => Stream.fromEffect(run(query)))], { concurrency: "unbounded" }))
|
|
2412
|
+
};
|
|
2413
|
+
};
|
|
2414
|
+
/** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
|
|
2415
|
+
const liveEntry = (bound, requirementsOf, toMessage, options) => ({
|
|
2416
|
+
dependenciesSchema: Schema.Struct({
|
|
2417
|
+
requirements: Schema.Array(ReadRequest),
|
|
2418
|
+
cursor: Schema.Number
|
|
2419
|
+
}),
|
|
2420
|
+
modelToDependencies: (model) => {
|
|
2421
|
+
const requirements = requirementsOf(model);
|
|
2422
|
+
const stream = liveStreamKey(requirements);
|
|
2423
|
+
return {
|
|
2424
|
+
requirements,
|
|
2425
|
+
cursor: bound.store.get(model).live[stream]?.cursor ?? 0
|
|
2426
|
+
};
|
|
2427
|
+
},
|
|
2428
|
+
dependenciesToStream: ({ requirements, cursor }) => requirements.length === 0 ? Stream.empty : Stream.unwrap(Effect.gen(function* () {
|
|
2429
|
+
return (yield* RemoteClient).live({
|
|
2430
|
+
requirements,
|
|
2431
|
+
after: cursor
|
|
2432
|
+
});
|
|
2433
|
+
})).pipe(Stream.map((event) => toMessage({
|
|
2434
|
+
_tag: "LiveReceived",
|
|
2435
|
+
stream: liveStreamKey(requirements),
|
|
2436
|
+
event,
|
|
2437
|
+
now: (options.now ?? Date.now)()
|
|
2438
|
+
})), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
|
|
2439
|
+
_tag: "ReadFailed",
|
|
2440
|
+
requests: requirements,
|
|
2441
|
+
error: remoteError(error)
|
|
2442
|
+
}))))
|
|
2443
|
+
});
|
|
2444
|
+
/** The projection an active Surface has for this Model, if it is active. */
|
|
2445
|
+
const projectionOf = (entry, model) => "projectionOf" in entry ? entry.projectionOf(model) : entry.projection();
|
|
2446
|
+
/**
|
|
2447
|
+
* `projectionOf` computed once per Model: the read, live, and retain entries
|
|
2448
|
+
* all derive their dependencies from the same projection on the same Model
|
|
2449
|
+
* change, and a Surface's `model` callback (which lifts and builds schemas)
|
|
2450
|
+
* need not run three times for it. Models are objects, so the memo is weak.
|
|
2451
|
+
*/
|
|
2452
|
+
const memoizedProjectionOf = (entry) => {
|
|
2453
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
2454
|
+
return (model) => {
|
|
2455
|
+
const key = model;
|
|
2456
|
+
if (typeof key !== "object" || key === null) return projectionOf(entry, model);
|
|
2457
|
+
if (cache.has(key)) return cache.get(key);
|
|
2458
|
+
const projection = projectionOf(entry, model);
|
|
2459
|
+
cache.set(key, projection);
|
|
2460
|
+
return projection;
|
|
2461
|
+
};
|
|
2462
|
+
};
|
|
2463
|
+
const Remote = {
|
|
2464
|
+
/** The submodel's schema, the same for every domain; embed it in the application Model. */
|
|
2465
|
+
Model: remoteModelSchema(),
|
|
2466
|
+
/** The submodel's initial value. */
|
|
2467
|
+
initial: initialRemoteModel,
|
|
2468
|
+
/**
|
|
2469
|
+
* Remote's Message cases for `defineMessageUnion`: spread them into the
|
|
2470
|
+
* application's union, and reduce the ones `Remote.reduces` recognizes with
|
|
2471
|
+
* `RemoteDomain.reduce`.
|
|
2472
|
+
*/
|
|
2473
|
+
messages: remoteMessageCases,
|
|
2474
|
+
/** Whether a Message is one of Remote's, by tag. */
|
|
2475
|
+
reduces: isRemoteMessage,
|
|
2476
|
+
/**
|
|
2477
|
+
* Declares a Remote domain and binds it to its place in the application Model
|
|
2478
|
+
* in one step; the result carries the application-facing operations. The
|
|
2479
|
+
* descriptor alone is `Remote.define`, the binding alone `Remote.at`.
|
|
2480
|
+
*/
|
|
2481
|
+
make: (config) => bindDomain(defineRemote(config), config.model),
|
|
2482
|
+
/**
|
|
2483
|
+
* Declares a Remote domain without binding it: its entities, queries, and
|
|
2484
|
+
* mutations, plus the submodel. For a domain reused across applications or
|
|
2485
|
+
* bound in a test; `Remote.make` is the one-step form.
|
|
2486
|
+
*/
|
|
2487
|
+
define: defineRemote,
|
|
2488
|
+
/**
|
|
2489
|
+
* Binds a Remote domain to its store's location in the application Model. The
|
|
2490
|
+
* registered entity names are carried on the returned value, so `Remote.select`
|
|
2491
|
+
* rejects a selection for an entity this domain never declared.
|
|
2492
|
+
*/
|
|
2493
|
+
at: bindRemote,
|
|
2494
|
+
/**
|
|
2495
|
+
* A Projection node that reads a `RemoteData` value out of the store. The id
|
|
2496
|
+
* is supplied by the caller, usually from a Surface's params. The assembled
|
|
2497
|
+
* value is decoded against the Selection, so malformed server data surfaces
|
|
2498
|
+
* as `Failed` instead of being asserted into `Value`. A present value with a
|
|
2499
|
+
* stale field reads as `Refreshing`: an observer is refetching it.
|
|
2500
|
+
*/
|
|
2501
|
+
select: (bound, selection) => {
|
|
2502
|
+
assertRegistered(bound, "Entity", bound.definition.registry.entities, selection.entity);
|
|
2503
|
+
const relation = relationOf(selection);
|
|
2504
|
+
return (id) => ({
|
|
2505
|
+
Model: remoteDataSchema(selection.schema),
|
|
2506
|
+
dependencies: [],
|
|
2507
|
+
requirements: [{
|
|
2508
|
+
...relation,
|
|
2509
|
+
id
|
|
2510
|
+
}],
|
|
2511
|
+
connections: [],
|
|
2512
|
+
read: (root) => {
|
|
2513
|
+
const store = storeOf(bound, root);
|
|
2514
|
+
const key = entityKey(selection.entity, id);
|
|
2515
|
+
return memoRead(store, store, `${key}\u0000${stableStringify(relation)}`, () => {
|
|
2516
|
+
if (isTombstone(store, key)) return { _tag: "NotFound" };
|
|
2517
|
+
const assembled = assemble(store, key, relation);
|
|
2518
|
+
if (assembled === void 0) return { _tag: "Initial" };
|
|
2519
|
+
const decoded = Schema.decodeUnknownResult(selection.schema)(assembled.values);
|
|
2520
|
+
return Result.isFailure(decoded) ? {
|
|
2521
|
+
_tag: "Failed",
|
|
2522
|
+
error: {
|
|
2523
|
+
_tag: "DecodeError",
|
|
2524
|
+
message: decoded.failure.message
|
|
2525
|
+
}
|
|
2526
|
+
} : assembled.refreshing ? {
|
|
2527
|
+
_tag: "Refreshing",
|
|
2528
|
+
value: decoded.success
|
|
2529
|
+
} : {
|
|
2530
|
+
_tag: "Ready",
|
|
2531
|
+
value: decoded.success
|
|
2532
|
+
};
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
},
|
|
2537
|
+
/**
|
|
2538
|
+
* The pure plan for a projection against a Model: the requirements its
|
|
2539
|
+
* remote store does not satisfy, under `options` (freshness, force). A
|
|
2540
|
+
* Surface's projection is `surface.projection(params)`.
|
|
2541
|
+
*/
|
|
2542
|
+
plan: (bound, model, projection, options) => planAsked(bound.store.get(model), projection, options ?? {}).requirements,
|
|
2543
|
+
/**
|
|
2544
|
+
* The queries a projection needs run before its connections read: those the
|
|
2545
|
+
* Model does not hold, or holds stale (every one under `force`).
|
|
2546
|
+
*/
|
|
2547
|
+
planQueries: (bound, model, projection, options) => planAsked(bound.store.get(model), projection, options ?? {}).queries.flatMap((query) => query.ref === void 0 ? [] : [query.ref]),
|
|
2548
|
+
/**
|
|
2549
|
+
* The store reads see: the base store under the pending optimistic layers.
|
|
2550
|
+
* One store is shared by every read and plan of a Model whose remote state
|
|
2551
|
+
* has not changed.
|
|
2552
|
+
*/
|
|
2553
|
+
storeOf: (bound, model) => storeOf(bound, model),
|
|
2554
|
+
/**
|
|
2555
|
+
* Executes the plan against the `RemoteClient` and returns a new store. Used
|
|
2556
|
+
* for SSR route prefetch, hover prefetch, and tests. Never called during render.
|
|
2557
|
+
* `policy` decides what a present field means (default cache-first); `now`
|
|
2558
|
+
* is the clock it reads, so the planner itself stays pure and time-injected.
|
|
2559
|
+
*/
|
|
2560
|
+
prefetch: Effect.fn("Remote.prefetch")(function* (bound, model, projection, options = {}) {
|
|
2561
|
+
const store = storeOf(bound, model);
|
|
2562
|
+
const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
|
|
2563
|
+
const at = now();
|
|
2564
|
+
const missing = plan(store, projection.requirements, RemotePolicy.toPlan(policy, at));
|
|
2565
|
+
if (missing.length === 0) return store;
|
|
2566
|
+
yield* Effect.annotateCurrentSpan("requirementCount", missing.length);
|
|
2567
|
+
const result = yield* (yield* RemoteClient).read({
|
|
2568
|
+
version: 3,
|
|
2569
|
+
requests: missing
|
|
2570
|
+
});
|
|
2571
|
+
return writeRead(store, missing, result, at);
|
|
2572
|
+
}),
|
|
2573
|
+
/**
|
|
2574
|
+
* Writes a read result into the store, recording each field's applied window.
|
|
2575
|
+
* `requests` are the planned requirements the result answers.
|
|
2576
|
+
*/
|
|
2577
|
+
writeRead,
|
|
2578
|
+
/**
|
|
2579
|
+
* Adapts an Effect RPC client for `RemoteRpc` to `RemoteClient`, so an
|
|
2580
|
+
* application provides the transport's RPC layer instead of writing the
|
|
2581
|
+
* `LiveChange`-to-`LiveEvent` mapping by hand.
|
|
2582
|
+
*/
|
|
2583
|
+
clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
2584
|
+
const context = yield* Effect.context();
|
|
2585
|
+
return {
|
|
2586
|
+
read: (batch) => client.FoldkitRemoteRead(batch).pipe(Effect.provideContext(context)),
|
|
2587
|
+
query: (request) => client.FoldkitRemoteQuery(request).pipe(Effect.provideContext(context)),
|
|
2588
|
+
mutate: (request) => client.FoldkitRemoteMutate(request).pipe(Effect.provideContext(context)),
|
|
2589
|
+
live: ({ requirements, after }) => client.FoldkitRemoteLive({
|
|
2590
|
+
version: 3,
|
|
2591
|
+
requirements,
|
|
2592
|
+
after
|
|
2593
|
+
}).pipe(Stream.map(liveEventOf), Stream.provideContext(context))
|
|
2594
|
+
};
|
|
2595
|
+
})), options),
|
|
2596
|
+
/**
|
|
2597
|
+
* Wraps a `RemoteClient` layer so its reads coalesce: requirements issued
|
|
2598
|
+
* together become one batch, and a requirement already in flight is joined.
|
|
2599
|
+
* `Remote.clientLayer` applies this; use it on a hand-written client.
|
|
2600
|
+
*/
|
|
2601
|
+
coalesced: coalescedLayer,
|
|
2602
|
+
/**
|
|
2603
|
+
* A Foldkit Subscription entry that keeps the cache to what the active
|
|
2604
|
+
* Surfaces reach. `projections` are the ones the application observes (the
|
|
2605
|
+
* same it passes to `Remote.observe`), `connections` the query connections
|
|
2606
|
+
* it shows; anything else is collected once the roots have been stable for
|
|
2607
|
+
* `grace`, so a route transition that comes straight back does not thrash.
|
|
2608
|
+
*/
|
|
2609
|
+
retain: (projections, toMessage = identityMessage, options = {}) => {
|
|
2610
|
+
const roots = rootsOf(projections, options);
|
|
2611
|
+
return {
|
|
2612
|
+
dependenciesSchema: retentionRootsSchema,
|
|
2613
|
+
modelToDependencies: () => roots,
|
|
2614
|
+
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed(toMessage({
|
|
2615
|
+
_tag: "RetentionChanged",
|
|
2616
|
+
roots: current
|
|
2617
|
+
})).pipe(Effect.delay(options.grace ?? 0)))
|
|
2618
|
+
};
|
|
2619
|
+
},
|
|
2620
|
+
/**
|
|
2621
|
+
* Runs a `Query` through `RemoteClient`, encoding its input from the ref.
|
|
2622
|
+
* Pair the result with `Remote.queryMessage` to merge the page into the Model.
|
|
2623
|
+
*/
|
|
2624
|
+
query: Effect.fn("Remote.query")(function* (ref) {
|
|
2625
|
+
const client = yield* RemoteClient;
|
|
2626
|
+
const input = yield* Schema.encodeUnknownEffect(ref.Input)(ref.input).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteQueryError({ message: error.message }))));
|
|
2627
|
+
return yield* client.query({
|
|
2628
|
+
query: ref.query,
|
|
2629
|
+
input,
|
|
2630
|
+
window: ref.window
|
|
2631
|
+
});
|
|
2632
|
+
}),
|
|
2633
|
+
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
2634
|
+
queryMessage: (ref, result) => pageMessage(ref.identity, result),
|
|
2635
|
+
/**
|
|
2636
|
+
* The edges a connection shows: its server-known region with pending and
|
|
2637
|
+
* confirmed overlays placed around it, minus removed edges and edges whose
|
|
2638
|
+
* target is a tombstone.
|
|
2639
|
+
*/
|
|
2640
|
+
visibleItems: (model, connection) => {
|
|
2641
|
+
const identity = connectionIdentity(connection);
|
|
2642
|
+
return visibleItems(model.connections[identity] ?? emptyConnection, identity, model.optimistic.overlays, model.entities);
|
|
2643
|
+
},
|
|
2644
|
+
/** A pure, serializable view of the whole cache. */
|
|
2645
|
+
inspect: inspectRemote,
|
|
2646
|
+
/** A pure, serializable view of one entity, or `undefined` if unknown. */
|
|
2647
|
+
inspectEntity,
|
|
2648
|
+
/**
|
|
2649
|
+
* Runs a mutation through `RemoteClient`, decoding its typed Output and
|
|
2650
|
+
* returning the result's normalized patches so the caller can reconcile them
|
|
2651
|
+
* through `Remote.update`'s `MutationSucceeded` (or `Remote.mutateInto`).
|
|
2652
|
+
*/
|
|
2653
|
+
mutate: mutateRemote,
|
|
2654
|
+
/**
|
|
2655
|
+
* Runs a mutation and reconciles its patches into a `RemoteModel` in one step,
|
|
2656
|
+
* returning the new model alongside the typed Output. The model-level form of
|
|
2657
|
+
* `MutationStarted` → `RemoteClient.mutate` → `MutationSucceeded`; in an
|
|
2658
|
+
* application the three are `update` (with `optimistic`), a Command, and the
|
|
2659
|
+
* Message the Command returns, so the optimistic operations show meanwhile.
|
|
2660
|
+
*/
|
|
2661
|
+
mutateInto: Effect.fn("Remote.mutateInto")(function* (bound, model, mutation, input, requestId, options = {}) {
|
|
2662
|
+
const started = updateRemote(bound.store.get(model), {
|
|
2663
|
+
_tag: "MutationStarted",
|
|
2664
|
+
requestId,
|
|
2665
|
+
...options.optimistic === void 0 ? {} : { optimistic: options.optimistic }
|
|
2666
|
+
});
|
|
2667
|
+
const outcome = yield* mutateRemote(mutation, input, requestId);
|
|
2668
|
+
const settled = updateRemote(started, {
|
|
2669
|
+
_tag: "MutationSucceeded",
|
|
2670
|
+
requestId,
|
|
2671
|
+
entities: outcome.entities,
|
|
2672
|
+
connections: outcome.connections
|
|
2673
|
+
});
|
|
2674
|
+
return {
|
|
2675
|
+
output: outcome.output,
|
|
2676
|
+
model: bound.store.set(model, settled)
|
|
2677
|
+
};
|
|
2678
|
+
}),
|
|
2679
|
+
/**
|
|
2680
|
+
* A Foldkit Subscription entry that plans a Surface's missing fields from the
|
|
2681
|
+
* Model and fetches them through `RemoteClient`, emitting a `RemoteMessage`
|
|
2682
|
+
* per outcome. Wrap it in an application Message (`toMessage`) and reduce it
|
|
2683
|
+
* with the domain's `update`. Under a refreshing `policy` the entry first
|
|
2684
|
+
* emits `RefreshStarted`, so the fields being refetched read as `Refreshing`
|
|
2685
|
+
* while the request is pending.
|
|
2686
|
+
*/
|
|
2687
|
+
observe: (bound, surface, params, toMessage = identityMessage, options = {}) => observeEntry(bound, () => surface.projection(params), toMessage, options),
|
|
2688
|
+
/**
|
|
2689
|
+
* A Foldkit Subscription entry that consumes the live stream for a Surface's
|
|
2690
|
+
* requirements, emitting a `LiveReceived` per event and a `ReadFailed` when
|
|
2691
|
+
* the stream breaks (including `ResumeUnavailable`). The resume cursor is read
|
|
2692
|
+
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
2693
|
+
*/
|
|
2694
|
+
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => surface.projection(params).requirements, toMessage, options)
|
|
2695
|
+
};
|
|
2696
|
+
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
2697
|
+
const bindDomain = (definition, store) => {
|
|
2698
|
+
const bound = bindRemote(definition, store);
|
|
2699
|
+
const reduce = (model, message) => store.set(model, updateRemote(store.get(model), message));
|
|
2700
|
+
return {
|
|
2701
|
+
...definition,
|
|
2702
|
+
...bound,
|
|
2703
|
+
get: (selection, id) => Remote.select(bound, selection)(id),
|
|
2704
|
+
live: (selection, id) => {
|
|
2705
|
+
const projection = Remote.select(bound, selection)(id);
|
|
2706
|
+
return {
|
|
2707
|
+
...projection,
|
|
2708
|
+
requirements: projection.requirements.map((requirement) => ({
|
|
2709
|
+
...requirement,
|
|
2710
|
+
live: true
|
|
2711
|
+
}))
|
|
2712
|
+
};
|
|
2713
|
+
},
|
|
2714
|
+
subscriptions: (active, options = {}) => {
|
|
2715
|
+
const entries = {};
|
|
2716
|
+
const projections = [];
|
|
2717
|
+
for (const [key, entry] of Object.entries(active)) {
|
|
2718
|
+
if (bound.contract.owner !== void 0 && entry.owner !== bound.contract.owner) throw new Error(`Remote: Surface "${entry.name}" belongs to another application than domain "${bound.contract.name}"`);
|
|
2719
|
+
const projectionAt = memoizedProjectionOf(entry);
|
|
2720
|
+
projections.push(projectionAt);
|
|
2721
|
+
const asked = (model) => askedOf(projectionAt(model));
|
|
2722
|
+
entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
|
|
2723
|
+
entries[`${key}.live`] = liveEntry(bound, (model) => asked(model).requirements.filter((requirement) => requirement.live === true), identityMessage, options);
|
|
2724
|
+
}
|
|
2725
|
+
entries.retain = {
|
|
2726
|
+
dependenciesSchema: retentionRootsSchema,
|
|
2727
|
+
modelToDependencies: (model) => rootsOf(projections.flatMap((projectionAt) => {
|
|
2728
|
+
const projection = projectionAt(model);
|
|
2729
|
+
return projection === void 0 ? [] : [projection];
|
|
2730
|
+
}), options),
|
|
2731
|
+
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
|
|
2732
|
+
_tag: "RetentionChanged",
|
|
2733
|
+
roots: current
|
|
2734
|
+
}).pipe(Effect.delay(options.grace ?? 0)))
|
|
2735
|
+
};
|
|
2736
|
+
return entries;
|
|
2737
|
+
},
|
|
2738
|
+
plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
|
|
2739
|
+
storeOf: (model) => storeOf(bound, model),
|
|
2740
|
+
prefetch: (model, projection, options = {}) => Effect.gen(function* () {
|
|
2741
|
+
const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
|
|
2742
|
+
const client = yield* RemoteClient;
|
|
2743
|
+
const planOptions = RemotePolicy.toPlan(policy, now());
|
|
2744
|
+
let current = model;
|
|
2745
|
+
for (const query of planAsked(store.get(current), projection, planOptions).queries) {
|
|
2746
|
+
const page = yield* queryRequestOf(query).pipe(Effect.flatMap((request) => client.query(request)));
|
|
2747
|
+
current = reduce(current, pageMessage(query.identity, page, true));
|
|
2748
|
+
}
|
|
2749
|
+
const requirements = planAsked(store.get(current), projection, planOptions).requirements;
|
|
2750
|
+
if (requirements.length === 0) return current;
|
|
2751
|
+
const at = now();
|
|
2752
|
+
const result = yield* client.read({
|
|
2753
|
+
version: 3,
|
|
2754
|
+
requests: requirements
|
|
2755
|
+
});
|
|
2756
|
+
return reduce(current, {
|
|
2757
|
+
_tag: "ReadReceived",
|
|
2758
|
+
requests: requirements,
|
|
2759
|
+
result,
|
|
2760
|
+
now: at
|
|
2761
|
+
});
|
|
2762
|
+
}),
|
|
2763
|
+
query: (query, input, options) => {
|
|
2764
|
+
assertRegistered(bound, "Query", definition.registry.queries, query.name);
|
|
2765
|
+
const { select, ...window } = options;
|
|
2766
|
+
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`);
|
|
2767
|
+
const listed = query.Result.entity;
|
|
2768
|
+
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`);
|
|
2769
|
+
if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
|
|
2770
|
+
const ref = {
|
|
2771
|
+
...query.ref(input),
|
|
2772
|
+
window: pickWindow(window)
|
|
2773
|
+
};
|
|
2774
|
+
const relation = relationOf(select);
|
|
2775
|
+
const relationKey = stableStringify(relation);
|
|
2776
|
+
const requirement = {
|
|
2777
|
+
identity: ref.identity,
|
|
2778
|
+
window: ref.window,
|
|
2779
|
+
select: relation,
|
|
2780
|
+
ref
|
|
2781
|
+
};
|
|
2782
|
+
return {
|
|
2783
|
+
Model: remoteDataSchema(pageSchema(select.schema)),
|
|
2784
|
+
dependencies: [],
|
|
2785
|
+
requirements: [],
|
|
2786
|
+
connections: [requirement],
|
|
2787
|
+
ref,
|
|
2788
|
+
read: (root) => {
|
|
2789
|
+
const remote = store.get(root);
|
|
2790
|
+
const connection = remote.connections[ref.identity];
|
|
2791
|
+
if (connection === void 0 || connection.stale && connection.segments.length === 0) return { _tag: "Initial" };
|
|
2792
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
2793
|
+
return memoRead(visible, connection, `${ref.identity}\u0000${relationKey}`, () => {
|
|
2794
|
+
const items = [];
|
|
2795
|
+
let refreshing = connection.stale;
|
|
2796
|
+
const edges = visibleItems(connection, ref.identity, remote.optimistic.overlays, remote.entities);
|
|
2797
|
+
for (const edge of edges) {
|
|
2798
|
+
const assembled = assemble(visible, entityKey(edge.ref.entity, edge.ref.id), relation);
|
|
2799
|
+
if (assembled === void 0) return { _tag: "Initial" };
|
|
2800
|
+
const decoded = Schema.decodeUnknownResult(select.schema)(assembled.values);
|
|
2801
|
+
if (Result.isFailure(decoded)) return {
|
|
2802
|
+
_tag: "Failed",
|
|
2803
|
+
error: {
|
|
2804
|
+
_tag: "DecodeError",
|
|
2805
|
+
message: decoded.failure.message
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
refreshing ||= assembled.refreshing;
|
|
2809
|
+
items.push(decoded.success);
|
|
2810
|
+
}
|
|
2811
|
+
const page = {
|
|
2812
|
+
items,
|
|
2813
|
+
hasNext: hasNext(connection),
|
|
2814
|
+
hasPrevious: hasPrevious(connection)
|
|
2815
|
+
};
|
|
2816
|
+
return refreshing ? {
|
|
2817
|
+
_tag: "Refreshing",
|
|
2818
|
+
value: page
|
|
2819
|
+
} : {
|
|
2820
|
+
_tag: "Ready",
|
|
2821
|
+
value: page
|
|
2822
|
+
};
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
},
|
|
2827
|
+
next: (model, projection) => {
|
|
2828
|
+
const segments = store.get(model).connections[projection.ref.identity]?.segments ?? [];
|
|
2829
|
+
const end = segments[segments.length - 1]?.end;
|
|
2830
|
+
return end?._tag === "Cursor" ? {
|
|
2831
|
+
...projection.ref,
|
|
2832
|
+
window: {
|
|
2833
|
+
...pageSize(projection.ref.window, "first"),
|
|
2834
|
+
after: end.cursor
|
|
2835
|
+
}
|
|
2836
|
+
} : void 0;
|
|
2837
|
+
},
|
|
2838
|
+
previous: (model, projection) => {
|
|
2839
|
+
const start = store.get(model).connections[projection.ref.identity]?.segments[0]?.start;
|
|
2840
|
+
return start?._tag === "Cursor" ? {
|
|
2841
|
+
...projection.ref,
|
|
2842
|
+
window: {
|
|
2843
|
+
...pageSize(projection.ref.window, "last"),
|
|
2844
|
+
before: start.cursor
|
|
2845
|
+
}
|
|
2846
|
+
} : void 0;
|
|
2847
|
+
},
|
|
2848
|
+
fetch: (ref) => ({
|
|
2849
|
+
name: `Remote.query(${ref.query})`,
|
|
2850
|
+
args: {
|
|
2851
|
+
connection: ref.identity,
|
|
2852
|
+
window: ref.window
|
|
2853
|
+
},
|
|
2854
|
+
effect: Remote.query(ref).pipe(Effect.match({
|
|
2855
|
+
onFailure: (error) => ({
|
|
2856
|
+
_tag: "QueryFailed",
|
|
2857
|
+
connection: ref.identity,
|
|
2858
|
+
error: remoteError(error)
|
|
2859
|
+
}),
|
|
2860
|
+
onSuccess: (page) => pageMessage(ref.identity, page)
|
|
2861
|
+
}))
|
|
2862
|
+
}),
|
|
2863
|
+
mutate: (model, mutation, input, options = {}) => {
|
|
2864
|
+
assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
|
|
2865
|
+
const remote = store.get(model);
|
|
2866
|
+
const requestId = options.requestId ?? `${bound.contract.name}-${remote.mutations.sequence + 1}`;
|
|
2867
|
+
const tempId = `${requestId}.tmp`;
|
|
2868
|
+
const optimistic = typeof options.optimistic === "function" ? options.optimistic({
|
|
2869
|
+
requestId,
|
|
2870
|
+
tempId
|
|
2871
|
+
}) : options.optimistic;
|
|
2872
|
+
const started = updateRemote(remote, {
|
|
2873
|
+
_tag: "MutationStarted",
|
|
2874
|
+
requestId,
|
|
2875
|
+
...optimistic === void 0 ? {} : { optimistic }
|
|
2876
|
+
});
|
|
2877
|
+
return {
|
|
2878
|
+
model: store.set(model, started),
|
|
2879
|
+
requestId,
|
|
2880
|
+
tempId,
|
|
2881
|
+
command: {
|
|
2882
|
+
name: `Remote.mutate(${mutation.name})`,
|
|
2883
|
+
args: { requestId },
|
|
2884
|
+
effect: mutateRemote(mutation, input, requestId).pipe(Effect.match({
|
|
2885
|
+
onFailure: (error) => ({
|
|
2886
|
+
_tag: "MutationFailed",
|
|
2887
|
+
requestId,
|
|
2888
|
+
error: remoteError(error)
|
|
2889
|
+
}),
|
|
2890
|
+
onSuccess: (outcome) => ({
|
|
2891
|
+
_tag: "MutationSucceeded",
|
|
2892
|
+
requestId,
|
|
2893
|
+
entities: outcome.entities,
|
|
2894
|
+
connections: outcome.connections
|
|
2895
|
+
})
|
|
2896
|
+
}))
|
|
2897
|
+
}
|
|
2898
|
+
};
|
|
2899
|
+
},
|
|
2900
|
+
reduce,
|
|
2901
|
+
inspect: (model) => inspectRemote(store.get(model))
|
|
2902
|
+
};
|
|
2903
|
+
};
|
|
2904
|
+
/** The window keys of a `QueryOptions`, and only those, without the undefined ones. */
|
|
2905
|
+
const pickWindow = (window) => ({
|
|
2906
|
+
...window.first === void 0 ? {} : { first: window.first },
|
|
2907
|
+
...window.last === void 0 ? {} : { last: window.last },
|
|
2908
|
+
...window.after === void 0 ? {} : { after: window.after },
|
|
2909
|
+
...window.before === void 0 ? {} : { before: window.before }
|
|
2910
|
+
});
|
|
2911
|
+
/** The page size of a window, carried onto the next or previous page's window under `key`. */
|
|
2912
|
+
const pageSize = (window, key) => {
|
|
2913
|
+
const size = window.first ?? window.last;
|
|
2914
|
+
return size === void 0 ? {} : { [key]: size };
|
|
2915
|
+
};
|
|
2916
|
+
//#endregion
|
|
2917
|
+
export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRpc, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refsIn, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, writeEntities, writeEntity, writeRead };
|
|
2918
|
+
|
|
2919
|
+
//# sourceMappingURL=index.mjs.map
|