@fangorn-network/westmarch 2026.10.2-7.1 → 2026.10.2-7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "2026.10.27.1",
3
+ "version": "2026.10.27.2",
4
4
  "description": "all-in-one builder for fangorn apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@fangorn-network/markov": "^0.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@fangorn-network/sdk": "^2026.10.26-dev",
48
+ "@fangorn-network/sdk": "^2026.10.27-dev",
49
49
  "@huggingface/transformers": "^4.2.0",
50
50
  "@noble/curves": "^1.9.1",
51
51
  "viem": "^2.55.1"
@@ -56,7 +56,7 @@
56
56
  }
57
57
  },
58
58
  "devDependencies": {
59
- "@fangorn-network/sdk": "^2026.10.26-dev",
59
+ "@fangorn-network/sdk": "^2026.10.27-dev",
60
60
  "@huggingface/transformers": "^4.2.0",
61
61
  "@noble/curves": "^1.9.1",
62
62
  "@privy-io/react-auth": "^3.47.0",
package/site/worker.js CHANGED
@@ -57,8 +57,18 @@ const encode = (env, prompt) => async (texts) =>
57
57
  // ── the indexer ───────────────────────────────────────────────────────────────
58
58
 
59
59
  const STATE = "state.json", LOCK = "index.lock";
60
- // A run past this is presumed dead and its lock taken over. Cron runs get 15 minutes.
61
- const LOCK_MS = 14 * 60_000;
60
+ // A live run beats its lock every BEAT_MS; one silent past LOCK_MS is presumed dead (killed for CPU
61
+ // or wall time, which skips `finally`) and its lock taken over. Lock: "<last beat ms> <run id>".
62
+ // ponytail: get-then-put, so two runs starting in the same instant can both take it; R2's onlyIf etag when that shows.
63
+ const LOCK_MS = 2 * 60_000, BEAT_MS = 30_000;
64
+ export async function takeLock(bucket, now = Date.now()) {
65
+ const held = await (await bucket.get(LOCK))?.text();
66
+ if (held && now - Number(held.split(" ")[0]) < LOCK_MS) return null;
67
+ const id = crypto.randomUUID();
68
+ const beat = () => bucket.put(LOCK, `${Date.now()} ${id}`);
69
+ await beat();
70
+ return { id, beat, release: async () => { if ((await (await bucket.get(LOCK))?.text())?.endsWith(id)) await bucket.delete(LOCK); } };
71
+ }
62
72
 
63
73
  // CONFIG.network: the SDK's network name ("baseSepolia"); absent, its default.
64
74
  const network = () => SupportedNetworks[CONFIG.network] ?? FangornConfig;
@@ -96,9 +106,9 @@ export async function presented({ fangorn, chain }, state) {
96
106
  }
97
107
 
98
108
  export async function index(env, { log = console.log } = {}) {
99
- const lock = await env.VIEW.get(LOCK);
100
- if (lock && Date.now() - Number(await lock.text()) < LOCK_MS) return log("index: a run is in progress; skipped");
101
- await env.VIEW.put(LOCK, String(Date.now()));
109
+ const lock = await takeLock(env.VIEW);
110
+ if (!lock) return log("index: a run is in progress; skipped");
111
+ const beating = setInterval(() => lock.beat().catch(() => {}), BEAT_MS);
102
112
  try {
103
113
  const state = (await (await env.VIEW.get(STATE))?.json()) ?? { block: null, repos: {} };
104
114
  const r = reader(env);
@@ -113,7 +123,8 @@ export async function index(env, { log = console.log } = {}) {
113
123
  log(`index: through block ${state.block}, ${Object.keys(report).length} domain(s) changed`);
114
124
  return report;
115
125
  } finally {
116
- await env.VIEW.delete(LOCK);
126
+ clearInterval(beating);
127
+ await lock.release();
117
128
  }
118
129
  }
119
130
 
@@ -517,6 +528,15 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
517
528
  delimitedPrefixes: [...new Set(ks.filter((k) => rest(k).includes("/")).map((k) => `${prefix + rest(k).split("/")[0]}/`))] };
518
529
  },
519
530
  };
531
+ const a = await takeLock(VIEW);
532
+ eq([!!a, await takeLock(VIEW)], [true, null], "a held lock skips the next run");
533
+ objs.set(LOCK, { bytes: new TextEncoder().encode(`${Date.now() - LOCK_MS - 1} dead`), at: new Date() });
534
+ const b = await takeLock(VIEW);
535
+ await a.release();
536
+ eq([!!b, objs.has(LOCK)], [true, true], "a silent lock is taken over, and its dead owner can't release the new one");
537
+ await b.release();
538
+ eq(objs.has(LOCK), false, "the owner releases its own lock");
539
+
520
540
  // Words → a 768-d vector, so a query shares dimensions with the rows that share its words.
521
541
  const calls = [];
522
542
  const AI = { run: async (model, { text }) => { calls.push({ model, text });
@@ -166,10 +166,13 @@ export function presentedRepos(cfg) {
166
166
 
167
167
  /** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
168
168
  * binding, the cron and CONFIG. `worker` is site/worker.js unless a test points elsewhere. */
169
- export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
169
+ export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", cpuMs, config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
170
170
  return {
171
171
  name, main: worker, compatibility_date: "2025-09-15", compatibility_flags: ["nodejs_compat"],
172
172
  ...(account ? { account_id: account } : {}),
173
+ // site.cpu_ms: the indexer's CPU per run. Reading one big commit (50k records) outlasts the
174
+ // 30 s Workers Paid default; up to 300000. The Free plan refuses the deploy with it set.
175
+ ...(cpuMs ? { limits: { cpu_ms: cpuMs } } : {}),
173
176
  assets: { directory: site, binding: "ASSETS" },
174
177
  r2_buckets: [{ binding: "VIEW", bucket_name: `${name}-view` }],
175
178
  ai: { binding: "AI" },
@@ -401,7 +404,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
401
404
  const workerConfig = (u) => {
402
405
  const path = join(dir, "wrangler.json");
403
406
  writeFileSync(path, JSON.stringify(wranglerConfig({
404
- name: cfg.site.project, site, account: cfg.site.account, cron: cfg.site.cron, url: cfg.site.url, ipfsGateway: view.ipfsGateway(),
407
+ name: cfg.site.project, site, account: cfg.site.account, cron: cfg.site.cron, cpuMs: cfg.site.cpu_ms, url: cfg.site.url, ipfsGateway: view.ipfsGateway(),
405
408
  config: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock: state.fromBlock, network: cfg.network,
406
409
  schema: { types: cfg.types, relations: cfg.relations }, repos: register ? [] : repos, paid: sale(u).sale, private: access(u).config },
407
410
  }), null, 1));
@@ -717,8 +720,11 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
717
720
  if (secret) writeFileSync(vars, `PAID_SECRET=${secret}\n`, { mode: 0o600 });
718
721
  const url = `http://127.0.0.1:${port}`, start = Math.floor(Date.now() / 1000);
719
722
  const wrangler = spawn("npx", ["wrangler", "dev", "--config", wpath, "--port", String(port), "--ip", "127.0.0.1", "--test-scheduled"],
720
- { cwd: join(HERE, "../.."), stdio: ["ignore", "inherit", "inherit"],
723
+ { cwd: join(HERE, "../.."), stdio: ["ignore", "pipe", "inherit"],
721
724
  env: { ...process.env, ...(cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {}) } });
725
+ // A run already holds the lock (the deployed cron's): this one skips, and the catalog would never move.
726
+ let skipped = false;
727
+ wrangler.stdout.on("data", (b) => { process.stdout.write(b); if (String(b).includes("index: a run is in progress; skipped")) skipped = true; });
722
728
  try {
723
729
  for (;;) {
724
730
  if (wrangler.exitCode !== null) throw new Error("wrangler dev exited");
@@ -730,6 +736,7 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
730
736
  let catalog = null;
731
737
  while (!(catalog?.generated_at >= start)) {
732
738
  if (wrangler.exitCode !== null) throw new Error("wrangler dev exited");
739
+ if (skipped) throw new Error("another indexer run holds the lock (the deployed cron's, most likely); it frees within 2 minutes of that run dying. Try again then.");
733
740
  await new Promise((ok) => setTimeout(ok, 5000));
734
741
  catalog = await fetch(`${url}/view/cdn/catalog`).then((r) => (r.ok ? r.json() : null), () => null);
735
742
  }
@@ -168,12 +168,21 @@ export async function readOnlyFangorn() {
168
168
  * A build that must not touch the chain — a candidate recipe graded before it ships —
169
169
  * runs the exact code a real one does. */
170
170
  export function localFangorn(chain, { appId, owner }) {
171
- const root = (k) => `0x${createHash("sha256").update(JSON.stringify(chain[k])).digest("hex")}`;
171
+ // Every version handed out is kept, so repoDiff can diff any two, as the SDK diffs two roots.
172
+ const versions = new Map();
173
+ const root = (k) => { const h = `0x${createHash("sha256").update(JSON.stringify(chain[k])).digest("hex")}`; versions.set(h, structuredClone(chain[k]?.records ?? [])); return h; };
174
+ const repoDiff = async (from, to) => {
175
+ if (!versions.has(from) || !versions.has(to)) throw new Error("unknown root");
176
+ const [a, b] = [versions.get(from), versions.get(to)], cidOf = new Map(a.map((r) => [r.id, r.cid])), ids = new Set(b.map((r) => r.id));
177
+ return { puts: b.filter((r) => cidOf.get(r.id) !== r.cid), deletes: a.filter((r) => !ids.has(r.id)).map((r) => r.id),
178
+ superseded: a.filter((r) => !ids.has(r.id) || b.some((x) => x.id === r.id && x.cid !== r.cid)).map((r) => r.cid) };
179
+ };
172
180
  return {
181
+ engine: { repoDiff }, reads: 0,
173
182
  setAppId() {}, getAppId: () => appId,
174
183
  getAppRegistry: () => ({ getAppOwner: async () => owner }),
175
184
  appRepos: async () => Object.keys(chain).map((k) => ({ owner: k.split("/")[0], repo: k.split("/").slice(1).join("/"), root: root(k) })),
176
- readRepo: async (o, repo) => { const c = chain[`${o}/${repo}`]; return { tip: c ? root(`${o}/${repo}`) : null, contents: { records: c?.records ?? [] } }; },
185
+ readRepo: async function (o, repo) { this.reads++; const c = chain[`${o}/${repo}`]; return { tip: c ? root(`${o}/${repo}`) : null, contents: { records: c?.records ?? [] } }; },
177
186
  };
178
187
  }
179
188
 
@@ -243,11 +252,12 @@ export function r2Store(bucket, prefix = "view/") {
243
252
  const readJson = async (store, p, dflt) => { const b = await store.get(p); return b ? JSON.parse(new TextDecoder().decode(b)) : dflt; };
244
253
  const writeJson = (store, p, o) => store.put(p, JSON.stringify(o, null, 1));
245
254
 
246
- /** What a domain already serves: its manifest and every row (for tombstones and coverage). */
247
- async function loadDomain(store, dir) {
255
+ /** What a domain already serves: its manifest and every row (for tombstones and coverage), or
256
+ * with `only`, the rows of those shard files alone. */
257
+ async function loadDomain(store, dir, only = null) {
248
258
  const manifest = await readJson(store, `${dir}/manifest`, null);
249
259
  const rows = new Map();
250
- for (const s of manifest?.shards ?? []) {
260
+ for (const s of (manifest?.shards ?? []).filter((x) => !only || only.includes(x.file))) {
251
261
  const b = await store.get(`${dir}/shards/${s.file}`);
252
262
  if (!b) throw new Error(`${dir}: shard ${s.file} is named by its manifest and missing`);
253
263
  for (const line of gunzipSync(b).toString("utf8").split("\n")) if (line.trim()) { const r = JSON.parse(line); rows.set(r.track_id, r); }
@@ -339,21 +349,38 @@ async function retry(f, tries = 6) {
339
349
  }
340
350
  }
341
351
 
352
+ const CHECKPOINT = 2048; // rows embedded between saves; a killed run redoes at most this many
342
353
  async function publishDomain({ fangorn, store, schema, schemaHash, publisher, repo, head, domain, dir, rebake, embedBatch, embedder, log, shardRows, open }) {
343
354
  // The head the domain was built from, and the schema it was built under: neither moved,
344
355
  // nothing to read. This is what keeps a cron run with nothing new to one read per repo.
345
356
  const was = rebake ? null : await readJson(store, `${dir}/manifest`, null);
346
357
  if (was && was.head === head && was.schema_hash === schemaHash) return null;
347
358
 
348
- const { contents } = await retry(() => fangorn.readRepo(publisher, repo));
359
+ // The head moved from one this domain was built from: read only what changed between them (the
360
+ // SDK's repoDiff opens only the changed paths of the record tree), not every record again. A
361
+ // schema with relations links each row against all the others, so it still reads the whole repo;
362
+ // so does an SDK whose diff can't name the replaced versions, and a diff that fails (a force-push
363
+ // to a head whose blocks are gone).
364
+ // ponytail: relations read whole; diff their targets too when a big corpus declares one.
365
+ const diff = was?.head && !schema.relations?.length && fangorn.engine?.repoDiff
366
+ ? await retry(() => fangorn.engine.repoDiff(was.head, head)).catch((e) => { log(`${domain}: diff from ${was.head} failed (${e.message}); reading it whole`); return null; })
367
+ : null;
368
+ const got = diff?.superseded ? diff.puts : (await retry(() => fangorn.readRepo(publisher, repo))).contents.records;
349
369
  // `open`: a private app's rows are sealed on chain (x402.js's sealRow); the Worker opens them here.
350
- const records = new Map(await Promise.all(contents.records.filter((r) => r.payload && typeof r.payload === "object")
370
+ const records = new Map(await Promise.all(got.filter((r) => r.payload && typeof r.payload === "object")
351
371
  .map(async (r) => [r.cid, { id: r.id, fields: { ...(await open(r.payload)), entityType: r.type } }])));
352
372
  linkRelations(records, schema);
353
373
  const fieldsOf = (cid) => records.get(cid).fields;
354
374
 
355
375
  if (rebake) await store.del(dir); // old shards would stay reachable
356
- const had = await loadDomain(store, dir);
376
+ // Append only: the diff replaced nothing, so no row already served changes, and the 50k of them
377
+ // are left unread. Only the shards saved since the last head (`ahead`, a killed run's checkpoints)
378
+ // are read, to skip what they hold. A removal, an edit, a rebake or a coverage refit reads all.
379
+ // Rows in `ahead` are already counted, and come back among the puts of a diff from the old head.
380
+ const aheadRows = (was?.shards ?? []).filter((x) => was.ahead?.includes(x.file)).reduce((n, x) => n + x.count, 0);
381
+ const append = !!was && !!diff?.superseded && !diff.superseded.length && !was.tombstones?.length
382
+ && (was.count ?? 0) + diff.puts.length - aheadRows < COVERAGE_REFIT * (was.coverage?.fit_at ?? 0);
383
+ const had = await loadDomain(store, dir, append ? was.ahead ?? [] : null);
357
384
  const made = had.manifest?.embedder ?? (had.manifest ? { runtime: "fastembed", dtype: "fp32" } : null);
358
385
  // Same model and dtype is the same encoder, whatever ran it: q8 on CPU, CUDA and WASM agree to
359
386
  // ~0.98-0.99 cosine, fp32 against q8 noticeably less.
@@ -364,10 +391,11 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
364
391
  const dead = new Set(had.manifest?.tombstones ?? []);
365
392
  const live = (id) => had.rows.has(id) && !dead.has(id);
366
393
  const fresh = [...records.keys()].filter((id) => !live(id));
367
- const gone = [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
394
+ // From a diff, `records` holds only what changed: what is gone is what it superseded.
395
+ const gone = diff?.superseded ? diff.superseded.filter(live) : [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
368
396
  // A row already served whose links changed (its matter came later, or moved): new fields,
369
- // the same vector, so no embedding, only a rewrite.
370
- const relinked = [...had.rows.keys()].filter((id) => live(id) && records.has(id)
397
+ // the same vector, so no embedding, only a rewrite. Only relations relink, and they read whole.
398
+ const relinked = diff?.superseded ? [] : [...had.rows.keys()].filter((id) => live(id) && records.has(id)
371
399
  && JSON.stringify(had.rows.get(id).fields) !== JSON.stringify(fieldsOf(id)));
372
400
  for (const id of relinked) had.rows.get(id).fields = fieldsOf(id);
373
401
 
@@ -383,6 +411,34 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
383
411
  }
384
412
  }
385
413
 
414
+ const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
415
+ distance: "Cosine", embedder, filter: { owner: [publisher], repo: [repo] }, shards: [], tombstones: [] };
416
+ // Pages and R2 serve files of any size, but a reader holds a shard whole; 52k Steam games
417
+ // gzipped to 28 MiB in one. ponytail: split by row count (~550 B a row gzipped).
418
+ const writeShard = async (all) => {
419
+ const files = [];
420
+ for (let i = 0; i < all.length; i += shardRows) {
421
+ const ls = all.slice(i, i + shardRows);
422
+ const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
423
+ const digest = sha256(gz);
424
+ const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${digest.slice(0, 12)}.ndjson.gz`;
425
+ await store.put(`${dir}/shards/${file}`, gz);
426
+ manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256: digest });
427
+ files.push(file);
428
+ }
429
+ return files;
430
+ };
431
+ // What is embedded so far, served now under the old head: a run killed later (CPU, wall time:
432
+ // no exception, no `finally`) loses at most CHECKPOINT rows, and the next run embeds only the rest.
433
+ const save = async () => {
434
+ manifest.ahead = [...(manifest.ahead ?? []), ...await writeShard(lines)];
435
+ const rows = lines.map((l) => JSON.parse(l));
436
+ for (const r of rows) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
437
+ lines.length = 0;
438
+ manifest.head = had.manifest?.head ?? null;
439
+ manifest.schema_hash = schemaHash;
440
+ await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? rows : null);
441
+ };
386
442
  let n = 0, stopped = null;
387
443
  const lines = [];
388
444
  // 32 at a time: what an encoder batches (embed.js, a GPU, the Worker's AI binding).
@@ -400,39 +456,26 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
400
456
  }
401
457
  if (Math.floor((n + ids.length) / 1000) > Math.floor(n / 1000)) log(`${domain}: embedded ${n + ids.length}/${fresh.length}`);
402
458
  n += ids.length;
459
+ if (lines.length >= CHECKPOINT && n < fresh.length) await save();
403
460
  }
404
- const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
405
- distance: "Cosine", embedder, filter: { owner: [publisher], repo: [repo] }, shards: [], tombstones: [] };
406
461
  // Unfinished: the old head stays, so the next run sees a head to catch up to and embeds only what is missing.
407
462
  manifest.head = stopped ? (had.manifest?.head ?? null) : head;
408
463
  manifest.schema_hash = schemaHash;
464
+ if (!stopped) delete manifest.ahead; // the head is taken: everything is behind it
409
465
  if (!fresh.length && !gone.length && !relinked.length && had.manifest && !rebake) {
410
- await applySpec(store, dir, manifest, schema, had.rows, dead); // a schema change alone still lands
466
+ await applySpec(store, dir, manifest, schema, had.rows, dead, new Map(), append ? [] : null); // a schema change alone still lands
411
467
  return { added: 0, removed: 0 };
412
468
  }
413
469
 
414
- // Pages and R2 serve files of any size, but a reader holds a shard whole; 52k Steam games
415
- // gzipped to 28 MiB in one. ponytail: split by row count (~550 B a row gzipped).
416
- const writeShard = async (all) => {
417
- for (let i = 0; i < all.length; i += shardRows) {
418
- const ls = all.slice(i, i + shardRows);
419
- const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
420
- const digest = sha256(gz);
421
- const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${digest.slice(0, 12)}.ndjson.gz`;
422
- await store.put(`${dir}/shards/${file}`, gz);
423
- manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256: digest });
424
- }
425
- };
426
470
  if (stopped) {
427
- await writeShard(lines);
428
- for (const l of lines) { const r = JSON.parse(l); had.rows.set(r.track_id, r); dead.delete(r.track_id); }
429
- await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf);
471
+ await save();
430
472
  log(`${domain}: stopped at ${n}/${fresh.length} (${stopped.message}); the rest on the next run`);
431
473
  return { added: n, removed: 0, partial: true };
432
474
  }
475
+ const last = lines.map((l) => JSON.parse(l));
433
476
  if (lines.length) {
434
477
  await writeShard(lines);
435
- for (const l of lines) { const r = JSON.parse(l); had.rows.set(r.track_id, r); dead.delete(r.track_id); }
478
+ for (const r of last) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
436
479
  }
437
480
  for (const id of gone) dead.add(id);
438
481
  // A tombstone hides a row from readers, but its shard still serves the bytes. A record
@@ -441,6 +484,8 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
441
484
  // files are deleted. No re-embedding: the vectors are already in the rows.
442
485
  // ponytail: rewrites the whole domain on every removal; compact past a threshold if domains get big.
443
486
  if (dead.size || relinked.length) {
487
+ // A compaction rewrites `had.rows` as the whole domain, which append mode never loaded.
488
+ if (append) throw new Error(`${domain}: append mode reached a compaction; nothing written past the new shard`);
444
489
  for (const s of manifest.shards) await store.del(`${dir}/shards/${s.file}`);
445
490
  manifest.shards = [];
446
491
  for (const id of dead) had.rows.delete(id);
@@ -449,16 +494,19 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
449
494
  }
450
495
  manifest.tombstones = [...dead].sort();
451
496
  manifest.created_at = Math.floor(Date.now() / 1000);
452
- await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf);
497
+ await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? last : null);
453
498
  log(`${domain}: +${fresh.length} embedded, -${gone.length} removed, ${relinked.length} relinked, ${manifest.count} live`);
454
499
  return { added: fresh.length, removed: gone.length, ...(relinked.length ? { relinked: relinked.length } : {}) };
455
500
  }
456
501
 
457
502
  /** Counts, types, roles, description, presentation, coverage and the hosts its media role
458
- * links to (what a Worker may show inline, see site/worker.js) from what the domain holds. */
459
- async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new Map()) {
460
- const liveRows = [...rows.values()].filter((r) => !dead.has(r.track_id));
461
- const types = {};
503
+ * links to (what a Worker may show inline, see site/worker.js) from what the domain holds.
504
+ * `added`: append mode, where `rows` is not the whole domain: fold these new rows into the
505
+ * manifest's totals instead of recounting. Coverage is left as fitted (publishDomain appends only
506
+ * while no refit is due). ponytail: hosts only grow on append; a removal recounts them. */
507
+ async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new Map(), added = null) {
508
+ const liveRows = added ?? [...rows.values()].filter((r) => !dead.has(r.track_id));
509
+ const types = added ? Object.fromEntries((manifest.entity_types ?? []).map((e) => [e.type, e.count])) : {};
462
510
  for (const r of liveRows) types[r.fields?.entityType] = (types[r.fields?.entityType] ?? 0) + 1;
463
511
  const tags = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t]) => t);
464
512
  // Compare with what is stored, not with the manifest as it arrived: publishDomain has already
@@ -466,20 +514,20 @@ async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new
466
514
  // the old manifest naming deleted shards.
467
515
  const before = JSON.stringify(await readJson(store, `${dir}/manifest`, null));
468
516
 
469
- manifest.count = liveRows.length;
517
+ manifest.count = added ? (manifest.count ?? 0) + added.length : liveRows.length;
470
518
  manifest.entity_types = tags.map((t) => ({ type: t, count: types[t] }));
471
519
  const spec = domainSpec(schema, tags);
472
- const fields = [...new Set(liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {})))].sort();
520
+ const fields = [...new Set([...(added ? manifest.role_map?.fields ?? [] : []), ...liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {}))])].sort();
473
521
  manifest.role_map = { ...(spec?.role_map ?? manifest.role_map ?? guessed.values().next().value ?? completeRoles()), fields };
474
522
  if (spec?.description) manifest.description = spec.description;
475
523
  if (spec && Object.keys(spec.presentation).length) manifest.presentation = spec.presentation;
476
524
  const media = [manifest.role_map.media ?? []].flat();
477
- manifest.link_hosts = [...new Set(liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
525
+ manifest.link_hosts = [...new Set([...(added ? manifest.link_hosts ?? [] : []), ...liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
478
526
  .filter((u) => typeof u === "string" && u.startsWith("https://"))
479
- .map((u) => { try { return new URL(u).hostname; } catch { return null; } }).filter(Boolean))].sort();
527
+ .map((u) => { try { return new URL(u).hostname; } catch { return null; } })].filter(Boolean))].sort();
480
528
 
481
529
  const cov = manifest.coverage;
482
- if (liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
530
+ if (!added && liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
483
531
  manifest.coverage = { ...fitCoverage(liveRows.map((r) => unpackVec(r.v))), fit_at: liveRows.length };
484
532
  }
485
533
  if (JSON.stringify(manifest) !== before) await writeJson(store, `${dir}/manifest`, manifest);
@@ -617,6 +665,49 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
617
665
  eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
618
666
  { added: 48, removed: 0 }, "the next run embeds only the rest");
619
667
  eq(manifestOf(big, bd).count, 80, "and serves all of it");
668
+ // Killed partway (a Worker over its CPU or wall time: no exception, no return): the last
669
+ // checkpoint is served, and the next run embeds only what came after it.
670
+ chain[BIG] = { records: Array.from({ length: CHECKPOINT + 100 }, (_, i) => ({ id: `k${i}`, cid: `kill${i}`, type: "shop.item.v1", payload: { sku: `k${i}`, name: `Kept ${i}` } })) };
671
+ let left = CHECKPOINT + 64;
672
+ const dies = (ts) => (left -= ts.length) < 0 ? new Promise(() => {}) : Promise.all(ts.map(embed));
673
+ publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
674
+ for (let i = 0; i < 200 && manifestOf(big, bd).count < CHECKPOINT; i++) await new Promise((ok) => setTimeout(ok, 10));
675
+ eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
676
+ { added: 100, removed: 80 }, "a killed run resumes from its last checkpoint");
677
+ // A built domain whose head moves reads only the diff: 10 new tracks on top of thousands
678
+ // never read the thousands again, and a removal or an edit drops the version it replaced.
679
+ const whole = fangorn.reads;
680
+ chain[BIG].records.push(...Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, cid: `next${i}`, type: "shop.item.v1", payload: { sku: `n${i}`, name: `New ${i}` } })));
681
+ chain[BIG].records.splice(0, 1, { ...chain[BIG].records[0], cid: "kill0-v2", payload: { sku: "k0", name: "Kept 0, renamed" } });
682
+ chain[BIG].records.splice(1, 1);
683
+ eq([(await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd], fangorn.reads - whole],
684
+ [{ added: 11, removed: 2 }, 0], "a moved head reads only what changed");
685
+ eq(manifestOf(big, bd).count, CHECKPOINT + 100 + 10 - 1, "and serves the repo as it now stands");
686
+ // Only additions: the rows already served are not read at all, only a delta shard is written.
687
+ const disk = fsStore(big), opened = [];
688
+ const watched = { ...disk, get: (p) => { opened.push(p); return disk.get(p); } };
689
+ const n0 = manifestOf(big, bd).count, shards0 = manifestOf(big, bd).shards.length;
690
+ chain[BIG].records.push(...Array.from({ length: 10 }, (_, i) => ({ id: `m${i}`, cid: `more${i}`, type: "shop.item.v1", payload: { sku: `m${i}`, name: `More ${i}` } })));
691
+ eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
692
+ { added: 10, removed: 0 }, "an append embeds only the new records");
693
+ eq(opened.filter((p) => p.includes("/shards/")), [], "and reads no shard it already served");
694
+ const after = manifestOf(big, bd);
695
+ eq([after.count, after.shards.length, after.entity_types[0].count, after.ahead], [n0 + 10, shards0 + 1, n0 + 10, undefined], "counts, types and one delta shard, with the head taken");
696
+ eq((await liveFields(big)).length, n0 + 10, "and every row is still served");
697
+ // An append killed after a checkpoint: the next run reads only that checkpoint's shard, and
698
+ // embeds only what came after it, never the checkpointed rows a second time.
699
+ const n1 = manifestOf(big, bd).count, fit = manifestOf(big, bd).coverage.fit_at, extra = CHECKPOINT + 2;
700
+ if (n1 + extra >= COVERAGE_REFIT * fit) throw new Error(`test setup: ${n1} + ${extra} rows would refit coverage (fit at ${fit}), not append`);
701
+ chain[BIG].records.push(...Array.from({ length: extra }, (_, i) => ({ id: `a${i}`, cid: `app${i}`, type: "shop.item.v1", payload: { sku: `a${i}`, name: `Appended ${i}` } })));
702
+ left = CHECKPOINT + 1;
703
+ publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
704
+ for (let i = 0; i < 300 && !manifestOf(big, bd).ahead; i++) await new Promise((ok) => setTimeout(ok, 10));
705
+ eq([manifestOf(big, bd).count, manifestOf(big, bd).ahead?.length], [n1 + CHECKPOINT, 1], "a killed append serves its checkpoint, named as ahead of the head");
706
+ opened.length = 0;
707
+ eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
708
+ { added: 2, removed: 0 }, "and the next run embeds only what came after it");
709
+ eq([opened.filter((p) => p.includes("/shards/")).length, manifestOf(big, bd).count, (await liveFields(big)).length],
710
+ [1, n1 + extra, n1 + extra], "reading only the checkpoint's shard, and serving each row once");
620
711
  delete chain[BIG];
621
712
  rmSync(big, { recursive: true, force: true });
622
713
  eq((await publishView({ fangorn, app: APP, schema, out, embed, rebake: true, log: () => {} }))[d],
@@ -6,7 +6,7 @@
6
6
  "deploy": "wrangler deploy"
7
7
  },
8
8
  "dependencies": {
9
- "@fangorn-network/westmarch": "2026.10.27.1",
9
+ "@fangorn-network/westmarch": "2026.10.27.2",
10
10
  "@huggingface/transformers": "^4.3.1",
11
11
  "wrangler": "^4.83.0"
12
12
  }