feat(client): per-document reactive subscription (doc_subscribe), drop polling

Expose subscribeDoc(nuri, onChange) / subscribeDocs(nuris, onChange) wrapping the
real ng.doc_subscribe — per-document, event-driven (initial State + a Patch per
commit, local or broker-synced from a remote peer), returning a sync unsubscribe.
subscribeDocs isolates per doc (a failing/unsynced doc never aborts the others),
so it sidesteps the ORM fan-out hang (never orm_start_graph(graphs:[…])).

Replace the setInterval polling in inbox.watch() and discovery.watchIndex() with
doc_subscribe — same public contract, now push not poll. NextGraph is subscription-
first; no polling remains.

Verified against the real broker (@data harness spike): the doc_subscribe callback
marshals across the iframe RPC (@ng-org/web strips the callback and drives it via a
MessagePort — no DataCloneError) and fires on the initial push and on a real write.
Lib bun test 91 pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 23:04:32 +02:00
parent 7ebb03a3f3
commit c0498a6ebc
7 changed files with 396 additions and 22 deletions
+32 -9
View File
@@ -38,6 +38,7 @@
*/
import * as inbox from "./inbox";
import { subscribeDoc } from "./subscribe";
import { ensureAccount, reservedAccount } from "./store-registry";
import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types";
@@ -156,18 +157,25 @@ export async function readIndex(): Promise<IndexEntry[]> {
}
/**
* Watch the discovery index. Polls {@link readIndex} and fires `onEntries`
* whenever the index changes. Returns an unsubscribe. Fires once immediately.
* (Deduplication is applied on each read.)
* Watch the discovery index — **event-driven, not polled**. Subscribes to the
* index document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onEntries` fires once on the initial state push and again on every subsequent
* change to the index document — a local submission OR a broker-synced remote one.
* Returns an unsubscribe. (Deduplication is applied on each read.)
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. The index is a single document, so this is immune to the
* ORM fan-out hang (see {@link subscribeDoc}).
*/
export function watchIndex(
onEntries: (entries: IndexEntry[]) => void,
opts?: { intervalMs?: number },
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
const intervalMs = opts?.intervalMs ?? 1000;
const tick = async (): Promise<void> => {
let unsubscribe: (() => void) | null = null;
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const entries = await readIndex();
@@ -179,10 +187,25 @@ export function watchIndex(
console.error("[discovery] watchIndex read failed:", error);
}
};
void tick();
const handle = setInterval(() => void tick(), intervalMs);
// The index document NURI is resolved async (ensureAccount); subscribe once it
// is known. The initial State push fires the first read (onEntries fires once),
// each later Patch a re-read.
void (async () => {
try {
const anchor = await indexInboxNuri();
if (stopped) return;
unsubscribe = subscribeDoc(anchor, () => void refresh());
} catch (error) {
console.error("[discovery] watchIndex subscribe failed:", error);
}
})();
return () => {
stopped = true;
clearInterval(handle);
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
};
}