docs+refactor(client): fidelity pass — id identity, drop connections, no faux-login, accurate NextGraph framing

Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:

- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
  normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
  login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
  (NextGraph has no bilateral-connection primitive: grantee is unpersisted
  scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
  only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
  Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
  unseals and applies each queued sealed message inline (process_inbox);
  inbox_post_link is a proposed/future API. Stop attributing the emulated
  curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
  targeted read of an unheld repo -> RepoNotFound; cap introspection
  (canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
  queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
  section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
  emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
  sentinel namespace; de-domained generic-layer comments; softened tone.

Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+61 -61
View File
@@ -1,11 +1,11 @@
/**
* 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
* 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
* (`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.
*
@@ -14,13 +14,13 @@
* 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.
* ── 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 ─────────────────────────────────────────
* ── 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
@@ -41,7 +41,7 @@ import type { Nuri, Scope } from "./types";
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
username: string;
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
@@ -50,7 +50,7 @@ export interface AccountRecord {
const SHIM = "urn:ng-eventually:shim";
const P = {
type: `${SHIM}:Account`,
username: `${SHIM}:username`,
id: `${SHIM}:id`,
docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
@@ -61,22 +61,22 @@ const P = {
// documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string {
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it
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(username))}`;
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 username. A reserved account is created
// 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
// `normalizeUser` (consumer-injected) can never produce: it strips a leading
// `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 username
// 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:";
@@ -84,24 +84,24 @@ 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 username.
* (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(username: string): boolean {
return username.startsWith(RESERVED_PREFIX);
function isReserved(id: string): boolean {
return id.startsWith(RESERVED_PREFIX);
}
/**
* The shim/cache key for an account. Reserved accounts bypass `normalizeUser`
* 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 username; everyone else normalizes as usual.
* a normalized id; everyone else normalizes as usual.
*/
function accountKey(username: string): string {
return isReserved(username) ? username : normalize(username);
function accountKey(id: string): string {
return isReserved(id) ? id : normalize(id);
}
// --- session / normalization access (injected by the consumer) ------------
@@ -118,8 +118,8 @@ export interface RegistrySession {
publicStoreId?: string;
}
function normalize(username: string): string {
return getStoreRegistryDeps().normalizeUser(username);
function normalize(id: string): string {
return getStoreRegistryDeps().normalizeId(id);
}
async function session(): Promise<RegistrySession> {
@@ -174,10 +174,10 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
const s = await session();
const anchor = await anchorNuri();
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ;
<${P.username}> ?username ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
@@ -187,11 +187,11 @@ export async function loadShim(): Promise<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;
const key = accountKey(username);
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const record: AccountRecord = {
username,
id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
@@ -210,15 +210,15 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
/**
* 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(username)`) directly,
* 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(username: string): Promise<AccountRecord | null> {
const key = accountKey(username);
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
const key = accountKey(id);
const cached = accountCache.get(key);
if (cached) return cached;
@@ -226,12 +226,12 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
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(username);
const subj = accountSubject(id);
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.username}> ?username ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
@@ -243,7 +243,7 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
if (rows.length === 0) return null;
const row = rows[0]!;
const record: AccountRecord = {
username: bindingValue(row, "username") || username,
id: bindingValue(row, "id") || id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
@@ -273,11 +273,11 @@ async function createDoc(): Promise<Nuri> {
* 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 key = accountKey(username);
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.
const existing = await resolveAccount(username);
const existing = await resolveAccount(id);
if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -285,20 +285,20 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
createDoc(),
createDoc(),
]);
const record: AccountRecord = { username, docPublic, docProtected, docPrivate };
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
const s = await session();
const anchor = await anchorNuri();
const subj = accountSubject(username);
const subj = accountSubject(id);
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
// trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL
// 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.username}> "${escapeLiteral(username)}" ;
<${P.id}> "${escapeLiteral(id)}" ;
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
@@ -328,12 +328,12 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
}
/**
* 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.
* 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(username: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username);
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
@@ -414,8 +414,8 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
* 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);
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();
@@ -480,14 +480,14 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
}
/**
* The entity-document NURIs of `scope` belonging to ONE account (`username`) —
* the read-by-need path for "my own entities" (my profile, my participations).
* Bounded to a SINGLE account: it resolves only that account's scope index doc
* (via `ensureAccount`) and reads the contained NURIs — NO cross-account
* enumeration, so it never touches another account's unsynced docs. This is the
* helper FestipodDataContext uses instead of the all-accounts `listEntityDocs`.
* 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(username: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(username);
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope));
}