c0498a6ebc
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>
212 lines
9.4 KiB
TypeScript
212 lines
9.4 KiB
TypeScript
/**
|
|
* discovery — a GENERIC discovery-index surface, reusing the ONE deposit +
|
|
* materialization mechanism (`inbox.ts`). GENERIC by construction: this module
|
|
* knows no application domain (no event, no meeting-point). The consumer submits
|
|
* an opaque reference and interprets the entries it reads back.
|
|
*
|
|
* ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
|
|
* Access and discovery are separate concerns. A public entity is world-readable
|
|
* with its NURI; the discovery index is how a client learns that NURI exists
|
|
* without holding a grant to read its creator's other documents. There is one
|
|
* global index — an owned document (public read), fed via its own inbox. A
|
|
* creator deposits a reference into the index's inbox; reading the index folds
|
|
* those deposits into entries, deduplicating identical references along the way.
|
|
*
|
|
* ── The special account (polyfill owner) ──────────────────────────────────
|
|
* Ownership of a truly global index is undecided in the real platform, where an
|
|
* identity's apps and services see only what that identity shares. The polyfill
|
|
* therefore parks ownership on a reserved special account in the shim
|
|
* ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
|
|
* deposits land in that document's inbox (a stable NURI: every client opening the
|
|
* same shared wallet resolves the same account, so the same document). This is
|
|
* the app-facing discovery path, in place of a cross-account fan-out
|
|
* (`store-registry.ts` `listEntityDocs`), which survives only as an internal
|
|
* fallback (see {@link readIndex}).
|
|
*
|
|
* ── Real target vs this emulation ─────────────────────────────────────────
|
|
* The intended real shape is: `submitToIndex` seals a reference into the index
|
|
* document's own inbox (a future `inbox_post_link`), and reading the index is a
|
|
* query on the materialized index document. Here, everything runs in-lib on the
|
|
* shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
|
|
* NextGraph the special account gives way to the decided global-index owner and
|
|
* `readIndex` points at that document; the consumer surface (`submitToIndex` /
|
|
* `readIndex`) is designed to survive that change unchanged.
|
|
*
|
|
* All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
|
|
* primitives, the real injected `ng`), so this module imports no `@ng-org`
|
|
* package.
|
|
*/
|
|
|
|
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";
|
|
|
|
/**
|
|
* The reserved special account that owns the global discovery index in the
|
|
* polyfill. It hosts the index document but is never a real identity. It lives in
|
|
* the registry's reserved namespace ({@link reservedAccount}), whose key
|
|
* `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
|
|
* (it normalizes to "index", a disjoint key). Removed against real NextGraph
|
|
* (see file header).
|
|
*/
|
|
export const INDEX_ACCOUNT = reservedAccount("index");
|
|
|
|
/** One entry as materialized from the discovery index. */
|
|
export interface IndexEntry {
|
|
/** The reference submitted by a creator (opaque — the consumer interprets it). */
|
|
ref: unknown;
|
|
/** The submitter, if identified; `null` when the submission was anonymous. */
|
|
from: PrincipalId | null;
|
|
/** Submission timestamp (ms epoch). */
|
|
ts: number;
|
|
}
|
|
|
|
/** Options for {@link submitToIndex}. */
|
|
export interface SubmitOptions {
|
|
/**
|
|
* Who is submitting. Omit for the current identity, or pass `null` for an
|
|
* anonymous submission. `from` is bound to the current identity by the inbox
|
|
* (naming another identity is rejected as a spoof — see {@link inbox.post}).
|
|
*/
|
|
from?: PrincipalId | null;
|
|
/**
|
|
* The NURI of the document being made discoverable. When given, the index
|
|
* admits only a public document: one under a non-public (protected/private)
|
|
* read policy is refused, so the world-readable index never exposes a governed
|
|
* document's NURI. Omit it only for a ref with no addressable document (rare);
|
|
* a governed document passes it so the guard can fire.
|
|
*/
|
|
doc?: Nuri;
|
|
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
|
* keeps tests deterministic. */
|
|
ts?: number;
|
|
}
|
|
|
|
/**
|
|
* Resolve the NURI of the index document — the stable inbox where discovery
|
|
* submissions land. The special account owns this document (its `public` scope
|
|
* document, a real repo NURI from `docCreate`); deposits go into that document's
|
|
* inbox exactly as host-registration deposits go into a host inbox. Because the
|
|
* special account lives in the shim (persisted in the shared wallet's private
|
|
* store), EVERY client opening the same wallet resolves the same account → the
|
|
* same document NURI → ONE shared index for all clients. Distinct from
|
|
* host-registration inboxes because it is a distinct document NURI.
|
|
*/
|
|
async function indexInboxNuri(): Promise<Nuri> {
|
|
// Ensure the special account exists (idempotent) so its scope documents are
|
|
// created and stably resolvable across clients.
|
|
const record = await ensureAccount(INDEX_ACCOUNT);
|
|
return record.docPublic;
|
|
}
|
|
|
|
/**
|
|
* Submit a reference to the global discovery index — the SDK act "make this
|
|
* discoverable". Deposits `ref` into the index document's inbox via
|
|
* {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
|
|
* entry. `ref` is opaque here (the consumer serializes whatever a client needs to
|
|
* later locate the entity — e.g. an entity document NURI plus discovery metadata).
|
|
* `from` follows the inbox convention (anonymous when `null`).
|
|
*
|
|
* When `opts.doc` names the document being surfaced, a document under a
|
|
* non-public read policy (protected/private) is refused: the global index is
|
|
* world-readable, so admitting a governed document's NURI would expose it past
|
|
* its scope.
|
|
*/
|
|
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
|
|
const doc = opts?.doc;
|
|
if (doc !== undefined) {
|
|
const caps = getCaps();
|
|
// A governed doc is submittable ONLY if it is public (anonymous may read it).
|
|
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
|
|
throw new Error(
|
|
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
|
|
"the discovery index — a protected/private document must not be surfaced.",
|
|
);
|
|
}
|
|
}
|
|
const target = await indexInboxNuri();
|
|
await inbox.post(target, {
|
|
payload: ref,
|
|
...(opts && "from" in opts ? { from: opts.from } : {}),
|
|
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read the global discovery index. Reads every submission from the index inbox,
|
|
* deduplicates by serialized `ref` (a duplicate submission surfaces once — the
|
|
* discovery model's moderation point), and returns the entries sorted by `ts`
|
|
* ascending. Against real NextGraph this becomes a query on the materialized
|
|
* index document.
|
|
*/
|
|
export async function readIndex(): Promise<IndexEntry[]> {
|
|
const target = await indexInboxNuri();
|
|
const deposits = await inbox.read(target);
|
|
const seen = new Set<string>();
|
|
const entries: IndexEntry[] = [];
|
|
for (const d of deposits) {
|
|
// Dedup on the serialized reference — the materialization moderation point.
|
|
const key = JSON.stringify(d.payload ?? null);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
entries.push({ ref: d.payload, from: d.from, ts: d.ts });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
): () => void {
|
|
let stopped = false;
|
|
let lastCount = -1;
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
const refresh = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
try {
|
|
const entries = await readIndex();
|
|
if (!stopped && entries.length !== lastCount) {
|
|
lastCount = entries.length;
|
|
onEntries(entries);
|
|
}
|
|
} catch (error) {
|
|
console.error("[discovery] watchIndex read failed:", error);
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
unsubscribe = null;
|
|
}
|
|
};
|
|
}
|