@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.
@@ -0,0 +1,3758 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
+ import { $t as apply, At as RejectedMutation, B as FormMutationBegin, Dn as relationshipsOf, Dt as Poke, Et as PatchOp, F as ofPlan, G as mutationStatesUpdate, In as MutationRejected, It as make$8, J as relatedUpdate, K as namedKey, M as make$7, Mn as BackendError, P as NOT_PULLED, Tt as NamedSubscription, U as decodeMirrorMessage, V as MUTATION_STATES_KEY, Vn as TransportError, W as mirrorUpdate, Xt as related, Yt as query, Z as ClientEngine, Zt as Subset, _n as ne, _t as canonicalJson, a as layer$2, an as always, bn as or, cn as eq, dn as isIn, dt as make$6, en as hash, fn as isNotNull, ft as put, gn as lte, hn as lt, j as counted, kt as PushResponse, l as layerInProcess, ln as gt, n as handler, on as and, p as layerMemoryStore, pn as isNull, pt as result, q as relatedKey, qt as tryPlan, r as validatedHandlers, s as InvalidationBus, sn as compareText, tn as make$5, un as gte, ut as del, vn as not, vt as contentHash, wt as Mutation, y as isError, yn as notIn, yt as ClientStore, zn as QueryError } 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 HashMap from "effect/HashMap";
9
+ import * as Context from "effect/Context";
10
+ import * as Result from "effect/Result";
11
+ import * as Stream from "effect/Stream";
12
+ import * as Fiber from "effect/Fiber";
13
+ import * as Queue from "effect/Queue";
14
+ import * as Ref from "effect/Ref";
15
+ import * as Exit from "effect/Exit";
16
+ import * as Scope from "effect/Scope";
17
+ import * as Semaphore from "effect/Semaphore";
18
+ import * as Clock from "effect/Clock";
19
+ import * as Deferred from "effect/Deferred";
20
+ import * as Duration from "effect/Duration";
21
+ import * as Layer from "effect/Layer";
22
+ import * as PubSub from "effect/PubSub";
23
+ import * as SubscriptionRef from "effect/SubscriptionRef";
24
+ import { Redis } from "effect/persistence";
25
+ import * as JsonSchema from "effect/JsonSchema";
26
+ import * as SchemaRepresentation from "effect/SchemaRepresentation";
27
+ import * as Str from "effect/String";
28
+ import { Atom } from "effect/reactivity";
29
+ import * as Cause from "effect/Cause";
30
+ import * as ManagedRuntime from "effect/ManagedRuntime";
31
+ import * as DateTime from "effect/DateTime";
32
+ import * as Random from "effect/Random";
33
+ // src/SpecExport.ts
34
+ /**
35
+ * SpecExport
36
+ *
37
+ * Emit machine-readable API descriptions of an app's sync surface so bespoke
38
+ * clients can be generated in any language (ADR-0017). The protocol is
39
+ * schema-defined (Protocol.ts) and carrier-agnostic (ADR-0002); this module
40
+ * turns those schemas — *specialized with the app's own collections, mutators
41
+ * and queries* — into an OpenAPI document (the unary push/pull operations) and
42
+ * an AsyncAPI document (push/pull plus the streamed poke channel).
43
+ *
44
+ * The value is specialization. The wire protocol moves `unknown` values and
45
+ * args; a generic spec describes an opaque envelope. Here each message is
46
+ * narrowed to a discriminated union over the app's actual names — a `Mutation`
47
+ * per mutator (its `args` schema), a patch `Put` per collection (its row
48
+ * schema), a named subscription per query — so a generated client gets
49
+ * concrete, per-name types instead of `any`.
50
+ *
51
+ * The pipeline is two steps so new formats are cheap to add: `build` produces a
52
+ * format-neutral {@link SyncSpec} (a shared JSON-Schema component pool plus the
53
+ * names of each message), and the emitters (`toOpenApi`, `toAsyncApi`, or your
54
+ * own) project that into a concrete document.
55
+ *
56
+ * The emitted OpenAPI is the protocol's *logical REST projection*: push and
57
+ * pull as `POST {path}/push` and `POST {path}/pull`. The default RPC carrier
58
+ * (RpcTransport.ts) multiplexes both over a single endpoint with its own
59
+ * envelope; the projection is the codegen-friendly contract, equally servable
60
+ * by a thin REST adapter.
61
+ */
62
+ var SpecExport_exports = /* @__PURE__ */ __exportAll({
63
+ asyncApi: () => asyncApi,
64
+ build: () => build,
65
+ openApi: () => openApi,
66
+ toAsyncApi: () => toAsyncApi,
67
+ toOpenApi: () => toOpenApi
68
+ });
69
+ /** Coerce an arbitrary name into a `$ref`-safe component identifier fragment. */
70
+ const sanitize = (name) => {
71
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_").replace(/^_+|_+$/g, "");
72
+ if (Str.isEmpty(cleaned)) return "x";
73
+ return /^[0-9]/.test(cleaned) ? `n${cleaned}` : cleaned;
74
+ };
75
+ /** A namer that keeps identifiers unique by suffixing collisions (`_2`, `_3`). */
76
+ const uniqueNamer = () => {
77
+ const seen = /* @__PURE__ */ new Set();
78
+ return (prefix, name) => {
79
+ const base = `${prefix}${sanitize(name)}`;
80
+ const at = (n) => n < 2 ? base : `${base}_${n}`;
81
+ const free = (n) => seen.has(at(n)) ? free(n + 1) : at(n);
82
+ const candidate = free(1);
83
+ seen.add(candidate);
84
+ return candidate;
85
+ };
86
+ };
87
+ const id = (schema, identifier) => schema.annotate({ identifier });
88
+ /** The serializable failure carried on sync operations (RpcTransport's). */
89
+ const SyncError = Schema.Struct({
90
+ _tag: Schema.tag("SyncRpcError"),
91
+ message: Schema.String
92
+ });
93
+ /**
94
+ * Build the specialized message schemas, each tagged with a stable component
95
+ * identifier. Where the app provides names the generic `unknown` envelope is
96
+ * narrowed to a discriminated union; otherwise the generic schema stands in.
97
+ */
98
+ const specialize = (api) => {
99
+ const collections = api.collections ?? [];
100
+ const mutators = api.mutators ?? [];
101
+ const queries = api.queries ?? [];
102
+ const name = uniqueNamer();
103
+ const collectionNames = collections.map((c) => ({
104
+ def: c,
105
+ schema: name("Row_", c.name)
106
+ }));
107
+ const mutatorNames = mutators.map((m) => ({
108
+ def: m,
109
+ schema: name("MutatorArgs_", m.name)
110
+ }));
111
+ const queryNames = queries.map((q) => ({
112
+ def: q,
113
+ schema: name("QueryArgs_", q.name)
114
+ }));
115
+ const mutation = Arr.isReadonlyArrayEmpty(mutatorNames) ? id(Mutation, "Mutation") : id(Schema.Union(mutatorNames.map(({ def, schema }) => id(Schema.Struct({
116
+ clientId: Schema.String,
117
+ mutationId: Schema.Number,
118
+ mutator: Schema.Literal(def.name),
119
+ args: id(def.args, schema),
120
+ createdAt: Schema.Number
121
+ }), `Mutation_${sanitize(def.name)}`))), "Mutation");
122
+ const patchOp = Arr.isReadonlyArrayEmpty(collectionNames) ? id(PatchOp, "PatchOp") : id(Schema.Union([
123
+ ...collectionNames.map(({ def, schema }) => id(Schema.Struct({
124
+ _tag: Schema.tag("Put"),
125
+ collection: Schema.Literal(def.name),
126
+ id: Schema.String,
127
+ version: Schema.Union([Schema.String, Schema.Number]),
128
+ value: id(def.schema, schema)
129
+ }), `Put_${sanitize(def.name)}`)),
130
+ Schema.Struct({
131
+ _tag: Schema.tag("Del"),
132
+ collection: Schema.String,
133
+ id: Schema.String
134
+ }),
135
+ Schema.Struct({ _tag: Schema.tag("Clear") })
136
+ ]), "PatchOp");
137
+ const namedSubscription = Arr.isReadonlyArrayEmpty(queryNames) ? id(NamedSubscription, "NamedSubscription") : id(Schema.Union(queryNames.map(({ def, schema }) => id(Schema.Struct({
138
+ name: Schema.Literal(def.name),
139
+ args: id(def.args, schema)
140
+ }), `NamedSubscription_${sanitize(def.name)}`))), "NamedSubscription");
141
+ const pushRequest = id(Schema.Struct({
142
+ clientGroupId: Schema.String,
143
+ mutations: Schema.Array(mutation)
144
+ }), "PushRequest");
145
+ const pullRequest = id(Schema.Struct({
146
+ clientGroupId: Schema.String,
147
+ cookie: Schema.optional(Schema.String),
148
+ subscriptions: Schema.Array(id(Subset, "Subset")),
149
+ namedSubscriptions: Schema.optional(Schema.Array(namedSubscription))
150
+ }), "PullRequest");
151
+ const pullResponse = id(Schema.Struct({
152
+ cookie: Schema.String,
153
+ patch: Schema.Array(patchOp),
154
+ lastMutationIds: Schema.Record(Schema.String, Schema.Number),
155
+ rejected: Schema.optional(Schema.Array(RejectedMutation))
156
+ }), "PullResponse");
157
+ return {
158
+ roots: [
159
+ pushRequest.ast,
160
+ id(PushResponse, "PushResponse").ast,
161
+ pullRequest.ast,
162
+ pullResponse.ast,
163
+ id(Poke, "Poke").ast,
164
+ id(SyncError, "SyncRpcError").ast
165
+ ],
166
+ catalog: {
167
+ collections: collectionNames.map(({ def, schema }) => ({
168
+ name: def.name,
169
+ schema
170
+ })),
171
+ mutators: mutatorNames.map(({ def, schema }) => ({
172
+ name: def.name,
173
+ schema
174
+ })),
175
+ queries: queryNames.map(({ def, schema }) => ({
176
+ name: def.name,
177
+ schema
178
+ }))
179
+ }
180
+ };
181
+ };
182
+ /**
183
+ * Build the format-neutral {@link SyncSpec} from an app's sync surface. The
184
+ * shared component pool is assembled once (so every message reuses the same
185
+ * row/args definitions) in OpenAPI-style draft-2020-12.
186
+ */
187
+ const build = (api) => {
188
+ const { roots, catalog } = specialize(api);
189
+ const openApi = JsonSchema.toMultiDocumentOpenApi3_1(SchemaRepresentation.toJsonSchemaMultiDocument(SchemaRepresentation.toRepresentations(roots)));
190
+ return {
191
+ info: api.info,
192
+ path: api.path ?? "/sync",
193
+ components: openApi.definitions,
194
+ messages: {
195
+ pushRequest: "PushRequest",
196
+ pushResponse: "PushResponse",
197
+ pullRequest: "PullRequest",
198
+ pullResponse: "PullResponse",
199
+ poke: "Poke",
200
+ error: "SyncRpcError"
201
+ },
202
+ catalog
203
+ };
204
+ };
205
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
206
+ const msgRef = (name) => ({ $ref: `#/components/messages/${name}` });
207
+ const chanRef = (name) => ({ $ref: `#/channels/${name}` });
208
+ const message = (payload) => ({
209
+ name: payload,
210
+ payload: ref(payload)
211
+ });
212
+ const withDescription = (info, extra) => ({
213
+ title: info.title,
214
+ version: info.version,
215
+ description: info.description === void 0 ? extra : `${info.description}\n\n${extra}`
216
+ });
217
+ /**
218
+ * Project a {@link SyncSpec} into an OpenAPI 3.1 document: push and pull as the
219
+ * protocol's logical `POST {path}/push` and `POST {path}/pull` operations, with
220
+ * every app type in `components.schemas`. Streamed pokes live in AsyncAPI.
221
+ */
222
+ const toOpenApi = (spec) => {
223
+ const operation = (operationId, summary, request, response) => ({ post: {
224
+ operationId,
225
+ summary,
226
+ requestBody: {
227
+ required: true,
228
+ content: { "application/json": { schema: ref(request) } }
229
+ },
230
+ responses: {
231
+ "200": {
232
+ description: "Success.",
233
+ content: { "application/json": { schema: ref(response) } }
234
+ },
235
+ default: {
236
+ description: "Sync error.",
237
+ content: { "application/json": { schema: ref(spec.messages.error) } }
238
+ }
239
+ }
240
+ } });
241
+ return {
242
+ openapi: "3.1.0",
243
+ info: withDescription(spec.info, "Logical REST projection of the sync protocol. The default RPC carrier multiplexes push and pull over a single endpoint; these operations are the codegen-friendly contract."),
244
+ paths: {
245
+ [`${spec.path}/push`]: operation("push", "Deliver pending mutations (push).", spec.messages.pushRequest, spec.messages.pushResponse),
246
+ [`${spec.path}/pull`]: operation("pull", "Request a patch to the next cookie (pull).", spec.messages.pullRequest, spec.messages.pullResponse)
247
+ },
248
+ components: { schemas: spec.components }
249
+ };
250
+ };
251
+ /**
252
+ * Project a {@link SyncSpec} into an AsyncAPI 3.0 document: push and pull as
253
+ * request/reply send operations and the contentless poke hint as a receive
254
+ * channel. Component schemas are shared with the OpenAPI projection.
255
+ */
256
+ const toAsyncApi = (spec) => {
257
+ return {
258
+ asyncapi: "3.0.0",
259
+ info: withDescription(spec.info, "Message-oriented view of the sync protocol: push/pull request-reply and the streamed poke hint."),
260
+ channels: {
261
+ push: {
262
+ address: `${spec.path}/push`,
263
+ messages: {
264
+ request: msgRef(spec.messages.pushRequest),
265
+ response: msgRef(spec.messages.pushResponse)
266
+ }
267
+ },
268
+ pull: {
269
+ address: `${spec.path}/pull`,
270
+ messages: {
271
+ request: msgRef(spec.messages.pullRequest),
272
+ response: msgRef(spec.messages.pullResponse)
273
+ }
274
+ },
275
+ pokes: {
276
+ address: `${spec.path}/pokes`,
277
+ messages: { poke: msgRef(spec.messages.poke) }
278
+ }
279
+ },
280
+ operations: {
281
+ push: {
282
+ action: "send",
283
+ channel: chanRef("push"),
284
+ reply: {
285
+ channel: chanRef("push"),
286
+ messages: [msgRef(spec.messages.pushResponse)]
287
+ }
288
+ },
289
+ pull: {
290
+ action: "send",
291
+ channel: chanRef("pull"),
292
+ reply: {
293
+ channel: chanRef("pull"),
294
+ messages: [msgRef(spec.messages.pullResponse)]
295
+ }
296
+ },
297
+ receivePokes: {
298
+ action: "receive",
299
+ channel: chanRef("pokes")
300
+ }
301
+ },
302
+ components: {
303
+ schemas: spec.components,
304
+ messages: {
305
+ [spec.messages.pushRequest]: message(spec.messages.pushRequest),
306
+ [spec.messages.pushResponse]: message(spec.messages.pushResponse),
307
+ [spec.messages.pullRequest]: message(spec.messages.pullRequest),
308
+ [spec.messages.pullResponse]: message(spec.messages.pullResponse),
309
+ [spec.messages.poke]: message(spec.messages.poke)
310
+ }
311
+ }
312
+ };
313
+ };
314
+ /** Build and project to OpenAPI 3.1 in one step. */
315
+ const openApi = (api) => toOpenApi(build(api));
316
+ /** Build and project to AsyncAPI 3.0 in one step. */
317
+ const asyncApi = (api) => toAsyncApi(build(api));
318
+ // src/Undo.ts
319
+ /**
320
+ * Undo
321
+ *
322
+ * An optional, additive undo/redo module layered over anything that can
323
+ * `mutate(name, args)` — no engine surgery (issue #46). It follows the
324
+ * *paired-intents* design (the `@rocicorp/undo` shape): the caller supplies
325
+ * both directions of a change at run time, and undo/redo replay through the
326
+ * supplied handle's ordinary `mutate` pipeline — a `ClientEngine`, a
327
+ * `Client`, or a test double all satisfy {@link MutateHandle} structurally
328
+ * (issue #223). Because they are just mutations, undo and redo are
329
+ * optimistic, journaled, rebased, and offline-safe like any other write —
330
+ * the module adds only the in-memory history.
331
+ *
332
+ * `ClientEngine.mutate` and `Client.mutate` do not share an error channel
333
+ * (the engine fails with its mutator/store union, the Client with its
334
+ * narrower view error), so both {@link MutateHandle} and {@link UndoManager}
335
+ * are generic in that error type: whatever the supplied handle's `mutate`
336
+ * fails with is what `run`/`undo`/`redo` fail with, unchanged.
337
+ *
338
+ * History is a per-client, in-memory pair of stacks (one manager per handle).
339
+ * It does not survive a reload; the journal (which does) still carries any
340
+ * pending undo/redo mutations to the backend.
341
+ *
342
+ * ## Paired intents
343
+ *
344
+ * Each {@link UndoEntry} names the mutator + args to run now (`execute`) and the
345
+ * mutator + args that reverse it (`undo`). The caller captures whatever the
346
+ * reversal needs at run time — crucially, the *pre-delete value*: read the row
347
+ * from the local view, then `run({ execute: delete, undo: set(capturedValue) })`
348
+ * so an undo re-creates the row exactly.
349
+ *
350
+ * ## Grouping
351
+ *
352
+ * {@link UndoManager.group} coalesces every `run` made while its effect runs
353
+ * into ONE composite step: a single `undo` replays each member's `undo` in
354
+ * reverse order, a single `redo` replays each member's `execute` in order.
355
+ */
356
+ var Undo_exports = /* @__PURE__ */ __exportAll({ make: () => make$4 });
357
+ const EMPTY_HISTORY = {
358
+ undo: [],
359
+ redo: []
360
+ };
361
+ /**
362
+ * Build an {@link UndoManager} over any {@link MutateHandle} — a
363
+ * `ClientEngine`, a `Client`, or a test double. History is in-memory, so
364
+ * nothing needs a scope; the manager lives as long as the reference is held.
365
+ * The manager's error type is inferred from the handle's `mutate`, so a
366
+ * `ClientEngine` yields the same mutator/store union it always has, and a
367
+ * `Client` yields its narrower view error.
368
+ */
369
+ const make$4 = (handle) => Effect.gen(function* () {
370
+ const history = yield* SubscriptionRef.make(EMPTY_HISTORY);
371
+ const groupBuffer = yield* Ref.make(void 0);
372
+ const runIntent = (intent) => handle.mutate(intent.mutator, intent.args);
373
+ const pushStep = (step) => SubscriptionRef.update(history, (h) => ({
374
+ undo: [...h.undo, step],
375
+ redo: []
376
+ }));
377
+ const record = (entry) => Ref.get(groupBuffer).pipe(Effect.flatMap((buffer) => buffer === void 0 ? pushStep([entry]) : Ref.set(groupBuffer, [...buffer, entry])));
378
+ const run = (entry) => runIntent(entry.execute).pipe(Effect.andThen(record(entry)));
379
+ const take = (from) => SubscriptionRef.modify(history, (h) => {
380
+ const stack = h[from];
381
+ const step = stack.at(-1);
382
+ if (step === void 0) return [void 0, h];
383
+ return [step, {
384
+ ...h,
385
+ [from]: stack.slice(0, -1)
386
+ }];
387
+ });
388
+ const pushOnto = (onto, step) => SubscriptionRef.update(history, (h) => ({
389
+ ...h,
390
+ [onto]: [...h[onto], step]
391
+ }));
392
+ const replay = (step, direction) => Effect.gen(function* () {
393
+ const ordered = direction === "undo" ? [...step].reverse() : step;
394
+ yield* Effect.forEach(ordered, (entry) => Effect.gen(function* () {
395
+ yield* runIntent(direction === "undo" ? entry.undo : entry.execute);
396
+ }), {
397
+ concurrency: 1,
398
+ discard: true
399
+ });
400
+ });
401
+ const step = (from, onto) => take(from).pipe(Effect.flatMap((popped) => popped === void 0 ? Effect.void : replay(popped, from).pipe(Effect.andThen(pushOnto(onto, popped)))));
402
+ const canUndo = SubscriptionRef.get(history).pipe(Effect.map((h) => Arr.isReadonlyArrayNonEmpty(h.undo)));
403
+ const canRedo = SubscriptionRef.get(history).pipe(Effect.map((h) => Arr.isReadonlyArrayNonEmpty(h.redo)));
404
+ const observe = (project) => SubscriptionRef.changes(history).pipe(Stream.map(project), Stream.changes);
405
+ const flushGroup = Ref.get(groupBuffer).pipe(Effect.flatMap((buffer) => buffer !== void 0 && Arr.isReadonlyArrayNonEmpty(buffer) ? pushStep(buffer) : Effect.void));
406
+ const group = (effect) => Ref.get(groupBuffer).pipe(Effect.flatMap((existing) => existing !== void 0 ? effect : Ref.set(groupBuffer, []).pipe(Effect.andThen(effect), Effect.tap(() => flushGroup), Effect.ensuring(Ref.set(groupBuffer, void 0)))));
407
+ return {
408
+ run,
409
+ undo: step("undo", "redo"),
410
+ redo: step("redo", "undo"),
411
+ canUndo,
412
+ canRedo,
413
+ canUndoChanges: observe((h) => Arr.isReadonlyArrayNonEmpty(h.undo)),
414
+ canRedoChanges: observe((h) => Arr.isReadonlyArrayNonEmpty(h.redo)),
415
+ group
416
+ };
417
+ });
418
+ // src/Paced.ts
419
+ /**
420
+ * Paced mutations (#117)
421
+ *
422
+ * Debounce/throttle/coalesce strategies for rapid mutation intents — a text
423
+ * field autosaving per keystroke, a slider, a cart-quantity stepper — layered
424
+ * over {@link ClientEngine} the same way {@link Undo} is: no engine surgery,
425
+ * built entirely from `mutateWithAck` + `dropIfPending` + Effect's own timing
426
+ * primitives (`Effect.sleep`, `Fiber.interrupt`). No pacer dependency, no
427
+ * general-purpose pacer module — just the journal-compaction rule ADR-0008
428
+ * already permits on the unpushed tail.
429
+ *
430
+ * ## Design: journal-then-compact
431
+ *
432
+ * Every call — debounce, throttle, or coalesce — always journals through the
433
+ * ordinary `mutateWithAck` path immediately: full durability, the usual
434
+ * optimistic overlay render, no separate preview mechanism. What pacing adds
435
+ * is a *compaction* step run after journaling: for the mutation's `key` (a
436
+ * caller-supplied string — e.g. a row id — distinguishing independent rapid
437
+ * intents from each other), drop the previous still-pending entry for that
438
+ * key via {@link ClientEngine.dropIfPending}, so a burst collapses to the
439
+ * newest entry rather than piling one row per call into the journal.
440
+ *
441
+ * This was chosen over the alternative sketched in #117 (journal once,
442
+ * replace an unpushed entry's args in place) because it needs no new
443
+ * "mutate this pushed-adjacent entry in place" primitive — `dropIfPending`
444
+ * is a narrow, easily-audited addition (drop specific still-pending ids, never
445
+ * touching anything a push may have claimed) that composes with the journal
446
+ * exactly as `acknowledge` already does, and because journal-then-compact
447
+ * gives every strategy the *same* durability profile for the leading and
448
+ * every eagerly-journaled call: nothing is ever held only in memory once it
449
+ * has been accepted as "the current value" for a key.
450
+ *
451
+ * The one place memory-only buffering remains is intentional: `debounce` and
452
+ * `throttle` do not journal *every* call. A call arriving while a burst is
453
+ * already active only updates an in-memory `latestArgs` slot and (for
454
+ * `debounce`) restarts the trailing timer; the store is touched at most twice
455
+ * per debounce window (leading + trailing) or once per throttle window,
456
+ * regardless of how many calls landed inside it — which is what keeps overlay
457
+ * replays bounded for a 50-keystroke burst. **Durability tradeoff:** the
458
+ * args sitting in that in-memory slot between flushes are not journaled, so a
459
+ * crash inside a debounce/throttle window loses the freshest edit — the
460
+ * journal still holds the last successfully flushed (leading-edge) args, not
461
+ * nothing, but not the very latest keystroke either. This mirrors TanStack
462
+ * DB's `createPacedMutations` debounce/throttle strategies exactly. `coalesce`
463
+ * has no such window — every call journals immediately — so its tradeoff is
464
+ * the opposite one: momentarily more journal entries (until the next call's
465
+ * compaction catches up), never a lost edit.
466
+ *
467
+ * ## Compaction renumbers (#249)
468
+ *
469
+ * The backends reject a gap in a client's mutation ids (ADR-0008), so dropping
470
+ * a superseded entry is not enough: `dropIfPending` also renumbers the
471
+ * surviving unpushed entries, in order, so the journal stays contiguous and a
472
+ * push can drain it. That means an id this module remembered for a key can
473
+ * move — and not only when *its own* instance compacts: any other paced
474
+ * mutation on the same engine shifts every unpushed entry behind the one it
475
+ * drops. Acting on a stale id could drop an unrelated entry that has since
476
+ * taken that number, so every instance's bookkeeping lives in one registry per
477
+ * engine and each compaction's moves are applied to all of it (see
478
+ * {@link Tracker}).
479
+ *
480
+ * ## Undo pairing
481
+ *
482
+ * A caller combining this with {@link Undo}: wrap the whole burst (every
483
+ * `PacedMutation.call` your input handler makes) in `undo.group(...)`. Because
484
+ * `Undo.group` coalesces every `run` made while its effect executes into one
485
+ * step regardless of how many, a coalesced/debounced/throttled burst becomes
486
+ * a single undo step too, matching what the user actually experiences as one
487
+ * edit.
488
+ */
489
+ var Paced_exports = /* @__PURE__ */ __exportAll({
490
+ coalesce: () => coalesce,
491
+ debounce: () => debounce,
492
+ throttle: () => throttle
493
+ });
494
+ /**
495
+ * Every live {@link Tracker} for one engine. Weakly held, so an instance that
496
+ * is dropped (a `coalesce` has no scope to release it from) does not pin its
497
+ * map for the engine's lifetime.
498
+ */
499
+ const trackersByEngine = /* @__PURE__ */ new WeakMap();
500
+ /** Trackers already registered, so each is added once. */
501
+ const registered = /* @__PURE__ */ new WeakSet();
502
+ /** Register `tracker` so compactions by any instance on `engine` keep it current. */
503
+ const track = (engine, tracker) => {
504
+ if (registered.has(tracker)) return;
505
+ registered.add(tracker);
506
+ let all = trackersByEngine.get(engine);
507
+ if (all === void 0) {
508
+ all = /* @__PURE__ */ new Set();
509
+ trackersByEngine.set(engine, all);
510
+ }
511
+ all.add(new WeakRef(tracker));
512
+ };
513
+ /**
514
+ * Drop `previous` (if still compactable) and apply the renumbering it caused
515
+ * to every tracker on the engine, so no instance is left holding an id that
516
+ * now names a different entry.
517
+ */
518
+ const compact = (engine, previous) => Effect.uninterruptible(Effect.gen(function* () {
519
+ const { renumbered } = yield* engine.dropIfPending([previous]);
520
+ if (Arr.isReadonlyArrayEmpty(renumbered)) return;
521
+ const moves = new Map(renumbered.map(({ from, to }) => [from, to]));
522
+ const all = trackersByEngine.get(engine);
523
+ if (all === void 0) return;
524
+ Arr.forEach(all, (ref) => {
525
+ const tracker = ref.deref();
526
+ if (tracker === void 0) {
527
+ all.delete(ref);
528
+ return;
529
+ }
530
+ Arr.forEach(tracker, ([key, id]) => {
531
+ const moved = moves.get(id);
532
+ if (moved !== void 0) tracker.set(key, moved);
533
+ });
534
+ });
535
+ }));
536
+ /**
537
+ * Journal `args` for `mutator`/`key` via `mutateWithAck`, then drop this
538
+ * key's previously-tracked mutation id if it is still pending — the shared
539
+ * compaction step every strategy below is built from. `lastIdByKey` is the
540
+ * caller's own bookkeeping (not the engine's): each Paced instance tracks
541
+ * only the ids *it* journaled, so it only ever asks to drop its own entries.
542
+ */
543
+ const flush = (engine, mutator, lastIdByKey, key, args) => Effect.gen(function* () {
544
+ track(engine, lastIdByKey);
545
+ const previous = yield* Effect.uninterruptible(Effect.gen(function* () {
546
+ const handle = yield* engine.mutateWithAck(mutator, args);
547
+ const before = lastIdByKey.get(key);
548
+ lastIdByKey.set(key, handle.mutationId);
549
+ return before;
550
+ }));
551
+ if (previous !== void 0) yield* compact(engine, previous);
552
+ });
553
+ /**
554
+ * A background flush (debounce's trailing edge, throttle's periodic tick)
555
+ * that isn't awaited by any caller: failures would otherwise die silently in
556
+ * the forked fiber, so log them instead. The entry this call would have
557
+ * compacted simply stays journaled uncompacted — never lost, just uncoalesced
558
+ * (the same "short-lived extra entries" tradeoff `coalesce` always accepts).
559
+ */
560
+ const flushBackground = (engine, mutator, lastIdByKey, key, args) => flush(engine, mutator, lastIdByKey, key, args).pipe(Effect.catch((error) => Effect.logWarning("Paced: background flush failed; entry left journaled uncompacted", {
561
+ mutator,
562
+ key,
563
+ error
564
+ })));
565
+ /**
566
+ * Coalesce: consecutive unpushed invocations of `mutator` for the same `key`
567
+ * collapse to the last one. No timing — every call journals and compacts
568
+ * immediately, so nothing is ever buffered only in memory (fully durable),
569
+ * at the cost of a momentary extra journal entry between a call and its
570
+ * compaction.
571
+ */
572
+ const coalesce = (engine, mutator) => Effect.gen(function* () {
573
+ const lock = yield* Semaphore.make(1);
574
+ const lastIdByKey = /* @__PURE__ */ new Map();
575
+ const call = (key, args) => Semaphore.withPermit(lock, flush(engine, mutator, lastIdByKey, key, args));
576
+ return { call };
577
+ });
578
+ /**
579
+ * Debounce: the first call after a quiet period journals immediately (the
580
+ * overlay renders it right away — optimistic rendering never waits for the
581
+ * window); every call inside the window just buffers its args and restarts
582
+ * the trailing timer. When `duration` elapses with no further calls, the
583
+ * latest buffered args flush once, superseding the leading entry. A 50-call
584
+ * burst inside one window therefore produces exactly two journal writes
585
+ * (leading + trailing) no matter how many calls landed in between — see the
586
+ * module doc for the durability tradeoff this implies.
587
+ */
588
+ const debounce = (engine, mutator, duration) => Effect.gen(function* () {
589
+ const scope = yield* Effect.scope;
590
+ const lock = yield* Semaphore.make(1);
591
+ const lastIdByKey = /* @__PURE__ */ new Map();
592
+ const entries = /* @__PURE__ */ new Map();
593
+ const trailing = (key) => Effect.sleep(duration).pipe(Effect.andThen(Semaphore.withPermit(lock, Effect.gen(function* () {
594
+ const entry = entries.get(key);
595
+ if (entry === void 0 || !entry.dirty) {
596
+ entries.delete(key);
597
+ return;
598
+ }
599
+ const args = entry.latestArgs;
600
+ entries.delete(key);
601
+ yield* flushBackground(engine, mutator, lastIdByKey, key, args);
602
+ }))));
603
+ const call = (key, args) => Semaphore.withPermit(lock, Effect.gen(function* () {
604
+ let entry = entries.get(key);
605
+ if (entry === void 0) {
606
+ entry = {
607
+ latestArgs: args,
608
+ dirty: false,
609
+ timer: void 0
610
+ };
611
+ entries.set(key, entry);
612
+ yield* flush(engine, mutator, lastIdByKey, key, args);
613
+ } else {
614
+ if (entry.timer !== void 0) yield* Fiber.interrupt(entry.timer);
615
+ entry.latestArgs = args;
616
+ entry.dirty = true;
617
+ }
618
+ entry.timer = yield* Effect.forkIn(trailing(key), scope);
619
+ }));
620
+ return { call };
621
+ });
622
+ /**
623
+ * Throttle: like {@link debounce}'s leading edge, but the window is never
624
+ * reset by later calls — it flushes at most once per `duration` while calls
625
+ * keep arriving (a continuous drag emits periodic journal writes, not just
626
+ * one at the very end), then goes idle once a window elapses with nothing new
627
+ * buffered.
628
+ */
629
+ const throttle = (engine, mutator, duration) => Effect.gen(function* () {
630
+ const scope = yield* Effect.scope;
631
+ const lock = yield* Semaphore.make(1);
632
+ const lastIdByKey = /* @__PURE__ */ new Map();
633
+ const entries = /* @__PURE__ */ new Map();
634
+ const tick = (key) => Effect.sleep(duration).pipe(Effect.andThen(Semaphore.withPermit(lock, Effect.gen(function* () {
635
+ const entry = entries.get(key);
636
+ if (entry === void 0) return;
637
+ if (!entry.dirty) {
638
+ entries.delete(key);
639
+ return;
640
+ }
641
+ entry.dirty = false;
642
+ const args = entry.latestArgs;
643
+ yield* flushBackground(engine, mutator, lastIdByKey, key, args);
644
+ entry.timer = yield* Effect.forkIn(tick(key), scope);
645
+ }))));
646
+ const call = (key, args) => Semaphore.withPermit(lock, Effect.gen(function* () {
647
+ const entry = entries.get(key);
648
+ if (entry === void 0) {
649
+ const created = {
650
+ latestArgs: args,
651
+ dirty: false,
652
+ timer: void 0
653
+ };
654
+ entries.set(key, created);
655
+ yield* flush(engine, mutator, lastIdByKey, key, args);
656
+ created.timer = yield* Effect.forkIn(tick(key), scope);
657
+ return;
658
+ }
659
+ entry.latestArgs = args;
660
+ entry.dirty = true;
661
+ }));
662
+ return { call };
663
+ });
664
+ // src/Introspection.ts
665
+ /**
666
+ * Introspection
667
+ *
668
+ * A read-only view of a running {@link ClientEngine} and its
669
+ * {@link ClientStore} (#45): the active subscriptions, the pending-mutation
670
+ * journal, the pull cookie, the materialised collections, and the unified
671
+ * {@link ClientEngine.engineEvents} lifecycle stream. Built entirely over the
672
+ * engine's public read accessors and the store's public reads — it never
673
+ * reaches into private engine state.
674
+ *
675
+ * Every member is a plain `Effect`/`Stream`, so it drives cleanly through
676
+ * `PromiseFacade` for non-Effect surfaces (devtools panels, a demo journal
677
+ * view):
678
+ *
679
+ * ```ts
680
+ * const intro = Introspection.make(engine, store);
681
+ * const pending = await facade.run(intro.journal);
682
+ * const sub = facade.subscribe(intro.events, {
683
+ * onValue: (event) => console.log(event._tag),
684
+ * });
685
+ * ```
686
+ */
687
+ var Introspection_exports = /* @__PURE__ */ __exportAll({
688
+ EngineIntrospection: () => EngineIntrospection,
689
+ fromServices: () => fromServices,
690
+ layer: () => layer$1,
691
+ make: () => make$3
692
+ });
693
+ /** The {@link EngineIntrospection} service devtools read through. */
694
+ const EngineIntrospection = Context.Service("@thomasfosterau/legation/EngineIntrospection");
695
+ /** Build the introspection view over a given engine and store. */
696
+ const make$3 = (engine, store) => ({
697
+ subscriptions: engine.activeSubscriptions,
698
+ journal: store.pendingMutations,
699
+ cookie: store.getCookie,
700
+ collections: store.listCollections,
701
+ events: engine.engineEvents
702
+ });
703
+ /** Build the view from the {@link ClientEngine} + {@link ClientStore} in context. */
704
+ const fromServices = Effect.gen(function* () {
705
+ const engine = yield* ClientEngine;
706
+ const store = yield* ClientStore;
707
+ return make$3(engine, store);
708
+ });
709
+ /** Layer providing {@link EngineIntrospection} from the engine + store services. */
710
+ const layer$1 = Layer.effect(EngineIntrospection)(fromServices);
711
+ // src/Incremental.ts
712
+ /**
713
+ * Incremental
714
+ *
715
+ * The pure delta engines behind #114's derived combinators ({@link Derive}).
716
+ * ADR-0027 (decision 4) blesses the proven per-module seams over extracting a
717
+ * shared #108 operator core, and rules that #114's combinators *are* the
718
+ * operator layer, landing with their consumer — so these are exactly those
719
+ * combinators' engines, not a separately-consumed core. Each mirrors a proven
720
+ * seam's *technique* (not its coupled code): `join` the keyed-bucket-plus-
721
+ * reverse-index of `Related.makeJoinEngine`, `groupBy` the running-fold-plus-
722
+ * `byId` of `Aggregate`, `orderedWindow` the `LiveView`/`OrderedTree`
723
+ * persistent order.
724
+ *
725
+ * The delta currency is keyed `added`/`removed` rows — the `Patch`-op model of
726
+ * ADR-0027 §1, on row identity, not `[value, ±multiplicity]` multiset entries.
727
+ * Each engine is a small, pure, Effect-free function that emits the delta to
728
+ * its result, never a refold of the whole input (a refolding version of this
729
+ * tier was explicitly judged not worth shipping, #114 brief) — and being pure
730
+ * and stream-free is what lets a property test drive it against a naive
731
+ * from-scratch recompute after every prefix of a randomized delta sequence.
732
+ *
733
+ * **The boundary rule (ADR-0027 §3).** These operators are read-layer
734
+ * machinery behind `observe`. They see only rows the client already holds;
735
+ * they never widen what syncs, never serialize, and are never auth-relevant.
736
+ * The serializable `Subset`/`Predicate` tier alone decides what the server
737
+ * ships.
738
+ *
739
+ * The unit is a {@link Row} — the same `(id, value)` shape as `Subset.Row` and
740
+ * `ClientEngine.LocalRow`, so a source's page (`ReadonlyArray<LocalRow>`) is a
741
+ * valid input and an operator's result rows compose as the input of the next
742
+ * operator (queries-from-queries falls out of the shared shape). Output row
743
+ * ids are synthesized where the operation demands it — a join row is keyed by
744
+ * its `(leftId, rightId)` pair, a group row by its group key, a distinct row
745
+ * by its canonical value — always a plain string, always stable.
746
+ */
747
+ var Incremental_exports = /* @__PURE__ */ __exportAll({
748
+ applyDelta: () => applyDelta,
749
+ deltaFromChanges: () => deltaFromChanges,
750
+ diff: () => diff,
751
+ distinctFromScratch: () => distinctFromScratch,
752
+ emptyDelta: () => emptyDelta,
753
+ filterFromScratch: () => filterFromScratch,
754
+ groupByFromScratch: () => groupByFromScratch,
755
+ indexById: () => indexById,
756
+ joinFromScratch: () => joinFromScratch,
757
+ makeDistinct: () => makeDistinct,
758
+ makeFilter: () => makeFilter,
759
+ makeGroupBy: () => makeGroupBy,
760
+ makeJoin: () => makeJoin,
761
+ makeMap: () => makeMap,
762
+ makeOrderedWindow: () => makeOrderedWindow,
763
+ mapFromScratch: () => mapFromScratch,
764
+ orderedWindowFromScratch: () => orderedWindowFromScratch,
765
+ pageOf: () => pageOf
766
+ });
767
+ /** The empty delta (a no-op step result). */
768
+ const emptyDelta = {
769
+ added: [],
770
+ removed: []
771
+ };
772
+ /** Index a page by id, for diffing against the next snapshot. */
773
+ const indexById = (rows) => {
774
+ const map = /* @__PURE__ */ new Map();
775
+ for (const row of rows) map.set(row.id, row);
776
+ return map;
777
+ };
778
+ /**
779
+ * Diff a new page against the previous snapshot (by id, value change detected
780
+ * by content hash): a new id is an `added`, a vanished id a `removed`, a
781
+ * changed value a `removed` (old) + `added` (new). Returns the delta and the
782
+ * new snapshot index to carry forward.
783
+ */
784
+ const diff = (previous, next) => {
785
+ const added = [];
786
+ const removed = [];
787
+ const index = /* @__PURE__ */ new Map();
788
+ for (const row of next) {
789
+ index.set(row.id, row);
790
+ const prior = previous.get(row.id);
791
+ if (prior === void 0) added.push(row);
792
+ else if (prior !== row && contentHash(prior.value) !== contentHash(row.value)) {
793
+ removed.push(prior);
794
+ added.push(row);
795
+ }
796
+ }
797
+ for (const [id, prior] of previous) if (!index.has(id)) removed.push(prior);
798
+ return {
799
+ delta: {
800
+ added,
801
+ removed
802
+ },
803
+ index
804
+ };
805
+ };
806
+ /** Fold a delta into a maintained result map (remove ids, then set adds). */
807
+ const applyDelta = (result, delta) => {
808
+ for (const row of delta.removed) result.delete(row.id);
809
+ for (const row of delta.added) result.set(row.id, row);
810
+ };
811
+ /**
812
+ * Recover a {@link Delta} directly from an `observeChanges` batch (#115),
813
+ * instead of diffing two page snapshots (see {@link diff} above) — the
814
+ * optimization ADR-0027 §4 and this module's header flag as a follow-up:
815
+ * `LiveView` already decided enter/update/exit/move per row, so this just
816
+ * reads that decision instead of recomputing it from two `Map`s. `known`
817
+ * tracks the last row seen per id (across calls, mutated in place) so a
818
+ * `reset` can emit a `removed` for everything it cleared — the same
819
+ * information a page-diff's `previous` snapshot would have given for free.
820
+ * `move` carries no value change, so it contributes nothing to the delta.
821
+ */
822
+ const deltaFromChanges = (known, batch) => {
823
+ const added = [];
824
+ const removed = [];
825
+ for (const change of batch) switch (change._tag) {
826
+ case "reset":
827
+ for (const row of known.values()) removed.push(row);
828
+ known.clear();
829
+ break;
830
+ case "enter":
831
+ added.push(change.row);
832
+ known.set(change.row.id, change.row);
833
+ break;
834
+ case "update":
835
+ removed.push(change.previous);
836
+ added.push(change.row);
837
+ known.set(change.row.id, change.row);
838
+ break;
839
+ case "exit":
840
+ removed.push(change.previous);
841
+ known.delete(change.id);
842
+ }
843
+ return {
844
+ added,
845
+ removed
846
+ };
847
+ };
848
+ /** Materialize a result map to a page in a deterministic (id-ascending, code-point) order. */
849
+ const pageOf = (result) => [...result.values()].toSorted((a, b) => compareText(a.id, b.id));
850
+ /**
851
+ * `filter` — keep rows whose value passes an arbitrary JS predicate. Stateless
852
+ * per row: the predicate is deterministic on the value, and a delta carries the
853
+ * value it concerns, so an added/removed row's membership is decided on the
854
+ * spot (an update that filters a row out is a `removed` with the old value that
855
+ * passed; one that filters it in is an `added` that now passes).
856
+ */
857
+ const makeFilter = (predicate) => ({ step: (delta) => ({
858
+ added: delta.added.filter((row) => predicate(row.value)),
859
+ removed: delta.removed.filter((row) => predicate(row.value))
860
+ }) });
861
+ /** {@link makeFilter}'s naive recompute, for the property test. */
862
+ const filterFromScratch = (rows, predicate) => indexById(rows.filter((row) => predicate(row.value)));
863
+ /**
864
+ * `map`/select — project each value through an arbitrary JS function, keeping
865
+ * the row id (projection preserves identity). Stateless: `f` is deterministic,
866
+ * so an update's `removed` reconstructs the old projection and its `added` the
867
+ * new one.
868
+ */
869
+ const makeMap = (f) => ({ step: (delta) => ({
870
+ added: delta.added.map((row) => ({
871
+ id: row.id,
872
+ value: f(row.value)
873
+ })),
874
+ removed: delta.removed.map((row) => ({
875
+ id: row.id,
876
+ value: f(row.value)
877
+ }))
878
+ }) });
879
+ /** {@link makeMap}'s naive recompute. */
880
+ const mapFromScratch = (rows, f) => indexById(rows.map((row) => ({
881
+ id: row.id,
882
+ value: f(row.value)
883
+ })));
884
+ /**
885
+ * `distinct` — collapse duplicate values to one row, keyed and identified by
886
+ * the value's canonical serialization (so value equality, not reference,
887
+ * decides duplication and there is no hash-collision risk). A multiplicity
888
+ * count per distinct value means removing one of several duplicates keeps the
889
+ * distinct row; the row appears when its count goes 0→1 and disappears at 1→0.
890
+ */
891
+ const makeDistinct = () => {
892
+ const counts = /* @__PURE__ */ new Map();
893
+ return { step: (delta) => {
894
+ const added = [];
895
+ const removed = [];
896
+ for (const row of delta.removed) {
897
+ const key = canonicalJson(row.value);
898
+ const entry = counts.get(key);
899
+ if (entry === void 0) continue;
900
+ entry.count -= 1;
901
+ if (entry.count === 0) {
902
+ counts.delete(key);
903
+ removed.push({
904
+ id: key,
905
+ value: entry.value
906
+ });
907
+ }
908
+ }
909
+ for (const row of delta.added) {
910
+ const key = canonicalJson(row.value);
911
+ const entry = counts.get(key);
912
+ if (entry === void 0) {
913
+ counts.set(key, {
914
+ count: 1,
915
+ value: row.value
916
+ });
917
+ added.push({
918
+ id: key,
919
+ value: row.value
920
+ });
921
+ } else entry.count += 1;
922
+ }
923
+ return {
924
+ added,
925
+ removed
926
+ };
927
+ } };
928
+ };
929
+ /** {@link makeDistinct}'s naive recompute (dedupe by canonical value). */
930
+ const distinctFromScratch = (rows) => {
931
+ const result = /* @__PURE__ */ new Map();
932
+ for (const row of rows) {
933
+ const key = canonicalJson(row.value);
934
+ if (!result.has(key)) result.set(key, {
935
+ id: key,
936
+ value: row.value
937
+ });
938
+ }
939
+ return result;
940
+ };
941
+ /**
942
+ * `groupBy` returning grouped **rows** (not only scalar aggregates): one output
943
+ * row per group, `value` = `{ key, rows }` where `rows` are the group members'
944
+ * values ordered by member id (a deterministic within-group order, so a
945
+ * from-scratch recompute matches). Only the one or two groups a delta touches
946
+ * re-materialize — every other group's row is untouched (the same
947
+ * touch-only-the-affected-bucket discipline as `Aggregate`'s grouped state).
948
+ */
949
+ const makeGroupBy = (key) => {
950
+ const groups = /* @__PURE__ */ new Map();
951
+ const groupOf = /* @__PURE__ */ new Map();
952
+ const lastRow = /* @__PURE__ */ new Map();
953
+ const groupRow = (groupKey) => {
954
+ return {
955
+ id: groupKey,
956
+ value: {
957
+ key: groupKey,
958
+ rows: [...groups.get(groupKey)?.entries() ?? []].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, value]) => value)
959
+ }
960
+ };
961
+ };
962
+ return { step: (delta) => {
963
+ const touched = /* @__PURE__ */ new Set();
964
+ for (const row of delta.removed) {
965
+ const groupKey = groupOf.get(row.id);
966
+ if (groupKey === void 0) continue;
967
+ const members = groups.get(groupKey);
968
+ members?.delete(row.id);
969
+ groupOf.delete(row.id);
970
+ if (members?.size === 0) groups.delete(groupKey);
971
+ touched.add(groupKey);
972
+ }
973
+ for (const row of delta.added) {
974
+ const groupKey = key(row.value);
975
+ let members = groups.get(groupKey);
976
+ if (members === void 0) {
977
+ members = /* @__PURE__ */ new Map();
978
+ groups.set(groupKey, members);
979
+ }
980
+ members.set(row.id, row.value);
981
+ groupOf.set(row.id, groupKey);
982
+ touched.add(groupKey);
983
+ }
984
+ const added = [];
985
+ const removed = [];
986
+ for (const groupKey of touched) {
987
+ const previous = lastRow.get(groupKey);
988
+ if (previous !== void 0) removed.push(previous);
989
+ if (groups.has(groupKey)) {
990
+ const next = groupRow(groupKey);
991
+ added.push(next);
992
+ lastRow.set(groupKey, next);
993
+ } else lastRow.delete(groupKey);
994
+ }
995
+ return {
996
+ added,
997
+ removed
998
+ };
999
+ } };
1000
+ };
1001
+ /** {@link makeGroupBy}'s naive recompute. */
1002
+ const groupByFromScratch = (rows, key) => {
1003
+ const groups = /* @__PURE__ */ new Map();
1004
+ for (const row of rows) {
1005
+ const groupKey = key(row.value);
1006
+ const bucket = groups.get(groupKey) ?? [];
1007
+ bucket.push([row.id, row.value]);
1008
+ groups.set(groupKey, bucket);
1009
+ }
1010
+ const result = /* @__PURE__ */ new Map();
1011
+ for (const [groupKey, bucket] of groups) {
1012
+ const ordered = bucket.toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, value]) => value);
1013
+ result.set(groupKey, {
1014
+ id: groupKey,
1015
+ value: {
1016
+ key: groupKey,
1017
+ rows: ordered
1018
+ }
1019
+ });
1020
+ }
1021
+ return result;
1022
+ };
1023
+ const NUL = "\0";
1024
+ const defaultCombine = (left, right) => ({
1025
+ left,
1026
+ right
1027
+ });
1028
+ /** A join key's canonical string, or `undefined` for a non-joining `null`/`undefined` key. */
1029
+ const joinKey = (value) => value === null || value === void 0 ? void 0 : canonicalJson(value);
1030
+ /** Build a {@link JoinOperator} for the given join keys and kind (inner or left). */
1031
+ const makeJoin = (options) => {
1032
+ const combine = options.combine ?? defaultCombine;
1033
+ const leftById = /* @__PURE__ */ new Map();
1034
+ const rightById = /* @__PURE__ */ new Map();
1035
+ const rightByKey = /* @__PURE__ */ new Map();
1036
+ const leftByKey = /* @__PURE__ */ new Map();
1037
+ const contribution = /* @__PURE__ */ new Map();
1038
+ const contributionOf = (leftId) => {
1039
+ const out = /* @__PURE__ */ new Map();
1040
+ const left = leftById.get(leftId);
1041
+ if (left === void 0) return out;
1042
+ const key = joinKey(options.leftKey(left.value));
1043
+ const rights = key === void 0 ? void 0 : rightByKey.get(key);
1044
+ if (rights !== void 0 && rights.size > 0) for (const [rightId, rightValue] of rights) {
1045
+ const id = `${leftId}${NUL}${rightId}`;
1046
+ out.set(id, {
1047
+ id,
1048
+ value: combine(left.value, rightValue)
1049
+ });
1050
+ }
1051
+ else if (options.kind === "left") {
1052
+ const id = `${leftId}${NUL}`;
1053
+ out.set(id, {
1054
+ id,
1055
+ value: combine(left.value, null)
1056
+ });
1057
+ }
1058
+ return out;
1059
+ };
1060
+ /** Recompute one left row's contribution and emit the diff against its last. */
1061
+ const reconcile = (leftId, added, removed) => {
1062
+ const previous = contribution.get(leftId) ?? /* @__PURE__ */ new Map();
1063
+ const next = contributionOf(leftId);
1064
+ for (const [id, row] of previous) if (!next.has(id)) removed.push(row);
1065
+ for (const [id, row] of next) {
1066
+ const prior = previous.get(id);
1067
+ if (prior === void 0) added.push(row);
1068
+ else if (contentHash(prior.value) !== contentHash(row.value)) {
1069
+ removed.push(prior);
1070
+ added.push(row);
1071
+ }
1072
+ }
1073
+ if (next.size > 0) contribution.set(leftId, next);
1074
+ else contribution.delete(leftId);
1075
+ };
1076
+ const indexLeft = (row) => {
1077
+ leftById.set(row.id, row);
1078
+ const key = joinKey(options.leftKey(row.value));
1079
+ if (key === void 0) return;
1080
+ const set = leftByKey.get(key) ?? /* @__PURE__ */ new Set();
1081
+ set.add(row.id);
1082
+ leftByKey.set(key, set);
1083
+ };
1084
+ const unindexLeft = (row) => {
1085
+ leftById.delete(row.id);
1086
+ const key = joinKey(options.leftKey(row.value));
1087
+ if (key === void 0) return;
1088
+ const set = leftByKey.get(key);
1089
+ if (set === void 0) return;
1090
+ set.delete(row.id);
1091
+ if (set.size === 0) leftByKey.delete(key);
1092
+ };
1093
+ const putRight = (row, keys) => {
1094
+ const key = joinKey(options.rightKey(row.value));
1095
+ rightById.set(row.id, row);
1096
+ if (key === void 0) return;
1097
+ const bucket = rightByKey.get(key) ?? /* @__PURE__ */ new Map();
1098
+ bucket.set(row.id, row.value);
1099
+ rightByKey.set(key, bucket);
1100
+ keys.add(key);
1101
+ };
1102
+ const removeRight = (row, keys) => {
1103
+ const key = joinKey(options.rightKey(row.value));
1104
+ rightById.delete(row.id);
1105
+ if (key === void 0) return;
1106
+ const bucket = rightByKey.get(key);
1107
+ if (bucket === void 0) return;
1108
+ bucket.delete(row.id);
1109
+ if (bucket.size === 0) rightByKey.delete(key);
1110
+ keys.add(key);
1111
+ };
1112
+ return {
1113
+ stepLeft: (delta) => {
1114
+ const affected = /* @__PURE__ */ new Set();
1115
+ for (const row of delta.removed) {
1116
+ unindexLeft(row);
1117
+ affected.add(row.id);
1118
+ }
1119
+ for (const row of delta.added) {
1120
+ const prior = leftById.get(row.id);
1121
+ if (prior !== void 0) unindexLeft(prior);
1122
+ indexLeft(row);
1123
+ affected.add(row.id);
1124
+ }
1125
+ const added = [];
1126
+ const removed = [];
1127
+ for (const leftId of affected) reconcile(leftId, added, removed);
1128
+ return {
1129
+ added,
1130
+ removed
1131
+ };
1132
+ },
1133
+ stepRight: (delta) => {
1134
+ const keys = /* @__PURE__ */ new Set();
1135
+ for (const row of delta.removed) removeRight(row, keys);
1136
+ for (const row of delta.added) {
1137
+ const prior = rightById.get(row.id);
1138
+ if (prior !== void 0) removeRight(prior, keys);
1139
+ putRight(row, keys);
1140
+ }
1141
+ const affectedLefts = /* @__PURE__ */ new Set();
1142
+ for (const key of keys) {
1143
+ const ids = leftByKey.get(key);
1144
+ if (ids !== void 0) for (const id of ids) affectedLefts.add(id);
1145
+ }
1146
+ const added = [];
1147
+ const removed = [];
1148
+ for (const leftId of affectedLefts) reconcile(leftId, added, removed);
1149
+ return {
1150
+ added,
1151
+ removed
1152
+ };
1153
+ }
1154
+ };
1155
+ };
1156
+ /** {@link makeJoin}'s naive recompute (nested-loop join by key equality). */
1157
+ const joinFromScratch = (left, right, options) => {
1158
+ const combine = options.combine ?? defaultCombine;
1159
+ const rightByKey = /* @__PURE__ */ new Map();
1160
+ for (const row of right) {
1161
+ const key = joinKey(options.rightKey(row.value));
1162
+ if (key === void 0) continue;
1163
+ const bucket = rightByKey.get(key) ?? [];
1164
+ bucket.push(row);
1165
+ rightByKey.set(key, bucket);
1166
+ }
1167
+ const result = /* @__PURE__ */ new Map();
1168
+ for (const leftRow of left) {
1169
+ const key = joinKey(options.leftKey(leftRow.value));
1170
+ const matches = key === void 0 ? void 0 : rightByKey.get(key);
1171
+ if (matches !== void 0 && Arr.isReadonlyArrayNonEmpty(matches)) for (const rightRow of matches) {
1172
+ const id = `${leftRow.id}${NUL}${rightRow.id}`;
1173
+ result.set(id, {
1174
+ id,
1175
+ value: combine(leftRow.value, rightRow.value)
1176
+ });
1177
+ }
1178
+ else if (options.kind === "left") {
1179
+ const id = `${leftRow.id}${NUL}`;
1180
+ result.set(id, {
1181
+ id,
1182
+ value: combine(leftRow.value, null)
1183
+ });
1184
+ }
1185
+ }
1186
+ return result;
1187
+ };
1188
+ /** The subset an ordered window maintains (an always-true `where` over derived rows). */
1189
+ const windowSubset = (orderBy, limit) => make$5({
1190
+ collection: "",
1191
+ orderBy,
1192
+ ...limit === void 0 ? {} : { limit }
1193
+ });
1194
+ /**
1195
+ * `orderedWindow` — sort derived rows by an `orderBy` and keep the first
1196
+ * `limit`. Reuses `LiveView` (the `OrderedTree` + fractional-index machinery of
1197
+ * #112): a delta is folded in as O(log n) `put`/`del`s and the window resliced
1198
+ * without a re-sort, so a change outside the page costs the same as one inside
1199
+ * it. Order is `Subset.comparator` — the one ordering authority (ADR-0026), so
1200
+ * "no orderBy" still means a stable id-ascending order.
1201
+ */
1202
+ const makeOrderedWindow = (orderBy, limit) => {
1203
+ const subset = windowSubset(orderBy, limit);
1204
+ let view = make$6(subset, []);
1205
+ return { step: (delta) => {
1206
+ for (const row of delta.removed) view = del(view, row.id);
1207
+ for (const row of delta.added) view = put(view, {
1208
+ id: row.id,
1209
+ value: row.value
1210
+ });
1211
+ return result(view).map((row) => ({
1212
+ id: row.id,
1213
+ value: row.value
1214
+ }));
1215
+ } };
1216
+ };
1217
+ /** {@link makeOrderedWindow}'s naive recompute (`Subset.apply`: sort then window). */
1218
+ const orderedWindowFromScratch = (rows, orderBy, limit) => apply(rows, windowSubset(orderBy, limit)).map((row) => ({
1219
+ id: row.id,
1220
+ value: row.value
1221
+ }));
1222
+ // src/Derive.ts
1223
+ var Derive_exports = /* @__PURE__ */ __exportAll({
1224
+ atom: () => atom,
1225
+ distinct: () => distinct,
1226
+ filter: () => filter,
1227
+ groupBy: () => groupBy,
1228
+ join: () => join,
1229
+ map: () => map,
1230
+ observed: () => observed,
1231
+ orderedWindow: () => orderedWindow,
1232
+ select: () => select
1233
+ });
1234
+ const CHANGES = Symbol.for("@thomasfosterau/legation/Derive/changes");
1235
+ const withChanges = (pages, changes) => Object.assign(pages, { [CHANGES]: changes });
1236
+ const isChangeTagged = (source) => CHANGES in source;
1237
+ const changesOf = (source) => isChangeTagged(source) ? source[CHANGES] : void 0;
1238
+ const sameRow = (a, b) => a === b || a.id === b.id && contentHash(a.value) === contentHash(b.value);
1239
+ /**
1240
+ * Two derived pages are equal when they have the same rows in the same order.
1241
+ * Set combinators emit in a stable id order (see {@link Incremental.pageOf}) and
1242
+ * `orderedWindow` in its comparator order, so a positional compare never sees a
1243
+ * spurious reorder — a genuine no-op step re-emits nothing.
1244
+ */
1245
+ const samePage = (a, b) => a.length === b.length && a.every((row, i) => {
1246
+ const other = b[i];
1247
+ return other !== void 0 && sameRow(row, other);
1248
+ });
1249
+ /**
1250
+ * Recover a delta per emission from any `Source`: when it carries the
1251
+ * {@link CHANGES} tag (built by `observed` over a `ClientView`), read the
1252
+ * delta `LiveView` already computed (`Incremental.deltaFromChanges`);
1253
+ * otherwise diff each page against the last snapshot
1254
+ * ({@link Incremental.diff}), the pre-#115 fallback for a plain page source.
1255
+ * Every combinator funnels through this so the tag needs checking once, not
1256
+ * once per combinator.
1257
+ */
1258
+ const toDeltaStream = (source) => {
1259
+ const changes = changesOf(source);
1260
+ return changes !== void 0 ? changes.pipe(Stream.mapAccum(() => /* @__PURE__ */ new Map(), (known, batch) => [known, [deltaFromChanges(known, batch)]])) : source.pipe(Stream.mapAccum(() => /* @__PURE__ */ new Map(), (last, page) => {
1261
+ const { delta, index } = diff(last, page);
1262
+ return [index, [delta]];
1263
+ }));
1264
+ };
1265
+ /**
1266
+ * Drive one delta→delta {@link Incremental.Operator} over a source's deltas
1267
+ * ({@link toDeltaStream}): step the operator, fold the output delta into a
1268
+ * maintained result, and emit the result as a page (deduped). The operator
1269
+ * (and its state) is built lazily per subscription, so one derived value
1270
+ * mounted from many components each gets its own maintained state.
1271
+ */
1272
+ const maintain = (source, makeOperator) => toDeltaStream(source).pipe(Stream.mapAccum(() => ({
1273
+ operator: makeOperator(),
1274
+ result: /* @__PURE__ */ new Map()
1275
+ }), (state, delta) => {
1276
+ applyDelta(state.result, state.operator.step(delta));
1277
+ return [state, [pageOf(state.result)]];
1278
+ }), Stream.changesWith(samePage));
1279
+ /**
1280
+ * Keep the rows whose value passes an arbitrary JS predicate. The predicate is
1281
+ * never serialized and never narrows what syncs — it shapes only what is shown
1282
+ * of the already-synced page (the boundary rule above).
1283
+ */
1284
+ const filter = (source, predicate) => maintain(source, () => makeFilter(predicate));
1285
+ /** Project each row's value through an arbitrary JS function, keeping its id. */
1286
+ const map = (source, f) => maintain(source, () => makeMap(f));
1287
+ /** {@inheritDoc map} — `select` reads better for a projection; same combinator. */
1288
+ const select = map;
1289
+ /** Collapse duplicate values to one row, keyed by the canonical value. */
1290
+ const distinct = (source) => maintain(source, () => makeDistinct());
1291
+ /**
1292
+ * Group rows by an arbitrary key function, emitting one row per group whose
1293
+ * value is `{ key, rows }` (the group's member values). A having-clause is
1294
+ * `filter` after `groupBy`; a scalar rollup is `map` over the group rows.
1295
+ */
1296
+ const groupBy = (source, key) => maintain(source, () => makeGroupBy(key));
1297
+ /**
1298
+ * Join two derived sources on arbitrary key equality (inner or left). Merges
1299
+ * the two page streams, feeding each side's delta to the shared join operator,
1300
+ * which re-derives only the left rows a change touches. A joined row's value is
1301
+ * `{ left, right }` by default (`right` is `null` for an unmatched left row
1302
+ * under `kind: "left"`); pass `combine` to shape it otherwise.
1303
+ */
1304
+ const join = (left, right, options) => {
1305
+ return Stream.merge(toDeltaStream(left).pipe(Stream.map((delta) => ({
1306
+ side: "left",
1307
+ delta
1308
+ }))), toDeltaStream(right).pipe(Stream.map((delta) => ({
1309
+ side: "right",
1310
+ delta
1311
+ })))).pipe(Stream.mapAccum(() => ({
1312
+ operator: makeJoin(options),
1313
+ result: /* @__PURE__ */ new Map()
1314
+ }), (state, event) => {
1315
+ const outDelta = event.side === "left" ? state.operator.stepLeft(event.delta) : state.operator.stepRight(event.delta);
1316
+ applyDelta(state.result, outDelta);
1317
+ return [state, [pageOf(state.result)]];
1318
+ }), Stream.changesWith(samePage));
1319
+ };
1320
+ /**
1321
+ * Sort derived rows by an `orderBy` and keep the first `limit` — an ORDER
1322
+ * BY … LIMIT over the derived result, maintained by `LiveView`'s `OrderedTree`
1323
+ * so a change outside the window costs the same as one inside it (ADR-0026's
1324
+ * one ordering authority applies, so "no orderBy" still means stable id order).
1325
+ */
1326
+ const orderedWindow = (source, options) => toDeltaStream(source).pipe(Stream.mapAccum(() => makeOrderedWindow(options.orderBy, options.limit), (window, delta) => [window, [window.step(delta)]]), Stream.changesWith(samePage));
1327
+ /**
1328
+ * Expose a derived pipeline as an Atom on the standard reactivity surface — the
1329
+ * same shape `LiveQuery.make(...).atom(...)` produces for a raw subset, so a
1330
+ * framework binding renders a derived value with no new machinery.
1331
+ */
1332
+ const atom = (source) => Atom.make(source);
1333
+ /**
1334
+ * Fold an `observeChanges` feed into a page stream without ever diffing a
1335
+ * page: each batch's delta ({@link Incremental.deltaFromChanges}) is folded
1336
+ * straight into a running result map. This is what `observed` returns when
1337
+ * the view has `observeChanges` — a real, standalone page stream (so it's
1338
+ * correct if consumed directly), separate from the {@link CHANGES} tag that
1339
+ * lets a combinator built on top skip this fold entirely and read the same
1340
+ * underlying batches itself.
1341
+ */
1342
+ const pagesFromChanges = (changes) => changes.pipe(Stream.mapAccum(() => ({
1343
+ known: /* @__PURE__ */ new Map(),
1344
+ result: /* @__PURE__ */ new Map()
1345
+ }), (state, batch) => {
1346
+ applyDelta(state.result, deltaFromChanges(state.known, batch));
1347
+ return [state, [pageOf(state.result)]];
1348
+ }), Stream.changesWith(samePage));
1349
+ /**
1350
+ * A scoped leaf source: acquire a refcounted subscription for `subset` (so the
1351
+ * rows actually sync) and observe it, releasing the subscription when the scope
1352
+ * closes. A pipeline built from `observed` leaves holds all its sources'
1353
+ * subscriptions for its lifetime and drops them together on teardown — the
1354
+ * refcounted-lifetime discipline the #114 brief asks for, `Subscriptions`
1355
+ * doing the reference counting so a shared leaf is synced once.
1356
+ *
1357
+ * When `view` is a full {@link ClientView.ClientView} (not just the narrower
1358
+ * {@link ClientView.Reader}), the returned source is built from its
1359
+ * `observeChanges` feed and tagged so every combinator composed on top reads
1360
+ * that feed directly too (see the module doc). A bare `Reader` has no
1361
+ * `observeChanges` to prefer, so this falls back to `observe`'s pages,
1362
+ * unchanged.
1363
+ */
1364
+ const observed = (subscriptions, view, subset) => Effect.gen(function* () {
1365
+ const handle = yield* subscriptions.acquire(subset);
1366
+ yield* Effect.addFinalizer(() => handle.release);
1367
+ if (!("observeChanges" in view)) return view.observe(subset);
1368
+ const changes = view.observeChanges(subset, { includeInitial: true });
1369
+ return withChanges(pagesFromChanges(changes), changes);
1370
+ });
1371
+ // src/LiveQuery.ts
1372
+ var LiveQuery_exports = /* @__PURE__ */ __exportAll({ make: () => make$2 });
1373
+ /** Build the Atom factory for a source's live queries. */
1374
+ const make$2 = (source) => {
1375
+ const cache = /* @__PURE__ */ new Map();
1376
+ return { atom: (subset) => {
1377
+ const key = hash(subset);
1378
+ const existing = cache.get(key);
1379
+ if (existing !== void 0) return existing;
1380
+ const created = Atom.make(source.observe(subset));
1381
+ cache.set(key, created);
1382
+ return created;
1383
+ } };
1384
+ };
1385
+ // src/EngineAtoms.ts
1386
+ var EngineAtoms_exports = /* @__PURE__ */ __exportAll({ fromLayer: () => fromLayer });
1387
+ /**
1388
+ * Build the engine's reactive surface from its layer. The layer must provide
1389
+ * `ClientEngine` with all of its own dependencies already satisfied —
1390
+ * `Atom.runtime` supplies only `AtomRegistry`/`Reactivity` and manages the
1391
+ * engine's scope for the registry's lifetime.
1392
+ */
1393
+ const fromLayer = (engineLayer) => {
1394
+ const runtime = Atom.runtime(engineLayer);
1395
+ const liveQueries = /* @__PURE__ */ new Map();
1396
+ const liveQuery = (subset) => {
1397
+ const key = hash(subset);
1398
+ const existing = liveQueries.get(key);
1399
+ if (existing !== void 0) return existing;
1400
+ const atom = runtime.atom(Stream.unwrap(Effect.map(ClientEngine, (engine) => engine.observe(subset))));
1401
+ liveQueries.set(key, atom);
1402
+ return atom;
1403
+ };
1404
+ const namedQueries = /* @__PURE__ */ new Map();
1405
+ const namedQuery = (name, args) => {
1406
+ const key = `${name}:${canonicalJson(args)}`;
1407
+ const existing = namedQueries.get(key);
1408
+ if (existing !== void 0) return existing;
1409
+ const atom = runtime.atom(Stream.unwrap(Effect.map(ClientEngine, (engine) => engine.observeNamed(name, args))));
1410
+ namedQueries.set(key, atom);
1411
+ return atom;
1412
+ };
1413
+ const aggregates = /* @__PURE__ */ new Map();
1414
+ const aggregate = (subset, agg) => {
1415
+ const key = `${hash(subset)}:${canonicalJson(agg)}`;
1416
+ const existing = aggregates.get(key);
1417
+ if (existing !== void 0) return existing;
1418
+ const atom = runtime.atom(Stream.unwrap(Effect.map(ClientEngine, (engine) => engine.observeAggregate(subset, agg))));
1419
+ aggregates.set(key, atom);
1420
+ return atom;
1421
+ };
1422
+ const groupedAggregates = /* @__PURE__ */ new Map();
1423
+ const aggregateGrouped = (subset, agg, groupBy) => {
1424
+ const key = `${hash(subset)}:${canonicalJson(agg)}:${groupBy}`;
1425
+ const existing = groupedAggregates.get(key);
1426
+ if (existing !== void 0) return existing;
1427
+ const atom = runtime.atom(Stream.unwrap(Effect.map(ClientEngine, (engine) => engine.observeAggregateGrouped(subset, agg, groupBy))));
1428
+ groupedAggregates.set(key, atom);
1429
+ return atom;
1430
+ };
1431
+ const mutation = runtime.fn((arg) => Effect.flatMap(ClientEngine, (engine) => engine.mutate(arg.mutator, arg.args)));
1432
+ const mutationWithAck = runtime.fn((arg) => Effect.flatMap(ClientEngine, (engine) => engine.mutateWithAck(arg.mutator, arg.args).pipe(Effect.flatMap((handle) => handle.acknowledged))));
1433
+ const mutationStates = runtime.atom(Stream.unwrap(Effect.map(ClientEngine, (engine) => engine.mutationStates)));
1434
+ const mutationsForCache = /* @__PURE__ */ new Map();
1435
+ const mutationsFor = (mutator) => {
1436
+ const existing = mutationsForCache.get(mutator);
1437
+ if (existing !== void 0) return existing;
1438
+ const atom = Atom.mapResult(mutationStates, (states) => [...HashMap.values(states)].filter((state) => state.mutator === mutator).toSorted((a, b) => a.mutationId - b.mutationId));
1439
+ mutationsForCache.set(mutator, atom);
1440
+ return atom;
1441
+ };
1442
+ return {
1443
+ runtime,
1444
+ liveQuery,
1445
+ namedQuery,
1446
+ aggregate,
1447
+ aggregateGrouped,
1448
+ mutation,
1449
+ mutationWithAck,
1450
+ mutationStates,
1451
+ mutationsFor
1452
+ };
1453
+ };
1454
+ // src/PromiseFacade.ts
1455
+ /**
1456
+ * PromiseFacade
1457
+ *
1458
+ * Helpers for driving the Effect-native sync engine from non-Effect code:
1459
+ * plain promises and callback subscriptions. This is what framework
1460
+ * bindings (React hooks, Vue composables, vanilla JS apps, service worker
1461
+ * scripts) build on.
1462
+ *
1463
+ * A facade wraps a `ManagedRuntime` built from the layer that provides
1464
+ * the engine's services. Effects are run with `run`/`runExit`, streams are
1465
+ * consumed with `subscribe`, and `dispose` tears everything down.
1466
+ *
1467
+ * To await a mutation's authoritative backend outcome (#36), run the handle's
1468
+ * `acknowledged` effect through `run`:
1469
+ * `facade.run(engine.mutateWithAck(m, a).pipe(Effect.flatMap((h) => h.acknowledged)))`.
1470
+ */
1471
+ var PromiseFacade_exports = /* @__PURE__ */ __exportAll({ make: () => make$1 });
1472
+ /**
1473
+ * Build a promise/callback facade from a layer providing `R`.
1474
+ *
1475
+ * ```ts
1476
+ * const facade = makePromiseFacade(
1477
+ * SyncCache.layer.pipe(Layer.provideMerge(MemoryCacheStore.layer()))
1478
+ * );
1479
+ * const value = await facade.run(
1480
+ * Effect.flatMap(SyncCache.SyncCache.asEffect(), (cache) =>
1481
+ * cache.read({ key: ["users", "1"], loader: fetchUser("1") })
1482
+ * )
1483
+ * );
1484
+ * await facade.dispose();
1485
+ * ```
1486
+ */
1487
+ const make$1 = (layer) => {
1488
+ const runtime = ManagedRuntime.make(layer);
1489
+ const run = (effect) => runtime.runPromise(effect);
1490
+ const runExit = (effect) => runtime.runPromiseExit(effect);
1491
+ const subscribe = (stream, listener) => {
1492
+ const consume = Stream.runForEach(stream, (value) => Effect.sync(() => listener.onValue(value)));
1493
+ const fiber = runtime.runFork(consume.pipe(Effect.onExit((exit) => Effect.sync(() => {
1494
+ if (Exit.isSuccess(exit)) listener.onDone?.();
1495
+ else if (Exit.isFailure(exit) && listener.onError !== void 0) {
1496
+ const failure = Cause.findFail(exit.cause);
1497
+ if (Result.isSuccess(failure)) listener.onError(failure.success.error);
1498
+ }
1499
+ }))));
1500
+ return { unsubscribe: () => {
1501
+ runtime.runFork(Fiber.interrupt(fiber));
1502
+ } };
1503
+ };
1504
+ return {
1505
+ runtime,
1506
+ run,
1507
+ runExit,
1508
+ subscribe,
1509
+ dispose: () => runtime.dispose()
1510
+ };
1511
+ };
1512
+ // src/MirrorChannel.ts
1513
+ var MirrorChannel_exports = /* @__PURE__ */ __exportAll({ makeLoopbackPair: () => makeLoopbackPair });
1514
+ /**
1515
+ * Two linked in-memory channels: messages posted on one side arrive on the
1516
+ * other. For tests and main-thread hosting (the documented degraded mode
1517
+ * for environments without service workers).
1518
+ */
1519
+ const makeLoopbackPair = Effect.gen(function* () {
1520
+ const makeHub = Effect.acquireRelease(PubSub.unbounded(), (hub) => PubSub.shutdown(hub));
1521
+ const toHost = yield* makeHub;
1522
+ const toMirror = yield* makeHub;
1523
+ const side = (outbound, inbound) => ({
1524
+ post: (message) => Effect.asVoid(PubSub.publish(outbound, message)),
1525
+ subscribe: PubSub.subscribe(inbound).pipe(Effect.map((subscription) => ({ take: PubSub.take(subscription) })))
1526
+ });
1527
+ return {
1528
+ host: side(toMirror, toHost),
1529
+ mirror: side(toHost, toMirror)
1530
+ };
1531
+ });
1532
+ // src/EngineHost.ts
1533
+ var EngineHost_exports = /* @__PURE__ */ __exportAll({ run: () => run$2 });
1534
+ /** Whether a registration's edges are all declared by the registry; the first that is not, otherwise. */
1535
+ const undeclaredEdge = (registry, collection, edges) => {
1536
+ const declared = relationshipsOf(registry, collection);
1537
+ return edges.find((edge) => !declared.has(edge.name));
1538
+ };
1539
+ /** The subscription an entry reports: its registration, with the URL while a party holds it by URL. */
1540
+ const subscriptionOf = (entry) => entry.registered === void 0 ? void 0 : {
1541
+ ...entry.registered.registration,
1542
+ ...entry.urlMirrors.size > 0 && entry.registered.url !== void 0 ? { url: entry.registered.url } : {}
1543
+ };
1544
+ /** A set with one member fewer. */
1545
+ const without$1 = (set, member) => {
1546
+ const next = new Set(set);
1547
+ next.delete(member);
1548
+ return next;
1549
+ };
1550
+ /** Liveness settings with their defaults applied. */
1551
+ const livenessOf = (options) => ({
1552
+ mirrorTimeout: options.liveness?.mirrorTimeout ?? "30 seconds",
1553
+ sweepInterval: options.liveness?.sweepInterval ?? "10 seconds"
1554
+ });
1555
+ /** The key a mirror's enhanced form submission is held under (#536): its mirror and the begin's requestId. */
1556
+ const formKey = (mirrorId, requestId) => `${mirrorId}:${requestId}`;
1557
+ /**
1558
+ * A reply's address (#546): the request's `requestId`, and its `mirrorId` when
1559
+ * the mirror sent one, so another tab with a request of the same number ignores
1560
+ * the reply. A mirror that predates it sends none and gets none back.
1561
+ */
1562
+ const replyTo = (request) => request.mirrorId === void 0 ? { requestId: request.requestId } : {
1563
+ requestId: request.requestId,
1564
+ mirrorId: request.mirrorId
1565
+ };
1566
+ /** Decode a {@link MirrorProtocol.FORM_MUTATION_MUTATOR} `Mutate`'s args. */
1567
+ const decodeFormBegin = Schema.decodeUnknownEffect(FormMutationBegin);
1568
+ /** Read just the mirror a malformed form begin names, to refuse it to that mirror. */
1569
+ const decodeFormSender = Schema.decodeUnknownEffect(Schema.Struct({ mirrorId: Schema.String }));
1570
+ /**
1571
+ * Serve the engine over the channel. The inbound subscription is
1572
+ * established before this effect completes; message handling runs on a
1573
+ * scoped background fiber.
1574
+ */
1575
+ const run$2 = (options) => Effect.gen(function* () {
1576
+ const { channel } = options;
1577
+ const engine = counted(options.engine);
1578
+ const inbox = yield* channel.subscribe;
1579
+ const active = yield* Ref.make(/* @__PURE__ */ new Map());
1580
+ const lastSeen = yield* Ref.make(/* @__PURE__ */ new Map());
1581
+ const marks = make$7(engine);
1582
+ const announced = yield* Ref.make(void 0);
1583
+ const announce = Effect.gen(function* () {
1584
+ const registrations = yield* marks.registrations;
1585
+ const serialised = JSON.stringify(registrations);
1586
+ if (serialised === (yield* Ref.get(announced))) return;
1587
+ yield* Ref.set(announced, serialised);
1588
+ yield* channel.post({
1589
+ _tag: "PulledRegistrations",
1590
+ registrations
1591
+ }).pipe(Effect.ignore);
1592
+ });
1593
+ const syncState = yield* Ref.make("idle");
1594
+ const syncUntilSettled = engine.pull.pipe(Effect.ignore, Effect.andThen(Ref.modify(syncState, (state) => state === "again" ? [true, "running"] : [false, "idle"])), Effect.flatMap((again) => again ? syncUntilSettled : Effect.void));
1595
+ const syncRegistrations = Ref.modify(syncState, (state) => state === "idle" ? [true, "running"] : [false, "again"]).pipe(Effect.flatMap((started) => started ? Effect.forkScoped(syncUntilSettled) : Effect.void), Effect.asVoid);
1596
+ const touch = (mirrorId) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Ref.update(lastSeen, (map) => new Map(map).set(mirrorId, now))));
1597
+ const forget = (mirrorId) => Ref.update(lastSeen, (map) => {
1598
+ const next = new Map(map);
1599
+ next.delete(mirrorId);
1600
+ return next;
1601
+ });
1602
+ const broadcast = (key, rows) => channel.post(mirrorUpdate(key, rows));
1603
+ const broadcastRelated = (key, rows) => channel.post(relatedUpdate(key, rows));
1604
+ const broadcastMutationStates = (states) => channel.post(mutationStatesUpdate(states));
1605
+ const currentMutationStates = Stream.runHead(engine.mutationStates).pipe(Effect.map(Option.getOrElse(() => HashMap.empty())));
1606
+ /**
1607
+ * Register (or refcount) one broadcastable registration. `start` begins
1608
+ * the engine registration and returns the broadcast loop to fork; a mirror
1609
+ * joining an already-active key is hydrated from the current result. What
1610
+ * a read's registration covers is recorded only once `start` has
1611
+ * succeeded — the engine holds the windows — and its URL identity while
1612
+ * some mirror holds the key by URL.
1613
+ */
1614
+ const acquire = (mirrorId, key, start, registered) => Effect.gen(function* () {
1615
+ const byUrl = registered?.url !== void 0;
1616
+ const existing = (yield* Ref.get(active)).get(key);
1617
+ if (existing !== void 0) {
1618
+ const known = existing.registered?.url === void 0 ? registered : existing.registered;
1619
+ yield* Ref.update(active, (map) => new Map(map).set(key, {
1620
+ ...existing,
1621
+ mirrors: new Set(existing.mirrors).add(mirrorId),
1622
+ urlMirrors: byUrl ? new Set(existing.urlMirrors).add(mirrorId) : existing.urlMirrors,
1623
+ ...known === void 0 ? {} : { registered: known }
1624
+ }));
1625
+ return yield* existing.hydrate;
1626
+ }
1627
+ const { loop, teardown, hydrate } = yield* start;
1628
+ const fiber = yield* Effect.forkScoped(loop.pipe(Effect.ignore));
1629
+ yield* Ref.update(active, (map) => new Map(map).set(key, {
1630
+ mirrors: /* @__PURE__ */ new Set([mirrorId]),
1631
+ urlMirrors: new Set(byUrl ? [mirrorId] : []),
1632
+ fiber,
1633
+ teardown,
1634
+ hydrate,
1635
+ ...registered === void 0 ? {} : { registered }
1636
+ }));
1637
+ yield* announce;
1638
+ });
1639
+ /** Drop one mirror from one registration; tear it down if it was the last. */
1640
+ const release = (mirrorId, key) => Effect.gen(function* () {
1641
+ const entry = (yield* Ref.get(active)).get(key);
1642
+ if (entry === void 0 || !entry.mirrors.has(mirrorId)) return;
1643
+ const mirrors = without$1(entry.mirrors, mirrorId);
1644
+ if (mirrors.size > 0) {
1645
+ yield* Ref.update(active, (map) => new Map(map).set(key, {
1646
+ ...entry,
1647
+ mirrors,
1648
+ urlMirrors: without$1(entry.urlMirrors, mirrorId)
1649
+ }));
1650
+ return;
1651
+ }
1652
+ yield* Fiber.interrupt(entry.fiber);
1653
+ yield* entry.teardown.pipe(Effect.ignore);
1654
+ yield* Ref.update(active, (map) => {
1655
+ const next = new Map(map);
1656
+ next.delete(key);
1657
+ return next;
1658
+ });
1659
+ yield* announce;
1660
+ });
1661
+ const handleSubscribe = (mirrorId, subset, url) => {
1662
+ const key = hash(subset);
1663
+ return acquire(mirrorId, key, Effect.gen(function* () {
1664
+ yield* marks.open(key, subset);
1665
+ yield* marks.wrap(key).subscribe(subset).pipe(Effect.tapError(() => marks.close(key)));
1666
+ yield* syncRegistrations;
1667
+ return {
1668
+ loop: Stream.runForEach(engine.observe(subset), (rows) => broadcast(key, rows)).pipe(Effect.asVoid),
1669
+ teardown: marks.wrap(key).unsubscribe(subset).pipe(Effect.ignore, Effect.andThen(marks.close(key))),
1670
+ hydrate: engine.query(subset).pipe(Effect.flatMap((rows) => broadcast(key, rows)), Effect.ignore)
1671
+ };
1672
+ }), {
1673
+ registration: {
1674
+ root: subset,
1675
+ edges: []
1676
+ },
1677
+ ...url === void 0 ? {} : { url }
1678
+ });
1679
+ };
1680
+ const handleSubscribeNamed = (mirrorId, name, args) => {
1681
+ const key = namedKey(name, args);
1682
+ return acquire(mirrorId, key, Effect.gen(function* () {
1683
+ yield* engine.subscribeQuery(name, args);
1684
+ yield* engine.pull.pipe(Effect.ignore);
1685
+ return {
1686
+ loop: Stream.runForEach(engine.observeNamed(name, args), (rows) => broadcast(key, rows)).pipe(Effect.asVoid),
1687
+ teardown: engine.unsubscribeQuery(name, args).pipe(Effect.ignore),
1688
+ hydrate: engine.queryNamed(name, args).pipe(Effect.flatMap((rows) => broadcast(key, rows)), Effect.ignore)
1689
+ };
1690
+ }));
1691
+ };
1692
+ const handleSubscribeRelated = (mirrorId, collection, subset, edges, url) => {
1693
+ const key = relatedKey(collection, subset, edges);
1694
+ const registry = options.registry ?? options.nodes?.collections;
1695
+ if (registry === void 0) return Effect.logWarning("EngineHost: SubscribeRelated received but no collection registry was configured").pipe(Effect.andThen(broadcastRelated(key, [])), Effect.ignore);
1696
+ return acquire(mirrorId, key, Effect.gen(function* () {
1697
+ const undeclared = undeclaredEdge(registry, collection, edges);
1698
+ if (undeclared !== void 0) return yield* Effect.fail(new QueryError({
1699
+ message: `EngineHost: SubscribeRelated on "${collection}" names an edge the registry does not declare: "${undeclared.name}"`,
1700
+ query: collection
1701
+ }));
1702
+ const scope = yield* Scope.fork(yield* Effect.scope);
1703
+ yield* marks.open(key, subset);
1704
+ const stream = yield* related(marks.wrap(key), registry, collection, subset, edges).pipe(Scope.provide(scope), Effect.tapError(() => marks.close(key)));
1705
+ return {
1706
+ loop: Stream.runForEach(stream, (rows) => broadcastRelated(key, rows)),
1707
+ teardown: Scope.close(scope, Exit.void).pipe(Effect.andThen(marks.close(key))),
1708
+ hydrate: query(engine, registry, collection, subset, edges).pipe(Effect.flatMap((rows) => broadcastRelated(key, rows)), Effect.ignore)
1709
+ };
1710
+ }).pipe(Effect.tapError((error) => Effect.logWarning(`EngineHost: SubscribeRelated rejected: ${String(error)}`))), {
1711
+ registration: {
1712
+ root: subset,
1713
+ edges
1714
+ },
1715
+ ...url === void 0 ? {} : { url }
1716
+ });
1717
+ };
1718
+ const handleSubscribeMutationStates = (mirrorId) => acquire(mirrorId, MUTATION_STATES_KEY, Effect.succeed({
1719
+ loop: Stream.runForEach(engine.mutationStates, broadcastMutationStates).pipe(Effect.asVoid),
1720
+ teardown: Effect.void,
1721
+ hydrate: currentMutationStates.pipe(Effect.flatMap(broadcastMutationStates), Effect.ignore)
1722
+ }));
1723
+ const { nodes, urls } = options;
1724
+ const registrationOfUrl = (url) => urls === void 0 || nodes === void 0 ? Effect.fail(new QueryError({
1725
+ message: "EngineHost: SubscribeUrl received but no `nodes`/`urls` were configured",
1726
+ query: url
1727
+ })) : Effect.gen(function* () {
1728
+ const read = yield* urls.parse(url);
1729
+ const plan = yield* tryPlan(nodes, read);
1730
+ return {
1731
+ ...ofPlan(plan),
1732
+ url: yield* urls.canonical(read)
1733
+ };
1734
+ });
1735
+ const handleSubscribeUrl = (mirrorId, url) => registrationOfUrl(url).pipe(Effect.tapError((error) => Effect.logWarning(`EngineHost: SubscribeUrl "${url}" rejected: ${error.message}`)), Effect.flatMap(({ root, edges, url: canonical }) => Arr.isReadonlyArrayEmpty(edges) ? handleSubscribe(mirrorId, root, canonical) : handleSubscribe(mirrorId, root).pipe(Effect.andThen(handleSubscribeRelated(mirrorId, root.collection, root, edges, canonical)))));
1736
+ const handleUnsubscribeUrl = (mirrorId, url) => registrationOfUrl(url).pipe(Effect.flatMap(({ root, edges }) => release(mirrorId, hash(root)).pipe(Effect.andThen(Arr.isReadonlyArrayEmpty(edges) ? Effect.void : release(mirrorId, relatedKey(root.collection, root, edges))))), Effect.ignore);
1737
+ const handleRead = (request, subsets) => (engine.readThrough === void 0 ? Effect.fail(new TransportError({ message: "Host transport does not support reads" })) : engine.readThrough(subsets)).pipe(Effect.flatMap((rows) => channel.post({
1738
+ _tag: "ReadResult",
1739
+ ...replyTo(request),
1740
+ rows: rows.map((row) => ({
1741
+ collection: row.collection,
1742
+ id: row.id,
1743
+ value: row.value
1744
+ }))
1745
+ })), Effect.catch((error) => channel.post({
1746
+ _tag: "ReadResult",
1747
+ ...replyTo(request),
1748
+ rows: [],
1749
+ error: error.message
1750
+ })));
1751
+ const forms = yield* Ref.make(HashMap.empty());
1752
+ const takeForm = (key) => Ref.modify(forms, (map) => [HashMap.get(map, key), HashMap.remove(map, key)]);
1753
+ const beginForm = (requestId, begin) => touch(begin.mirrorId).pipe(Effect.andThen(engine.beginFormMutationEncoded(begin.form, begin.mutator, begin.args)), Effect.flatMap((submission) => Ref.update(forms, HashMap.set(formKey(begin.mirrorId, requestId), submission))), Effect.matchEffect({
1754
+ onFailure: (error) => channel.post({
1755
+ _tag: "FormMutationFailed",
1756
+ mirrorId: begin.mirrorId,
1757
+ requestId,
1758
+ message: error.message
1759
+ }),
1760
+ onSuccess: () => channel.post({
1761
+ _tag: "FormMutationBegun",
1762
+ mirrorId: begin.mirrorId,
1763
+ requestId
1764
+ })
1765
+ }));
1766
+ const handleBeginForm = (message) => decodeFormBegin(message.args).pipe(Effect.matchEffect({
1767
+ onSuccess: (begin) => beginForm(message.requestId, begin),
1768
+ onFailure: (error) => decodeFormSender(message.args).pipe(Effect.flatMap(({ mirrorId }) => channel.post({
1769
+ _tag: "FormMutationFailed",
1770
+ mirrorId,
1771
+ requestId: message.requestId,
1772
+ message: error.message
1773
+ })), Effect.catch(() => Effect.logWarning("EngineHost: dropped a form begin that names no mirror")))
1774
+ }));
1775
+ const handleEndForm = (message) => takeForm(formKey(message.mirrorId, message.requestId)).pipe(Effect.flatMap(Option.match({
1776
+ onNone: () => Effect.void,
1777
+ onSome: (submission) => message.ruling === void 0 ? submission.abandon : submission.settle(message.ruling)
1778
+ })), Effect.matchEffect({
1779
+ onFailure: (error) => channel.post({
1780
+ _tag: "FormMutationEnded",
1781
+ mirrorId: message.mirrorId,
1782
+ requestId: message.requestId,
1783
+ error: error.message
1784
+ }),
1785
+ onSuccess: () => channel.post({
1786
+ _tag: "FormMutationEnded",
1787
+ mirrorId: message.mirrorId,
1788
+ requestId: message.requestId
1789
+ })
1790
+ }));
1791
+ const abandonForms = (mirrorId) => Ref.modify(forms, (map) => {
1792
+ const prefix = `${mirrorId}:`;
1793
+ return [[...HashMap.values(HashMap.filter(map, (_, key) => key.startsWith(prefix)))], HashMap.filter(map, (_, key) => !key.startsWith(prefix))];
1794
+ }).pipe(Effect.flatMap((held) => Effect.forEach(held, (submission) => submission.abandon, {
1795
+ concurrency: 1,
1796
+ discard: true
1797
+ })));
1798
+ const handleDisconnect = (mirrorId) => Effect.gen(function* () {
1799
+ const keys = [...yield* Ref.get(active)].filter(([, entry]) => entry.mirrors.has(mirrorId)).map(([key]) => key);
1800
+ yield* Effect.forEach(keys, (key) => Effect.gen(function* () {
1801
+ yield* release(mirrorId, key);
1802
+ }), {
1803
+ concurrency: 1,
1804
+ discard: true
1805
+ });
1806
+ yield* abandonForms(mirrorId);
1807
+ yield* forget(mirrorId);
1808
+ });
1809
+ const postSettled = (request, outcome) => channel.post({
1810
+ _tag: "MutateSettled",
1811
+ ...replyTo(request),
1812
+ outcome: outcome._tag,
1813
+ ...outcome._tag === "rejected" ? { reason: outcome.reason } : {}
1814
+ });
1815
+ const handleMutate = (message) => message.mutator === "@thomasfosterau/legation/beginFormMutation" ? handleBeginForm(message) : handleJournaledMutate(message);
1816
+ const handleJournaledMutate = (message) => engine.mutateWithAck(message.mutator, message.args).pipe(Effect.flatMap((mutation) => channel.post({
1817
+ _tag: "MutateAck",
1818
+ ...replyTo(message)
1819
+ }).pipe(Effect.andThen(Effect.forkScoped(mutation.acknowledged.pipe(Effect.flatMap((outcome) => postSettled(message, outcome))))))), Effect.catch((error) => channel.post({
1820
+ _tag: "MutateError",
1821
+ ...replyTo(message),
1822
+ message: error.message
1823
+ })));
1824
+ const handle = (message) => {
1825
+ switch (message._tag) {
1826
+ case "Mutate": return handleMutate(message);
1827
+ case "EndFormMutation": return touch(message.mirrorId).pipe(Effect.andThen(handleEndForm(message)));
1828
+ case "Subscribe": return touch(message.mirrorId).pipe(Effect.andThen(handleSubscribe(message.mirrorId, message.subset)));
1829
+ case "Unsubscribe": return touch(message.mirrorId).pipe(Effect.andThen(release(message.mirrorId, hash(message.subset))));
1830
+ case "SubscribeNamed": return touch(message.mirrorId).pipe(Effect.andThen(handleSubscribeNamed(message.mirrorId, message.name, message.args)));
1831
+ case "UnsubscribeNamed": return touch(message.mirrorId).pipe(Effect.andThen(release(message.mirrorId, namedKey(message.name, message.args))));
1832
+ case "SubscribeRelated": return touch(message.mirrorId).pipe(Effect.andThen(handleSubscribeRelated(message.mirrorId, message.collection, message.subset, message.edges)));
1833
+ case "UnsubscribeRelated": return touch(message.mirrorId).pipe(Effect.andThen(release(message.mirrorId, relatedKey(message.collection, message.subset, message.edges))));
1834
+ case "SubscribeUrl": return touch(message.mirrorId).pipe(Effect.andThen(handleSubscribeUrl(message.mirrorId, message.url)));
1835
+ case "UnsubscribeUrl": return touch(message.mirrorId).pipe(Effect.andThen(handleUnsubscribeUrl(message.mirrorId, message.url)));
1836
+ case "Read": return handleRead(message, message.subsets);
1837
+ case "SubscribeMutationStates": return touch(message.mirrorId).pipe(Effect.andThen(handleSubscribeMutationStates(message.mirrorId)));
1838
+ case "UnsubscribeMutationStates": return touch(message.mirrorId).pipe(Effect.andThen(release(message.mirrorId, MUTATION_STATES_KEY)));
1839
+ case "Disconnect": return handleDisconnect(message.mirrorId);
1840
+ case "Heartbeat": return touch(message.mirrorId);
1841
+ }
1842
+ };
1843
+ const { mirrorTimeout, sweepInterval } = livenessOf(options);
1844
+ const sweep = Effect.gen(function* () {
1845
+ const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(mirrorTimeout);
1846
+ const stale = [...yield* Ref.get(lastSeen)].filter(([, seen]) => seen < cutoff).map(([mirrorId]) => mirrorId);
1847
+ yield* Effect.forEach(stale, (mirrorId) => Effect.gen(function* () {
1848
+ yield* handleDisconnect(mirrorId);
1849
+ }), {
1850
+ concurrency: 1,
1851
+ discard: true
1852
+ });
1853
+ });
1854
+ yield* Effect.forkScoped(Effect.forever(sweep.pipe(Effect.andThen(Effect.sleep(sweepInterval)))));
1855
+ const pullEvents = yield* Stream.toPull(engine.engineEvents);
1856
+ yield* Effect.forkScoped(Effect.forever(pullEvents.pipe(Effect.flatMap((events) => Effect.forEach(events, (event) => event._tag === "PullCompleted" ? announce : Effect.void, { discard: true })))).pipe(Effect.ignore));
1857
+ yield* Effect.forkScoped(Effect.forever(inbox.take.pipe(Effect.flatMap(decodeMirrorMessage), Effect.flatMap((message) => message === void 0 ? Effect.void : handle(message).pipe(Effect.ignore)))));
1858
+ yield* announce;
1859
+ yield* channel.post({ _tag: "HostReady" }).pipe(Effect.ignore);
1860
+ return { registrations: Effect.all({
1861
+ entries: Ref.get(active),
1862
+ pulled: marks.registrations
1863
+ }).pipe(Effect.map(({ entries, pulled }) => {
1864
+ const summaries = new Map(pulled.map((entry) => [entry.key, entry.pulled]));
1865
+ return [...entries].flatMap(([key, entry]) => {
1866
+ const subscription = subscriptionOf(entry);
1867
+ return subscription === void 0 ? [] : [{
1868
+ ...subscription,
1869
+ pulled: summaries.get(key) ?? NOT_PULLED
1870
+ }];
1871
+ });
1872
+ })) };
1873
+ });
1874
+ // src/FilterCorpus.ts
1875
+ /**
1876
+ * FilterCorpus
1877
+ *
1878
+ * Seeded generators for the differential filter harness (issue #153,
1879
+ * ADR-0028). Three evaluators of one predicate AST exist — `Predicate.evaluate`
1880
+ * in JS, `Sql.compileWhere` on SQLite, and `Sql.compileWhere` on Postgres —
1881
+ * and Postgres is normative: every other evaluator must agree with it
1882
+ * exactly. This module produces the corpus they are compared over: random
1883
+ * rows over a fixed fixture schema (text, integer, double, boolean, ISO
1884
+ * instant) including NULL, absent fields, empty strings, boundary numerics,
1885
+ * and non-ASCII text up to astral characters; random depth-bounded
1886
+ * predicates covering every operator in the core; random `orderBy` terms;
1887
+ * and a minimiser that shrinks a disagreeing predicate/row pair to its
1888
+ * smallest reproducing form.
1889
+ *
1890
+ * The generators are Effects over Effect's `Random` service, and
1891
+ * `generateCorpus` runs them under `Random.withSeed(seed)` — Effect's own
1892
+ * seedable generator — so a seed reproduces a corpus exactly and a failure
1893
+ * reports the seed. This is the repo's shared corpus module; the property
1894
+ * tests elsewhere still carry their own hand-rolled mulberry32 copies (no
1895
+ * `fast-check` in the dependency set), and `Random.withSeed` is the idiom
1896
+ * to reach for instead of another copy.
1897
+ *
1898
+ * Rule (d) of ADR-0028 — no cross-type comparison — is enforced here by
1899
+ * construction: a leaf's literal always has its column's kind. Rule (e)
1900
+ * fixes the literal kinds (`Predicate.LiteralKind`): `text`, `integer` (int4
1901
+ * range), `double` precision, `boolean`, and `instant` as `Date#toISOString`
1902
+ * text compared as text.
1903
+ *
1904
+ * `Exists` is never generated; it is relational and has no single-row
1905
+ * meaning.
1906
+ */
1907
+ var FilterCorpus_exports = /* @__PURE__ */ __exportAll({
1908
+ DEFAULT_SEED: () => DEFAULT_SEED,
1909
+ corpusColumns: () => corpusColumns,
1910
+ emptyCoverage: () => emptyCoverage,
1911
+ formatDisagreement: () => formatDisagreement,
1912
+ generateCorpus: () => generateCorpus,
1913
+ generateOrderBy: () => generateOrderBy,
1914
+ generatePredicate: () => generatePredicate,
1915
+ generateRows: () => generateRows,
1916
+ leafOperators: () => leafOperators,
1917
+ minimise: () => minimise,
1918
+ minimumRows: () => minimumRows,
1919
+ resolveSeed: () => resolveSeed,
1920
+ shrinkPredicate: () => shrinkPredicate
1921
+ });
1922
+ /** A uniform integer in `[0, max)`. */
1923
+ const int = (max) => Random.nextIntBetween(0, max, { halfOpen: true });
1924
+ /** A uniform pick from a non-empty list (an empty one is a generator bug). */
1925
+ const pick = (xs) => Random.choice(xs).pipe(Effect.orDie);
1926
+ /** `true` with probability `p`. */
1927
+ const chance = (p) => Effect.map(Random.next, (roll) => roll < p);
1928
+ /** The default corpus seed; `LEGATION_CORPUS_SEED` overrides it. */
1929
+ const DEFAULT_SEED = 20260902;
1930
+ /**
1931
+ * Resolve the corpus seed from an optional environment override (the value
1932
+ * of `LEGATION_CORPUS_SEED`). Callers pass the variable in so this module
1933
+ * stays free of `process` (it ships in the engine core, which also runs in
1934
+ * browsers).
1935
+ */
1936
+ const resolveSeed = (override) => {
1937
+ if (override === void 0 || override.trim() === "") return DEFAULT_SEED;
1938
+ const seed = Number(override);
1939
+ if (!Number.isInteger(seed)) throw new Error(`LEGATION_CORPUS_SEED must be an integer, got ${JSON.stringify(override)}`);
1940
+ return seed;
1941
+ };
1942
+ /**
1943
+ * The harness's fixture table `corpus`: every non-id column is nullable.
1944
+ * Postgres declares `COLLATE "C"` on `id` and `s` and stores `t` as
1945
+ * `timestamptz`; SQLite uses `BINARY` text throughout.
1946
+ */
1947
+ const corpusColumns = [
1948
+ {
1949
+ name: "s",
1950
+ kind: "text"
1951
+ },
1952
+ {
1953
+ name: "i",
1954
+ kind: "integer"
1955
+ },
1956
+ {
1957
+ name: "d",
1958
+ kind: "double"
1959
+ },
1960
+ {
1961
+ name: "b",
1962
+ kind: "boolean"
1963
+ },
1964
+ {
1965
+ name: "t",
1966
+ kind: "instant"
1967
+ }
1968
+ ];
1969
+ const resolveOptions = (options) => ({
1970
+ nulls: options?.nulls ?? true,
1971
+ astral: options?.astral ?? true,
1972
+ booleanOrder: options?.booleanOrder ?? true
1973
+ });
1974
+ const ASCII_STRINGS = [
1975
+ "",
1976
+ "a",
1977
+ "b",
1978
+ "abc",
1979
+ "abd",
1980
+ "A",
1981
+ "Z",
1982
+ "z",
1983
+ " ",
1984
+ "a b",
1985
+ "0",
1986
+ "10",
1987
+ "9"
1988
+ ];
1989
+ /** BMP non-ASCII: Latin-1, a private-use character, and the U+FFFF noncharacter. */
1990
+ const BMP_STRINGS = [
1991
+ "é",
1992
+ "ß",
1993
+ "éa",
1994
+ "",
1995
+ "￿",
1996
+ "ア"
1997
+ ];
1998
+ /** Astral characters, alone and mixed with BMP text. */
1999
+ const ASTRAL_STRINGS = [
2000
+ "😀",
2001
+ "𝔘",
2002
+ "􏿿",
2003
+ "a😀",
2004
+ "😀a",
2005
+ "￿😀",
2006
+ "é😀",
2007
+ "😀😀"
2008
+ ];
2009
+ const FOREIGN_STRINGS = [
2010
+ "nope",
2011
+ "zz",
2012
+ "è"
2013
+ ];
2014
+ const INTEGERS = [
2015
+ 0,
2016
+ -0,
2017
+ 1,
2018
+ -1,
2019
+ 2,
2020
+ 3,
2021
+ 7,
2022
+ 10,
2023
+ 42,
2024
+ 100,
2025
+ -7,
2026
+ 2147483647,
2027
+ -2147483648,
2028
+ 2147483646
2029
+ ];
2030
+ const FOREIGN_INTEGERS = [
2031
+ 5,
2032
+ -100,
2033
+ 12345
2034
+ ];
2035
+ const DOUBLES = [
2036
+ 0,
2037
+ -0,
2038
+ .5,
2039
+ -.5,
2040
+ 1e-7,
2041
+ 1.5,
2042
+ 2 ** 53 - 1,
2043
+ 2 ** 53,
2044
+ 2 ** 53 + 2,
2045
+ 1e300,
2046
+ -1e-300,
2047
+ 3.25,
2048
+ 100.125,
2049
+ -2.5
2050
+ ];
2051
+ const FOREIGN_DOUBLES = [2.75, 1e-6];
2052
+ const INSTANTS = [
2053
+ "1970-01-01T00:00:00.000Z",
2054
+ "1999-12-31T23:59:59.999Z",
2055
+ "2000-01-01T00:00:00.001Z",
2056
+ "2020-02-29T12:34:56.789Z",
2057
+ "2024-01-01T00:00:00.000Z",
2058
+ "2038-01-19T03:14:07.000Z"
2059
+ ];
2060
+ const FOREIGN_INSTANTS = ["2001-09-09T01:46:40.000Z"];
2061
+ const poolFor = (kind, options) => {
2062
+ switch (kind) {
2063
+ case "text": return {
2064
+ rows: [
2065
+ ...ASCII_STRINGS,
2066
+ ...BMP_STRINGS,
2067
+ ...options.astral ? ASTRAL_STRINGS : []
2068
+ ],
2069
+ foreign: FOREIGN_STRINGS
2070
+ };
2071
+ case "integer": return {
2072
+ rows: INTEGERS,
2073
+ foreign: FOREIGN_INTEGERS
2074
+ };
2075
+ case "double": return {
2076
+ rows: DOUBLES,
2077
+ foreign: FOREIGN_DOUBLES
2078
+ };
2079
+ case "boolean": return {
2080
+ rows: [true, false],
2081
+ foreign: []
2082
+ };
2083
+ case "instant": return {
2084
+ rows: INSTANTS,
2085
+ foreign: FOREIGN_INSTANTS
2086
+ };
2087
+ }
2088
+ };
2089
+ const NULL_RATE = .12;
2090
+ const planRows = (columns, options) => {
2091
+ const pools = columns.map((column) => poolFor(column.kind, options).rows);
2092
+ return {
2093
+ columns,
2094
+ pools,
2095
+ nulls: options.nulls,
2096
+ pinned: options.nulls ? 2 : 0,
2097
+ sweep: Math.max(0, ...pools.map((pool) => pool.length))
2098
+ };
2099
+ };
2100
+ /** A random field value: NULL, absent (`undefined`), or a pool value. */
2101
+ const randomField = (pool, nulls) => Effect.gen(function* () {
2102
+ if (nulls) {
2103
+ const roll = yield* Random.next;
2104
+ if (roll < NULL_RATE) return null;
2105
+ if (roll < .2) return void 0;
2106
+ }
2107
+ return yield* pick(pool);
2108
+ });
2109
+ const generateRow = (plan, index) => Effect.gen(function* () {
2110
+ const value = {};
2111
+ yield* Effect.forEach(plan.columns.entries(), ([c, column]) => Effect.gen(function* () {
2112
+ const pool = plan.pools[c] ?? [];
2113
+ let field;
2114
+ if (index < plan.pinned) field = index === 0 ? null : void 0;
2115
+ else if (index < plan.pinned + plan.sweep) field = pool[(index - plan.pinned) % pool.length];
2116
+ else field = yield* randomField(pool, plan.nulls);
2117
+ if (field !== void 0) value[column.name] = field;
2118
+ }), {
2119
+ concurrency: 1,
2120
+ discard: true
2121
+ });
2122
+ return {
2123
+ id: `r${String(index).padStart(3, "0")}`,
2124
+ value
2125
+ };
2126
+ });
2127
+ /**
2128
+ * The fewest rows {@link generateRows} accepts for `columns` under
2129
+ * `options`: the pinned NULL/absent rows plus the sweep that puts every pool
2130
+ * value into every column. Fewer would silently drop guaranteed edge values.
2131
+ */
2132
+ const minimumRows = (columns = corpusColumns, options) => {
2133
+ const plan = planRows(columns, resolveOptions(options));
2134
+ return plan.pinned + plan.sweep;
2135
+ };
2136
+ /**
2137
+ * Generate `count` rows over `columns`. Coverage of the edge values is
2138
+ * guaranteed, not left to chance: with `nulls` on, row 0 is all-NULL and row
2139
+ * 1 all-absent; then one row per pool position sweeps every pool value into
2140
+ * every column (int4 bounds, `-0`, `2^53±1`, the empty string, U+FFFF, the
2141
+ * astral characters, …); the remaining rows are random with NULL/absent
2142
+ * rolls. `count` below {@link minimumRows} is an error rather than a
2143
+ * truncated sweep. Ids are zero-padded ASCII (`r000`, `r001`, …) so their
2144
+ * order is the same in JS, SQLite `BINARY`, and Postgres `COLLATE "C"`,
2145
+ * keeping the id tie-break comparable across legs.
2146
+ */
2147
+ const generateRows = (count, columns = corpusColumns, options) => {
2148
+ const plan = planRows(columns, resolveOptions(options));
2149
+ const minimum = plan.pinned + plan.sweep;
2150
+ if (count < minimum) throw new Error(`generateRows needs at least ${minimum} rows for these columns (the pinned rows plus the pool sweep), got ${count}`);
2151
+ return Effect.forEach(Array.from({ length: count }, (_, index) => index), (index) => generateRow(plan, index));
2152
+ };
2153
+ /** A fresh all-zero coverage table. */
2154
+ const emptyCoverage = () => ({
2155
+ eq: 0,
2156
+ ne: 0,
2157
+ gt: 0,
2158
+ gte: 0,
2159
+ lt: 0,
2160
+ lte: 0,
2161
+ in: 0,
2162
+ nin: 0,
2163
+ isnull: 0,
2164
+ and: 0,
2165
+ or: 0,
2166
+ not: 0,
2167
+ true: 0
2168
+ });
2169
+ const comparison = (make) => (field, literal) => Effect.map(literal, (value) => make(field, value));
2170
+ /** The leaf operator table: one constructor per core operator. */
2171
+ const leafOperators = [
2172
+ {
2173
+ name: "eq",
2174
+ ordered: false,
2175
+ build: comparison(eq)
2176
+ },
2177
+ {
2178
+ name: "ne",
2179
+ ordered: false,
2180
+ build: comparison(ne)
2181
+ },
2182
+ {
2183
+ name: "gt",
2184
+ ordered: true,
2185
+ build: comparison(gt)
2186
+ },
2187
+ {
2188
+ name: "gte",
2189
+ ordered: true,
2190
+ build: comparison(gte)
2191
+ },
2192
+ {
2193
+ name: "lt",
2194
+ ordered: true,
2195
+ build: comparison(lt)
2196
+ },
2197
+ {
2198
+ name: "lte",
2199
+ ordered: true,
2200
+ build: comparison(lte)
2201
+ },
2202
+ {
2203
+ name: "in",
2204
+ ordered: false,
2205
+ build: (field, _literal, list) => Effect.map(list, (values) => isIn(field, values))
2206
+ },
2207
+ {
2208
+ name: "nin",
2209
+ ordered: false,
2210
+ build: (field, _literal, list) => Effect.map(list, (values) => notIn(field, values))
2211
+ },
2212
+ {
2213
+ name: "isnull",
2214
+ ordered: false,
2215
+ build: (field) => Effect.map(chance(.5), (negated) => negated ? isNotNull(field) : isNull(field))
2216
+ }
2217
+ ];
2218
+ const EMPTY_IN_RATE = .1;
2219
+ const LEAF_RATE = .55;
2220
+ const TRUE_RATE = .03;
2221
+ const FOREIGN_RATE = .3;
2222
+ /** A literal of the pool's kind: usually one that occurs, sometimes one that does not. */
2223
+ const literalFor = (pool) => Effect.gen(function* () {
2224
+ const foreign = Arr.isReadonlyArrayNonEmpty(pool.foreign) && (yield* chance(FOREIGN_RATE));
2225
+ return yield* pick(foreign ? pool.foreign : pool.rows);
2226
+ });
2227
+ /** An `In` list: occasionally empty, otherwise 1–4 literals. */
2228
+ const listFor = (pool) => Effect.gen(function* () {
2229
+ if (yield* chance(EMPTY_IN_RATE)) return [];
2230
+ const length = 1 + (yield* int(4));
2231
+ return yield* literalFor(pool).pipe(Effect.replicateEffect(length));
2232
+ });
2233
+ const generateLeaf = (columns, options, coverage) => Effect.gen(function* () {
2234
+ if (yield* chance(TRUE_RATE)) {
2235
+ coverage.true++;
2236
+ return always;
2237
+ }
2238
+ const column = yield* pick(columns);
2239
+ const pool = poolFor(column.kind, options);
2240
+ const candidates = leafOperators.filter((op) => (!op.ordered || column.kind !== "boolean" || options.booleanOrder) && (op.name !== "isnull" || options.nulls));
2241
+ const operator = yield* pick(candidates);
2242
+ coverage[operator.name]++;
2243
+ return yield* operator.build(column.name, literalFor(pool), listFor(pool));
2244
+ });
2245
+ /**
2246
+ * Generate one depth-bounded predicate tree: leaves from the operator table
2247
+ * on a random column with a literal of that column's kind; `And`/`Or` with
2248
+ * 2–3 children and `Not` with one, down to `depth`. Emitted operators are
2249
+ * tallied into `coverage`.
2250
+ */
2251
+ const generatePredicate = (columns, options, coverage, depth = 2) => {
2252
+ const resolved = resolveOptions(options);
2253
+ const go = (remaining) => Effect.gen(function* () {
2254
+ if (remaining === 0 || (yield* chance(LEAF_RATE))) return yield* generateLeaf(columns, resolved, coverage);
2255
+ const branch = yield* int(3);
2256
+ if (branch === 2) {
2257
+ coverage.not++;
2258
+ return not(yield* go(remaining - 1));
2259
+ }
2260
+ const width = 2 + (yield* int(2));
2261
+ const children = yield* go(remaining - 1).pipe(Effect.replicateEffect(width));
2262
+ if (branch === 0) {
2263
+ coverage.and++;
2264
+ return and(...children);
2265
+ }
2266
+ coverage.or++;
2267
+ return or(...children);
2268
+ });
2269
+ return go(depth);
2270
+ };
2271
+ /** Generate 1–2 `orderBy` terms over distinct columns with random directions. */
2272
+ const generateOrderBy = (columns = corpusColumns) => Effect.gen(function* () {
2273
+ const count = Math.min(columns.length, 1 + (yield* int(2)));
2274
+ const shuffled = yield* Random.shuffle(columns);
2275
+ return yield* Effect.forEach(shuffled.slice(0, count), (column) => Effect.map(Random.nextBoolean, (asc) => ({
2276
+ field: column.name,
2277
+ direction: asc ? "asc" : "desc"
2278
+ })));
2279
+ });
2280
+ /**
2281
+ * Generate a whole corpus deterministically from a seed. The generators run
2282
+ * under `Random.withSeed(options.seed)`; the seeding is suspended so every
2283
+ * run of the returned effect starts the sequence afresh and yields the same
2284
+ * corpus.
2285
+ */
2286
+ const generateCorpus = (options) => Effect.suspend(() => {
2287
+ const columns = options.columns ?? corpusColumns;
2288
+ const coverage = emptyCoverage();
2289
+ return Effect.gen(function* () {
2290
+ const rows = yield* generateRows(options.rows ?? 64, columns, options);
2291
+ const predicates = yield* generatePredicate(columns, options, coverage, options.depth ?? 2).pipe(Effect.replicateEffect(options.predicates ?? 200));
2292
+ const orderBys = yield* generateOrderBy(columns).pipe(Effect.replicateEffect(options.orderBys ?? 60));
2293
+ return {
2294
+ seed: options.seed,
2295
+ columns,
2296
+ rows,
2297
+ predicates,
2298
+ orderBys,
2299
+ coverage
2300
+ };
2301
+ }).pipe(Random.withSeed(options.seed));
2302
+ });
2303
+ const replaceAt = (xs, index, x) => xs.map((y, i) => i === index ? x : y);
2304
+ const without = (xs, index) => xs.filter((_, i) => i !== index);
2305
+ /**
2306
+ * One-step shrinks of a predicate, larger jumps first: replace a junction by
2307
+ * one child, drop one child, unwrap `Not`, drop one `In` value, then the
2308
+ * same recursively inside each child.
2309
+ */
2310
+ const shrinkPredicate = (predicate) => {
2311
+ switch (predicate._tag) {
2312
+ case "And":
2313
+ case "Or": {
2314
+ const rebuild = predicate._tag === "And" ? and : or;
2315
+ const children = predicate.predicates;
2316
+ const out = [];
2317
+ if (children.length > 1) {
2318
+ out.push(...children);
2319
+ out.push(...children.map((_, i) => rebuild(...without(children, i))));
2320
+ }
2321
+ children.forEach((child, i) => {
2322
+ Arr.forEach(shrinkPredicate(child), (shrunk) => {
2323
+ out.push(rebuild(...replaceAt(children, i, shrunk)));
2324
+ });
2325
+ });
2326
+ return out;
2327
+ }
2328
+ case "Not": return [predicate.predicate, ...shrinkPredicate(predicate.predicate).map(not)];
2329
+ case "In":
2330
+ case "NotIn": {
2331
+ const rebuild = predicate._tag === "In" ? isIn : notIn;
2332
+ return predicate.values.length > 1 ? predicate.values.map((_, i) => rebuild(predicate.field, without(predicate.values, i))) : [];
2333
+ }
2334
+ default: return [];
2335
+ }
2336
+ };
2337
+ const MAX_SHRINK_STEPS = 1e3;
2338
+ /**
2339
+ * Greedily shrink a disagreeing case while `disagrees` keeps holding:
2340
+ * predicate shrinks first, then row removals, restarting from the smallest
2341
+ * accepted candidate until no candidate still disagrees. `disagrees` runs the
2342
+ * subjects, so it is an Effect (the SQL legs are asynchronous).
2343
+ */
2344
+ const minimise = (failing, disagrees) => {
2345
+ const round = (current, steps) => {
2346
+ if (steps >= MAX_SHRINK_STEPS) return Effect.succeed(current);
2347
+ const candidates = [...shrinkPredicate(current.predicate).map((predicate) => ({
2348
+ predicate,
2349
+ rows: current.rows
2350
+ })), ...current.rows.map((_, i) => ({
2351
+ predicate: current.predicate,
2352
+ rows: without(current.rows, i)
2353
+ }))];
2354
+ let tried = 0;
2355
+ return Effect.findFirst(candidates, (candidate) => {
2356
+ tried++;
2357
+ return disagrees(candidate);
2358
+ }).pipe(Effect.flatMap((accepted) => Option.isSome(accepted) ? round(accepted.value, steps + tried) : Effect.succeed(current)));
2359
+ };
2360
+ return round(failing, 0);
2361
+ };
2362
+ /**
2363
+ * `JSON.stringify` that keeps `-0` visible (as the string `"-0"`): plain
2364
+ * JSON prints it as `0`, and a minimal case built on `-0` would not
2365
+ * reproduce from its own report.
2366
+ */
2367
+ const print = (value) => JSON.stringify(value, (_key, field) => Object.is(field, -0) ? "-0" : field);
2368
+ /**
2369
+ * Render a disagreement: the seed, the (minimal) predicate or order terms,
2370
+ * the (minimal) rows — absent keys stay absent, `null` prints as `null`,
2371
+ * `-0` prints as `"-0"` — and every subject's ids.
2372
+ */
2373
+ const formatDisagreement = (disagreement) => {
2374
+ const reproduce = disagreement.reproduce ?? "set LEGATION_CORPUS_SEED to reproduce";
2375
+ const lines = [`${disagreement.subject} (seed ${disagreement.seed}; ${reproduce})`];
2376
+ if (disagreement.predicate !== void 0) lines.push(`predicate: ${print(disagreement.predicate)}`);
2377
+ if (disagreement.orderBy !== void 0) lines.push(`orderBy: ${print(disagreement.orderBy)}`);
2378
+ lines.push("rows:");
2379
+ Arr.forEach(disagreement.rows, (row) => {
2380
+ lines.push(` ${row.id} ${print(row.value)}`);
2381
+ });
2382
+ Arr.forEach(Object.entries(disagreement.results), ([name, ids]) => {
2383
+ lines.push(`${name}: ${JSON.stringify(ids)}`);
2384
+ });
2385
+ return lines.join("\n");
2386
+ };
2387
+ // src/BackendContract.ts
2388
+ /**
2389
+ * BackendContract
2390
+ *
2391
+ * The executable specification of the Backend contract (ADR-0009).
2392
+ * `verifyBackendContract` drives an implementation through the guarantees
2393
+ * documented in Backend.ts — in-order application, idempotent skip, gap
2394
+ * rejection, bump-atomic-with-effects (for both a transient failure and a
2395
+ * permanent rejection), version-bumping writes, reference subset-evaluation
2396
+ * semantics (Subset.apply), and client-group isolation — failing with a
2397
+ * ContractViolation naming the broken rule.
2398
+ *
2399
+ * The backend under test must register the contract mutators (the
2400
+ * definitions below; `memoryHandlers` is the ready-made registration for
2401
+ * MemoryBackend-style backends). Verification writes rows into the
2402
+ * configured contract collections, so run it against disposable instances.
2403
+ *
2404
+ * Visibility before the bump (guarantee 1) is not a rule here. The rules make
2405
+ * one call at a time, so the harness never observes a backend mid-mutation:
2406
+ * that needs a handler parked after its write, which only the backend's own
2407
+ * handler can express, and its concurrency differs per backend. Each backend
2408
+ * therefore discharges it itself: `MemoryBackend` by its stage (covered by
2409
+ * `memory-backend-atomicity.test.ts`, "keeps a handler's writes and its bump
2410
+ * invisible until the mutation commits"). `SqlBackend` by its transaction
2411
+ * (`sql-backend-visibility.test.ts`: a second connection reads nothing of a
2412
+ * parked handler's write or bump). `JsonApiBackend` by the server's
2413
+ * all-or-nothing batch (`jsonapi-atomic-contract.test.ts`, "JsonApiBackend
2414
+ * visibility before the bump"). A new backend must add its own mid-flight test
2415
+ * before it can claim guarantee 1.
2416
+ *
2417
+ * Serialisability across client groups (guarantee 1, #353) is a rule: rule 11
2418
+ * parks a handler after its read, lets another group commit a write to the same
2419
+ * row, then resumes the handler and checks that no write was lost. Its handler
2420
+ * parks through {@link park}, the one seam a backend's own counter handler needs
2421
+ * to expose the read-then-write window. The gate is named by a string in the
2422
+ * mutator's args, so the mutation stays serialisable like any other.
2423
+ *
2424
+ * A backend that caches the rows a listing returns must keep that cache in line
2425
+ * with its own writes: rule 12 lists a collection, applies an update and a delete
2426
+ * to listed rows through the backend, and checks that `getRows` returns the
2427
+ * post-write value and omits the deleted row, without a re-list (#444). It uses
2428
+ * the existing `putRow` and `delRow` mutators and the `evaluate` collection.
2429
+ *
2430
+ * **Types and runtime (#177, ADR-0009's amendment).** A backend whose
2431
+ * `applyMutations` is built with `Backend.transaction` gets the ledger's
2432
+ * ordering rules by construction: in-order application, idempotent skip and gap
2433
+ * rejection (rules 1–3), and the bump travelling with each outcome, a rejection
2434
+ * committing no effects (rules 4, 9 and 10). The store keeps one obligation:
2435
+ * its `commit` (or `atomically`) must really be atomic and isolated. Types cannot
2436
+ * see that, so this suite still runs every rule against every backend, built
2437
+ * through the combinator or by hand. Evaluation (rules 6 and 14), row versions,
2438
+ * `getRows`, isolation and the rejection list (rules 5, 7, 8, 12 and 13) are
2439
+ * behaviour, and stay runtime only.
2440
+ *
2441
+ * A backend that provides the optional `evaluateMany` (#505) must agree with
2442
+ * its own `evaluate`: rule 14 asks for rule 6's subsets, the generated corpus
2443
+ * included, in one call and checks each list against `evaluate` for that
2444
+ * subset. A backend without it passes that rule vacuously.
2445
+ */
2446
+ var BackendContract_exports = /* @__PURE__ */ __exportAll({
2447
+ ContractViolation: () => ContractViolation,
2448
+ countUp: () => countUp,
2449
+ delRow: () => delRow,
2450
+ failAlways: () => failAlways,
2451
+ memoryHandlers: () => memoryHandlers,
2452
+ park: () => park,
2453
+ putRow: () => putRow,
2454
+ rejectAlways: () => rejectAlways,
2455
+ verifyBackendContract: () => verifyBackendContract,
2456
+ writeThenFail: () => writeThenFail
2457
+ });
2458
+ /** A backend broke one of the contract's guarantees. */
2459
+ var ContractViolation = class extends Schema.TaggedError()("ContractViolation", {
2460
+ rule: Schema.String,
2461
+ message: Schema.String
2462
+ }) {};
2463
+ /** The gates the harness has registered, by name. Args carry the name, never the gate. */
2464
+ const parkGates = /* @__PURE__ */ new Map();
2465
+ /**
2466
+ * The park a `contract/count-up` handler takes between its read and its write.
2467
+ * When the harness has registered `gate`, it signals that the handler is parked
2468
+ * and waits until the harness releases it; for any other name it does nothing.
2469
+ * Only the contract's `countUp` handler calls it, so no production handler should.
2470
+ */
2471
+ const park = (gate) => {
2472
+ const registered = parkGates.get(gate);
2473
+ return registered === void 0 ? Effect.void : Deferred.succeed(registered.entered, void 0).pipe(Effect.andThen(Deferred.await(registered.release)));
2474
+ };
2475
+ /** Write one row. */
2476
+ const putRow = make$8("contract/put", Schema.Struct({
2477
+ collection: Schema.String,
2478
+ id: Schema.String,
2479
+ value: Schema.Unknown
2480
+ }));
2481
+ /** Delete one row. */
2482
+ const delRow = make$8("contract/del", Schema.Struct({
2483
+ collection: Schema.String,
2484
+ id: Schema.String
2485
+ }));
2486
+ /**
2487
+ * Fails transiently — probes that a `BackendError` aborts the batch and the
2488
+ * mutation-id bump is atomic with effects (no bump on abort).
2489
+ */
2490
+ const failAlways = make$8("contract/fail", Schema.Struct({}));
2491
+ /** The reason `rejectAlways` gives for its permanent rejection. */
2492
+ const rejectReason = "contract/reject always rejects";
2493
+ /**
2494
+ * Writes a row and then rejects permanently — probes that a `MutationRejected`
2495
+ * bumps the id *without* leaving effects (the staged write must roll back).
2496
+ */
2497
+ const rejectAlways = make$8("contract/reject", Schema.Struct({
2498
+ collection: Schema.String,
2499
+ id: Schema.String
2500
+ }));
2501
+ /**
2502
+ * Writes a row and then fails transiently — probes that a `BackendError` leaves
2503
+ * no trace of that write (the row keeps its value and version) and no bump
2504
+ * (the client's retry applies the mutation again). `failAlways` writes nothing
2505
+ * first, so it does not probe this. A backend that cannot discard a handler's
2506
+ * writes on its own failure fails this rule by design: D1 commits each
2507
+ * statement separately (#342), so it is not run through this contract. A
2508
+ * registered handler that never writes passes this rule vacuously, and the
2509
+ * contract cannot detect that: it sees only the failure.
2510
+ */
2511
+ const writeThenFail = make$8("contract/write-then-fail", Schema.Struct({
2512
+ collection: Schema.String,
2513
+ id: Schema.String
2514
+ }));
2515
+ /**
2516
+ * Reads a counter row, parks after the read (see {@link park}), then writes the
2517
+ * counter plus one. Two client groups running it on one row must both count
2518
+ * (rule 11). `gate` names a park gate the harness registered, or matches none.
2519
+ *
2520
+ * A third-party backend registers its handler for this mutator under
2521
+ * `BackendContract.countUp.name` (`contract/count-up`), passes `gate` unchanged
2522
+ * to `park` between its read and its write, and writes the counter plus one. A
2523
+ * handler that omits `park` fails rule 11 with "finished without reaching park()".
2524
+ */
2525
+ const countUp = make$8("contract/count-up", Schema.Struct({
2526
+ collection: Schema.String,
2527
+ id: Schema.String,
2528
+ gate: Schema.String
2529
+ }));
2530
+ /** Ready-made authoritative handlers for MemoryBackend-style registration. */
2531
+ const memoryHandlers = validatedHandlers([
2532
+ handler(putRow, (args, writer) => writer.put(args.collection, args.id, args.value)),
2533
+ handler(delRow, (args, writer) => writer.del(args.collection, args.id)),
2534
+ handler(failAlways, () => Effect.fail(new BackendError({ message: "contract/fail always fails" }))),
2535
+ handler(rejectAlways, (args, writer) => writer.put(args.collection, args.id, { poison: true }).pipe(Effect.andThen(Effect.fail(new MutationRejected({ reason: rejectReason }))))),
2536
+ handler(writeThenFail, (args, writer) => writer.put(args.collection, args.id, { n: 99 }).pipe(Effect.andThen(Effect.fail(new BackendError({ message: "contract/write-then-fail fails after writing" }))))),
2537
+ handler(countUp, (args, writer) => Effect.gen(function* () {
2538
+ const current = yield* writer.get(args.collection, args.id);
2539
+ yield* park(args.gate);
2540
+ yield* writer.put(args.collection, args.id, { n: (current?.n ?? 0) + 1 });
2541
+ }))
2542
+ ]);
2543
+ /**
2544
+ * True when a failed call shows `mutator` is not registered. The
2545
+ * `UnknownMutator` tag identifies it; the exact message `UnknownMutator.make`
2546
+ * writes is a fallback for a third-party backend that does not use it yet, so a
2547
+ * registered handler whose own error merely mentions "Unknown mutator" still counts.
2548
+ */
2549
+ const isUnregisteredFailure = (error, mutator) => isError(error) || error.message === `Unknown mutator: ${mutator}`;
2550
+ /** The client id every contract mutation is sent under. */
2551
+ const CONTRACT_CLIENT = "contract-client";
2552
+ /**
2553
+ * The rule that checks, before any other rule runs, that the backend reports the
2554
+ * ledger the run expects (#483).
2555
+ */
2556
+ const restoredLedgerRule = "reports the ledger the run expects";
2557
+ /**
2558
+ * The message for a backend whose ledger for `clientId` in the named group is not the one the run
2559
+ * expects. A fresh group must report 0. A restored group that reports 0 has forgotten its ledger.
2560
+ */
2561
+ const ledgerMismatch = (group, clientId, expected, reported) => {
2562
+ const ledger = `lastMutationIds["${clientId}"]`;
2563
+ if (expected === 0) return `A fresh ${group} must report ${ledger} = 0, got ${reported}`;
2564
+ if (reported === 0) return `Expected the restored ${group} ${ledger} = ${expected}, got 0: the backend forgot its ledger on restart`;
2565
+ return `Expected the restored ${group} ${ledger} = ${expected}, got ${reported}: the restored ledger differs`;
2566
+ };
2567
+ const makeHarness = (backend, options, start, peerStart) => {
2568
+ const rows = options?.collection ?? "__contract";
2569
+ const context = { clientGroupId: options?.clientGroupId ?? "__contract-group" };
2570
+ const clientId = CONTRACT_CLIENT;
2571
+ const violation = (rule, message) => new ContractViolation({
2572
+ rule,
2573
+ message
2574
+ });
2575
+ const check = (rule, condition, message) => condition ? Effect.void : Effect.fail(violation(rule, message));
2576
+ const expectOk = (rule, effect) => effect.pipe(Effect.mapError((error) => violation(rule, `Backend failed unexpectedly: ${error.message}`)));
2577
+ const expectFail = (rule, effect, what) => Effect.exit(effect).pipe(Effect.flatMap((exit) => check(rule, exit._tag === "Failure", `Expected failure: ${what}`)));
2578
+ const expectRegisteredFailure = (rule, effect, mutator, exportName) => Effect.exit(effect).pipe(Effect.flatMap((exit) => {
2579
+ const failure = Exit.findErrorOption(exit);
2580
+ if (Option.isNone(failure)) return check(rule, false, `Expected failure: the ${mutator} mutator`);
2581
+ return check(rule, !isUnregisteredFailure(failure.value, mutator), `${mutator} is not registered: register BackendContract.${exportName}`);
2582
+ }));
2583
+ const mutation = (mutationId, mutator, args) => ({
2584
+ clientId,
2585
+ mutationId,
2586
+ mutator,
2587
+ args,
2588
+ createdAt: DateTime.toEpochMillis(DateTime.nowUnsafe())
2589
+ });
2590
+ return {
2591
+ backend,
2592
+ context,
2593
+ clientId,
2594
+ start,
2595
+ peerStart,
2596
+ rows,
2597
+ evalRows: `${rows}_eval`,
2598
+ check,
2599
+ expectOk,
2600
+ expectFail,
2601
+ expectRegisteredFailure,
2602
+ lastIdOf: (rule) => expectOk(rule, backend.lastMutationIds(context)).pipe(Effect.map((ids) => ids[clientId] ?? 0)),
2603
+ mutation,
2604
+ put: (mutationId, collection, id, value) => mutation(mutationId, putRow.name, {
2605
+ collection,
2606
+ id,
2607
+ value
2608
+ }),
2609
+ reject: (mutationId, collection, id) => mutation(mutationId, rejectAlways.name, {
2610
+ collection,
2611
+ id
2612
+ }),
2613
+ awaitPeer: options?.awaitPeer
2614
+ };
2615
+ };
2616
+ /** 1. In-order application bumps lastMutationIds and applies effects. */
2617
+ const rule1InOrderApplication = ({ backend, context, clientId, rows, start, check, expectOk, put }) => Effect.gen(function* () {
2618
+ const rule = "applies mutations in order and bumps lastMutationIds";
2619
+ const result = yield* expectOk(rule, backend.applyMutations(context, [put(start + 1, rows, "a", { n: 1 }), put(start + 2, rows, "b", { n: 2 })]));
2620
+ yield* check(rule, result.lastMutationIds[clientId] === start + 2, `Expected lastMutationIds["${clientId}"] = ${start + 2}, got ${result.lastMutationIds[clientId]}`);
2621
+ const fetched = yield* expectOk(rule, backend.getRows(context, rows, ["a", "b"]));
2622
+ yield* check(rule, fetched.length === 2, `Expected 2 rows back, got ${fetched.length}`);
2623
+ yield* check(rule, fetched.every((row) => row.version !== void 0 && row.version !== null), "Every row must carry a row version");
2624
+ });
2625
+ /** 2. Idempotent skip: an already-applied mutation id has no effect. */
2626
+ const rule2IdempotentSkip = ({ backend, context, clientId, rows, start, check, expectOk, put }) => Effect.gen(function* () {
2627
+ const rule = "skips already-applied mutation ids (idempotency)";
2628
+ const result = yield* expectOk(rule, backend.applyMutations(context, [put(start + 2, rows, "b", { n: 99 })]));
2629
+ yield* check(rule, result.lastMutationIds[clientId] === start + 2, `Replaying mutation ${start + 2} must keep lastMutationId at ${start + 2}, got ${result.lastMutationIds[clientId]}`);
2630
+ const [b] = yield* expectOk(rule, backend.getRows(context, rows, ["b"]));
2631
+ yield* check(rule, (b?.value)?.n === 2, `Replaying mutation 2 must not re-execute its effects (row "b" became ${JSON.stringify(b?.value)})`);
2632
+ });
2633
+ /** 3. Gap rejection: a mutation id beyond last+1 is an error. */
2634
+ const rule3GapRejection = ({ backend, context, rows, start, check, expectOk, expectFail, lastIdOf, put }) => Effect.gen(function* () {
2635
+ const rule = "rejects mutation-id gaps";
2636
+ yield* expectFail(rule, backend.applyMutations(context, [put(start + 4, rows, "c", { n: 4 })]), `mutation id ${start + 4} after lastMutationId ${start + 2}`);
2637
+ yield* check(rule, (yield* lastIdOf(rule)) === start + 2, "A rejected gap must not bump lastMutationId");
2638
+ const fetched = yield* expectOk(rule, backend.getRows(context, rows, ["c"]));
2639
+ yield* check(rule, Arr.isReadonlyArrayEmpty(fetched), "A rejected gap must not apply effects");
2640
+ });
2641
+ /** 4. Atomicity: a failed mutator bumps nothing. */
2642
+ const rule4AtomicBump = ({ backend, context, start, check, expectRegisteredFailure, lastIdOf, mutation }) => Effect.gen(function* () {
2643
+ const rule = "keeps the lastMutationId bump atomic with mutator effects";
2644
+ yield* expectRegisteredFailure(rule, backend.applyMutations(context, [mutation(start + 3, failAlways.name, {})]), failAlways.name, "failAlways");
2645
+ yield* check(rule, (yield* lastIdOf(rule)) === start + 2, "A failed mutator must not bump lastMutationId");
2646
+ });
2647
+ /** 5. Authoritative writes change row versions. */
2648
+ const rule5VersionBump = ({ backend, context, rows, start, check, expectOk, put }) => Effect.gen(function* () {
2649
+ const rule = "changes the row version on every authoritative write";
2650
+ const [before] = yield* expectOk(rule, backend.getRows(context, rows, ["a"]));
2651
+ yield* expectOk(rule, backend.applyMutations(context, [put(start + 3, rows, "a", { n: 3 })]));
2652
+ const [after] = yield* expectOk(rule, backend.getRows(context, rows, ["a"]));
2653
+ yield* check(rule, before !== void 0 && after !== void 0 && before.version !== after.version, `Rewriting row "a" must change its version (was ${before?.version}, now ${after?.version})`);
2654
+ });
2655
+ /**
2656
+ * `evaluate` must return exactly the ids `Subset.apply` returns over
2657
+ * `reference`, order included. Returns the expected ids so a caller can
2658
+ * page on from them; `describe` renders the violation from both id lists.
2659
+ */
2660
+ const expectEvaluation = ({ backend, context, check, expectOk }, rule, reference, subset, describe) => Effect.gen(function* () {
2661
+ const expected = apply(reference, subset).map((row) => row.id);
2662
+ const evaluated = (yield* expectOk(rule, backend.evaluate(context, subset))).map((entry) => entry.id);
2663
+ yield* check(rule, JSON.stringify(evaluated) === JSON.stringify(expected), describe(expected, evaluated));
2664
+ return expected;
2665
+ });
2666
+ /** The five hand-picked rows rule 6 starts from, in insertion order. */
2667
+ const evalSeed = [
2668
+ ["e1", {
2669
+ rank: 10,
2670
+ tag: "keep"
2671
+ }],
2672
+ ["e2", {
2673
+ rank: 40,
2674
+ tag: "keep"
2675
+ }],
2676
+ ["e3", {
2677
+ rank: 20,
2678
+ tag: "drop"
2679
+ }],
2680
+ ["e4", {
2681
+ rank: 30,
2682
+ tag: "keep"
2683
+ }],
2684
+ ["e5", {
2685
+ rank: 50,
2686
+ tag: "keep"
2687
+ }]
2688
+ ];
2689
+ /**
2690
+ * The generated corpus rules 6 and 14 share (issue #153): two columns, NULL
2691
+ * and astral values on, from the fixed seed, so every run sees the same rows,
2692
+ * predicates and order terms.
2693
+ */
2694
+ const contractCorpus = Effect.suspend(() => {
2695
+ const columns = [{
2696
+ name: "rank",
2697
+ kind: "integer"
2698
+ }, {
2699
+ name: "tag",
2700
+ kind: "text"
2701
+ }];
2702
+ const options = {
2703
+ nulls: true,
2704
+ astral: true
2705
+ };
2706
+ return generateCorpus({
2707
+ seed: DEFAULT_SEED,
2708
+ columns,
2709
+ rows: minimumRows(columns, options) + 10,
2710
+ predicates: 40,
2711
+ orderBys: 40,
2712
+ ...options
2713
+ });
2714
+ });
2715
+ /** The corpus's subsets over the evaluation collection: one per predicate, some with a limit. */
2716
+ const corpusSubsets = (h, corpus) => corpus.predicates.map((where, index) => make$5({
2717
+ collection: h.evalRows,
2718
+ where,
2719
+ orderBy: corpus.orderBys[index % corpus.orderBys.length],
2720
+ ...index % 3 === 0 ? { limit: 1 + index % 7 } : {}
2721
+ }));
2722
+ /**
2723
+ * The generated half of rule 6: a small corpus (issue #153) over the same two
2724
+ * columns, so every backend inherits the check for every operator in the
2725
+ * core: `evaluate` must return exactly what `Subset.apply` returns over the
2726
+ * same rows, order included, for random predicates, order terms, and
2727
+ * limits. NULL/absent values and astral text are on: ADR-0028 makes
2728
+ * Postgres normative for both and the JS evaluator is exact there (#155),
2729
+ * so a backend whose storage treats NULL two-valued, orders NULL first
2730
+ * ascending, or compares text by UTF-16 code unit fails here by design.
2731
+ * String order is code-point order (ADR-0028 rule (c)): the `tag` pool
2732
+ * mixes case and accents, so a backend whose text column carries a locale
2733
+ * collation fails too — the contract requires `COLLATE "C"` (Postgres) or
2734
+ * `BINARY` (SQLite).
2735
+ */
2736
+ const generatedCorpus = (h, rule, reference, nextMutationId) => Effect.gen(function* () {
2737
+ const corpus = yield* contractCorpus;
2738
+ const corpusRows = corpus.rows.map((row) => ({
2739
+ id: `c-${row.id}`,
2740
+ value: row.value
2741
+ }));
2742
+ yield* h.expectOk(rule, h.backend.applyMutations(h.context, corpusRows.map((row, index) => h.put(nextMutationId + index, h.evalRows, row.id, row.value))));
2743
+ const all = [...reference, ...corpusRows];
2744
+ yield* Effect.forEach(corpusSubsets(h, corpus), (subset, index) => expectEvaluation(h, rule, all, subset, (expected, evaluated) => formatDisagreement({
2745
+ seed: corpus.seed,
2746
+ subject: `generated subset ${index}${subset.limit !== void 0 ? ` (limit ${subset.limit})` : ""}`,
2747
+ predicate: subset.where,
2748
+ orderBy: subset.orderBy,
2749
+ rows: all,
2750
+ results: {
2751
+ "Subset.apply": expected,
2752
+ "backend.evaluate": evaluated
2753
+ },
2754
+ reproduce: "the contract corpus uses FilterCorpus.DEFAULT_SEED, not the environment"
2755
+ })), {
2756
+ concurrency: 1,
2757
+ discard: true
2758
+ });
2759
+ });
2760
+ /** 6. Evaluate matches the reference subset semantics (Subset.apply). */
2761
+ const rule6ReferenceSemantics = (h) => Effect.gen(function* () {
2762
+ const rule = "evaluates subsets with the reference semantics (Subset.apply)";
2763
+ yield* h.expectOk(rule, h.backend.applyMutations(h.context, evalSeed.map(([id, value], index) => h.put(h.start + 4 + index, h.evalRows, id, value))));
2764
+ const reference = evalSeed.map(([id, value]) => ({
2765
+ id,
2766
+ value
2767
+ }));
2768
+ const limited = make$5({
2769
+ collection: h.evalRows,
2770
+ where: eq("tag", "keep"),
2771
+ orderBy: [{
2772
+ field: "rank",
2773
+ direction: "desc"
2774
+ }],
2775
+ limit: 2
2776
+ });
2777
+ const expectedLimited = yield* expectEvaluation(h, rule, reference, limited, (expected, evaluated) => `where+orderBy+limit: expected ${JSON.stringify(expected)}, got ${JSON.stringify(evaluated)}`);
2778
+ const paged = make$5({
2779
+ collection: h.evalRows,
2780
+ where: eq("tag", "keep"),
2781
+ orderBy: [{
2782
+ field: "rank",
2783
+ direction: "desc"
2784
+ }],
2785
+ limit: 2,
2786
+ cursor: expectedLimited[expectedLimited.length - 1]
2787
+ });
2788
+ yield* expectEvaluation(h, rule, reference, paged, (expected, evaluated) => `cursor page: expected ${JSON.stringify(expected)}, got ${JSON.stringify(evaluated)}`);
2789
+ yield* generatedCorpus(h, rule, reference, h.start + 4 + evalSeed.length);
2790
+ });
2791
+ /** 7. getRows omits missing ids. */
2792
+ const rule7OmitsMissingIds = ({ backend, context, rows, check, expectOk }) => Effect.gen(function* () {
2793
+ const rule = "omits missing ids from getRows";
2794
+ const fetched = yield* expectOk(rule, backend.getRows(context, rows, ["a", "does-not-exist"]));
2795
+ yield* check(rule, fetched.length === 1 && fetched[0]?.id === "a", `Expected exactly row "a", got ${JSON.stringify(fetched.map((row) => row.id))}`);
2796
+ });
2797
+ /** 8. Client groups are isolated. */
2798
+ const rule8ClientGroupIsolation = ({ backend, context, clientId, check, expectOk }) => Effect.gen(function* () {
2799
+ const rule = "isolates lastMutationIds between client groups";
2800
+ const other = { clientGroupId: `${context.clientGroupId}-other` };
2801
+ const ids = yield* expectOk(rule, backend.lastMutationIds(other));
2802
+ yield* check(rule, (ids[clientId] ?? 0) === 0, `A fresh client group must not see another group's mutation ids (got ${ids[clientId]})`);
2803
+ });
2804
+ /**
2805
+ * 9. Permanent rejection: the poison mutation is acknowledged (its id
2806
+ * bumped) with no effects, and the mutation queued behind it applies.
2807
+ */
2808
+ const rule9PermanentRejection = (h) => Effect.gen(function* () {
2809
+ const { backend, context, clientId, rows, check, expectOk } = h;
2810
+ const rule = "acknowledges a permanent rejection without effects and applies those behind it";
2811
+ const startId = yield* h.lastIdOf(rule);
2812
+ const result = yield* expectOk(rule, backend.applyMutations(context, [h.reject(startId + 1, rows, "poison"), h.put(startId + 2, rows, "behind", { n: 1 })]));
2813
+ yield* check(rule, result.lastMutationIds[clientId] === startId + 2, `A rejection must still bump lastMutationId (expected ${startId + 2}, got ${result.lastMutationIds[clientId]})`);
2814
+ yield* check(rule, (result.rejected ?? []).some((entry) => entry.clientId === clientId && entry.mutationId === startId + 1), "The rejected mutation must appear in the result's `rejected` list");
2815
+ const poison = yield* expectOk(rule, backend.getRows(context, rows, ["poison"]));
2816
+ yield* check(rule, Arr.isReadonlyArrayEmpty(poison), "A rejected mutation must leave no effects (its staged write must roll back)");
2817
+ yield* check(rule, (yield* expectOk(rule, backend.getRows(context, rows, ["behind"]))).length === 1, "A mutation queued behind a rejected one must still apply in the same push");
2818
+ });
2819
+ /**
2820
+ * 10. Transient failure after a write: the failed mutator's write is discarded
2821
+ * (the row keeps its value and version, and a new row is not created) and
2822
+ * the id is not bumped, so the client's retry applies the mutation again.
2823
+ */
2824
+ const rule10WriteThenFail = (h) => Effect.gen(function* () {
2825
+ const { backend, context, rows, check, expectOk } = h;
2826
+ const rule = "discards a transiently failed mutation's writes and does not bump";
2827
+ const startId = yield* h.lastIdOf(rule);
2828
+ const [before] = yield* expectOk(rule, backend.getRows(context, rows, ["a"]));
2829
+ yield* check(rule, before !== void 0, "Row \"a\" must exist before the probe");
2830
+ const attempt = (id) => backend.applyMutations(context, [h.mutation(startId + 1, writeThenFail.name, {
2831
+ collection: rows,
2832
+ id
2833
+ })]);
2834
+ yield* h.expectRegisteredFailure(rule, attempt("a"), writeThenFail.name, "writeThenFail");
2835
+ const [after] = yield* expectOk(rule, backend.getRows(context, rows, ["a"]));
2836
+ yield* check(rule, after?.version === before?.version && JSON.stringify(after?.value) === JSON.stringify(before?.value), `A failed write must leave row "a" unchanged (was ${JSON.stringify(before?.value)} at version ${before?.version}, now ${JSON.stringify(after?.value)} at version ${after?.version})`);
2837
+ yield* Effect.exit(attempt("ghost"));
2838
+ const ghost = yield* expectOk(rule, backend.getRows(context, rows, ["ghost"]));
2839
+ yield* check(rule, Arr.isReadonlyArrayEmpty(ghost), "A failed write must not create a row");
2840
+ yield* check(rule, (yield* h.lastIdOf(rule)) === startId, "A failed write must not bump lastMutationId");
2841
+ });
2842
+ /** How many times a transient `BackendError` is retried, as the client's own retry would. */
2843
+ const TRANSIENT_RETRIES = 3;
2844
+ /**
2845
+ * Scheduler turns the rule gives the peer to commit while the parked handler
2846
+ * waits, when the backend supplies no `awaitPeer`. A backend that runs the peer
2847
+ * at once commits it within a few turns. One that serialises it behind the parked
2848
+ * call (SQLite's writer lock, a server lock) keeps it waiting, and the rule then
2849
+ * releases the handler anyway. Counted in turns, not time, so the rule never reads
2850
+ * the wall clock. On PostgreSQL the peer's statements are real I/O, so this turn
2851
+ * count does not reach the overlap: that backend observes the peer through
2852
+ * `awaitPeer` instead (#416).
2853
+ */
2854
+ const PEER_TURNS = 1e3;
2855
+ /** The rule's own gate names are unique per run, so two verifications in one process cannot collide. */
2856
+ let gateRuns = 0;
2857
+ /** The gate name for a mutation that parks at nothing: no registered gate has this name. */
2858
+ const NO_GATE = "";
2859
+ const retryTransient = (effect) => effect.pipe(Effect.retry({
2860
+ times: TRANSIENT_RETRIES,
2861
+ while: (error) => error._tag === "BackendError"
2862
+ }));
2863
+ /** Runs the scheduler until `peerSettled` reads true, or `PEER_TURNS` turns have passed. */
2864
+ const settleByTurns = (peerSettled) => {
2865
+ const turn = (left) => left <= 0 ? Effect.void : Effect.flatMap(peerSettled, (settled) => settled ? Effect.void : Effect.andThen(Effect.yieldNow, turn(left - 1)));
2866
+ return turn(PEER_TURNS);
2867
+ };
2868
+ /**
2869
+ * 11. Serialisable across client groups (guarantee 1, #353). Group X reads a
2870
+ * counter and parks after the read. Group Y increments the same counter and
2871
+ * commits while X is parked. Then X resumes and increments. Both increments
2872
+ * must land, so the counter reads 2. A backend whose handler read is not
2873
+ * validated at commit writes X's stale count over Y's and reads 1. A backend
2874
+ * that serialises the two (a lock, or serialisable isolation) either orders
2875
+ * them or refuses one with a transient `BackendError`, which the harness
2876
+ * retries. Y waits behind X on a backend that serialises by lock, so the rule
2877
+ * releases X once the peer has settled, or blocked on X's lock, and does not
2878
+ * deadlock. That wait is the backend's `awaitPeer`, or `PEER_TURNS` turns when
2879
+ * it supplies none. A PostgreSQL run needs `awaitPeer` to reach the overlap at
2880
+ * all (#416). D1 does not meet guarantee 1 (#342): it has no transaction to
2881
+ * isolate the read from another group's commit, so it is not run through this
2882
+ * contract, and this rule does not claim it.
2883
+ */
2884
+ const rule11CrossGroupLostUpdate = (h) => Effect.gen(function* () {
2885
+ const rule = "keeps a read-modify-write across client groups from losing an update (guarantee 1)";
2886
+ const startId = yield* h.lastIdOf(rule);
2887
+ const row = "race";
2888
+ yield* h.expectOk(rule, h.backend.applyMutations(h.context, [h.put(startId + 1, h.rows, row, { n: 0 })]));
2889
+ const peer = { clientGroupId: `${h.context.clientGroupId}-peer` };
2890
+ const peerLedger = (yield* h.expectOk(rule, h.backend.lastMutationIds(peer)))[h.clientId] ?? 0;
2891
+ yield* h.check(rule, peerLedger === h.peerStart, ledgerMismatch("peer group", h.clientId, h.peerStart, peerLedger));
2892
+ gateRuns += 1;
2893
+ const gate = `${h.context.clientGroupId}/race-${gateRuns}`;
2894
+ const parked = {
2895
+ entered: yield* Deferred.make(),
2896
+ release: yield* Deferred.make()
2897
+ };
2898
+ parkGates.set(gate, parked);
2899
+ const increment = (mutationId, gateName) => h.mutation(mutationId, countUp.name, {
2900
+ collection: h.rows,
2901
+ id: row,
2902
+ gate: gateName
2903
+ });
2904
+ yield* Effect.gen(function* () {
2905
+ const first = yield* Effect.forkChild(retryTransient(h.backend.applyMutations(h.context, [increment(startId + 2, gate)])));
2906
+ const firstEarly = yield* Effect.raceFirst(Deferred.await(parked.entered).pipe(Effect.as(void 0)), Effect.exit(Fiber.join(first)));
2907
+ if (firstEarly !== void 0) {
2908
+ const failure = Exit.findErrorOption(firstEarly);
2909
+ if (Option.isSome(failure) && isUnregisteredFailure(failure.value, countUp.name)) return yield* h.check(rule, false, `${countUp.name} is not registered: register BackendContract.countUp`);
2910
+ return yield* h.check(rule, false, Option.isSome(failure) ? `Backend failed unexpectedly: ${failure.value.message}` : `${countUp.name} finished without reaching park(): its handler must call BackendContract.park between its read and its write`);
2911
+ }
2912
+ const peerDone = yield* Deferred.make();
2913
+ const second = yield* Effect.forkChild(retryTransient(h.backend.applyMutations(peer, [increment(h.peerStart + 1, NO_GATE)])).pipe(Effect.ensuring(Deferred.succeed(peerDone, void 0))));
2914
+ const peerSettled = Deferred.isDone(peerDone);
2915
+ yield* (h.awaitPeer ?? settleByTurns)(peerSettled);
2916
+ yield* Deferred.succeed(parked.release, void 0);
2917
+ const firstExit = yield* Effect.exit(Fiber.join(first));
2918
+ const peerExit = yield* Effect.exit(Fiber.join(second));
2919
+ const failed = Arr.findFirst([["first", firstExit], ["peer", peerExit]], ([label, exit]) => Option.map(Exit.findErrorOption(exit), (error) => ({
2920
+ label,
2921
+ error
2922
+ })));
2923
+ if (Option.isSome(failed)) return yield* h.check(rule, false, `The ${failed.value.label} increment failed: ${failed.value.error.message}`);
2924
+ const [stored] = yield* h.expectOk(rule, h.backend.getRows(h.context, h.rows, [row]));
2925
+ const count = (stored?.value)?.n;
2926
+ yield* h.check(rule, count === 2, `Two increments of "${row}" from two client groups must leave it at 2, got ${count}: a committed write was lost to the other group's concurrent commit`);
2927
+ }).pipe(Effect.ensuring(Effect.sync(() => parkGates.delete(gate)).pipe(Effect.andThen(Deferred.succeed(parked.release, void 0)))));
2928
+ });
2929
+ /**
2930
+ * 12. Own writes reach getRows (#444). A listing (`evaluate`) may cache a row, and a
2931
+ * later write of that row through the same backend must show in `getRows` with no
2932
+ * re-list: an update reads back the post-write value, and a delete makes `getRows`
2933
+ * omit the row. A backend that caches listed rows and does not bring its cache into
2934
+ * line with its own writes passes rules 1 to 11 and fails here.
2935
+ */
2936
+ const rule12OwnWritesReachGetRows = (h) => Effect.gen(function* () {
2937
+ const { backend, context, check, expectOk, put } = h;
2938
+ const rule = "getRows reflects this backend's own writes to rows a listing has cached";
2939
+ const collection = h.evalRows;
2940
+ const kept = "cached-kept";
2941
+ const gone = "cached-gone";
2942
+ const startId = yield* h.lastIdOf(rule);
2943
+ yield* expectOk(rule, backend.applyMutations(context, [put(startId + 1, collection, kept, { n: 1 }), put(startId + 2, collection, gone, { n: 1 })]));
2944
+ const listed = (yield* expectOk(rule, backend.evaluate(context, make$5({ collection })))).map((entry) => entry.id);
2945
+ yield* check(rule, listed.includes(kept) && listed.includes(gone), `The listing must return both seeded rows so the backend can cache them, got ${JSON.stringify(listed)}`);
2946
+ yield* expectOk(rule, backend.applyMutations(context, [put(startId + 3, collection, kept, { n: 2 })]));
2947
+ const [updated] = yield* expectOk(rule, backend.getRows(context, collection, [kept]));
2948
+ yield* check(rule, (updated?.value)?.n === 2, `An update must show in getRows without a re-list: row "${kept}" reads ${JSON.stringify(updated?.value)}`);
2949
+ yield* expectOk(rule, backend.applyMutations(context, [h.mutation(startId + 4, delRow.name, {
2950
+ collection,
2951
+ id: gone
2952
+ })]));
2953
+ const removed = yield* expectOk(rule, backend.getRows(context, collection, [gone]));
2954
+ yield* check(rule, Arr.isReadonlyArrayEmpty(removed), `A delete must make getRows omit the row without a re-list: row "${gone}" still reads ${JSON.stringify(removed[0]?.value)}`);
2955
+ });
2956
+ /**
2957
+ * 13. Rejection memory: a permanent rejection is listed by `rejections`, with
2958
+ * the reason `applyMutations` reported for it, so a pull whose watermark
2959
+ * covers it can report it (#365). A client whose push response was lost
2960
+ * learns the outcome from that list.
2961
+ */
2962
+ const rule13ListsRejections = (h) => Effect.gen(function* () {
2963
+ const { backend, context, clientId, rows, check, expectOk } = h;
2964
+ const rule = "lists a permanent rejection, with the reason applyMutations reported, in rejections";
2965
+ const startId = yield* h.lastIdOf(rule);
2966
+ const reported = ((yield* expectOk(rule, backend.applyMutations(context, [h.reject(startId + 1, rows, "listed")]))).rejected ?? []).find((entry) => entry.clientId === clientId && entry.mutationId === startId + 1);
2967
+ yield* check(rule, reported !== void 0, "The rejected mutation must appear in the result's `rejected` list");
2968
+ const listed = yield* expectOk(rule, backend.rejections(context));
2969
+ yield* check(rule, listed.some((entry) => entry.clientId === clientId && entry.mutationId === startId + 1 && entry.reason === reported?.reason), `A permanent rejection must be listed by rejections with the reason applyMutations reported (${JSON.stringify(reported?.reason)}), got ${JSON.stringify(listed)}`);
2970
+ });
2971
+ /**
2972
+ * 14. `evaluateMany`, when the backend provides it (#505), equals per-subset
2973
+ * `evaluate`: for rule 6's subsets over its rows (the hand-picked windows
2974
+ * and the generated corpus) plus whole-collection subsets of both contract
2975
+ * collections, asked in one call, each list holds the same entries, ids
2976
+ * and versions, in the same order, as `evaluate` gives that subset. Asking
2977
+ * for no subsets gives no lists. A backend without `evaluateMany` passes
2978
+ * vacuously: a pull falls back to `evaluate`.
2979
+ */
2980
+ /** Whether two entry lists hold the same ids and versions in the same order. */
2981
+ const sameEntries = (left, right) => left.length === right.length && left.every((entry, index) => entry.id === right[index]?.id && entry.version === right[index]?.version);
2982
+ /** An entry list for a violation message: `id@version`, comma-separated. */
2983
+ const describeEntries = (entries) => entries.map((entry) => `${entry.id}@${entry.version}`).join(", ");
2984
+ const rule14EvaluateManyEquivalence = (h) => Effect.gen(function* () {
2985
+ const many = h.backend.evaluateMany;
2986
+ if (many === void 0) return;
2987
+ const rule = "evaluateMany equals per-subset evaluate";
2988
+ const corpus = yield* contractCorpus;
2989
+ const keep = eq("tag", "keep");
2990
+ const byRank = [{
2991
+ field: "rank",
2992
+ direction: "desc"
2993
+ }];
2994
+ const subsets = [
2995
+ make$5({ collection: h.evalRows }),
2996
+ make$5({ collection: h.rows }),
2997
+ make$5({
2998
+ collection: h.evalRows,
2999
+ where: keep,
3000
+ orderBy: byRank,
3001
+ limit: 2
3002
+ }),
3003
+ make$5({
3004
+ collection: h.evalRows,
3005
+ where: keep,
3006
+ orderBy: byRank,
3007
+ limit: 2,
3008
+ cursor: "e5"
3009
+ }),
3010
+ ...corpusSubsets(h, corpus)
3011
+ ];
3012
+ const batched = yield* h.expectOk(rule, many(h.context, subsets));
3013
+ yield* h.check(rule, batched.length === subsets.length, `Expected ${subsets.length} entry lists, one per subset, got ${batched.length}`);
3014
+ yield* Effect.forEach(subsets, (subset, index) => Effect.gen(function* () {
3015
+ const single = yield* h.expectOk(rule, h.backend.evaluate(h.context, subset));
3016
+ const together = batched[index] ?? [];
3017
+ yield* h.check(rule, sameEntries(together, single), `Subset ${index} over "${subset.collection}": evaluate gave [${describeEntries(single)}], evaluateMany gave [${describeEntries(together)}]`);
3018
+ }), {
3019
+ concurrency: 1,
3020
+ discard: true
3021
+ });
3022
+ const none = yield* h.expectOk(rule, many(h.context, []));
3023
+ yield* h.check(rule, Arr.isReadonlyArrayEmpty(none), `Asked for no subsets, evaluateMany gave ${none.length} lists`);
3024
+ });
3025
+ /** The contract, in the order the rules build on one another's state. */
3026
+ const rules = [
3027
+ rule1InOrderApplication,
3028
+ rule2IdempotentSkip,
3029
+ rule3GapRejection,
3030
+ rule4AtomicBump,
3031
+ rule5VersionBump,
3032
+ rule6ReferenceSemantics,
3033
+ rule7OmitsMissingIds,
3034
+ rule8ClientGroupIsolation,
3035
+ rule9PermanentRejection,
3036
+ rule10WriteThenFail,
3037
+ rule11CrossGroupLostUpdate,
3038
+ rule12OwnWritesReachGetRows,
3039
+ rule13ListsRejections,
3040
+ rule14EvaluateManyEquivalence
3041
+ ];
3042
+ /**
3043
+ * Verify a backend against the contract. The backend must have the contract
3044
+ * mutators registered and the contract collections empty. On a fresh client group
3045
+ * the contract expects the backend to report a ledger of 0 for its client. A run
3046
+ * over a restarted backend passes the ledger it expects as `restoredLedger`: the
3047
+ * contract first checks the backend reports exactly that, failing a backend that
3048
+ * forgot its ledger, and then numbers its mutations from it (#483).
3049
+ */
3050
+ const verifyBackendContract = (backend, options) => {
3051
+ const context = { clientGroupId: options?.clientGroupId ?? "__contract-group" };
3052
+ const expected = options?.restoredLedger ?? 0;
3053
+ return backend.lastMutationIds(context).pipe(Effect.mapError((error) => new ContractViolation({
3054
+ rule: restoredLedgerRule,
3055
+ message: `Backend failed unexpectedly: ${error.message}`
3056
+ })), Effect.flatMap((ids) => {
3057
+ const reported = ids[CONTRACT_CLIENT] ?? 0;
3058
+ if (reported !== expected) return Effect.fail(new ContractViolation({
3059
+ rule: restoredLedgerRule,
3060
+ message: ledgerMismatch("contract group", CONTRACT_CLIENT, expected, reported)
3061
+ }));
3062
+ const harness = makeHarness(backend, options, expected, options?.restoredPeerLedger ?? 0);
3063
+ return Effect.forEach(rules, (rule) => rule(harness), { discard: true });
3064
+ }));
3065
+ };
3066
+ // src/InvalidationSink.ts
3067
+ /**
3068
+ * InvalidationSink
3069
+ *
3070
+ * Turn committed writes into evictions of server-side caches (#206). A
3071
+ * {@link InvalidationSink} is told what each hint batch on the
3072
+ * {@link InvalidationBus} touched, the collections and the row ids, and evicts
3073
+ * whatever it caches for them: a KV read-through cache, the edge HTTP cache's
3074
+ * tags. {@link run} subscribes a sink to the bus, so eviction stops being an
3075
+ * obligation of each mutation's call site.
3076
+ *
3077
+ * Eviction is best-effort, like the bus itself (ADR-0009): a sink logs a
3078
+ * failure and never surfaces it, and it runs on its own fibre, so it can
3079
+ * neither fail nor delay the write that published the hint. A cache's TTL is
3080
+ * the backstop for an eviction that is lost.
3081
+ *
3082
+ * The tag spelling is `HttpCache`'s convention (`collection:{name}`,
3083
+ * `collection:{name}:{id}`), shared with the browser's Cache API invalidation.
3084
+ * Runtime sinks live in their runtime's package: Cloudflare KV and
3085
+ * `cache.purge` in `@thomasfosterau/legation-alchemy-cloudflare` (`EdgeInvalidation`).
3086
+ */
3087
+ var InvalidationSink_exports = /* @__PURE__ */ __exportAll({
3088
+ all: () => all,
3089
+ run: () => run$1,
3090
+ touchedBy: () => touchedBy
3091
+ });
3092
+ /** What a hint batch touched: its collections, and every row id it names (puts and deletes). */
3093
+ const touchedBy = (hints) => ({
3094
+ collections: Arr.dedupe(hints.map((hint) => hint.collection)),
3095
+ rows: hints.flatMap((hint) => hint.ids.map((id) => ({
3096
+ collection: hint.collection,
3097
+ id
3098
+ })))
3099
+ });
3100
+ /** Whether a change touched nothing. */
3101
+ const isEmpty = (touched) => Arr.isReadonlyArrayEmpty(touched.collections) && Arr.isReadonlyArrayEmpty(touched.rows);
3102
+ /**
3103
+ * One sink over several, run concurrently: a KV cache and the edge cache
3104
+ * evicted from the same hint. A defect in one is logged and does not stop the
3105
+ * others.
3106
+ */
3107
+ const all = (sinks) => ({ invalidate: (touched) => Effect.forEach(sinks, (sink) => guarded(sink, touched), {
3108
+ concurrency: "unbounded",
3109
+ discard: true
3110
+ }) });
3111
+ /** Run one sink's eviction, logging rather than propagating a defect it did not catch. */
3112
+ const guarded = (sink, touched) => sink.invalidate(touched).pipe(Effect.catchCause((cause) => Effect.logWarning("InvalidationSink: an eviction failed; the cache TTL is the backstop", cause)));
3113
+ /**
3114
+ * Subscribe `sink` to the bus for the scope's lifetime: each hint batch's
3115
+ * touched collections and rows are passed to it, on a fibre of its own. Run it
3116
+ * where the bus is, in the process that commits the writes. A sink that has to
3117
+ * reach a cache in another process forwards there (Cloudflare:
3118
+ * `EdgeInvalidation.forward` over a service binding).
3119
+ */
3120
+ const run$1 = (sink) => Effect.gen(function* () {
3121
+ const subscription = yield* (yield* InvalidationBus).subscribe;
3122
+ yield* Effect.forkScoped(Effect.forever(subscription.take.pipe(Effect.flatMap((hints) => {
3123
+ const touched = touchedBy(hints);
3124
+ return isEmpty(touched) ? Effect.void : guarded(sink, touched);
3125
+ }))));
3126
+ });
3127
+ // src/PushPoke.ts
3128
+ /**
3129
+ * PushPoke
3130
+ *
3131
+ * Web Push as a poke carrier (#214): the one mechanism that wakes a service
3132
+ * worker that is not running, so a client with no open tab converges before
3133
+ * the user returns. Under poke-then-pull it is a safe fit. A poke carries no
3134
+ * data, so a lost push costs a delay and a duplicate costs one pull that finds
3135
+ * nothing: pull is the correctness path (ADR-0008).
3136
+ *
3137
+ * ## The payload is contentless
3138
+ *
3139
+ * Every push this module sends is {@link payload}: a fixed marker and nothing
3140
+ * else. No topic, no collection name, no row id. A push payload crosses a
3141
+ * third-party push service, and a collection name would tell it what a user
3142
+ * subscribes to. The pull the poke prompts is the authenticated channel; that
3143
+ * is where the data travels.
3144
+ *
3145
+ * ## The app owns the subscriptions and the credentials
3146
+ *
3147
+ * {@link sink} takes the app's endpoint registry (which subscriptions a change
3148
+ * should wake, {@link SinkOptions.targetsFor}) and the app's sender
3149
+ * ({@link SinkOptions.send}, which signs with the app's VAPID keys). The
3150
+ * library holds no credential and stores no subscription.
3151
+ *
3152
+ * ## The budget is the app's stated choice
3153
+ *
3154
+ * Browsers penalise a push that shows no notification, and push services
3155
+ * rate-limit each subscription: an app that wakes a device for every write can
3156
+ * lose its push permission. So {@link SinkOptions.coalesce} and
3157
+ * {@link SinkOptions.budget} are required, with no default: the silent-push
3158
+ * posture is a decision the app writes down, not a guess the library makes.
3159
+ * Within the coalescing window a target gets at most one push, plus one more at
3160
+ * the window's end if anything else changed meanwhile; past its budget a
3161
+ * target gets none until the budget period moves on. A skipped push is logged
3162
+ * at debug level: the next pull, or the next page boot, converges anyway.
3163
+ *
3164
+ * The worker side, which turns a recognised push into a Background Sync
3165
+ * registration or a pull, is `PushPoke` in `@thomasfosterau/legation-browser`.
3166
+ */
3167
+ var PushPoke_exports = /* @__PURE__ */ __exportAll({
3168
+ isPoke: () => isPoke,
3169
+ payload: () => payload,
3170
+ sink: () => sink
3171
+ });
3172
+ /**
3173
+ * The payload of every push-poke: a fixed marker, so the worker can tell the
3174
+ * library's push from the app's own, and nothing about what changed.
3175
+ */
3176
+ const payload = "{\"legation\":\"poke\"}";
3177
+ /** Whether a push's text is the library's poke ({@link payload}). */
3178
+ const isPoke = (text) => text === payload;
3179
+ const emptyState = {
3180
+ lastSent: Number.NEGATIVE_INFINITY,
3181
+ trailing: false,
3182
+ sent: []
3183
+ };
3184
+ /**
3185
+ * An `InvalidationSink` that pokes the devices a change should wake, over Web
3186
+ * Push, within the app's coalescing window and budget. Run it with
3187
+ * `InvalidationSink.run` where the bus is. Its trailing pushes run on fibres
3188
+ * of the scope it is built in.
3189
+ */
3190
+ const sink = (options) => Effect.gen(function* () {
3191
+ const window = Duration.toMillis(Duration.fromInputUnsafe(options.coalesce));
3192
+ const period = Duration.toMillis(Duration.fromInputUnsafe(options.budget.per));
3193
+ const limit = Math.max(0, options.budget.pushes);
3194
+ const states = yield* Ref.make(HashMap.empty());
3195
+ const scope = yield* Effect.scope;
3196
+ const stateOf = (all, key) => Option.getOrElse(HashMap.get(all, key), () => emptyState);
3197
+ const claim = (key, now) => Ref.modify(states, (all) => {
3198
+ const sent = stateOf(all, key).sent.filter((at) => now - at < period);
3199
+ if (sent.length >= limit) return [false, all];
3200
+ return [true, HashMap.set(all, key, {
3201
+ lastSent: now,
3202
+ trailing: false,
3203
+ sent: [...sent, now]
3204
+ })];
3205
+ });
3206
+ const push = (target, key, now) => Effect.flatMap(claim(key, now), (allowed) => allowed ? options.send(target, payload).pipe(Effect.catchCause((cause) => Effect.logWarning("PushPoke: a push failed; the next pull converges", cause))) : Effect.logDebug("PushPoke: a target is over its push budget; skipped"));
3207
+ const decide = (key, now) => Ref.modify(states, (all) => {
3208
+ const state = stateOf(all, key);
3209
+ if (now - state.lastSent >= window) return ["send", all];
3210
+ if (state.trailing) return ["skip", all];
3211
+ return ["trail", HashMap.set(all, key, {
3212
+ ...state,
3213
+ trailing: true
3214
+ })];
3215
+ });
3216
+ const poke = (target) => Effect.gen(function* () {
3217
+ const key = options.keyOf(target);
3218
+ const now = yield* Clock.currentTimeMillis;
3219
+ const decision = yield* decide(key, now);
3220
+ if (decision === "send") return yield* push(target, key, now);
3221
+ if (decision !== "trail") return;
3222
+ const state = stateOf(yield* Ref.get(states), key);
3223
+ const wait = Math.max(0, state.lastSent + window - now);
3224
+ yield* Effect.forkIn(Effect.sleep(Duration.millis(wait)).pipe(Effect.andThen(Clock.currentTimeMillis), Effect.flatMap((at) => push(target, key, at))), scope);
3225
+ });
3226
+ return { invalidate: (touched) => options.targetsFor(touched).pipe(Effect.catchCause((cause) => Effect.logWarning("PushPoke: the endpoint registry failed; no push sent", cause).pipe(Effect.as(Arr.empty()))), Effect.flatMap((targets) => Effect.forEach(Arr.dedupeWith(targets, (a, b) => options.keyOf(a) === options.keyOf(b)), poke, {
3227
+ concurrency: "unbounded",
3228
+ discard: true
3229
+ }))) };
3230
+ });
3231
+ // src/FanoutTopics.ts
3232
+ /**
3233
+ * FanoutTopics
3234
+ *
3235
+ * The pure topic algebra of ADR-0023: fan-out is publish/subscribe over
3236
+ * **derived topics**, and the fan-out layer never evaluates a predicate.
3237
+ * For a node type `C` the topic space is the coarse topic `C` plus fine
3238
+ * topics `C/field=value`, allowed only for fields in the node's
3239
+ * **derivable set** (its `one`-edge keys + declared `hintKeys`) — declared
3240
+ * on the Node, so registration and write-derivation cannot disagree.
3241
+ *
3242
+ * Soundness by construction: a window registers the finest topic its
3243
+ * predicate legally allows (an equality conjunct on a derivable field),
3244
+ * and a write emits every derivable-set topic for its **old and new**
3245
+ * values — so any write that can affect a fine-registered window emits
3246
+ * that window's topic. Over-poking is harmless (pull is the correctness
3247
+ * path); under-poking is impossible.
3248
+ */
3249
+ var FanoutTopics_exports = /* @__PURE__ */ __exportAll({
3250
+ coarse: () => coarse,
3251
+ collectionOf: () => collectionOf,
3252
+ derivableSets: () => derivableSets,
3253
+ fine: () => fine,
3254
+ topicForSubset: () => topicForSubset,
3255
+ topicsForWrite: () => topicsForWrite
3256
+ });
3257
+ /** The coarse topic of a collection: every write to it publishes here. */
3258
+ const coarse = (collection) => collection;
3259
+ /** A fine topic: a graph location (`reviews/productId=p-1` IS that edge). */
3260
+ const fine = (collection, field, value) => `${collection}/${field}=${String(value)}`;
3261
+ /** The collection a topic belongs to (both coarse and fine). */
3262
+ const collectionOf = (topic) => {
3263
+ const slash = topic.indexOf("/");
3264
+ return slash === -1 ? topic : topic.slice(0, slash);
3265
+ };
3266
+ /** The derivable set of every node in a registry, keyed by collection. */
3267
+ const derivableSets = (nodes) => {
3268
+ const map = /* @__PURE__ */ new Map();
3269
+ Arr.forEach(nodes.nodes, ([type, node]) => {
3270
+ const fields = new Set(node.hintKeys);
3271
+ Arr.forEach(Object.values(node.edges), (edge) => {
3272
+ if (edge.kind === "one") fields.add(edge.via);
3273
+ });
3274
+ map.set(type, fields);
3275
+ });
3276
+ return map;
3277
+ };
3278
+ const isLiteral = (value) => P.isString(value) || P.isNumber(value) || P.isBoolean(value);
3279
+ /** The equality conjuncts of a predicate (a bare Compare or an And of them). */
3280
+ const eqConjuncts = (predicate) => {
3281
+ if (predicate._tag === "Compare" && predicate.op === "eq" && isLiteral(predicate.value)) return [{
3282
+ field: predicate.field,
3283
+ value: predicate.value
3284
+ }];
3285
+ if (predicate._tag === "And") return predicate.predicates.flatMap(eqConjuncts);
3286
+ return [];
3287
+ };
3288
+ /**
3289
+ * The finest topic a window's predicate legally allows: an equality
3290
+ * conjunct on a derivable field → that fine topic (every row in the
3291
+ * window carries the value, so every affecting write derives it);
3292
+ * anything else → the coarse topic.
3293
+ */
3294
+ const topicForSubset = (subset, derivable) => {
3295
+ const conjunct = derivable === void 0 ? void 0 : eqConjuncts(subset.where).find((candidate) => derivable.has(candidate.field));
3296
+ return conjunct === void 0 ? coarse(subset.collection) : fine(subset.collection, conjunct.field, conjunct.value);
3297
+ };
3298
+ /**
3299
+ * The topics one written row derives: the coarse topic plus a fine topic
3300
+ * per derivable field of its **old and new** values — an update that moves
3301
+ * a row between parents pokes both edge windows; a delete derives from the
3302
+ * old row; a value-less write degrades to coarse alone.
3303
+ */
3304
+ const topicsForWrite = (collection, derivable, values) => {
3305
+ const topics = /* @__PURE__ */ new Set([coarse(collection)]);
3306
+ if (derivable === void 0) return topics;
3307
+ Arr.forEach([values.oldValue, values.newValue], (value) => {
3308
+ if (!P.isObjectOrArray(value)) return;
3309
+ Arr.forEach(derivable, (field) => {
3310
+ const fieldValue = value[field];
3311
+ if (isLiteral(fieldValue)) topics.add(fine(collection, field, fieldValue));
3312
+ });
3313
+ });
3314
+ return topics;
3315
+ };
3316
+ // src/FanoutBroker.ts
3317
+ /**
3318
+ * FanoutBroker
3319
+ *
3320
+ * ADR-0023's shard seam: topic pub/sub with **leased** registrations.
3321
+ * Rooms register the topic set their windows compile to and renew on a
3322
+ * heartbeat; shards drop expired entries, so crashed rooms age out with no
3323
+ * distributed cleanup. `publish` routes a write's derived topics to every
3324
+ * room whose live lease intersects them — the fan-out layer never
3325
+ * evaluates a predicate.
3326
+ *
3327
+ * The in-process implementation is the single-process reference (and the
3328
+ * semantics a Durable Object or Redis shard must match). A poke here is a
3329
+ * batch of matched topics; a dropped poke only delays the next interval
3330
+ * pull, never prevents convergence.
3331
+ */
3332
+ var FanoutBroker_exports = /* @__PURE__ */ __exportAll({
3333
+ FanoutBroker: () => FanoutBroker,
3334
+ makeInProcess: () => makeInProcess
3335
+ });
3336
+ /**
3337
+ * The {@link FanoutBroker} service tag, for apps that provide a broker by layer;
3338
+ * `FanoutBus.make` takes it as an option.
3339
+ */
3340
+ const FanoutBroker = Context.Service("@thomasfosterau/legation/FanoutBroker");
3341
+ const POKE_CAPACITY = 256;
3342
+ /** The in-process broker: leases in a Ref, one dropping hub per room. */
3343
+ const makeInProcess = Effect.gen(function* () {
3344
+ const leases = yield* Ref.make(/* @__PURE__ */ new Map());
3345
+ const hubs = yield* Ref.make(/* @__PURE__ */ new Map());
3346
+ const scope = yield* Effect.scope;
3347
+ const hubFor = (roomId) => Effect.gen(function* () {
3348
+ const existing = (yield* Ref.get(hubs)).get(roomId);
3349
+ if (existing !== void 0) return existing;
3350
+ const hub = yield* PubSub.dropping({ capacity: POKE_CAPACITY });
3351
+ yield* Scope.addFinalizer(scope, PubSub.shutdown(hub));
3352
+ yield* Ref.update(hubs, (map) => new Map(map).set(roomId, hub));
3353
+ return hub;
3354
+ });
3355
+ return {
3356
+ register: (roomId, topics, ttl) => Effect.gen(function* () {
3357
+ const now = yield* Clock.currentTimeMillis;
3358
+ yield* Ref.update(leases, (map) => new Map(map).set(roomId, {
3359
+ topics,
3360
+ expiresAt: now + Duration.toMillis(ttl)
3361
+ }));
3362
+ }),
3363
+ publish: (topics) => Effect.gen(function* () {
3364
+ const wanted = new Set(topics);
3365
+ const now = yield* Clock.currentTimeMillis;
3366
+ const live = /* @__PURE__ */ new Map();
3367
+ Arr.forEach(yield* Ref.get(leases), ([roomId, lease]) => {
3368
+ if (lease.expiresAt > now) live.set(roomId, lease);
3369
+ });
3370
+ yield* Ref.set(leases, live);
3371
+ const hubMap = yield* Ref.get(hubs);
3372
+ yield* Effect.forEach(live, ([roomId, lease]) => Effect.gen(function* () {
3373
+ const matched = [...wanted].filter((topic) => lease.topics.has(topic));
3374
+ if (Arr.isReadonlyArrayEmpty(matched)) return;
3375
+ const hub = hubMap.get(roomId);
3376
+ if (hub !== void 0) yield* PubSub.publish(hub, matched);
3377
+ }), {
3378
+ concurrency: 1,
3379
+ discard: true
3380
+ });
3381
+ }),
3382
+ pokes: (roomId) => Effect.gen(function* () {
3383
+ const hub = yield* hubFor(roomId);
3384
+ const subscription = yield* PubSub.subscribe(hub);
3385
+ return { take: PubSub.take(subscription) };
3386
+ })
3387
+ };
3388
+ });
3389
+ // src/FanoutBus.ts
3390
+ /**
3391
+ * FanoutBus
3392
+ *
3393
+ * An {@link InvalidationBus} over a {@link FanoutBroker} — the piece that
3394
+ * makes ADR-0023's topic routing a drop-in: `Server.make({ bus })` accepts
3395
+ * it unchanged, and the room deployment gets topic-precise cross-room
3396
+ * fan-out instead of a flat channel.
3397
+ *
3398
+ * Publishing derives each hint's topics (coarse always; fine per derivable
3399
+ * field of the old and new values) and hands them to the broker. The
3400
+ * subscription side surfaces the broker's topic pokes as collection-level
3401
+ * hints — within a room the group count is small (typically one user), so
3402
+ * the sync server's existing matcher finishes the job.
3403
+ *
3404
+ * Registration: the room's current windows compile to topics
3405
+ * ({@link FanoutTopics.topicForSubset}) and are leased on a heartbeat —
3406
+ * registration is idempotent, so the heartbeat IS renewal, and a crashed
3407
+ * room simply ages out shard-side.
3408
+ */
3409
+ var FanoutBus_exports = /* @__PURE__ */ __exportAll({ make: () => make });
3410
+ /** The topics one hint derives (coarse + fine over old and new values). */
3411
+ const topicsForHint = (hint, derivable) => {
3412
+ const values = new Map(hint.values?.map(({ id, value }) => [id, value]) ?? []);
3413
+ const olds = new Map(hint.oldValues?.map(({ id, value }) => [id, value]) ?? []);
3414
+ const topics = /* @__PURE__ */ new Set([coarse(hint.collection)]);
3415
+ const ids = Arr.isReadonlyArrayNonEmpty(hint.ids) ? hint.ids : [...values.keys()];
3416
+ Arr.forEach(ids, (id) => {
3417
+ Arr.forEach(topicsForWrite(hint.collection, derivable, {
3418
+ oldValue: olds.get(id),
3419
+ newValue: values.get(id)
3420
+ }), (topic) => {
3421
+ topics.add(topic);
3422
+ });
3423
+ });
3424
+ return topics;
3425
+ };
3426
+ /** Build the bus; the lease heartbeat runs until the scope closes. */
3427
+ const make = (options) => Effect.gen(function* () {
3428
+ const derivable = derivableSets(options.nodes);
3429
+ const heartbeat = options.heartbeat ?? "10 seconds";
3430
+ const ttl = options.ttl ?? Duration.millis(Duration.toMillis(heartbeat) * 3);
3431
+ const timeout = options.timeout ?? "5 seconds";
3432
+ const bounded = (what, call) => call.pipe(Effect.timeoutOrElse({
3433
+ duration: timeout,
3434
+ orElse: () => Effect.logWarning(`FanoutBus: ${what} timed out; dropped (the next heartbeat or pull recovers it)`)
3435
+ }));
3436
+ const registerNow = bounded("lease registration", options.subsets.pipe(Effect.flatMap((subsets) => options.broker.register(options.roomId, new Set(subsets.map((subset) => topicForSubset(subset, derivable.get(subset.collection)))), ttl))));
3437
+ yield* registerNow;
3438
+ yield* Effect.forkScoped(Effect.forever(Effect.sleep(heartbeat).pipe(Effect.andThen(registerNow))));
3439
+ return {
3440
+ publish: (hints) => {
3441
+ const topics = /* @__PURE__ */ new Set();
3442
+ Arr.forEach(hints, (hint) => {
3443
+ Arr.forEach(topicsForHint(hint, derivable.get(hint.collection)), (topic) => {
3444
+ topics.add(topic);
3445
+ });
3446
+ });
3447
+ return bounded("publish", options.broker.publish(topics)).pipe(Effect.andThen(registerNow));
3448
+ },
3449
+ subscribe: Effect.gen(function* () {
3450
+ return { take: (yield* options.broker.pokes(options.roomId)).take.pipe(Effect.map((topics) => {
3451
+ return [...new Set(topics.map(collectionOf))].map((collection) => ({
3452
+ collection,
3453
+ ids: []
3454
+ }));
3455
+ })) };
3456
+ })
3457
+ };
3458
+ });
3459
+ // src/BusBrokers.ts
3460
+ /**
3461
+ * BusBrokers
3462
+ *
3463
+ * Concrete {@link BusBroker} implementations for the distributed
3464
+ * invalidation bus (ADR-0009). A broker is one string pub/sub channel;
3465
+ * `InvalidationBus.makeSerialized` runs the whole bus over it. These add no
3466
+ * dependency footprint (Redis ships inside `effect`; the WebSocket broker is
3467
+ * structural), so they live in core per ADR-0006.
3468
+ *
3469
+ * - `makeRedisBroker`: over Redis **Streams** (`XADD` / `XREAD BLOCK`) using
3470
+ * `effect/persistence` Redis. Streams give durable, replayable
3471
+ * fan-out; point every instance's subscriber at the same stream key.
3472
+ * - `makeWebSocketBroker`: over any `WebSocketLike` whose far end broadcasts
3473
+ * every frame to all peers — the shape of a Cloudflare Durable Object
3474
+ * relay (each Worker isolate holds a socket to the DO; the DO rebroadcasts).
3475
+ */
3476
+ var BusBrokers_exports = /* @__PURE__ */ __exportAll({
3477
+ makeBroadcastChannelBroker: () => makeBroadcastChannelBroker,
3478
+ makeRedisBroker: () => makeRedisBroker,
3479
+ makeWebSocketBroker: () => makeWebSocketBroker
3480
+ });
3481
+ const PAYLOAD_FIELD = "p";
3482
+ /** Pull the payload value out of an entry's flattened field/value pairs. */
3483
+ const payloadOf = (fields) => {
3484
+ const at = Arr.findFirstIndex(fields, (field, i) => i % 2 === 0 && i + 1 < fields.length && String(field) === PAYLOAD_FIELD);
3485
+ return Option.match(at, {
3486
+ onNone: () => void 0,
3487
+ onSome: (i) => String(fields[i + 1])
3488
+ });
3489
+ };
3490
+ /**
3491
+ * The ID an XREAD continues from after the given XREVRANGE reply: the newest
3492
+ * entry's ID, or `0-0` for an empty stream (so the next XREAD delivers every entry).
3493
+ */
3494
+ const cursorAfter = (reply) => {
3495
+ const [newest] = reply;
3496
+ return newest === void 0 ? "0-0" : newest[0];
3497
+ };
3498
+ /**
3499
+ * How long `subscribe` waits for its one start-position lookup. Past it, the
3500
+ * subscription is returned anyway and the reader resolves the position itself.
3501
+ */
3502
+ const START_LOOKUP_BOUND = Duration.millis(500);
3503
+ /** The reader's start-position lookup did not answer within {@link START_LOOKUP_BOUND}. */
3504
+ var StartLookupTimeout = class extends Schema.TaggedError()("StartLookupTimeout", {}) {};
3505
+ /**
3506
+ * The margin added to the XREAD BLOCK time to bound one reader XREAD. Redis ends a healthy idle
3507
+ * block at BLOCK, so a reply still missing at BLOCK plus this margin is a dead read (a half-open
3508
+ * connection, say), not a quiet stream.
3509
+ */
3510
+ const XREAD_MARGIN_MS = 2e3;
3511
+ /** A reader XREAD got no reply within its bound (see {@link XREAD_MARGIN_MS}). */
3512
+ var XReadTimeout = class extends Schema.TaggedError()("XReadTimeout", {}) {};
3513
+ /** The reader's first back-off after a failed step; it doubles on each consecutive failure. */
3514
+ const RETRY_BASE_MS = 200;
3515
+ /** The cap on the reader's back-off. */
3516
+ const RETRY_CAP_MS = 5e3;
3517
+ /** Back-off before the next attempt, after `failures` consecutive failed steps (`failures >= 1`). */
3518
+ const backoffMs = (failures) => Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** (failures - 1));
3519
+ /**
3520
+ * A BusBroker over Redis Streams. Captures the `Redis` service; each
3521
+ * `subscribe` forks a scoped reader looping `XREAD BLOCK`.
3522
+ *
3523
+ * `subscribe` makes one attempt at the start position (`XREVRANGE`, or `0-0`
3524
+ * for an empty stream), waiting at most 500 ms, and returns either way. If the
3525
+ * attempt answers in time, a hint published once `subscribe` returns is never
3526
+ * missed. If it fails or times out, the forked reader resolves the start
3527
+ * position on its first step and retries with back-off (200 ms, doubling,
3528
+ * capped at 5 s) until Redis answers. While Redis is unreachable, a publish that
3529
+ * races the start can be missed: it lands after `subscribe` returns but before
3530
+ * the reader resolves its start position. Delivery is best-effort (ADR-0009).
3531
+ * Each reader XREAD is bounded at its `block` time plus the margin described on
3532
+ * `block`. A call with no reply by then is a failed step: it is logged, backed off
3533
+ * and retried from the last ID seen, as a failed start lookup is, so no entry is
3534
+ * skipped. A reply of null (a normal BLOCK timeout) is a success.
3535
+ *
3536
+ * The `Redis` service's `send` must meet two properties that this bound relies on.
3537
+ * It must be interruptible: the bound interrupts a read that never replies. And
3538
+ * after an aborted `send`, it must keep replies in order on its connection, so a
3539
+ * late reply to an abandoned XREAD is never read as the answer to the next command.
3540
+ *
3541
+ * Known limit: after a `DEL` and recreate of the stream, or a Redis clock
3542
+ * stepping backwards, new IDs can fall at or below the sticky cursor and be
3543
+ * skipped until the subscriber resubscribes.
3544
+ */
3545
+ const makeRedisBroker = (options) => Effect.gen(function* () {
3546
+ const redis = yield* Redis.Redis;
3547
+ const stream = options?.stream ?? "legation:hints";
3548
+ const blockMillis = Math.trunc(Duration.toMillis(options?.block ?? "5 seconds"));
3549
+ const blockMs = String(blockMillis);
3550
+ const from = options?.from ?? "$";
3551
+ const timeout = options?.timeout ?? "5 seconds";
3552
+ const boundRead = (read) => blockMillis > 0 ? read.pipe(Effect.timeoutOrElse({
3553
+ duration: Duration.millis(blockMillis + XREAD_MARGIN_MS),
3554
+ orElse: () => Effect.fail(new XReadTimeout())
3555
+ })) : read;
3556
+ const lastId = redis.send("XREVRANGE", stream, "+", "-", "COUNT", "1").pipe(Effect.map(cursorAfter));
3557
+ return {
3558
+ publish: (payload) => redis.send("XADD", stream, "*", PAYLOAD_FIELD, payload).pipe(Effect.timeoutOrElse({
3559
+ duration: timeout,
3560
+ orElse: () => Effect.logWarning("Redis broker: XADD timed out; publish dropped")
3561
+ }), Effect.ignore),
3562
+ subscribe: Effect.gen(function* () {
3563
+ const queue = yield* Queue.unbounded();
3564
+ const cursor = { id: from === "0" ? "0" : void 0 };
3565
+ if (cursor.id === void 0) {
3566
+ const first = yield* lastId.pipe(Effect.timeoutOption(START_LOOKUP_BOUND), Effect.orElseSucceed(() => Option.none()));
3567
+ if (Option.isSome(first)) cursor.id = first.value;
3568
+ }
3569
+ const outage = { failures: 0 };
3570
+ const answered = Effect.suspend(() => {
3571
+ const failures = outage.failures;
3572
+ if (failures === 0) return Effect.void;
3573
+ outage.failures = 0;
3574
+ return Effect.logInfo(`Redis broker: reader recovered after ${failures} failed step(s)`);
3575
+ });
3576
+ const failed = (error) => Effect.suspend(() => {
3577
+ outage.failures += 1;
3578
+ const delay = backoffMs(outage.failures);
3579
+ return (outage.failures === 1 ? Effect.logWarning("Redis broker: reader step failed; retrying with back-off", error) : Effect.logDebug(`Redis broker: reader step failed again; retrying in ${delay} millis`, error)).pipe(Effect.andThen(Effect.sleep(Duration.millis(delay))));
3580
+ });
3581
+ const readOnce = Effect.gen(function* () {
3582
+ if (cursor.id === void 0) cursor.id = yield* lastId.pipe(Effect.timeoutOrElse({
3583
+ duration: START_LOOKUP_BOUND,
3584
+ orElse: () => Effect.fail(new StartLookupTimeout())
3585
+ }), Effect.tap(() => answered));
3586
+ const reply = yield* boundRead(redis.send("XREAD", "BLOCK", blockMs, "STREAMS", stream, cursor.id)).pipe(Effect.tap(() => answered));
3587
+ if (reply === null) return;
3588
+ yield* Effect.forEach(reply, ([, entries]) => Effect.gen(function* () {
3589
+ yield* Effect.forEach(entries, ([id, fields]) => Effect.gen(function* () {
3590
+ cursor.id = id;
3591
+ const payload = payloadOf(fields);
3592
+ if (payload !== void 0) yield* Queue.offer(queue, payload);
3593
+ }), {
3594
+ concurrency: 1,
3595
+ discard: true
3596
+ });
3597
+ }), {
3598
+ concurrency: 1,
3599
+ discard: true
3600
+ });
3601
+ }).pipe(Effect.catch(failed));
3602
+ yield* Effect.forkScoped(Effect.forever(readOnce));
3603
+ return { take: Queue.take(queue) };
3604
+ })
3605
+ };
3606
+ });
3607
+ /**
3608
+ * A BusBroker over a `WebSocketLike` whose far end rebroadcasts every frame
3609
+ * to all connected peers — the Durable Object relay pattern. `publish` sends
3610
+ * a frame; `subscribe` collects inbound string frames.
3611
+ */
3612
+ const makeWebSocketBroker = (socket) => ({
3613
+ publish: (payload) => Effect.sync(() => socket.send(payload)),
3614
+ subscribe: Effect.gen(function* () {
3615
+ const queue = yield* Queue.unbounded();
3616
+ yield* Effect.acquireRelease(Effect.sync(() => {
3617
+ const listener = (event) => {
3618
+ if (P.isString(event.data)) Queue.offerUnsafe(queue, event.data);
3619
+ };
3620
+ socket.addEventListener("message", listener);
3621
+ return listener;
3622
+ }), (listener) => Effect.sync(() => socket.removeEventListener("message", listener)));
3623
+ return { take: Queue.take(queue) };
3624
+ })
3625
+ });
3626
+ /**
3627
+ * A BusBroker over a `BroadcastChannel` — cross-context fan-out on one machine
3628
+ * (browser tabs, Node worker threads). A `BroadcastChannel` delivers to every
3629
+ * *other* channel of the same name but not the sender, so `publish` also offers
3630
+ * to this broker's own subscribers, honoring the contract's self-delivery (each
3631
+ * instance fans the hint out to its local sessions). Point every context's
3632
+ * broker at the same channel name.
3633
+ */
3634
+ const makeBroadcastChannelBroker = (channel) => Effect.gen(function* () {
3635
+ const queues = /* @__PURE__ */ new Set();
3636
+ yield* Effect.acquireRelease(Effect.sync(() => {
3637
+ const listener = (event) => {
3638
+ const data = event.data;
3639
+ if (!P.isString(data)) return;
3640
+ Arr.forEach(queues, (queue) => {
3641
+ Queue.offerUnsafe(queue, data);
3642
+ });
3643
+ };
3644
+ channel.addEventListener("message", listener);
3645
+ return listener;
3646
+ }), (listener) => Effect.sync(() => channel.removeEventListener("message", listener)));
3647
+ return {
3648
+ publish: (payload) => Effect.sync(() => {
3649
+ Arr.forEach(queues, (queue) => {
3650
+ Queue.offerUnsafe(queue, payload);
3651
+ });
3652
+ channel.postMessage(payload);
3653
+ }),
3654
+ subscribe: Effect.gen(function* () {
3655
+ const queue = yield* Queue.unbounded();
3656
+ yield* Effect.acquireRelease(Effect.sync(() => queues.add(queue)), () => Effect.sync(() => queues.delete(queue)));
3657
+ return { take: Queue.take(queue) };
3658
+ })
3659
+ };
3660
+ });
3661
+ // src/HintSource.ts
3662
+ /**
3663
+ * HintSource
3664
+ *
3665
+ * A HintSource produces ChangeHints for writes that bypass mutators (CDC —
3666
+ * cron jobs, admin tools, another service, psql), publishing them to the
3667
+ * InvalidationBus so out-of-band changes poke subscribed clients. Fork its
3668
+ * `run` into a scope alongside the SyncServer.
3669
+ *
3670
+ * Correctness is preserved regardless: a HintSource only pokes, and poke is
3671
+ * never a correctness dependency (ADR-0009) — a missed or redundant hint only
3672
+ * changes when the next pull happens. A source that publishes value-less hints
3673
+ * (no `values`) is matched at the collection level by the #42 fan-out matcher,
3674
+ * which is sound: over-matching a poke costs an empty pull, under-matching would
3675
+ * delay convergence, so a hint that names only a collection pokes every group
3676
+ * subscribed to it.
3677
+ */
3678
+ var HintSource_exports = /* @__PURE__ */ __exportAll({
3679
+ fromStream: () => fromStream,
3680
+ run: () => run
3681
+ });
3682
+ /**
3683
+ * Build a HintSource from a stream of hint batches (used by the LISTEN/NOTIFY
3684
+ * recipe and tests): each emitted batch is published to the in-context bus, and
3685
+ * the consuming fibre is tied to the scope so it stops when the scope closes.
3686
+ */
3687
+ const fromStream = (hints) => ({ run: Effect.flatMap(InvalidationBus, (bus) => Effect.asVoid(Effect.forkScoped(Stream.runForEach(hints, bus.publish)))) });
3688
+ /**
3689
+ * Fork a HintSource for the caller's scope, resolving the bus from context: the
3690
+ * source's worker runs on a scoped fibre alongside the SyncServer and is
3691
+ * interrupted when the scope closes.
3692
+ */
3693
+ const run = (source) => source.run;
3694
+ // src/SyncRoom.ts
3695
+ /**
3696
+ * SyncRoom
3697
+ *
3698
+ * The packaged server composition (ADR-0009): SyncServer orchestration +
3699
+ * in-memory CVR store + in-process invalidation bus, ready to bind to any
3700
+ * carrier. The same composition runs in-process on Node or inside a
3701
+ * Cloudflare Durable Object (where the DO's single-threaded scope IS the
3702
+ * room); distributed deployments swap the bus and CVR store layers.
3703
+ */
3704
+ var SyncRoom_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
3705
+ /**
3706
+ * A self-contained room: provide a Backend and an AuthPolicy, get a
3707
+ * ServerHandler. CVRs and the bus are in-memory/in-process.
3708
+ */
3709
+ const layer = layer$2.pipe(Layer.provide(layerMemoryStore), Layer.provide(layerInProcess));
3710
+ // src/HttpCache.ts
3711
+ /**
3712
+ * HttpCache
3713
+ *
3714
+ * The separate service-worker HTTP caching module (distinct from collection
3715
+ * sync; see CONTEXT.md). Core defines the runtime-agnostic service: a
3716
+ * Request/Response cache with freshness windows, stale-while-revalidate,
3717
+ * and tag-based invalidation. The browser package implements the store over
3718
+ * the Cache API (fixing the former text-only body limitation) and closes the
3719
+ * loop on collection- and row-driven invalidation: `HttpCacheInvalidation`
3720
+ * maps an engine's collection and row changes (the `collection:{name}` /
3721
+ * `collection:{name}:{id}` tag convention, #207) onto `invalidateTags`, so a
3722
+ * synced change evicts the responses tagged for what it touched.
3723
+ * Implementations for edge runtimes can back the store with their own
3724
+ * primitives.
3725
+ *
3726
+ * Phase 3 module: interfaces are final-shape; the fetch-event handler and
3727
+ * Cache API store land with the browser host.
3728
+ */
3729
+ var HttpCache_exports = /* @__PURE__ */ __exportAll({
3730
+ ResponseCacheStore: () => ResponseCacheStore,
3731
+ evictionTags: () => evictionTags,
3732
+ fineTagFor: () => fineTagFor,
3733
+ tagFor: () => tagFor
3734
+ });
3735
+ /**
3736
+ * The {@link ResponseCacheStore} service tag, for apps that provide a store by layer;
3737
+ * the fetch handlers take it as an option.
3738
+ */
3739
+ const ResponseCacheStore = Context.Service("@thomasfosterau/legation/ResponseCacheStore");
3740
+ /** The coarse tag of a collection: `collection:{name}`. Every response enumerating (part of) it carries it. */
3741
+ const tagFor = (collection) => `collection:${collection}`;
3742
+ /** The fine tag of one row: `collection:{name}:{id}`. A response carries one per row it contains. */
3743
+ const fineTagFor = (collection, id) => `${tagFor(collection)}:${id}`;
3744
+ /**
3745
+ * The tags to evict for a change, each once: the coarse tag of every touched
3746
+ * collection **and** the fine tag of every touched row. The coarse eviction is
3747
+ * not redundant with the fine one: a cached aggregate (a list, a feed, a
3748
+ * sitemap) can be staled by a row it does not yet contain, such as a newly
3749
+ * published one, and only the coarse tag reaches it.
3750
+ */
3751
+ const evictionTags = (touched, options = {}) => {
3752
+ const coarse = options.tagFor ?? tagFor;
3753
+ const fine = options.fineTagFor ?? fineTagFor;
3754
+ return Arr.dedupe([...touched.collections.map((collection) => coarse(collection)), ...touched.rows.map((row) => fine(row.collection, row.id))]);
3755
+ };
3756
+ export { SpecExport_exports as S, Derive_exports as _, FanoutBus_exports as a, Paced_exports as b, PushPoke_exports as c, FilterCorpus_exports as d, EngineHost_exports as f, LiveQuery_exports as g, EngineAtoms_exports as h, BusBrokers_exports as i, InvalidationSink_exports as l, PromiseFacade_exports as m, SyncRoom_exports as n, FanoutBroker_exports as o, MirrorChannel_exports as p, HintSource_exports as r, FanoutTopics_exports as s, HttpCache_exports as t, BackendContract_exports as u, Incremental_exports as v, Undo_exports as x, Introspection_exports as y };
3757
+
3758
+ //# sourceMappingURL=internal-JyoQMlfK.js.map