/** * Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting- * point registration AND submission to the discovery index (see the discovery- * model decision: same `inbox.post` API, same watcher). GENERIC by construction: * this module knows no application domain (no meeting-point, no notification). * The consumer supplies the inbox document NURI and interprets the `payload`. * * ── TARGET vs POLYFILL ──────────────────────────────────────────────────── * Target: `post` seals a reference into the inbox owner's native inbox * (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred * `@ng-eventually/service` package) MATERIALIZES the deposits into the owned * document. Here, in the shared-wallet polyfill (everything is readable), * both sides are emulated in-lib: * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox * DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive; * - `read` / `watch` play the CURATOR: they read the deposits back via * `docs.sparqlQuery` and expose them. This in-client emulation is enough * for the polyfill — at migration the real materialization moves to the * separate curator and this read side goes away. * * All NextGraph I/O routes through the T01.a `docs` primitives (the REAL * injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package. */ import { sparqlUpdate, sparqlQuery } from "./docs"; import { getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { escapeLiteral } from "./sparql"; import type { Nuri, PrincipalId } from "./types"; // --- deposit model -------------------------------------------------------- /** One deposit as materialized from an inbox document. */ export interface Deposit { /** The sender, if identified; `null` when the deposit was anonymous. */ from: PrincipalId | null; /** The consumer-defined payload (opaque here — JSON-serialized in storage). */ payload: unknown; /** Deposit timestamp (ms epoch). Caller may pass one for determinism. */ ts: number; } /** Options for {@link post}. `from` and `ts` are both optional. */ export interface PostOptions { /** * Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a * principal id to identify the sender. Defaults to the current polyfill user * ({@link getCurrentUser}) when the property is entirely absent, so callers * that want anonymity must pass `from: null` explicitly. */ from?: PrincipalId | null; /** The payload to deposit (interpreted only by the consumer). */ payload: unknown; /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it * keeps tests deterministic. */ ts?: number; } const SHIM = "urn:ng-eventually:inbox"; const P = { type: `${SHIM}:Deposit`, from: `${SHIM}:from`, payload: `${SHIM}:payload`, ts: `${SHIM}:ts`, } as const; // --- session access (shared with the storeRegistry) ----------------------- /** The inbox documents live in the shared wallet, so we reuse the registry's * injected session provider for the sessionId. Disappears at migration. */ async function sessionId(): Promise { return (await getStoreRegistryDeps().getSession()).sessionId; } // --- SPARQL result helpers ------------------------------------------------ /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ function readBindings(result: unknown): Array> { if (!result) return []; if (Array.isArray(result)) return result as Array>; const anyRes = result as { results?: { bindings?: Array> }; }; return anyRes.results?.bindings ?? []; } // --- deposit (client side) ------------------------------------------------ /** * Deposit a payload into `targetInbox`. * * Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate` * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox * graph, so concurrent deposits don't collide. * * `from` is BOUND TO THE CURRENT IDENTITY — it is authenticated, not * caller-supplied. Omit it to stamp the current user; pass `null` to deposit * ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise"). * A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the * broker seals the sender from the wallet's own key, so a client cannot forge * another's identity. (At migration this check is redundant — the seal enforces * it — but until then it closes the spoof the shared wallet would otherwise allow.) */ export async function post(targetInbox: Nuri, opts: PostOptions): Promise { const current = getCurrentUser(); let from: PrincipalId | null; if (opts.from === undefined) { from = current; // default: stamp the current identity } else if (opts.from === null) { from = null; // explicit anonymous deposit } else if (opts.from === current) { from = opts.from; // identifying as self — allowed } else { throw new Error( "[ng-eventually] inbox.post: `from` must be the current identity or null " + "(anonymous) — depositing as another principal is a spoof.", ); } const ts = opts.ts ?? Date.now(); const sid = await sessionId(); // A unique subject per deposit (in the inbox graph) — no collisions. const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`; const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null)); const fromTriple = from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`; const update = ` INSERT DATA { GRAPH <${targetInbox}> { <${subject}> a <${P.type}> ; <${P.payload}> "${payloadLiteral}" ; <${P.ts}> "${ts}"${fromTriple} . } }`; await sparqlUpdate(sid, update, targetInbox); } // --- read / materialize (emulated curator) -------------------------------- /** * Read (materialize) every deposit currently in `targetInbox`, sorted by `ts` * ascending. This is the EMULATED CURATOR: at migration a separate curator * process materializes deposits and this in-client read goes away. The consumer * interprets each deposit's `payload`. */ export async function read(targetInbox: Nuri): Promise { const sid = await sessionId(); const query = ` SELECT ?payload ?ts ?from WHERE { GRAPH <${targetInbox}> { ?d a <${P.type}> ; <${P.payload}> ?payload ; <${P.ts}> ?ts . OPTIONAL { ?d <${P.from}> ?from } } }`; const result = await sparqlQuery(sid, query, undefined, targetInbox); const deposits: Deposit[] = []; for (const row of readBindings(result)) { const rawPayload = row.payload?.value ?? "null"; let payload: unknown; try { payload = JSON.parse(rawPayload); } catch { payload = rawPayload; // tolerate a non-JSON literal } const tsRaw = row.ts?.value ?? "0"; const ts = Number.parseInt(tsRaw, 10) || 0; const fromValue = row.from?.value; deposits.push({ from: fromValue ? fromValue : null, payload, ts }); } deposits.sort((a, b) => a.ts - b.ts); return deposits; } /** Alias for {@link read} — the name that reads as "run the curator now". */ export const materialize = read; /** * Subscription over an inbox — the emulated watcher. Polls {@link read} on an * interval and invokes `onDeposits` with the full current deposit list whenever * it changes (grows). Returns an unsubscribe function. The polyfill has no * native reactive inbox subscription, so this emulates one; at migration it is * replaced by the real curator's watch. `onDeposits` fires once immediately. */ export function watch( targetInbox: Nuri, onDeposits: (deposits: Deposit[]) => void, opts?: { intervalMs?: number }, ): () => void { let stopped = false; let lastCount = -1; const intervalMs = opts?.intervalMs ?? 1000; const tick = async (): Promise => { if (stopped) return; try { const deposits = await read(targetInbox); if (!stopped && deposits.length !== lastCount) { lastCount = deposits.length; onDeposits(deposits); } } catch (error) { console.error("[inbox] watch read failed:", error); } }; void tick(); // fire once immediately const handle = setInterval(() => void tick(), intervalMs); return () => { stopped = true; clearInterval(handle); }; }