feat(client): SDK-shaped scope resolvers (resolveScopeGraph/resolveInboxAnchor)
Expose a clean scope-based surface so consumers work by scope (public/protected/ private) and never see a physical store id — the library resolves placement and performs the shared-wallet simulation internally. RegistrySession gains optional protected/public store ids, supplied at the single injection point (configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,35 @@ The `store≠document` two axes materialize here directly: the registry moves al
|
||||
axis B (more documents = more isolation), never axis A (it always writes into the
|
||||
one private store via `docCreate(..., undefined)`).
|
||||
|
||||
### SDK-shaped scope resolvers — the consumer holds NO store-id
|
||||
|
||||
The consumer must never construct a `did:ng:${store_id}` NURI itself: physical
|
||||
placement is the lib's job (the whole point of the SDK boundary). Two resolvers
|
||||
turn a **logical scope** into an **opaque graph NURI** without exposing any
|
||||
store-id:
|
||||
|
||||
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
|
||||
entities of `scope`, and whose repo `useShape` subscribes to read them back.
|
||||
Use the returned value as BOTH the read scope (`useShape(shape, nuri)`) and the
|
||||
`@graph` write target. Placement lives HERE (Axis A): `private` → the private
|
||||
native store; `public` + `protected` → the **protected** native store, because
|
||||
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
|
||||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
||||
resolves to the user's REAL per-scope store — the change is in this function,
|
||||
the consumer is unchanged.
|
||||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land
|
||||
(today the shared wallet's private store — a real repo NURI, required because
|
||||
the broker rejects a `urn:` anchor). At migration it becomes the host's native
|
||||
inbox NURI.
|
||||
|
||||
Both resolve the native store ids from the **injected session**
|
||||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||||
`privateStoreId` anchor). The consumer hands the whole session to the lib at the
|
||||
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring,
|
||||
not placement logic; everything else in the consumer speaks only in scopes. If
|
||||
the session omits `protectedStoreId`, the non-private scopes fall back to the
|
||||
private store rather than emit a broken NURI.
|
||||
|
||||
## `RepoNotFound` and the `orm_start_graph` scope rule
|
||||
|
||||
A hard constraint inherited from the SDK: to read **and** write entities through
|
||||
|
||||
@@ -76,6 +76,11 @@ 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(username: string): string {
|
||||
@@ -239,6 +244,53 @@ export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
|
||||
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 inbox anchor NURI for the current session (where emulated inbox deposits
|
||||
* physically land). SDK-shaped: the consumer never resolves the private store
|
||||
* itself. In the polyfill this is the shared wallet's private store (a real
|
||||
* repo NURI — required because the broker rejects a `urn:` anchor); at
|
||||
* migration it becomes the host's native inbox and this resolution moves here.
|
||||
*/
|
||||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
||||
return scopeStoreNuri("private");
|
||||
}
|
||||
|
||||
// --- per-entity documents + per-scope index -------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
loadShim,
|
||||
resolveWriteGraph,
|
||||
resolveReadGraphs,
|
||||
resolveScopeGraph,
|
||||
resolveInboxAnchor,
|
||||
createEntityDoc,
|
||||
listEntityDocs,
|
||||
resetRegistryCache,
|
||||
@@ -177,6 +179,33 @@ test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans
|
||||
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
||||
});
|
||||
|
||||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||||
// Session with all three store ids: private → private store; public+protected
|
||||
// co-locate on the protected native store (the polyfill's Axis-A placement).
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({
|
||||
sessionId: "sid-2",
|
||||
privateStoreId: "PRIV",
|
||||
protectedStoreId: "PROT",
|
||||
publicStoreId: "PUB",
|
||||
}),
|
||||
});
|
||||
resetRegistryCache();
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
||||
expect(await resolveInboxAnchor()).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
// The default SESSION carries only privateStoreId — non-private scopes fall
|
||||
// back to the private store rather than emitting a broken NURI.
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
||||
const rec = await ensureAccount("Dave");
|
||||
const e1 = await createEntityDoc("dave", "public");
|
||||
|
||||
Reference in New Issue
Block a user