@thomasfosterau/legation 0.0.0-stage → 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.
@@ -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