feat(client): discovery via a global index (special @index account)
Add a generic discovery-index surface: submitToIndex(ref) deposits a reference into the index document's inbox; readIndex() returns the materialized entries. A reserved special account (@index) owns the index document; deposits flow through the emulated inbox and are materialized by the emulated curator (the dedup/ moderation point). This replaces cross-account fan-out as the discovery path and is more faithful to the target (a single owned index fed via its inbox). Generic (the consumer supplies the reference to index). 79 tests pass; tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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 ≠ discovery. A public entity is world-readable *with its NURI*; the
|
||||
* discovery index is how a client learns that NURI EXISTS without holding a
|
||||
* connection to its creator. There is ONE global index — an OWNED document
|
||||
* (public read), fed via ITS OWN inbox, and MATERIALIZED by a curator. Nobody
|
||||
* writes the index directly: a creator DEPOSITS a reference into the index's
|
||||
* inbox; the (emulated) curator ingests deposits into entries. Materialization
|
||||
* is the natural dedup / moderation point.
|
||||
*
|
||||
* ── The special account (polyfill owner) ──────────────────────────────────
|
||||
* "Who owns the global index" is undecided in the target (NextGraph is
|
||||
* mono-user with no global data — see docs/nextgraph-current-state.md § Apps &
|
||||
* services; a singleton app is the only glimpsed path). So the polyfill 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 → same document). This replaces the cross-account
|
||||
* fan-out (`store-registry.ts` `listEntityDocs`) as the app-facing discovery
|
||||
* path — the fan-out survives only as an internal fallback (see {@link readIndex}).
|
||||
*
|
||||
* ── TARGET vs POLYFILL ────────────────────────────────────────────────────
|
||||
* Target: `submitToIndex` seals a reference into the index's native inbox
|
||||
* (`inbox_post_link`) and a SEPARATE curator package materializes deposits into
|
||||
* the owned index document; `readIndex` is a query on the materialized index.
|
||||
* Here, everything is emulated in-lib on the shared wallet (deposit via
|
||||
* `inbox.post`, materialize via `inbox.read`). At migration the special account
|
||||
* disappears; ownership moves to the decided global-index owner and this module
|
||||
* points `readIndex` at the real materialized document. The consumer surface
|
||||
* (`submitToIndex` / `readIndex`) is designed to survive that swap unchanged.
|
||||
*
|
||||
* All NextGraph I/O routes through `inbox.ts` (which routes through the T01.a
|
||||
* `docs` primitives, the REAL injected `ng`), so this module imports NO
|
||||
* `@ng-org` package.
|
||||
*/
|
||||
|
||||
import * as inbox from "./inbox";
|
||||
import { ensureAccount } from "./store-registry";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
|
||||
/**
|
||||
* The reserved SPECIAL ACCOUNT that owns the global discovery index in the
|
||||
* polyfill. A normal shim account (username), so its scope documents are created
|
||||
* on first sight like any other account — but it is never a real user; it only
|
||||
* hosts the index document. Disappears at migration (see file header).
|
||||
*/
|
||||
export const INDEX_ACCOUNT = "@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 (or pass `null`) for an ANONYMOUS submission; pass a
|
||||
* principal id to identify the submitter. Defaults to the current polyfill user
|
||||
* when the property is entirely absent (mirrors {@link inbox.post}).
|
||||
*/
|
||||
from?: PrincipalId | null;
|
||||
/** 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}; the curator ({@link readIndex}) materializes it into an
|
||||
* entry. GENERIC: `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 if `null`).
|
||||
*/
|
||||
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
|
||||
const target = await indexInboxNuri();
|
||||
await inbox.post(target, {
|
||||
payload: ref,
|
||||
...(opts && "from" in opts ? { from: opts.from } : {}),
|
||||
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read (materialize) the global discovery index — the EMULATED CURATOR. Reads
|
||||
* every submission from the index inbox, DEDUPLICATES by serialized `ref` (the
|
||||
* materialization dedup / moderation point of the discovery model: a duplicate
|
||||
* submission surfaces once), and returns the entries sorted by `ts` ascending.
|
||||
* At migration this becomes a query on the real 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 — the emulated curator's watcher. Polls
|
||||
* {@link readIndex} and fires `onEntries` whenever the index changes. Returns an
|
||||
* unsubscribe. Fires once immediately. (Deduplication is applied on each read.)
|
||||
*/
|
||||
export function watchIndex(
|
||||
onEntries: (entries: IndexEntry[]) => void,
|
||||
opts?: { intervalMs?: number },
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let lastCount = -1;
|
||||
const intervalMs = opts?.intervalMs ?? 1000;
|
||||
const tick = 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);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const handle = setInterval(() => void tick(), intervalMs);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export * from "./types";
|
||||
export { useShape } from "./use-shape";
|
||||
export { init, initNg } from "./lifecycle";
|
||||
export * as inbox from "./inbox";
|
||||
export * as discovery from "./discovery";
|
||||
export type { IndexEntry, SubmitOptions } from "./discovery";
|
||||
export * as docs from "./docs";
|
||||
export * as storeRegistry from "./store-registry";
|
||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
||||
|
||||
Reference in New Issue
Block a user