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:
Sylvain Duchesne
2026-07-04 09:33:42 +02:00
parent 7db5eef33f
commit 9951cd5223
5 changed files with 461 additions and 8 deletions
+233
View File
@@ -0,0 +1,233 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
import type { IndexEntry } from "../src/discovery";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
setCurrentUser,
} from "../src/polyfill";
import { resetRegistryCache } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
// discovery.ts submits to / reads from a global index owned by a RESERVED
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
// a single in-memory quad store. Restore un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
});
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
resetRegistryCache();
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
interface Quad { g: string; s: string; p: string; o: string }
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
if (!gm) return undefined;
const g = gm[1]!;
const body = gm[2]!;
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
// pick by which body we're in (deposit vs account) — harmless if wrong,
// the SELECT filters by the real predicates below.
const isDeposit = query.includes(`${INBOX}:Deposit`);
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Shim account SELECT.
if (query.includes(`<${SHIM}:username>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:username`) rec.username = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.username)
.map((r) => ({
username: { value: r.username! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT (?payload ?ts ?from).
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// Entity-index SELECT (shim contains) — unused here.
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
// ensureAccount('@index') created its 3 scope docs.
expect(fake.doc_create).toHaveBeenCalledTimes(3);
// The deposit landed in the @index public document (its inbox).
const depositCall = fake.sparql_update.mock.calls.find((c) =>
(c[1] as string).includes(`${INBOX}:Deposit`),
)!;
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
});
test("submit → read round-trips the reference as an index entry", async () => {
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
await submitToIndex(ref, { from: "alice", ts: 100 });
const entries = await readIndex();
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
});
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
// A submits (identified). No connection is ever declared. A separate reader
// materializes the SAME index (same special account → same document) and sees
// the reference — discovery is via the index, not any direct fan-out/link.
setCurrentUser("alice");
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
await submitToIndex(ref, { ts: 100 });
// Reader B: a fresh cache, never connected to A, reads the index.
resetRegistryCache();
setCurrentUser("bob");
const entries = await readIndex();
const refs = entries.map((e) => e.ref);
expect(refs).toContainEqual(ref);
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
});
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
await submitToIndex(ref, { from: "alice", ts: 100 });
await submitToIndex(ref, { from: "bob", ts: 200 }); // duplicate reference
const entries = await readIndex();
expect(entries).toHaveLength(1); // surfaced once
});
test("from: null makes an anonymous submission", async () => {
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
const entries = await readIndex();
expect(entries[0]!.from).toBeNull();
});
test("INDEX_ACCOUNT is the reserved special account", () => {
expect(INDEX_ACCOUNT).toBe("@index");
});
test("watchIndex fires immediately then when a submission arrives", async () => {
const seen: IndexEntry[][] = [];
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
stop();
const countAfterStop = seen.length;
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop);
});