7c233df5c0
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>
524 lines
22 KiB
TypeScript
524 lines
22 KiB
TypeScript
/**
|
||
* storeRegistry — resolves (account, scope) → document NURI.
|
||
*
|
||
* Stopgap / polyfill-era. Emulates the target infrastructure — where each
|
||
* user owns their own public/protected/private stores — on top of one shared
|
||
* wallet. It creates one document per (account × scope) inside that shared
|
||
* wallet (via the `docs.docCreate` primitive), so the `scope`
|
||
* (`public|protected|private`) is a logical attribute tracked here, not a
|
||
* physical NextGraph store. Isolation is enforced by the app layer + the
|
||
* emulated cap registry, not by crypto.
|
||
*
|
||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**,
|
||
* persisted as RDF in the shared wallet's private store (the anchor, always
|
||
* known from the session). That makes login cross-device: another device
|
||
* opening the same wallet reads the same shim and finds the same accounts.
|
||
*
|
||
* ── Generic by construction ──────────────────────────────────────────────
|
||
* This module knows only the three native scopes; it knows no application
|
||
* entity kind. The consumer maps its entities to a scope and calls
|
||
* `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native
|
||
* scope. No application domain here.
|
||
*
|
||
* ── What disappears at migration ─────────────────────────────────────────
|
||
* At the real multi-store migration the shim vanishes entirely: `(account,
|
||
* scope)` maps to the user's REAL store NURI instead of a document in the
|
||
* shared wallet, `docCreate` targets the real per-user store, and the
|
||
* per-scope index document (the store-container emulation) is replaced by the
|
||
* store itself. The consumer-facing surface (`createEntityDoc`,
|
||
* `listEntityDocs`, resolvers) is designed to survive that swap unchanged.
|
||
*
|
||
* All NextGraph I/O routes through the T01.a `docs` primitive (real injected
|
||
* `ng`), so this module imports **no** `@ng-org` package.
|
||
*/
|
||
|
||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||
import { getStoreRegistryDeps } from "./polyfill";
|
||
import { ensureRepoOpen } from "./open-repo";
|
||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||
import type { Nuri, Scope } from "./types";
|
||
|
||
// --- sharedWalletShim model ----------------------------------------------
|
||
|
||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
||
export interface AccountRecord {
|
||
id: string;
|
||
docPublic: Nuri;
|
||
docProtected: Nuri;
|
||
docPrivate: Nuri;
|
||
}
|
||
|
||
const SHIM = "urn:ng-eventually:shim";
|
||
const P = {
|
||
type: `${SHIM}:Account`,
|
||
id: `${SHIM}:id`,
|
||
docPublic: `${SHIM}:docPublic`,
|
||
docProtected: `${SHIM}:docProtected`,
|
||
docPrivate: `${SHIM}:docPrivate`,
|
||
contains: `${SHIM}:contains`, // scope-index → entity document NURI
|
||
} as const;
|
||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||
// the role of the future store-container: it lists the NURIs of the entity
|
||
// documents (one per entity) that live "in" that scope.
|
||
const INDEX_SUBJECT = `${SHIM}:index`;
|
||
|
||
function accountSubject(id: string): string {
|
||
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
||
// root). accountKey() runs first so the subject stays stable per shim key.
|
||
return `${SHIM}:account:${escapeIri(accountKey(id))}`;
|
||
}
|
||
|
||
// --- reserved accounts -----------------------------------------------------
|
||
//
|
||
// Some accounts are internal to the lib (e.g. the discovery index owner) and
|
||
// must NOT collide with any user-chosen id. A reserved account is created
|
||
// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that
|
||
// `normalizeId` (consumer-injected) can never produce: it strips a leading
|
||
// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved
|
||
// keys therefore live in a disjoint namespace from every normalized id —
|
||
// a real user named "index"/"@index" can never resolve to the reserved
|
||
// `reservedAccount("index")` account.
|
||
const RESERVED_PREFIX = "\u0000reserved:";
|
||
|
||
/**
|
||
* Wrap an internal account name so it occupies a key that no user input can
|
||
* produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount}
|
||
* (and the other registry calls) instead of a bare id.
|
||
*/
|
||
export function reservedAccount(name: string): string {
|
||
return `${RESERVED_PREFIX}${name}`;
|
||
}
|
||
|
||
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
|
||
function isReserved(id: string): boolean {
|
||
return id.startsWith(RESERVED_PREFIX);
|
||
}
|
||
|
||
/**
|
||
* The shim/cache key for an account. Reserved accounts bypass `normalizeId`
|
||
* entirely and key on their sentinel-prefixed name, so they cannot collide with
|
||
* a normalized id; everyone else normalizes as usual.
|
||
*/
|
||
function accountKey(id: string): string {
|
||
return isReserved(id) ? id : normalize(id);
|
||
}
|
||
|
||
// --- session / normalization access (injected by the consumer) ------------
|
||
|
||
/** Minimal session shape the registry needs — provided by the consumer. */
|
||
export interface RegistrySession {
|
||
sessionId: string;
|
||
/** The shared wallet's private store id — the shim anchor. */
|
||
privateStoreId: string;
|
||
/** The shared wallet's protected store id (native store). Optional: only the
|
||
* scope resolvers need it; the shim only needs the private anchor. */
|
||
protectedStoreId?: string;
|
||
/** The shared wallet's public store id (native store). Optional. */
|
||
publicStoreId?: string;
|
||
}
|
||
|
||
function normalize(id: string): string {
|
||
return getStoreRegistryDeps().normalizeId(id);
|
||
}
|
||
|
||
async function session(): Promise<RegistrySession> {
|
||
return getStoreRegistryDeps().getSession();
|
||
}
|
||
|
||
/** The shim lives in the shared wallet's private store (always-known anchor). */
|
||
async function anchorNuri(): Promise<Nuri> {
|
||
const s = await session();
|
||
return `did:ng:${s.privateStoreId}`;
|
||
}
|
||
|
||
// --- cache ----------------------------------------------------------------
|
||
|
||
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
|
||
// only once loadShim() has read every account — used by the all-accounts paths.
|
||
let cache: Map<string, AccountRecord> | null = null;
|
||
|
||
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
|
||
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
|
||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||
const accountCache = new Map<string, AccountRecord>();
|
||
|
||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||
export function resetRegistryCache(): void {
|
||
cache = null;
|
||
accountCache.clear();
|
||
}
|
||
|
||
// --- SPARQL result helpers ------------------------------------------------
|
||
|
||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
||
if (!result) return [];
|
||
const anyRes = result as {
|
||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||
};
|
||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
||
if (anyRes.results?.bindings) return anyRes.results.bindings;
|
||
return [];
|
||
}
|
||
|
||
function bindingValue(row: Record<string, { value: string }>, key: string): string {
|
||
return row[key]?.value ?? "";
|
||
}
|
||
|
||
// --- shim load / account bootstrap ----------------------------------------
|
||
|
||
/** Load all accounts from the shim into the cache. */
|
||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||
if (cache) return cache;
|
||
const s = await session();
|
||
const anchor = await anchorNuri();
|
||
const query = `
|
||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||
GRAPH <${assertNuri(anchor)}> {
|
||
?acc a <${P.type}> ;
|
||
<${P.id}> ?id ;
|
||
<${P.docPublic}> ?docPublic ;
|
||
<${P.docProtected}> ?docProtected ;
|
||
<${P.docPrivate}> ?docPrivate .
|
||
}
|
||
}`;
|
||
const map = new Map<string, AccountRecord>();
|
||
try {
|
||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
|
||
for (const row of readBindings(result)) {
|
||
const id = bindingValue(row, "id");
|
||
if (!id) continue;
|
||
const key = accountKey(id);
|
||
const record: AccountRecord = {
|
||
id,
|
||
docPublic: bindingValue(row, "docPublic"),
|
||
docProtected: bindingValue(row, "docProtected"),
|
||
docPrivate: bindingValue(row, "docPrivate"),
|
||
};
|
||
map.set(key, record);
|
||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
||
accountCache.set(key, record);
|
||
}
|
||
} catch (error) {
|
||
console.error("[storeRegistry] loadShim failed:", error);
|
||
}
|
||
cache = map;
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||
* the account record at its known subject (`accountSubject(id)`) directly,
|
||
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
|
||
* record or `null` if it does not exist yet.
|
||
*
|
||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||
*/
|
||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
||
const key = accountKey(id);
|
||
const cached = accountCache.get(key);
|
||
if (cached) return cached;
|
||
|
||
const s = await session();
|
||
const anchor = await anchorNuri();
|
||
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
|
||
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
|
||
const subj = accountSubject(id);
|
||
const query = `
|
||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||
GRAPH <${assertNuri(anchor)}> {
|
||
<${subj}> a <${P.type}> ;
|
||
<${P.id}> ?id ;
|
||
<${P.docPublic}> ?docPublic ;
|
||
<${P.docProtected}> ?docProtected ;
|
||
<${P.docPrivate}> ?docPrivate .
|
||
}
|
||
}`;
|
||
try {
|
||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
|
||
const rows = readBindings(result);
|
||
if (rows.length === 0) return null;
|
||
const row = rows[0]!;
|
||
const record: AccountRecord = {
|
||
id: bindingValue(row, "id") || id,
|
||
docPublic: bindingValue(row, "docPublic"),
|
||
docProtected: bindingValue(row, "docProtected"),
|
||
docPrivate: bindingValue(row, "docPrivate"),
|
||
};
|
||
accountCache.set(key, record);
|
||
return record;
|
||
} catch (error) {
|
||
console.error("[storeRegistry] resolveAccount failed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** All known accounts (from the shim). */
|
||
export async function allAccounts(): Promise<AccountRecord[]> {
|
||
return [...(await loadShim()).values()];
|
||
}
|
||
|
||
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
||
async function createDoc(): Promise<Nuri> {
|
||
const s = await session();
|
||
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
|
||
// store_repo=undefined → shared wallet's private store.
|
||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
||
}
|
||
|
||
/**
|
||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||
* first sight. Idempotent — returns the existing record if already present.
|
||
*/
|
||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||
const key = accountKey(id);
|
||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||
// The private store is synchronised at login, so a simple resolveAccount read
|
||
// is authoritative: 0 rows = genuinely new → provision; rows present → reuse.
|
||
const existing = await resolveAccount(id);
|
||
if (existing) return existing;
|
||
|
||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||
createDoc(),
|
||
createDoc(),
|
||
createDoc(),
|
||
]);
|
||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||
|
||
const s = await session();
|
||
const anchor = await anchorNuri();
|
||
const subj = accountSubject(id);
|
||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
||
// literals here, so they are escaped as literals too (defence in depth).
|
||
const update = `
|
||
INSERT DATA {
|
||
GRAPH <${assertNuri(anchor)}> {
|
||
<${subj}> a <${P.type}> ;
|
||
<${P.id}> "${escapeLiteral(id)}" ;
|
||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
||
}
|
||
}`;
|
||
try {
|
||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
||
} catch (error) {
|
||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
||
}
|
||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||
// (so allAccounts / the fan-out see the freshly-created account too).
|
||
accountCache.set(key, record);
|
||
cache?.set(key, record);
|
||
return record;
|
||
}
|
||
|
||
// --- resolvers ------------------------------------------------------------
|
||
|
||
/** The index document NURI of an account for a scope (the store-container). */
|
||
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
|
||
return scope === "public"
|
||
? record.docPublic
|
||
: scope === "protected"
|
||
? record.docProtected
|
||
: record.docPrivate;
|
||
}
|
||
|
||
/**
|
||
* NURI of the document where `id` writes GROUPED entities of `scope` (a single
|
||
* per-scope index document, for entities that need no per-entity document / no
|
||
* inbox). For per-entity scopes use {@link createEntityDoc} instead.
|
||
*/
|
||
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
|
||
const record = await ensureAccount(id);
|
||
return indexDocOf(record, scope);
|
||
}
|
||
|
||
/** NURIs of every account's document for `scope` (read fan-out). */
|
||
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
|
||
const accounts = await allAccounts();
|
||
return accounts.map((a) => indexDocOf(a, scope));
|
||
}
|
||
|
||
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
|
||
//
|
||
// The consumer asks by SCOPE ("give me the graph to write/read entities of
|
||
// scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}`
|
||
// store NURI itself. The lib owns the physical placement — which is the whole
|
||
// point of the SDK boundary. In THIS polyfill the placement is the shared
|
||
// wallet's native stores (Axis A, per the two-axes doctrine in
|
||
// docs/simulation.md): a scope maps to a native store NURI resolved from the
|
||
// injected session. `public` currently co-locates with `protected` because
|
||
// `doc_create`/ORM cannot target a non-private/protected native store today
|
||
// (the SDK blocker recorded in migration-guide.md); at migration each scope
|
||
// resolves to the user's REAL per-scope store and this mapping changes here,
|
||
// in the lib, with no consumer change.
|
||
|
||
/** The native store NURI backing `scope`, resolved from the injected session.
|
||
* Requires `protectedStoreId` on the session for the non-private scopes. */
|
||
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
|
||
const s = await session();
|
||
if (scope === "private") return `did:ng:${s.privateStoreId}`;
|
||
// public + protected → the protected native store (see note above). Falls
|
||
// back to the private store if the session didn't carry a protected id.
|
||
const store = s.protectedStoreId ?? s.privateStoreId;
|
||
return `did:ng:${store}`;
|
||
}
|
||
|
||
/**
|
||
* The graph NURI where the current session WRITES entities of `scope`, and
|
||
* whose repo `useShape` must subscribe to read them back. SDK-shaped: the
|
||
* consumer passes a logical scope and gets an opaque graph NURI — it holds no
|
||
* store-id and builds no NURI. Use the returned value as both the read scope
|
||
* (`useShape(shape, nuri)`) and the `@graph` write target.
|
||
*/
|
||
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
|
||
return scopeStoreNuri(scope);
|
||
}
|
||
|
||
/**
|
||
* The reserved account that OWNS the shared registration-inbox document. Like the
|
||
* discovery index's special account, it lives in the reserved namespace (no user
|
||
* can produce this key) and only HOSTS a document — its `public` scope document is
|
||
* the inbox anchor. Disappears at migration (native per-document inboxes).
|
||
*/
|
||
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
|
||
|
||
/**
|
||
* The inbox anchor NURI for the current session (where emulated inbox deposits
|
||
* physically land). SDK-shaped: the consumer never resolves a store itself.
|
||
*
|
||
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
||
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
||
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
||
* account→document trust root) lives in the private-store graph and is scanned on
|
||
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it
|
||
* without bound (thousands of deposit triples across sessions), turning `loadShim`
|
||
* into a multi-second full-graph scan. A separate inbox document keeps the shim
|
||
* graph small and the deposits isolated. At migration this becomes the host's
|
||
* native per-document inbox and the resolution moves here.
|
||
*/
|
||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
||
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
||
return record.docPublic;
|
||
}
|
||
|
||
// --- per-entity documents + per-scope index -------------------------------
|
||
|
||
/**
|
||
* Create a dedicated document for ONE entity — mirrors the target, where each
|
||
* such entity is its own document/repo (addressable, future inbox). The new
|
||
* document's NURI is appended to the account's scope index document (the
|
||
* store-container). Returns the entity document NURI (use it as `@graph`).
|
||
*/
|
||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||
const record = await ensureAccount(id);
|
||
const indexDoc = indexDocOf(record, scope);
|
||
const entityNuri = await createDoc();
|
||
const s = await session();
|
||
try {
|
||
await sparqlUpdate(
|
||
s.sessionId,
|
||
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
|
||
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
|
||
// anchored default-graph read queries (readScopeIndex below, same as
|
||
// read-model.ts). Not a round-trip necessity on the current broker: the e2e
|
||
// harness (`packages/client/e2e/`) verified an anchored `GRAPH <plainNuri>`
|
||
// write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH
|
||
// is kept as a simplicity/safety convention. entityNuri is a NURI stored as
|
||
// a literal → escapeLiteral.
|
||
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
||
indexDoc,
|
||
"createEntityDoc",
|
||
);
|
||
} catch (error) {
|
||
console.error("[storeRegistry] createEntityDoc index append failed:", error);
|
||
}
|
||
return entityNuri;
|
||
}
|
||
|
||
/** Read the entity-document NURIs contained in ONE scope index document. */
|
||
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
||
const s = await session();
|
||
const out: Nuri[] = [];
|
||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||
// scope-index repo (public OR protected — the protected one carries participations
|
||
// and is the one that most often reads empty) is not yet in `self.repos`, so this
|
||
// anchored read would return 0 NURIs → nothing gets listed → nothing gets
|
||
// subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE
|
||
// before reading it. Idempotent per session; no-op with the unit fake ng. See
|
||
// open-repo.ts.
|
||
await ensureRepoOpen(indexDoc);
|
||
try {
|
||
const res = await sparqlQuery(
|
||
s.sessionId,
|
||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
||
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
||
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
|
||
undefined,
|
||
indexDoc,
|
||
"readScopeIndex",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const v = bindingValue(row, "e");
|
||
if (v) out.push(v);
|
||
}
|
||
} catch (error) {
|
||
console.error("[storeRegistry] readScopeIndex read failed:", error);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Every entity document NURI of `scope`, across all accounts — the read
|
||
* fan-out for per-entity scopes. Reads each account's scope index document and
|
||
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
|
||
*
|
||
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
|
||
* model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced
|
||
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
|
||
* docs) for "my entities", and the discovery index for "all public events".
|
||
* Retained for callers that legitimately need every account (tests).
|
||
*/
|
||
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||
const accounts = await allAccounts();
|
||
const out: Nuri[] = [];
|
||
for (const a of accounts) {
|
||
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the
|
||
* store-container document that LISTS the account's per-entity document NURIs
|
||
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
|
||
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
|
||
* entity-doc set when the index changes (a new entity created appends a NURI
|
||
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
|
||
* user's real per-scope store NURI (the container the store itself provides).
|
||
*/
|
||
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
|
||
const record = await ensureAccount(id);
|
||
return indexDocOf(record, scope);
|
||
}
|
||
|
||
/**
|
||
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
||
* the read-by-need path for one account's own entities. Bounded to a SINGLE
|
||
* account: it resolves only that account's scope index doc (via `ensureAccount`)
|
||
* and reads the contained NURIs — NO cross-account fan-out, so it never touches
|
||
* another account's unsynced docs. This is the helper a consumer application uses
|
||
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
|
||
*/
|
||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
|
||
const record = await ensureAccount(id);
|
||
return readScopeIndex(indexDocOf(record, scope));
|
||
}
|