Files
ng-eventually/packages/client/src/discovery.ts
T
Sylvain Duchesne 7c233df5c0 feat(client): watchShape — lecture réactive à la forme TanStack useQuery
Phase A du refactor des lectures. Surface la barrière de sync interne
(open-repo `getSyncState`) dans une API useQuery-shaped, en anticipation de la
mise à jour prévue de useShape par NextGraph — distingue nativement « sync en
cours » de « synchronisé mais vide ».

`watchShape<T>(shapeType, scope): { getSnapshot(): ShapeQuery<T>, subscribe(cb),
refetch() }` avec `ShapeQuery = { data, isPending, isSuccess, isError, error }`.
OBSERVABLE (pas de dépendance React — l'app câblera useSyncExternalStore en
phase B) ; getSnapshot rend une référence stable.

- Scope LOGIQUE (public/protected/private) résolu au wallet virtuel :
  listMyEntityDocs(getCurrentUser, scope) + découverte foldée pour public.
- isPending tant que la barrière n'est pas atteinte / 1er readUnion non rendu ;
  isSuccess après ; timed-out → isSuccess (best-effort, pas isError).
- Réactif SANS polling : subscribeDoc sur les docs + le doc d'index de scope
  (+ index découverte) → re-read/re-résolution au push ; souscriptions idempotentes.
- Générique : aucune logique domaine Festipod dans le lib ; filtre par la shape
  SHEX (rdf:type). Pas de double filtre cap.

gate : tsc 0 ; bun test 120 (+4 : pending→success, synced-vide, réactif, timed-out) ;
test:e2e 42 passed (+scénario broker réel : isPending au 1er snapshot → isSuccess
avec données, scope vide → isSuccess data:[]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:17:18 +02:00

223 lines
9.8 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;
}
/**
* 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<Nuri> {
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<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;
}
};
}