@thomasfosterau/legation 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2054 -0
- package/LICENSE +21 -0
- package/README.md +350 -2
- package/dist/MemoryBackend-BziXwfcs.d.ts +4884 -0
- package/dist/MemoryBackend-BziXwfcs.d.ts.map +1 -0
- package/dist/MemoryBackend-DxP8t4ge.js +9477 -0
- package/dist/MemoryBackend-DxP8t4ge.js.map +1 -0
- package/dist/index-CPPfp0F4.d.ts +399 -0
- package/dist/index-CPPfp0F4.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal-CxXRlFK9.d.ts +2362 -0
- package/dist/internal-CxXRlFK9.d.ts.map +1 -0
- package/dist/internal-JyoQMlfK.js +3758 -0
- package/dist/internal-JyoQMlfK.js.map +1 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/sql/index.d.ts +294 -0
- package/dist/sql/index.d.ts.map +1 -0
- package/dist/sql/index.js +969 -0
- package/dist/sql/index.js.map +1 -0
- package/dist/src-FWXpu_vP.js +1082 -0
- package/dist/src-FWXpu_vP.js.map +1 -0
- package/package.json +69 -3
|
@@ -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
|