@thomasfosterau/legation 0.0.0 → 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/CHANGELOG.md +2054 -0
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/dist/MemoryBackend-BziXwfcs.d.ts +4884 -0
- package/dist/MemoryBackend-BziXwfcs.d.ts.map +1 -0
- package/dist/MemoryBackend-DxP8t4ge.js +9477 -0
- package/dist/MemoryBackend-DxP8t4ge.js.map +1 -0
- package/dist/index-CPPfp0F4.d.ts +399 -0
- package/dist/index-CPPfp0F4.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal-CxXRlFK9.d.ts +2362 -0
- package/dist/internal-CxXRlFK9.d.ts.map +1 -0
- package/dist/internal-JyoQMlfK.js +3758 -0
- package/dist/internal-JyoQMlfK.js.map +1 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/sql/index.d.ts +294 -0
- package/dist/sql/index.d.ts.map +1 -0
- package/dist/sql/index.js +969 -0
- package/dist/sql/index.js.map +1 -0
- package/dist/src-FWXpu_vP.js +1082 -0
- package/dist/src-FWXpu_vP.js.map +1 -0
- package/package.json +61 -3
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
|
|
2
|
+
import { $ as make$5, $t as apply, An as resolve, Bn as StoreError, Ct as makeMemory, D as make$6, En as registry$2, F as ofPlan, Gt as runPlanVia, Hn as UnknownCollectionError, Ht as edgeSelects, I as requestedOf, It as make$4, Kt as runRows, L as snapshotOf, Lt as registry$3, Mn as BackendError, Mt as transitionOf, Nn as DuplicateNameError, Pn as FilterDeclarationError, R as verdictOf, S as AuthPolicy, St as freezePending, T as SyncMiddleware, Tn as make$3, Ut as plan, Vn as TransportError, Vt as applyWindows, Wt as run, X as fromOrigin, at as offline, d as CvrStore, en as hash, et as resyncAttachedEngines, h as Backend, it as makeInMemory, k as fromEngine, kn as kindOf, m as makeMemoryStore, nn as parseOrder, nt as ServerHandler, o as makeWith, q as relatedKey, qt as tryPlan, s as InvalidationBus, tt as ClientTransport, u as makeInProcess, w as IdentityResolver, wt as Mutation, xt as StoredRow, yt as ClientStore, zn as QueryError, zt as QueryResolver } from "./MemoryBackend-DxP8t4ge.js";
|
|
3
|
+
import * as Arr from "effect/Array";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as P from "effect/Predicate";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
import * as Stream from "effect/Stream";
|
|
10
|
+
import * as Ref from "effect/Ref";
|
|
11
|
+
import * as Exit from "effect/Exit";
|
|
12
|
+
import * as R from "effect/Record";
|
|
13
|
+
import * as Scope from "effect/Scope";
|
|
14
|
+
import * as Semaphore from "effect/Semaphore";
|
|
15
|
+
import * as Clock from "effect/Clock";
|
|
16
|
+
// src/Node.ts
|
|
17
|
+
/**
|
|
18
|
+
* Node
|
|
19
|
+
*
|
|
20
|
+
* A typed resource (ADR-0022): scalar attributes plus {@link Edge}s to
|
|
21
|
+
* other nodes. The Node definition is the single source of truth the rest
|
|
22
|
+
* of the system derives from — its row schema (the identity attribute + its
|
|
23
|
+
* attributes + the foreign-key fields its `one` edges declare), the
|
|
24
|
+
* {@link Collection} the engine materializes, the joins and sync windows its
|
|
25
|
+
* edges imply, and the fan-out **derivable set** (ADR-0023: `one`-edge keys
|
|
26
|
+
* + declared `hintKeys`), and the **filterable set** (ADR-0028, #154: the
|
|
27
|
+
* fields a client may filter or sort by, with their operators and kinds).
|
|
28
|
+
*
|
|
29
|
+
* Ids are branded per node type, so a `products` id cannot be passed where
|
|
30
|
+
* a `categories` id is expected; `one`-edge fields carry the **target**
|
|
31
|
+
* type's brand.
|
|
32
|
+
*
|
|
33
|
+
* A node's **identity attribute** is the attribute supplying its row id. It
|
|
34
|
+
* defaults to a synthetic branded `id` added to the row, and a domain with
|
|
35
|
+
* natural keys names one of its own attributes instead (`id: "username"`) —
|
|
36
|
+
* so the key is stored once, not duplicated into an `id` nothing keeps equal
|
|
37
|
+
* to it. Everything downstream reads the declared name: the derived
|
|
38
|
+
* collection's id extractor, both ends of every edge's join (each resolved
|
|
39
|
+
* from its own node, so the two ends may differ), and the filterable set a
|
|
40
|
+
* client's `where` / `order` is admitted against. Row ids are strings on the
|
|
41
|
+
* wire and in CVR bookkeeping either way (ADR-0008) — this names which
|
|
42
|
+
* attribute supplies the string, nothing more.
|
|
43
|
+
*
|
|
44
|
+
* Edges reference targets by name; {@link registry} closes the graph and
|
|
45
|
+
* validates it (targets exist, inverses resolve to a matching `one` edge),
|
|
46
|
+
* then derives the engine's `CollectionRegistry` — so the existing engine,
|
|
47
|
+
* `Related` joins, and auth filters consume Node definitions unchanged
|
|
48
|
+
* while later ADR-0022 program steps move onto them directly.
|
|
49
|
+
*/
|
|
50
|
+
var Node_exports = /* @__PURE__ */ __exportAll({
|
|
51
|
+
Id: () => Id,
|
|
52
|
+
derivableSet: () => derivableSet,
|
|
53
|
+
make: () => make$2,
|
|
54
|
+
registry: () => registry$1
|
|
55
|
+
});
|
|
56
|
+
/** Create the branded id schema for a node type. */
|
|
57
|
+
const Id = (type) => Schema.String.pipe(Schema.brand(`${type}/id`));
|
|
58
|
+
/** Define a node type. */
|
|
59
|
+
const make$2 = (type, options) => {
|
|
60
|
+
const edges = options.edges ?? {};
|
|
61
|
+
const id = Id(type);
|
|
62
|
+
const identity = options.id ?? "id";
|
|
63
|
+
if (options.id !== void 0) assertTextIdentity(type, options.id, options.attributes);
|
|
64
|
+
const viaFields = {};
|
|
65
|
+
Arr.forEach(Object.values(edges), (edge) => {
|
|
66
|
+
if (edge.kind === "one") viaFields[edge.via] = Id(edge.target);
|
|
67
|
+
});
|
|
68
|
+
const declaration = resolve({
|
|
69
|
+
collection: type,
|
|
70
|
+
attributes: options.attributes,
|
|
71
|
+
identity,
|
|
72
|
+
keyFields: [identity, ...Object.keys(viaFields)],
|
|
73
|
+
filterable: options.filterable,
|
|
74
|
+
sortable: options.sortable
|
|
75
|
+
});
|
|
76
|
+
const row = Schema.Struct({
|
|
77
|
+
...options.id === void 0 ? { id } : {},
|
|
78
|
+
...options.attributes,
|
|
79
|
+
...viaFields
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
type,
|
|
83
|
+
id: identity,
|
|
84
|
+
attributes: options.attributes,
|
|
85
|
+
edges,
|
|
86
|
+
hintKeys: options.hintKeys ?? [],
|
|
87
|
+
indexes: options.indexes ?? [],
|
|
88
|
+
filterable: declaration.filterable,
|
|
89
|
+
sortable: declaration.sortable,
|
|
90
|
+
Id: id,
|
|
91
|
+
row
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* A declared identity attribute must carry the row id, and a row id is a
|
|
96
|
+
* string on the wire (ADR-0008) — so an attribute whose literal kind is
|
|
97
|
+
* derivably something else is a definition defect, not a runtime surprise at
|
|
98
|
+
* the filter boundary. A kind that cannot be derived at all passes: the
|
|
99
|
+
* declaration is taken at its word, exactly as `filterable` does.
|
|
100
|
+
*/
|
|
101
|
+
const assertTextIdentity = (type, identity, attributes) => {
|
|
102
|
+
const attribute = attributes[identity];
|
|
103
|
+
const kind = attribute === void 0 ? void 0 : kindOf(attribute);
|
|
104
|
+
if (kind === void 0 || kind === "text") return;
|
|
105
|
+
throw new FilterDeclarationError({
|
|
106
|
+
collection: type,
|
|
107
|
+
field: identity,
|
|
108
|
+
message: `Node "${type}" declares "${identity}" as its identity attribute, but its literal kind is "${kind}"; a row id is a string`
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The fields of a node eligible for fine fan-out topics (ADR-0023): its
|
|
113
|
+
* `one`-edge keys plus declared `hintKeys`. Declared on the node, so
|
|
114
|
+
* topic registration and write-derivation cannot disagree.
|
|
115
|
+
*/
|
|
116
|
+
const derivableSet = (node) => {
|
|
117
|
+
const fields = new Set(node.hintKeys);
|
|
118
|
+
Arr.forEach(Object.values(node.edges), (edge) => {
|
|
119
|
+
if (edge.kind === "one") fields.add(edge.via);
|
|
120
|
+
});
|
|
121
|
+
return fields;
|
|
122
|
+
};
|
|
123
|
+
/** Resolve a collection-valued edge's key field from its inverse. */
|
|
124
|
+
const inverseVia = (node, name, edge, nodes) => {
|
|
125
|
+
const target = nodes.get(edge.target);
|
|
126
|
+
if (target === void 0) throw new UnknownCollectionError({
|
|
127
|
+
from: node.type,
|
|
128
|
+
relationship: name,
|
|
129
|
+
collection: edge.target
|
|
130
|
+
});
|
|
131
|
+
const inverse = target.edges[edge.inverse];
|
|
132
|
+
if (inverse === void 0 || inverse.kind !== "one" || inverse.target !== node.type) throw new UnknownCollectionError({
|
|
133
|
+
from: node.type,
|
|
134
|
+
relationship: `${name} (inverse "${edge.inverse}" is not a one-edge back to ${node.type})`,
|
|
135
|
+
collection: edge.target
|
|
136
|
+
});
|
|
137
|
+
return inverse.via;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* The identity attribute of an edge's target — resolved per-node, because
|
|
141
|
+
* the two ends of one edge may name different identities (`comments.author →
|
|
142
|
+
* users.username` while `comments.article → articles.slug`).
|
|
143
|
+
*/
|
|
144
|
+
const identityOf = (node, name, target, nodes) => {
|
|
145
|
+
const resolved = nodes.get(target);
|
|
146
|
+
if (resolved === void 0) throw new UnknownCollectionError({
|
|
147
|
+
from: node.type,
|
|
148
|
+
relationship: name,
|
|
149
|
+
collection: target
|
|
150
|
+
});
|
|
151
|
+
return resolved.id;
|
|
152
|
+
};
|
|
153
|
+
/** Derive the engine collection for one node (registry context required). */
|
|
154
|
+
const toCollection = (node, nodes) => {
|
|
155
|
+
const relationships = [];
|
|
156
|
+
Arr.forEach(Object.entries(node.edges), ([name, edge]) => {
|
|
157
|
+
if (edge.kind === "one") relationships.push({
|
|
158
|
+
name,
|
|
159
|
+
kind: "one",
|
|
160
|
+
collection: edge.target,
|
|
161
|
+
localField: edge.via,
|
|
162
|
+
foreignField: identityOf(node, name, edge.target, nodes)
|
|
163
|
+
});
|
|
164
|
+
else relationships.push({
|
|
165
|
+
name,
|
|
166
|
+
kind: edge.kind,
|
|
167
|
+
collection: edge.target,
|
|
168
|
+
localField: node.id,
|
|
169
|
+
foreignField: inverseVia(node, name, edge, nodes),
|
|
170
|
+
...edge.kind === "paginated" ? { order: parseOrder(edge.order) } : {}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
return make$3({
|
|
174
|
+
name: node.type,
|
|
175
|
+
schema: node.row,
|
|
176
|
+
id: (value) => value[node.id] ?? "",
|
|
177
|
+
identity: node.id,
|
|
178
|
+
relationships,
|
|
179
|
+
...Arr.isReadonlyArrayNonEmpty(node.indexes) ? { indexes: node.indexes } : {},
|
|
180
|
+
filterable: node.filterable,
|
|
181
|
+
sortable: node.sortable
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Every field of a node's row: its identity attribute, its attributes, its
|
|
186
|
+
* `one`-edge keys. A declared identity is one of the attributes already; an
|
|
187
|
+
* undeclared one is the synthetic `id`.
|
|
188
|
+
*/
|
|
189
|
+
const rowFields = (node) => {
|
|
190
|
+
const fields = /* @__PURE__ */ new Set([node.id, ...Object.keys(node.attributes)]);
|
|
191
|
+
Arr.forEach(Object.values(node.edges), (edge) => {
|
|
192
|
+
if (edge.kind === "one") fields.add(edge.via);
|
|
193
|
+
});
|
|
194
|
+
return fields;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* A paginated edge's declared `order` is what its per-parent windows sort
|
|
198
|
+
* the **target** by (ADR-0024), so those fields join the target's
|
|
199
|
+
* `sortable` here — declared on the node graph, so window registration and
|
|
200
|
+
* the filterable set cannot disagree (as `one`-edge keys are `eq` / `in`).
|
|
201
|
+
* An order field the target's row does not carry is a definition defect.
|
|
202
|
+
*/
|
|
203
|
+
const withEdgeOrders = (node, definitions) => {
|
|
204
|
+
const sortable = new Set(node.sortable);
|
|
205
|
+
const fields = rowFields(node);
|
|
206
|
+
Arr.forEach(definitions, (source) => Arr.forEach(Object.entries(source.edges), ([name, edge]) => {
|
|
207
|
+
if (edge.kind !== "paginated" || edge.target !== node.type) return;
|
|
208
|
+
Arr.forEach(parseOrder(edge.order), (term) => {
|
|
209
|
+
if (!fields.has(term.field)) throw new FilterDeclarationError({
|
|
210
|
+
collection: node.type,
|
|
211
|
+
field: term.field,
|
|
212
|
+
message: `Paginated edge "${name}" on ${source.type} orders "${node.type}" by "${term.field}", which is not a field of its row`
|
|
213
|
+
});
|
|
214
|
+
sortable.add(term.field);
|
|
215
|
+
});
|
|
216
|
+
}));
|
|
217
|
+
return sortable.size === node.sortable.length ? node : {
|
|
218
|
+
...node,
|
|
219
|
+
sortable: [...sortable]
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Close and validate the node graph: unique types, every edge target
|
|
224
|
+
* present, every collection-valued edge's `inverse` resolving to a `one`
|
|
225
|
+
* edge back to the declaring node, every paginated edge's order over fields
|
|
226
|
+
* its target carries. Returns the graph — each node's `sortable` widened by
|
|
227
|
+
* the paginated edges ordering it — plus the derived engine
|
|
228
|
+
* `CollectionRegistry`.
|
|
229
|
+
*/
|
|
230
|
+
const registry$1 = (definitions) => {
|
|
231
|
+
const declared = /* @__PURE__ */ new Map();
|
|
232
|
+
Arr.forEach(definitions, (node) => {
|
|
233
|
+
if (declared.has(node.type)) throw new DuplicateNameError({
|
|
234
|
+
kind: "collection",
|
|
235
|
+
name: node.type
|
|
236
|
+
});
|
|
237
|
+
declared.set(node.type, node);
|
|
238
|
+
});
|
|
239
|
+
Arr.forEach(definitions, (node) => {
|
|
240
|
+
Arr.forEach(Object.entries(node.edges), ([name, edge]) => {
|
|
241
|
+
if (!declared.has(edge.target)) throw new UnknownCollectionError({
|
|
242
|
+
from: node.type,
|
|
243
|
+
relationship: name,
|
|
244
|
+
collection: edge.target
|
|
245
|
+
});
|
|
246
|
+
if (edge.kind !== "one") inverseVia(node, name, edge, declared);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
250
|
+
Arr.forEach(definitions, (node) => {
|
|
251
|
+
nodes.set(node.type, withEdgeOrders(node, definitions));
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
nodes,
|
|
255
|
+
collections: registry$2([...nodes.values()].map((node) => toCollection(node, nodes)))
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
// src/Edge.ts
|
|
259
|
+
var Edge_exports = /* @__PURE__ */ __exportAll({
|
|
260
|
+
many: () => many,
|
|
261
|
+
one: () => one,
|
|
262
|
+
paginated: () => paginated
|
|
263
|
+
});
|
|
264
|
+
/** Declare a reference to a single `target` node, stored in field `via`. */
|
|
265
|
+
const one = (target, options) => ({
|
|
266
|
+
kind: "one",
|
|
267
|
+
target,
|
|
268
|
+
via: options.via
|
|
269
|
+
});
|
|
270
|
+
/** Declare the dependent collection of `target` nodes (unbounded). */
|
|
271
|
+
const many = (target, options) => ({
|
|
272
|
+
kind: "many",
|
|
273
|
+
target,
|
|
274
|
+
inverse: options.inverse
|
|
275
|
+
});
|
|
276
|
+
/** Declare the dependent collection of `target` nodes, paged by cursor. */
|
|
277
|
+
const paginated = (target, options) => ({
|
|
278
|
+
kind: "paginated",
|
|
279
|
+
target,
|
|
280
|
+
inverse: options.inverse,
|
|
281
|
+
order: options.order
|
|
282
|
+
});
|
|
283
|
+
// src/CrudMutation.ts
|
|
284
|
+
/**
|
|
285
|
+
* CrudMutation
|
|
286
|
+
*
|
|
287
|
+
* The **graph-derived CRUD mutations** (ADR-0030 §5): for every node a graph
|
|
288
|
+
* declares, a `{type}/create`, `{type}/update` and `{type}/delete`
|
|
289
|
+
* {@link Mutation}, whose args are in the **graph row form** — the flat row
|
|
290
|
+
* a node-graph replica stores, attributes and `one`-edge `via` keys side by
|
|
291
|
+
* side — with the client (optimistic) handler that writes it. They are what a
|
|
292
|
+
* plain write means when nobody said otherwise: the JSON:API façade's default
|
|
293
|
+
* intent mapping journals a reflected `POST` / `PATCH` / `DELETE` as one of
|
|
294
|
+
* them (`@thomasfosterau/legation-jsonapi`'s `JsonApiFacade`).
|
|
295
|
+
*
|
|
296
|
+
* Args, for a node `reviews` with an edge `product: Edge.one("products", { via: "productId" })`:
|
|
297
|
+
*
|
|
298
|
+
* - `reviews/create`: `{ type: "reviews", id, attributes }`, `attributes`
|
|
299
|
+
* holding the row's fields (`productId` among them). The handler puts the
|
|
300
|
+
* row `{ ...attributes, [node.id]: id }`.
|
|
301
|
+
* - `reviews/update`: the same shape, with only the fields being set. The
|
|
302
|
+
* handler merges them over the current row and is a **no-op when the row
|
|
303
|
+
* is absent**, so a patch journaled behind a create that the server later
|
|
304
|
+
* rejects leaves no phantom row behind (ADR-0030 §2).
|
|
305
|
+
* - `reviews/delete`: `{ type: "reviews", id }`. The handler deletes the row
|
|
306
|
+
* (a no-op when it is absent).
|
|
307
|
+
*
|
|
308
|
+
* The args schemas are permissive (`attributes` is a record of unknown
|
|
309
|
+
* values), so the encoded and decoded forms coincide and a JSON body can be
|
|
310
|
+
* journaled as decoded. Attribute values are not validated here: the server's
|
|
311
|
+
* handler under the same name is the authority (ADR-0008), and a value it
|
|
312
|
+
* refuses surfaces as a permanent rejection.
|
|
313
|
+
*
|
|
314
|
+
* **Not `Derive.ts`'s mutations.** `@thomasfosterau/legation-jsonapi`'s
|
|
315
|
+
* resource-derived CRUD mutators share these names but store a different row
|
|
316
|
+
* shape (`JsonApiRow`: attributes flat, `relationships` nested, for the
|
|
317
|
+
* client-direct stack with no node graph), and their `update` puts a partial
|
|
318
|
+
* row when the row is absent. An engine registers one family or the other,
|
|
319
|
+
* never both. These are for a replica built from a node graph.
|
|
320
|
+
*
|
|
321
|
+
* The server half is the app's: register a backend handler under each name
|
|
322
|
+
* the client registers, applying the app's own write rules.
|
|
323
|
+
*/
|
|
324
|
+
var CrudMutation_exports = /* @__PURE__ */ __exportAll({
|
|
325
|
+
DeleteArgs: () => DeleteArgs,
|
|
326
|
+
WriteArgs: () => WriteArgs,
|
|
327
|
+
entries: () => entries,
|
|
328
|
+
name: () => name,
|
|
329
|
+
registry: () => registry
|
|
330
|
+
});
|
|
331
|
+
/** The mutation name for a type and an operation: `"{type}/{operation}"`. */
|
|
332
|
+
const name = (type, operation) => `${type}/${operation}`;
|
|
333
|
+
/** The args of a `{type}/create` or `{type}/update`: the row's fields as a record, keyed by its id. */
|
|
334
|
+
const WriteArgs = Schema.Struct({
|
|
335
|
+
type: Schema.String,
|
|
336
|
+
id: Schema.String,
|
|
337
|
+
attributes: Schema.Record(Schema.String, Schema.Unknown)
|
|
338
|
+
});
|
|
339
|
+
/** The args of a `{type}/delete`. */
|
|
340
|
+
const DeleteArgs = Schema.Struct({
|
|
341
|
+
type: Schema.String,
|
|
342
|
+
id: Schema.String
|
|
343
|
+
});
|
|
344
|
+
const derivesFor = (writes, type, operation) => writes === void 0 || (writes[type] ?? []).includes(operation);
|
|
345
|
+
/** The derived mutations of one node, filtered by `writes`. */
|
|
346
|
+
const nodeEntries = (node, writes) => {
|
|
347
|
+
const { type } = node;
|
|
348
|
+
const identity = node.id;
|
|
349
|
+
const entries = [];
|
|
350
|
+
if (derivesFor(writes, type, "create")) entries.push({
|
|
351
|
+
type,
|
|
352
|
+
operation: "create",
|
|
353
|
+
mutator: make$4(name(type, "create"), WriteArgs),
|
|
354
|
+
local: (args, writer) => writer.put(type, args.id, {
|
|
355
|
+
...args.attributes,
|
|
356
|
+
[identity]: args.id
|
|
357
|
+
})
|
|
358
|
+
});
|
|
359
|
+
if (derivesFor(writes, type, "update")) entries.push({
|
|
360
|
+
type,
|
|
361
|
+
operation: "update",
|
|
362
|
+
mutator: make$4(name(type, "update"), WriteArgs),
|
|
363
|
+
local: (args, writer) => Effect.flatMap(writer.get(type, args.id), (current) => current === void 0 ? Effect.void : writer.put(type, args.id, {
|
|
364
|
+
...current,
|
|
365
|
+
...args.attributes,
|
|
366
|
+
[identity]: args.id
|
|
367
|
+
}))
|
|
368
|
+
});
|
|
369
|
+
if (derivesFor(writes, type, "delete")) entries.push({
|
|
370
|
+
type,
|
|
371
|
+
operation: "delete",
|
|
372
|
+
mutator: make$4(name(type, "delete"), DeleteArgs),
|
|
373
|
+
local: (args, writer) => writer.del(type, args.id)
|
|
374
|
+
});
|
|
375
|
+
return entries;
|
|
376
|
+
};
|
|
377
|
+
/**
|
|
378
|
+
* The graph-derived CRUD mutations of every node in the graph (those `writes`
|
|
379
|
+
* selects), as registry entries an app spreads beside its own named
|
|
380
|
+
* mutations: `Mutation.registry([...mine, ...CrudMutation.entries(nodes)])`.
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* import * as Schema from "effect/Schema";
|
|
385
|
+
* import { CrudMutation, Edge, Mutation, Node } from "@thomasfosterau/legation";
|
|
386
|
+
*
|
|
387
|
+
* const products = Node.make("products", { attributes: { name: Schema.String } });
|
|
388
|
+
* const reviews = Node.make("reviews", {
|
|
389
|
+
* attributes: { rating: Schema.Number },
|
|
390
|
+
* edges: { product: Edge.one("products", { via: "productId" }) },
|
|
391
|
+
* });
|
|
392
|
+
* const nodes = Node.registry([products, reviews]);
|
|
393
|
+
*
|
|
394
|
+
* const registry = Mutation.registry(
|
|
395
|
+
* CrudMutation.entries(nodes, { writes: { reviews: ["create", "delete"] } })
|
|
396
|
+
* );
|
|
397
|
+
* console.log([...registry.mutators.keys()]); // ["reviews/create", "reviews/delete"]
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
400
|
+
const entries = (nodes, options) => [...nodes.nodes.values()].flatMap((node) => nodeEntries(node, options?.writes));
|
|
401
|
+
/** The graph-derived CRUD mutations as a registry of their own. Duplicate names cannot occur. */
|
|
402
|
+
const registry = (nodes, options) => registry$3(entries(nodes, options));
|
|
403
|
+
// src/Client.ts
|
|
404
|
+
/**
|
|
405
|
+
* Client
|
|
406
|
+
*
|
|
407
|
+
* The one-per-thread object (ADR-0022): it runs many {@link Query} reads
|
|
408
|
+
* and mutations, and **where a read is answered is configuration, never a
|
|
409
|
+
* concept the UI sees**:
|
|
410
|
+
*
|
|
411
|
+
* - {@link make} owns the engine in this thread (store + transport come
|
|
412
|
+
* from context; the sync loop starts automatically — the class of bug
|
|
413
|
+
* where an app forgets `runSync` ends here). Starting the loop is **not**
|
|
414
|
+
* the same as auto-pushing, though: on its own it only wires the baseline
|
|
415
|
+
* triggers (pull on poke, push+pull on reconnect), so a mutation journaled
|
|
416
|
+
* on an already-connected client sits until one of those fires or the app
|
|
417
|
+
* calls `push` explicitly. Auto-push is `syncLoop.pushDebounce` — opt in
|
|
418
|
+
* with it if writes should reach the server without anything else
|
|
419
|
+
* happening first (the examples use `"300 millis"`).
|
|
420
|
+
* - {@link connect} joins an engine hosted elsewhere — a service worker,
|
|
421
|
+
* an Electron main process, a daemon — over a `MirrorChannel`; the
|
|
422
|
+
* machinery behind it (the mirror) is internal vocabulary.
|
|
423
|
+
*
|
|
424
|
+
* Reads are declarative ({@link Query.Read}); **observing subscribes**: a
|
|
425
|
+
* live read registers its windows for the stream's lifetime through a
|
|
426
|
+
* per-window ledger (one voice per window, however many observers), and
|
|
427
|
+
* `subscribe` pins a read's windows — root and, with `include`, edge —
|
|
428
|
+
* beyond any observer. A subscription can also be named by its JSON:API
|
|
429
|
+
* URL (`subscribe(url)`, ADR-0029 section 2) when the Client is built with
|
|
430
|
+
* {@link UrlReads}; the canonical URL is then the subscription's identity
|
|
431
|
+
* for devtools and the façade. Includes resolve through the view's
|
|
432
|
+
* auto-subscribing relational read — host-side on a connected engine,
|
|
433
|
+
* locally on an owned one — with the plan's edge windows applied.
|
|
434
|
+
*
|
|
435
|
+
* **Reads have three tiers and the UI sees none of them** (ADR-0025):
|
|
436
|
+
* `query` answers from the replica when a subscription in this Client's
|
|
437
|
+
* ledger **covers** the read — conjunct subsumption with strict windows
|
|
438
|
+
* (ADR-0029 section 3, `Coverage`); otherwise it falls through to the server — point-in-time,
|
|
439
|
+
* auth-filtered, no CVR, results never entering the replica — degrading
|
|
440
|
+
* back to the local answer when no transport read is available or the
|
|
441
|
+
* server is unreachable (offline-first: local is always an answer).
|
|
442
|
+
* `observe` never falls through: observing subscribes, which IS the
|
|
443
|
+
* upgrade from a one-shot fallthrough to a live, replica-backed read.
|
|
444
|
+
*/
|
|
445
|
+
var Client_exports = /* @__PURE__ */ __exportAll({
|
|
446
|
+
Client: () => Client,
|
|
447
|
+
connect: () => connect,
|
|
448
|
+
fromView: () => fromView,
|
|
449
|
+
make: () => make$1
|
|
450
|
+
});
|
|
451
|
+
/** One more unit of `kind` on a count that tracks `tracked`. */
|
|
452
|
+
const oneMore = (count, kind, tracked) => (count ?? 0) + (kind === tracked ? 1 : 0);
|
|
453
|
+
/** One unit of `kind` fewer on a count that tracks `tracked`, never below zero. */
|
|
454
|
+
const oneFewer = (count, kind, tracked) => Math.max(0, count - (kind === tracked ? 1 : 0));
|
|
455
|
+
/** Nobody holds the entry any more. */
|
|
456
|
+
const unheld = (interest) => interest.observers === 0 && interest.pins === 0;
|
|
457
|
+
/**
|
|
458
|
+
* An entry with one unit of `kind` fewer. The canonical URL is a pin's
|
|
459
|
+
* identity, so it goes with the last pin even while observers keep the
|
|
460
|
+
* entry: {@link Client.coverageOf} names only what {@link Client.subscriptions}
|
|
461
|
+
* lists.
|
|
462
|
+
*/
|
|
463
|
+
const oneFewerOn = (current, kind) => {
|
|
464
|
+
const { url, ...rest } = current;
|
|
465
|
+
const pins = oneFewer(current.pins, kind, "explicit");
|
|
466
|
+
return {
|
|
467
|
+
...rest,
|
|
468
|
+
observers: oneFewer(current.observers, kind, "observer"),
|
|
469
|
+
pins,
|
|
470
|
+
...pins > 0 && url !== void 0 ? { url } : {}
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The ledger key of a registration: the root window's hash alone, or with
|
|
475
|
+
* its edge selects — the same key the mirror protocol gives a relational
|
|
476
|
+
* registration — so a read with `include` is its own entry beside the
|
|
477
|
+
* plain root, and each entry is what coverage compares against.
|
|
478
|
+
*/
|
|
479
|
+
const keyOf = (registration) => Arr.isReadonlyArrayEmpty(registration.edges) ? hash(registration.root) : relatedKey(registration.root.collection, registration.root, registration.edges);
|
|
480
|
+
const noUrlReads = (url) => new QueryError({
|
|
481
|
+
message: "Client.subscribe(url): this Client has no URL reads; build it with `urls` (`JsonApiQuery.urlReads(nodes)` from @thomasfosterau/legation-jsonapi) to subscribe by URL",
|
|
482
|
+
query: url
|
|
483
|
+
});
|
|
484
|
+
/**
|
|
485
|
+
* Build a Client over any view. The window ledger lives here, so however
|
|
486
|
+
* many observers a window has — and however the view refcounts — the
|
|
487
|
+
* Client speaks with one voice per window: subscribe on first interest,
|
|
488
|
+
* unsubscribe when the last observer ends and no explicit pin remains.
|
|
489
|
+
*/
|
|
490
|
+
const fromView = (nodes, view, options = {}) => {
|
|
491
|
+
const urls = options.urls;
|
|
492
|
+
const ledger = Ref.makeUnsafe(/* @__PURE__ */ new Map());
|
|
493
|
+
const roots = Ref.makeUnsafe(/* @__PURE__ */ new Map());
|
|
494
|
+
const edgeScopes = /* @__PURE__ */ new Map();
|
|
495
|
+
const lock = Semaphore.makeUnsafe(1);
|
|
496
|
+
const holdRoot = (root) => Ref.modify(roots, (map) => {
|
|
497
|
+
const key = hash(root);
|
|
498
|
+
const count = map.get(key) ?? 0;
|
|
499
|
+
return [count === 0, new Map(map).set(key, count + 1)];
|
|
500
|
+
}).pipe(Effect.flatMap((first) => first ? view.subscribe(root).pipe(Effect.orDie) : Effect.void));
|
|
501
|
+
const dropRoot = (root) => Ref.modify(roots, (map) => {
|
|
502
|
+
const key = hash(root);
|
|
503
|
+
const count = map.get(key) ?? 0;
|
|
504
|
+
const next = new Map(map);
|
|
505
|
+
if (count <= 1) next.delete(key);
|
|
506
|
+
else next.set(key, count - 1);
|
|
507
|
+
return [count === 1, next];
|
|
508
|
+
}).pipe(Effect.flatMap((last) => last ? view.unsubscribe(root).pipe(Effect.ignore) : Effect.void));
|
|
509
|
+
const openEdges = (key, registration) => Effect.gen(function* () {
|
|
510
|
+
const scope = yield* Scope.make();
|
|
511
|
+
const { root, edges } = registration;
|
|
512
|
+
yield* view.pinEdges(nodes.collections, root.collection, root, edges).pipe(Scope.provide(scope));
|
|
513
|
+
edgeScopes.set(key, scope);
|
|
514
|
+
});
|
|
515
|
+
const closeEdges = (key) => Effect.suspend(() => {
|
|
516
|
+
const scope = edgeScopes.get(key);
|
|
517
|
+
edgeScopes.delete(key);
|
|
518
|
+
return scope === void 0 ? Effect.void : Scope.close(scope, Exit.void);
|
|
519
|
+
});
|
|
520
|
+
const register = (key, registration) => holdRoot(registration.root).pipe(Effect.andThen(Arr.isReadonlyArrayEmpty(registration.edges) ? Effect.void : openEdges(key, registration)));
|
|
521
|
+
const unregister = (key, registration) => closeEdges(key).pipe(Effect.andThen(dropRoot(registration.root)));
|
|
522
|
+
/** Add one unit of interest; registers with the view when it is the first. */
|
|
523
|
+
const acquire = (registration, kind, url) => Semaphore.withPermit(lock, acquireUnlocked(registration, kind, url));
|
|
524
|
+
const acquireUnlocked = (registration, kind, url) => Ref.modify(ledger, (map) => {
|
|
525
|
+
const key = keyOf(registration);
|
|
526
|
+
const current = map.get(key);
|
|
527
|
+
const known = url ?? current?.url;
|
|
528
|
+
const next = new Map(map).set(key, {
|
|
529
|
+
root: registration.root,
|
|
530
|
+
edges: registration.edges,
|
|
531
|
+
observers: oneMore(current?.observers, kind, "observer"),
|
|
532
|
+
pins: oneMore(current?.pins, kind, "explicit"),
|
|
533
|
+
...known === void 0 ? {} : { url: known }
|
|
534
|
+
});
|
|
535
|
+
return [current === void 0 ? key : void 0, next];
|
|
536
|
+
}).pipe(Effect.flatMap((key) => key === void 0 ? Effect.void : register(key, registration)));
|
|
537
|
+
/** Drop one unit of interest; releases from the view when it was the last. */
|
|
538
|
+
const release = (registration, kind) => Semaphore.withPermit(lock, releaseUnlocked(registration, kind));
|
|
539
|
+
const releaseUnlocked = (registration, kind) => Ref.modify(ledger, (map) => {
|
|
540
|
+
const key = keyOf(registration);
|
|
541
|
+
const current = map.get(key);
|
|
542
|
+
if (current === void 0) return [void 0, map];
|
|
543
|
+
const updated = oneFewerOn(current, kind);
|
|
544
|
+
const next = new Map(map);
|
|
545
|
+
if (unheld(updated)) {
|
|
546
|
+
next.delete(key);
|
|
547
|
+
return [key, next];
|
|
548
|
+
}
|
|
549
|
+
next.set(key, updated);
|
|
550
|
+
return [void 0, next];
|
|
551
|
+
}).pipe(Effect.flatMap((key) => key === void 0 ? Effect.void : unregister(key, registration)));
|
|
552
|
+
/**
|
|
553
|
+
* Drop every registration this Client holds, releasing each through the
|
|
554
|
+
* view exactly as the last observer or pin would. A reset's subscriptions
|
|
555
|
+
* are as much the superseded identity's as its rows: a ledger entry left
|
|
556
|
+
* behind would keep `coverageOf` claiming the new principal's reads are
|
|
557
|
+
* answerable locally, and would re-pull the old windows on the next sync.
|
|
558
|
+
* Roots are deduped the way {@link dropRoot} counts them, so the view hears
|
|
559
|
+
* one release per window however many entries shared it.
|
|
560
|
+
*/
|
|
561
|
+
const clearLedger = Semaphore.withPermit(lock, Effect.gen(function* () {
|
|
562
|
+
const entries = yield* Ref.getAndSet(ledger, /* @__PURE__ */ new Map());
|
|
563
|
+
yield* Ref.set(roots, /* @__PURE__ */ new Map());
|
|
564
|
+
const released = /* @__PURE__ */ new Set();
|
|
565
|
+
yield* Effect.forEach(entries, ([key, interest]) => Effect.gen(function* () {
|
|
566
|
+
yield* closeEdges(key);
|
|
567
|
+
const root = hash(interest.root);
|
|
568
|
+
if (released.has(root)) return;
|
|
569
|
+
released.add(root);
|
|
570
|
+
yield* view.unsubscribe(interest.root).pipe(Effect.ignore);
|
|
571
|
+
}), {
|
|
572
|
+
concurrency: 1,
|
|
573
|
+
discard: true
|
|
574
|
+
});
|
|
575
|
+
}));
|
|
576
|
+
const noBoundary = () => Effect.fail(new StoreError({ message: "Client.reset: this Client is connected to an engine hosted elsewhere (Client.connect), which owns the durable replica. Reset the Client that owns the engine — in the service worker or other host — instead." }));
|
|
577
|
+
const boundary = view.identityBoundary;
|
|
578
|
+
const reset = (identity) => boundary === void 0 ? noBoundary() : Effect.gen(function* () {
|
|
579
|
+
if (transitionOf((yield* boundary.current) ?? "@legation/unstamped", identity) === "Reset") yield* clearLedger;
|
|
580
|
+
return yield* boundary.reset(identity);
|
|
581
|
+
});
|
|
582
|
+
const targetOfUrl = (url) => urls === void 0 ? Effect.fail(noUrlReads(url)) : Effect.gen(function* () {
|
|
583
|
+
const read = yield* urls.parse(url);
|
|
584
|
+
const plan = yield* tryPlan(nodes, read);
|
|
585
|
+
return {
|
|
586
|
+
registration: ofPlan(plan),
|
|
587
|
+
url: yield* urls.canonical(read)
|
|
588
|
+
};
|
|
589
|
+
});
|
|
590
|
+
const targetOfRead = (read) => Effect.gen(function* () {
|
|
591
|
+
const plan = yield* tryPlan(nodes, read);
|
|
592
|
+
const url = urls === void 0 ? void 0 : Option.getOrUndefined(yield* Effect.option(urls.canonical(read)));
|
|
593
|
+
return {
|
|
594
|
+
registration: ofPlan(plan),
|
|
595
|
+
url
|
|
596
|
+
};
|
|
597
|
+
});
|
|
598
|
+
const targetOf = (input) => P.isString(input) ? targetOfUrl(input) : targetOfRead(input);
|
|
599
|
+
const verdictOf$1 = (plan, rowsOf = view.query) => requestedOf(nodes.collections, plan, rowsOf).pipe(Effect.flatMap((requested) => Ref.get(ledger).pipe(Effect.flatMap((interests) => Effect.forEach(interests.values(), (interest) => view.pulled(keyOf(interest)).pipe(Effect.map((pulled) => ({
|
|
600
|
+
...interest,
|
|
601
|
+
pulled
|
|
602
|
+
}))))), Effect.map((entries) => verdictOf(entries, requested)))), Effect.catchTag([
|
|
603
|
+
"MutatorError",
|
|
604
|
+
"StoreError",
|
|
605
|
+
"TransportError",
|
|
606
|
+
"QueryError"
|
|
607
|
+
], () => Effect.succeed({ _tag: "Uncovered" })));
|
|
608
|
+
const queryOnce = (read) => {
|
|
609
|
+
const readThrough = view.read;
|
|
610
|
+
if (readThrough === void 0) return run(view, nodes, read);
|
|
611
|
+
const compiled = plan(nodes, read);
|
|
612
|
+
const local = Effect.suspend(() => run(view, nodes, read));
|
|
613
|
+
return view.query(compiled.root).pipe(Effect.orElseSucceed(() => void 0), Effect.flatMap((snapshot) => snapshot === void 0 ? runPlanVia(readThrough, nodes, compiled).pipe(Effect.catch(() => local)) : verdictOf$1(compiled, snapshotOf({
|
|
614
|
+
subset: compiled.root,
|
|
615
|
+
rows: snapshot
|
|
616
|
+
}, view.query)).pipe(Effect.flatMap((verdict) => verdict._tag === "Covered" ? runRows(view, nodes, read, snapshot) : runPlanVia(readThrough, nodes, compiled).pipe(Effect.catch(() => local))))));
|
|
617
|
+
};
|
|
618
|
+
const observe = (read) => {
|
|
619
|
+
const compiled = plan(nodes, read);
|
|
620
|
+
if (Arr.isReadonlyArrayEmpty(compiled.edges)) {
|
|
621
|
+
const registration = ofPlan(compiled);
|
|
622
|
+
return Stream.unwrap(Effect.acquireRelease(acquire(registration, "observer"), () => release(registration, "observer")).pipe(Effect.as(view.observe(compiled.root).pipe(Stream.orDie, Stream.map((rows) => rows.map((row) => ({
|
|
623
|
+
...row,
|
|
624
|
+
related: {}
|
|
625
|
+
})))))));
|
|
626
|
+
}
|
|
627
|
+
return Stream.unwrap(view.related(nodes.collections, read.collection, compiled.root, edgeSelects(compiled)).pipe(Effect.map((stream) => stream.pipe(Stream.map((rows) => applyWindows(compiled, rows))))));
|
|
628
|
+
};
|
|
629
|
+
return {
|
|
630
|
+
nodes,
|
|
631
|
+
query: queryOnce,
|
|
632
|
+
observe,
|
|
633
|
+
subscribe: (input) => targetOf(input).pipe(Effect.flatMap(({ registration, url }) => acquire(registration, "explicit", url))),
|
|
634
|
+
unsubscribe: (input) => targetOf(input).pipe(Effect.flatMap(({ registration }) => release(registration, "explicit"))),
|
|
635
|
+
subscriptions: Ref.get(ledger).pipe(Effect.map((interests) => [...interests.values()].flatMap((entry) => entry.pins > 0 && entry.url !== void 0 ? [entry.url] : []))),
|
|
636
|
+
coverageOf: (read, options) => {
|
|
637
|
+
const plan$1 = plan(nodes, read);
|
|
638
|
+
return verdictOf$1(plan$1, options?.snapshot === void 0 ? view.query : snapshotOf(options.snapshot, view.query));
|
|
639
|
+
},
|
|
640
|
+
reset,
|
|
641
|
+
identity: boundary === void 0 ? noBoundary() : boundary.current,
|
|
642
|
+
quarantines: boundary === void 0 ? noBoundary() : boundary.quarantines,
|
|
643
|
+
quarantinedMutations: (identity) => boundary === void 0 ? noBoundary() : boundary.quarantinedMutations(identity),
|
|
644
|
+
discardQuarantine: (identity) => boundary === void 0 ? noBoundary() : boundary.discardQuarantine(identity),
|
|
645
|
+
mutate: (mutation, args) => view.mutate(mutation, args),
|
|
646
|
+
mutateWithAck: (mutation, args) => view.mutateWithAck(mutation, args),
|
|
647
|
+
beginFormMutation: (form, mutation, args) => view.beginFormMutation === void 0 ? Effect.fail(new TransportError({ message: "Client.beginFormMutation: this view carries no enhanced form submit (it has neither an engine nor a host to ask)" })) : view.beginFormMutation(form, mutation, args),
|
|
648
|
+
mutationStates: view.mutationStates,
|
|
649
|
+
view
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* The Client as a service — for runtimes and bindings that carry "the one
|
|
654
|
+
* per-thread object" in context (ADR-0022).
|
|
655
|
+
*/
|
|
656
|
+
const Client = Context.Service("@thomasfosterau/legation/Client");
|
|
657
|
+
/** The view options of a larger option set, absent keys left absent. */
|
|
658
|
+
const viewOptions = (options) => options.urls === void 0 ? {} : { urls: options.urls };
|
|
659
|
+
/** The `indexes` declared on each of `nodes`' collections, by collection name. */
|
|
660
|
+
const declaredIndexes = (nodes) => {
|
|
661
|
+
const indexes = {};
|
|
662
|
+
Arr.forEach(nodes.collections, ([name, collection]) => {
|
|
663
|
+
if (collection.indexes !== void 0 && Arr.isReadonlyArrayNonEmpty(collection.indexes)) indexes[name] = collection.indexes;
|
|
664
|
+
});
|
|
665
|
+
return indexes;
|
|
666
|
+
};
|
|
667
|
+
/**
|
|
668
|
+
* A Client that owns the engine in this thread. Zero wiring works:
|
|
669
|
+
* `Client.make({ nodes, mutations })` is a purely local Client over an
|
|
670
|
+
* in-memory store. Add `transport: { url }` for the standard remote setup,
|
|
671
|
+
* or pass/provide full `ClientStore`/`ClientTransport` implementations for
|
|
672
|
+
* anything else — options win over context, context over defaults.
|
|
673
|
+
*
|
|
674
|
+
* Returns the Client with the engine and store it built ({@link Made}), so
|
|
675
|
+
* an app that needs an engine-level capability (devtools) need not build the
|
|
676
|
+
* engine by hand.
|
|
677
|
+
*/
|
|
678
|
+
const make$1 = (options) => Effect.gen(function* () {
|
|
679
|
+
const store = options.store ?? Option.getOrUndefined(yield* Effect.serviceOption(ClientStore)) ?? (yield* makeMemory);
|
|
680
|
+
const clientGroupId = options.clientGroupId ?? `group-${crypto.randomUUID()}`;
|
|
681
|
+
const configured = options.transport !== void 0 && "url" in options.transport ? yield* fromOrigin({
|
|
682
|
+
origin: options.transport.url,
|
|
683
|
+
clientGroupId
|
|
684
|
+
}) : options.transport;
|
|
685
|
+
const contextual = Option.getOrUndefined(yield* Effect.serviceOption(ClientTransport));
|
|
686
|
+
const transport = configured ?? contextual ?? offline;
|
|
687
|
+
const sync = options.sync ?? (configured ?? contextual) !== void 0;
|
|
688
|
+
const indexes = declaredIndexes(options.nodes);
|
|
689
|
+
const engine = yield* make$5({
|
|
690
|
+
clientId: options.clientId ?? `client-${crypto.randomUUID()}`,
|
|
691
|
+
clientGroupId,
|
|
692
|
+
mutators: options.mutations,
|
|
693
|
+
...options.queries === void 0 ? {} : { queries: options.queries },
|
|
694
|
+
...options.syncLoop === void 0 ? {} : { syncLoop: options.syncLoop },
|
|
695
|
+
...!R.isEmptyReadonlyRecord(indexes) ? { indexes } : {},
|
|
696
|
+
...options.autoIndex === void 0 ? {} : { autoIndex: options.autoIndex }
|
|
697
|
+
}).pipe(Effect.provideService(ClientStore, store), Effect.provideService(ClientTransport, transport));
|
|
698
|
+
if (options.identity !== void 0) yield* engine.reset(options.identity).pipe(Effect.orDie);
|
|
699
|
+
if (sync) yield* Effect.forkScoped(engine.runSync.pipe(Effect.orDie));
|
|
700
|
+
return {
|
|
701
|
+
client: fromView(options.nodes, fromEngine(engine), viewOptions(options)),
|
|
702
|
+
engine,
|
|
703
|
+
store
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
/**
|
|
707
|
+
* A Client over an engine hosted elsewhere (service worker, Electron main,
|
|
708
|
+
* a daemon), joined over a `MirrorChannel`. Reads and writes cross the
|
|
709
|
+
* channel; relational reads resolve host-side.
|
|
710
|
+
*/
|
|
711
|
+
const connect = (options) => Effect.gen(function* () {
|
|
712
|
+
const mirror = yield* make$6({
|
|
713
|
+
channel: options.channel,
|
|
714
|
+
mutators: options.mutations
|
|
715
|
+
});
|
|
716
|
+
return fromView(options.nodes, mirror, viewOptions(options));
|
|
717
|
+
});
|
|
718
|
+
// src/Hydration.ts
|
|
719
|
+
/**
|
|
720
|
+
* Hydration
|
|
721
|
+
*
|
|
722
|
+
* Move a client's durable state across a serialisation boundary: `dehydrate`
|
|
723
|
+
* reads a {@link ClientStore} into a plain {@link Snapshot}; `hydrate` writes a
|
|
724
|
+
* snapshot back into a store. Between the two the snapshot is JSON — the value
|
|
725
|
+
* an SSR response embeds, a devtools export writes to disk, or a client hands to a
|
|
726
|
+
* worker.
|
|
727
|
+
*
|
|
728
|
+
* ## SSR flow
|
|
729
|
+
*
|
|
730
|
+
* The server renders a page over a *real* engine per request: an in-memory
|
|
731
|
+
* {@link ClientStore} plus a {@link ClientTransport} to the sync server. It
|
|
732
|
+
* `subscribe`s the subsets the page needs, `pull`s once, then `dehydrate`s the
|
|
733
|
+
* store — capturing the confirmed rows, the pull cookie, and any journalled
|
|
734
|
+
* mutations. That snapshot ships to the browser (JSON-encoded in the document).
|
|
735
|
+
* The client builds its durable store, `hydrate`s the snapshot into it, and
|
|
736
|
+
* constructs the engine over that store. The first render already has rows (no
|
|
737
|
+
* loading flash), and because the cookie came across too the client's first
|
|
738
|
+
* pull continues the CVR conversation from where the server left off — no
|
|
739
|
+
* refetch of state the server already sent.
|
|
740
|
+
*
|
|
741
|
+
* The transport is the seam that makes this work off-process. Pull is the
|
|
742
|
+
* correctness path, so subscribe → pull → dehydrate behaves identically
|
|
743
|
+
* against a remote Server: the renderer need not hold the backend. A
|
|
744
|
+
* single-process app passes nothing and gets a loop-back transport over the
|
|
745
|
+
* in-context {@link ServerHandler}; a split deployment (a SvelteKit Worker
|
|
746
|
+
* rendering against a separate API Worker) passes its own carrier.
|
|
747
|
+
*
|
|
748
|
+
* ## Seeded-versions caveat
|
|
749
|
+
*
|
|
750
|
+
* A snapshot carries each row's authoritative `version`. Those versions must be
|
|
751
|
+
* genuine backend versions for CVR diffing to converge cheaply on the next
|
|
752
|
+
* pull: the server compares them against its record and sends only the delta.
|
|
753
|
+
* Self-invented versions are *safe* — they never corrupt state — but they will
|
|
754
|
+
* not match the server's CVR, so the next pull degrades to the reset path (the
|
|
755
|
+
* server sends a `Clear` followed by full puts). The same caveat applies to
|
|
756
|
+
* {@link ClientEngine.seed}.
|
|
757
|
+
*
|
|
758
|
+
* ## Validation
|
|
759
|
+
*
|
|
760
|
+
* `hydrate` decodes the snapshot through {@link Snapshot} before writing,
|
|
761
|
+
* so a `dehydrate → JSON.stringify → JSON.parse → hydrate` round-trip is
|
|
762
|
+
* schema-checked end to end; a malformed snapshot fails as a `StoreError`.
|
|
763
|
+
* Only the *envelopes* are validated — row values and mutation args stay
|
|
764
|
+
* `Unknown` and round-trip untouched (ADR-0014), exactly like the store's own
|
|
765
|
+
* persisted codecs. Use {@link SnapshotFromJson} to fold the JSON step into the
|
|
766
|
+
* decode.
|
|
767
|
+
*/
|
|
768
|
+
var Hydration_exports = /* @__PURE__ */ __exportAll({
|
|
769
|
+
Snapshot: () => Snapshot,
|
|
770
|
+
SnapshotFromJson: () => SnapshotFromJson,
|
|
771
|
+
SnapshotRow: () => SnapshotRow,
|
|
772
|
+
dehydrate: () => dehydrate,
|
|
773
|
+
hydrate: () => hydrate,
|
|
774
|
+
hydrateIfEmpty: () => hydrateIfEmpty,
|
|
775
|
+
isEmpty: () => isEmpty,
|
|
776
|
+
rowsFor: () => rowsFor,
|
|
777
|
+
snapshot: () => snapshot
|
|
778
|
+
});
|
|
779
|
+
/**
|
|
780
|
+
* One dehydrated row: a {@link StoredRow} tagged with the collection it belongs
|
|
781
|
+
* to, so the flat `rows` array reconstructs the per-collection layout on
|
|
782
|
+
* hydrate. `value` stays `Unknown` (envelope-validated only, ADR-0014).
|
|
783
|
+
*/
|
|
784
|
+
const SnapshotRow = Schema.Struct({
|
|
785
|
+
collection: Schema.String,
|
|
786
|
+
...StoredRow.fields
|
|
787
|
+
});
|
|
788
|
+
/**
|
|
789
|
+
* A serialisable capture of a {@link ClientStore}: confirmed rows (flattened
|
|
790
|
+
* with their collection), the pull cookie, the pending-mutation journal, the
|
|
791
|
+
* acknowledged-but-unpulled tail (#336), and each client's mutation id counter
|
|
792
|
+
* (#271). The tail and the counters are optional so a snapshot taken before
|
|
793
|
+
* they existed still loads, as an empty tail and no counters. The schema is
|
|
794
|
+
* the source of truth; {@link Snapshot} is derived from it.
|
|
795
|
+
*
|
|
796
|
+
* The counters are what let a client id be reused across the hydration
|
|
797
|
+
* boundary: once acknowledgements have drained the journal, they are the only
|
|
798
|
+
* record of the ids the backend has already applied, and a hydrated store
|
|
799
|
+
* without them would issue those ids again, which the backend skips as
|
|
800
|
+
* duplicates (a silent lost write).
|
|
801
|
+
*/
|
|
802
|
+
const Snapshot = Schema.Struct({
|
|
803
|
+
rows: Schema.Array(SnapshotRow),
|
|
804
|
+
cookie: Schema.optional(Schema.String),
|
|
805
|
+
journal: Schema.Array(Mutation),
|
|
806
|
+
unpulled: Schema.optional(Schema.Array(Mutation)),
|
|
807
|
+
counters: Schema.optional(Schema.Record(Schema.String, Schema.Number.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)))))
|
|
808
|
+
});
|
|
809
|
+
/**
|
|
810
|
+
* JSON codec for a {@link Snapshot}: `decode` parses a JSON string and validates
|
|
811
|
+
* it in one step (SSR/devtools ingest), `encode` produces the JSON string.
|
|
812
|
+
*/
|
|
813
|
+
const SnapshotFromJson = Schema.fromJsonString(Snapshot);
|
|
814
|
+
const storeError = (message) => (cause) => new StoreError({
|
|
815
|
+
message,
|
|
816
|
+
cause
|
|
817
|
+
});
|
|
818
|
+
/**
|
|
819
|
+
* The replica read member by member: the path for a store without `snapshot`. Its reads are not one
|
|
820
|
+
* state, so a pull or a push acknowledgement can land between them (#392).
|
|
821
|
+
*/
|
|
822
|
+
const readMembers = (store) => Effect.gen(function* () {
|
|
823
|
+
const rows = [];
|
|
824
|
+
yield* Effect.forEach(yield* store.listCollections, (collection) => Effect.gen(function* () {
|
|
825
|
+
Arr.forEach(yield* store.listRows(collection), (row) => {
|
|
826
|
+
rows.push({
|
|
827
|
+
collection,
|
|
828
|
+
row
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
}), {
|
|
832
|
+
concurrency: 1,
|
|
833
|
+
discard: true
|
|
834
|
+
});
|
|
835
|
+
return {
|
|
836
|
+
rows,
|
|
837
|
+
cookie: yield* store.getCookie,
|
|
838
|
+
journal: yield* store.pendingMutations,
|
|
839
|
+
unpulled: yield* store.unpulled,
|
|
840
|
+
counters: yield* store.mutationCounters
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
/**
|
|
844
|
+
* Read a {@link ClientStore} into a {@link Snapshot}: enumerate the collections,
|
|
845
|
+
* gather their confirmed rows (flattened with the collection), the cookie, the
|
|
846
|
+
* journal, the unpulled tail and the mutation counters. The tail and the
|
|
847
|
+
* counters are each left out when empty, so a snapshot without them keeps its
|
|
848
|
+
* previous shape.
|
|
849
|
+
*
|
|
850
|
+
* The five parts are one state when the store offers {@link ClientStore.snapshot}
|
|
851
|
+
* (the memory and key-value stores do): a pull or a push acknowledgement lands wholly
|
|
852
|
+
* before or wholly after the read (#392). A store without it is read member by member.
|
|
853
|
+
*/
|
|
854
|
+
const dehydrate = (store) => Effect.gen(function* () {
|
|
855
|
+
const state = yield* store.snapshot ?? readMembers(store);
|
|
856
|
+
const base = {
|
|
857
|
+
rows: state.rows.map(({ collection, row }) => ({
|
|
858
|
+
collection,
|
|
859
|
+
id: row.id,
|
|
860
|
+
version: row.version,
|
|
861
|
+
value: row.value
|
|
862
|
+
})),
|
|
863
|
+
...state.cookie !== void 0 ? { cookie: state.cookie } : {},
|
|
864
|
+
journal: state.journal,
|
|
865
|
+
...Arr.isReadonlyArrayNonEmpty(state.unpulled) ? { unpulled: state.unpulled } : {}
|
|
866
|
+
};
|
|
867
|
+
return R.isEmptyRecord(state.counters) ? base : {
|
|
868
|
+
...base,
|
|
869
|
+
counters: state.counters
|
|
870
|
+
};
|
|
871
|
+
});
|
|
872
|
+
/**
|
|
873
|
+
* Write a {@link Snapshot} into a {@link ClientStore}: validate it through
|
|
874
|
+
* {@link Snapshot} (so a malformed snapshot fails as a `StoreError`), then
|
|
875
|
+
* put the rows, set the cookie if present, park the unpulled tail, raise the
|
|
876
|
+
* store's mutation counters to the snapshot's, and append the journal, skipping
|
|
877
|
+
* any entry the tail already holds. A snapshot without a tail parks nothing. An
|
|
878
|
+
* outstanding pull release is finished first.
|
|
879
|
+
*
|
|
880
|
+
* The counters are raised, never lowered (#271): a client id reused after
|
|
881
|
+
* hydration continues above every id the snapshot's store had issued, so it
|
|
882
|
+
* never reissues an id the backend has already applied. A snapshot without
|
|
883
|
+
* counters leaves the store's as they are.
|
|
884
|
+
*
|
|
885
|
+
* A running {@link ClientEngine} built over the store re-reads its view and built
|
|
886
|
+
* indexes afterwards: a direct write does not go through the engine, so without this
|
|
887
|
+
* an indexed query could return a row whose indexed field changed (#350). The re-read
|
|
888
|
+
* runs when a write fails too, since the rows written before the failure are in the
|
|
889
|
+
* store. It reaches only the engines built over this exact store object: a store
|
|
890
|
+
* wrapped in a new object (`ClientStore.of({ ...store })`) is not seen, so its engine
|
|
891
|
+
* can stay stale. The same re-read tracks the appended journal entries in its
|
|
892
|
+
* `mutationStates` as `"pending"` (#358), so they settle like a local mutation. Journal ids
|
|
893
|
+
* should be ids this client has not already settled: an id the map has evicted is tracked again,
|
|
894
|
+
* and its outcome event can then follow a second time.
|
|
895
|
+
*
|
|
896
|
+
* The writes are not atomic. Between the first `putRow` and the re-read, an indexed
|
|
897
|
+
* read on an async store can see the old index against the new rows. Once `hydrate`
|
|
898
|
+
* returns or fails, the engine's view and indexes match the store again.
|
|
899
|
+
*
|
|
900
|
+
* `hydrate` does not take the overlay lock of an engine built over the store, so it must
|
|
901
|
+
* not run while that engine is pulling. Its first step finishes an outstanding pull release
|
|
902
|
+
* (#387). If that release is a live pull's, the pull's remaining row writes then land over
|
|
903
|
+
* the snapshot's rows. Hydrate before the engine starts, or while it is stopped.
|
|
904
|
+
*/
|
|
905
|
+
const hydrate = (store, snapshot) => Effect.gen(function* () {
|
|
906
|
+
const decoded = yield* Schema.decodeUnknownEffect(Snapshot)(snapshot).pipe(Effect.mapError(storeError("Invalid snapshot")));
|
|
907
|
+
yield* Effect.gen(function* () {
|
|
908
|
+
yield* store.finishRelease;
|
|
909
|
+
yield* Effect.forEach(decoded.rows, ({ collection, ...row }) => Effect.gen(function* () {
|
|
910
|
+
yield* store.putRow(collection, row);
|
|
911
|
+
}), {
|
|
912
|
+
concurrency: 1,
|
|
913
|
+
discard: true
|
|
914
|
+
});
|
|
915
|
+
if (decoded.cookie !== void 0) yield* store.setCookie(decoded.cookie);
|
|
916
|
+
if (decoded.unpulled !== void 0) yield* store.parkUnpulled(decoded.unpulled);
|
|
917
|
+
if (decoded.counters !== void 0) yield* store.raiseMutationCounters(decoded.counters);
|
|
918
|
+
const parked = new Set((yield* store.unpulled).map((m) => `${m.clientId}\u0000${m.mutationId}`));
|
|
919
|
+
yield* Effect.forEach(decoded.journal, (mutation) => Effect.gen(function* () {
|
|
920
|
+
if (parked.has(`${mutation.clientId}\u0000${mutation.mutationId}`)) return;
|
|
921
|
+
yield* store.appendMutation(mutation);
|
|
922
|
+
}), {
|
|
923
|
+
concurrency: 1,
|
|
924
|
+
discard: true
|
|
925
|
+
});
|
|
926
|
+
yield* freezePending(store);
|
|
927
|
+
}).pipe(Effect.ensuring(resyncAttachedEngines(store)));
|
|
928
|
+
});
|
|
929
|
+
/**
|
|
930
|
+
* Whether a store holds nothing: no rows, no cookie, no journalled or unpulled mutations.
|
|
931
|
+
* The "fresh browser" test {@link hydrateIfEmpty} gates on.
|
|
932
|
+
*/
|
|
933
|
+
const isEmpty = (store) => Effect.gen(function* () {
|
|
934
|
+
if ((yield* store.getCookie) !== void 0) return false;
|
|
935
|
+
if (Arr.isReadonlyArrayNonEmpty(yield* store.pendingMutations)) return false;
|
|
936
|
+
if (Arr.isReadonlyArrayNonEmpty(yield* store.unpulled)) return false;
|
|
937
|
+
const holding = yield* Effect.findFirst(yield* store.listCollections, (collection) => Effect.map(store.listRows(collection), Arr.isReadonlyArrayNonEmpty));
|
|
938
|
+
return Option.isNone(holding);
|
|
939
|
+
});
|
|
940
|
+
/**
|
|
941
|
+
* {@link hydrate}, but only into an empty store; returns whether it hydrated.
|
|
942
|
+
*
|
|
943
|
+
* This is the right client-boot default over a *durable* store: on a repeat
|
|
944
|
+
* visit the store already holds rows, a cookie, and possibly journalled
|
|
945
|
+
* offline mutations that are all fresher than the SSR snapshot — hydrating
|
|
946
|
+
* over them would regress the cookie (and with it the CVR conversation) and
|
|
947
|
+
* clobber newer confirmed rows. An empty store (first visit, cleared storage)
|
|
948
|
+
* takes the snapshot wholesale.
|
|
949
|
+
*/
|
|
950
|
+
const hydrateIfEmpty = (store, snapshot) => Effect.gen(function* () {
|
|
951
|
+
if (!(yield* isEmpty(store))) return false;
|
|
952
|
+
yield* hydrate(store, snapshot);
|
|
953
|
+
return true;
|
|
954
|
+
});
|
|
955
|
+
/**
|
|
956
|
+
* Project a snapshot onto a read, locally and synchronously: the snapshot's
|
|
957
|
+
* rows for the read's collection, filtered/sorted/windowed by the read's
|
|
958
|
+
* compiled root window. This is how a component derives its initial value
|
|
959
|
+
* (e.g. the rune hooks' `initial` option) from the page's snapshot — during
|
|
960
|
+
* SSR and on first client render — without touching an engine.
|
|
961
|
+
*
|
|
962
|
+
* `include`d edges are not resolved here: a snapshot is flat rows, and
|
|
963
|
+
* resolving edges is the Client's job once the engine is up. Seed the base
|
|
964
|
+
* rows and let the first live emission fill the edges in.
|
|
965
|
+
*/
|
|
966
|
+
const rowsFor = (snapshot, nodes, read) => apply(snapshot.rows.filter((row) => row.collection === read.collection).map((row) => ({
|
|
967
|
+
id: row.id,
|
|
968
|
+
value: row.value
|
|
969
|
+
})), plan(nodes, read).root);
|
|
970
|
+
/** The snapshot render itself, over an already-built carrier. */
|
|
971
|
+
const renderOver = (transport, options) => Effect.scoped(Effect.gen(function* () {
|
|
972
|
+
const store = yield* makeMemory;
|
|
973
|
+
const engine = yield* make$5({
|
|
974
|
+
clientId: options.clientId ?? "ssr",
|
|
975
|
+
clientGroupId: options.clientGroupId,
|
|
976
|
+
mutators: options.mutations ?? registry$3([])
|
|
977
|
+
}).pipe(Effect.provideService(ClientStore, store), Effect.provideService(ClientTransport, transport));
|
|
978
|
+
yield* Effect.forEach(options.reads, (read) => Effect.gen(function* () {
|
|
979
|
+
yield* engine.subscribe(plan(options.nodes, read).root);
|
|
980
|
+
}), {
|
|
981
|
+
concurrency: 1,
|
|
982
|
+
discard: true
|
|
983
|
+
});
|
|
984
|
+
yield* engine.pull;
|
|
985
|
+
return yield* dehydrate(store);
|
|
986
|
+
}));
|
|
987
|
+
function snapshot(options) {
|
|
988
|
+
const supplied = options.transport;
|
|
989
|
+
if (supplied !== void 0) return renderOver(supplied, options);
|
|
990
|
+
return Effect.scoped(Effect.gen(function* () {
|
|
991
|
+
const handler = yield* ServerHandler;
|
|
992
|
+
const transport = yield* makeInMemory(handler, options.clientGroupId);
|
|
993
|
+
return yield* renderOver(transport, options);
|
|
994
|
+
}));
|
|
995
|
+
}
|
|
996
|
+
// src/Server.ts
|
|
997
|
+
/**
|
|
998
|
+
* Server
|
|
999
|
+
*
|
|
1000
|
+
* The one server-side object (ADR-0022): built once over the {@link Backend}
|
|
1001
|
+
* and auth seams, it exposes the protocol **handler** (push/pull with CVR
|
|
1002
|
+
* diffing — mount it on any router or carrier) and **broadcast**, the single
|
|
1003
|
+
* verb for "these resources changed".
|
|
1004
|
+
*
|
|
1005
|
+
* Mutation writes pushed through the handler broadcast implicitly (the push
|
|
1006
|
+
* path publishes their hints); `broadcast` is for everything else — cron
|
|
1007
|
+
* jobs, admin tools, any writer that bypasses mutations (ADR-0019's
|
|
1008
|
+
* out-of-band story in one line). Underneath it is unchanged poke-then-pull:
|
|
1009
|
+
* hints are droppable latency optimizations, pull is the correctness path.
|
|
1010
|
+
*
|
|
1011
|
+
* CVR storage and the fan-out bus are **options with working defaults**
|
|
1012
|
+
* (in-memory store, in-process bus); scale-out swaps them (a shared
|
|
1013
|
+
* KeyValueStore, a serialized broker — ADR-0011) without touching app code.
|
|
1014
|
+
* The Server is logical: its parts deploy separately (per-user rooms,
|
|
1015
|
+
* fan-out shards — ADR-0022's reference topology), and ADR-0023's
|
|
1016
|
+
* topic-derived fan-out lands behind `broadcast` without changing its
|
|
1017
|
+
* signature.
|
|
1018
|
+
*
|
|
1019
|
+
* A third verb, **mutate**, runs one named Mutation server-side as one of a
|
|
1020
|
+
* client group's mutations (ADR-0030, amendment #215): the entry point a form
|
|
1021
|
+
* action calls. It is a push minus the transport, so it goes through the
|
|
1022
|
+
* handler's push path (identity, middleware, the backend's ledger, the
|
|
1023
|
+
* broadcast), and it answers a {@link Receipt} carrying the backend's ruling.
|
|
1024
|
+
*/
|
|
1025
|
+
var Server_exports = /* @__PURE__ */ __exportAll({ make: () => make });
|
|
1026
|
+
/**
|
|
1027
|
+
* Read one mutation's ruling off a push response: rejected when the response
|
|
1028
|
+
* lists it, acknowledged when the client's watermark covers it. A response
|
|
1029
|
+
* that does neither breaks Backend guarantee 1, so it is a defect.
|
|
1030
|
+
*/
|
|
1031
|
+
const receiptOf = (response, clientId, mutationId) => {
|
|
1032
|
+
const rejected = response.rejected?.find((entry) => entry.clientId === clientId && entry.mutationId === mutationId);
|
|
1033
|
+
if (rejected !== void 0) return Effect.succeed({
|
|
1034
|
+
clientId,
|
|
1035
|
+
mutationId,
|
|
1036
|
+
phase: "rejected",
|
|
1037
|
+
reason: rejected.reason
|
|
1038
|
+
});
|
|
1039
|
+
const watermark = response.lastMutationIds[clientId] ?? 0;
|
|
1040
|
+
return watermark >= mutationId ? Effect.succeed({
|
|
1041
|
+
clientId,
|
|
1042
|
+
mutationId,
|
|
1043
|
+
phase: "acknowledged"
|
|
1044
|
+
}) : Effect.die(new BackendError({ message: `Server.mutate: the backend neither applied nor rejected mutation ${mutationId} of client ${clientId} (its watermark is ${watermark})` }));
|
|
1045
|
+
};
|
|
1046
|
+
/** Build the Server: handler + broadcast over backend and auth. */
|
|
1047
|
+
const make = (options) => Effect.gen(function* () {
|
|
1048
|
+
const cvr = options.cvr ?? (yield* makeMemoryStore);
|
|
1049
|
+
const bus = options.bus ?? (yield* makeInProcess);
|
|
1050
|
+
let handler = makeWith(options.options).pipe(Effect.provideService(Backend, options.backend), Effect.provideService(AuthPolicy, options.auth.policy), Effect.provideService(CvrStore, cvr), Effect.provideService(InvalidationBus, bus));
|
|
1051
|
+
if (options.auth.identity !== void 0) handler = handler.pipe(Effect.provideService(IdentityResolver, options.auth.identity));
|
|
1052
|
+
if (options.queries !== void 0) handler = handler.pipe(Effect.provideService(QueryResolver, options.queries));
|
|
1053
|
+
if (options.middleware !== void 0) handler = handler.pipe(Effect.provideService(SyncMiddleware, options.middleware));
|
|
1054
|
+
const built = yield* handler;
|
|
1055
|
+
const mutate = (request) => Effect.gen(function* () {
|
|
1056
|
+
const args = yield* Schema.encodeEffect(request.mutation.args)(request.args).pipe(Effect.orDie);
|
|
1057
|
+
const createdAt = yield* Clock.currentTimeMillis;
|
|
1058
|
+
const response = yield* built.handlePush({
|
|
1059
|
+
clientGroupId: request.clientGroupId,
|
|
1060
|
+
mutations: [{
|
|
1061
|
+
clientId: request.clientId,
|
|
1062
|
+
mutationId: request.mutationId,
|
|
1063
|
+
mutator: request.mutation.name,
|
|
1064
|
+
args,
|
|
1065
|
+
createdAt
|
|
1066
|
+
}],
|
|
1067
|
+
schemaVersion: request.schemaVersion
|
|
1068
|
+
});
|
|
1069
|
+
return yield* receiptOf(response, request.clientId, request.mutationId);
|
|
1070
|
+
});
|
|
1071
|
+
return {
|
|
1072
|
+
handler: built,
|
|
1073
|
+
broadcast: (change) => bus.publish([{
|
|
1074
|
+
collection: change.collection,
|
|
1075
|
+
ids: change.ids ?? []
|
|
1076
|
+
}]),
|
|
1077
|
+
mutate
|
|
1078
|
+
};
|
|
1079
|
+
});
|
|
1080
|
+
export { Edge_exports as a, CrudMutation_exports as i, Hydration_exports as n, Node_exports as o, Client_exports as r, Server_exports as t };
|
|
1081
|
+
|
|
1082
|
+
//# sourceMappingURL=src-FWXpu_vP.js.map
|