654cb90d99
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>
203 lines
7.6 KiB
TypeScript
203 lines
7.6 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
import {
|
|
ensureAccount,
|
|
allAccounts,
|
|
loadShim,
|
|
resolveWriteGraph,
|
|
resolveReadGraphs,
|
|
createEntityDoc,
|
|
listEntityDocs,
|
|
resetRegistryCache,
|
|
} from "../src/store-registry";
|
|
import type { RegistrySession } from "../src/store-registry";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
} from "../src/polyfill";
|
|
|
|
// This suite injects a fake `ng` via configure(); bun runs test files in a
|
|
// shared process with a single module singleton, and may run this file BEFORE
|
|
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
|
|
// configured state when we're done so that guard still sees a null config.
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
});
|
|
|
|
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
|
|
// sets module-level singletons and this suite never fully un-injects the real
|
|
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
|
|
|
|
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
await expect(ensureAccount("alice")).rejects.toThrow(
|
|
/configureStoreRegistry\(\) must be called before use/,
|
|
);
|
|
});
|
|
|
|
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
|
|
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
|
|
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
|
|
|
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`.
|
|
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]!;
|
|
// Subject is the first <...> token in the body.
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
// Predicate/object pairs: `<p> "o"` or `<p> <o>` (a == rdf:type ignored form
|
|
// handled as literal-free; here the registry only uses <p> "literal" and
|
|
// <p> <iri> and `a <type>`).
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"([^"]*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
// Skip the subject token so we don't treat it as a predicate.
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
|
|
const o = 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;
|
|
if (query.includes("<urn:ng-eventually:shim:username>")) {
|
|
// Account SELECT, scoped to the anchor graph.
|
|
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 === "urn:ng-eventually:shim:username") rec.username = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
if (q.p === "urn:ng-eventually: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 } };
|
|
}
|
|
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
|
.map((q) => ({ e: { value: q.o } }));
|
|
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();
|
|
return ng;
|
|
}
|
|
|
|
let fake: ReturnType<typeof makeFakeNg>;
|
|
beforeEach(() => {
|
|
fake = inject();
|
|
});
|
|
|
|
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
|
|
const rec = await ensureAccount("Alice");
|
|
expect(rec.username).toBe("Alice");
|
|
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
expect(rec.docProtected).not.toBe(rec.docPublic);
|
|
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
|
// Persisted into the shim anchor graph (did:ng:PRIV).
|
|
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV");
|
|
});
|
|
|
|
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
|
const a = await ensureAccount("Alice");
|
|
const b = await ensureAccount("@alice");
|
|
expect(b).toEqual(a);
|
|
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6
|
|
});
|
|
|
|
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
|
await ensureAccount("Bob");
|
|
resetRegistryCache(); // force a re-read from the fake store
|
|
const map = await loadShim();
|
|
const rec = map.get("bob");
|
|
expect(rec?.username).toBe("Bob");
|
|
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
|
});
|
|
|
|
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
|
const rec = await ensureAccount("Carol");
|
|
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
|
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
|
});
|
|
|
|
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
|
const rec = await ensureAccount("Dave");
|
|
const e1 = await createEntityDoc("dave", "public");
|
|
const e2 = await createEntityDoc("dave", "public");
|
|
const other = await createEntityDoc("dave", "protected");
|
|
// Public listing unions dave's public entities only.
|
|
const pub = await listEntityDocs("public");
|
|
expect(pub.sort()).toEqual([e1, e2].sort());
|
|
const prot = await listEntityDocs("protected");
|
|
expect(prot).toEqual([other]);
|
|
// The index append targets the account's public index doc.
|
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
});
|
|
|
|
test("listEntityDocs fans out across multiple accounts", async () => {
|
|
await ensureAccount("Eve");
|
|
await ensureAccount("Frank");
|
|
const e = await createEntityDoc("eve", "public");
|
|
const f = await createEntityDoc("frank", "public");
|
|
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
|
});
|
|
|
|
test("allAccounts reflects every ensured account", async () => {
|
|
await ensureAccount("Gina");
|
|
await ensureAccount("Hank");
|
|
const names = (await allAccounts()).map((a) => a.username).sort();
|
|
expect(names).toEqual(["Gina", "Hank"]);
|
|
});
|
|
|
|
test("normalizeUser defaults to trim when not provided", async () => {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION });
|
|
resetRegistryCache();
|
|
const a = await ensureAccount(" Ivy ");
|
|
const b = await ensureAccount("Ivy"); // trimmed key matches
|
|
expect(b).toEqual(a);
|
|
expect(ng.doc_create).toHaveBeenCalledTimes(3);
|
|
});
|