@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.
- package/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/consume/shard.js
ADDED
|
@@ -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
|
+
}
|