@raybase/record-mount 0.0.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
1
+ /**
2
+ * A contract-conformant, in-memory record door — the dev-time half.
3
+ *
4
+ * It exists so that this package's own suite drives the real client
5
+ * against something that answers the wire contract's shapes, and so that a
6
+ * consuming app's tests can exercise its mount without a deployed Worker,
7
+ * a service binding, or a network. It is exported for exactly that.
8
+ *
9
+ * **What it is honest about.** It implements the contract's *shapes* — the
10
+ * result envelope, the closed taxonomy, the Row projection, `version()`,
11
+ * keyset paging, append idempotency by content hash, the ordering rule
12
+ * that `forbidden` outranks `not_found`. It is generic: no family, no id
13
+ * prefix and no lifecycle vocabulary is baked in, and every one of those
14
+ * is a hook with a documented generic default.
15
+ *
16
+ * **What it is not.** It is not the door. Bytes that this double accepts
17
+ * are not thereby proven acceptable to a deployed door — its frontmatter
18
+ * reader is this package's own, written to this package's own dialect, so
19
+ * a suite green against it is a builder-authored oracle for wire
20
+ * acceptance and nothing more. The real-door cross-check is a consumer-side
21
+ * act against the running door itself, and this double does not stand in
22
+ * for it. Its materialize path layout is its own invention (`family/id`),
23
+ * because the real one belongs to the consumer's substrate.
24
+ *
25
+ * ## Seam fidelity: why the default is a Proxy
26
+ *
27
+ * The double used to hand back a plain object that simply OMITTED
28
+ * `append`/`transition` for a read door. That is not how the production seam
29
+ * behaves, and the difference cost a real defect: a Cloudflare RPC
30
+ * service-binding stub answers EVERY property access with a callable, so
31
+ * every property-presence check the client made read `true` against a
32
+ * deployed read door while reading `false` against this fixture. The suite
33
+ * was green on a shape that does not exist in production — a self-authored
34
+ * oracle in its purest form.
35
+ *
36
+ * So by default the double presents as an RPC-faithful stub: a Proxy whose
37
+ * every property is a function, where calling one the underlying door does
38
+ * not carry rejects with a `TypeError` imitating the runtime's method-missing
39
+ * shape. Pass `rpc: false` for the plain object, which is the honest fixture
40
+ * for an in-process mount that really does hold a plain implementation.
41
+ *
42
+ * The imitation is an imitation, but its SHAPE is load-bearing: the client
43
+ * translates a `TypeError` only when its message carries the observed
44
+ * method-missing form — `does not implement` naming the verb — so the double
45
+ * reproduces that form. Do not pin the full string in a test as if it were
46
+ * workerd's contract; the wording is workerd's to change, and when it changes
47
+ * the client deliberately stops translating rather than guessing.
48
+ */
49
+ import { RECORD_COUNT_GROUPS, RECORD_EXACT_FILTERS, RECORD_SET_FILTERS, RECORD_DOOR_CONTRACT_VERSION } from "./contract.js";
50
+ import { parseRecordFrontmatter, recordProseOf } from "./body.js";
51
+ /** The contract's id scheme. The prefix charset matches the composer's
52
+ * `RECORD_ID_PATTERN` exactly — a double stricter than the composer would
53
+ * refuse ids this package itself builds, which is a fixture that lies. */
54
+ const DEFAULT_ID_RE = /^[a-z0-9]+-\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
55
+ const MAX_LIMIT = 1000;
56
+ /** Build the door double. */
57
+ export function createRecordDoorDouble(options = {}) {
58
+ const contract = options.contract ?? RECORD_DOOR_CONTRACT_VERSION;
59
+ const deployment = options.deployment ?? "double";
60
+ const writable = options.writable ?? true;
61
+ const rpc = options.rpc ?? true;
62
+ const declaredWrites = options.declaredWrites ?? writable;
63
+ const hasSerializer = options.serializer ?? true;
64
+ const idPattern = options.idPattern ?? DEFAULT_ID_RE;
65
+ /** Whether the consumer supplied a real prefix-to-family map. */
66
+ const familyDeclared = options.familyOfId !== undefined;
67
+ const familyOfId = options.familyOfId ??
68
+ ((id) => {
69
+ const cut = id.indexOf("-");
70
+ return cut > 0 ? id.slice(0, cut) : null;
71
+ });
72
+ const lifecycleField = options.lifecycleField ?? (() => "status");
73
+ const isLegalTransition = options.isLegalTransition ??
74
+ ((_family, from, to) => from.length > 0 && to.length > 0);
75
+ const canWrite = options.canWrite ?? (() => true);
76
+ const headline = options.headline ?? defaultHeadline;
77
+ const refsOf = options.refsOf ?? defaultRefs;
78
+ const pageEntries = options.materializePageEntries ?? 500;
79
+ const held = new Map();
80
+ const faults = new Map();
81
+ const bindingRefusal = () => {
82
+ if (options.invalidBinding === undefined)
83
+ return null;
84
+ const detail = { missing: options.invalidBinding.missing ?? [] };
85
+ if (options.invalidBinding.actor !== undefined)
86
+ detail.actor = options.invalidBinding.actor;
87
+ if (options.invalidBinding.served !== undefined)
88
+ detail.served = options.invalidBinding.served;
89
+ return refuse("invalid_binding", detail);
90
+ };
91
+ const fault = (verb) => {
92
+ const planted = faults.get(verb);
93
+ if (planted === undefined)
94
+ return null;
95
+ faults.delete(verb);
96
+ return { ok: false, error: planted };
97
+ };
98
+ const guard = (verb) => fault(verb) ?? bindingRefusal();
99
+ const envelopeOf = (rec) => {
100
+ const f = rec.fields;
101
+ const lifecycle = lifecycleField(rec.family);
102
+ const last = rec.events[rec.events.length - 1];
103
+ const env = {
104
+ id: rec.id,
105
+ type: rec.family,
106
+ by: str(f.by) ?? "",
107
+ refs: refsOf(f),
108
+ body_sha256: rec.sha,
109
+ head_seq: rec.events.length
110
+ };
111
+ if (str(f.created) !== undefined)
112
+ env.created = str(f.created);
113
+ if (str(f.ts) !== undefined)
114
+ env.ts = str(f.ts);
115
+ for (const key of ["case", "due", "kind", "severity", "decided"]) {
116
+ const v = str(f[key]);
117
+ if (v !== undefined)
118
+ env[key] = v;
119
+ }
120
+ const decidedBy = str(f["decided-by"]);
121
+ if (decidedBy !== undefined)
122
+ env["decided-by"] = decidedBy;
123
+ // The lifecycle value is the last transition's target, falling back to
124
+ // what the body was filed with. `disposition` is carried as filed
125
+ // unless it IS the family's lifecycle field.
126
+ const filed = lifecycle === null ? undefined : str(f[lifecycle]);
127
+ const current = lastTransition(rec.events)?.to ?? filed;
128
+ if (lifecycle === "disposition") {
129
+ if (current !== undefined)
130
+ env.disposition = current;
131
+ }
132
+ else {
133
+ if (current !== undefined)
134
+ env.status = current;
135
+ const disposition = str(f.disposition) ?? str(last?.fields?.disposition);
136
+ if (disposition !== undefined)
137
+ env.disposition = disposition;
138
+ }
139
+ return env;
140
+ };
141
+ const rowOf = (rec) => {
142
+ const envelope = envelopeOf(rec);
143
+ const body = replay(rec, lifecycleField(rec.family));
144
+ const last = rec.events[rec.events.length - 1];
145
+ return {
146
+ ...envelope,
147
+ headline: headline({ envelope, body }),
148
+ head_ts: last === undefined ? null : last.ts
149
+ };
150
+ };
151
+ const allRows = () => [...held.values()].map(rowOf);
152
+ const door = {
153
+ failNext(verb, error) {
154
+ faults.set(String(verb), {
155
+ code: error.code,
156
+ detail: error.detail ?? {},
157
+ message: error.message ?? `${error.code}: planted by the record door double`
158
+ });
159
+ },
160
+ ids() {
161
+ return [...held.keys()];
162
+ },
163
+ async version() {
164
+ const planted = fault("version");
165
+ if (planted !== null)
166
+ return planted;
167
+ // An older door has no `writes` field at all — the absence IS the
168
+ // simulation, so it is omitted rather than sent as undefined.
169
+ return declaredWrites === "omit"
170
+ ? { ok: true, deployment, contract }
171
+ : { ok: true, deployment, contract, writes: declaredWrites };
172
+ },
173
+ async get(id) {
174
+ const stop = guard("get");
175
+ if (stop !== null)
176
+ return stop;
177
+ if (!idPattern.test(id) || familyOfId(id) === null) {
178
+ return refuse("invalid_id", { id });
179
+ }
180
+ const rec = held.get(id);
181
+ if (rec === undefined)
182
+ return refuse("not_found", { id });
183
+ return {
184
+ ok: true,
185
+ envelope: envelopeOf(rec),
186
+ body: replay(rec, lifecycleField(rec.family)),
187
+ events: rec.events.map((e) => ({ ...e }))
188
+ };
189
+ },
190
+ async query(q) {
191
+ const stop = guard("query");
192
+ if (stop !== null)
193
+ return stop;
194
+ let bounds;
195
+ try {
196
+ bounds = validate(q, false);
197
+ }
198
+ catch (ex) {
199
+ return asRefusal(ex);
200
+ }
201
+ let rows = allRows().filter((row) => matches(row, q));
202
+ rows = sortRows(rows, bounds.order);
203
+ if (q.cursor !== undefined) {
204
+ const after = decodeCursor(q.cursor, queryHash(q));
205
+ if (after === null)
206
+ return refuse("invalid_cursor", {});
207
+ rows = afterKey(rows, after, bounds.order);
208
+ }
209
+ const page = rows.slice(0, bounds.limit);
210
+ const envelopes = page.map(stripRow);
211
+ if (rows.length > bounds.limit) {
212
+ const last = page[page.length - 1];
213
+ return {
214
+ ok: true,
215
+ envelopes,
216
+ cursor: encodeCursor(sortKey(last), last.id, queryHash(q))
217
+ };
218
+ }
219
+ return { ok: true, envelopes };
220
+ },
221
+ async list(q) {
222
+ const stop = guard("list");
223
+ if (stop !== null)
224
+ return stop;
225
+ let bounds;
226
+ try {
227
+ bounds = validate(q, true);
228
+ }
229
+ catch (ex) {
230
+ return asRefusal(ex);
231
+ }
232
+ let rows = sortRows(allRows().filter((row) => matches(row, q)), bounds.order);
233
+ if (q.cursor !== undefined) {
234
+ const after = decodeCursor(q.cursor, queryHash(q));
235
+ if (after === null)
236
+ return refuse("invalid_cursor", {});
237
+ rows = afterKey(rows, after, bounds.order);
238
+ }
239
+ const page = rows.slice(0, bounds.limit);
240
+ if (rows.length > bounds.limit) {
241
+ const last = page[page.length - 1];
242
+ return { ok: true, rows: page, cursor: encodeCursor(sortKey(last), last.id, queryHash(q)) };
243
+ }
244
+ return { ok: true, rows: page };
245
+ },
246
+ async count(args) {
247
+ const stop = guard("count");
248
+ if (stop !== null)
249
+ return stop;
250
+ if (!RECORD_COUNT_GROUPS.includes(args.groupBy)) {
251
+ return refuse("invalid_query", {
252
+ errors: [`groupBy '${String(args.groupBy)}' is not one of ${RECORD_COUNT_GROUPS.join("|")}`]
253
+ });
254
+ }
255
+ const rows = allRows().filter((row) => args.family === undefined || row.type === args.family);
256
+ const tally = new Map();
257
+ for (const row of rows) {
258
+ const value = row[args.groupBy] ?? null;
259
+ tally.set(value, (tally.get(value) ?? 0) + 1);
260
+ }
261
+ const groups = [...tally.entries()]
262
+ .map(([value, count]) => ({ value, count }))
263
+ .sort((a, b) => cmp(a.value ?? "", b.value ?? ""));
264
+ return { ok: true, groups, total: groups.reduce((n, g) => n + g.count, 0) };
265
+ },
266
+ async reverseRefs(id, opts) {
267
+ const stop = guard("reverseRefs");
268
+ if (stop !== null)
269
+ return stop;
270
+ let bounds;
271
+ try {
272
+ bounds = validate({ limit: opts?.limit, order: opts?.order }, false);
273
+ }
274
+ catch (ex) {
275
+ return asRefusal(ex);
276
+ }
277
+ const rows = sortRows(allRows().filter((row) => row.refs.includes(id)), bounds.order).slice(0, bounds.limit);
278
+ return { ok: true, rows };
279
+ },
280
+ async materialize(prefix, cursor) {
281
+ const stop = guard("materialize");
282
+ if (stop !== null)
283
+ return stop;
284
+ // A stored record's family is what it was filed as; only an unknown
285
+ // id falls back to the prefix derivation.
286
+ const parsed = parsePrefix(prefix, (id) => held.get(id)?.family ?? familyOfId(id), idPattern);
287
+ if (parsed === null)
288
+ return refuse("invalid_prefix", { prefix });
289
+ const all = [...held.values()]
290
+ .filter((rec) => (parsed.family === null || rec.family === parsed.family) &&
291
+ (parsed.onlyId === null || rec.id === parsed.onlyId))
292
+ .sort((a, b) => cmp(`${a.family}/${a.id}`, `${b.family}/${b.id}`));
293
+ let index = 0;
294
+ if (cursor !== undefined) {
295
+ const at = decodeIndexCursor(cursor, hash(prefix));
296
+ if (at === null)
297
+ return refuse("invalid_cursor", {});
298
+ index = at;
299
+ }
300
+ const slice = all.slice(index, index + pageEntries);
301
+ const entries = slice.map((rec) => ({
302
+ path: `${rec.family}/${rec.id}`,
303
+ bytes: new TextEncoder().encode(replay(rec, lifecycleField(rec.family)))
304
+ }));
305
+ const next = index + slice.length;
306
+ return next < all.length
307
+ ? { ok: true, entries, cursor: encodeIndexCursor(next, hash(prefix)) }
308
+ : { ok: true, entries };
309
+ },
310
+ ...(writable
311
+ ? {
312
+ async append(body) {
313
+ const stop = guard("append");
314
+ if (stop !== null)
315
+ return stop;
316
+ const text = typeof body === "string" ? body : new TextDecoder().decode(body);
317
+ let fields;
318
+ try {
319
+ fields = parseRecordFrontmatter(text);
320
+ }
321
+ catch (ex) {
322
+ return refuse("invalid_record", { errors: [ex.message] });
323
+ }
324
+ const id = str(fields.id);
325
+ if (id === undefined) {
326
+ return refuse("invalid_record", { errors: ["missing required field 'id'"] });
327
+ }
328
+ const named = familyOfId(id);
329
+ if (!idPattern.test(id) || named === null) {
330
+ return refuse("invalid_id", { id });
331
+ }
332
+ const errors = [];
333
+ // With a declared prefix-to-family map the door's own rule
334
+ // applies: the body's `type` must be the family its id names.
335
+ // Without one, the body's `type` IS the family — see the
336
+ // familyOfId option's note on why equality is not the default.
337
+ const family = familyDeclared ? named : (str(fields.type) ?? named);
338
+ if (familyDeclared && str(fields.type) !== named) {
339
+ errors.push(`type '${String(fields.type)}' != family '${named}'`);
340
+ }
341
+ if (str(fields.created) === undefined && str(fields.ts) === undefined) {
342
+ errors.push("missing created/ts");
343
+ }
344
+ if (str(fields.by) === undefined)
345
+ errors.push("missing required field 'by'");
346
+ if (errors.length > 0) {
347
+ return refuse("invalid_record", { id, errors });
348
+ }
349
+ // forbidden outranks conflict and not_found: authorization is
350
+ // judged before the record is looked for.
351
+ if (!canWrite({ verb: "append", family, id })) {
352
+ return refuse("forbidden", { actor: "(double)", verb: "append", family, id });
353
+ }
354
+ const sha = await sha256(text);
355
+ const existing = held.get(id);
356
+ if (existing !== undefined) {
357
+ if (existing.sha === sha)
358
+ return { ok: true, id, created: false };
359
+ return refuse("conflict", { id, existing_sha256: existing.sha, offered_sha256: sha });
360
+ }
361
+ held.set(id, { id, family, body: text, sha, fields, events: [] });
362
+ return { ok: true, id, created: true };
363
+ },
364
+ async transition(id, expected, next, event) {
365
+ const stop = guard("transition");
366
+ if (stop !== null)
367
+ return stop;
368
+ if (!hasSerializer) {
369
+ return refuse("no_serializer", { id, verb: "transition" });
370
+ }
371
+ if (!idPattern.test(id) || familyOfId(id) === null) {
372
+ return refuse("invalid_id", { id });
373
+ }
374
+ const family = familyOfId(id);
375
+ const lifecycle = lifecycleField(family);
376
+ if (lifecycle === null) {
377
+ return refuse("illegal_transition", { id, family, expected, next });
378
+ }
379
+ // `expected === next` IS the note form. The wire contract's
380
+ // published section does not say so; the reference
381
+ // implementation does, and a note additionally requires
382
+ // non-empty `fields` — it records something or it is noise.
383
+ const isNote = expected === next;
384
+ if (!isNote && !isLegalTransition(family, expected, next)) {
385
+ return refuse("illegal_transition", { id, family, expected, next });
386
+ }
387
+ const missing = [];
388
+ if (str(event?.by) === undefined)
389
+ missing.push("by");
390
+ if (isNote && (event?.fields === undefined || Object.keys(event.fields).length === 0)) {
391
+ missing.push("fields");
392
+ }
393
+ if (missing.length > 0) {
394
+ return refuse("invalid_event", { id, missing });
395
+ }
396
+ if (!canWrite({ verb: "transition", family, id })) {
397
+ return refuse("forbidden", { actor: "(double)", verb: "transition", family, id });
398
+ }
399
+ const rec = held.get(id);
400
+ if (rec === undefined)
401
+ return refuse("not_found", { id });
402
+ const current = lastTransition(rec.events)?.to ?? str(rec.fields[lifecycle]) ?? "";
403
+ if (!isNote && current !== expected) {
404
+ return refuse("stale", { id, expected, current });
405
+ }
406
+ const seq = rec.events.length + 1;
407
+ const ev = {
408
+ record: id,
409
+ seq,
410
+ kind: isNote ? "note" : "transition",
411
+ ...(isNote ? {} : { from: expected, to: next }),
412
+ by: event.by,
413
+ ts: event.ts ?? new Date().toISOString(),
414
+ ...(event.fields ? { fields: event.fields } : {})
415
+ };
416
+ rec.events.push(ev);
417
+ return { ok: true, seq };
418
+ }
419
+ }
420
+ : {})
421
+ };
422
+ return rpc ? asRpcStub(door) : door;
423
+ }
424
+ /**
425
+ * The door, wearing a Cloudflare RPC service-binding stub's manners.
426
+ *
427
+ * The one property that matters: **every property access answers with a
428
+ * callable**, whether or not the door carries that verb. Calling one it does
429
+ * not carry rejects with a `TypeError`, which is the runtime's own wall and
430
+ * the only signal the seam gives — there is nothing to inspect beforehand.
431
+ *
432
+ * `then` is the deliberate exception, answered as `undefined`: a stub that
433
+ * claimed a `then` would be treated as a thenable by every `await` that ever
434
+ * touched it, and real RPC stubs are careful about exactly that.
435
+ *
436
+ * And the manners go one level down: a callable READ OFF the stub is itself
437
+ * proxy-mannered, so `stub.append.call` is not `Function.prototype.call` but
438
+ * a further RPC method named "call" — invoking it rejects method-missing,
439
+ * exactly as workerd does. That interception is what lets this double catch
440
+ * an extracted-and-re-invoked verb (`const v = stub.append; v.call(stub, …)`),
441
+ * the invocation shape that broke every real-binding write while the plainer
442
+ * double waved it through (fnd-2026-08-31-mount-write-call-invocation).
443
+ */
444
+ function asRpcStub(door) {
445
+ return new Proxy(door, {
446
+ get(target, key, receiver) {
447
+ if (typeof key === "symbol")
448
+ return Reflect.get(target, key, receiver);
449
+ if (key === "then")
450
+ return undefined;
451
+ const held = Reflect.get(target, key, receiver);
452
+ const method = held === undefined ? methodMissing(String(key)) : held;
453
+ return typeof method === "function" ? asRpcMethod(method) : method;
454
+ },
455
+ // Presence is uninformative here too, which is the whole point: a client
456
+ // that reached for `in` instead of `typeof` would learn exactly as little.
457
+ has(target, key) {
458
+ return typeof key === "symbol" ? Reflect.has(target, key) : true;
459
+ }
460
+ });
461
+ }
462
+ /**
463
+ * A callable handed out by the RPC-faithful stub. Invoking it runs the
464
+ * door's method; reading any property off it — `call`, `apply`, `bind`,
465
+ * anything — answers another method-missing callable, because the real
466
+ * proxy answers property access with further RPC addressing, never with
467
+ * `Function.prototype`'s helpers.
468
+ */
469
+ function asRpcMethod(fn) {
470
+ return new Proxy(fn, {
471
+ get(target, key, receiver) {
472
+ if (typeof key === "symbol")
473
+ return Reflect.get(target, key, receiver);
474
+ if (key === "then")
475
+ return undefined;
476
+ return methodMissing(String(key));
477
+ }
478
+ });
479
+ }
480
+ /**
481
+ * A verb the bound entrypoint does not carry.
482
+ *
483
+ * The message IMITATES workerd's method-missing shape (observed on the real
484
+ * seam, 2026-08-31) because the client's translation now keys on that shape:
485
+ * `does not implement` plus the quoted method name. A test may rely on the
486
+ * shape the client keys on; pinning the full sentence is still pinning a
487
+ * fixture's prose to a runtime's wording nobody here controls.
488
+ */
489
+ function methodMissing(name) {
490
+ return async () => {
491
+ throw new TypeError(`The RPC receiver does not implement the method "${name}".`);
492
+ };
493
+ }
494
+ /** An `env`-shaped object carrying the double under a binding name — what
495
+ * `openRecordDoor({ env, binding })` reads. */
496
+ export function recordDoorEnv(binding, door) {
497
+ return { [binding]: door };
498
+ }
499
+ // --- the double's internals ------------------------------------------------
500
+ /**
501
+ * A typed refusal, in one uniform message shape:
502
+ * `` `${code}: ${JSON.stringify(detail)}` ``.
503
+ *
504
+ * That is the record-door error type's own default, and the double uses it
505
+ * for every code deliberately. A real door authors bespoke prose at some
506
+ * sites and takes the default at others, so no fixture can match its
507
+ * messages text-for-text — and one that invented door-ish prose would be
508
+ * non-conformant in the least visible way. A single documented shape is the
509
+ * honest fixture.
510
+ *
511
+ * The one place message equality is load-bearing is `not_found`, and there
512
+ * the double and the door genuinely agree: every `not_found` a record door
513
+ * raises takes this default. That matters because the mount's read-policy
514
+ * denial must be byte-identical to real absence. This double previously
515
+ * wrote `not_found: <id>` — the *mount's* bespoke shape rather than the
516
+ * door's — so the suite proved indistinguishability against a fixture built
517
+ * to agree with the implementation, which is a builder-authored oracle
518
+ * passing itself on a security claim.
519
+ */
520
+ function refuse(code, detail) {
521
+ return { ok: false, error: { code, detail, message: `${code}: ${JSON.stringify(detail)}` } };
522
+ }
523
+ class QueryRefusal extends Error {
524
+ errors;
525
+ constructor(errors) {
526
+ super(errors.join("; "));
527
+ this.errors = errors;
528
+ }
529
+ }
530
+ function asRefusal(ex) {
531
+ if (ex instanceof QueryRefusal) {
532
+ return refuse("invalid_query", { errors: ex.errors });
533
+ }
534
+ throw ex;
535
+ }
536
+ function validate(q, sets) {
537
+ const errors = [];
538
+ const known = new Set([...RECORD_EXACT_FILTERS, "ts", "due", "order", "limit", "cursor"]);
539
+ for (const key of Object.keys(q)) {
540
+ if (q[key] === undefined)
541
+ continue;
542
+ if (!known.has(key))
543
+ errors.push(`unknown filter field '${key}'`);
544
+ }
545
+ for (const key of RECORD_EXACT_FILTERS) {
546
+ const value = q[key];
547
+ if (value === undefined)
548
+ continue;
549
+ const isSet = RECORD_SET_FILTERS.includes(key);
550
+ if (Array.isArray(value)) {
551
+ if (!sets || !isSet) {
552
+ errors.push(`filter '${key}' is exact-match — a list is only legal on ${RECORD_SET_FILTERS.join("/")} at list`);
553
+ continue;
554
+ }
555
+ if (value.length === 0) {
556
+ errors.push(`empty set on filter '${key}' — an OR over nothing matches nothing; omit the filter`);
557
+ }
558
+ if (value.some((one) => typeof one !== "string" || one === "")) {
559
+ errors.push(`set filter '${key}' takes nonempty strings`);
560
+ }
561
+ }
562
+ for (const one of Array.isArray(value) ? value : [value]) {
563
+ if (typeof one === "string" && one.includes(",")) {
564
+ errors.push(`comma in exact-match filter '${key}': '${one}' — filters are exact-match, never lists`);
565
+ }
566
+ }
567
+ }
568
+ if (q.order !== undefined && q.order !== "asc" && q.order !== "desc") {
569
+ errors.push(`order must be asc|desc, got '${String(q.order)}'`);
570
+ }
571
+ const limit = q.limit ?? 100;
572
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
573
+ errors.push(`limit out of bounds: ${String(q.limit)} (1..${MAX_LIMIT})`);
574
+ }
575
+ for (const key of ["ts", "due"]) {
576
+ const range = q[key];
577
+ if (range === undefined)
578
+ continue;
579
+ if (typeof range !== "object" ||
580
+ range === null ||
581
+ Array.isArray(range) ||
582
+ Object.keys(range).some((k) => k !== "gte" && k !== "lte") ||
583
+ (range.gte === undefined && range.lte === undefined)) {
584
+ errors.push(`malformed range on '${key}' — {gte?, lte?} with at least one bound`);
585
+ }
586
+ }
587
+ if (errors.length > 0)
588
+ throw new QueryRefusal(errors);
589
+ return { limit, order: q.order ?? "asc" };
590
+ }
591
+ function matches(row, q) {
592
+ for (const key of RECORD_EXACT_FILTERS) {
593
+ const want = q[key];
594
+ if (want === undefined)
595
+ continue;
596
+ const have = key === "family" ? row.type : row[key];
597
+ const wanted = Array.isArray(want) ? want : [want];
598
+ if (!wanted.includes(have))
599
+ return false;
600
+ }
601
+ const key = sortKey(row);
602
+ if (q.ts?.gte !== undefined && key < q.ts.gte)
603
+ return false;
604
+ if (q.ts?.lte !== undefined && key > q.ts.lte)
605
+ return false;
606
+ if (q.due?.gte !== undefined && (row.due ?? "") < q.due.gte)
607
+ return false;
608
+ if (q.due?.lte !== undefined && (row.due ?? "") > q.due.lte)
609
+ return false;
610
+ return true;
611
+ }
612
+ function sortKey(row) {
613
+ return row.ts ?? (row.created !== undefined ? `${row.created}T00:00:00Z` : "");
614
+ }
615
+ function sortRows(rows, order) {
616
+ const out = [...rows].sort((a, b) => cmp(sortKey(a), sortKey(b)) || cmp(a.id, b.id));
617
+ return order === "asc" ? out : out.reverse();
618
+ }
619
+ function afterKey(rows, after, order) {
620
+ return rows.filter((row) => {
621
+ const c = cmp(sortKey(row), after[0]) || cmp(row.id, after[1]);
622
+ return order === "asc" ? c > 0 : c < 0;
623
+ });
624
+ }
625
+ function stripRow(row) {
626
+ const { headline: _h, head_ts: _t, ...envelope } = row;
627
+ return envelope;
628
+ }
629
+ function cmp(a, b) {
630
+ return a < b ? -1 : a > b ? 1 : 0;
631
+ }
632
+ function lastTransition(events) {
633
+ for (let i = events.length - 1; i >= 0; i -= 1) {
634
+ if (events[i].kind === "transition")
635
+ return events[i];
636
+ }
637
+ return undefined;
638
+ }
639
+ /** The lifecycle line, rewritten to the last transition's target — the
640
+ * double's whole replay. The real substrate projects more (a decision's
641
+ * signature fields, per-family edges); nothing generic can. */
642
+ function replay(rec, lifecycle) {
643
+ const last = lastTransition(rec.events);
644
+ if (last === undefined || lifecycle === null || last.to === undefined)
645
+ return rec.body;
646
+ const end = rec.body.indexOf("\n---", 4);
647
+ if (end === -1)
648
+ return rec.body;
649
+ const lines = rec.body.slice(4, end).split("\n");
650
+ const at = lines.findIndex((line) => line.startsWith(`${lifecycle}:`));
651
+ if (at >= 0)
652
+ lines[at] = `${lifecycle}: ${last.to}`;
653
+ else
654
+ lines.push(`${lifecycle}: ${last.to}`);
655
+ return `---\n${lines.join("\n")}${rec.body.slice(end)}`;
656
+ }
657
+ function defaultHeadline({ envelope, body }) {
658
+ for (const line of recordProseOf(body).split("\n")) {
659
+ const text = line.trim();
660
+ if (text.startsWith("# "))
661
+ return clip(text.slice(2).trim());
662
+ if (text.length > 0)
663
+ return clip(text);
664
+ }
665
+ const cut = envelope.id.match(/^[a-z0-9]+-\d{4}-\d{2}-\d{2}-(.+)$/);
666
+ return clip(cut === null ? envelope.id : cut[1]);
667
+ }
668
+ function clip(text) {
669
+ const flat = text.replace(/\s+/g, " ").trim();
670
+ return flat.length <= 160 ? flat : `${flat.slice(0, 160).replace(/\s\S*$/, "")}…`;
671
+ }
672
+ function defaultRefs(fields) {
673
+ const out = [];
674
+ const refs = fields.refs;
675
+ if (Array.isArray(refs))
676
+ out.push(...refs.filter((r) => typeof r === "string"));
677
+ const single = str(fields.ref);
678
+ if (single !== undefined)
679
+ out.push(single);
680
+ return [...new Set(out)];
681
+ }
682
+ function str(value) {
683
+ return typeof value === "string" && value.length > 0 ? value : undefined;
684
+ }
685
+ function parsePrefix(prefix, familyOfId, idPattern) {
686
+ if (prefix === "")
687
+ return { family: null, onlyId: null };
688
+ const [head, ...tail] = prefix.split("/");
689
+ if (head.length === 0 || tail.length > 1)
690
+ return null;
691
+ if (tail.length === 0)
692
+ return { family: head, onlyId: null };
693
+ const onlyId = tail[0];
694
+ if (!idPattern.test(onlyId) || familyOfId(onlyId) !== head)
695
+ return null;
696
+ return { family: head, onlyId };
697
+ }
698
+ async function sha256(text) {
699
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
700
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
701
+ }
702
+ /** Cursors are opaque and only their binding matters, so the double binds
703
+ * them with a cheap non-cryptographic hash rather than pretending to a
704
+ * digest it does not need. */
705
+ function hash(text) {
706
+ let h = 0x811c9dc5;
707
+ for (let i = 0; i < text.length; i += 1) {
708
+ h ^= text.charCodeAt(i);
709
+ h = Math.imul(h, 0x01000193) >>> 0;
710
+ }
711
+ return h.toString(16).padStart(8, "0");
712
+ }
713
+ function queryHash(q) {
714
+ const filters = {};
715
+ for (const key of RECORD_EXACT_FILTERS) {
716
+ const value = q[key];
717
+ if (value === undefined)
718
+ continue;
719
+ filters[key] = Array.isArray(value) ? [...value].sort() : value;
720
+ }
721
+ for (const key of ["ts", "due"]) {
722
+ const range = q[key];
723
+ if (range !== undefined)
724
+ filters[key] = { gte: range.gte ?? null, lte: range.lte ?? null };
725
+ }
726
+ filters.order = q.order ?? "asc";
727
+ return hash(JSON.stringify(filters));
728
+ }
729
+ function b64url(text) {
730
+ return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
731
+ }
732
+ function unb64url(text) {
733
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
734
+ return atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
735
+ }
736
+ function encodeCursor(key, id, bound) {
737
+ return b64url(JSON.stringify({ k: [key, id], h: bound }));
738
+ }
739
+ function decodeCursor(cursor, bound) {
740
+ try {
741
+ const parsed = JSON.parse(unb64url(cursor));
742
+ if (!Array.isArray(parsed.k) || parsed.k.length !== 2 || parsed.h !== bound)
743
+ return null;
744
+ return [String(parsed.k[0]), String(parsed.k[1])];
745
+ }
746
+ catch {
747
+ return null;
748
+ }
749
+ }
750
+ function encodeIndexCursor(index, bound) {
751
+ return b64url(JSON.stringify({ i: index, h: bound }));
752
+ }
753
+ function decodeIndexCursor(cursor, bound) {
754
+ try {
755
+ const parsed = JSON.parse(unb64url(cursor));
756
+ if (typeof parsed.i !== "number" || parsed.h !== bound)
757
+ return null;
758
+ return parsed.i;
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ }
764
+ //# sourceMappingURL=testing.js.map