feat(client): generic shared-wallet shim surface (docs/storeRegistry/isolation/accounts)
Port the shared-wallet shim mechanics from the Festipod app into the
generic @ng-eventually/client library — zero app-specific knowledge, the
consumer injects the domain (entity->scope mapping, connections, storage).
New namespaces exposed from src/index.ts:
- docs docCreate/sparqlUpdate/sparqlQuery via the REAL injected `ng`
(getConfig().ng), never the public makeNg proxy — the JS-over-
iframe double proxy breaks doc_create postMessage marshaling
(DataCloneError). Validated hard constraint.
- storeRegistry generic (account,scope)->NURI resolver, createEntityDoc/
listEntityDocs + per-scope index, sharedWalletShim in the
private_store, cache. Consumer wiring injected via
configureStoreRegistry({ getSession, normalizeUser }).
- isolation pure applyIsolation (public=all / protected=owner+connections
/ private=owner); accessors + connection graph injected.
- accounts AccountStore (localStorage-backed faux login, storage injected)
+ normalizeUsername. React wrapper intentionally NOT ported.
polyfill.ts gains configureStoreRegistry/getStoreRegistryDeps + resetConfig.
36/36 bun test, tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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 does NOT know any
|
||||
* application entity kind (event, meeting-point, profile, …). The consumer
|
||||
* maps its entities to a scope and calls `createEntityDoc(scope)` /
|
||||
* `listEntityDocs(scope)` with the resulting native scope. Zero 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 type { Nuri, Scope } from "./types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
|
||||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
||||
export interface AccountRecord {
|
||||
username: string;
|
||||
docPublic: Nuri;
|
||||
docProtected: Nuri;
|
||||
docPrivate: Nuri;
|
||||
}
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const P = {
|
||||
type: `${SHIM}:Account`,
|
||||
username: `${SHIM}:username`,
|
||||
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(username: string): string {
|
||||
return `${SHIM}:account:${normalize(username)}`;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
function normalize(username: string): string {
|
||||
return getStoreRegistryDeps().normalizeUser(username);
|
||||
}
|
||||
|
||||
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 shim, keyed by normalized username.
|
||||
let cache: Map<string, AccountRecord> | null = null;
|
||||
|
||||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
// --- 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 ?username ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
GRAPH <${anchor}> {
|
||||
?acc a <${P.type}> ;
|
||||
<${P.username}> ?username ;
|
||||
<${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);
|
||||
for (const row of readBindings(result)) {
|
||||
const username = bindingValue(row, "username");
|
||||
if (!username) continue;
|
||||
map.set(normalize(username), {
|
||||
username,
|
||||
docPublic: bindingValue(row, "docPublic"),
|
||||
docProtected: bindingValue(row, "docProtected"),
|
||||
docPrivate: bindingValue(row, "docPrivate"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] loadShim failed:", error);
|
||||
}
|
||||
cache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 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(username: string): Promise<AccountRecord> {
|
||||
const map = await loadShim();
|
||||
const key = normalize(username);
|
||||
const existing = map.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { username, docPublic, docProtected, docPrivate };
|
||||
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const subj = accountSubject(username);
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${anchor}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.username}> "${username}" ;
|
||||
<${P.docPublic}> "${docPublic}" ;
|
||||
<${P.docProtected}> "${docProtected}" ;
|
||||
<${P.docPrivate}> "${docPrivate}" .
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, anchor);
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
||||
}
|
||||
map.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 `username` writes GROUPED entities of `scope`
|
||||
* (e.g. participations, profile — no per-entity document / no inbox needed).
|
||||
* For per-entity scopes use {@link createEntityDoc} instead.
|
||||
*/
|
||||
export async function resolveWriteGraph(username: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(username);
|
||||
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));
|
||||
}
|
||||
|
||||
// --- 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(username: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(username);
|
||||
const indexDoc = indexDocOf(record, scope);
|
||||
const entityNuri = await createDoc();
|
||||
const s = await session();
|
||||
try {
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`,
|
||||
indexDoc,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] createEntityDoc index append failed:", error);
|
||||
}
|
||||
return entityNuri;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })`.
|
||||
*/
|
||||
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
const accounts = await allAccounts();
|
||||
const s = await session();
|
||||
const out: Nuri[] = [];
|
||||
for (const a of accounts) {
|
||||
const indexDoc = indexDocOf(a, scope);
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const v = bindingValue(row, "e");
|
||||
if (v) out.push(v);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] listEntityDocs read failed:", error);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user