/** * 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 { // 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; } /** * The NURI of the global discovery-index document (the inbox where submissions * land). Exposed so a reactive reader ({@link watchShape}) that folds discovery * into the public read-set can SUBSCRIBE to this document and re-resolve when a * new public entity is announced. This is exactly {@link watchIndex}'s subscribe * anchor. Removed against real NextGraph along with the special account. */ export async function indexDocNuri(): Promise { return indexInboxNuri(); } /** * 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 { 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 { const target = await indexInboxNuri(); const deposits = await inbox.read(target); const seen = new Set(); 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 => { 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; } }; }