@fangorn-network/westmarch 0.0.1-dev

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,726 @@
1
+ // Reading a quickbeam VIEW's Semantic CDN shards, in the consumer's own process.
2
+ //
3
+ // One view → one manifest per watched (owner, namespace) domain → N gzipped
4
+ // NDJSON shards, downloaded once, kept in memory, ranked locally — no index
5
+ // server, no backend, and no chain read on the reader's path. The query never
6
+ // leaves the tab, which is the property the Semantic CDN exists for.
7
+ //
8
+ // Nothing here knows what a row IS. A row is `{ id, owner, ...fields, text,
9
+ // vector, norm }` and everything above that — what a file is, what a folder is,
10
+ // how hits group into results — is the app's. Scoring lives in ./rank.js.
11
+ //
12
+ // ── what an app has to wire ─────────────────────────────────────────────────
13
+ // `configure({ resolveView })` — how to learn WHICH view to read. Deliberately
14
+ // a callback and not a build-time constant: a view id embeds its requester and
15
+ // its name, so repointing an app at a new one must not need a rebuild. sond3r
16
+ // resolves it from its relay's /api/config; an app with a fixed view can pass
17
+ // `() => "https://reg/q/qb_1"`.
18
+ //
19
+ // `configure({ rowText })` — the free text a lexical query matches, per row.
20
+ // The default is name/path/desc. An app with passages (subtitle cues, chapters)
21
+ // joins those instead, because that is what its rows are about.
22
+ //
23
+ // Two ranking modes, chosen per row by ./rank.js: cosine for rows that carry a
24
+ // vector, word-boundary lexical for rows that don't. So search degrades rather
25
+ // than breaks.
26
+ //
27
+ // ponytail: brute-force scan over every row, and rows are held in memory whole.
28
+ // A linear pass over 2k × 256 floats is well under a frame. Reach for
29
+ // HNSW/quantization past ~50k rows, and for IndexedDB past what a tab will hold.
30
+
31
+ import { unpackVec } from "./embed.js";
32
+ import { rankDomains } from "./rank.js";
33
+
34
+ let _view = null; // the app's default view base, resolved lazily
35
+ let _active = null; // the view the reader navigated into, or null for the default
36
+ let _only = null; // domain allowlist; null = pull every domain
37
+ const _shards = new Map(); // view base → its rows
38
+ // Everyone waiting to paint a download that is already running. Honouring only
39
+ // the caller that STARTED it was a race nobody could win: an app's catalog, its
40
+ // vector map and its search box's warm-up all call loadShard on the same tick,
41
+ // and whichever resumed first became the starter — so the one with the callback
42
+ // usually wasn't it, and the page sat blank until the last byte.
43
+ const _watchers = new Map(); // view base → Set of onRows
44
+ // A view's domain manifests, kept rather than discarded. They carry `role_map`
45
+ // and `presentation` — which field is the title, which are tags, which prose to
46
+ // search, what icon an entity type gets — baked by whoever published the domain.
47
+ // Throwing them away is what forces every consumer to hardcode field names and
48
+ // makes a "generic" reader work on exactly one dataset. See ./roles.js.
49
+ const _meta = new Map(); // view base → [manifest, …]
50
+
51
+ /** Free text a lexical query can match. Overridable — see the header. */
52
+ let _rowText = (f) => [f.name, f.path, f.desc].filter(Boolean).join(" ");
53
+ /** How to learn the default view. Overridden by the app; unset means an app that
54
+ * always passes an explicit view base, which is a legitimate way to use this. */
55
+ let _resolveView = null;
56
+ /** Called with the domain manifests once they land and BEFORE the first shard is
57
+ * parsed. See configure(). */
58
+ let _onManifests = null;
59
+
60
+ /**
61
+ * Wire the app-specific pieces. All optional; called once at startup.
62
+ *
63
+ * `onManifests` exists because of an ordering that is easy to get wrong and
64
+ * silent when you do: `rowText` runs per row during parsing, so anything it
65
+ * depends on must be ready BEFORE the first shard is read. Deriving it from the
66
+ * manifests after `loadShard` resolves is too late — every row was already
67
+ * parsed with whatever the default was, and the symptom is not an error, it is
68
+ * search quietly matching nothing. The format puts every manifest on the wire
69
+ * before any shard, so this hook is the moment that dependency can be satisfied.
70
+ *
71
+ * Both hooks are handed the VIEW they are running for. A page that holds more
72
+ * than one corpus open needs per-corpus roles, and `_shards`/`_meta` are already
73
+ * keyed by view — these two callbacks were the only globals left, and without
74
+ * the argument the second corpus loaded would silently be parsed with the
75
+ * first one's text role.
76
+ */
77
+ export function configure({ resolveView, rowText, onManifests } = {}) {
78
+ if (resolveView) _resolveView = resolveView;
79
+ if (rowText) _rowText = rowText;
80
+ if (onManifests) _onManifests = onManifests;
81
+ }
82
+
83
+ /** Drop everything cached — self-checks reload between fixtures, and the SSE
84
+ * watcher drops it when a domain gains shards. Leaves `configure` alone: it is
85
+ * startup wiring, not cache. */
86
+ export const resetShard = () => { _shards.clear(); _watchers.clear(); _meta.clear(); _view = null; _active = null; _only = null; };
87
+
88
+ /**
89
+ * Point every no-arg reader (browse, search, vectors, the SSE watcher) at one
90
+ * view. `null` goes back to the app's default.
91
+ *
92
+ * A module-level current view rather than a url threaded through every call
93
+ * site: an app shows one view at a time, and the cache below is keyed by view,
94
+ * so switching back is free rather than a re-download.
95
+ */
96
+ export function setView(url) {
97
+ _active = url ? trimView(url) : null;
98
+ }
99
+ export const activeView = () => _active;
100
+
101
+ /** The registry prints a domain's `/stream` and `/cdn` URLs rather than the base,
102
+ * so either is accepted and trimmed back to the thing every route hangs off. */
103
+ export const trimView = (url) => String(url).replace(/\/+$/, "").replace(/\/(stream|cdn)$/, "");
104
+
105
+ /** The view base — `{registry}/q/{viewId}`. Cached; a failure clears the slot so
106
+ * the next call retries. */
107
+ function viewBase() {
108
+ if (!_resolveView) return Promise.resolve(null);
109
+ return (_view ??= Promise.resolve()
110
+ .then(() => _resolveView())
111
+ .then((u) => (u ? trimView(u) : null))
112
+ .catch((e) => { _view = null; throw e; }));
113
+ }
114
+
115
+ /**
116
+ * Which of a view's domains to actually download. `null` = all of them.
117
+ *
118
+ * A view fuses several publishers/namespaces, and `load()` pulls EVERY one —
119
+ * which is right for a view built to be browsed whole and wrong for one that has
120
+ * last month's test corpus still watched alongside this month's. Two corpora is
121
+ * twice the bytes, twice the parse, and the second one is not on screen.
122
+ *
123
+ * An entry matches a domain by exact name, by its namespace half (`0xabc/videos`
124
+ * matched by `videos`), or as a prefix — the registry prints these three ways
125
+ * depending on which surface you read them off, and requiring the exact form
126
+ * means a filter that silently matches nothing.
127
+ */
128
+ export function setDomains(only) {
129
+ const list = (Array.isArray(only) ? only : String(only ?? "").split(","))
130
+ .map((x) => String(x).trim()).filter(Boolean);
131
+ _only = list.length ? list : null;
132
+ }
133
+
134
+ /** Does `name` (as the view lists it) match the configured allowlist? */
135
+ const wanted = (name) => !_only || _only.some((e) =>
136
+ name === e || name.split("/").pop() === e || name.startsWith(e));
137
+
138
+ const NO_VIEW = "no quickbeam view configured — pass a view base, or wire one with configure({ resolveView })";
139
+
140
+ /** Fetch + parse a view's shards. Cached PER VIEW; concurrent callers share one
141
+ * fetch. Keyed by the resolved base, so two apps on screen in one session can't
142
+ * serve each other's rows out of a single global slot.
143
+ *
144
+ * `onRows` is called with the merged rows so far after every shard, so a big
145
+ * catalog paints as it arrives instead of after the last byte. Only the caller
146
+ * that starts the download sees them — a second concurrent reader shares the
147
+ * same promise and just waits. */
148
+ export async function loadShard(base, onRows) {
149
+ const at = base ?? _active ?? await viewBase();
150
+ if (!at) throw new Error(NO_VIEW);
151
+ // Subscribe BEFORE the cache check: a load already in flight still has rows to
152
+ // come, and a late subscriber must get them. Registering first also means the
153
+ // order these callers happen to resume in stops mattering.
154
+ let subs = _watchers.get(at);
155
+ if (!subs) _watchers.set(at, (subs = new Set()));
156
+ if (onRows) subs.add(onRows);
157
+
158
+ const hit = _shards.get(at);
159
+ if (hit) return hit; // already downloaded: nothing to stream, the caller gets it whole
160
+ // A rejected promise left in the cache poisons every later search until a page
161
+ // reload, so failures clear the slot and the next call retries.
162
+ const fan = (rows) => { for (const f of subs) { try { f(rows); } catch { /* one bad painter must not stop the rest */ } } };
163
+ const p = load(at, fan)
164
+ .finally(() => _watchers.delete(at))
165
+ .catch((e) => { _shards.delete(at); throw e; });
166
+ _shards.set(at, p);
167
+ return p;
168
+ }
169
+
170
+ /** Fetch a text body, gunzipping it if the server sent raw gzip — see the sniff
171
+ * below. Exported because an app resolving its own view reads config the same
172
+ * way, and a second copy of the sniff would drift. */
173
+ export async function fetchText(url) {
174
+ const res = await fetch(url);
175
+ if (!res.ok) throw new Error(`shard: HTTP ${res.status} for ${url}`);
176
+
177
+ // Who gunzips depends on the server. `cdn serve` sends a shard as
178
+ // `application/gzip` with no Content-Encoding, so the raw gzip arrives here; a
179
+ // host (or a proxy) that labels it `Content-Encoding: gzip` has the BROWSER
180
+ // decode it first, and a DecompressionStream would then choke on plain NDJSON.
181
+ // Sniff the magic bytes rather than trusting headers — this has to work under
182
+ // both, and the same function reads the plain-JSON catalog and manifests.
183
+ return decodeMaybeGzip(new Uint8Array(await res.arrayBuffer()));
184
+ }
185
+
186
+ const decodeMaybeGzip = async (buf) => (buf[0] === 0x1f && buf[1] === 0x8b
187
+ ? await new Response(new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip"))).text()
188
+ : new TextDecoder().decode(buf));
189
+
190
+ function toRow(row, view) {
191
+ const f = row.fields ?? {};
192
+ // Three wire forms for the same vector, and all three are live:
193
+ // `v` base64 int8, what the CDN bake writes now
194
+ // `embedding` plain float array, what it wrote before — shards are
195
+ // served immutable for a year, so this never goes away
196
+ // `fields.embed.vec` base64 int8, the shape a publisher commits on-chain
197
+ // Packed first: a row with both was written by a bake keeping the old key
198
+ // for compatibility, and the packed one is what the manifest's `dim` describes.
199
+ const v = typeof row.v === "string" ? unpackVec(row.v)
200
+ : Array.isArray(row.embedding) ? row.embedding
201
+ : typeof f.embed?.vec === "string" ? unpackVec(f.embed.vec) : null;
202
+ let norm = 0;
203
+ if (v) for (const x of v) norm += x * x;
204
+ return { id: row.track_id, owner: row.owner, ...f, text: _rowText(f, view), vector: v, norm: Math.sqrt(norm) || 1 };
205
+ }
206
+
207
+ /** Hex SHA-256 of the bytes exactly as they arrived. */
208
+ async function sha256Hex(chunks) {
209
+ let n = 0;
210
+ for (const c of chunks) n += c.length;
211
+ const all = new Uint8Array(n);
212
+ let at = 0;
213
+ for (const c of chunks) { all.set(c, at); at += c.length; }
214
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-256", all))]
215
+ .map((b) => b.toString(16).padStart(2, "0")).join("");
216
+ }
217
+
218
+ /**
219
+ * Check a shard against the digest its own manifest published.
220
+ *
221
+ * WHY THIS IS WHAT LETS THE BYTES LIVE ANYWHERE
222
+ * ---------------------------------------------
223
+ * Shards are immutable, hash-named files. Without this check a reader trusts
224
+ * whichever host handed them over, so they can only safely come from the
225
+ * publisher's own origin — and every mirror, CDN edge, gateway or peer is a
226
+ * place to substitute rows nobody would notice. With it, the manifest is the
227
+ * authority and the transport is not trusted at all, which is the difference
228
+ * between "we have to run the server" and "put it anywhere".
229
+ *
230
+ * The manifest hashes the file AS SERVED — gzipped. So this can only run when
231
+ * the raw gzip actually reached us. A host or proxy that sets
232
+ * `Content-Encoding: gzip` has the fetch layer decode it first, and gzip is not
233
+ * reproducible across implementations, so there is nothing left to compare
234
+ * against. That is REPORTED rather than silently passed: serving `.ndjson.gz`
235
+ * as `application/gzip` with no Content-Encoding is precisely what makes a
236
+ * third-party mirror checkable, and a publisher who has it wrong needs to know
237
+ * that their readers cannot verify anything.
238
+ *
239
+ * ponytail: verified at the END of the stream, so rows are handed to the caller
240
+ * before the digest is known. Buffering 34MB to check it first would delete the
241
+ * streaming first paint this file exists to provide. A mismatch therefore
242
+ * REJECTS the load — `loadShard` drops the cache entry, and a caller that
243
+ * painted intermediate rows must treat a failed load as "discard what you
244
+ * showed". Same trade every streaming extractor makes.
245
+ */
246
+ async function verifyShard(url, expect, chunks, gz) {
247
+ // No digest: an older bake. Shards are served immutable for a year, so these
248
+ // do not go away and refusing them would take working corpora offline.
249
+ if (!expect) return;
250
+ if (!gz) { console.warn(`shard: cannot verify ${url} — the transport decoded it (Content-Encoding), and the manifest digest is over the gzipped bytes`); return; }
251
+ const got = await sha256Hex(chunks);
252
+ if (got !== expect) throw new Error(`shard: ${url} does not match the digest in its manifest (expected ${expect.slice(0, 12)}…, got ${got.slice(0, 12)}…)`);
253
+ }
254
+
255
+ /** NDJSON as it arrives, a line at a time.
256
+ *
257
+ * A shard is ONE file and it is big — the live view's is 34MB gzipped, 100MB of
258
+ * JSON, and `arrayBuffer()` means nothing at all exists until the last byte of
259
+ * it lands. Read the body as a stream and the first rows are usable after the
260
+ * first chunk, which is the difference between a blank page for the length of a
261
+ * 34MB download and a catalog that fills in.
262
+ *
263
+ * Same gzip sniff as fetchText and for the same reason (see there) — it just has
264
+ * to happen on the first chunk instead of on the whole buffer. No `res.body`
265
+ * (the self-check's fixtures, an ancient browser) falls back to the buffered
266
+ * read, so this is an optimisation and never a requirement.
267
+ */
268
+ async function fetchLines(url, onLine, expect) {
269
+ const res = await fetch(url);
270
+ if (!res.ok) throw new Error(`shard: HTTP ${res.status} for ${url}`);
271
+ if (!res.body) {
272
+ // The buffered path has every byte before it parses one, so it can verify
273
+ // FIRST — the streaming path below cannot, and says so.
274
+ const buf = new Uint8Array(await res.arrayBuffer());
275
+ await verifyShard(url, expect, [buf], buf[0] === 0x1f && buf[1] === 0x8b);
276
+ // `.split("\n")`: iterating the decoded string yields CHARACTERS, and
277
+ // every one of them reached JSON.parse.
278
+ for (const line of (await decodeMaybeGzip(buf)).split("\n")) onLine(line);
279
+ return;
280
+ }
281
+ const reader = res.body.getReader();
282
+ const first = await reader.read();
283
+ const head = first.value ?? new Uint8Array();
284
+ // Kept only when there is a digest to check them against: a 34MB shard is
285
+ // 34MB of retained chunks, and paying that for a bake that published no
286
+ // digest buys nothing.
287
+ const seen = expect ? [] : null;
288
+ const raw = new ReadableStream({
289
+ start(c) { if (head.length) { seen?.push(head); c.enqueue(head); } if (first.done) c.close(); },
290
+ async pull(c) { const { done, value } = await reader.read(); if (done) { c.close(); } else { seen?.push(value); c.enqueue(value); } },
291
+ cancel(r) { return reader.cancel(r); },
292
+ });
293
+ const gz = head[0] === 0x1f && head[1] === 0x8b;
294
+ const bytes = gz ? raw.pipeThrough(new DecompressionStream("gzip")) : raw;
295
+ const dec = new TextDecoder();
296
+ let buf = "";
297
+ for (const r = bytes.getReader(); ;) {
298
+ const { done, value } = await r.read();
299
+ // A chunk boundary lands mid-line far more often than not, so the tail is
300
+ // held back until its newline arrives. `stream: true` does the same for a
301
+ // multi-byte character split across chunks.
302
+ buf += done ? dec.decode() : dec.decode(value, { stream: true });
303
+ const lines = buf.split("\n");
304
+ buf = done ? "" : lines.pop();
305
+ for (const line of lines) onLine(line);
306
+ if (done) {
307
+ if (buf) onLine(buf);
308
+ // After the last line, not before the first: see verifyShard's note
309
+ // on why the stream is not buffered to check it up front.
310
+ await verifyShard(url, expect, seen, gz);
311
+ return;
312
+ }
313
+ }
314
+ }
315
+
316
+ // How often a partial catalog is handed up. Every line would be correct and
317
+ // useless: the viewer rebuilds its tree, shelves, concepts and wiki from scratch
318
+ // on each one — ~200ms of derivation at 4k files. Painting four times a second
319
+ // is already faster than anyone reads.
320
+ const EMIT_MS = 250;
321
+
322
+ // The download itself is not optimised, only its arrival — but it no longer
323
+ // needs to be: embeddings were 56% of a shard as JSON floats and the bake now
324
+ // writes them as base64 int8 (~14x smaller per vector). Both forms are still
325
+ // read; see toRow.
326
+
327
+ async function load(view, onRows) {
328
+ const cdn = `${view}/cdn`;
329
+
330
+ // The view's own domain list: one per watched (owner, namespace), already
331
+ // filtered to this view by the registry worker. It's the only way the browser
332
+ // learns which domains to pull — the view's sources aren't public here.
333
+ const { domains: listed = [] } = JSON.parse(await fetchText(`${cdn}/catalog`));
334
+ // Narrowed BEFORE any manifest is fetched: a domain that is filtered out must
335
+ // cost nothing, not be downloaded and then hidden.
336
+ const domains = listed.filter((d) => wanted(d.name));
337
+ if (_only && domains.length !== listed.length) {
338
+ console.info(`shard: pulling ${domains.length} of ${listed.length} domains (allowlist: ${_only.join(",")})`);
339
+ }
340
+ if (_only && !domains.length) {
341
+ console.warn(`shard: allowlist ${_only.join(",")} matched none of ${listed.map((d) => d.name).join(", ")} — nothing will load`);
342
+ }
343
+ // A view with nothing baked yet is the normal state of a brand-new one, not an
344
+ // error. Say which it is, or "search finds nothing" is indistinguishable from
345
+ // a URL pointing at the wrong view.
346
+ if (!domains.length) console.warn(`shard: quickbeam view ${cdn} has no baked domains yet — nothing to read until the watcher bakes one.`);
347
+
348
+ const byId = new Map();
349
+ const dead = new Set();
350
+
351
+ // Every manifest BEFORE any shard: a tombstone retracts an id whatever order
352
+ // its shard arrives in, and knowing them all up front is what makes an
353
+ // intermediate emit honest rather than a flash of deleted files.
354
+ const domainsAt = await Promise.all(domains.map(async (d) => {
355
+ const at = `${cdn}/domains/${encodeURIComponent(d.name)}`;
356
+ const m = JSON.parse(await fetchText(`${at}/manifest`));
357
+ for (const id of m.tombstones ?? []) dead.add(id);
358
+ return { at, manifest: m, shards: m.shards ?? [] };
359
+ }));
360
+ // Recorded — and handed to the app — before a single shard is read, so a
361
+ // consumer can lay out its page from the manifests while the rows are still
362
+ // arriving, and so `rowText` is correct for the very first row.
363
+ const manifests = domainsAt.map((d) => d.manifest);
364
+ _meta.set(view, manifests);
365
+ try { _onManifests?.(manifests, view); }
366
+ catch (e) { console.warn("shard: onManifests threw, continuing with the default rowText —", e); }
367
+
368
+ // Domains stream in parallel: their track_ids are vertex CIDs, so rows can't
369
+ // collide across publishers and the interleaving is free. Sequential WITHIN a
370
+ // domain, because a delta shard re-delivers an updated record under the same
371
+ // track_id and the later one has to win.
372
+ let painted = Date.now(); // first paint is a shard in, or EMIT_MS in — whichever comes first
373
+ // A shard boundary always paints — it's the honest checkpoint, and there are
374
+ // one or two of them. The throttle is for the lines INSIDE one, where there
375
+ // are tens of thousands.
376
+ const emit = (force) => {
377
+ if (!onRows || (!force && Date.now() - painted < EMIT_MS)) return;
378
+ onRows([...byId.values()]);
379
+ // Clocked from when the paint FINISHED, not when it started: the callback
380
+ // rebuilds the viewer's whole page synchronously, so timing from the start
381
+ // would queue the next one before the last had let go of the thread.
382
+ painted = Date.now();
383
+ };
384
+ await Promise.all(domainsAt.map(async ({ at, shards }) => {
385
+ for (const s of shards) {
386
+ // The manifest's own digest for this file. A shard that does not
387
+ // match it fails the load rather than becoming rows — which is what
388
+ // lets `/cdn` sit behind any CDN, gateway or peer instead of a
389
+ // server this project has to run.
390
+ await fetchLines(`${at}/shards/${s.file}`, (line) => {
391
+ if (!line.trim()) return;
392
+ const r = toRow(JSON.parse(line), view);
393
+ if (!dead.has(r.id)) byId.set(r.id, r);
394
+ emit();
395
+ }, s.sha256);
396
+ emit(true);
397
+ }
398
+ }));
399
+ return [...byId.values()];
400
+ }
401
+
402
+ /**
403
+ * Hold the view's SSE stream open and call `onChange` when a watched namespace gains
404
+ * shards. Returns an unsubscribe.
405
+ *
406
+ * The stream says WHEN a domain changed, never what — so this only drops the cache
407
+ * and lets the next read re-pull through the normal shard route. `snapshot` is the
408
+ * connect-time census and it re-fires on every automatic reconnect, so treating it
409
+ * as a change would reload the whole catalog each time a proxy cuts the socket.
410
+ */
411
+ export function watchShard(onChange, base) {
412
+ let es = null, stopped = false;
413
+ (async () => {
414
+ const view = base ?? _active ?? await viewBase();
415
+ if (!view || stopped) return;
416
+ es = new EventSource(`${view}/stream`);
417
+ const changed = () => { resetShard(); onChange(); };
418
+ es.addEventListener("added", changed); // a namespace's first bake
419
+ es.addEventListener("change", changed); // new delta shards
420
+ })().catch(() => { }); // no view configured → no live updates; search still works
421
+ return () => { stopped = true; es?.close(); };
422
+ }
423
+
424
+
425
+ /** The manifests of the domains this view actually loaded — `role_map`,
426
+ * `presentation`, `entity_types`, shard checksums. Empty until `loadShard` has
427
+ * fetched them, which happens before any row is parsed. Feed them to
428
+ * ./roles.js rather than reading role_map by hand. */
429
+ export const domainManifests = (base) => _meta.get(base ?? _active ?? "") ?? [..._meta.values()][0] ?? [];
430
+
431
+ /** The view's domain list, ranked against a lookahead vector. Not cached — it's
432
+ * one small JSON and it changes whenever the watcher bakes. */
433
+ export async function suggestDomains(q, { url } = {}) {
434
+ const view = url ?? _active ?? await viewBase();
435
+ if (!view) throw new Error(NO_VIEW);
436
+ const { domains = [] } = JSON.parse(await fetchText(`${view}/cdn/catalog`));
437
+ return rankDomains(domains, q);
438
+ }
439
+
440
+ // ── self-check: `node consume/shard.js` — no network, no view ────────────────
441
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
442
+ const { gzipSync } = await import("node:zlib");
443
+ const warn = console.warn, info = console.info;
444
+ console.warn = console.info = () => {}; // the fallback warnings are behaviour under test, not output
445
+
446
+ const row = (owner, path, extra = {}, embedding) => ({
447
+ track_id: `${owner}:${path}`, owner, ...(embedding ? { embedding } : {}),
448
+ fields: { entityType: "file", name: path.split("/").pop(), path, ...extra },
449
+ });
450
+ const ndjson = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
451
+ const VIEW = "https://registry.test/q/qb_test";
452
+
453
+ // Two chunks, split mid-body: exercises the streaming reader the real loader
454
+ // uses, including a line cut across a chunk boundary.
455
+ const body = (buf) => {
456
+ const b = Buffer.from(buf), half = Math.max(1, Math.floor(b.length / 2));
457
+ return {
458
+ ok: true, headers: new Headers(), arrayBuffer: async () => b,
459
+ body: new ReadableStream({ start(c) { c.enqueue(new Uint8Array(b.subarray(0, half))); c.enqueue(new Uint8Array(b.subarray(half))); c.close(); } }),
460
+ };
461
+ };
462
+
463
+ const hex = async (b) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", b))].map((x) => x.toString(16).padStart(2, "0")).join("");
464
+ const serveView = async (domains, { tombstones = {}, gzipped = true, keep = false, coverage = {}, digest = false, corrupt = false } = {}) => {
465
+ const state = new Map();
466
+ for (const [name, shards] of Object.entries(domains)) {
467
+ const files = shards.map((_, i) => `shard-000${i}-${name}.ndjson.gz`);
468
+ const bodies = new Map(files.map((f, i) => [f, ndjson(shards[i])]));
469
+ // `digest` publishes the sha256 of the bytes AS SERVED, which is what a
470
+ // real bake writes; `corrupt` publishes one for different bytes, which
471
+ // is what a substituting mirror looks like from the reader's side.
472
+ const shas = digest
473
+ ? await Promise.all(files.map(async (f) => hex(gzipSync(Buffer.from(corrupt ? bodies.get(f) + "\n" : bodies.get(f))))))
474
+ : files.map(() => undefined);
475
+ state.set(name, {
476
+ manifest: JSON.stringify({ name, shards: files.map((file, i) => ({ file, ...(shas[i] ? { sha256: shas[i] } : {}) })), tombstones: tombstones[name] ?? [] }),
477
+ bodies,
478
+ });
479
+ }
480
+ globalThis.fetch = async (url) => {
481
+ const { pathname } = new URL(String(url), "https://app.test");
482
+ // An untrimmed base would ask for /stream/cdn/… — which must 404 here,
483
+ // not silently work.
484
+ if (pathname.includes("/stream/")) return { ok: false, status: 404 };
485
+ if (pathname.endsWith("/cdn/catalog")) return body(JSON.stringify({ domains: [...state.keys()].map((name) => ({ name, ...(coverage[name] ? { coverage: coverage[name] } : {}) })) }));
486
+ let m = pathname.match(/\/cdn\/domains\/([^/]+)\/manifest$/);
487
+ if (m) return state.has(m[1]) ? body(state.get(m[1]).manifest) : { ok: false, status: 404 };
488
+ m = pathname.match(/\/cdn\/domains\/([^/]+)\/shards\/([^/]+)$/);
489
+ if (m) {
490
+ const text = state.get(m[1])?.bodies.get(m[2]);
491
+ if (text === undefined) return { ok: false, status: 404 };
492
+ return body(gzipped ? gzipSync(Buffer.from(text)) : Buffer.from(text));
493
+ }
494
+ return { ok: false, status: 404 };
495
+ };
496
+ if (!keep) resetShard();
497
+ // Re-wired after every reset: resetShard clears the resolved base, not the wiring.
498
+ configure({ resolveView: () => `${VIEW}/stream` });
499
+ };
500
+ const serve = async (shards, opts = {}) => serveView({ d0: shards }, { ...opts, tombstones: { d0: opts.tombstones ?? [] } });
501
+ // gzipSync is deterministic for a fixed input at a fixed level, which is why
502
+ // the fixture can hash what it is about to serve. Across zlib builds it is
503
+ // not — hence verifyShard's rule that only the bytes as served are hashed.
504
+ const paths = (rows) => rows.map((r) => r.path).sort().join(",");
505
+
506
+ // ── the view is resolved through the app's callback, and trimmed ──
507
+ // The registry prints /stream and /cdn URLs rather than the base; a base that
508
+ // kept the suffix asks for /stream/cdn/catalog, which the fixture 404s.
509
+ await serve([[row("0xa", "a.md")]]);
510
+ if (paths(await loadShard()) !== "a.md") throw new Error("resolveView never reached the loader");
511
+
512
+ // ── gzip is sniffed, not trusted ──
513
+ // `cdn serve` sends application/gzip with no Content-Encoding, so raw gzip
514
+ // arrives here; a proxy that labels it has the BROWSER decode it first, and a
515
+ // DecompressionStream would then choke on plain NDJSON. Both must work.
516
+ await serve([[row("0xa", "plain.md")]], { gzipped: false });
517
+ if (paths(await loadShard()) !== "plain.md") throw new Error("an already-decoded body must still parse");
518
+
519
+ // ── a shard is checked against the digest its manifest published ──
520
+ // This is what lets /cdn live on any CDN, gateway or peer: the manifest is
521
+ // the authority, and the host handing over the bytes is not trusted at all.
522
+ await serve([[row("0xa", "signed.md")]], { digest: true });
523
+ if (paths(await loadShard()) !== "signed.md") throw new Error("a shard matching its digest must load");
524
+
525
+ // …and a substituted one fails the load rather than becoming rows. Bytes that
526
+ // differ by a single trailing newline are the polite version of this; a mirror
527
+ // rewriting a price or a link looks identical from here.
528
+ await serve([[row("0xa", "tampered.md")]], { digest: true, corrupt: true });
529
+ let caught = null;
530
+ try { await loadShard(); } catch (e) { caught = e; }
531
+ if (!caught) throw new Error("a shard that does not match its digest must fail the load");
532
+ if (!/does not match the digest/.test(caught.message)) throw new Error(`wrong error: ${caught.message}`);
533
+ // …and nothing is left cached, or the next reader is served the tampered rows
534
+ // out of memory with no fetch and no second chance to notice.
535
+ caught = null;
536
+ try { await loadShard(); } catch (e) { caught = e; }
537
+ if (!caught) throw new Error("a failed verification must not leave the rows in the cache");
538
+
539
+ // A transport that decoded the body first (Content-Encoding: gzip) leaves
540
+ // nothing to compare — gzip is not reproducible across implementations. That
541
+ // is reported and the rows still load: refusing them would take every corpus
542
+ // behind such a host offline, and pretending it verified would be a lie.
543
+ let warned = "";
544
+ console.warn = (m) => { warned += m; };
545
+ await serve([[row("0xa", "unverifiable.md")]], { digest: true, gzipped: false });
546
+ if (paths(await loadShard()) !== "unverifiable.md") throw new Error("an unverifiable body must still load");
547
+ if (!/cannot verify/.test(warned)) throw new Error("an unverifiable shard must say so, not pass silently");
548
+ console.warn = () => {};
549
+
550
+ // ── domains merge; a tombstone retracts only its own domain's row ──
551
+ await serveView({
552
+ "aaa": [[row("0xa", "a.md", { desc: "alpha" }), row("0xa", "gone.md", { desc: "retracted" })]],
553
+ "bbb": [[row("0xb", "b.md", { desc: "beta" })]],
554
+ }, { tombstones: { aaa: ["0xa:gone.md"] } });
555
+ if (paths(await loadShard()) !== "a.md,b.md") throw new Error("domains must merge and tombstones retract");
556
+
557
+ // ── the allowlist is applied BEFORE any manifest is fetched ──
558
+ // A filtered-out domain must cost nothing, not be downloaded and then hidden.
559
+ // It matches by exact name, namespace half, or prefix — the registry prints
560
+ // all three, and requiring one form is a filter that silently matches nothing.
561
+ for (const only of ["bbb", ["bbb"], "bb"]) {
562
+ await serveView({ aaa: [[row("0xa", "a.md")]], bbb: [[row("0xb", "b.md")]] });
563
+ setDomains(only);
564
+ if (paths(await loadShard()) !== "b.md") throw new Error(`allowlist ${JSON.stringify(only)} did not narrow the load`);
565
+ }
566
+ await serveView({ aaa: [[row("0xa", "a.md")]] });
567
+ setDomains(null);
568
+ if (paths(await loadShard()) !== "a.md") throw new Error("a null allowlist must pull everything");
569
+
570
+ // ── streaming: a two-shard catalog arrives in installments, growing ──
571
+ await serve([
572
+ [row("0xa", "a.md"), row("0xa", "gone.md")],
573
+ [row("0xa", "b.md")],
574
+ ], { tombstones: ["0xa:gone.md"] });
575
+ {
576
+ const steps = [];
577
+ const all = await loadShard(undefined, (r) => steps.push(r.length));
578
+ if (steps.length !== 2 || steps[0] !== 1 || steps[1] !== 2) throw new Error(`must emit per shard, got ${steps}`);
579
+ if (all.length !== 2) throw new Error("the final rows must still be whole");
580
+ if (steps.some((n) => n > 2)) throw new Error("a tombstoned row must never surface in a partial emit");
581
+ }
582
+
583
+ // ── a LATE subscriber streams too ──
584
+ // Several readers call loadShard on the same tick; honouring only the one that
585
+ // happened to start the download meant the caller holding the paint callback
586
+ // usually wasn't it, and the page stayed blank until the last byte.
587
+ await serve([[row("0xa", "a.md")], [row("0xa", "b.md")]]);
588
+ {
589
+ const first = [], second = [];
590
+ const a = loadShard(undefined, (r) => first.push(r.length)); // the starter
591
+ const b = loadShard(undefined, (r) => second.push(r.length)); // joins in flight
592
+ await Promise.all([a, b]);
593
+ if (!first.length) throw new Error("the starting caller must still receive partials");
594
+ if (!second.length) throw new Error("a caller that joins an in-flight load must receive partials too");
595
+ }
596
+
597
+ // ── a failed load must not poison the cache ──
598
+ // A rejected promise left in the slot makes every later read fail until a page
599
+ // reload, which reads as "search broke" rather than "one fetch failed".
600
+ resetShard();
601
+ configure({ resolveView: () => `${VIEW}/stream` });
602
+ globalThis.fetch = async () => ({ ok: false, status: 500 });
603
+ await loadShard().then(() => { throw new Error("a 500 must reject"); }, () => {});
604
+ await serve([[row("0xa", "a.md")]], { keep: true });
605
+ if (paths(await loadShard()) !== "a.md") throw new Error("a failed load poisoned the cache — the next read must retry");
606
+
607
+ // ── onManifests fires BEFORE the first row is parsed ──
608
+ // The ordering rowText depends on. Deriving row text from the manifest after
609
+ // the load resolves is too late and fails silently: every row is already
610
+ // parsed, and search matches nothing rather than erroring.
611
+ {
612
+ const order = [];
613
+ await serveView({ d0: [[row("0xa", "a.md", { blurb: "declared prose" })]] });
614
+ configure({
615
+ onManifests: (ms) => order.push(`manifests:${ms[0]?.name}`),
616
+ rowText: (f) => { order.push("rowText"); return f.blurb ?? ""; },
617
+ });
618
+ const got = await loadShard();
619
+ if (order[0] !== "manifests:d0") throw new Error(`onManifests must fire first, got ${order}`);
620
+ if (!order.includes("rowText")) throw new Error("rowText never ran");
621
+ if (got[0].text !== "declared prose") throw new Error("the manifest-derived rowText did not reach toRow");
622
+ // A throwing hook must not take the load down with it.
623
+ resetShard();
624
+ configure({ resolveView: () => `${VIEW}/stream`, onManifests: () => { throw new Error("boom"); } });
625
+ if ((await loadShard()).length !== 1) throw new Error("a throwing onManifests must not fail the load");
626
+ configure({ onManifests: () => {} });
627
+ }
628
+
629
+ // ── configure({ rowText }) decides what a lexical query can match ──
630
+ // The default is name/path/desc. An app whose rows carry passages joins those,
631
+ // because that is what its rows are about.
632
+ await serve([[row("0xa", "ep1.md", { cues: [{ text: "the submarine dives" }] })]]);
633
+ if ((await loadShard())[0].text.includes("submarine")) throw new Error("the default rowText must not know about cues");
634
+ configure({ rowText: (f) => (f.cues?.length ? f.cues.map((c) => c.text).join(" ") : [f.name, f.path, f.desc].filter(Boolean).join(" ")) });
635
+ resetShard();
636
+ configure({ resolveView: () => `${VIEW}/stream` });
637
+ if (!(await loadShard())[0].text.includes("submarine")) throw new Error("a configured rowText must reach toRow");
638
+
639
+ // …and it is told WHICH view it is parsing for, which is the whole of what a
640
+ // page needs to hold two publishers' corpora open at once: roles are per
641
+ // corpus, and rowText runs before loadShard has returned either of them.
642
+ {
643
+ const seen = new Set();
644
+ configure({ rowText: (f, view) => { seen.add(view); return f.name ?? ""; },
645
+ onManifests: (ms, view) => { seen.add(`m:${view}`); } });
646
+ resetShard();
647
+ configure({ resolveView: () => `${VIEW}/stream` });
648
+ await loadShard();
649
+ if (!seen.has(VIEW)) throw new Error(`rowText must receive its trimmed view base, saw ${[...seen]}`);
650
+ if (!seen.has(`m:${VIEW}`)) throw new Error("onManifests must receive its view");
651
+ configure({ rowText: (f) => [f.name, f.path, f.desc].filter(Boolean).join(" "), onManifests: () => {} });
652
+ }
653
+
654
+ // ── vectors: both wire forms, and the norm computed once at parse time ──
655
+ await serve([[row("0xa", "v.md", {}, [3, 4]), row("0xa", "packed.md", { embed: { vec: "fwA=" } }), row("0xa", "none.md")]]);
656
+ {
657
+ const by = new Map((await loadShard()).map((r) => [r.path, r]));
658
+ if (Math.abs(by.get("v.md").norm - 5) > 1e-9) throw new Error("norm must be precomputed from the plain float array");
659
+ if (!by.get("packed.md").vector) throw new Error("the base64 int8 form a publisher commits must unpack");
660
+ if (by.get("none.md").vector !== null) throw new Error("a row with no vector must be null, and rank lexically");
661
+ if (by.get("none.md").norm !== 1) throw new Error("a zero norm would divide search by zero");
662
+ }
663
+
664
+ // ── the bake's packed vector (`v`), the form shipped today ──
665
+ // Pinned against pack_vec in quickbeam/vectors.py: [0,1,-1] -> "AH+B". A
666
+ // drift here is not a crash, it is cosine ranking on noise.
667
+ {
668
+ await serveView({ d0: [[{ track_id: "p1", owner: "0xa", v: "AH+B", fields: { entityType: "file", name: "packed.md", path: "packed.md" } },
669
+ { track_id: "p2", owner: "0xa", embedding: [0, 1, -1], fields: { entityType: "file", name: "old.md", path: "old.md" } }]] });
670
+ const by = new Map((await loadShard()).map((r) => [r.path, r]));
671
+ const p = by.get("packed.md").vector, o = by.get("old.md").vector;
672
+ if (!p) throw new Error("the bake's `v` key must unpack");
673
+ for (let i = 0; i < 3; i++) {
674
+ if (Math.abs(p[i] - o[i]) > 1 / 127) throw new Error(`packed and float forms disagree at ${i}: ${p[i]} vs ${o[i]}`);
675
+ }
676
+ if (Math.abs(by.get("packed.md").norm - by.get("old.md").norm) > 0.02) throw new Error("norm must match across wire forms");
677
+ }
678
+
679
+ // ── suggestDomains ranks what has NOT been downloaded ──
680
+ await serveView({
681
+ near: [[row("0xa", "a.md")]], far: [[row("0xb", "b.md")]], old: [[row("0xc", "c.md")]],
682
+ }, { coverage: { near: { vectors: [[1, 0]] }, far: { vectors: [[0, 1]] } } });
683
+ {
684
+ const ranked = await suggestDomains([1, 0]);
685
+ if (ranked.map((d) => d.name).join(",") !== "near,far,old") throw new Error(`suggestDomains misranked: ${ranked.map((d) => d.name)}`);
686
+ }
687
+
688
+ // ── watchShard: `change`/`added` drop the cache, `snapshot` must not ──
689
+ // snapshot is the connect-time census and it re-fires on every automatic
690
+ // reconnect, so treating it as a change re-downloads every shard whenever a
691
+ // proxy cuts the socket.
692
+ await serve([[row("0xa", "before.md")]]);
693
+ {
694
+ const listeners = new Map();
695
+ let opened = "", closed = false;
696
+ globalThis.EventSource = class {
697
+ constructor(url) { opened = url; }
698
+ addEventListener(type, fn) { listeners.set(type, fn); }
699
+ close() { closed = true; }
700
+ };
701
+ let bumps = 0;
702
+ const stop = watchShard(() => { bumps++; });
703
+ await new Promise((r) => setTimeout(r, 0)); // the base resolves a microtask later
704
+ if (opened !== `${VIEW}/stream`) throw new Error(`watchShard must open the view's SSE stream, got ${opened}`);
705
+ if (listeners.has("snapshot")) throw new Error("snapshot is the connect-time census, not a change");
706
+ if (paths(await loadShard()) !== "before.md") throw new Error("watchShard fixture never loaded");
707
+
708
+ await serve([[row("0xa", "after.md")]], { keep: true });
709
+ listeners.get("change")();
710
+ if (bumps !== 1) throw new Error("a `change` event must reach the caller");
711
+ if (paths(await loadShard()) !== "after.md") throw new Error("a `change` must drop the cache so the next read re-pulls");
712
+ stop();
713
+ if (!closed) throw new Error("the unsubscribe must close the stream");
714
+ }
715
+
716
+ // ── setView switches app without re-downloading the one you came from ──
717
+ await serveView({ d0: [[row("0xa", "home.md")]], other: [[row("0xb", "guest.md")]] });
718
+ await loadShard();
719
+ setView(`${VIEW}/cdn`); // an app's own view, given as the /cdn URL the registry prints
720
+ if (activeView() !== VIEW) throw new Error("setView must trim to the base every route hangs off");
721
+ setView(null);
722
+ if (activeView() !== null) throw new Error("setView(null) must go back to the app default");
723
+
724
+ console.warn = warn; console.info = info;
725
+ console.log("shard.js self-check ok — resolveView, gzip sniff, shard digests, domain merge + tombstones + allowlist, streaming, late subscriber, cache poisoning, rowText, vectors, suggestDomains, watchShard");
726
+ }