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,109 @@
|
||||
/**
|
||||
* accounts — the framework-agnostic core of the *simulated* app-level login.
|
||||
*
|
||||
* STOPGAP — part of the shared-wallet shim. The real NextGraph login (redirect
|
||||
* to the broker, opening the single SHARED wallet) is perceived as a technical
|
||||
* access barrier, not as a login. THIS layer is the perceived login: the user
|
||||
* picks a username (no password — declarative), persisted in `localStorage` so
|
||||
* the "session" survives reloads and lands on the same account when the shared
|
||||
* wallet re-opens.
|
||||
*
|
||||
* `login()` / `logout()` here are FAUX: they only read/write the username in
|
||||
* storage. They must NEVER call NextGraph (no session_stop / wallet_close) — the
|
||||
* shared wallet stays open underneath. The real logout lives elsewhere.
|
||||
*
|
||||
* FRAMEWORK-AGNOSTIC on purpose: NO React, no DOM assumption beyond an optional
|
||||
* storage. The React `Context`/`Provider` is a thin wrapper that stays in the
|
||||
* consumer app (it wires `useState` around {@link login}/{@link logout}). The
|
||||
* lib must not force a React dependency. Removed at migration.
|
||||
*/
|
||||
|
||||
/** localStorage key holding the perceived-login username. */
|
||||
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username";
|
||||
|
||||
/**
|
||||
* Minimal storage contract (a subset of the Web `Storage` interface). The
|
||||
* consumer injects one — `window.localStorage` in a browser, a fake in tests —
|
||||
* so this stays framework/DOM-agnostic. When none is available (SSR, no
|
||||
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
|
||||
*/
|
||||
export interface AccountStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a username for matching: case-insensitive, optional leading `@`
|
||||
* stripped, trimmed. Lets "marie", "@marie", "Marie" match interchangeably.
|
||||
* Pure — safe to use as the shim key normalizer too.
|
||||
*/
|
||||
export function normalizeUsername(username: string | null | undefined): string {
|
||||
return (username ?? "").trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted app-level account. A tiny store around an injected
|
||||
* {@link AccountStorage}. Holds no React state; the consumer's Provider mirrors
|
||||
* `get()` into framework state and re-reads after `login`/`logout`.
|
||||
*/
|
||||
export class AccountStore {
|
||||
private readonly storage: AccountStorage | null;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(storage: AccountStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
|
||||
this.storage = storage;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
/** The current app-level username (the perceived "login"). null = not connected. */
|
||||
get(): string | null {
|
||||
if (!this.storage) return null;
|
||||
try {
|
||||
return this.storage.getItem(this.key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faux login — persist the (trimmed) username. Empty/blank is ignored and the
|
||||
* previous value is kept (returns the resulting username, or null). No NG call.
|
||||
*/
|
||||
login(username: string): string | null {
|
||||
const clean = username.trim();
|
||||
if (!clean) return this.get();
|
||||
if (this.storage) {
|
||||
try {
|
||||
this.storage.setItem(this.key, clean);
|
||||
} catch {
|
||||
/* ignore — staging, no security */
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Faux logout — clear the username only. No NG call. */
|
||||
logout(): void {
|
||||
if (this.storage) {
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory using `globalThis.localStorage` when present, else a
|
||||
* null (non-persisting) store — so the same call is safe in browser and SSR.
|
||||
*/
|
||||
export function browserAccountStore(key: string = ACCOUNT_STORAGE_KEY): AccountStore {
|
||||
const ls =
|
||||
typeof globalThis !== "undefined" &&
|
||||
(globalThis as { localStorage?: AccountStorage }).localStorage
|
||||
? (globalThis as { localStorage: AccountStorage }).localStorage
|
||||
: null;
|
||||
return new AccountStore(ls, key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Low-level document + SPARQL primitives.
|
||||
*
|
||||
* These call the **REAL injected `ng`** (`getConfig().ng`) DIRECTLY — never the
|
||||
* public `ng` proxy (`makeNg`). This is a validated hard constraint, NOT a style
|
||||
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
|
||||
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
|
||||
* (`DataCloneError: function ... could not be cloned`). Reaching the real `ng`
|
||||
* held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`.
|
||||
*
|
||||
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
|
||||
* app's storeRegistry usage), so this is a drop-in for those raw calls.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
/**
|
||||
* Create one document → its NURI.
|
||||
*
|
||||
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
|
||||
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
|
||||
* "store")` (store_repo left undefined → private store).
|
||||
*/
|
||||
export async function docCreate(
|
||||
sessionId: string,
|
||||
crdt: string,
|
||||
cls: string,
|
||||
dest: string,
|
||||
store?: unknown,
|
||||
): Promise<Nuri> {
|
||||
const { ng } = getConfig();
|
||||
return ng.doc_create(sessionId, crdt, cls, dest, store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
|
||||
*
|
||||
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
|
||||
* document NURI the update is scoped/base'd to (optional).
|
||||
*/
|
||||
export async function sparqlUpdate(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
anchor?: Nuri,
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
|
||||
*
|
||||
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
|
||||
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
|
||||
*/
|
||||
export async function sparqlQuery(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
base?: string,
|
||||
anchor?: Nuri,
|
||||
): Promise<unknown> {
|
||||
const { ng } = getConfig();
|
||||
return ng.sparql_query(sessionId, query, base, anchor);
|
||||
}
|
||||
@@ -15,6 +15,13 @@ export * from "./types";
|
||||
export { useShape } from "./use-shape";
|
||||
export { init, initNg } from "./lifecycle";
|
||||
export * as inbox from "./inbox";
|
||||
export * as docs from "./docs";
|
||||
export * as storeRegistry from "./store-registry";
|
||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
||||
export * as isolation from "./isolation";
|
||||
export type { Connections, IsolationAccessors } from "./isolation";
|
||||
export * as accounts from "./accounts";
|
||||
export type { AccountStorage } from "./accounts";
|
||||
|
||||
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
|
||||
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* isolation — APPLICATION-LAYER visibility filter, per **account + connections**.
|
||||
*
|
||||
* STOPGAP (shared-wallet shim): with one shared wallet everything is physically
|
||||
* readable. To make staging *behave* like the target infra, the consumer HONORS
|
||||
* a visibility matrix by filtering reads relative to the current principal and
|
||||
* its social connections:
|
||||
*
|
||||
* - public → visible to everyone
|
||||
* - protected → visible to the owner + its direct connections
|
||||
* - private → visible to the owner only
|
||||
*
|
||||
* Pure functions — no NextGraph, no React, ZERO application domain. The consumer
|
||||
* supplies (a) the connection graph (who is connected to whom — the lib does NOT
|
||||
* invent it) and (b) accessors telling, for each item, its owner and scope.
|
||||
*
|
||||
* ── isolation vs ReadCap (read-filter.ts / caps.ts) — they COEXIST ──────────
|
||||
* Two DIFFERENT axes, deliberately kept separate:
|
||||
*
|
||||
* ReadCap ({@link ./caps} + {@link ./read-filter})
|
||||
* - Unit : the DOCUMENT (an item's `@graph` = the repo it lives in).
|
||||
* - Question: does the current principal HOLD this document's read cap?
|
||||
* - Models : NextGraph's native capability delivery (broker-enforced).
|
||||
* - Grants : explicit, per-document (grantRead / makePublic).
|
||||
*
|
||||
* isolation (this file)
|
||||
* - Unit : the ITEM / record (per owner + scope).
|
||||
* - Question: given WHO is connected to WHOM, may this principal see it?
|
||||
* - Models : an application social-visibility policy, above the doc layer.
|
||||
* - Grants : implicit, derived from the connection graph + the item's scope.
|
||||
*
|
||||
* They are NOT redundant and do NOT merge: ReadCap answers "can the wallet even
|
||||
* fetch this document"; isolation answers "should the app show this record to
|
||||
* this account given its relationships". The connection-derived `protected`
|
||||
* visibility of isolation has no equivalent in the per-document cap model. Both
|
||||
* are removable scaffolds; each disappears against a different piece of the real
|
||||
* infra (caps → native ReadCaps; isolation → real per-account social graph +
|
||||
* per-account wallets). See T01.c ## Result for the recorded decision.
|
||||
*/
|
||||
|
||||
import type { PrincipalId, Scope } from "./types";
|
||||
|
||||
/**
|
||||
* The connection graph, consumer-injected. An undirected "is connected to"
|
||||
* relation between principals (e.g. accepted friendships). The lib never builds
|
||||
* this — the consumer maps its own domain (friendships, follows, …) onto it.
|
||||
*/
|
||||
export interface Connections {
|
||||
/** All principals directly connected to `principal` (excluding itself). */
|
||||
neighbors(principal: PrincipalId): Iterable<PrincipalId>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link Connections} from a flat list of undirected links. A link
|
||||
* `{ a, b }` connects `a` and `b` both ways. Convenience for the common case;
|
||||
* the consumer may implement {@link Connections} directly instead.
|
||||
*/
|
||||
export function connectionsFromLinks(
|
||||
links: Iterable<{ a: PrincipalId; b: PrincipalId }>,
|
||||
): Connections {
|
||||
const adj = new Map<PrincipalId, Set<PrincipalId>>();
|
||||
const link = (x: PrincipalId, y: PrincipalId) => {
|
||||
let s = adj.get(x);
|
||||
if (!s) adj.set(x, (s = new Set()));
|
||||
s.add(y);
|
||||
};
|
||||
for (const { a, b } of links) {
|
||||
link(a, b);
|
||||
link(b, a);
|
||||
}
|
||||
return {
|
||||
neighbors: (principal) => adj.get(principal) ?? new Set<PrincipalId>(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The set the current principal may see PROTECTED data for: itself + its direct
|
||||
* connections. Pure over the injected {@link Connections}.
|
||||
*/
|
||||
export function visibleSet(current: PrincipalId, connections: Connections): Set<PrincipalId> {
|
||||
const set = new Set<PrincipalId>([current]);
|
||||
for (const n of connections.neighbors(current)) set.add(n);
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* May `current` see an item owned by `owner` at visibility `scope`, given the
|
||||
* `visible` set (self + connections, precomputed via {@link visibleSet})?
|
||||
*
|
||||
* - public → always
|
||||
* - protected → owner ∈ visible
|
||||
* - private → owner === current
|
||||
*/
|
||||
export function isVisible(
|
||||
scope: Scope,
|
||||
owner: PrincipalId,
|
||||
current: PrincipalId,
|
||||
visible: Set<PrincipalId>,
|
||||
): boolean {
|
||||
switch (scope) {
|
||||
case "public":
|
||||
return true;
|
||||
case "protected":
|
||||
return visible.has(owner);
|
||||
case "private":
|
||||
return owner === current;
|
||||
}
|
||||
}
|
||||
|
||||
/** How to read an item's owner + scope. Consumer-supplied — keeps this generic. */
|
||||
export interface IsolationAccessors<T> {
|
||||
/** The principal that owns / authored the item. */
|
||||
ownerOf: (item: T) => PrincipalId;
|
||||
/** The item's visibility scope. */
|
||||
scopeOf: (item: T) => Scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: keep only the items `current` is allowed to see, per the visibility
|
||||
* matrix and the injected connection graph.
|
||||
*
|
||||
* Empty `current` (no identity yet, e.g. during hydration) → pass everything
|
||||
* through, so the app doesn't blank out mid-load (matches the app's prior
|
||||
* `if (!currentUserId) return data` guard).
|
||||
*/
|
||||
export function applyIsolation<T>(
|
||||
items: Iterable<T>,
|
||||
current: PrincipalId,
|
||||
connections: Connections,
|
||||
accessors: IsolationAccessors<T>,
|
||||
): T[] {
|
||||
const all = [...items];
|
||||
if (!current) return all;
|
||||
const visible = visibleSet(current, connections);
|
||||
return all.filter((item) =>
|
||||
isVisible(accessors.scopeOf(item), accessors.ownerOf(item), current, visible),
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,22 @@
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
||||
import type { RegistrySession } from "./store-registry";
|
||||
import { CapRegistry } from "./caps";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
* registry itself is generic (it knows only native scopes); the consumer wires
|
||||
* up how to reach the shared-wallet session and how to normalize a username
|
||||
* used as the shim key. Removed at migration along with the whole shim.
|
||||
*/
|
||||
export interface StoreRegistryDeps {
|
||||
/** Resolve the current shared-wallet session (id + private-store anchor). */
|
||||
getSession: () => Promise<RegistrySession>;
|
||||
/** Normalize a username for shim keying. Default: trim (identity-ish). */
|
||||
normalizeUser?: (username: string) => string;
|
||||
}
|
||||
|
||||
export interface EventuallyConfig {
|
||||
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
|
||||
ng: NgLike;
|
||||
@@ -28,6 +42,7 @@ export interface EventuallyConfig {
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
let registryDeps: Required<StoreRegistryDeps> | null = null;
|
||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
||||
* while it has no read policy the read filter passes through (no regression). */
|
||||
let caps = new CapRegistry();
|
||||
@@ -43,6 +58,39 @@ export function getConfig(): EventuallyConfig {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Reset the injected config back to un-configured (mainly for tests, so a
|
||||
* suite that calls configure() can restore the not-configured guard state). */
|
||||
export function resetConfig(): void {
|
||||
cfg = null;
|
||||
currentUser = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the storeRegistry's consumer-injected dependencies (session + username
|
||||
* normalization). Must be called before any storeRegistry.* use. Separate from
|
||||
* {@link configure} because it's storeRegistry-specific and, like the shim,
|
||||
* disappears at migration.
|
||||
*/
|
||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
registryDeps = {
|
||||
getSession: deps.getSession,
|
||||
normalizeUser: deps.normalizeUser ?? ((u: string) => u.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
||||
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> {
|
||||
if (!registryDeps) {
|
||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
||||
}
|
||||
return registryDeps;
|
||||
}
|
||||
|
||||
/** Reset storeRegistry deps (mainly for tests). */
|
||||
export function resetStoreRegistry(): void {
|
||||
registryDeps = null;
|
||||
}
|
||||
|
||||
/** Set the current app-level user (the polyfill's notion of "who am I"). */
|
||||
export function setCurrentUser(id: PrincipalId | null): void {
|
||||
currentUser = id;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
AccountStore,
|
||||
browserAccountStore,
|
||||
normalizeUsername,
|
||||
ACCOUNT_STORAGE_KEY,
|
||||
type AccountStorage,
|
||||
} from "../src/accounts";
|
||||
|
||||
// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic.
|
||||
function fakeStorage(): AccountStorage & { map: Map<string, string> } {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
map,
|
||||
getItem: (k) => (map.has(k) ? (map.get(k) as string) : null),
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeUsername: strips leading @, trims, lowercases", () => {
|
||||
expect(normalizeUsername("marie")).toBe("marie");
|
||||
expect(normalizeUsername("@Marie")).toBe("marie");
|
||||
expect(normalizeUsername(" @@MARIE ")).toBe("marie");
|
||||
expect(normalizeUsername(null)).toBe("");
|
||||
expect(normalizeUsername(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("AccountStore: login persists a trimmed username, get reads it back", () => {
|
||||
const s = fakeStorage();
|
||||
const store = new AccountStore(s);
|
||||
expect(store.get()).toBeNull();
|
||||
|
||||
expect(store.login(" Marie ")).toBe("Marie"); // trimmed, NOT normalized (display form)
|
||||
expect(store.get()).toBe("Marie");
|
||||
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("Marie");
|
||||
});
|
||||
|
||||
test("AccountStore: blank login is ignored, keeps the previous value", () => {
|
||||
const store = new AccountStore(fakeStorage());
|
||||
store.login("bob");
|
||||
expect(store.login(" ")).toBe("bob");
|
||||
expect(store.get()).toBe("bob");
|
||||
});
|
||||
|
||||
test("AccountStore: logout clears the username (no throw)", () => {
|
||||
const store = new AccountStore(fakeStorage());
|
||||
store.login("bob");
|
||||
store.logout();
|
||||
expect(store.get()).toBeNull();
|
||||
});
|
||||
|
||||
test("AccountStore: null storage degrades to non-persisting (SSR-safe)", () => {
|
||||
const store = new AccountStore(null);
|
||||
expect(store.get()).toBeNull();
|
||||
expect(store.login("bob")).toBe("bob"); // returns the value, just doesn't persist
|
||||
expect(store.get()).toBeNull();
|
||||
store.logout(); // no throw
|
||||
});
|
||||
|
||||
test("AccountStore: swallows storage errors on read and write", () => {
|
||||
const throwing: AccountStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
const store = new AccountStore(throwing);
|
||||
expect(store.get()).toBeNull(); // read error swallowed → null
|
||||
expect(() => store.login("bob")).not.toThrow();
|
||||
expect(() => store.logout()).not.toThrow();
|
||||
});
|
||||
|
||||
test("browserAccountStore returns a working store (uses global localStorage if present)", () => {
|
||||
const store = browserAccountStore("ng-eventually.test.account");
|
||||
expect(store).toBeInstanceOf(AccountStore);
|
||||
// Behaves regardless of environment: login returns the value.
|
||||
expect(store.login("zoe")).toBe("zoe");
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
||||
import * as ngProxy from "../src/ng-proxy";
|
||||
|
||||
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
|
||||
// call, because configure() sets a module-level singleton with no public reset.
|
||||
|
||||
test("throws a clear error when configure() was not called", async () => {
|
||||
await expect(docCreate("sid", "Graph", "data:graph", "store")).rejects.toThrow(
|
||||
/configure\(\) must be called before use/,
|
||||
);
|
||||
await expect(sparqlUpdate("sid", "INSERT DATA {}")).rejects.toThrow(
|
||||
/configure\(\) must be called before use/,
|
||||
);
|
||||
await expect(sparqlQuery("sid", "SELECT * {}")).rejects.toThrow(
|
||||
/configure\(\) must be called before use/,
|
||||
);
|
||||
});
|
||||
|
||||
// From here on, a fake real `ng` is injected via configure().
|
||||
import { configure } from "../src/polyfill";
|
||||
|
||||
function fakeNg() {
|
||||
return {
|
||||
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"),
|
||||
sparql_update: mock(async (..._a: unknown[]) => undefined),
|
||||
sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })),
|
||||
// A sentinel: makeNg(), if ever used, would `.bind` and call THIS through
|
||||
// the JS Proxy. We assert the primitives call the raw fns above directly.
|
||||
};
|
||||
}
|
||||
|
||||
function inject() {
|
||||
const ng = fakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
return ng;
|
||||
}
|
||||
|
||||
test("docCreate calls the real injected ng.doc_create with the exact args", async () => {
|
||||
const ng = inject();
|
||||
const nuri = await docCreate("sid-1", "Graph", "data:graph", "store", undefined);
|
||||
expect(nuri).toBe("did:ng:o:new-doc");
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(1);
|
||||
expect(ng.doc_create.mock.calls[0]).toEqual(["sid-1", "Graph", "data:graph", "store", undefined]);
|
||||
});
|
||||
|
||||
test("sparqlUpdate forwards (sessionId, query, anchor) to the real ng.sparql_update", async () => {
|
||||
const ng = inject();
|
||||
await sparqlUpdate("sid-2", "INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }", "did:ng:o:a");
|
||||
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
|
||||
expect(ng.sparql_update.mock.calls[0]).toEqual([
|
||||
"sid-2",
|
||||
"INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }",
|
||||
"did:ng:o:a",
|
||||
]);
|
||||
});
|
||||
|
||||
test("sparqlUpdate passes anchor=undefined when omitted", async () => {
|
||||
const ng = inject();
|
||||
await sparqlUpdate("sid-3", "INSERT DATA {}");
|
||||
expect(ng.sparql_update.mock.calls[0]).toEqual(["sid-3", "INSERT DATA {}", undefined]);
|
||||
});
|
||||
|
||||
test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw result", async () => {
|
||||
const ng = inject();
|
||||
const res = await sparqlQuery("sid-4", "SELECT ?e { GRAPH <g> { ?s ?p ?e } }", undefined, "did:ng:o:g");
|
||||
expect(res).toEqual({ results: { bindings: [] } });
|
||||
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
|
||||
expect(ng.sparql_query.mock.calls[0]).toEqual([
|
||||
"sid-4",
|
||||
"SELECT ?e { GRAPH <g> { ?s ?p ?e } }",
|
||||
undefined,
|
||||
"did:ng:o:g",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the primitives do NOT route through the public ng proxy (makeNg)", async () => {
|
||||
// makeNg builds a JS Proxy over the injected ng. If a primitive went through
|
||||
// it, calls would land on the proxy's `get` trap, not on our raw mock fns.
|
||||
// Spy on makeNg: it must never be invoked by the docs primitives.
|
||||
const spy = mock(ngProxy.makeNg);
|
||||
const ng = inject();
|
||||
await docCreate("sid", "Graph", "data:graph", "store");
|
||||
await sparqlUpdate("sid", "INSERT DATA {}");
|
||||
await sparqlQuery("sid", "SELECT * {}");
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
// And the raw injected fns were reached directly:
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(1);
|
||||
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
|
||||
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
applyIsolation,
|
||||
connectionsFromLinks,
|
||||
visibleSet,
|
||||
isVisible,
|
||||
type IsolationAccessors,
|
||||
} from "../src/isolation";
|
||||
import type { Scope } from "../src/types";
|
||||
|
||||
// Generic item: owner + scope. ZERO domain — the accessors read these fields.
|
||||
interface Item {
|
||||
id: string;
|
||||
owner: string;
|
||||
scope: Scope;
|
||||
}
|
||||
const acc: IsolationAccessors<Item> = { ownerOf: (i) => i.owner, scopeOf: (i) => i.scope };
|
||||
|
||||
// alice —— bob (carol is unconnected)
|
||||
const links = [{ a: "alice", b: "bob" }];
|
||||
const conns = connectionsFromLinks(links);
|
||||
|
||||
test("connectionsFromLinks / visibleSet: self + direct connections, both directions", () => {
|
||||
expect([...conns.neighbors("alice")].sort()).toEqual(["bob"]);
|
||||
expect([...conns.neighbors("bob")].sort()).toEqual(["alice"]);
|
||||
expect([...conns.neighbors("carol")]).toEqual([]);
|
||||
|
||||
expect([...visibleSet("alice", conns)].sort()).toEqual(["alice", "bob"]);
|
||||
expect([...visibleSet("carol", conns)].sort()).toEqual(["carol"]);
|
||||
});
|
||||
|
||||
test("isVisible matrix: public=all, protected=owner+connections, private=owner", () => {
|
||||
const vis = visibleSet("alice", conns); // { alice, bob }
|
||||
// public → everyone, regardless of owner
|
||||
expect(isVisible("public", "carol", "alice", vis)).toBe(true);
|
||||
// protected → owner must be in the visible set
|
||||
expect(isVisible("protected", "bob", "alice", vis)).toBe(true);
|
||||
expect(isVisible("protected", "carol", "alice", vis)).toBe(false);
|
||||
// private → owner must be the current principal
|
||||
expect(isVisible("private", "alice", "alice", vis)).toBe(true);
|
||||
expect(isVisible("private", "bob", "alice", vis)).toBe(false);
|
||||
});
|
||||
|
||||
test("applyIsolation narrows a mixed collection for the current principal", () => {
|
||||
const items: Item[] = [
|
||||
{ id: "e", owner: "carol", scope: "public" }, // public → kept for all
|
||||
{ id: "p1", owner: "alice", scope: "protected" }, // own protected → kept
|
||||
{ id: "p2", owner: "bob", scope: "protected" }, // connection's protected → kept
|
||||
{ id: "p3", owner: "carol", scope: "protected" }, // stranger's protected → dropped
|
||||
{ id: "s1", owner: "alice", scope: "private" }, // own private → kept
|
||||
{ id: "s2", owner: "bob", scope: "private" }, // connection's private → dropped
|
||||
];
|
||||
|
||||
const forAlice = applyIsolation(items, "alice", conns, acc).map((i) => i.id);
|
||||
expect(forAlice).toEqual(["e", "p1", "p2", "s1"]);
|
||||
|
||||
const forCarol = applyIsolation(items, "carol", conns, acc).map((i) => i.id);
|
||||
// carol sees: public + her own protected + her own private
|
||||
expect(forCarol).toEqual(["e", "p3"]);
|
||||
});
|
||||
|
||||
test("applyIsolation with empty principal passes everything through (hydration guard)", () => {
|
||||
const items: Item[] = [
|
||||
{ id: "s1", owner: "alice", scope: "private" },
|
||||
{ id: "p1", owner: "bob", scope: "protected" },
|
||||
];
|
||||
expect(applyIsolation(items, "", conns, acc).map((i) => i.id)).toEqual(["s1", "p1"]);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user